From bb5ca85a884df4723f2083c4eb28ead338b5d5fa Mon Sep 17 00:00:00 2001 From: Zakaria Date: Sun, 25 Oct 2026 09:18:50 -0400 Subject: [PATCH] feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page now lists all three kinds with a Type column, creates each from the same form, and opens HTTP and WebSocket APIs in their own panel: - Quick create: an HTTP API with a Lambda gets a catch-all route on an auto-deployed $default stage, plus the Lambda permission. - Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes the integration and Lambda permission only it used. - Stages with or without automatic deployment, and deploy on demand. - JWT authorizers filled in from a Cognito pool's OpenID Connect discovery document and an app client, or from any issuer; protect routes with them. - CORS configuration. - A tester that sends a real request to a stage, and for WebSocket APIs one that connects, sends a message and collects the replies. Capabilities are advertised under a new httpApiActions group, covered by the cloudProxy guard test. Verified end to end against TheCloud's runtime. --- README.md | 12 +- packages/api/package.json | 1 + .../adapter-aws/AwsApiGatewayAdapter.test.ts | 21 +- .../src/adapter-aws/AwsApiGatewayAdapter.ts | 81 ++- .../adapter-aws/AwsHttpApiOperations.test.ts | 326 ++++++++++ .../src/adapter-aws/AwsHttpApiOperations.ts | 592 +++++++++++++++++ packages/api/src/aws.ts | 4 + .../api/src/cloud-spi/apigatewaySchema.ts | 67 +- packages/api/src/cloud-spi/types.ts | 127 ++++ packages/api/src/cloudProxy.test.ts | 27 +- packages/api/src/cloudProxy.ts | 2 +- packages/api/src/routes/clouds.ts | 129 ++++ packages/api/src/service/CloudProxyService.ts | 60 ++ packages/frontend/src/api/api.ts | 24 + packages/frontend/src/api/cloudProxyClient.ts | 133 ++++ .../src/components/ApiGatewayRoutePanel.tsx | 48 +- .../src/components/DynamicResourceView.tsx | 12 +- .../src/components/HttpApiPanel.test.tsx | 126 ++++ .../frontend/src/components/HttpApiPanel.tsx | 610 ++++++++++++++++++ packages/frontend/src/index.css | 13 + packages/frontend/src/lib/apiGateway.ts | 45 ++ pnpm-lock.yaml | 18 + 22 files changed, 2410 insertions(+), 68 deletions(-) create mode 100644 packages/api/src/adapter-aws/AwsHttpApiOperations.test.ts create mode 100644 packages/api/src/adapter-aws/AwsHttpApiOperations.ts create mode 100644 packages/frontend/src/components/HttpApiPanel.test.tsx create mode 100644 packages/frontend/src/components/HttpApiPanel.tsx create mode 100644 packages/frontend/src/lib/apiGateway.ts diff --git a/README.md b/README.md index 556100f..db50d6f 100644 --- a/README.md +++ b/README.md @@ -259,14 +259,18 @@ Current gaps:
API Gateway -AWS only, through the generic apigateway service category. +AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column. -- List and inspect REST APIs. -- Create and delete REST APIs. +- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage. +- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers. +- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies. + +Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip. Current gaps: -- Resources, methods, deployments, and stages are not yet exposed. +- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed. +- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed. - No Azure or GCP API Gateway adapter yet.
diff --git a/packages/api/package.json b/packages/api/package.json index 2309697..9228d93 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -11,6 +11,7 @@ }, "dependencies": { "@aws-sdk/client-api-gateway": "^3.1076.0", + "@aws-sdk/client-apigatewayv2": "^3.1076.0", "@aws-sdk/client-appconfig": "^3.1076.0", "@aws-sdk/client-cloudformation": "^3.1111.0", "@aws-sdk/client-cloudfront": "^3.1146.0", diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts index e4f18a0..1a95814 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts @@ -18,6 +18,7 @@ import { type APIGatewayClient, } from '@aws-sdk/client-api-gateway' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' +import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2' import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter' function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient { @@ -32,6 +33,18 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient { } as unknown as LambdaClient } +/** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */ +const noHttpApis = { + send: async (command: unknown) => { + if (command instanceof GetApisCommand) return {Items: []} + throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) + }, +} as unknown as ApiGatewayV2Client + +function restOnlyAdapter(client: APIGatewayClient) { + return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis) +} + const orders = { id: 'a1b2c3', name: 'orders-api', @@ -44,7 +57,7 @@ const orders = { describe('AwsApiGatewayAdapter', () => { test('lists every page and maps REST APIs to normalized resources', async () => { - const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + const adapter = restOnlyAdapter(fakeClient((command) => { if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command') if (!command.input.position) return {items: [orders], position: 'next'} return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]} @@ -70,7 +83,7 @@ describe('AwsApiGatewayAdapter', () => { }) test('filters APIs by search term', async () => { - const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({ + const adapter = restOnlyAdapter(fakeClient(() => ({ items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}], }))) @@ -92,7 +105,7 @@ describe('AwsApiGatewayAdapter', () => { }) test('returns null when API Gateway reports a missing REST API', async () => { - const adapter = new AwsApiGatewayAdapter(fakeClient(() => { + const adapter = restOnlyAdapter(fakeClient(() => { throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) })) @@ -123,7 +136,7 @@ describe('AwsApiGatewayAdapter', () => { }) test('deletes the requested REST API', async () => { - const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + const adapter = restOnlyAdapter(fakeClient((command) => { expect(command).toBeInstanceOf(DeleteRestApiCommand) expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3') return {} diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts index 573ca4b..8aefd7e 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts @@ -22,10 +22,13 @@ import { type RestApi, } from '@aws-sdk/client-api-gateway' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' -import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws' +import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2' +import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws' import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema' import {ValidationError} from '../cloud-spi/errors' import {awsEndpoint} from '../service/runtimeProbe' +import {AwsHttpApiOperations} from './AwsHttpApiOperations' +import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types' import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' export class AwsApiGatewayAdapter implements CloudServiceAdapter { @@ -37,7 +40,13 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { private readonly lambda: LambdaClient = defaultLambda, private readonly accountId = defaultAccountId(), private readonly region = process.env.AWS_REGION ?? 'us-east-1', - ) {} + apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2, + ) { + this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region) + } + + /** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */ + private readonly httpApis: AwsHttpApiOperations schema(): ServiceSchema { return awsApiGatewaySchema() @@ -53,7 +62,17 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { position = res.position } while (position) - return filterBySearch(apis.map(toResource), query.search) + return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search) + } + + /** A runtime without API Gateway v2 still lists its REST APIs. */ + private async listHttpApis(): Promise { + try { + return await this.httpApis.list() + } catch (error) { + if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return [] + throw error + } } async get(id: string): Promise { @@ -61,12 +80,15 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id})) return toResource(res) } catch (error) { - if (hasHttpStatus(error, 404)) return null - throw error + if (!hasHttpStatus(error, 404)) throw error } + return this.httpApis.get(id) } async create(input: CreateResourceInput): Promise { + const protocol = stringValue(input.values.protocol) || 'REST' + if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol) + if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET') const name = stringValue(input.values.name) const description = stringValue(input.values.description) if (!name) throw new ValidationError('name is required') @@ -79,9 +101,54 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { } async delete(id: string): Promise { + if (await this.httpApis.get(id)) return this.httpApis.delete(id) await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id})) } + describeHttpApi(apiId: string): Promise { + return this.httpApis.describe(apiId) + } + + createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise { + return this.httpApis.createRoute(apiId, input) + } + + deleteHttpApiRoute(apiId: string, routeId: string): Promise { + return this.httpApis.deleteRoute(apiId, routeId) + } + + setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise { + return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId) + } + + createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise { + return this.httpApis.createStage(apiId, input) + } + + deleteHttpApiStage(apiId: string, stageName: string): Promise { + return this.httpApis.deleteStage(apiId, stageName) + } + + deployHttpApi(apiId: string, stageName: string): Promise { + return this.httpApis.deploy(apiId, stageName) + } + + createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise { + return this.httpApis.createAuthorizer(apiId, input) + } + + deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise { + return this.httpApis.deleteAuthorizer(apiId, authorizerId) + } + + updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise { + return this.httpApis.updateCors(apiId, cors) + } + + testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise { + return this.httpApis.testWebSocket(apiId, input) + } + async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise { const method = input.method.toUpperCase() const stage = stageName(input.stage) @@ -470,7 +537,8 @@ function childPath(parentPath: string, pathPart: string): string { function stageName(stage: string): string { const value = stringValue(stage) - if (!/^[A-Za-z0-9_-]+$/.test(value)) { + // $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API. + if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) { throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens') } return value @@ -518,6 +586,7 @@ function toResource(api: RestApi): CloudResource { metadata: { provider: 'aws', apigatewayService: 'rest', + protocol: 'REST', description: api.description, version: api.version, endpointTypes: api.endpointConfiguration?.types, diff --git a/packages/api/src/adapter-aws/AwsHttpApiOperations.test.ts b/packages/api/src/adapter-aws/AwsHttpApiOperations.test.ts new file mode 100644 index 0000000..1cff86d --- /dev/null +++ b/packages/api/src/adapter-aws/AwsHttpApiOperations.test.ts @@ -0,0 +1,326 @@ +import {afterEach, describe, expect, test} from 'bun:test' +import { + CreateApiCommand, + CreateAuthorizerCommand, + CreateDeploymentCommand, + CreateIntegrationCommand, + CreateRouteCommand, + CreateRouteResponseCommand, + CreateStageCommand, + DeleteApiCommand, + DeleteCorsConfigurationCommand, + DeleteIntegrationCommand, + DeleteRouteCommand, + GetApiCommand, + GetApisCommand, + GetAuthorizersCommand, + GetIntegrationsCommand, + GetRoutesCommand, + GetStageCommand, + GetStagesCommand, + UpdateApiCommand, + UpdateRouteCommand, + type ApiGatewayV2Client, +} from '@aws-sdk/client-apigatewayv2' +import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' +import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations' +import {ValidationError} from '../cloud-spi/errors' + +const CREATED = new Date('2026-10-25T10:00:00Z') +const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED} +const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED} +const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes' + +interface Runtime { + apis?: Record + routes?: unknown[] + integrations?: unknown[] + stages?: unknown[] + authorizers?: unknown[] + reply?: (command: object) => unknown +} + +function stubRuntime(runtime: Runtime = {}) { + const sent: object[] = [] + const lambdaSent: object[] = [] + const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi} + const client = { + async send(command: object) { + sent.push(command) + const custom = runtime.reply?.(command) + if (custom !== undefined) return custom + if (command instanceof GetApisCommand) return {Items: Object.values(apis)} + if (command instanceof GetApiCommand) { + const api = apis[command.input.ApiId!] + if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}}) + return api + } + if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []} + if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []} + if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []} + if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []} + if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input} + if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input} + if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input} + if (command instanceof CreateStageCommand) return {...command.input} + return {} + }, + } as unknown as ApiGatewayV2Client + const lambda = { + async send(command: object) { + lambdaSent.push(command) + if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}} + return {} + }, + } as unknown as LambdaClient + const discoveries: string[] = [] + const fetchJson = async (url: string) => { + discoveries.push(url) + return {issuer: 'http://floci:4566/us-east-1_pool1'} + } + const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson) + const of = (type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[] + const lambdaOf = (type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[] + return {operations, sent, of, lambdaOf, discoveries} +} + +describe('AwsHttpApiOperations APIs', () => { + test('lists HTTP and WebSocket APIs with their protocol', async () => { + const {operations} = stubRuntime() + + const apis = await operations.list() + expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([ + ['h1', 'http-api', 'HTTP', 'http'], + ['w1', 'websocket-api', 'WEBSOCKET', 'websocket'], + ]) + expect(await operations.get('rest-id')).toBeNull() + }) + + test('quick-creates an HTTP API that sends every request to a Lambda', async () => { + const {operations, of, lambdaOf} = stubRuntime() + + await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP') + + expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn}) + expect(of(CreateStageCommand)).toHaveLength(0) + expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({ + FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*', + }) + }) + + test('gives an HTTP API without a target an auto-deployed $default stage', async () => { + const {operations, of} = stubRuntime() + + await operations.create({name: 'empty'}, 'HTTP') + expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true}) + }) + + test('creates a WebSocket API with its route selection expression and a stage', async () => { + const {operations, of} = stubRuntime() + + await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET') + expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'}) + expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true}) + await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/) + }) + + test('deletes an API and the Lambda permissions it granted', async () => { + const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]}) + + await operations.delete('h1') + expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'}) + expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'}) + }) +}) + +describe('AwsHttpApiOperations routes', () => { + test('describes routes with their integration, stages, authorizers and CORS', async () => { + const {operations} = stubRuntime({ + apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}}, + routes: [ + {RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'}, + {RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'}, + ], + integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}], + stages: [{StageName: '$default', AutoDeploy: true}], + authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}], + }) + + const overview = await operations.describe('h1') + expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([ + ['GET /notes', 'notes', null], + ['POST /notes', 'notes', 'a1'], + ]) + expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}]) + expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']}) + expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300}) + }) + + test('adds a Lambda route, reusing the integration the function already has', async () => { + const {operations, of, lambdaOf} = stubRuntime({ + integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}], + }) + + const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'}) + expect(of(CreateIntegrationCommand)).toHaveLength(0) + expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'}) + expect(lambdaOf(AddPermissionCommand)).toHaveLength(1) + expect(route.integration?.functionName).toBe('notes') + }) + + test('adds an HTTP proxy route with a payload 1.0 integration', async () => { + const {operations, of} = stubRuntime() + + await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'}) + expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'}) + }) + + test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => { + const {operations, of} = stubRuntime() + + await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true}) + expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`) + expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default') + expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'}) + }) + + test('checks a route before creating anything', async () => { + const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]}) + const before = sent.length + + await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/) + await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/) + await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError) + await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/) + await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/) + expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false) + }) + + test('deleting a route removes the integration and permission only it used', async () => { + const integrations = [ + {IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}, + {IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'}, + ] + const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations}) + await shared.operations.deleteRoute('h1', 'r1') + expect(shared.of(DeleteRouteCommand)).toHaveLength(1) + expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0) + + const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations}) + await last.operations.deleteRoute('h1', 'r1') + expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'}) + expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes') + }) + + test('protects a route with an authorizer and removes it again', async () => { + const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined}) + + expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1') + expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull() + expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([ + {ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'}, + {ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined}, + ]) + }) +}) + +describe('AwsHttpApiOperations stages, authorizers and CORS', () => { + test('a stage without auto-deploy is deployed once when created', async () => { + const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined}) + + const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false}) + expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'}) + expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'}) + await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/) + }) + + test('fills a JWT authorizer from a Cognito pool discovery document', async () => { + const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined}) + + const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'}) + expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration') + expect(of(CreateAuthorizerCommand)[0].input).toEqual({ + ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], + JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}, + }) + expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1') + await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/) + await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/) + await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/) + }) + + test('will not delete an authorizer that still protects a route', async () => { + const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]}) + + await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first') + }) + + test('sets and removes the CORS configuration', async () => { + const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined}) + const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true} + + expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']}) + expect(await operations.updateCors('h1', null)).toBeNull() + expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1) + await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/) + await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/) + await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/) + }) +}) + +describe('AwsHttpApiOperations WebSocket test', () => { + const endpoint = process.env.FLOCI_ENDPOINT + afterEach(() => { + if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT + else process.env.FLOCI_ENDPOINT = endpoint + }) + + test('connects to the stage, sends the message and collects the replies', async () => { + const paths: string[] = [] + const server = Bun.serve({ + port: 0, + fetch(request, upgrade) { + paths.push(new URL(request.url).pathname + new URL(request.url).search) + return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400}) + }, + websocket: {message(socket, message) { socket.send(`echo ${message}`) }}, + }) + process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}` + try { + const {operations} = stubRuntime() + const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'}) + expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null}) + expect(paths).toEqual(['/ws/w1/dev?token=abc']) + } finally { + server.stop(true) + } + }) + + test('reports a refused connection', async () => { + process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1' + const {operations} = stubRuntime() + + const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000) + expect(result.connected).toBe(false) + expect(result.error).toBeTruthy() + await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/) + }) +}) + +describe('route keys', () => { + test('HTTP route keys are $default or a method and a path', () => { + expect(httpRouteKey('$default')).toBe('$default') + expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}') + expect(httpRouteKey('GET /')).toBe('GET /') + for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) { + expect(() => httpRouteKey(bad), bad).toThrow(ValidationError) + } + }) + + test('WebSocket route keys are the system routes or an action name', () => { + expect(webSocketRouteKey('$connect')).toBe('$connect') + expect(webSocketRouteKey('sendMessage')).toBe('sendMessage') + expect(() => webSocketRouteKey('$other')).toThrow(ValidationError) + expect(() => webSocketRouteKey('send message')).toThrow(ValidationError) + }) +}) diff --git a/packages/api/src/adapter-aws/AwsHttpApiOperations.ts b/packages/api/src/adapter-aws/AwsHttpApiOperations.ts new file mode 100644 index 0000000..631d19f --- /dev/null +++ b/packages/api/src/adapter-aws/AwsHttpApiOperations.ts @@ -0,0 +1,592 @@ +import { + CreateApiCommand, + CreateAuthorizerCommand, + CreateDeploymentCommand, + CreateIntegrationCommand, + CreateRouteCommand, + CreateRouteResponseCommand, + CreateStageCommand, + DeleteApiCommand, + DeleteAuthorizerCommand, + DeleteCorsConfigurationCommand, + DeleteIntegrationCommand, + DeleteRouteCommand, + DeleteStageCommand, + GetApiCommand, + GetApisCommand, + GetAuthorizersCommand, + GetIntegrationsCommand, + GetRoutesCommand, + GetStageCommand, + GetStagesCommand, + UpdateApiCommand, + UpdateRouteCommand, + type Api, + type ApiGatewayV2Client, + type Authorizer, + type Cors, + type Integration, + type Route, + type Stage, +} from '@aws-sdk/client-apigatewayv2' +import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' +import {NotFoundError, ValidationError} from '../cloud-spi/errors' +import {awsEndpoint} from '../service/runtimeProbe' +import type { + CloudResource, + HttpApiAuthorizerInput, + HttpApiAuthorizerOverview, + HttpApiCors, + HttpApiIntegrationOverview, + HttpApiOverview, + HttpApiProtocol, + HttpApiRouteInput, + HttpApiRouteOverview, + HttpApiStageInput, + HttpApiStageOverview, + HttpApiWebSocketTestInput, + HttpApiWebSocketTestResult, +} from '../cloud-spi/types' + +export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY'] +export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*'] +export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action' +const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default'] + +/** + * API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own + * routes, integrations, stages and JWT authorizers, served by the same API Gateway page. + */ +export class AwsHttpApiOperations { + constructor( + private readonly client: ApiGatewayV2Client, + private readonly lambda: LambdaClient, + private readonly accountId: string, + private readonly region: string, + private readonly fetchJson: (url: string) => Promise = defaultFetchJson, + ) {} + + async list(): Promise { + const apis: Api[] = [] + let next: string | undefined + do { + const page = await this.client.send(new GetApisCommand({NextToken: next})) + apis.push(...(page.Items ?? [])) + next = page.NextToken + } while (next) + return apis.map(toResource) + } + + /** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */ + async get(apiId: string): Promise { + const api = await this.findApi(apiId) + return api ? toResource(api) : null + } + + async create(values: Record, protocol: HttpApiProtocol): Promise { + const name = stringValue(values.name) + if (!name) throw new ValidationError('name is required') + const description = stringValue(values.description) || undefined + + if (protocol === 'WEBSOCKET') { + const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION + if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) { + throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action') + } + const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression})) + const stageName = stageNameValue(stringValue(values.stageName) || 'dev') + await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true})) + return toResource(api) + } + + // Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage. + const functionName = stringValue(values.functionName) + if (functionName) { + const functionArn = await this.functionArn(functionName) + const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn})) + await this.allowInvoke(api.ApiId!, functionName) + return toResource(api) + } + const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'})) + await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true})) + return toResource(api) + } + + async delete(apiId: string): Promise { + const {integrations} = await this.describeParts(apiId) + await this.client.send(new DeleteApiCommand({ApiId: apiId})) + for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) { + await this.removeInvokePermission(apiId, functionName) + } + } + + async describe(apiId: string): Promise { + const api = await this.requireApi(apiId) + const {routes, integrations, stages, authorizers} = await this.describeParts(apiId) + const protocol = protocolOf(api) + const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration])) + return { + apiId, + protocol, + apiEndpoint: api.ApiEndpoint ?? null, + routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null, + routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes), + stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)), + authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)), + cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null, + } + } + + async createRoute(apiId: string, input: HttpApiRouteInput): Promise { + const api = await this.requireApi(apiId) + const protocol = protocolOf(api) + const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey) + const functionName = stringValue(input.functionName) + const httpUrl = stringValue(input.httpUrl) + if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL') + if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only') + if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL') + + const {routes, integrations} = await this.describeParts(apiId) + if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`) + const authorizerId = stringValue(input.authorizerId) + if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet') + + let integration: Partial | undefined + if (functionName) { + const functionArn = await this.functionArn(functionName) + const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn) + integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri) + ?? await this.client.send(new CreateIntegrationCommand({ + ApiId: apiId, + IntegrationType: 'AWS_PROXY', + IntegrationUri: uri, + IntegrationMethod: 'POST', + PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined, + })) + await this.allowInvoke(apiId, functionName) + } else { + const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0] + integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method) + ?? await this.client.send(new CreateIntegrationCommand({ + ApiId: apiId, + IntegrationType: 'HTTP_PROXY', + IntegrationUri: httpUrl, + IntegrationMethod: method, + PayloadFormatVersion: '1.0', + })) + } + + const route = await this.client.send(new CreateRouteCommand({ + ApiId: apiId, + RouteKey: routeKey, + Target: `integrations/${integration.IntegrationId}`, + AuthorizationType: authorizerId ? 'JWT' : 'NONE', + AuthorizerId: authorizerId || undefined, + RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined, + })) + // A WebSocket route sends its integration's response back only once it has a route response. + if (protocol === 'WEBSOCKET' && input.returnsResponse) { + await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'})) + } + return toRouteOverview(route, new Map([[integration.IntegrationId, integration]])) + } + + async deleteRoute(apiId: string, routeId: string): Promise { + const {routes, integrations} = await this.describeParts(apiId) + const route = routes.find((candidate) => candidate.RouteId === routeId) + if (!route) throw new NotFoundError(`Route ${routeId} not found`) + await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId})) + + // Clean up what only this route used: its integration, then the Lambda's permission for this API. + const integrationId = targetIntegrationId(route.Target) + const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId) + const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId) + if (!integration || stillUsed) return + await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId})) + const functionName = integrationFunctionName(integration) + const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName) + if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName) + } + + async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise { + const api = await this.requireApi(apiId) + if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet') + const route = await this.client.send(new UpdateRouteCommand({ + ApiId: apiId, + RouteId: routeId, + AuthorizationType: authorizerId ? 'JWT' : 'NONE', + AuthorizerId: authorizerId ?? undefined, + })) + const {integrations} = await this.describeParts(apiId) + return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration]))) + } + + async createStage(apiId: string, input: HttpApiStageInput): Promise { + const api = await this.requireApi(apiId) + const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)})) + // A stage without auto-deploy serves nothing until its first deployment. + if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!) + return toStageOverview(apiId, protocolOf(api), stage) + } + + async deleteStage(apiId: string, stageName: string): Promise { + await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)})) + } + + async deploy(apiId: string, stageName: string): Promise { + const api = await this.requireApi(apiId) + const name = stageNameValue(stageName) + await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'})) + const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name})) + return toStageOverview(apiId, protocolOf(api), stage) + } + + async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise { + const api = await this.requireApi(apiId) + if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers') + const name = stringValue(input.name) + if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores') + + let issuer = stringValue(input.issuer) + let audience = (input.audience ?? []).map(stringValue).filter(Boolean) + const userPoolId = stringValue(input.userPoolId) + if (userPoolId) { + const clientId = stringValue(input.userPoolClientId) + if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts') + issuer = await this.userPoolIssuer(userPoolId) + audience = [clientId] + } + if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc') + if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id') + + const authorizer = await this.client.send(new CreateAuthorizerCommand({ + ApiId: apiId, + Name: name, + AuthorizerType: 'JWT', + IdentitySource: ['$request.header.Authorization'], + JwtConfiguration: {Issuer: issuer, Audience: audience}, + })) + return toAuthorizerOverview(authorizer) + } + + async deleteAuthorizer(apiId: string, authorizerId: string): Promise { + const {routes} = await this.describeParts(apiId) + const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey) + if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`) + await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId})) + } + + async updateCors(apiId: string, cors: HttpApiCors | null): Promise { + const api = await this.requireApi(apiId) + if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only') + if (!cors) { + await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId})) + return null + } + const configuration = toCors(cors) + const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration})) + return fromCors(updated.CorsConfiguration) + } + + /** Opens a real connection to the deployed stage, sends one message and collects the replies. */ + async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise { + const api = await this.requireApi(apiId) + if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API') + const stage = stageNameValue(input.stage) + const query = stringValue(input.query).replace(/^\?/, '') + const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}` + return exchangeWebSocketMessage(url, input.message, timeoutMs) + } + + private async describeParts(apiId: string) { + const [routes, integrations, stages, authorizers] = await Promise.all([ + this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))), + this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))), + this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))), + this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))), + ]) + return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]} + } + + private async pages(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise { + const items: T[] = [] + let next: string | undefined + do { + const page = await fetchPage(next) + items.push(...(page.Items ?? [])) + next = page.NextToken + } while (next) + return items + } + + private async findApi(apiId: string): Promise | null> { + try { + return await this.client.send(new GetApiCommand({ApiId: apiId})) + } catch (error) { + if (hasHttpStatus(error, 404)) return null + throw error + } + } + + private async requireApi(apiId: string): Promise> { + const api = await this.findApi(apiId) + if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`) + return api + } + + private async functionArn(functionName: string): Promise { + const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName})) + const arn = result.Configuration?.FunctionArn + if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`) + return arn + } + + /** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */ + private async allowInvoke(apiId: string, functionName: string) { + try { + await this.lambda.send(new AddPermissionCommand({ + FunctionName: functionName, + StatementId: invokeStatementId(apiId), + Action: 'lambda:InvokeFunction', + Principal: 'apigateway.amazonaws.com', + SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`, + })) + } catch (error) { + if (!hasHttpStatus(error, 409)) throw error + } + } + + private async removeInvokePermission(apiId: string, functionName: string) { + try { + await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)})) + } catch (error) { + if (!hasHttpStatus(error, 404)) throw error + } + } + + /** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */ + private async userPoolIssuer(userPoolId: string): Promise { + if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id') + const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`) + const issuer = (discovery as {issuer?: unknown} | null)?.issuer + if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`) + return issuer + } +} + +async function defaultFetchJson(url: string): Promise { + const response = await globalThis.fetch(url) + if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`) + return response.json() +} + +function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise { + const startedAt = performance.now() + return new Promise((resolve) => { + const messages: string[] = [] + let connected = false + let settled = false + let socket: WebSocket + const finish = (closeCode: number | null, error: string | null) => { + if (settled) return + settled = true + clearTimeout(timer) + if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000) + resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)}) + } + // After the first reply, wait briefly for any more before hanging up. + let quiet: ReturnType | undefined + const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs) + try { + socket = new WebSocket(url) + } catch (error) { + finish(null, error instanceof Error ? error.message : 'Could not connect') + return + } + socket.onopen = () => { + connected = true + if (message) socket.send(message) + else finish(null, null) + } + socket.onmessage = (event) => { + messages.push(typeof event.data === 'string' ? event.data : String(event.data)) + clearTimeout(quiet) + quiet = setTimeout(() => finish(null, null), 500) + } + socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection') + socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`) + }) +} + +function toResource(api: Partial): CloudResource { + const protocol = protocolOf(api) + return { + id: api.ApiId ?? '', + name: api.Name ?? '', + cloud: 'aws', + service: 'apigateway', + type: protocol === 'HTTP' ? 'http-api' : 'websocket-api', + region: null, + createdAt: api.CreatedDate?.toISOString() ?? null, + metadata: { + provider: 'aws', + apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket', + protocol, + description: api.Description, + apiEndpoint: api.ApiEndpoint, + routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined, + }, + } +} + +function protocolOf(api: Partial): HttpApiProtocol { + return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP' +} + +function toRouteOverview(route: Partial, integrations: Map>): HttpApiRouteOverview { + const integration = integrations.get(targetIntegrationId(route.Target)) + return { + routeId: route.RouteId ?? '', + routeKey: route.RouteKey ?? '', + integration: integration ? toIntegrationOverview(integration) : null, + authorizationType: route.AuthorizationType ?? 'NONE', + // Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE. + authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null, + authorizationScopes: route.AuthorizationScopes ?? [], + returnsResponse: Boolean(route.RouteResponseSelectionExpression), + } +} + +function toIntegrationOverview(integration: Partial): HttpApiIntegrationOverview { + return { + integrationId: integration.IntegrationId ?? '', + type: integration.IntegrationType ?? '', + uri: integration.IntegrationUri ?? null, + functionName: integrationFunctionName(integration), + payloadFormatVersion: integration.PayloadFormatVersion ?? null, + } +} + +function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial): HttpApiStageOverview { + const stageName = stage.StageName ?? '' + return { + stageName, + autoDeploy: Boolean(stage.AutoDeploy), + deploymentId: stage.DeploymentId ?? null, + lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null, + invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`, + } +} + +function toAuthorizerOverview(authorizer: Partial): HttpApiAuthorizerOverview { + return { + authorizerId: authorizer.AuthorizerId ?? '', + name: authorizer.Name ?? '', + type: authorizer.AuthorizerType ?? '', + identitySource: authorizer.IdentitySource ?? [], + issuer: authorizer.JwtConfiguration?.Issuer ?? null, + audience: authorizer.JwtConfiguration?.Audience ?? [], + } +} + +function fromCors(cors: Cors | undefined): HttpApiCors | null { + if (!cors) return null + return { + allowOrigins: cors.AllowOrigins ?? [], + allowMethods: cors.AllowMethods ?? [], + allowHeaders: cors.AllowHeaders ?? [], + exposeHeaders: cors.ExposeHeaders ?? [], + maxAgeSeconds: cors.MaxAge ?? null, + allowCredentials: Boolean(cors.AllowCredentials), + } +} + +function toCors(cors: HttpApiCors): Cors { + const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))] + const allowOrigins = clean(cors.allowOrigins) + const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase()) + if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *') + if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) { + throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com') + } + if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`) + if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead') + const maxAge = cors.maxAgeSeconds + if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) { + throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400') + } + return { + AllowOrigins: allowOrigins, + AllowMethods: allowMethods, + AllowHeaders: clean(cors.allowHeaders), + ExposeHeaders: clean(cors.exposeHeaders), + MaxAge: maxAge ?? undefined, + AllowCredentials: Boolean(cors.allowCredentials), + } +} + +/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */ +export function httpRouteKey(value: string): string { + const key = stringValue(value).replace(/\s+/g, ' ') + if (key === '$default') return key + const [method, path, ...rest] = key.split(' ') + if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) { + throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}') + } + const segments = path === '/' ? [] : path.split('/').slice(1) + if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment) + || (segment.endsWith('+}') && index !== segments.length - 1))) { + throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}') + } + return `${method.toUpperCase()} ${path}` +} + +/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */ +export function webSocketRouteKey(value: string): string { + const key = stringValue(value) + if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key + if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) { + throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage') + } + return key +} + +function stageNameValue(value: string): string { + const name = stringValue(value) + if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) { + throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores') + } + return name +} + +function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number { + const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey + return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey) +} + +function lambdaInvocationUri(region: string, functionArn: string): string { + return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations` +} + +function integrationFunctionName(integration: Partial): string | null { + if (integration.IntegrationType !== 'AWS_PROXY') return null + return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null +} + +function targetIntegrationId(target: string | undefined): string | undefined { + return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined +} + +function invokeStatementId(apiId: string): string { + return `apigatewayv2-${apiId}` +} + +function stringValue(value: unknown): string { + return typeof value === 'string' ? value.trim() : '' +} + +function hasHttpStatus(error: unknown, status: number): boolean { + if (typeof error !== 'object' || error === null) return false + return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status +} diff --git a/packages/api/src/aws.ts b/packages/api/src/aws.ts index eb2a0ae..86a2b13 100644 --- a/packages/api/src/aws.ts +++ b/packages/api/src/aws.ts @@ -15,6 +15,7 @@ import { SQSClient } from "@aws-sdk/client-sqs"; import { IAMClient } from "@aws-sdk/client-iam"; import { DynamoDBClient } from "@aws-sdk/client-dynamodb"; import { APIGatewayClient } from "@aws-sdk/client-api-gateway"; +import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2"; import { CloudFormationClient } from "@aws-sdk/client-cloudformation"; import { AppConfigClient } from "@aws-sdk/client-appconfig"; import { SNSClient } from "@aws-sdk/client-sns"; @@ -79,6 +80,7 @@ export type AwsClients = { iam: IAMClient; dynamodb: DynamoDBClient; apiGateway: APIGatewayClient; + apiGatewayV2: ApiGatewayV2Client; cloudformation: CloudFormationClient; appConfig: AppConfigClient; sns: SNSClient; @@ -127,6 +129,7 @@ function buildClients(accountId: string): AwsClients { iam: new IAMClient(base), dynamodb: new DynamoDBClient(base), apiGateway: new APIGatewayClient(base), + apiGatewayV2: new ApiGatewayV2Client(base), cloudformation: new CloudFormationClient(base), appConfig: new AppConfigClient(base), sns: new SNSClient(base), @@ -181,6 +184,7 @@ export const sqs = awsClients.sqs; export const iam = awsClients.iam; export const dynamodb = awsClients.dynamodb; export const apiGateway = awsClients.apiGateway; +export const apiGatewayV2 = awsClients.apiGatewayV2; export const cloudformation = awsClients.cloudformation; export const ecr = awsClients.ecr; export const kinesis = awsClients.kinesis; diff --git a/packages/api/src/cloud-spi/apigatewaySchema.ts b/packages/api/src/cloud-spi/apigatewaySchema.ts index b9a133c..c036df0 100644 --- a/packages/api/src/cloud-spi/apigatewaySchema.ts +++ b/packages/api/src/cloud-spi/apigatewaySchema.ts @@ -1,4 +1,4 @@ -import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types' +import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types' const resourceActions: CapabilitySchema[] = [ {name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true}, @@ -7,12 +7,42 @@ const resourceActions: CapabilitySchema[] = [ {name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false}, ] +const httpApiActions: CapabilitySchema[] = [ + {name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true}, + {name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true}, +] + +const httpOnly = {field: 'protocol', values: ['HTTP']} +const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']} + export function awsApiGatewaySchema(): ServiceSchema { return { cloud: 'aws', service: 'apigateway', displayName: 'AWS API Gateway', fields: [ + { + name: 'protocol', + label: 'API type', + type: 'select', + required: true, + defaultValue: 'REST', + options: [ + {label: 'REST API (resources, methods and stages)', value: 'REST'}, + {label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'}, + {label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'}, + ], + }, { name: 'name', label: 'API Name', @@ -30,20 +60,47 @@ export function awsApiGatewaySchema(): ServiceSchema { label: 'Description', type: 'text', required: false, - description: 'Optional description for the REST API.', + description: 'Optional description for the API.', validation: { maxLength: 1024, message: 'Description must be 1024 characters or fewer.', }, }, + { + name: 'functionName', + label: 'Lambda function', + type: 'text', + required: false, + visibleWhen: httpOnly, + description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.', + }, + { + name: 'routeSelectionExpression', + label: 'Route selection expression', + type: 'text', + required: false, + defaultValue: '$request.body.action', + visibleWhen: webSocketOnly, + description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.', + }, + { + name: 'stageName', + label: 'Stage', + type: 'text', + required: false, + defaultValue: 'dev', + visibleWhen: webSocketOnly, + description: 'Created with automatic deployment, so route changes go live right away.', + validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'}, + }, ], actions: ['list', 'create', 'delete', 'inspect'], - capabilities: {resourceActions}, + capabilities: {resourceActions, httpApiActions}, filters: [{name: 'search', label: 'Search', type: 'text', required: false}], columns: [ {name: 'name', label: 'API Name'}, - {name: 'type', label: 'Type'}, - {name: 'createdAt', label: 'Created At'}, + {name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'}, + {name: 'createdAt', label: 'Created At', format: 'relative'}, ], } } diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index dc88433..96e23f5 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -182,6 +182,19 @@ export type UserPoolActionName = | 'updateClientOAuth' export type DistributionActionName = 'setEnabled' | 'createInvalidation' export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions' +export type HttpApiActionName = + | 'describe' + | 'createRoute' + | 'deleteRoute' + | 'setRouteAuthorizer' + | 'createStage' + | 'deleteStage' + | 'deploy' + | 'createAuthorizer' + | 'deleteAuthorizer' + | 'updateCors' + | 'sendRequest' + | 'testWebSocket' export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon' export interface CapabilitySchema { @@ -228,6 +241,8 @@ export interface ServiceSchema { userPoolActions?: CapabilitySchema[] distributionActions?: CapabilitySchema[] monitoringActions?: CapabilitySchema[] + /** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */ + httpApiActions?: CapabilitySchema[] // Child levels advertise separately from the resource level because a // store can be readable at the leaf and writable at the collection — // CloudWatch Logs creates streams but cannot delete an event. @@ -769,6 +784,107 @@ export interface ApiGatewayCorsResult { proxyMethods: string[] } +export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET' + +export interface HttpApiIntegrationOverview { + integrationId: string + type: string + uri: string | null + /** The Lambda function a proxy integration invokes, when it is one. */ + functionName: string | null + payloadFormatVersion: string | null +} + +export interface HttpApiRouteOverview { + routeId: string + /** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */ + routeKey: string + integration: HttpApiIntegrationOverview | null + authorizationType: string + authorizerId: string | null + authorizationScopes: string[] + /** WebSocket only: whether the integration's response is sent back to the client. */ + returnsResponse: boolean +} + +export interface HttpApiStageOverview { + stageName: string + autoDeploy: boolean + deploymentId: string | null + lastUpdatedAt: string | null + /** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */ + invokePath: string +} + +export interface HttpApiAuthorizerOverview { + authorizerId: string + name: string + type: string + identitySource: string[] + issuer: string | null + audience: string[] +} + +export interface HttpApiCors { + allowOrigins: string[] + allowMethods: string[] + allowHeaders: string[] + exposeHeaders: string[] + maxAgeSeconds: number | null + allowCredentials: boolean +} + +export interface HttpApiOverview { + apiId: string + protocol: HttpApiProtocol + apiEndpoint: string | null + routeSelectionExpression: string | null + routes: HttpApiRouteOverview[] + stages: HttpApiStageOverview[] + authorizers: HttpApiAuthorizerOverview[] + cors: HttpApiCors | null +} + +export interface HttpApiRouteInput { + routeKey: string + /** A Lambda function name; exactly one of functionName and httpUrl. */ + functionName?: string + /** An HTTP backend the route proxies to; HTTP APIs only. */ + httpUrl?: string + authorizerId?: string + /** WebSocket only: send the integration's response back to the client. */ + returnsResponse?: boolean +} + +export interface HttpApiStageInput { + stageName: string + autoDeploy: boolean +} + +export interface HttpApiAuthorizerInput { + name: string + /** Fill the issuer and audience from a Cognito user pool and one of its app clients. */ + userPoolId?: string + userPoolClientId?: string + issuer?: string + audience?: string[] +} + +export interface HttpApiWebSocketTestInput { + stage: string + message: string + /** Sent as query string parameters on the connection request, for a $connect authorizer. */ + query?: string +} + +export interface HttpApiWebSocketTestResult { + connected: boolean + messages: string[] + closeCode: number | null + error: string | null + durationMs: number +} + export interface S3LambdaTriggerInput { functionName: string events: string[] @@ -963,6 +1079,17 @@ export interface CloudServiceAdapter { listApiGatewayRoutes?(apiId: string): Promise deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise + describeHttpApi?(apiId: string): Promise + createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise + deleteHttpApiRoute?(apiId: string, routeId: string): Promise + setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise + createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise + deleteHttpApiStage?(apiId: string, stageName: string): Promise + deployHttpApi?(apiId: string, stageName: string): Promise + createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise + deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise + updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise + testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise listS3LambdaTriggers?(bucketName: string): Promise deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise diff --git a/packages/api/src/cloudProxy.test.ts b/packages/api/src/cloudProxy.test.ts index 69c6cef..abbe6d6 100644 --- a/packages/api/src/cloudProxy.test.ts +++ b/packages/api/src/cloudProxy.test.ts @@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test' import {createCloudAdapterRegistry} from './cloudProxy' import {checkChildCapabilities} from './cloud-spi/childCapabilities' import {isServiceType} from './cloud-spi/serviceCatalog' -import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' +import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types' /** * Guards the schema-to-implementation contract for every registered adapter. @@ -55,6 +55,21 @@ const METHOD_FOR_MONITORING_ACTION: Record = { + describe: 'describeHttpApi', + createRoute: 'createHttpApiRoute', + deleteRoute: 'deleteHttpApiRoute', + setRouteAuthorizer: 'setHttpApiRouteAuthorizer', + createStage: 'createHttpApiStage', + deleteStage: 'deleteHttpApiStage', + deploy: 'deployHttpApi', + createAuthorizer: 'createHttpApiAuthorizer', + deleteAuthorizer: 'deleteHttpApiAuthorizer', + updateCors: 'updateHttpApiCors', + sendRequest: 'testApiGatewayRoute', + testWebSocket: 'testWebSocketApi', +} + const METHOD_FOR_DISTRIBUTION_ACTION: Record = { setEnabled: 'setDistributionEnabled', createInvalidation: 'createDistributionInvalidation', @@ -164,6 +179,15 @@ describe('registered adapters honour their schema', () => { ).toBe('function') } + const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available') + for (const capability of httpApiClaims) { + const method = METHOD_FOR_HTTP_API_ACTION[capability.name] + expect( + typeof adapter[method], + `${label} advertises ${capability.name} as available but has no ${String(method)}()`, + ).toBe('function') + } + const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available') for (const capability of distributionClaims) { const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name] @@ -187,6 +211,7 @@ describe('registered adapters honour their schema', () => { ...(adapter.schema().capabilities?.userPoolActions ?? []), ...(adapter.schema().capabilities?.distributionActions ?? []), ...(adapter.schema().capabilities?.monitoringActions ?? []), + ...(adapter.schema().capabilities?.httpApiActions ?? []), ...(adapter.schema().capabilities?.collectionActions ?? []), ...(adapter.schema().capabilities?.itemActions ?? []), ] diff --git a/packages/api/src/cloudProxy.ts b/packages/api/src/cloudProxy.ts index 604434c..48ebcaf 100644 --- a/packages/api/src/cloudProxy.ts +++ b/packages/api/src/cloudProxy.ts @@ -85,7 +85,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap new AwsTargetGroupAdapter(clients.elbv2, clients.ec2), new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda), new AwsIamAdapter(clients.iam), - new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)), + new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2), new AwsCloudFormationAdapter(clients.cloudformation), new AwsSecretsAdapter(clients.secretsManager), new AwsSesAdapter(), diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 79a023d..4c43392 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -803,6 +803,131 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + // HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs. + app.get('/:cloud/services/apigateway/resources/:id/http-api', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => c.json(await svc(c).describeHttpApi(cloud, c.req.param('id')))) + }) + + app.post('/:cloud/services/apigateway/resources/:id/http-api/routes', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json>() + const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), { + routeKey: typeof input.routeKey === 'string' ? input.routeKey : '', + functionName: typeof input.functionName === 'string' ? input.functionName : undefined, + httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined, + authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined, + returnsResponse: input.returnsResponse === true, + }) + return c.json(route, 201) + }) + }) + + app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId')) + return c.json({ok: true}) + }) + }) + + app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json<{authorizerId?: unknown}>() + const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null + return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId)) + }) + }) + + app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json>() + const stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), { + stageName: typeof input.stageName === 'string' ? input.stageName : '', + autoDeploy: input.autoDeploy === true, + }) + return c.json(stage, 201) + }) + }) + + app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName')) + return c.json({ok: true}) + }) + }) + + app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => c.json(await svc(c).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201)) + }) + + app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json>() + const authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), { + name: typeof input.name === 'string' ? input.name : '', + userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined, + userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined, + issuer: typeof input.issuer === 'string' ? input.issuer : undefined, + audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined, + }) + return c.json(authorizer, 201) + }) + }) + + app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId')) + return c.json({ok: true}) + }) + }) + + app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json<{cors?: Record | null}>() + const cors = input.cors ? { + allowOrigins: stringList(input.cors.allowOrigins), + allowMethods: stringList(input.cors.allowMethods), + allowHeaders: stringList(input.cors.allowHeaders), + exposeHeaders: stringList(input.cors.exposeHeaders), + maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null, + allowCredentials: input.cors.allowCredentials === true, + } : null + return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)}) + }) + }) + + app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-test', async (c) => { + const cloud = c.req.param('cloud') as CloudProvider + if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) + return withRuntime(c, async () => { + const input = await c.req.json>() + return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), { + stage: typeof input.stage === 'string' ? input.stage : '', + message: typeof input.message === 'string' ? input.message : '', + query: typeof input.query === 'string' ? input.query : undefined, + })) + }) + }) + app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) @@ -1662,6 +1787,10 @@ function isStringRecord(value: unknown): value is Record { && Object.values(value).every((item) => typeof item === 'string') } +function stringList(value: unknown): string[] { + return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [] +} + function stringHeaders(value: unknown): Record { if (value === undefined) return {} if (!value || typeof value !== 'object' || Array.isArray(value)) { diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index b95fce0..c66a663 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -30,6 +30,16 @@ import type { ApiGatewayDeleteRouteInput, ApiGatewayCorsInput, ApiGatewayCorsResult, + HttpApiAuthorizerInput, + HttpApiAuthorizerOverview, + HttpApiCors, + HttpApiOverview, + HttpApiRouteInput, + HttpApiRouteOverview, + HttpApiStageInput, + HttpApiStageOverview, + HttpApiWebSocketTestInput, + HttpApiWebSocketTestResult, S3LambdaTriggerInput, S3LambdaTriggerOverview, S3BucketWebsiteConfiguration, @@ -330,6 +340,56 @@ export class CloudProxyService { return adapter.enableApiGatewayCors(apiId, input) } + async describeHttpApi(cloud: CloudProvider, apiId: string): Promise { + return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId) + } + + async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise { + return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input) + } + + async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise { + await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId) + } + + async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise { + return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId) + } + + async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise { + return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input) + } + + async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise { + await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName) + } + + async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise { + return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName) + } + + async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise { + return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input) + } + + async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise { + await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId) + } + + async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise { + return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors) + } + + async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise { + return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input) + } + + private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter { + const adapter = this.requireAdapter(cloud, 'apigateway') + if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`) + return adapter + } + async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise { const adapter = this.requireAdapter(cloud, 'storage') if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 02d5b75..be4e9d7 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -130,6 +130,19 @@ export const apiEndpointKeys = { messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"}, moveTasks: "clouds.services.messaging.move-tasks", }, + httpApi: { + describe: "clouds.services.apigateway.http-api.describe", + createRoute: "clouds.services.apigateway.http-api.routes.create", + deleteRoute: "clouds.services.apigateway.http-api.routes.delete", + setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer", + createStage: "clouds.services.apigateway.http-api.stages.create", + deleteStage: "clouds.services.apigateway.http-api.stages.delete", + deploy: "clouds.services.apigateway.http-api.stages.deploy", + createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create", + deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete", + cors: "clouds.services.apigateway.http-api.cors", + webSocketTest: "clouds.services.apigateway.http-api.websocket-test", + }, monitoring: { metrics: "clouds.services.monitoring.metrics", metricData: "clouds.services.monitoring.metric-data", @@ -897,6 +910,17 @@ export const endpointRegistry: EndpointRegistry = new Map([ method: "POST", }, ], + [apiEndpointKeys.clouds.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}], + [apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}], + [apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}], + [apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}], + [apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}], + [apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}], + [apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}], + [apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}], + [apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}], + [apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}], + [apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}], [apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}], [apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}], [apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}], diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 1384bec..67c9d36 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -1546,6 +1546,139 @@ export async function setAlarmActionsEnabled( ); } +export type HttpApiProtocol = "HTTP" | "WEBSOCKET"; + +export interface HttpApiIntegrationOverview { + integrationId: string; + type: string; + uri: string | null; + functionName: string | null; + payloadFormatVersion: string | null; +} + +export interface HttpApiRouteOverview { + routeId: string; + routeKey: string; + integration: HttpApiIntegrationOverview | null; + authorizationType: string; + authorizerId: string | null; + authorizationScopes: string[]; + returnsResponse: boolean; +} + +export interface HttpApiStageOverview { + stageName: string; + autoDeploy: boolean; + deploymentId: string | null; + lastUpdatedAt: string | null; + invokePath: string; +} + +export interface HttpApiAuthorizerOverview { + authorizerId: string; + name: string; + type: string; + identitySource: string[]; + issuer: string | null; + audience: string[]; +} + +export interface HttpApiCors { + allowOrigins: string[]; + allowMethods: string[]; + allowHeaders: string[]; + exposeHeaders: string[]; + maxAgeSeconds: number | null; + allowCredentials: boolean; +} + +export interface HttpApiOverview { + apiId: string; + protocol: HttpApiProtocol; + apiEndpoint: string | null; + routeSelectionExpression: string | null; + routes: HttpApiRouteOverview[]; + stages: HttpApiStageOverview[]; + authorizers: HttpApiAuthorizerOverview[]; + cors: HttpApiCors | null; +} + +export interface HttpApiRouteInput { + routeKey: string; + functionName?: string; + httpUrl?: string; + authorizerId?: string; + returnsResponse?: boolean; +} + +export interface HttpApiAuthorizerInput { + name: string; + userPoolId?: string; + userPoolClientId?: string; + issuer?: string; + audience?: string[]; +} + +export interface HttpApiWebSocketTestResult { + connected: boolean; + messages: string[]; + closeCode: number | null; + error: string | null; + durationMs: number; +} + +export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId }); + return res.data; +} + +export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId }); + return res.data; +} + +export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise { + await apiClient.call(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId }); +} + +export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId }); + return res.data; +} + +export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId }); + return res.data; +} + +export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise { + await apiClient.call(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName }); +} + +export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName }); + return res.data; +} + +export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId }); + return res.data; +} + +export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise { + await apiClient.call(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId }); +} + +export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise { + const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId }); + return res.data.cors; +} + +export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise { + const res = await apiClient.call(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId }); + return res.data; +} + export async function setDistributionEnabled( cloud: CloudProvider, distributionId: string, diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx index a4b21ed..3c30274 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -8,6 +8,7 @@ import type {CloudResource} from '@/types/resource' import {CopyValueButton} from './CopyValueButton' import {LogsQueryPanel} from './LogsQueryPanel' import {ServiceIcon} from '@/components/serviceIcons' +import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway' interface ApiGatewayRoutePanelProps { cloud: CloudProvider @@ -138,8 +139,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime return (
-

Select a REST API

-

Select an API Gateway REST API to connect a route to a Lambda function.

+

Select an API

+

Select an API to see its routes, or create one.

) @@ -513,49 +514,6 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes } -function invokeEndpoint(runtimeEndpoint?: string): string | null { - if (!runtimeEndpoint) return null - try { - const endpoint = new URL(runtimeEndpoint) - if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') { - endpoint.hostname = window.location.hostname - } - return endpoint.toString().replace(/\/$/, '') - } catch { - return runtimeEndpoint.replace(/\/$/, '') - } -} - -function isDockerBridgeAddress(hostname: string): boolean { - const octets = hostname.split('.').map(Number) - return octets.length === 4 - && octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255) - && octets[0] === 172 - && octets[1] >= 16 - && octets[1] <= 31 -} - -function parseHeaders(value: string): Record { - let parsed: unknown - try { - parsed = JSON.parse(value || '{}') - } catch { - throw new Error('Request headers must be valid JSON.') - } - if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed) || Object.values(parsed).some((header) => typeof header !== 'string')) { - throw new Error('Request headers must be a JSON object with string values.') - } - return parsed as Record -} - -function formatResponseBody(body: string): string { - try { - return JSON.stringify(JSON.parse(body), null, 2) - } catch { - return body || '(empty response body)' - } -} - function buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string { let headers: Record = {} try { diff --git a/packages/frontend/src/components/DynamicResourceView.tsx b/packages/frontend/src/components/DynamicResourceView.tsx index 9e67128..e97677d 100644 --- a/packages/frontend/src/components/DynamicResourceView.tsx +++ b/packages/frontend/src/components/DynamicResourceView.tsx @@ -48,6 +48,7 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress"; import { AppConfigPanel } from "@/components/AppConfigPanel"; import { KmsCryptoPanel } from "@/components/KmsCryptoPanel"; import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel"; +import { HttpApiPanel } from "@/components/HttpApiPanel"; import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel"; import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel"; import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel"; @@ -1037,14 +1038,21 @@ export function DynamicResourceView({ )} )} - {service === "apigateway" && ( + {service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? ( + + ) : ( - )} + ))} {service === "events" && ( ({ + describeHttpApi: vi.fn(), + listCloudResources: vi.fn(), + createHttpApiRoute: vi.fn(), + deleteHttpApiRoute: vi.fn(), + setHttpApiRouteAuthorizer: vi.fn(), + createHttpApiStage: vi.fn(), + deleteHttpApiStage: vi.fn(), + deployHttpApi: vi.fn(), + createHttpApiAuthorizer: vi.fn(), + deleteHttpApiAuthorizer: vi.fn(), + updateHttpApiCors: vi.fn(), + testApiGatewayRoute: vi.fn(), + testWebSocketApi: vi.fn(), +})) + +vi.mock('@/api/cloudProxyClient', () => api) + +const httpResource: CloudResource = { + id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null, + metadata: {apigatewayService: 'http', protocol: 'HTTP'}, +} + +const httpOverview: HttpApiOverview = { + apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null, + routes: [ + {routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false}, + {routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false}, + ], + stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}], + authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}], + cors: null, +} + +function renderPanel(resource: CloudResource = httpResource) { + const client = new QueryClient({defaultOptions: {queries: {retry: false}}}) + return render() +} + +beforeEach(() => { + vi.clearAllMocks() + api.describeHttpApi.mockResolvedValue(httpOverview) + api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless' + ? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}] + : [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}]) + api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0]) + api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0]) + api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0]) + api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false}) + api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12}) +}) + +test('shows the stage URL, routes with their targets and who may call them', async () => { + renderPanel() + + expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument() + const routes = within(screen.getByRole('region', {name: 'Routes'})) + expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument() + expect(routes.getAllByText('Lambda: notes')).toHaveLength(2) + expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1') + expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('') +}) + +test('adds a Lambda route and protects an existing one', async () => { + renderPanel() + const routes = within(await screen.findByRole('region', {name: 'Routes'})) + + await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST') + await userEvent.clear(routes.getByRole('textbox', {name: 'Path'})) + await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes') + await routes.findByRole('option', {name: 'notes'}) + await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes') + await userEvent.click(routes.getByRole('button', {name: 'Add route'})) + expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined}) + + await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1') + expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1') +}) + +test('creates a JWT authorizer from a Cognito pool and app client', async () => { + renderPanel() + const security = within(await screen.findByRole('region', {name: 'Security'})) + + await security.findByRole('option', {name: 'notes-users'}) + await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool') + await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1') + await userEvent.click(security.getByRole('button', {name: 'Add authorizer'})) + expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'}) +}) + +test('sends a request to the stage and shows the response', async () => { + renderPanel() + const tester = within(await screen.findByRole('region', {name: 'Send a request'})) + + expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1') + await userEvent.click(tester.getByRole('button', {name: 'Send request'})) + expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'})) + expect(await tester.findByText('200')).toBeInTheDocument() +}) + +test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => { + api.describeHttpApi.mockResolvedValue({ + apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action', + routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}], + stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}], + authorizers: [], cors: null, + } satisfies HttpApiOverview) + api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40}) + renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}}) + + expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument() + expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument() + const tester = within(screen.getByRole('region', {name: 'Send a message'})) + expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2)) + await userEvent.click(tester.getByRole('button', {name: 'Connect and send'})) + expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'})) + expect(await tester.findByText('Connected')).toBeInTheDocument() +}) diff --git a/packages/frontend/src/components/HttpApiPanel.tsx b/packages/frontend/src/components/HttpApiPanel.tsx new file mode 100644 index 0000000..c337d43 --- /dev/null +++ b/packages/frontend/src/components/HttpApiPanel.tsx @@ -0,0 +1,610 @@ +import {useEffect, useState} from 'react' +import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import { + createHttpApiAuthorizer, + createHttpApiRoute, + createHttpApiStage, + deleteHttpApiAuthorizer, + deleteHttpApiRoute, + deleteHttpApiStage, + deployHttpApi, + describeHttpApi, + listCloudResources, + setHttpApiRouteAuthorizer, + testApiGatewayRoute, + testWebSocketApi, + updateHttpApiCors, +} from '@/api/cloudProxyClient' +import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient' +import type {CloudProvider} from '@/types/cloud' +import type {CloudResource} from '@/types/resource' +import {formatDateTime} from '@/lib/format' +import {CopyValueButton} from './CopyValueButton' +import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway' + +interface HttpApiPanelProps { + cloud: CloudProvider + resource: CloudResource + runtimeReachable: boolean + runtimeEndpoint?: string +} + +const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS'] +const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'] +const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*'] +const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default'] + +/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */ +export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) { + const queryKey = ['http-api', cloud, resource.id] + const query = useQuery({ + queryKey, + queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal), + enabled: runtimeReachable, + }) + const api = query.data + const endpoint = invokeEndpoint(runtimeEndpoint) + const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET' + + return
+
+
+
+

{isWebSocket ? 'WebSocket API' : 'HTTP API'}

+ {isWebSocket + ? <>Messages are routed by {api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}. + : 'Routes send requests to Lambda functions or HTTP backends.'} +
+ +
+ {query.isLoading &&

Loading the API

} + {query.error instanceof Error &&

{query.error.message}

} + {api && } +
+ {api && <> + + + {api.protocol === 'HTTP' && } + {api.protocol === 'HTTP' + ? + : } + } +
+} + +function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) { + if (api.stages.length === 0) return

No stages yet. Add one below to serve the API.

+ return
+ {api.stages.map((stage) => { + const url = stageUrl(api, stage, endpoint) + return
+ {stage.stageName} {stage.autoDeploy && · deploys automatically} + {url ?
{url}
: {stage.invokePath}} +
+ })} +
+} + +type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]} + +function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) { + const queryClient = useQueryClient() + const isWebSocket = api.protocol === 'WEBSOCKET' + const [method, setMethod] = useState('GET') + const [path, setPath] = useState('/hello') + const [catchAll, setCatchAll] = useState(false) + const [webSocketKey, setWebSocketKey] = useState('$default') + const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda') + const [functionName, setFunctionName] = useState('') + const [httpUrl, setHttpUrl] = useState('') + const [authorizerId, setAuthorizerId] = useState('') + const [returnsResponse, setReturnsResponse] = useState(true) + const [confirmDelete, setConfirmDelete] = useState(null) + + useEffect(() => { + setConfirmDelete(null) + setAuthorizerId('') + }, [api.apiId]) + + const functionsQuery = useQuery({ + queryKey: ['cloud-resources', cloud, 'serverless'], + queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal), + enabled: runtimeReachable, + }) + const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? [] + const refresh = () => queryClient.invalidateQueries({queryKey}) + + const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}` + const createMutation = useMutation({ + mutationFn: () => createHttpApiRoute(cloud, api.apiId, { + routeKey, + functionName: targetKind === 'lambda' ? functionName : undefined, + httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined, + authorizerId: authorizerId || undefined, + returnsResponse: isWebSocket ? returnsResponse : undefined, + }), + onSuccess: refresh, + }) + const deleteMutation = useMutation({ + mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId), + onSuccess: () => { + setConfirmDelete(null) + return refresh() + }, + }) + const authorizerMutation = useMutation({ + mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer), + onSuccess: refresh, + }) + + const target = targetKind === 'lambda' ? functionName : httpUrl.trim() + const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending + const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id + const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error + + return
+
+
+

Routes

+ {isWebSocket + ? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.' + : 'The most specific route wins; $default catches every request no other route matches.'} +
+
+ {api.routes.length === 0 &&

No routes yet.

} + {api.routes.length > 0 &&
+ {!isWebSocket && } + {api.routes.map((route) => + + + {!isWebSocket && } + + )} +
RouteTargetAccess
{route.routeKey}{isWebSocket && route.returnsResponse && · replies}{describeTarget(route)} + + {route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && Authorizer {authorizerName(route.authorizerId)}} + + {confirmDelete === route.routeId + ? + Delete {route.routeKey}? + + + + : } +
} + +
{ + event.preventDefault() + if (canCreate) createMutation.mutate() + }}> + {isWebSocket ? : <> + + + + } + {!isWebSocket && } + {targetKind === 'lambda' || isWebSocket + ? + : } + {!isWebSocket && api.authorizers.length > 0 && } + {isWebSocket && } + +
+ {error instanceof Error &&

{error.message}

} +
+} + +function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) { + const queryClient = useQueryClient() + const [stageName, setStageName] = useState('prod') + const [autoDeploy, setAutoDeploy] = useState(true) + const [confirmDelete, setConfirmDelete] = useState(null) + const [notice, setNotice] = useState('') + const refresh = () => queryClient.invalidateQueries({queryKey}) + + useEffect(() => { + setConfirmDelete(null) + setNotice('') + }, [api.apiId]) + + const createMutation = useMutation({ + mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}), + onSuccess: (stage) => { + setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`) + return refresh() + }, + }) + const deployMutation = useMutation({ + mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name), + onSuccess: (stage) => { + setNotice(`Deployed the current routes to ${stage.stageName}.`) + return refresh() + }, + }) + const deleteMutation = useMutation({ + mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name), + onSuccess: () => { + setConfirmDelete(null) + return refresh() + }, + }) + const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error + const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending + + return
+
+
+

Stages

+ A stage serves a deployment of the routes. With automatic deployment, every change is live right away. +
+
+ {api.stages.length > 0 &&
+ + {api.stages.map((stage) => + + + + + )} +
StageDeploymentUpdated
{stage.stageName}{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? {stage.deploymentId} : 'Not deployed'}{formatDateTime(stage.lastUpdatedAt) ?? '—'} + {!stage.autoDeploy && } + {confirmDelete === stage.stageName + ? + Delete {stage.stageName}? + + + + : } +
} +
{ + event.preventDefault() + setNotice('') + if (stageName.trim()) createMutation.mutate() + }}> + + + +
+ {notice &&

{notice}

} + {error instanceof Error &&

{error.message}

} +
+} + +function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) { + return
+
+
+

Security

+ A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above. +
+
+ + +
+} + +function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) { + const queryClient = useQueryClient() + const [source, setSource] = useState<'cognito' | 'issuer'>('cognito') + const [name, setName] = useState('cognito') + const [userPoolId, setUserPoolId] = useState('') + const [clientId, setClientId] = useState('') + const [issuer, setIssuer] = useState('') + const [audience, setAudience] = useState('') + const refresh = () => queryClient.invalidateQueries({queryKey}) + + const poolsQuery = useQuery({ + queryKey: ['cloud-resources', cloud, 'userpools'], + queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal), + enabled: runtimeReachable, + }) + const pools = poolsQuery.data ?? [] + const pool = pools.find((candidate) => candidate.id === userPoolId) + const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : [] + + const createMutation = useMutation({ + mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito' + ? {name: name.trim(), userPoolId, userPoolClientId: clientId} + : {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}), + onSuccess: refresh, + }) + const deleteMutation = useMutation({ + mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId), + onSuccess: refresh, + }) + const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim()) + const error = createMutation.error ?? deleteMutation.error + + return <> + {api.authorizers.length > 0 &&
+ + {api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => + + + + + )} +
AuthorizerIssuerAudience
{authorizer.name} · {authorizer.type}{authorizer.issuer ?? '—'}{authorizer.audience.join(', ') || '—'}
} +
{ + event.preventDefault() + if (ready && name.trim()) createMutation.mutate() + }}> + + + {source === 'cognito' ? <> + + + : <> + + + } + +
+ {error instanceof Error &&

{error.message}

} + +} + +function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) { + const queryClient = useQueryClient() + const [origins, setOrigins] = useState('') + const [methods, setMethods] = useState([]) + const [headers, setHeaders] = useState('') + const [exposeHeaders, setExposeHeaders] = useState('') + const [maxAge, setMaxAge] = useState('') + const [credentials, setCredentials] = useState(false) + const [notice, setNotice] = useState('') + + useEffect(() => { + const cors = api.cors + setOrigins(cors?.allowOrigins.join(', ') ?? '*') + setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS']) + setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization') + setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '') + setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds)) + setCredentials(cors?.allowCredentials ?? false) + setNotice('') + }, [api.apiId, api.cors]) + + const mutation = useMutation({ + mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors), + onSuccess: (cors) => { + setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.') + return queryClient.invalidateQueries({queryKey}) + }, + }) + const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean) + + return <> +
+

CORS

+ {api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'} +
+
{ + event.preventDefault() + setNotice('') + mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials}) + }}> + +
Allowed methods
{CORS_METHODS.map((value) => )}
+ + + + +
+ + {api.cors && } +
+
+ {notice &&

{notice}

} + {mutation.error instanceof Error &&

{mutation.error.message}

} + +} + +function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) { + const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default') + const [method, setMethod] = useState('GET') + const [path, setPath] = useState(suggestedPath(api.routes)) + const [query, setQuery] = useState('') + const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}') + const [body, setBody] = useState('') + + useEffect(() => { + setStage(api.stages[0]?.stageName ?? '$default') + setPath(suggestedPath(api.routes)) + // Only when another API is opened; keep what the user typed while this one refreshes. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [api.apiId]) + + const mutation = useMutation({ + mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}), + }) + const result = mutation.data + + return
+
+

Send a request

+ Sends a real request to the stage, through its routes and authorizers. For a protected route, add an authorization header with Bearer and a token. +
+
+
{ + event.preventDefault() + if (!mutation.isPending) mutation.mutate() + }}> + + + + +