Skip to content

Smithy TypeScript API

Smithy is a protocol-agnostic interface definition language for authoring APIs in a model driven fashion.

The Smithy TypeScript API generator creates a new API using Smithy for service definition, and the Smithy TypeScript Server SDK for implementation. The generator vends CDK or Terraform infrastructure as code to deploy your service to AWS Lambda, exposed via an AWS API Gateway REST API. It provides type-safe API development with automatic code generation from Smithy models. The generated handler uses AWS Lambda Powertools for TypeScript for observability, including logging, AWS X-Ray tracing and CloudWatch Metrics

You can generate a new Smithy TypeScript API in two ways:

  1. Install the Nx Console VSCode Plugin if you haven't already
  2. Open the Nx Console in VSCode
  3. Click Generate (UI) in the "Common Nx Commands" section
  4. Search for @aws/nx-plugin - ts#smithy-api
  5. Fill in the required parameters
    • Click Generate
    Parameter Type Default Description
    name Required 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.

    The generator creates two related projects in the <directory>/<api-name> directory:

    • Directorymodel/ Smithy model project
      • project.json Project configuration and build targets
      • smithy-build.json Smithy build configuration
      • build.Dockerfile Docker configuration for building Smithy artifacts
      • Directorysrc/
        • main.smithy Main service definition
        • Directoryoperations/
          • echo.smithy Example operation definition
    • Directorybackend/ TypeScript backend implementation
      • project.json Project configuration and build targets
      • rolldown.config.ts Bundle configuration
      • Directorysrc/
        • handler.ts AWS Lambda handler
        • local-server.ts Local development server
        • service.ts Service implementation
        • context.ts Service context definition
        • Directoryoperations/
          • echo.ts Example operation implementation
        • Directorygenerated/ Generated TypeScript SDK (created during build)

    Since this generator creates infrastructure as code based on your chosen iacProvider, it will create a project in packages/common which includes the relevant CDK constructs or Terraform modules.

    The common infrastructure as code project is structured as follows:

    • Directorypackages/common/constructs
      • Directorysrc
        • Directoryapp/ Constructs for infrastructure specific to a project/generator
          • Directoryapis/
            • <project-name>.ts CDK construct for deploying your API
        • Directorycore/ Generic constructs which are reused by constructs in app
          • Directoryapi/
            • rest-api.ts CDK construct for deploying a REST API
            • utils.ts Utilities for the API constructs
        • index.ts Entry point exporting constructs from app
      • project.json Project build targets and configuration

    Operations are defined in Smithy files within the model project. The main service definition is in 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,
    // Add your operations here
    ]
    errors: [
    ValidationException
    ]
    }

    Individual operations are defined in separate files in the operations/ directory:

    $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
    }

    Operation implementations are located in the backend project’s src/operations/ directory. Each operation is implemented using the generated types from the TypeScript Server SDK (generated at build time from your Smithy model).

    import { ServiceContext } from '../context.js';
    import { Echo as EchoOperation } from '../generated/ssdk/index.js';
    export const Echo: EchoOperation<ServiceContext> = async (input) => {
    // Your business logic here
    return {
    message: `Echo: ${input.message}` // type-safe based on your Smithy model
    };
    };

    Operations must be registered to the service definition in src/service.ts:

    import { ServiceContext } from './context.js';
    import { YourServiceService } from './generated/ssdk/index.js';
    import { Echo } from './operations/echo.js';
    // Import other operations here
    // Register operations to the service here
    export const Service: YourServiceService<ServiceContext> = {
    Echo,
    // Add other operations here
    };

    You can define shared context for your operations in context.ts:

    export interface ServiceContext {
    // Powertools tracer, logger and metrics are provided by default
    tracer: Tracer;
    logger: Logger;
    metrics: Metrics;
    // Add shared dependencies, database connections, etc.
    dbClient: any;
    userIdentity: string;
    }

    This context is passed to all operation implementations and can be used to share resources like database connections, configuration, or logging utilities.

    The generator configures structured logging using AWS Lambda Powertools with automatic context injection via Middy middleware.

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

    You can reference the logger from your operation implementations via the context:

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

    AWS X-Ray tracing is configured automatically via the captureLambdaHandler middleware.

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

    You can add custom subsegments to your traces in your operations:

    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) => {
    // Creates a new subsegment
    const subsegment = ctx.tracer.getSegment()?.addNewSubsegment('custom-operation');
    try {
    // Your logic here
    } catch (error) {
    subsegment?.addError(error as Error);
    throw error;
    } finally {
    subsegment?.close();
    }
    };

    CloudWatch metrics are collected automatically for each request via the logMetrics middleware.

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

    You can add custom metrics in your operations:

    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 provides built-in error handling. You can define custom errors in your Smithy model:

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

    And register them to your operation/service:

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

    Then throw them in your TypeScript implementation:

    import { InvalidRequestError } from '../generated/ssdk/index.js';
    export const MyOperation: MyOperationHandler<ServiceContext> = async (input) => {
    if (!input.requiredField) {
    throw new InvalidRequestError({
    message: "Required field is missing"
    });
    }
    return { /* success response */ };
    };

    The Smithy model project uses Docker to build the Smithy artifacts and generate the TypeScript Server SDK:

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

    This process:

    1. Compiles the Smithy model and validates it
    2. Generates OpenAPI specification from the Smithy model
    3. Creates TypeScript Server SDK with type-safe operation interfaces
    4. Outputs build artifacts to dist/<model-project>/build/

    The backend project automatically copies the generated SDK during compilation:

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

    The generator automatically configures a bundle target which uses Rolldown to create a deployment package:

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

    Rolldown configuration can be found in rolldown.config.ts, with an entry per bundle to generate. Rolldown manages creating multiple bundles in parallel if defined.

    The generator configures a local development server with hot reloading:

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

    The generator creates CDK or Terraform infrastructure based on your selected iacProvider.

    The CDK construct for deploying your API is in the common/constructs folder:

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

    This sets up:

    1. An AWS Lambda function for the Smithy service
    2. API Gateway REST API as the function trigger
    3. IAM roles and permissions
    4. CloudWatch log group
    5. X-Ray tracing configuration

    The REST/HTTP API CDK constructs are configured to provide a type-safe interface for defining integrations for each of your operations.

    The CDK constructs provide full type-safe integration support as described below.

    You can use the static defaultIntegrations to make use of the default pattern, which defines an individual AWS Lambda function for each operation:

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

    You can access the underlying AWS Lambda functions via the API construct’s integrations property, in a type-safe manner. For example, if your API defines an operation named sayHello and you need to add some permissions to this function, you can do so as follows:

    const api = new MyApi(this, 'MyApi', {
    integrations: MyApi.defaultIntegrations(this).build(),
    });
    // sayHello is typed to the operations defined in your API
    api.integrations.sayHello.handler.addToRolePolicy(new PolicyStatement({
    effect: Effect.ALLOW,
    actions: [...],
    resources: [...],
    }));

    If you would like to customise the options used when creating the Lambda function for each default integration, you can use the withDefaultOptions method. For example, if you would like all of your Lambda functions to reside in a Vpc:

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

    You can also override integrations for specific operations using the withOverrides method. Each override must specify an integration property which is typed to the appropriate CDK integration construct for the HTTP or REST API. The withOverrides method is also type-safe. For example, if you would like to override a getDocumentation API to point to documentation hosted by some external website you could achieve this as follows:

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

    You will also notice that the overridden integration no longer has a handler property when accessing it via api.integrations.getDocumentation.

    You can add additional properties to an integration which will also be typed accordingly, allowing for other types of integration to be abstracted but remain type-safe, for example if you have created an S3 integration for a REST API and later wish to reference the bucket for a particular operation, you can do so as follows:

    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(),
    });
    // Later, perhaps in another file, you can access the bucket property we defined
    // in a type-safe manner
    api.integrations.getFile.bucket.grantRead(...);

    You can also supply options in your integration to override particular method options such as authorizers, for example if you wished to use Cognito authentication for your getDocumentation operation:

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

    If you prefer, you can choose not to use the default integrations and instead directly supply one for each operation. This is useful if, for example, each operation needs to use a different type of integration or you would like to receive a type error when adding new operations:

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

    If you prefer to deploy a single Lambda function to service all API requests, you can freely edit the defaultIntegrations method for your API to create a single function instead of one per integration:

    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 {
    // Reference the same router lambda handler in every integration
    integration: new LambdaIntegration(router),
    };
    },
    });
    };
    }

    You can modify the code in other ways if you prefer, for example you may prefer to define the router function as a parameter to defaultIntegrations instead of constructing it within the method.

    Since operations are defined in Smithy, we use code generation to supply metadata to the CDK construct for type-safe integrations.

    A generate:<ApiName>-metadata target is added to the common constructs project.json to facilitate this code generation, which emits a file such as packages/common/constructs/src/generated/my-api/metadata.gen.ts. Since this is generated at build time, it is ignored in version control.

    If you selected IAM authentication, you can use the grantInvokeAccess method to grant access to your API:

    api.grantInvokeAccess(myIdentityPool.authenticatedRole);

    To invoke your API from a React website, you can use the api-connection generator, which provides type-safe client generation from your Smithy model.