In my last post How to Automatically Generate Request Models from TypeScript Interfaces, I walked through how to automatically define request and response models for an AWS API Gateway (with CDK). There several benefits to this such as better endpoint security and you can export an OpenAPI / Swagger spec like this from either the AWS CLI or via the AWS API Gateway Console.
The problem with that is the only way to export the OpenAPI spec is AFTER deploying the API. There are plenty of reasons you may want to have the spec before deploying the API out into the wild (more security checks, client library generation, documentation, etc).
So, what did I do? I improved my code from my last post a bit and created a CDK Construct that creates a RestAPI with parameter, request and response models (with validations) and as it's defining them it uses the same information to generate an OpenAPI spec.
The code for this project lives here: https://github.com/martzcodes/blog-cdk-openapi and this is the result (left is AWS CLI generated api spec from deployed API, right is CDK code-generated spec before deploying)
Getting Started
I made some improvement to the code I used for the last post... namely
- Response models were missing
- Remove unvalidated (boring)
- Add an authorizer lambda
- Updated the lambdas to return a stringified object instead of just a string
- Used a 3rd party library to do the TypeScript Interface -> JSON Schema stuff (https://github.com/vega/ts-json-schema-generator)
With those improvements it was a simple matter of running aws apigateway get-export --parameters extensions='postman' --rest-api-id c638jrlvt0 --stage-name prod --export-type oas30 openapideployed.json
to generate the openapi spec file to get my baseline. If you read through the file... there isn't a lot of API Gateway specific stuff in there... there's the server, which we don't really care about since our API isn't deployed at this point anyways, and the logical id of the authorizer function blogcdkopenapiblogAuthorizer0C135D8A
which isn't really important... the key name just needs to match what references it above, and we're generating that anyways. Everything else is internal references within the file.
The rough mapping is this:
- All models (request and response, doesn't matter) go into
components.schemas
by Model Name - The authorizer function is referenced under
components.securitySchemas
also by name - Everything else goes under
paths[<relative path>][<method>]
and then the config of the path
Pretty simple.
The API Gateway Module that generates the Rest API we'll be using is a bit annoying though. Unlike the HttpAPI in the v2 apigateway module... RestAPI defines endpoints via resource, like this:
const validatedResource = restApi.root.addResource('validated');
const validatedHelloResource = validatedResource.addResource('{hello}');
const validatedHelloBasicResource = validatedHelloResource.addResource(
'basic',
);
validatedHelloBasicResource.addMethod('POST', ...);
instead of
httpApi.addRoutes({ path: '/validated/{hello}', methods: [ HttpMethod.POST ], integration: ... });
Given that the OpenAPI spec uses full paths and IMO it feels cleaner, I'm going to write the construct in a way that allows you to add endpoints by route.
What did I end up with?
The two important files to pay attention to are https://github.com/martzcodes/blog-cdk-openapi/blob/main/src/main.ts and https://github.com/martzcodes/blog-cdk-openapi/blob/main/src/api.ts
api.ts
includes the CDK Construct. As an input it takes a path to a tsconfig.json file (used by the 3rd party TS -> JSON Schema library), the location to where the request/response models live, and the RestApiProps that go in the api constructor.
const api = new OpenApiConstruct(this, 'OpenApi', {
tsconfigPath: `${join(__dirname, '..', 'tsconfig.json')}`,
apiProps: {
defaultMethodOptions: {
authorizer: auth,
},
},
models: `${__dirname}/interfaces`,
});
The constructor of the construct creates the RestApi and retrieves the JSON schemas, but doesn't do anything with them. It also initializes the OpenAPI spec:
this.openApiSpec = {
openapi: '3.0.1',
info: {
title: `${id}`,
version: new Date().toISOString(),
},
paths: {},
components: {
schemas: {},
securitySchemes: {},
},
};
if (props.apiProps.defaultMethodOptions && props.apiProps.defaultMethodOptions.authorizer) {
this.openApiSpec.components.securitySchemes.authorizer = {
'type': 'apiKey',
'name': 'Authorization',
'in': 'header',
'x-amazon-apigateway-authtype': 'custom',
};
}
}
This construct also includes 5 methods:
- addEndpoint - Maybe I should've named this "addRoute" but this is the important one... it adds the route with request and response models
- addValidator - adds the validator for the route to use
- addModel - adds the model from the previously retrieved schema into the RestApi models
- addResourcesForPath - creates all the resources needed for the path
- generateOpenApiSpec - cleans up the openapi spec and saves it to the filesystem
Of these, addEndpoint is the most important... the rest are really more "internal"/private methods. Everything we need to do in our stack is effectively done with the addEndpoint
method.
For comparison...
...shows what we did before using the last post's code (left side), with what we now need to do in the stack to create an endpoint (right side). So, the construct abstracts away a lot of that.
Behind the scenes, this method creates the resources for the path (if they don't already exist, creates a validator if it doesn't exist (if needed), adds the path with reference to the model, adds the model if it hasn't already been added to both the OpenAPI spec and to the RestAPI.
The last "trick" is just to save the generated api to disk so that it can be committed with the rest of the code... we can do that via a test pretty easily.
First, I added a prop to the stack for whether or not to generate the spec
interface MyStackProps extends StackProps {
generateApiSpec: boolean;
}
export class MyStack extends Stack {
public apiSpec?: OpenApiSpec;
constructor(scope: Construct, id: string, props: MyStackProps = { generateApiSpec: false }) {
...
if (props.generateApiSpec) {
this.apiSpec = api.generateOpenApiSpec(join(`${__dirname}`, '..', 'openapigenerated.json'));
}
}
}
...which the test uses...
test('Api Spec', () => {
const app = new App();
const stack = new MyStack(app, 'test', { generateApiSpec: true });
console.log(`Stack: ${JSON.stringify(stack.apiSpec)}`);
console.log(`deployed: ${JSON.stringify(deployedApiSpec)}`);
expect(stack.apiSpec).toMatchSnapshot({
info: {
version: expect.any(String),
},
});
});
to generate this: https://github.com/martzcodes/blog-cdk-openapi/blob/main/openapigenerated.json
Which is better viewed as: https://editor.swagger.io/?url=https://raw.githubusercontent.com/martzcodes/blog-cdk-openapi/main/openapigenerated.json
What's next?
The world is your oyster!
Maybe you...
- Have a stack that includes some front-end code and you want to auto-generate client libraries and you don't want to deploy -> generate spec -> generate libraries -> redeploy with libraries
- Have a security analysis tool that can analyze OpenAPI spec files and you want to do that in your CI pipeline as a check prior to deploying
- Want to generate API documentation to live in the repo and you want it to reflect what's deployed on that same commit
- Want to auto-generate some Postman tests using the API spec
I'm considering turning this into a library... what do you think?
And, if you'd like me to go deeper into any of my code or have suggestions for improvements.... let me know!
If you're interested in starting a dev blog... join hashnode and use my referral: https://hashnode.com/@martzcodes/joinme (I get some perks).
Top comments (0)