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() + }}> + + + + +