From 806b884f51684fa4654d357fafdf8ebeda4de01b Mon Sep 17 00:00:00 2001 From: Alexander Schueren Date: Thu, 8 Aug 2024 10:49:02 +0200 Subject: [PATCH] feat(parser): add helper function to handle JSON stringified fields (#2901) --- docs/core/event-handler/api-gateway.md | 4 +- docs/utilities/parser.md | 28 +++ .../snippets/parser/exampleAlbPayload.json | 28 +++ .../snippets/parser/exampleSqsPayload.json | 43 +++++ examples/snippets/parser/extendAlbSchema.ts | 12 ++ examples/snippets/parser/extendSqsSchema.ts | 19 +++ packages/parser/package.json | 22 ++- packages/parser/src/helpers.ts | 41 +++++ packages/parser/tests/unit/helpers.test.ts | 161 ++++++++++++++++++ 9 files changed, 353 insertions(+), 5 deletions(-) create mode 100644 examples/snippets/parser/exampleAlbPayload.json create mode 100644 examples/snippets/parser/exampleSqsPayload.json create mode 100644 examples/snippets/parser/extendAlbSchema.ts create mode 100644 examples/snippets/parser/extendSqsSchema.ts create mode 100644 packages/parser/src/helpers.ts create mode 100644 packages/parser/tests/unit/helpers.test.ts diff --git a/docs/core/event-handler/api-gateway.md b/docs/core/event-handler/api-gateway.md index ae4a753865..c714ec550c 100644 --- a/docs/core/event-handler/api-gateway.md +++ b/docs/core/event-handler/api-gateway.md @@ -40,13 +40,13 @@ This is the sample infrastructure for API Gateway and Lambda Function URLs we ar === "API Gateway SAM Template" ```yaml title="AWS Serverless Application Model (SAM) example" - --8<-- "examples/snippets/event-handler/rest/templates/template.yaml" +[//]: # ( --8<-- "examples/snippets/event-handler/rest/templates/template.yaml") ``` === "Lambda Function URL SAM Template" ```yaml title="AWS Serverless Application Model (SAM) example" - --8<-- "examples/event_handler_lambda_function_url/sam/template.yaml" +[//]: # ( --8<-- "examples/event_handler_lambda_function_url/sam/template.yaml") ``` diff --git a/docs/utilities/parser.md b/docs/utilities/parser.md index d8e836184d..ef642cb5ff 100644 --- a/docs/utilities/parser.md +++ b/docs/utilities/parser.md @@ -109,6 +109,34 @@ You can extend every built-in schema to include your own schema, and yet have al --8<-- "examples/snippets/parser/examplePayload.json" ``` +If you want to extend a schema and transform a JSON stringified payload to an object, you can use helper function `JSONStringified`: + +=== "AlbSchema with JSONStringified" + ```typescript hl_lines="12" + --8<-- "examples/snippets/parser/extendAlbSchema.ts" + ``` + + 1. Extend built-in `AlbSchema` using JSONStringified function to transform your payload + +=== "Alb exmaple payload" + + ```json hl_lines="26" + --8<-- "examples/snippets/parser/exampleAlbPayload.json" + ``` + +=== "SQS Schema with JSONStringified" + ```typescript hl_lines="23-25 30 34" + --8<-- "examples/snippets/parser/extendSqsSchema.ts" + ``` + + 1. make sure to set your schema to the correct key in the JSON payload + +=== "SQS exmaple payload" + + ```json hl_lines="6 28" + --8<-- "examples/snippets/parser/exampleSqsPayload.json" + ``` + ## Envelopes When trying to parse your payload you might encounter the following situations: diff --git a/examples/snippets/parser/exampleAlbPayload.json b/examples/snippets/parser/exampleAlbPayload.json new file mode 100644 index 0000000000..9e2e5e78e4 --- /dev/null +++ b/examples/snippets/parser/exampleAlbPayload.json @@ -0,0 +1,28 @@ +{ + "requestContext": { + "elb": { + "targetGroupArn": "arn:aws:elasticloadbalancing:us-east-2:123456789012:targetgroup/lambda-279XGJDqGZ5rsrHC2Fjr/49e9d65c45c6791a" + } + }, + "httpMethod": "GET", + "path": "/lambda", + "queryStringParameters": { + "query": "1234ABCD" + }, + "headers": { + "accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8", + "accept-encoding": "gzip", + "accept-language": "en-US,en;q=0.9", + "connection": "keep-alive", + "host": "lambda-alb-123578498.us-east-2.elb.amazonaws.com", + "upgrade-insecure-requests": "1", + "user-agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/71.0.3578.98 Safari/537.36", + "x-amzn-trace-id": "Root=1-5c536348-3d683b8b04734faae651f476", + "x-forwarded-for": "72.12.164.125", + "x-forwarded-port": "80", + "x-forwarded-proto": "http", + "x-imforwards": "20" + }, + "body": "{\"name\":\"Walter\", \"age\": 50}", + "isBase64Encoded": false +} diff --git a/examples/snippets/parser/exampleSqsPayload.json b/examples/snippets/parser/exampleSqsPayload.json new file mode 100644 index 0000000000..aafd798b07 --- /dev/null +++ b/examples/snippets/parser/exampleSqsPayload.json @@ -0,0 +1,43 @@ +{ + "Records": [ + { + "messageId": "059f36b4-87a3-44ab-83d2-661975830a7d", + "receiptHandle": "AQEBwJnKyrHigUMZj6rYigCgxlaS3SLy0a...", + "body": "{\"name\": \"John Doe\", \"age\": 30}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082649183", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082649185" + }, + "messageAttributes": { + "testAttr": { + "stringValue": "100", + "binaryValue": "base64Str", + "dataType": "Number" + } + }, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + }, + { + "messageId": "2e1424d4-f796-459a-8184-9c92662be6da", + "receiptHandle": "AQEBzWwaftRI0KuVm4tP+/7q1rGgNqicHq...", + "body": "{\"name\": \"foo\", \"age\": 10}", + "attributes": { + "ApproximateReceiveCount": "1", + "SentTimestamp": "1545082650636", + "SenderId": "AIDAIENQZJOLO23YVJ4VO", + "ApproximateFirstReceiveTimestamp": "1545082650649", + "DeadLetterQueueSourceArn": "arn:aws:sqs:us-east-2:123456789012:my-queue-dead" + }, + "messageAttributes": {}, + "md5OfBody": "e4e68fb7bd0e697a0ae8f1bb342846b3", + "eventSource": "aws:sqs", + "eventSourceARN": "arn:aws:sqs:us-east-2:123456789012:my-queue", + "awsRegion": "us-east-2" + } + ] +} diff --git a/examples/snippets/parser/extendAlbSchema.ts b/examples/snippets/parser/extendAlbSchema.ts new file mode 100644 index 0000000000..8e3a62ce42 --- /dev/null +++ b/examples/snippets/parser/extendAlbSchema.ts @@ -0,0 +1,12 @@ +import { JSONStringified } from '@aws-lambda-powertools/parser/helpers'; +import { AlbSchema } from '@aws-lambda-powertools/parser/schemas'; +import { z } from 'zod'; + +const customSchema = z.object({ + name: z.string(), + age: z.number(), +}); + +const extendedSchema = AlbSchema.extend({ + body: JSONStringified(customSchema), // (1)! +}); diff --git a/examples/snippets/parser/extendSqsSchema.ts b/examples/snippets/parser/extendSqsSchema.ts new file mode 100644 index 0000000000..f9a6ce11ac --- /dev/null +++ b/examples/snippets/parser/extendSqsSchema.ts @@ -0,0 +1,19 @@ +import { JSONStringified } from '@aws-lambda-powertools/parser/helpers'; +import { + SqsRecordSchema, + SqsSchema, +} from '@aws-lambda-powertools/parser/schemas'; +import { z } from 'zod'; + +const customSchema = z.object({ + name: z.string(), + age: z.number(), +}); + +const extendedSchema = SqsSchema.extend({ + Records: z.array( + SqsRecordSchema.extend({ + body: JSONStringified(customSchema), // (1)! + }) + ), +}); diff --git a/packages/parser/package.json b/packages/parser/package.json index b22da3977c..d70c1a11d4 100644 --- a/packages/parser/package.json +++ b/packages/parser/package.json @@ -167,6 +167,10 @@ "require": "./lib/cjs/envelopes/vpc-latticev2.js", "import": "./lib/esm/envelopes/vpc-latticev2.js" }, + "./helpers": { + "require": "./lib/cjs/helpers.js", + "import": "./lib/esm/helpers.js" + }, "./types": { "require": "./lib/cjs/types/index.js", "import": "./lib/esm/types/index.js" @@ -174,7 +178,10 @@ }, "typesVersions": { "*": { - "types": ["./lib/cjs/types/index.d.ts", "./lib/esm/types/index.d.ts"], + "types": [ + "./lib/cjs/types/index.d.ts", + "./lib/esm/types/index.d.ts" + ], "middleware": [ "./lib/cjs/middleware/parser.d.ts", "./lib/esm/middleware/parser.d.ts" @@ -227,7 +234,10 @@ "./lib/cjs/schemas/lambda.d.ts", "./lib/esm/schemas/lambda.d.ts" ], - "schemas/s3": ["./lib/cjs/schemas/s3.d.ts", "./lib/esm/schemas/s3.d.ts"], + "schemas/s3": [ + "./lib/cjs/schemas/s3.d.ts", + "./lib/esm/schemas/s3.d.ts" + ], "schemas/ses": [ "./lib/cjs/schemas/ses.d.ts", "./lib/esm/schemas/ses.d.ts" @@ -303,12 +313,18 @@ "envelopes/vpc-latticev2": [ "./lib/cjs/envelopes/vpc-latticev2.d.ts", "./lib/esm/envelopes/vpc-latticev2.d.ts" + ], + "helpers": [ + "./lib/cjs/helpers.d.ts", + "./lib/esm/helpers.d.ts" ] } }, "main": "./lib/cjs/index.js", "types": "./lib/cjs/index.d.ts", - "files": ["lib"], + "files": [ + "lib" + ], "repository": { "type": "git", "url": "git+https://github.com/aws-powertools/powertools-lambda-typescript.git" diff --git a/packages/parser/src/helpers.ts b/packages/parser/src/helpers.ts new file mode 100644 index 0000000000..4c7390155e --- /dev/null +++ b/packages/parser/src/helpers.ts @@ -0,0 +1,41 @@ +import { type ZodSchema, z } from 'zod'; + +/** + * A helper function to parse a JSON string and validate it against a schema. + * Use it for built-in schemas like `AlbSchema`, `ApiGatewaySchema`, etc. to extend them with your customer schema. + * + * @example + * ```typescript + * import { JSONStringified } from '@aws-lambda-powertools/parser/helpers'; + * import { AlbSchema } from '@aws-lambda-powertools/parser/schemas'; + * import { z } from 'zod'; + * + * const customSchema = z.object({ + * name: z.string(), + * age: z.number(), + * }); + * + * const extendedSchema = AlbSchema.extend({ + * body: JSONStringified(customSchema), + * }); + * + * ``` + * + * @param schema - The schema to validate the JSON string against + */ +const JSONStringified = (schema: ZodSchema) => + z + .string() + .transform((str, ctx) => { + try { + return JSON.parse(str); + } catch (err) { + ctx.addIssue({ + code: 'custom', + message: 'Invalid JSON', + }); + } + }) + .pipe(schema); + +export { JSONStringified }; diff --git a/packages/parser/tests/unit/helpers.test.ts b/packages/parser/tests/unit/helpers.test.ts new file mode 100644 index 0000000000..bf763d03d3 --- /dev/null +++ b/packages/parser/tests/unit/helpers.test.ts @@ -0,0 +1,161 @@ +/** + * Test decorator parser + * + * @group unit/parser + */ + +import { z } from 'zod'; +import { JSONStringified } from '../../src/helpers.js'; +import { + AlbSchema, + SnsNotificationSchema, + SnsRecordSchema, + SqsRecordSchema, + SqsSchema, +} from '../../src/schemas'; +import type { SnsEvent, SqsEvent } from '../../src/types'; +import { getTestEvent } from './schema/utils'; + +describe('JSONStringified', () => { + const schema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + }); + const baseSchema = z.object({ + body: z.string(), + }); + it('should return a valid JSON', () => { + const data = { + body: JSON.stringify({ + id: 1, + name: 'John Doe', + email: 'foo@example.com', + }), + }; + + const extendedSchema = baseSchema.extend({ + body: JSONStringified(schema), + }); + + const result = extendedSchema.parse(data); + expect(result).toEqual({ + body: { id: 1, name: 'John Doe', email: 'foo@example.com' }, + }); + }); + + it('should throw an error if the JSON payload is invalid', () => { + const data = { + body: JSON.stringify({ + id: 1, + name: 'John Doe', + email: 'foo', + }), + }; + + const extendedSchema = baseSchema.extend({ + body: JSONStringified(schema), + }); + + expect(() => extendedSchema.parse(data)).toThrow(); + }); + + it('should throw an error if the JSON is malformed', () => { + const data = { + body: 'invalid', + }; + + const extendedSchema = baseSchema.extend({ + body: JSONStringified(schema), + }); + + expect(() => extendedSchema.parse(data)).toThrow(); + }); + + describe('should parse common built-in schemas', () => { + const customSchema = z.object({ + id: z.number(), + name: z.string(), + email: z.string().email(), + }); + + const payload = { + id: 1, + name: 'John Doe', + email: 'foo@bar.baz', + }; + + it('should parse extended AlbSchema', () => { + const extendedSchema = AlbSchema.extend({ + body: JSONStringified(customSchema), + }); + + const testEvent = getTestEvent({ + eventsPath: '.', + filename: 'albEvent', + }); + testEvent.body = JSON.stringify(payload); + + const result = extendedSchema.parse(testEvent); + expect(result).toEqual({ + ...testEvent, + body: payload, + }); + }); + + it('should parse extended SqsSchema', () => { + const extendedSchema = SqsSchema.extend({ + Records: z.array( + SqsRecordSchema.extend({ + body: JSONStringified(customSchema), + }) + ), + }); + + const testEvent = getTestEvent({ + eventsPath: '.', + filename: 'sqsEvent', + }); + testEvent.Records[0].body = JSON.stringify(payload); + testEvent.Records[1].body = JSON.stringify(payload); + + const result = extendedSchema.parse(testEvent); + expect(result).toEqual({ + ...testEvent, + Records: [ + { ...testEvent.Records[0], body: payload }, + { ...testEvent.Records[1], body: payload }, + ], + }); + }); + + it('should parse extended SnsSchema', () => { + const extendedSchema = SqsSchema.extend({ + Records: z.array( + SnsRecordSchema.extend({ + Sns: SnsNotificationSchema.extend({ + Message: JSONStringified(customSchema), + }), + }) + ), + }); + + const testEvent = getTestEvent({ + eventsPath: '.', + filename: 'snsEvent', + }); + testEvent.Records[0].Sns.Message = JSON.stringify(payload); + + const result = extendedSchema.parse(testEvent); + expect(result).toEqual({ + ...testEvent, + Records: [ + { + ...testEvent.Records[0], + Sns: { ...testEvent.Records[0].Sns, Message: payload }, + }, + ], + }); + }); + }); +});