Aller au contenu

Documentation de l'API Smithy TypeScript

Smithy est un langage de définition d’interface indépendant des protocoles pour créer des API de manière modélisée.

Le générateur d’API Smithy TypeScript crée une nouvelle API en utilisant Smithy pour la définition des services, et le SDK serveur Smithy TypeScript pour l’implémentation. Le générateur fournit une infrastructure IaC avec CDK ou Terraform pour déployer votre service sur AWS Lambda, exposé via une API REST AWS API Gateway. Il permet un développement d’API fortement typé avec génération automatique de code à partir des modèles Smithy. Le gestionnaire généré utilise AWS Lambda Powertools pour TypeScript pour l’observabilité, incluant la journalisation, le traçage AWS X-Ray et les métriques CloudWatch.

Vous pouvez générer une nouvelle API Smithy TypeScript de deux manières :

  1. Installez le Nx Console VSCode Plugin si ce n'est pas déjà fait
  2. Ouvrez la console Nx dans VSCode
  3. Cliquez sur Generate (UI) dans la section "Common Nx Commands"
  4. Recherchez @aws/nx-plugin - ts#smithy-api
  5. Remplissez les paramètres requis
    • Cliquez sur Generate
    Paramètre Type Par défaut Description
    name Requis 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.

    Le générateur crée deux projets associés dans le répertoire <directory>/<api-name> :

    • Répertoiremodel/ Projet de modèle Smithy
      • project.json Configuration du projet et cibles de build
      • smithy-build.json Configuration de build Smithy
      • build.Dockerfile Configuration Docker pour la construction des artefacts Smithy
      • Répertoiresrc/
        • main.smithy Définition principale du service
        • Répertoireoperations/
          • echo.smithy Exemple de définition d’opération
    • Répertoirebackend/ Implémentation TypeScript du backend
      • project.json Configuration du projet et cibles de build
      • rolldown.config.ts Configuration de bundle
      • Répertoiresrc/
        • handler.ts Gestionnaire AWS Lambda
        • local-server.ts Serveur de développement local
        • service.ts Implémentation du service
        • context.ts Définition du contexte du service
        • Répertoireoperations/
          • echo.ts Exemple d’implémentation d’opération
        • Répertoiregenerated/ SDK TypeScript généré (créé pendant le build)

    Comme ce générateur crée une infrastructure as code selon votre choix de iacProvider, il génère un projet dans packages/common incluant les constructs CDK ou modules Terraform pertinents.

    Le projet d’infrastructure as code commun est structuré comme suit :

    • Répertoirepackages/common/constructs
      • Répertoiresrc
        • Répertoireapp/ Constructs pour l’infrastructure spécifique à un projet/générateur
          • Répertoireapis/
            • <project-name>.ts Construct CDK pour déployer votre API
        • Répertoirecore/ Constructs génériques réutilisés par ceux dans app
          • Répertoireapi/
            • rest-api.ts Construct CDK pour déployer une API REST
            • utils.ts Utilitaires pour les constructs d’API
        • index.ts Point d’entrée exportant les constructs de app
      • project.json Cibles de build et configuration du projet

    Les opérations sont définies dans des fichiers Smithy au sein du projet de modèle. La définition principale du service se trouve dans 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,
    // Ajoutez vos opérations ici
    ]
    errors: [
    ValidationException
    ]
    }

    Les opérations individuelles sont définies dans des fichiers séparés dans le répertoire 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
    }

    Les implémentations d’opérations se trouvent dans le répertoire src/operations/ du projet backend. Chaque opération est implémentée en utilisant les types générés par le SDK serveur TypeScript (généré au moment du build à partir de votre modèle Smithy).

    import { ServiceContext } from '../context.js';
    import { Echo as EchoOperation } from '../generated/ssdk/index.js';
    export const Echo: EchoOperation<ServiceContext> = async (input) => {
    // Votre logique métier ici
    return {
    message: `Echo: ${input.message}` // fortement typé selon votre modèle Smithy
    };
    };

    Les opérations doivent être enregistrées dans la définition du service dans src/service.ts :

    import { ServiceContext } from './context.js';
    import { YourServiceService } from './generated/ssdk/index.js';
    import { Echo } from './operations/echo.js';
    // Importez d'autres opérations ici
    // Enregistrez les opérations dans le service ici
    export const Service: YourServiceService<ServiceContext> = {
    Echo,
    // Ajoutez d'autres opérations ici
    };

    Vous pouvez définir un contexte partagé pour vos opérations dans context.ts :

    export interface ServiceContext {
    // Tracer, logger et metrics Powertools sont fournis par défaut
    tracer: Tracer;
    logger: Logger;
    metrics: Metrics;
    // Ajoutez des dépendances partagées, connexions DB, etc.
    dbClient: any;
    userIdentity: string;
    }

    Ce contexte est passé à toutes les implémentations d’opérations et peut être utilisé pour partager des ressources comme des connexions de base de données, de la configuration ou des utilitaires de journalisation.

    Le générateur configure la journalisation structurée avec AWS Lambda Powertools et l’injection automatique de contexte via un middleware Middy.

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

    Vous pouvez accéder au logger depuis vos implémentations d’opérations via le contexte :

    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('Votre message de log');
    // ...
    };

    Le traçage AWS X-Ray est configuré automatiquement via le middleware captureLambdaHandler.

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

    Vous pouvez ajouter des sous-segments personnalisés à vos traces dans vos opérations :

    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) => {
    // Crée un nouveau sous-segment
    const subsegment = ctx.tracer.getSegment()?.addNewSubsegment('custom-operation');
    try {
    // Votre logique ici
    } catch (error) {
    subsegment?.addError(error as Error);
    throw error;
    } finally {
    subsegment?.close();
    }
    };

    Les métriques CloudWatch sont collectées automatiquement pour chaque requête via le middleware logMetrics.

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

    Vous pouvez ajouter des métriques personnalisées dans vos opérations :

    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("CustomMetric", MetricUnit.Count, 1);
    // ...
    };

    Smithy fournit une gestion d’erreurs intégrée. Vous pouvez définir des erreurs personnalisées dans votre modèle Smithy :

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

    Et les enregistrer dans votre opération/service :

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

    Puis les lever dans votre implémentation TypeScript :

    import { InvalidRequestError } from '../generated/ssdk/index.js';
    export const MyOperation: MyOperationHandler<ServiceContext> = async (input) => {
    if (!input.requiredField) {
    throw new InvalidRequestError({
    message: "Champ requis manquant"
    });
    }
    return { /* réponse de succès */ };
    };

    Le projet de modèle Smithy utilise Docker pour construire les artefacts Smithy et générer le SDK serveur TypeScript :

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

    Ce processus :

    1. Compile le modèle Smithy et le valide
    2. Génère une spécification OpenAPI à partir du modèle Smithy
    3. Crée le SDK serveur TypeScript avec des interfaces d’opération fortement typées
    4. Produit les artefacts de build dans dist/<model-project>/build/

    Le projet backend copie automatiquement le SDK généré pendant la compilation :

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

    Le générateur configure automatiquement une cible bundle qui utilise Rolldown pour créer un package de déploiement :

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

    La configuration de Rolldown se trouve dans rolldown.config.ts, avec une entrée par bundle à générer. Rolldown gère la création de plusieurs bundles en parallèle si définis.

    Le générateur configure un serveur de développement local avec rechargement à chaud :

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

    Le générateur crée une infrastructure CDK ou Terraform selon votre choix de iacProvider.

    Le construct CDK pour déployer votre API se trouve dans le dossier common/constructs :

    import { MyApi } from ':my-scope/common-constructs';
    export class ExampleStack extends Stack {
    constructor(scope: Construct, id: string) {
    // Ajoutez l'API à votre stack
    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    }
    }

    Ceci configure :

    1. Une fonction AWS Lambda pour le service Smithy
    2. Une API REST API Gateway comme déclencheur
    3. Les rôles et permissions IAM
    4. Un groupe de logs CloudWatch
    5. La configuration de traçage X-Ray

    Les constructs CDK pour l’API REST/HTTP sont configurés pour fournir une interface typée permettant de définir des intégrations pour chacune de vos opérations.

    Les constructs CDK offrent un support complet d’intégration typée comme décrit ci-dessous.

    Vous pouvez utiliser la méthode statique defaultIntegrations pour utiliser le modèle par défaut, qui définit une fonction AWS Lambda individuelle pour chaque opération :

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

    Vous pouvez accéder aux fonctions AWS Lambda sous-jacentes via la propriété integrations du construct API, de manière typée. Par exemple, si votre API définit une opération nommée sayHello et que vous devez ajouter des permissions à cette fonction, vous pouvez le faire comme suit :

    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    // sayHello est typé selon les opérations définies dans votre API
    api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [...],
    resources: [...],
    }));

    Si vous souhaitez personnaliser les options utilisées lors de la création des fonctions Lambda pour chaque intégration par défaut, vous pouvez utiliser la méthode withDefaultOptions. Par exemple, si vous voulez que toutes vos fonctions Lambda résident dans un VPC :

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

    Vous pouvez aussi surcharger les intégrations pour des opérations spécifiques en utilisant la méthode withOverrides. Chaque surcharge doit spécifier une propriété integration typée selon le construct CDK d’intégration approprié pour l’API HTTP ou REST. La méthode withOverrides est également typée. Par exemple, si vous voulez rediriger une API getDocumentation vers une documentation hébergée sur un site externe :

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

    Vous remarquerez que l’intégration surchargée n’a plus de propriété handler lorsqu’on y accède via api.integrations.getDocumentation.

    Vous pouvez ajouter des propriétés supplémentaires à une intégration qui seront également typées, permettant d’abstraire d’autres types d’intégration tout en conservant le typage. Par exemple, si vous avez créé une intégration S3 pour une API REST et que vous souhaitez référencer le bucket pour une opération particulière :

    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(),
    });
    // Plus tard, peut-être dans un autre fichier, vous pouvez accéder à la propriété bucket
    // que nous avons définie de manière typée
    api.integrations.getFile.bucket.grantRead(...);

    Vous pouvez aussi fournir des options dans votre intégration pour surcharger des options de méthode spécifiques comme les autorisations. Par exemple, si vous souhaitez utiliser l’authentification Cognito pour votre opération getDocumentation :

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

    Si vous préférez, vous pouvez choisir de ne pas utiliser les intégrations par défaut et fournir directement une intégration pour chaque opération. Ceci est utile si, par exemple, chaque opération nécessite un type d’intégration différent ou si vous voulez obtenir une erreur de type lors de l’ajout de nouvelles opérations :

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

    Si vous préférez déployer une seule fonction Lambda pour gérer toutes les requêtes API, vous pouvez librement modifier la méthode defaultIntegrations de votre API pour créer une seule fonction au lieu d’une par intégration :

    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 {
    # Référencer le même handler router dans chaque intégration
    integration: new LambdaIntegration(router),
    };
    },
    });
    };
    }

    Vous pouvez modifier le code selon vos préférences, par exemple définir la fonction router comme paramètre de defaultIntegrations au lieu de la construire dans la méthode.

    Comme les opérations sont définies dans Smithy, nous utilisons la génération de code pour fournir des métadonnées au construct CDK pour des intégrations fortement typées.

    Une cible generate:<ApiName>-metadata est ajoutée au project.json des constructs communs pour faciliter cette génération, produisant un fichier comme packages/common/constructs/src/generated/my-api/metadata.gen.ts. Ce fichier étant généré au build, il est ignoré dans le contrôle de version.

    Si vous avez sélectionné l’authentification IAM, vous pouvez utiliser la méthode grantInvokeAccess pour accorder l’accès à votre API :

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    Pour invoquer votre API depuis un site React, vous pouvez utiliser le générateur api-connection, qui fournit un client fortement typé généré à partir de votre modèle Smithy.