diff --git a/.changeset/blue-carpets-create.md b/.changeset/blue-carpets-create.md new file mode 100644 index 0000000000..a845151cc8 --- /dev/null +++ b/.changeset/blue-carpets-create.md @@ -0,0 +1,2 @@ +--- +--- diff --git a/.changeset/lucky-terms-vanish.md b/.changeset/lucky-terms-vanish.md new file mode 100644 index 0000000000..5717355dad --- /dev/null +++ b/.changeset/lucky-terms-vanish.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/rest-api-construct': major +--- + +This construct enables developers to configure API Gateway REST endpoints with Lambda integration and path/method-level auth (including Cognito User Pools and user groups). Supports route definition via a simple props interface. diff --git a/.changeset/moody-bushes-heal.md b/.changeset/moody-bushes-heal.md new file mode 100644 index 0000000000..5714891561 --- /dev/null +++ b/.changeset/moody-bushes-heal.md @@ -0,0 +1,5 @@ +--- +'@aws-amplify/rest-api-construct': patch +--- + +Adds error handling and a unit test for invalid REST API paths diff --git a/.eslint_dictionary.json b/.eslint_dictionary.json index fa02bbf53a..dabaa39584 100644 --- a/.eslint_dictionary.json +++ b/.eslint_dictionary.json @@ -18,6 +18,7 @@ "argv", "arn", "arns", + "authorizer", "aws", "backends", "birthdate", diff --git a/package-lock.json b/package-lock.json index 217d5ecf8f..7d163a1197 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12682,6 +12682,10 @@ "resolved": "packages/plugin-types", "link": true }, + "node_modules/@aws-amplify/rest-api-construct": { + "resolved": "packages/rest-api-construct", + "link": true + }, "node_modules/@aws-amplify/sandbox": { "resolved": "packages/sandbox", "link": true @@ -35046,7 +35050,6 @@ "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", "integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==", - "dev": true, "license": "MIT", "dependencies": { "sprintf-js": "~1.0.2" @@ -35807,7 +35810,6 @@ "version": "1.0.7", "resolved": "https://registry.npmjs.org/aws-lambda/-/aws-lambda-1.0.7.tgz", "integrity": "sha512-9GNFMRrEMG5y3Jvv+V4azWvc+qNWdWLTjDdhf/zgMlz8haaaLWv0xeAIWxz9PuWUBawsVxy0zZotjCdR3Xq+2w==", - "dev": true, "license": "MIT", "dependencies": { "aws-sdk": "^2.814.0", @@ -35823,14 +35825,12 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-3.0.2.tgz", "integrity": "sha512-Gar0ASD4BDyKC4hl4DwHqDrmvjoxWKZigVnAbn5H1owvm4CxCPdb0HQDehwNYMJpla5+M2tPmPARzhtYuwpHow==", - "dev": true, "license": "MIT" }, "node_modules/aws-sdk": { "version": "2.1692.0", "resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1692.0.tgz", "integrity": "sha512-x511uiJ/57FIsbgUe5csJ13k3uzu25uWQE+XqfBis/sB0SFoiElJWXRkgEAUh0U6n40eT3ay5Ue4oPkRMu1LYw==", - "dev": true, "hasInstallScript": true, "license": "Apache-2.0", "dependencies": { @@ -35865,7 +35865,6 @@ "version": "4.9.2", "resolved": "https://registry.npmjs.org/buffer/-/buffer-4.9.2.tgz", "integrity": "sha512-xq+q3SRMOxGivLhBNaUdC64hDTQwejJ+H0T/NB1XMtTVEwNTrfFF3gAxiyW0Bu/xWEGhjVKgUcMhCrUy2+uCWg==", - "dev": true, "license": "MIT", "dependencies": { "base64-js": "^1.0.2", @@ -35877,7 +35876,6 @@ "version": "1.1.1", "resolved": "https://registry.npmjs.org/events/-/events-1.1.1.tgz", "integrity": "sha512-kEcvvCBByWXGnZy6JUlgAp2gBIUjfCAV6P6TgT1/aaQKcmuAEC4OZTV1I4EWQLz2gxZw76atuVyvHhTxvi0Flw==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.x" @@ -35887,21 +35885,18 @@ "version": "1.1.13", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz", "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/aws-sdk/node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==", - "dev": true, "license": "MIT" }, "node_modules/aws-sdk/node_modules/uuid": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.0.0.tgz", "integrity": "sha512-jOXGuXZAWdsTH7eZLtyXMqUb9EcWMGZNbL9YcGBJl4MH4nrxHmZJhEHvyLFrkxo+28uLb/NYRcStH48fnD0Vzw==", - "dev": true, "license": "MIT", "bin": { "uuid": "dist/bin/uuid" @@ -38898,7 +38893,6 @@ "version": "4.0.1", "resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz", "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", - "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -39810,7 +39804,6 @@ "version": "0.4.1", "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", - "dev": true, "license": "BSD-2-Clause" }, "node_modules/globals": { @@ -40406,7 +40399,6 @@ "version": "1.2.0", "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz", "integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==", - "dev": true, "license": "MIT", "dependencies": { "call-bound": "^1.0.2", @@ -41193,7 +41185,6 @@ "version": "0.16.0", "resolved": "https://registry.npmjs.org/jmespath/-/jmespath-0.16.0.tgz", "integrity": "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.6.0" @@ -41218,7 +41209,6 @@ "version": "3.14.1", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.14.1.tgz", "integrity": "sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^1.0.7", @@ -44158,7 +44148,6 @@ "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.0.tgz", "integrity": "sha512-X/xY82scca2tau62i9mDyU9K+I+djTMUsvwf7xnUX5GLvVzgJybOJf4Y6o9Zx3oJK/LSXg5tTZBjwzqVPaPO2g==", "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", - "dev": true, "engines": { "node": ">=0.4.x" } @@ -44799,7 +44788,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/sax/-/sax-1.2.1.tgz", "integrity": "sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==", - "dev": true, "license": "ISC" }, "node_modules/scheduler": { @@ -45283,7 +45271,6 @@ "version": "1.0.3", "resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz", "integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==", - "dev": true, "license": "BSD-3-Clause" }, "node_modules/sqlstring": { @@ -46687,7 +46674,6 @@ "version": "0.10.3", "resolved": "https://registry.npmjs.org/url/-/url-0.10.3.tgz", "integrity": "sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ==", - "dev": true, "license": "MIT", "dependencies": { "punycode": "1.3.2", @@ -46698,7 +46684,6 @@ "version": "1.3.2", "resolved": "https://registry.npmjs.org/punycode/-/punycode-1.3.2.tgz", "integrity": "sha512-RofWgt/7fL5wP1Y7fxE7/EmTLzQVnB0ycyibJ0OOHIlJqTNzglYFxVwETOcIoJqJmpDXJ9xImDv+Fq34F/d4Dw==", - "dev": true, "license": "MIT" }, "node_modules/urlpattern-polyfill": { @@ -46711,7 +46696,6 @@ "version": "0.12.5", "resolved": "https://registry.npmjs.org/util/-/util-0.12.5.tgz", "integrity": "sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==", - "dev": true, "license": "MIT", "dependencies": { "inherits": "^2.0.3", @@ -46959,7 +46943,6 @@ "version": "2.4.2", "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", - "dev": true, "license": "MIT", "dependencies": { "glob-to-regexp": "^0.4.1", @@ -47177,7 +47160,6 @@ "version": "0.6.2", "resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz", "integrity": "sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==", - "dev": true, "license": "MIT", "dependencies": { "sax": ">=0.6.0", @@ -47191,7 +47173,6 @@ "version": "11.0.1", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz", "integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==", - "dev": true, "license": "MIT", "engines": { "node": ">=4.0" @@ -52699,6 +52680,21 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "packages/rest-api-construct": { + "name": "@aws-amplify/rest-api-construct", + "version": "1.0.0", + "license": "Apache-2.0", + "dependencies": { + "@aws-amplify/backend": "^1.16.1", + "@aws-amplify/backend-output-storage": "^1.3.1", + "@aws-amplify/platform-core": "^1.10.0", + "aws-lambda": "^1.0.7" + }, + "peerDependencies": { + "aws-cdk-lib": "^2.189.1", + "constructs": "^10.0.0" + } + }, "packages/sandbox": { "name": "@aws-amplify/sandbox", "version": "2.1.2", diff --git a/packages/create-amplify/src/default_packages.json b/packages/create-amplify/src/default_packages.json index 82f639268d..4502bc76d4 100644 --- a/packages/create-amplify/src/default_packages.json +++ b/packages/create-amplify/src/default_packages.json @@ -2,7 +2,7 @@ "defaultDevPackages": [ "@aws-amplify/backend", "@aws-amplify/backend-cli", - "aws-cdk-lib@2.207.0", + "aws-cdk-lib@2.204.0", "constructs@^10.0.0", "typescript@^5.0.0", "tsx", diff --git a/packages/rest-api-construct/.npmignore b/packages/rest-api-construct/.npmignore new file mode 100644 index 0000000000..dbde1fb5db --- /dev/null +++ b/packages/rest-api-construct/.npmignore @@ -0,0 +1,14 @@ +# Be very careful editing this file. It is crafted to work around [this issue](https://github.com/npm/npm/issues/4479) + +# First ignore everything +**/* + +# Then add back in transpiled js and ts declaration files +!lib/**/*.js +!lib/**/*.d.ts + +# Then ignore test js and ts declaration files +*.test.js +*.test.d.ts + +# This leaves us with including only js and ts declaration files of functional code diff --git a/packages/rest-api-construct/API.md b/packages/rest-api-construct/API.md new file mode 100644 index 0000000000..cd07064c1d --- /dev/null +++ b/packages/rest-api-construct/API.md @@ -0,0 +1,52 @@ +## API Report File for "@aws-amplify/rest-api-construct" + +> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/). + +```ts + +import * as apiGateway from 'aws-cdk-lib/aws-apigateway'; +import { Construct } from 'constructs'; +import { FunctionResources } from '@aws-amplify/backend/types/platform'; +import { IUserPool } from 'aws-cdk-lib/aws-cognito'; +import { ResourceProvider } from '@aws-amplify/backend/types/platform'; + +// @public (undocumented) +export type AuthorizerConfig = { + type: 'none'; +} | { + type: 'userPool'; +}; + +// @public (undocumented) +export type HttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS'; + +// @public (undocumented) +export type MethodsProps = { + method: HttpMethod; + authorizer?: AuthorizerConfig; +}; + +// @public (undocumented) +export class RestApiConstruct extends Construct { + constructor(scope: Construct, id: string, props: RestApiConstructProps, userPool?: IUserPool); + // (undocumented) + readonly api: apiGateway.RestApi; +} + +// @public (undocumented) +export type RestApiConstructProps = { + apiName: string; + apiProps: RestApiPathConfig[]; + defaultAuthorizer?: AuthorizerConfig; +}; + +// @public (undocumented) +export type RestApiPathConfig = { + path: string; + methods: MethodsProps[]; + lambdaEntry: ResourceProvider; +}; + +// (No @packageDocumentation comment for this package) + +``` diff --git a/packages/rest-api-construct/README.md b/packages/rest-api-construct/README.md new file mode 100644 index 0000000000..793417be04 --- /dev/null +++ b/packages/rest-api-construct/README.md @@ -0,0 +1,3 @@ +# Description + +Replace with a description of this package diff --git a/packages/rest-api-construct/api-extractor.json b/packages/rest-api-construct/api-extractor.json new file mode 100644 index 0000000000..0f56de03f6 --- /dev/null +++ b/packages/rest-api-construct/api-extractor.json @@ -0,0 +1,3 @@ +{ + "extends": "../../api-extractor.base.json" +} diff --git a/packages/rest-api-construct/package.json b/packages/rest-api-construct/package.json new file mode 100644 index 0000000000..e5839ca4dc --- /dev/null +++ b/packages/rest-api-construct/package.json @@ -0,0 +1,31 @@ +{ + "name": "@aws-amplify/rest-api-construct", + "version": "1.0.0", + "type": "module", + "main": "lib/index.js", + "publishConfig": { + "access": "public" + }, + "exports": { + ".": { + "types": "./lib/index.d.ts", + "import": "./lib/index.js", + "require": "./lib/index.js" + } + }, + "types": "lib/index.d.ts", + "scripts": { + "update:api": "api-extractor run --local" + }, + "license": "Apache-2.0", + "peerDependencies": { + "aws-cdk-lib": "^2.189.1", + "constructs": "^10.0.0" + }, + "dependencies": { + "@aws-amplify/backend": "^1.16.1", + "@aws-amplify/backend-output-storage": "^1.3.1", + "@aws-amplify/platform-core": "^1.10.0", + "aws-lambda": "^1.0.7" + } +} diff --git a/packages/rest-api-construct/src/construct.test.ts b/packages/rest-api-construct/src/construct.test.ts new file mode 100644 index 0000000000..2f44b40dc7 --- /dev/null +++ b/packages/rest-api-construct/src/construct.test.ts @@ -0,0 +1,268 @@ +import { describe, it } from 'node:test'; +import { RestApiConstruct } from './construct.js'; +import { App, Stack } from 'aws-cdk-lib'; +import { defineFunction } from '@aws-amplify/backend'; +import { StackMetadataBackendOutputStorageStrategy } from '@aws-amplify/backend-output-storage'; +import { + ConstructContainerStub, + ResourceNameValidatorStub, + StackResolverStub, +} from '@aws-amplify/backend-platform-test-stubs'; +import { ConstructFactoryGetInstanceProps } from '@aws-amplify/plugin-types'; +import assert from 'node:assert'; +import { RestApiPathConfig } from './types.js'; +import { handler } from './test-assets/handler.js'; +import { Context } from 'aws-lambda'; +import { Template } from 'aws-cdk-lib/assertions'; +import * as cognito from 'aws-cdk-lib/aws-cognito'; +import * as apiGateway from 'aws-cdk-lib/aws-apigateway'; + +const setupExampleLambda = (stack: Stack) => { + const factory = defineFunction({ + name: 'Test Function', + entry: './test-assets/handler.ts', + }); + + //stubs for the instance props + const constructContainer = new ConstructContainerStub( + new StackResolverStub(stack), + ); + const outputStorageStrategy = new StackMetadataBackendOutputStorageStrategy( + stack, + ); + const resourceNameValidator = new ResourceNameValidatorStub(); + const getInstanceProps: ConstructFactoryGetInstanceProps = { + constructContainer, + outputStorageStrategy, + resourceNameValidator, + }; + + return factory.getInstance(getInstanceProps); +}; + +const constructApiWithPath = (path: string, n: number = 1) => { + if (n < 0) n = 0; + const stack = new Stack(new App()); + const resource = setupExampleLambda(stack); + if (n == 0) + return new RestApiConstruct(stack, 'testApi0', { + apiName: 'testApi0', + apiProps: [], + }); + const apiProp: RestApiPathConfig = { + lambdaEntry: resource, + methods: [{ method: 'GET', authorizer: { type: 'none' } }], + path: path, + }; + const apiProps: RestApiPathConfig[] = []; + for (let x = 0; x < n; x++) { + apiProps.push(apiProp); + } + return new RestApiConstruct(stack, 'testApi1', { + apiName: 'testApi1', + apiProps: apiProps, + }); +}; + +void describe('RestApiConstruct User Pool Handling', () => { + void it('attaches a Cognito authorizer when a user pool is provided', () => { + const app = new App(); + const stack = new Stack(app); + + // Create a test Cognito user pool + const userPool = new cognito.UserPool(stack, 'TestUserPool', { + userPoolName: 'test-pool', + }); + + // Setup a Lambda to attach to the API + const lambdaResource = setupExampleLambda(stack); + + // Create the API with a GET method using user pool authorization + new RestApiConstruct( + stack, + 'RestApiWithAuth', + { + apiName: 'RestApiWithAuth', + apiProps: [ + { + path: '/test', + lambdaEntry: lambdaResource, + methods: [ + { method: 'GET', authorizer: { type: 'userPool' } }, + { method: 'POST', authorizer: { type: 'userPool' } }, + ], + }, + ], + }, + userPool, + ); + + const template = Template.fromStack(stack); // <-- use Template here + + // Check that the GET method has the proper Cognito user pool auth + template.hasResourceProperties('AWS::ApiGateway::Method', { + AuthorizationType: apiGateway.AuthorizationType.COGNITO, // this is COGNITO_USER_POOLS + HttpMethod: 'GET', + }); + + // Check that the POST method also has the proper Cognito user pool auth + template.hasResourceProperties('AWS::ApiGateway::Method', { + AuthorizationType: apiGateway.AuthorizationType.COGNITO, + HttpMethod: 'POST', + }); + + // Optionally, check that authorizer is correctly attached + template.hasResourceProperties('AWS::ApiGateway::Method', { + AuthorizerId: { Ref: 'RestApiWithAuthDefaultUserPoolAuth0006E5FA' }, // CDK auto-generated ID + }); + }); + + void it('does not create an authorizer if no user pool is passed', () => { + const stack = new Stack(new App()); + const lambdaResource = setupExampleLambda(stack); + + new RestApiConstruct(stack, 'RestApiWithoutAuth', { + apiName: 'RestApiWithoutAuth', + apiProps: [ + { + path: '/test', + lambdaEntry: lambdaResource, + methods: [{ method: 'GET', authorizer: { type: 'none' } }], + }, + ], + }); + + const template = Template.fromStack(stack); // <-- use Template here + + // GET method should be open (no auth) + template.hasResourceProperties('AWS::ApiGateway::Method', { + AuthorizationType: apiGateway.AuthorizationType.NONE, + HttpMethod: 'GET', + }); + }); +}); + +void describe('RestApiConstruct Lambda Handling', () => { + void it('integrates the result of defineFunction into the api', () => { + const app = new App(); + const stack = new Stack(app); + const resource = setupExampleLambda(stack); + + new RestApiConstruct(stack, 'RestApiTest', { + apiName: 'RestApiTest', + apiProps: [ + { + path: '/items', + lambdaEntry: resource, + methods: [ + { + method: 'GET', + authorizer: { type: 'none' }, + }, + ], + }, + ], + }); + }); +}); + +void describe('RestApiConstruct Error Handling', () => { + void it('throws an error if any of the rest api paths are invalid', () => { + assert.throws( + () => constructApiWithPath('no/leading/slash'), + 'NoLeadingSlashError', + ); + assert.throws( + () => constructApiWithPath('/an/ending/slash/'), + 'TrailingSlashError', + ); + assert.throws( + () => constructApiWithPath('/a/double//slash'), + 'DoubleSlashError', + ); + assert.throws(() => constructApiWithPath(''), 'EmptyPathError'); + + assert.throws( + () => constructApiWithPath('/no/paths/provided', 0), + 'NoPathsError', + ); + assert.throws( + () => constructApiWithPath('/a/duplicate', 2), + 'DuplicatePathError', + ); + + assert.doesNotThrow(() => constructApiWithPath('/a/valid/path')); + }); +}); + +void describe('Handler testing', () => { + void it('test function works and returns hello world', async () => { + const mockContext: Context = { + callbackWaitsForEmptyEventLoop: false, + functionName: 'testFunction', + functionVersion: '1', + invokedFunctionArn: + 'arn:aws:lambda:us-east-1:123456789012:function:testFunction', + memoryLimitInMB: '128', + awsRequestId: 'testRequestId', + logGroupName: '/aws/lambda/testFunction', + logStreamName: '2021/01/01/[$LATEST]a', + getRemainingTimeInMillis: () => 1000, + done: () => {}, + fail: () => {}, + succeed: () => {}, + }; + const result = await handler(null, mockContext, () => {}); + assert.equal(result, 'Hello, World!'); + }); +}); + +//test needs to be updated to new lambda handling +// void describe('RestApiConstruct', () => { +// void it('creates a queue if specified', () => { +// const app = new App(); +// const stack = new Stack(app); +// new RestApiConstruct(stack, 'RestApiTest', { +// apiName: 'RestApiTest', +// apiProps: [ +// { +// path: '/test', +// methods: [ +// { +// method: 'GET', +// authorizer: { type: 'none' }, +// }, +// ], +// lambdaEntry: { +// runtime: lambda.Runtime.NODEJS_18_X, +// source: { +// path: './test-lambda', +// }, +// }, +// }, +// { +// path: '/blog', +// methods: [ +// { +// method: 'POST', +// authorizer: { type: 'userPool', groups: ['Admins'] }, +// }, +// { +// method: 'GET', +// authorizer: { type: 'userPool' }, +// }, +// ], +// defaultAuthorizer: { type: 'userPool' }, +// lambdaEntry: { +// runtime: lambda.Runtime.NODEJS_18_X, +// source: { +// path: './blog-lambda', +// }, +// }, +// }, +// ], +// }); +// const template = Template.fromStack(stack); +// template.resourceCountIs('AWS::AGW::RestApi', 1); +// }); +// }); diff --git a/packages/rest-api-construct/src/construct.ts b/packages/rest-api-construct/src/construct.ts new file mode 100644 index 0000000000..0f2b0617ab --- /dev/null +++ b/packages/rest-api-construct/src/construct.ts @@ -0,0 +1,125 @@ +import { Construct } from 'constructs'; +import * as apiGateway from 'aws-cdk-lib/aws-apigateway'; +import { IUserPool } from 'aws-cdk-lib/aws-cognito'; +import { MethodsProps, RestApiConstructProps } from './types.js'; +import { validateRestApiPaths } from './validate_paths.js'; + +/** + * + */ +export class RestApiConstruct extends Construct { + public readonly api: apiGateway.RestApi; + private readonly userPoolAuthorizer?: apiGateway.CognitoUserPoolsAuthorizer; + + /** + * Creates a new REST API in API Gateway with optional Cognito User Pool authorization. + * + * If a user pool is provided, all routes default to using it for authentication unless overridden. + * @param scope - The scope in which this construct is defined. + * @param id - The unique identifier for this construct. + * @param props - Configuration options for the REST API, including name and route definitions. + * @param userPool - Optional Cognito User Pool to use as the default authorizer. + */ + constructor( + scope: Construct, + id: string, + props: RestApiConstructProps, + userPool?: IUserPool, + ) { + super(scope, id); + + //check that the paths are valid before creating the API + const paths: string[] = []; + props.apiProps.forEach((value) => paths.push(value.path)); + validateRestApiPaths(paths); + + // Create API + this.api = new apiGateway.RestApi(this, 'RestApi', { + restApiName: props.apiName, + }); + + // If userPool exists, create default authorizer + if (userPool) { + this.userPoolAuthorizer = new apiGateway.CognitoUserPoolsAuthorizer( + this, + 'DefaultUserPoolAuth', + { + cognitoUserPools: [userPool], + }, + ); + } + + for (const pathConfig of props.apiProps) { + const { path, methods, lambdaEntry } = pathConfig; + // Add resource and methods for this route + const resource = this.addNestedResource(this.api.root, path); + + for (const method of methods) { + const integration = new apiGateway.LambdaIntegration( + lambdaEntry.resources.lambda, + ); + + resource.addMethod(method.method, integration, { + authorizer: this.getAuthorizerForMethod(method), + authorizationType: this.getAuthorizationType(method), + }); + } + } + } + + /** + * + * If the method specifies a user pool authorizer, it returns the default user pool authorizer. + * If no authorizer is specified but a user pool exists, it returns the default user pool authorizer. + * Otherwise, it returns undefined. + */ + + private getAuthorizerForMethod(method: MethodsProps) { + if (method.authorizer?.type === 'userPool' && this.userPoolAuthorizer) { + return this.userPoolAuthorizer; + } + if (!method.authorizer && this.userPoolAuthorizer) { + return this.userPoolAuthorizer; + } + return undefined; + } + + /** + * Determines the authorization type based on the method's authorizer configuration. + * If a user pool authorizer is set or if no authorizer is specified but a user pool exists, it returns COGNITO. + * Otherwise, it returns NONE. + */ + + private getAuthorizationType(method: MethodsProps) { + if ( + method.authorizer?.type === 'userPool' || + (!method.authorizer && this.userPoolAuthorizer) + ) { + return apiGateway.AuthorizationType.COGNITO; + } + return apiGateway.AuthorizationType.NONE; + } + + /** + * Adds nested resources to the API based on the provided path. + */ + private addNestedResource( + root: apiGateway.IResource, + path: string, + ): apiGateway.IResource { + //remove the leading slash to prevent empty id error + if (path.startsWith('/')) path = path.substring(1); + + // Split the path into parts (e.g. "posts/comments" → ["posts", "comments"]) + const parts = path.split('/'); + + // Traverse the path, adding any missing nested resources along the way + let current = root; + for (const part of parts) { + const existing = current.getResource(part); + current = existing ?? current.addResource(part); + } + + return current; + } +} diff --git a/packages/rest-api-construct/src/index.ts b/packages/rest-api-construct/src/index.ts new file mode 100644 index 0000000000..9e9123b068 --- /dev/null +++ b/packages/rest-api-construct/src/index.ts @@ -0,0 +1,2 @@ +export * from './construct.js'; +export * from './types.js'; diff --git a/packages/rest-api-construct/src/test-assets/handler.ts b/packages/rest-api-construct/src/test-assets/handler.ts new file mode 100644 index 0000000000..2f489adc83 --- /dev/null +++ b/packages/rest-api-construct/src/test-assets/handler.ts @@ -0,0 +1,9 @@ +import type { Handler } from 'aws-lambda'; + +/** + * Test function + * @returns the string "Hello, World!" + */ +export const handler: Handler = async () => { + return 'Hello, World!'; +}; diff --git a/packages/rest-api-construct/src/types.ts b/packages/rest-api-construct/src/types.ts new file mode 100644 index 0000000000..e662db401f --- /dev/null +++ b/packages/rest-api-construct/src/types.ts @@ -0,0 +1,36 @@ +import { + FunctionResources, + ResourceProvider, +} from '@aws-amplify/backend/types/platform'; + +export type RestApiConstructProps = { + apiName: string; + apiProps: RestApiPathConfig[]; + defaultAuthorizer?: AuthorizerConfig; +}; + +export type AuthorizerConfig = + | { type: 'none' } // public + | { type: 'userPool' }; // signed-in users +// TODO: Group Validation +// | { type: 'userPool', groups: string[] } // signed-in users in a group + +export type MethodsProps = { + method: HttpMethod; + authorizer?: AuthorizerConfig; +}; + +export type RestApiPathConfig = { + path: string; + methods: MethodsProps[]; + lambdaEntry: ResourceProvider; +}; + +export type HttpMethod = + | 'GET' + | 'POST' + | 'PUT' + | 'DELETE' + | 'PATCH' + | 'HEAD' + | 'OPTIONS'; diff --git a/packages/rest-api-construct/src/validate_paths.ts b/packages/rest-api-construct/src/validate_paths.ts new file mode 100644 index 0000000000..fedb2585ab --- /dev/null +++ b/packages/rest-api-construct/src/validate_paths.ts @@ -0,0 +1,57 @@ +import { AmplifyUserError } from '@aws-amplify/platform-core'; + +/** + * Validates REST API paths, ensuring a leading slash, no trailing slash, and no double slashes. + * If all paths are valid, nothing will happen; otherwise an error will be thrown. + * @param paths array of all the paths to be validated + */ +export const validateRestApiPaths = (paths: string[]) => { + if (paths.length == 0) { + throw new AmplifyUserError('NoPathsError', { + message: 'There must be at least one path.', + resolution: 'Add at least one valid path.', + }); + } + const pathCount: { [path: string]: number } = {}; + paths.forEach((value) => { + validatePath(value); + if (pathCount[value]) { + throw new AmplifyUserError('DuplicatePathError', { + message: + 'Rest API paths must be unique. Found duplicate path: ' + value, + resolution: 'Remove duplicates of the path, leaving only one.', + }); + } else { + pathCount[value] = 1; + } + }); +}; + +const validatePath = (path: string) => { + if (path === '') { + throw new AmplifyUserError('EmptyPathError', { + message: 'A path must not be an empty string.', + resolution: 'Replace any empty string paths with valid paths.', + }); + } + if (!path.startsWith('/')) { + throw new AmplifyUserError('NoLeadingSlashError', { + message: "Rest API paths must start with '/'. Found path: " + path, + resolution: "Add '/' to the start of your path.", + }); + } + + if (path.endsWith('/')) { + throw new AmplifyUserError('TrailingSlashError', { + message: "Rest API paths must not end with '/'. Found path: " + path, + resolution: "Remove '/' from the end of your path.", + }); + } + + if (path.includes('//')) { + throw new AmplifyUserError('DoubleSlashError', { + message: "Rest API paths must not contain '//'. Found path: " + path, + resolution: "Replace '//' with '/' in your path.", + }); + } +}; diff --git a/packages/rest-api-construct/tsconfig.json b/packages/rest-api-construct/tsconfig.json new file mode 100644 index 0000000000..7704b45b59 --- /dev/null +++ b/packages/rest-api-construct/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { "rootDir": "src", "outDir": "lib" }, + "references": [ + { "path": "../backend" }, + { "path": "../backend-output-storage" }, + { "path": "../platform-core" } + ] +} diff --git a/packages/rest-api-construct/typedoc.json b/packages/rest-api-construct/typedoc.json new file mode 100644 index 0000000000..35fed2c958 --- /dev/null +++ b/packages/rest-api-construct/typedoc.json @@ -0,0 +1,3 @@ +{ + "entryPoints": ["src/index.ts"] +}