Saltearse al contenido

Smithy TypeScript API

Smithy es un lenguaje de definición de interfaces independiente del protocolo para crear APIs de manera modelada.

El generador de API TypeScript para Smithy crea una nueva API usando Smithy para la definición del servicio y el SDK de servidor TypeScript para Smithy para la implementación. El generador provee infraestructura como código con CDK o Terraform para desplegar tu servicio en AWS Lambda, expuesto a través de una API REST de AWS API Gateway. Ofrece desarrollo de APIs con seguridad de tipos mediante generación automática de código a partir de modelos Smithy. El manejador generado utiliza AWS Lambda Powertools para TypeScript para observabilidad, incluyendo registro, trazado con AWS X-Ray y métricas de CloudWatch.

Puedes generar una nueva API TypeScript con Smithy de dos formas:

  1. Instale el Nx Console VSCode Plugin si aún no lo ha hecho
  2. Abra la consola Nx en VSCode
  3. Haga clic en Generate (UI) en la sección "Common Nx Commands"
  4. Busque @aws/nx-plugin - ts#smithy-api
  5. Complete los parámetros requeridos
    • Haga clic en Generate
    Parámetro Tipo Predeterminado Descripción
    name Requerido string - The name of the API (required). Used to generate class names and file paths.
    namespace string - The namespace for the Smithy API. Defaults to your monorepo scope
    computeType string ServerlessApiGatewayRestApi The type of compute to use to deploy this API.
    auth string IAM The method used to authenticate with your API. Choose between IAM (default), Cognito or None.
    directory string packages The directory to store the application in.
    iacProvider string Inherit The preferred IaC provider. By default this is inherited from your initial selection.

    El generador crea dos proyectos relacionados en el directorio <directory>/<api-name>:

    • Directorymodel/ Proyecto del modelo Smithy
      • project.json Configuración del proyecto y objetivos de build
      • smithy-build.json Configuración de build para Smithy
      • build.Dockerfile Configuración de Docker para construir artefactos Smithy
      • Directorysrc/
        • main.smithy Definición principal del servicio
        • Directoryoperations/
          • echo.smithy Definición de operación de ejemplo
    • Directorybackend/ Implementación del backend en TypeScript
      • project.json Configuración del proyecto y objetivos de build
      • rolldown.config.ts Configuración de bundling
      • Directorysrc/
        • handler.ts Manejador de AWS Lambda
        • local-server.ts Servidor local para desarrollo
        • service.ts Implementación del servicio
        • context.ts Definición del contexto del servicio
        • Directoryoperations/
          • echo.ts Implementación de operación de ejemplo
        • Directorygenerated/ SDK TypeScript generado (creado durante el build)

    Dado que este generador crea infraestructura como código según el iacProvider seleccionado, generará un proyecto en packages/common que incluye los constructos CDK o módulos Terraform relevantes.

    El proyecto común de infraestructura como código tiene la siguiente estructura:

    • Directorypackages/common/constructs
      • Directorysrc
        • Directoryapp/ Constructos para infraestructura específica de un proyecto/generador
          • Directoryapis/
            • <project-name>.ts Constructo CDK para desplegar tu API
        • Directorycore/ Constructos genéricos reutilizables por los de app
          • Directoryapi/
            • rest-api.ts Constructo CDK para desplegar una API REST
            • utils.ts Utilidades para los constructos de API
        • index.ts Punto de entrada que exporta los constructos de app
      • project.json Objetivos de build y configuración del proyecto

    Las operaciones se definen en archivos Smithy dentro del proyecto del modelo. La definición principal del servicio está en main.smithy:

    $version: "2.0"
    namespace your.namespace
    use aws.protocols#restJson1
    use smithy.framework#ValidationException
    @title("YourService")
    @restJson1
    service YourService {
    version: "1.0.0"
    operations: [
    Echo,
    // Añade tus operaciones aquí
    ]
    errors: [
    ValidationException
    ]
    }

    Las operaciones individuales se definen en archivos separados en el directorio operations/:

    $version: "2.0"
    namespace your.namespace
    @http(method: "POST", uri: "/echo")
    operation Echo {
    input: EchoInput
    output: EchoOutput
    }
    structure EchoInput {
    @required
    message: String
    foo: Integer
    bar: String
    }
    structure EchoOutput {
    @required
    message: String
    }

    Las implementaciones de operaciones se encuentran en el directorio src/operations/ del proyecto backend. Cada operación se implementa usando los tipos generados del SDK de servidor TypeScript (generados en tiempo de build a partir de tu modelo Smithy).

    import { ServiceContext } from '../context.js';
    import { Echo as EchoOperation } from '../generated/ssdk/index.js';
    export const Echo: EchoOperation<ServiceContext> = async (input) => {
    // Tu lógica de negocio aquí
    return {
    message: `Echo: ${input.message}` // seguridad de tipos basada en tu modelo Smithy
    };
    };

    Las operaciones deben registrarse en la definición del servicio en src/service.ts:

    import { ServiceContext } from './context.js';
    import { YourServiceService } from './generated/ssdk/index.js';
    import { Echo } from './operations/echo.js';
    // Importa otras operaciones aquí
    // Registra las operaciones en el servicio aquí
    export const Service: YourServiceService<ServiceContext> = {
    Echo,
    // Añade otras operaciones aquí
    };

    Puedes definir un contexto compartido para tus operaciones en context.ts:

    export interface ServiceContext {
    // Tracer, logger y metrics de Powertools se proveen por defecto
    tracer: Tracer;
    logger: Logger;
    metrics: Metrics;
    // Añade dependencias compartidas, conexiones a bases de datos, etc.
    dbClient: any;
    userIdentity: string;
    }

    Este contexto se pasa a todas las implementaciones de operaciones y puede usarse para compartir recursos como conexiones a bases de datos, configuración o utilidades de registro.

    El generador configura registro estructurado usando AWS Lambda Powertools con inyección automática de contexto mediante middleware Middy.

    handler.ts
    export const handler = middy<APIGatewayProxyEvent, APIGatewayProxyResult>()
    .use(captureLambdaHandler(tracer))
    .use(injectLambdaContext(logger))
    .use(logMetrics(metrics))
    .handler(lambdaHandler);

    Puedes acceder al logger desde tus implementaciones de operaciones vía el contexto:

    operations/echo.ts
    import { ServiceContext } from '../context.js';
    import { Echo as EchoOperation } from '../generated/ssdk/index.js';
    export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
    ctx.logger.info('Tu mensaje de log');
    // ...
    };

    El trazado con AWS X-Ray se configura automáticamente mediante el middleware captureLambdaHandler.

    handler.ts
    export const handler = middy<APIGatewayProxyEvent, APIGatewayProxyResult>()
    .use(captureLambdaHandler(tracer))
    .use(injectLambdaContext(logger))
    .use(logMetrics(metrics))
    .handler(lambdaHandler);

    Puedes añadir subsegmentos personalizados a tus trazas en tus operaciones:

    operations/echo.ts
    import { ServiceContext } from '../context.js';
    import { Echo as EchoOperation } from '../generated/ssdk/index.js';
    export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
    // Crea un nuevo subsegmento
    const subsegment = ctx.tracer.getSegment()?.addNewSubsegment('operacion-personalizada');
    try {
    // Tu lógica aquí
    } catch (error) {
    subsegment?.addError(error as Error);
    throw error;
    } finally {
    subsegment?.close();
    }
    };

    Las métricas de CloudWatch se recopilan automáticamente para cada solicitud mediante el middleware logMetrics.

    handler.ts
    export const handler = middy<APIGatewayProxyEvent, APIGatewayProxyResult>()
    .use(captureLambdaHandler(tracer))
    .use(injectLambdaContext(logger))
    .use(logMetrics(metrics))
    .handler(lambdaHandler);

    Puedes añadir métricas personalizadas en tus operaciones:

    operations/echo.ts
    import { MetricUnit } from '@aws-lambda-powertools/metrics';
    import { ServiceContext } from '../context.js';
    import { Echo as EchoOperation } from '../generated/ssdk/index.js';
    export const Echo: EchoOperation<ServiceContext> = async (input, ctx) => {
    ctx.metrics.addMetric("MetricaPersonalizada", MetricUnit.Count, 1);
    // ...
    };

    Smithy provee manejo de errores integrado. Puedes definir errores personalizados en tu modelo Smithy:

    @error("client")
    @httpError(400)
    structure InvalidRequestError {
    @required
    message: String
    }

    Y registrarlos en tu operación/servicio:

    operation MyOperation {
    ...
    errors: [InvalidRequestError]
    }

    Luego lanzarlos en tu implementación TypeScript:

    import { InvalidRequestError } from '../generated/ssdk/index.js';
    export const MyOperation: MyOperationHandler<ServiceContext> = async (input) => {
    if (!input.requiredField) {
    throw new InvalidRequestError({
    message: "Falta el campo requerido"
    });
    }
    return { /* respuesta exitosa */ };
    };

    El proyecto del modelo Smithy usa Docker para construir los artefactos Smithy y generar el SDK de servidor TypeScript:

    Terminal window
    pnpm nx run <model-project>:build

    Este proceso:

    1. Compila el modelo Smithy y lo valida
    2. Genera la especificación OpenAPI a partir del modelo Smithy
    3. Crea el SDK de servidor TypeScript con interfaces de operación con seguridad de tipos
    4. Genera artefactos de build en dist/<model-project>/build/

    El proyecto backend copia automáticamente el SDK generado durante la compilación:

    Terminal window
    pnpm nx run <backend-project>:copy-ssdk

    El generador configura automáticamente un objetivo bundle que utiliza Rolldown para crear un paquete de despliegue:

    Terminal window
    pnpm nx run <project-name>:bundle

    La configuración de Rolldown se encuentra en rolldown.config.ts, con una entrada por cada bundle a generar. Rolldown gestiona la creación de múltiples bundles en paralelo si están definidos.

    El generador configura un servidor local con recarga en caliente:

    Terminal window
    pnpm nx run <backend-project>:serve

    El generador crea infraestructura como código con CDK o Terraform según el iacProvider seleccionado.

    El constructo CDK para desplegar tu API está en la carpeta common/constructs:

    import { MyApi } from ':my-scope/common-constructs';
    export class ExampleStack extends Stack {
    constructor(scope: Construct, id: string) {
    // Añade la API a tu stack
    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    }
    }

    Esto configura:

    1. Una función AWS Lambda para el servicio Smithy
    2. API Gateway REST API como disparador de la función
    3. Roles y permisos IAM
    4. Grupo de logs de CloudWatch
    5. Configuración de trazado X-Ray

    Los constructos CDK de la API REST/HTTP están configurados para proporcionar una interfaz type-safe que define integraciones para cada una de tus operaciones.

    Los constructos CDK proporcionan soporte completo de integración con seguridad de tipos como se describe a continuación.

    Puedes usar el método estático defaultIntegrations para utilizar el patrón por defecto, que define una función AWS Lambda individual para cada operación:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });

    Puedes acceder a las funciones AWS Lambda subyacentes a través de la propiedad integrations del constructo de la API, de manera type-safe. Por ejemplo, si tu API define una operación llamada sayHello y necesitas agregar permisos a esta función, puedes hacerlo así:

    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    // sayHello está tipado según las operaciones definidas en tu API
    api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [...],
    resources: [...],
    }));

    Si deseas personalizar las opciones usadas al crear la función Lambda para cada integración por defecto, puedes usar el método withDefaultOptions. Por ejemplo, si quieres que todas tus funciones Lambda residan en una VPC:

    const vpc = new Vpc(this, 'Vpc', ...);
    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withDefaultOptions({
    vpc,
    })
    .build(),
    });

    También puedes sobrescribir integraciones para operaciones específicas usando el método withOverrides. Cada override debe especificar una propiedad integration que está tipada al constructo de integración CDK apropiado para la API HTTP o REST. El método withOverrides también es type-safe. Por ejemplo, si quieres sobrescribir una API getDocumentation para apuntar a documentación alojada en un sitio externo:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getDocumentation: {
    integration: new HttpIntegration('https://example.com/documentation'),
    },
    })
    .build(),
    });

    Notarás que la integración sobrescrita ya no tiene una propiedad handler cuando se accede a través de api.integrations.getDocumentation.

    Puedes agregar propiedades adicionales a una integración que también estarán tipadas, permitiendo abstraer otros tipos de integración manteniendo el type-safe. Por ejemplo, si creaste una integración S3 para una API REST y luego quieres referenciar el bucket para una operación particular:

    const storageBucket = new Bucket(this, 'Bucket', { ... });
    const apiGatewayRole = new Role(this, 'ApiGatewayS3Role', {
    assumedBy: new ServicePrincipal('apigateway.amazonaws.com'),
    });
    storageBucket.grantRead(apiGatewayRole);
    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getFile: {
    bucket: storageBucket,
    integration: new AwsIntegration({
    service: 's3',
    integrationHttpMethod: 'GET',
    path: `${storageBucket.bucketName}/{fileName}`,
    options: {
    credentialsRole: apiGatewayRole,
    requestParameters: {
    'integration.request.path.fileName': 'method.request.querystring.fileName',
    },
    integrationResponses: [{ statusCode: '200' }],
    },
    }),
    options: {
    requestParameters: {
    'method.request.querystring.fileName': true,
    },
    methodResponses: [{
    statusCode: '200',
    }],
    }
    },
    })
    .build(),
    });
    // Más tarde, quizás en otro archivo, puedes acceder a la propiedad bucket que definimos
    // de manera type-safe
    api.integrations.getFile.bucket.grantRead(...);

    También puedes proveer options en tu integración para sobrescribir opciones de método específicas como autorizadores. Por ejemplo, si deseas usar autenticación con Cognito para tu operación getDocumentation:

    new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this)
    .withOverrides({
    getDocumentation: {
    integration: new HttpIntegration('https://example.com/documentation'),
    options: {
    authorizer: new CognitoUserPoolsAuthorizer(...) // para REST, o HttpUserPoolAuthorizer para HTTP API
    }
    },
    })
    .build(),
    });

    Si prefieres, puedes no usar las integraciones por defecto y proveer directamente una para cada operación. Esto es útil si, por ejemplo, cada operación necesita usar un tipo diferente de integración o quieres recibir un error de tipo al agregar nuevas operaciones:

    new MyApi(this, 'MyApi', {
    integrations: {
    sayHello: {
    integration: new LambdaIntegration(...),
    },
    getDocumentation: {
    integration: new HttpIntegration(...),
    },
    },
    });

    Si prefieres desplegar una única función Lambda para manejar todas las solicitudes de la API, puedes modificar libremente el método defaultIntegrations de tu API para crear una sola función en lugar de una por integración:

    packages/common/constructs/src/app/apis/my-api.ts
    export class MyApi<...> extends ... {
    public static defaultIntegrations = (scope: Construct) => {
    const router = new Function(scope, 'RouterHandler', { ... });
    return IntegrationBuilder.rest({
    ...
    defaultIntegrationOptions: {},
    buildDefaultIntegration: (op) => {
    return {
    // Referenciar el mismo router lambda handler en cada integración
    integration: new LambdaIntegration(router),
    };
    },
    });
    };
    }

    Puedes modificar el código de otras formas si prefieres, por ejemplo definiendo la función router como parámetro de defaultIntegrations en lugar de construirla dentro del método.

    Dado que las operaciones se definen en Smithy, usamos generación de código para proveer metadatos al constructo CDK para integraciones con seguridad de tipos.

    Se añade un objetivo generate:<ApiName>-metadata al project.json de los constructos comunes para facilitar esta generación, el cual emite un archivo como packages/common/constructs/src/generated/my-api/metadata.gen.ts. Como esto se genera en tiempo de build, se ignora en control de versiones.

    Si seleccionaste autenticación IAM, puedes usar el método grantInvokeAccess para otorgar acceso a tu API:

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    Para invocar tu API desde un sitio web React, puedes usar el generador api-connection, que provee generación de cliente con seguridad de tipos a partir de tu modelo Smithy.