How to Automatically Generate Request Models from TypeScript Interfaces
Matt Martz
Posted on October 30, 2020
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 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 theBasic
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.
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:
Get the list of files in a folder
Check to make sure they have interfaces and figure out the hierarchy
Process them from child-up so that child nodes get generated as models first and use previously defined ones as a reference.
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?
Posted on October 30, 2020
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.