AWSAPI Gateway | 18 Min Read
Automatically Create an AWS API Gateway REST API and Related TypeScript Types via an OpenAPI Specification and AWS CDK
Learn how to automate the creation of an AWS API Gateway REST API and corresponding TypeScript types using OpenAPI specifications and AWS CDK.
When it comes to building REST APIs in AWS using API Gateway there are several different ways you can do it. A common way as I covered in a previous post is to use the AWS CDK and individually define each part of the API and link them together.
This method works great but there is another way you can use that utilises OpenAPI spec files. This method gives you extra benefits like built-in documentation for each of the endpoints including their possible responses, it also gives you the ability to generate TypeScript types dynamically for both the requests and the responses!
So, in this post, we’re going to be taking a closer look at that second approach and how we can use an OpenAPI spec file to create a REST API using AWS API Gateway as well as how we can generate TypeScript types for that API for us to then use in our Lambda handlers.
So, without further ado let’s get started on this tutorial and let’s build our API!
Prerequisites
Before jumping into the tutorial, there are a few prerequisites that we need to take care of. Most importantly we need an AWS account setup and configured, we’ll also need the AWS CDK and CLI configured on our local machine. Once you have all of this configured, you’ll need to either use an existing CDK project or create a new one which you can create by running the command `cdk init app --language typescript`
.
OpenAPI Spec File
With our CDK project ready to go, let’s get started by creating a new OpenAPI spec file that we’ll use throughout this tutorial. To create this, add a new file in the root of the project called `openapi-spec.json`
and paste the below code into it.
1{2 "openapi": "3.0.1",3 "info": {4 "title": "Books API",5 "version": "1.0.0",6 "description": "API for managing books in a database"7 },8 "paths": {9 "/books": {10 "post": {11 "summary": "Add a new book",12 "description": "Add a new book to the database",13 "operationId": "addBook",14 "requestBody": {15 "required": true,16 "content": {17 "application/json": {18 "schema": {19 "type": "object",20 "required": ["title", "author"],21 "properties": {22 "title": {23 "type": "string",24 "example": "The Great Gatsby"25 },26 "author": {27 "type": "string",28 "example": "F. Scott Fitzgerald"29 },30 "year": {31 "type": "integer",32 "example": 192533 },34 "genre": {35 "type": "string",36 "example": "Fiction"37 }38 }39 }40 }41 }42 },43 "responses": {44 "201": {45 "description": "Book created",46 "content": {47 "application/json": {48 "schema": {49 "type": "object",50 "properties": {51 "id": {52 "type": "string",53 "example": "1"54 }55 }56 }57 }58 }59 },60 "400": {61 "description": "Invalid input"62 },63 "500": {64 "description": "Internal server error"65 }66 },67 "x-amazon-apigateway-integration": {68 "uri": "arn:aws:apigateway:{{region}}:lambda:path/2015-03-31/functions/{{post_function_arn}}/invocations",69 "responses": {70 "default": {71 "statusCode": "200"72 }73 },74 "passthroughBehavior": "when_no_match",75 "httpMethod": "POST",76 "type": "aws_proxy"77 }78 },79 "get": {80 "summary": "Get all books",81 "description": "Retrieve a list of all books in the database",82 "operationId": "getAllBooks",83 "responses": {84 "200": {85 "description": "A list of books",86 "content": {87 "application/json": {88 "schema": {89 "type": "array",90 "items": {91 "type": "object",92 "properties": {93 "id": {94 "type": "string",95 "example": "1"96 },97 "title": {98 "type": "string",99 "example": "The Great Gatsby"100 },101 "author": {102 "type": "string",103 "example": "F. Scott Fitzgerald"104 },105 "year": {106 "type": "integer",107 "example": 1925108 },109 "genre": {110 "type": "string",111 "example": "Fiction"112 }113 }114 }115 }116 }117 }118 },119 "500": {120 "description": "Internal server error"121 }122 },123 "x-amazon-apigateway-integration": {124 "uri": "arn:aws:apigateway:{{region}}:lambda:path/2015-03-31/functions/{{get_all_function_arn}}/invocations",125 "responses": {126 "default": {127 "statusCode": "200"128 }129 },130 "passthroughBehavior": "when_no_match",131 "httpMethod": "POST",132 "type": "aws_proxy"133 }134 }135 }136 },137 "components": {138 "securitySchemes": {139 "ApiKeyAuth": {140 "type": "apiKey",141 "in": "header",142 "name": "x-api-key"143 }144 }145 },146 "security": [147 {148 "ApiKeyAuth": []149 }150 ]151}
jsonA fair amount is going on in this JSON file so over the next few sections let’s break it down so we can get a better understanding of what each part of the file does.
Defining the API
Right at the top of the file, we have the below data which denotes the version of OpenAPI we’re using in the file as well as some basic information about the API such as its name, description, and version number.
1"openapi": "3.0.1",2"info": {3 "title": "Books API",4 "version": "1.0.0",5 "description": "API for managing books in a database"6},
jsonDefining our endpoints
Then after we have added that basic information about our API we use the `paths`
property to define any endpoints on our API. In our case, we define one endpoint, the `/books`
endpoint. Then immediately after defining that endpoint, we define the methods we’d like to use with it by adding properties inside the `/paths`
object with the method names, in our case, we add the methods `get`
and `post`
.
Inside the `get`
and `post`
method objects we’re then able to add information specifically about that endpoint and method combination. For example, we add a `summary`
and `description`
to describe what will happen if a request is sent to that endpoint using the method specified. After giving this basic information about the method, we then move on to the interesting part where we define the `requestBody`
we expect to receive (if applicable) and the `response`
we’ll give back depending on the `statusCode`
.
Requests
To get a better understanding of the `requestBody`
property, let’s take a closer look at the one for the `POST`
method.
1// ...previous JSON2
3"requestBody": {4 "required": true,5 "content": {6 "application/json": {7 "schema": {8 "type": "object",9 "required": ["title", "author"],10 "properties": {11 "title": {12 "type": "string",13 "example": "The Great Gatsby"14 },15 "author": {16 "type": "string",17 "example": "F. Scott Fitzgerald"18 },19 "year": {20 "type": "integer",21 "example": 192522 },23 "genre": {24 "type": "string",25 "example": "Fiction"26 }27 }28 }29 }30 }31},32
33// ...rest of JSON
jsonIn this JSON for our `POST`
method, you can see we mark the `requestBody`
as required by setting the `required`
property to `true`
. We then define all of the properties that we expect to see in the body of the request as well as give examples for each of them. Finally, we also define the required fields in the body by providing an array of property names to the `required`
property inside the `schema`
of the body.
Responses
With the request out of the way, let’s now take a look at its counterpart, the response. Let’s continue our example by looking at the responses of the `POST`
method on our `/books`
endpoint.
1// ...previous JSON2
3"responses": {4 "201": {5 "description": "Book created",6 "content": {7 "application/json": {8 "schema": {9 "type": "object",10 "properties": {11 "id": {12 "type": "string",13 "example": "1"14 }15 }16 }17 }18 }19 },20 "400": {21 "description": "Invalid input"22 },23 "500": {24 "description": "Internal server error"25 }26},27
28// ...rest of JSON
jsonThe `responses`
object is fairly simple, we list the possible status codes we’d like to respond to requests with as properties inside the object and then we provide an object to each of them.
Inside the provided objects, we then provide a `description`
property which describes what the response will be. For example, for the `201`
it’s “Book created” and for the `500`
it’s “Internal server error”. We then define the `content`
property in the object which outlines what the body of that response for that status code will be. For example, for the `201`
response, the `content`
will be an object that contains the `id`
of the book created.
Linking the Lambda Integrations
Finally, there is one part left that we need to look at for defining endpoints using an OpenAPI spec and this bit is specific to AWS and that’s linking the Lambda function we want to run to the endpoint.
To do this, we can add another object alongside our `requestBody`
and `responses`
objects inside our method object called `x-amazon-apigateway-integration`
and inside that object we can specify the information required to connect our Lambda function to the endpoint.
Below is the object we use to connect the Lambda to our `POST`
method, let’s break it down.
1"x-amazon-apigateway-integration": {2 "uri": "arn:aws:apigateway:{{region}}:lambda:path/2015-03-31/functions/{{get_all_function_arn}}/invocations",3 "responses": {4 "default": {5 "statusCode": "200"6 }7 },8 "passthroughBehavior": "when_no_match",9 "httpMethod": "POST",10 "type": "aws_proxy"11}
jsonTo start with the first thing we define is the `uri`
property which is the ARN of the Lambda connection in API Gateway. Inside this API Gateway ARN, we provide the ARN of the Lambda function we want to trigger in response to requests, we specify that using `{{get_all_function_arn}}`
. We also do a similar thing with the region in our ARN and specify it with `{{region}}`
.
Now, at this point, you may be wondering why we specify the values in this template form instead of providing the actual values. In the case of the region which we could hard code, we specify it like this because it provides more flexibility when it comes to updating it. Instead of us needing to hunt through a file that is possibly thousands of lines long, looking for each instance, we can now update one line in our stack definition file and have the rest done for us.
However, in the case of the Lambda ARN, it is because at the time of us writing the OpenAPI spec file we didn’t know what the ARN would be so we needed to use a placeholder which we could then switch out during the deployment flow to the real ARN of the Lambda function.
When it comes to defining our API later in this tutorial, we’ll see how we can switch out these template values for real values and how doing this can give us a lot of flexibility. But, for now, let’s continue looking at our Lambda connection object.
The next property is the `responses`
object which defines the possible responses API Gateway can receive from Lambda when it invokes the Lambda function. In this case, we define a `200`
response which is for a successful invocation of the function. At this point, it’s important to remember that these aren’t the responses our Lambda function can return to the user but rather the responses API Gateway can receive from invoking the Lambda. In the vast majority of cases, you’ll be okay to specify just a `200`
response here.
Finally, after the `responses`
object, the rest of the properties follow a similar pattern and are related to the connection between API Gateway and Lambda such as the `method`
used which is always a `POST`
request. And, the `type`
of connection which is `aws_proxy`
as well as the `passthroughBehavior`
which we have set as `when_no_match`
.
If you would like to learn more about the `x-amazon-apigateway-integration`
object, check out the documentation.
API Key security
Finally, we’ve reached the last part of our OpenAPI spec file and that is defining the security we use with the API. In our case, we use an API key in the header of each request which we define in the OpenAPI spec file by using the below at the bottom of the file.
1"components": {2 "securitySchemes": {3 "ApiKeyAuth": {4 "type": "apiKey",5 "in": "header",6 "name": "x-api-key"7 }8 }9},10"security": [11 {12 "ApiKeyAuth": []13 }14]
jsonAnd, with that, we’ve now gone through each part of our OpenAPI spec file and we’re ready to move on to the next section where we’ll be generating TypeScript types from this file which we can then use in our Lambda handlers in the following section!
Generating TypeScript Types from the OpenAPI Spec File
With the OpenAPI spec file now complete, let’s turn our attention to generating the TypeScript types we mentioned. Thankfully, the process for doing this is actually incredibly simple as we’ll be making use of the `openapi-typescript`
NPM package to do the heavy lifting for us.
So, to add this functionality to the project, add the below command into the scripts object in your `package.json`
file.
1"generate-types": "npx openapi-typescript ./openapi-spec.json -o ./types/openapi.d.ts"
jsonThen run the command `npm run generate-types`
in your terminal to generate the types from your OpenAPI spec file. The generated types will then be available in the newly created `types`
directory at the root of the project.
Defining the Lambda Handlers
With our types now generated, let’s move on to defining and then creating the Lambda functions where we’ll consume those types. But, before we can define the Lambda functions, we first need to define a new DynamoDB database that we’ll use to contain all of the records for our API.
To define the database, add the below code into your stack definition file in the `lib`
directory inside the `constructor`
.
1// 1. Create a new DynamoDB database2const booksDb = new Table(this, "BooksDbTable", {3 partitionKey: { name: "pk", type: AttributeType.STRING },4 removalPolicy: RemovalPolicy.DESTROY,5 billingMode: BillingMode.PAY_PER_REQUEST,6});
tsThen with our new database defined, we can then define our two Lambda functions, one for the `GET`
method and one for the `POST`
method. To define these Lambda functions, add the below code under the code we just added for the DynamoDB database.
1// 2. Create the POST lambda function2const createBookLambda = new NodejsFunction(this, "CreateBookLambda", {3 entry: "resources/create-book.ts",4 handler: "handler",5 environment: {6 TABLE_NAME: booksDb.tableName,7 },8});9
10// 3. Create the GET lambda function11const getAllBooksLambda = new NodejsFunction(this, "GetAllBooksLambda", {12 entry: "resources/get-all-books.ts",13 handler: "handler",14 environment: {15 TABLE_NAME: booksDb.tableName,16 },17});
tsFinally, we just need to configure the permissions required for our Lambda functions to connect to our DynamoDB database as well as to allow them to be invoked by API Gateway. To add these permissions add the below code under our Lambda definition code.
1// 4. Grant permissions to the lambda functions2booksDb.grantReadWriteData(createBookLambda);3booksDb.grantReadWriteData(getAllBooksLambda);4
5getAllBooksLambda.addPermission("InvokeByApiGateway", {6 principal: new ServicePrincipal("apigateway.amazonaws.com"),7});8
9createBookLambda.addPermission("InvokeByApiGateway", {10 principal: new ServicePrincipal("apigateway.amazonaws.com"),11});
tsWith our DynamoDB database and Lambda’s defined as well as the required permissions configured for our Lambda functions we’re ready to move on to writing the code in our Lambda functions.
Writing the Lambda Handlers
Now, it’s time to see our generated types in action, to create the Lambda functions, create a new directory in the root of the project called `resources`
and then inside it add two files. One called `create-book.ts`
for our `POST`
method and one called `get-all-books.ts`
for our `GET`
method.
`POST`
With our two files created, let’s now add their contents. First of all, add the below code for our `POST`
handler into the `create-book.ts`
file.
1import { APIGatewayProxyEvent } from "aws-lambda";2import { DynamoDB } from "@aws-sdk/client-dynamodb";3import { PutCommand } from "@aws-sdk/lib-dynamodb";4import { randomUUID } from "crypto";5import { paths } from "../types/openapi";6
7const dynamodb = new DynamoDB({});8
9export const handler = async (event: APIGatewayProxyEvent) => {10 try {11 // If no body, return an error12 if (!event.body) {13 return {14 statusCode: 400,15 body: JSON.stringify({ message: "Invalid input" }),16 };17 }18
19 const body = JSON.parse(20 event.body21 ) as paths["/books"]["post"]["requestBody"]["content"]["application/json"];22 const uuid = randomUUID();23
24 await dynamodb.send(25 new PutCommand({26 TableName: process.env.TABLE_NAME,27 Item: {28 pk: `BOOK#${uuid}`,29 ...body,30 },31 })32 );33
34 return {35 statusCode: 200,36 body: JSON.stringify({ id: uuid }),37 };38 } catch (error) {39 // eslint-disable-next-line no-console40 console.log(error);41
42 return {43 statusCode: 500,44 body: JSON.stringify({ message: "Internal server error" }),45 };46 }47};
tsThis is a pretty standard `POST`
handler for creating new data in a database but what is important to note is the use of our generated types when we type the body passed to the request.
`GET`
With our `POST`
handler now complete, let’s turn our attention to the `GET`
handler and add the below code to our `get-all-books.ts`
file.
1import { DynamoDB } from "@aws-sdk/client-dynamodb";2import { ScanCommand } from "@aws-sdk/lib-dynamodb";3import { paths } from "../types/openapi";4
5const dynamodb = new DynamoDB({});6
7export const handler = async () => {8 try {9 const { Items } = await dynamodb.send(10 new ScanCommand({11 TableName: process.env.TABLE_NAME,12 })13 );14
15 const parsedItems =16 Items as paths["/books"]["get"]["responses"]["200"]["content"]["application/json"];17
18 return {19 statusCode: 200,20 body: JSON.stringify(parsedItems),21 };22 } catch (error) {23 // eslint-disable-next-line no-console24 console.log(error);25
26 return {27 statusCode: 500,28 body: JSON.stringify({ message: "Internal server error" }),29 };30 }31};
tsJust like the `POST`
handler, this is a pretty simple `GET`
handler for fetching all of the records in the database. In this case, we use a `ScanCommand`
to fetch all of the records but you could switch this out for a `QueryCommand`
if you wanted to.
Creating the REST API
With the code added to our Lambda functions, they’re now complete and we can turn our attention to defining and creating the REST API itself.
Preparing the OpenAPI spec file
However, before we can call the construct that will create the REST API for us on API Gateway, we must first prepare our OpenAPI spec file by replacing those template values from earlier with their real values.
To do this, we’re going to use another NPM package called `mustache`
which you’ll need to install by running the command `npm i mustache`
. With that package installed, we can then update our stack definition file to handle the replacement of the template values in our OpenAPI spec file with the real values.
To do this add the below code under the code you added earlier for adding the permissions to the Lambda functions.
1// 5. Define the variables to replace in the OpenAPI spec file2const variables = {3 region: "eu-west-2",4 post_function_arn: createBookLambda.functionArn,5 get_all_function_arn: getAllBooksLambda.functionArn,6};7
8const openApiSpecJson = this.resolve(9 Mustache.render(10 readFileSync(path.join(__dirname, "../openapi-spec.json"), "utf-8"),11 variables12 )13);
tsIn this code, we first list the variables we specified earlier in our OpenAPI spec file using the `{{VARIABLE_NAME}}`
syntax. We then assign them the value that we’d like to switch the template placeholder out with. So, for example, we’ll take the `{{REGION}}`
placeholder and switch it out with the value `eu-west-2`
.
Then after defining our variables and their target values, we call `Mustache.render()`
to perform the actual replacement of the variables. Once that process is complete we then have a finalised OpenAPI spec stored in our `openApiSpecJson`
variable with the templates switched out with their real values.
Defining the REST API
With our OpenAPI spec now ready to go, we’re now ready to add the construct that will create the REST API for us on AWS. For this, we’re going to be using the `SpecRestAPI`
construct as this will allow us to provide an OpenAPI spec and transform it into the required API Gateway resources.
To add this code into your stack definition file add the below code under the code we just added for switching out the variables in our OpenAPI spec file.
1// 6. Define the REST API from the spec file2const booksApi = new SpecRestApi(this, "BooksAPI", {3 apiDefinition: ApiDefinition.fromInline(openApiSpecJson),4});
tsAdding an API Key
With the API now defined, we’re almost ready to deploy and test it but before we can do that we need to create a new API key to use with our API. This is because if you remember back to earlier when we went through our OpenAPI spec, we defined that we’d be using API keys in the headers of the requests for security.
I won’t go into too much depth on creating API keys in the CDK in this tutorial but if you want to read more into the process and what each part does, you can read a previous tutorial I wrote on it here.
But, to add the API key creation into our stack, you can add the below code to the stack definition file under the code we just added for defining our REST API.
1// 8. Add an API key2const apiKey = new ApiKey(this, "ApiKey");3
4const booksUsagePlan = new UsagePlan(this, "BooksApiUsagePlan", {5 name: "Books Usage Plan",6 apiStages: [7 {8 api: booksApi,9 stage: booksApi.deploymentStage,10 },11 ],12});13
14booksUsagePlan.addApiKey(apiKey);
tsDeployment and Testing
All of the resources and services we’ll need are now defined but before we can deploy them to AWS, we first need to add an output to our stack to log out the ID of our new API key. With that ID, we can then retrieve the value of our API key using the AWS CLI which we can then use in the header of our requests.
To log out the API key ID, add the below code to the bottom of your stack definition file under the code we just added for adding the API key.
1// 9. Log out the API URL and API key2new CfnOutput(this, "api-values-output", {3 value: JSON.stringify({4 apiKey: apiKey.keyId,5 }),6});
tsThen with this added, we can deploy our stack to AWS by running the command `cdk deploy`
in the terminal and accepting any prompts displayed.
With the stack now deployed to AWS, you should now have the URL of your newly created API logged out in your terminal along with the ID of your API key as well. But, before we can send requests to our API, we first need to retrieve the value of the API key using the AWS CLI.
To do this, you can run the command `aws apigateway get-api-key --api-key API_KEY_ID --include-value`
in your terminal, making sure to replace the placeholder `API_KEY_ID`
with the ID you received a moment ago. In the output of this command, you should then see the value of your API key, copy this value as we’ll need it in the next step.
With our API key value now obtained, we’re ready to test our API and make sure that the OpenAPI spec is deployed correctly. To test the API, take the URL that was outputted in the terminal and add `/books`
to the end of it as that’s the endpoint we defined.
Then inside a tool like Postman, create a new request and add a new header to it with a key of `x-api-key`
and a value of the API key value we obtained a moment ago.
Then you should be able to send a `POST`
request to your API URL with a body like the one below to create a new record in the database with the provided data.
1{2 "title": "Example2",3 "author": "Example"4}
jsonIn response, you should then receive the ID of the newly created record, if so, the `POST`
method works as intended and all that is left to test is the `GET`
method. You can test this by removing the body of the request and switching the method to `GET`
. Then, after sending this request, you should receive back an array that contains the record we created in our `POST`
request.
If you did get both of the responses outlined above then congratulations, you now have a fully working REST API deployed on AWS API Gateway from an OpenAPI spec file!
Closing Thoughts
In this tutorial, we looked at how to create a REST API on AWS API Gateway from an OpenAPI spec file via the `SpecRestApi`
construct. We also looked at how to generate TypeScript types from that same OpenAPI spec file and how we can use those generated types in the Lambda functions powering our API to give us accurate types for our request and response bodies.
If you would like to see the final, complete code for this tutorial, you can see it here in my CDK Tutorials GitHub repository which contains the code for this tutorial and all of my other tutorials as well.
Finally, if you found this tutorial helpful, then make sure to check out my other AWS API Gateway tutorials below.
- How to Build a REST API With the AWS CDK Using API Gateway, Lambda, and Dynamodb With API Key Authentication
- Leveraging AWS WAF to Throttle API Gateway Rest API Requests
- Throttling API Keys in an API Gateway REST API using Usage Plans
I hope you found this post helpful and thank you for reading.