Skip to content

Instantly share code, notes, and snippets.

@MarkSFrancis
Created January 4, 2024 14:31
Show Gist options
  • Select an option

  • Save MarkSFrancis/9c756961533c538f75105099d1d5ab44 to your computer and use it in GitHub Desktop.

Select an option

Save MarkSFrancis/9c756961533c538f75105099d1d5ab44 to your computer and use it in GitHub Desktop.
AWS API Gateway CDK Construct

API Construct for API Gateway V2

This gist represents an API construct on top of API Gateway V2. It's designed to make it easy to add lots of protected endpoints to a serverless API.

It introduces the following opinions:

  • Sandbox environments exist in the same AWS account as "dev"
  • There are only dev and production accounts - no test account
  • Dev and prod have static DNS records, but sandbox DNS records are dynamically generated by AWS
  • All API endpoints are authenticated by default. You opt out rather than in to authentication
  • All endpoints are either one verb only or all HTTP verbs. You cannot have a handler for only GET and POST but not PUT, for example. Instead, you must create separate endpoints for each verb (unless you use ANY, in which case your lambda will be triggered for all HTTP verbs on that endpoint)

Adding endpoints

Typescript is used to enforce the pattern for the endpoint. It validates that the endpoint:

  • Starts with a HTTP verb (or ANY)
  • Followed by a whitespace character
  • Followed by any string that starts with / (just / is a valid string, but so is /parent/{id}/child)

Simple endpoints

For most endpoints, we'd expect syntax similar to the following:

api.addEndpoints({
  'GET /': 'src/helloWorld.ts',
  'GET /admin/{resource}': 'src/getAdminResourceList.ts',
  'GET /admin/{resource}/{id}': 'src/getAdminResourceById.ts',
});

This adds 3 endpoints with 3 separate handlers, which all require authentication as well as various lambda runtime defaults. These defaults include things like ARM CPU architecture, 265MB of memory, bundling (with minification), xray tracing, a NodeJS v20 runtime, and a 30 second timeout.

Complex lambda options

You can still fallback onto the full flexibility for the lambda runtime if you need to, with options like the following:

api.addEndpoints({
  'GET /': {
    entry: 'src/myHandler.ts',
    architecture: Architecture.X86_64
  },
});

This would create a handler using the code in src/myHandler.ts, triggered when a GET request is made to /, which runs on X86 CPU architecture. This endpoint would require authentication by default.

Public endpoints

A helper function addPublicEndpoints is provided for opting out of the default authentication for a range of endpoints. This means that in order to add a public endpoint, you could use any of the following syntax:

api.addPublicEndpoints({
  'GET /': 'src/myHandler.ts',
});

api.addEndpoints({
  'GET /': {
    entry: 'src/myHandler.ts',
    publicEndpoint: true,
  },
});

api.addEndpointsWithDefaults({
  'GET /': 'src/myHandler.ts',
}, {
  publicEndpoint: true,
});

api.addEndpoint('GET /', {
  entry: 'src/myHandler.ts',
  publicEndpoint: true,
});

Accessing endpoint functions

You can access the functions backing any endpoints you create via the value returned from addEndpoints

const newEndpoints = api.addEndpoints({
  'GET /': 'src/myHandler.ts',
});

// The NodejsFunction that was created for `GET /`
const getLambda = newEndpoints['GET /'];

Attempting to access endpoints which aren't in the newly added endpoints will cause Typescript errors

const newEndpoints = api.addEndpoints({
  'GET /': 'src/myHandler.ts',
});

// Typescript is happy as this endpoint was registered above
newEndpoints['GET /'];

// Typescript error as `GET /non-existent` does not exist above
newEndpoints['GET /non-existent'];

Custom permissions

Some lambdas require additional permissions (such as read / write to S3 buckets). You can manage those permissions by accessing the returned value from addEndpoints

const newEndpoints = api.addEndpoints({
  'GET /': 'src/myHandler.ts',
});

// Grants read access to `myBucket` for the lambda assigned to the `GET /` endpoint
myBucket.grantRead(newEndpoints['GET /']);

Usage

  • Requires CDK_ENV to be set when running cdk deploy or cdk synth, as this defines whether it's deploying to dev, prod, or a sandbox environment
# Deploy to sandbox
CDK_ENV=sandbox-me cdk deploy

# Deploy to dev
CDK_ENV=dev cdk deploy
import {
CorsHttpMethod,
DomainName,
HttpApi,
HttpApiProps,
HttpMethod,
HttpNoneAuthorizer,
} from 'aws-cdk-lib/aws-apigatewayv2';
import { Certificate } from 'aws-cdk-lib/aws-certificatemanager';
import { Construct } from 'constructs';
import {
NodejsFunction,
NodejsFunctionProps,
} from 'aws-cdk-lib/aws-lambda-nodejs';
import {
HttpLambdaIntegration,
HttpLambdaIntegrationProps,
} from 'aws-cdk-lib/aws-apigatewayv2-integrations';
import { LogGroup } from 'aws-cdk-lib/aws-logs';
import { CfnStage } from 'aws-cdk-lib/aws-apigatewayv2';
import { ServicePrincipal } from 'aws-cdk-lib/aws-iam';
import { ARecord, HostedZone, RecordTarget } from 'aws-cdk-lib/aws-route53';
import { ApiGatewayv2DomainProperties } from 'aws-cdk-lib/aws-route53-targets';
import { CfnOutput } from 'aws-cdk-lib';
import {
HttpLambdaAuthorizer,
HttpLambdaResponseType,
} from 'aws-cdk-lib/aws-apigatewayv2-authorizers';
import { getCdkEnv } from './env';
import { lambda } from './lambda';
type HttpEndpoint = `${HttpMethod} /${string}`;
type HttpEndpoints<TEndpoint extends HttpEndpoint = HttpEndpoint> = Record<
TEndpoint,
string | HttpEndpointOptions
>;
type HttpEndpointOptions = NodejsFunctionProps & {
integration?: HttpLambdaIntegrationProps;
publicEndpoint?: boolean;
};
/**
* A HTTP API backed with lambdas, using a `{stackId}.mydomain.com` domain or `mydomain.com` for production
*/
export class ApiConstruct extends Construct {
public readonly httpApi: HttpApi;
constructor(scope: Construct, id: string) {
super(scope, id);
const env = getCdkEnv();
// You'll have to create this auth handler yourself
const authorizeFn = lambda(this, 'authorize', {
entry: 'src/handlers/http/auth/authorize.ts',
});
const authorizer = new HttpLambdaAuthorizer('authorizer', authorizeFn, {
responseTypes: [HttpLambdaResponseType.SIMPLE],
});
const httpApiProps: HttpApiProps = {
corsPreflight: {
allowHeaders: ['*'],
allowMethods: [CorsHttpMethod.ANY],
allowOrigins: [], // TODO: set your allowed origins here
},
defaultAuthorizer: authorizer,
};
if (env.dns) {
const domainName = env.dns.hostedZoneName; // May not be correct for your stack
const dn = new DomainName(this, `${id}-dn`, {
domainName,
certificate: Certificate.fromCertificateArn(
this,
`${id}-cert`,
env.dns.certificateArn
),
});
this.httpApi = new HttpApi(this, `${id}-api`, {
...httpApiProps,
defaultDomainMapping: {
domainName: dn,
},
});
const hz = HostedZone.fromHostedZoneAttributes(this, `${id}-hz`, {
hostedZoneId: env.dns.hostedZoneId,
zoneName: env.dns.hostedZoneName,
});
new ARecord(this, `${id}-arecord`, {
target: RecordTarget.fromAlias(
new ApiGatewayv2DomainProperties(
dn.regionalDomainName,
dn.regionalHostedZoneId
)
),
zone: hz,
});
} else {
this.httpApi = new HttpApi(this, `${id}-api`, httpApiProps);
new CfnOutput(this, `${id}-endpoint`, {
value: this.httpApi.apiEndpoint,
});
}
}
/**
* Adds a map of endpoints + handlers to the API, with a range of default options
* @example
* ```ts
* // Adds a single "Hello world" endpoint to "/", with a `GET` HTTP verb, running on X86 CPU architecture
* addEndpointsWithDefaults({
* "GET /": "src/helloWorld.ts"
* }, {
* architecture: Architecture.X86_64
* })
*
* // Adds a single "Hello world" endpoint to "/", with a `GET` HTTP verb, running on ARM CPU architecture
* addEndpointsWithDefaults({
* "GET /": {
* entry: "src/helloWorld.ts",
* architecture: Architecture.ARM_64,
* }
* }, {
* architecture: Architecture.X86_64
* })
* ```
*/
addEndpointsWithDefaults<TEndpoint extends HttpEndpoint>(
endpoints: HttpEndpoints<TEndpoint>,
defaultOptions: Partial<HttpEndpointOptions>
) {
const mapped = Object.fromEntries(
Object.entries(endpoints).map(([endpointKey, props]) => {
const handlerProps: HttpEndpointOptions =
typeof props === 'string'
? // Map to default options
{
entry: props,
}
: (props as HttpEndpointOptions);
const fn = this.addEndpoint(endpointKey as HttpEndpoint, {
...defaultOptions,
...handlerProps,
});
return [endpointKey, fn];
})
);
return mapped as Record<TEndpoint, NodejsFunction>;
}
/**
* Adds a map of endpoints + handlers to the API, authenticated by default
* @example
* ```ts
* // Adds a single "Hello world" endpoint to "/", with a `GET` HTTP verb
* addEndpoints({
* "GET /": "src/helloWorld.ts"
* })
*
* // Adds a single "Hello world" endpoint to "/", with a `GET` HTTP verb, running on X64 CPU architecture
* addEndpoints({
* "GET /": {
* entry: "src/helloWorld.ts",
* architecture: Architecture.X86_64,
* }
* })
* ```
*/
addEndpoints<TEndpoint extends HttpEndpoint>(endpoints: HttpEndpoints<TEndpoint>) {
return this.addEndpointsWithDefaults(endpoints, {});
}
/**
* Adds a map of unauthenticated endpoints + handlers to the API
* @example
* ```ts
* // Adds a single unauthenticated "Hello world" endpoint to "/", with a `GET` HTTP verb
* addPublicEndpoints({
* "GET /": "src/helloWorld.ts"
* })
*
* // Adds a single unauthenticated "Hello world" endpoint to "/", with a `GET` HTTP verb, running on X64 CPU architecture
* addPublicEndpoints({
* "GET /": {
* entry: "src/helloWorld.ts",
* architecture: Architecture.X86_64,
* }
* })
* ```
*/
addPublicEndpoints<TEndpoints extends HttpEndpoint>(
endpoints: Record<
TEndpoints,
string | Omit<HttpEndpointOptions, 'publicEndpoint'>
>
) {
return this.addEndpointsWithDefaults(endpoints, {
publicEndpoint: true,
});
}
/**
* Adds an endpoint + handler to the API, authenticated by default
* @example
* ```ts
* // Adds a single "Hello world" endpoint to "/", with a `GET` HTTP verb
* addEndpoint("GET /", "src/helloWorld.ts")
*
* // Adds a single "Hello world" endpoint to "/", with a `GET` HTTP verb, running on X64 CPU architecture
* addEndpoint("GET /", {
* entry: "src/helloWorld.ts",
* architecture: Architecture.X86_64,
* })
* ```
*/
addEndpoint(endpoint: HttpEndpoint, props: HttpEndpointOptions) {
const newLambda = lambda(this, `${endpoint}-fn`, props);
const integration = new HttpLambdaIntegration(
`${endpoint}-integration`,
newLambda,
props.integration
);
const endpointParts = endpoint.split(' ');
if (endpointParts.length !== 2) {
throw new Error(`Invalid endpoint key ${endpoint}`);
}
const [httpMethod, endpointPath] = endpointParts;
this.httpApi.addRoutes({
integration,
path: endpointPath,
authorizer: props.publicEndpoint ? new HttpNoneAuthorizer() : undefined,
methods: [httpMethod as HttpMethod],
});
return newLambda;
}
}
export type StackIdSandbox = `sandbox-${string}`;
export type StackId = 'dev' | 'prod' | StackIdSandbox;
export type AwsAccountName = 'dev' | 'prod';
export const isSandboxStack = (stackId: StackId): stackId is StackIdSandbox => {
return stackId !== 'dev' && stackId !== 'prod';
};
export const AWS_REGION = 'eu-west-1';
export type CdkEnv = ReturnType<typeof getCdkEnv>;
export const getCdkEnv = () => {
const stackId = process.env.CDK_ENV as StackId;
if (!stackId) {
throw new Error(
'CDK_ENV is missing. Make sure that the CDK_ENV environment variable is set'
);
}
const awsRegion = AWS_REGION;
const awsAccountId = useEnvValue(stackId, {
dev: '...',
prod: '...',
});
return {
stackId,
dns: isSandboxStack(stackId)
? undefined
: {
certificateArn: useEnvValue(stackId, {
dev: `...`,
prod: `...`,
}),
hostedZoneId: useEnvValue(stackId, {
dev: '...',
prod: '...',
}),
hostedZoneName: useEnvValue(stackId, {
dev: 'dev.mydomain.com',
prod: 'mydomain.com',
}),
},
};
};
const useEnvValue = <T>(
envId: StackId,
values: Record<AwsAccountName, T> & { sandbox?: (id: string) => T }
) => {
if (isSandboxStack(envId)) {
if (values.sandbox) {
return values.sandbox(envId);
} else {
return values.dev;
}
}
return values[envId];
};
import { Runtime, Tracing, Architecture } from 'aws-cdk-lib/aws-lambda';
import {
NodejsFunction,
NodejsFunctionProps,
} from 'aws-cdk-lib/aws-lambda-nodejs';
import { Construct } from 'constructs';
import { getCdkEnv } from './env';
import { Duration } from 'aws-cdk-lib';
export const lambda = (
scope: Construct,
id: string,
options: Partial<NodejsFunctionProps>
) =>
new NodejsFunction(scope, id, {
runtime: Runtime.NODEJS_20_X,
memorySize: 256,
handler: 'handler',
timeout: Duration.seconds(30),
bundling: {
minify: true,
},
tracing: Tracing.ACTIVE,
architecture: Architecture.ARM_64,
...options,
environment: {
AWS_STACK_ID: getCdkEnv().stackId,
...(options.environment ?? {}),
},
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment