Skip to content

Commit

Permalink
feat: add support for API Gateway API Keys
Browse files Browse the repository at this point in the history
feat: add support for API Gateway Schema validation
  • Loading branch information
Inqnuam committed Jun 30, 2024
1 parent bc1bf22 commit b495223
Show file tree
Hide file tree
Showing 11 changed files with 1,146 additions and 960 deletions.
2 changes: 1 addition & 1 deletion build.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const compileDeclarations = () => {
console.log(error.output?.[1]?.toString());
}
};
const external = ["esbuild", "archiver", "serve-static", "@smithy/eventstream-codec", "local-aws-sqs", "@aws-sdk/client-sqs"];
const external = ["esbuild", "archiver", "serve-static", "@smithy/eventstream-codec", "local-aws-sqs", "@aws-sdk/client-sqs", "ajv", "ajv-formats"];
const watchPlugin = {
name: "watch-plugin",
setup: (build) => {
Expand Down
4 changes: 3 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "serverless-aws-lambda",
"version": "4.7.1",
"version": "4.8.0",
"description": "AWS Application Load Balancer and API Gateway - Lambda dev tool for Serverless. Allows Express synthax in handlers. Supports packaging, local invoking and offline ALB, APG, S3, SNS, SQS, DynamoDB Stream server mocking.",
"author": "Inqnuam",
"license": "MIT",
Expand Down Expand Up @@ -58,6 +58,8 @@
"@aws-sdk/client-sqs": ">=3",
"@smithy/eventstream-codec": "^2.2.0",
"@types/serverless": "^3.12.22",
"ajv": "^8.16.0",
"ajv-formats": "^3.0.1",
"archiver": "^5.3.1",
"esbuild": "0.21.5",
"local-aws-sqs": "^1.0.1",
Expand Down
9 changes: 6 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -293,6 +293,10 @@ class ServerlessAwsLambda extends Daemon {
// @ts-ignore
x.setEnv("AWS_LAMBDA_RUNTIME_API", `127.0.0.1:${port}`);
});

// @ts-ignore
this.setApiKeys(this.serverless.service.provider.apiGateway?.apiKeys);

console.log(`\x1b[32m${output}\x1b[0m`);
});
}
Expand All @@ -314,8 +318,7 @@ class ServerlessAwsLambda extends Daemon {
} catch (error) {
console.error(error);
}

log.GREEN(`${new Date().toLocaleString()} 🔄✅ Rebuild `);
console.log(`\x1b[32m${new Date().toLocaleString()} 🔄✅ Rebuild\x1b[0m`);
process.send?.({ rebuild: true });
}
}
Expand Down Expand Up @@ -432,7 +435,7 @@ class ServerlessAwsLambda extends Daemon {
const { payload } = slsDeclaration.httpApi;
httpApiPayload = payload == "1.0" ? 1 : payload == "2.0" ? 2 : defaultHttpApiPayload;
}
const { endpoints, sns, sqs, ddb, s3, kinesis, documentDb } = parseEvents(lambda.events, Outputs, this.resources, httpApiPayload, provider);
const { endpoints, sns, sqs, ddb, s3, kinesis, documentDb } = parseEvents({ events: lambda.events, Outputs, resources: this.resources, httpApiPayload, provider });
lambdaDef.endpoints = endpoints;
lambdaDef.sns = sns;
lambdaDef.sqs = sqs;
Expand Down
46 changes: 36 additions & 10 deletions src/lib/parseEvents/endpoints.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { HttpMethod } from "../server/handlers";
import { log } from "../utils/colorize";
import { compileAjvSchema } from "../utils/compileAjvSchema";

const pathPartsRegex = /^(\{[\w.:-]+\+?\}|[a-zA-Z0-9.:_-]+)$/;

Expand Down Expand Up @@ -27,9 +28,10 @@ export interface LambdaEndpoint {
requestPaths?: string[];
stream?: boolean;
private?: boolean;
schema?: any;
}
const supportedEvents = ["http", "httpApi", "alb"];
export const parseEndpoints = (event: any, httpApiPayload: LambdaEndpoint["version"]): LambdaEndpoint | null => {
export const parseEndpoints = (event: any, httpApiPayload: LambdaEndpoint["version"], provider: Record<string, any>): LambdaEndpoint | null => {
const keys = Object.keys(event);

if (!keys.length || !supportedEvents.includes(keys[0])) {
Expand Down Expand Up @@ -113,19 +115,43 @@ export const parseEndpoints = (event: any, httpApiPayload: LambdaEndpoint["versi
if (event.http.async) {
parsendEvent.async = event.http.async;
}
if (event.http.request?.parameters) {
const { headers, querystrings, paths } = event.http.request.parameters;
if (headers) {
parsendEvent.headers = Object.keys(headers).filter((x) => headers[x]);
}
if (querystrings) {
parsendEvent.querystrings = Object.keys(querystrings).filter((x) => querystrings[x]);

if (event.http.request) {
const request = event.http.request;
if (request.parameters) {
const { headers, querystrings, paths } = request.parameters;
if (headers) {
parsendEvent.headers = Object.keys(headers).filter((x) => headers[x]);
}
if (querystrings) {
parsendEvent.querystrings = Object.keys(querystrings).filter((x) => querystrings[x]);
}

if (paths) {
parsendEvent.requestPaths = Object.keys(paths).filter((x) => paths[x]);
}
}

if (paths) {
parsendEvent.requestPaths = Object.keys(paths).filter((x) => paths[x]);
if (request.schemas) {
let jsonReqTypeSchema = request.schemas["application/json"] ?? request.schemas["application/json; charset=utf-8"];
if (jsonReqTypeSchema) {
if (typeof jsonReqTypeSchema == "string") {
const schema = provider.apiGateway?.request?.schemas?.[jsonReqTypeSchema]?.schema;
if (!schema) {
throw new Error(`Can not find JSON Schema "${jsonReqTypeSchema}"`);
}
jsonReqTypeSchema = schema;
} else if (typeof jsonReqTypeSchema == "object" && typeof jsonReqTypeSchema.schema == "object") {
jsonReqTypeSchema = jsonReqTypeSchema.schema;
}

parsendEvent.schema = compileAjvSchema(jsonReqTypeSchema);
} else {
console.warn("Unsupported schema validator definition:", request.schemas);
}
}
}

if (event.http.private) {
parsendEvent.private = true;
}
Expand Down
16 changes: 14 additions & 2 deletions src/lib/parseEvents/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,19 @@ export interface IDestination {
kind: "lambda" | "sns" | "sqs";
name: string;
}
export const parseEvents = (events: any[], Outputs: any, resources: any, httpApiPayload: LambdaEndpoint["version"], provider: Record<string, any>) => {
export const parseEvents = ({
events,
Outputs,
resources,
httpApiPayload,
provider,
}: {
events: any[];
Outputs: any;
resources: any;
httpApiPayload: LambdaEndpoint["version"];
provider: Record<string, any>;
}) => {
const endpoints: LambdaEndpoint[] = [];
const sns: any[] = [];
const sqs: any[] = [];
Expand All @@ -22,7 +34,7 @@ export const parseEvents = (events: any[], Outputs: any, resources: any, httpApi
const kinesis: any[] = [];
const documentDb: any[] = [];
for (const event of events) {
const slsEvent = parseEndpoints(event, httpApiPayload);
const slsEvent = parseEndpoints(event, httpApiPayload, provider);
const snsEvent = parseSns(Outputs, resources, event);
const sqsEvent = parseSqs(Outputs, resources, event);
const ddbStream = parseDdbStreamDefinitions(Outputs, resources, event);
Expand Down
47 changes: 41 additions & 6 deletions src/lib/server/daemon.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,44 @@ export class Daemon extends Handlers {
stop(cb?: (err?: any) => void) {
this.#server.close(cb);
}

#keys: string[] = [];
#getKeys(keys: any[], genereatedKeys: Record<string, string>) {
for (const k of keys) {
if (typeof k == "string") {
const value = Buffer.from(randomUUID(), "utf-8").toString("base64");
this.#keys.push(value);
genereatedKeys[k] = value;
} else if (k && typeof k == "object" && !Array.isArray(k)) {
if (typeof k.value == "string") {
this.#keys.push(k.value);
} else {
for (const keysWithUsagePlan of Object.values(k)) {
if (Array.isArray(keysWithUsagePlan)) {
this.#getKeys(keysWithUsagePlan, genereatedKeys);
}
}
}
}
}
}
setApiKeys(keys?: any[]) {
if (!keys) {
return;
}

const genereatedKeys: Record<string, string> = {};
this.#getKeys(keys, genereatedKeys);

if (Object.keys(genereatedKeys).length) {
console.log("\n\x1b[90mREST API Gateway generated API Keys:\x1b[0m");

for (const [key, value] of Object.entries(genereatedKeys)) {
console.log(`\x1b[35m${key}: \x1b[0m\x1b[36m${value}\x1b[0m`);
}
}
}

constructor(config: IDaemonConfig = { debug: false }) {
super(config);
log.setDebug(config.debug);
Expand Down Expand Up @@ -119,10 +157,7 @@ export class Daemon extends Handlers {
Handlers.ip = localIp;
}
if (typeof callback == "function") {
callback(listeningPort, localIp);
} else {
let output = `✅ AWS Lambda offline server is listening on http://localhost:${listeningPort} | http://${Handlers.ip}:${listeningPort}`;
console.log(`\x1b[32m${output}\x1b[0m`);
await callback(listeningPort, localIp);
}
try {
await this.onReady?.(listeningPort, Handlers.ip);
Expand Down Expand Up @@ -184,7 +219,7 @@ export class Daemon extends Handlers {
console.error(err.stack);
});

const foundLambda = await defaultServer(req, res, parsedURL);
const foundLambda = await defaultServer(req, res, parsedURL, this.#keys);

const notFound = () => {
res.setHeader("Content-Type", "text/html");
Expand All @@ -210,7 +245,7 @@ export class Daemon extends Handlers {
console.log(error);
}
}
log.GREEN(`${new Date().toLocaleString()} 🔄✅ Rebuild`);
console.log(`\x1b[32m${new Date().toLocaleString()} 🔄✅ Rebuild\x1b[0m`);
process.send?.({ rebuild: true });
};
async load(lambdaDefinitions: ILambdaMock[]) {
Expand Down
114 changes: 114 additions & 0 deletions src/lib/utils/compileAjvSchema.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import Ajv from "ajv";
import ajvFormats from "ajv-formats";

// As Serverless already uses AJV, we use it too to avoid installing and loading other libs
// AJV@8 supports Draft 07 by default, APiG is only Draft 04 compatble and with some limitations.
// https://docs.aws.amazon.com/apigateway/latest/developerguide/api-gateway-known-issues.html#api-gateway-known-issues-rest-apis

const ajv = new Ajv({
discriminator: false,
useDefaults: false,
validateFormats: true,
jtd: false,
strictRequired: true,
allowUnionTypes: true,
unevaluated: false,
formats: {
"date-time": ajvFormats.get("date-time"),
email: ajvFormats.get("email"),
hostname: ajvFormats.get("hostname"),
ipv4: ajvFormats.get("ipv4"),
ipv6: ajvFormats.get("ipv6"),
uri: ajvFormats.get("uri"),
},
});

// Non APiG compatible keywords
ajv.removeKeyword("if");
ajv.removeKeyword("else");
ajv.removeKeyword("then");
ajv.removeKeyword("const");
ajv.removeKeyword("contains");
ajv.removeKeyword("contentEncoding");
ajv.removeKeyword("contentMediaType");
ajv.removeKeyword("contentSchema");
ajv.removeKeyword("nullable");
ajv.removeKeyword("prefixItems");
ajv.removeKeyword("example");
ajv.removeKeyword("examples");
ajv.removeKeyword("deprecated");
ajv.removeKeyword("readOnly");
ajv.removeKeyword("writeOnly");
ajv.removeKeyword("$comment");
ajv.removeKeyword("$defs");
ajv.removeKeyword("exclusiveMinimum");
ajv.removeKeyword("exclusiveMaximum");
ajv.removeKeyword("propertyNames");
ajv.removeKeyword("$id");
ajv.removeKeyword("id");

// some keywords can not be removed directly from AJV as they are used internally

ajv.addKeyword({
keyword: ["exclusiveMaximum", "exclusiveMinimum"],
type: ["number"],
schemaType: ["number"],
$data: true,
code(schema: any) {
if (schema.it.rootId == "http://json-schema.org/draft-07/schema#") {
return;
}

throw new Error(`unsupported keyword "${schema.keyword}"`);
},
});

ajv.addKeyword({
keyword: "propertyNames",
type: ["object"],
schemaType: ["object", "boolean"],
code(schema: any) {
if (schema.it.rootId == "http://json-schema.org/draft-07/schema#") {
return;
}

throw new Error(`unsupported keyword "propertyNames"`);
},
});

ajv.addKeyword({
keyword: "$id",
type: ["object"],
schemaType: ["string"],
code(schema: any) {
if (schema.it.rootId == "http://json-schema.org/draft-07/schema#") {
return;
}

throw new Error(`unsupported keyword "$id"`);
},
});

ajv.addKeyword({
keyword: "id",
schemaType: ["string"],
});

export const compileAjvSchema = (schema: any) => {
// $schema value is ignored by AWS API Gateway
// AJV will throw an error if $schema is defined and is not draft-07 (default)

if (schema && typeof schema == "object" && !Array.isArray(schema)) {
let schemaAsDraft7;

if (schema.$schema) {
schemaAsDraft7 = { ...schema, $schema: "http://json-schema.org/draft-07/schema#" };
} else {
schemaAsDraft7 = schema;
}

return ajv.compile(schemaAsDraft7);
} else {
throw new Error("API Gateway validator Schema must be an object");
}
};
Loading

0 comments on commit b495223

Please sign in to comment.