Skip to main content

Command Palette

Search for a command to run...

How to Automatically Generate Request Models from TypeScript Interfaces

Published
7 min read
How to Automatically Generate Request Models from TypeScript Interfaces
M

I'm an AWS Community Builder and a Principal Software Architect I love all things CDK, Event Driven Architecture and Serverless

I ♥️ AWS CDK (big CDK fan-boi) and have several projects at work that make use of CDK with AWS's APIGateway. But there isn't an easy way to do API Request Validation without manually defining everything... until now?

but why

But... Why do request validation?

Request validation allows you to verify that the requests coming through your API are valid before actually invoking the lambda that's behind it. That should yield some cost savings and there's some additional security benefits as well.

This article will go over the basics of doing request validation and the basics of automating it. It's not a tutorial about setting up an API (though you can probably infer that from this project) or using projen. If you'd like articles on those... let me know in the comments!

The code for this article is here: github.com/martzcodes/blog-ts-request-validation.

What's the general structure of the code?

This project was created using projen. It was my first time using it but it has some fairly convenient features. I look forward to tracking it in the future. The important files are in the src/ folder.

  • src/main.ts is where the CDK stack is defined
  • src/lambdas is where the very simple lambdas are defined... they basically just output text strings with inputs from the request path and body
  • src/interfaces contains the interfaces used by the two lambdas
  • src/interfaces/basic.ts is a basic interface... it just has one string and one number properties.
  • src/interfaces/advanced.ts is more complicated in that it has an optional property and it pulls in the Basic interface

The stack has one api with two root resources... validated and unvalidated... just to make it easier to compare. Both have endpoints that point to the same lambdas.

How does request validation work?

The simple branch in my repo has the non-automated version of the API gateway.

After the initial setup, we create the general resource:

const validatedResource = restApi.root.addResource('validated');
const validatedHelloResource = validatedResource.addResource('{hello}');
const validatedHelloBasicResource = validatedHelloResource.addResource(
    'basic',
);

In order to do request body validation we have to define a model:

  • https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-apigateway.Model.html

  • https://docs.aws.amazon.com/cdk/api/latest/docs/@aws-cdk_aws-apigateway.JsonSchema.html

const basicModel = restApi.addModel('BasicModel', {
    contentType: 'application/json',
    modelName: 'BasicModel',
    schema: {
    schema: JsonSchemaVersion.DRAFT4,
    title: 'basicModel',
    type: JsonSchemaType.OBJECT,
    properties: {
        someString: { type: JsonSchemaType.STRING },
        someNumber: { type: JsonSchemaType.NUMBER, pattern: '[0-9]+' },
    },
    required: ['someString', 'someNumber'],
    },
});

Note: It turns out the request validator doesn't really validate that a number is a number. A workaround is to use a regex pattern to do the verification.

Next... we define the validator on the API itself... here we're saying request parameters and request body verification have to be valid.

const basicValidator = restApi.addRequestValidator('BasicValidator', {
    validateRequestParameters: true,
    validateRequestBody: true,
});

Finally... we add these to the method:

validatedHelloBasicResource.addMethod(
    'POST',
    new LambdaIntegration(basicLambda),
    {
    requestModels: {
        'application/json': basicModel,
    },
    requestParameters: {
        'method.request.path.hello': true,
    },
    requestValidator: basicValidator,
    },
);

validateRequestParameters needs requestParameters to be defined... it won't automatically validate all of the parameters. The formatting of this is a bit odd, but defined in these docs: https://docs.aws.amazon.com/apigateway/latest/developerguide/request-response-data-mappings.html

Basically whatever you named the path parameter in the resource (hello in my case) you need to define in the requestParameters object as method.request.path.<path parameter you care about>.

The advancedModel gets a little more interesting. It's not well-documented, but models can inherit from one another via references. So we know the Advanced interface pulls in from the Basic interface... how do we do the same with the models?

basic: {
    ref: `https://apigateway.amazonaws.com/restapis/${restApi.restApiId}/models/${basicModel.modelId}`,
},

THAT is the common format for what ultimately happens to models. That URL wasn't changed to be generic / different from my own project... everything gets defined as apigateway.amazonaws.com/restapis... (instead of the execute-api url you usually get).

So what does simple validation look like?

To run through a quick example we'll do 6 tests. 3 on the unvalidated api and 3 on the validated one. We'd expect 2 failures across these:

# Unvalidated - Valid
$ curl --location --request POST 'https://<your api url>/prod/unvalidated/sdfg/basic' \
--header 'Content-Type: application/json' \
--data-raw '{
    "someString": "qwerty",
    "someNumber": 1234
}'
Hello sdfg.  How many times have you qwerty?  1234 times.%

# Unvalidated - Invalid
$ curl --location --request POST 'https://<your api url>/prod/unvalidated/sdfg/basic' \
--header 'Content-Type: application/json' \
--data-raw '{
    "someString": "qwerty",
    "someNumber": "asdf"
}'
Hello sdfg.  How many times have you qwerty?  asdf times.%

# Unvalidated - Missing Path Param
$ curl --location --request POST 'https://<your api url>/prod/unvalidated//basic' \
--header 'Content-Type: application/json' \
--data-raw '{
    "someString": "qwerty",
    "someNumber": 1234
}'
Hello no one.  How many times have you qwerty?  1234 times.%  

# Validated - Valid
$ curl --location --request POST 'https://<your api url>/prod/validated/sdfg/basic' \
--header 'Content-Type: application/json' \
--data-raw '{
    "someString": "qwerty",
    "someNumber": 1234
}'
Hello sdfg.  How many times have you qwerty?  1234 times.%

# Validated - Invalid
$ curl --location --request POST 'https://<your api url>/prod/validated/sdfg/basic' \
--header 'Content-Type: application/json' \
--data-raw '{
    "someString": "qwerty",
    "someNumber": "asdf"
}'
{"message": "Invalid request body"}%

# Validated - Missing Path Param
$ curl --location --request POST 'https://<your api url>/prod/validated//basic' \ 
--header 'Content-Type: application/json' \
--data-raw '{
    "someString": "qwerty",
    "someNumber": 1234
}'
{"message": "Missing required request parameters: [hello]"}%

Those last two are exactly what we wanted to see! Better yet... the lambdas weren't invoked in those cases.

smile

Ok, that's great... but I have to keep JSON schemas in sync with my actual code?!?

Yup. Unless you get creative with the Abstract Syntax Tree (AST).

Having to keep the same basic thing up-to-date in two (or more) separate places annoys me. It's too easy to change your interface and forget to update the schema that goes with it.

Now the important file I'm going to walk through is https://github.com/martzcodes/blog-ts-request-validation/blob/main/src/util/ast.ts

Here I make use of TypeScripts API to parse all the interfaces in a folder and output them in the JSON schema format that the API Gateway models are expecting. Working with the AST is a bit gnarly... it's not the most intuitive API, so there was a lot of trial and error. If you have any suggestions for how to improve this, please let me know.

I've added comments to the code... but at a high-level:

  1. Get the list of files in a folder

  2. Check to make sure they have interfaces and figure out the hierarchy

  3. Process them from child-up so that child nodes get generated as models first and use previously defined ones as a reference.

  4. Generate the actual schemas and then define them in the stack.

Now we can use these to add models to the actual API in the stack:

const models: { [key: string]: Model } = {};
Object.keys(modelSchemas).forEach((modelSchema) => {
    if (modelSchemas[modelSchema].modelName) {
    models[modelSchema] = restApi.addModel(
        modelSchemas[modelSchema].modelName || '',
        modelSchemas[modelSchema],
    );
    }
});

and in the actual methods just update the models to use the generated ones:

// Basic is the interface in this case
requestModels: {
    'application/json': models.Basic,
},

Running the same tests will now yield the same results, but the interfaces are only defined in one place. Anytime the stack is deployed it'll regenerate the models from the typescript interfaces.

Next steps?

There's still some manual processes related to this and you have to be careful with naming conventions. What improvements would you make to this?

sweet

B

I know it's been years but I just stumbled upon this article. I was looking into doing something like that for my project and this post and comments are a great resource for me! thank you!

1
M

Thanks!

O

What an amazing article! I integrated the ideas here into our project and stopped the divergence between Typescript interfaces and a home-grown api-models.json file that contained the model definitions!

1
R

Have you considered instead of generating the OpenAPI documentation and JSONSchema from code that you instead start with the OpenAPI documentation and use that as the single source of truth. That way you could define an OpenAPI document that is used to generate types for both the client and server as well as can be easily converted to JSONSchema for API validation.

I found the following library that does somewhat of what I mentioned above https://www.npmjs.com/package/@alma-cdk/openapix but they use SpecRestAPI construct under the hood. I'm going to try in my own project use the RestAPI construct and build the Models from the JSONSchema generated from the OpenAPI documentation.

Btw great articles I'm really liking them.

M

Thanks! I prefer the code-first approach. I hate writing specs and would much rather the code generate them. I talk about this a little bit in the next article: https://matt.martz.codes/openapi-specs-from-cdk-stack-without-deploying-first

But I have since moved more towards Event Driven Documentation: https://matt.martz.codes/automate-documenting-api-gateways-in-eventcatalog

Both still rely on converting typescript interfaces to json schemas... but in the more recent post I shift to downloading the spec from the deployed version of the API Gateway. Thanks for the feedback though, that's totally a valid approach... just not my preferred one.

More from this blog

martzmakes

50 posts

MS in CS. Interested in TypeScript, AWS, Brewing, DnD'ing. I ♥️ CDK, Event Driven Architectures and Serverless #AWSCommunityBuilder