How to Automatically Generate Request Models from TypeScript Interfaces

How to Automatically Generate Request Models from TypeScript Interfaces

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:

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: docs.aws.amazon.com/apigateway/latest/devel..

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 github.com/martzcodes/blog-ts-request-valid..

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