diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts index 7ed60ea..9ea4e13 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts @@ -6,7 +6,9 @@ import { DeleteRestApiCommand, GetRestApiCommand, GetRestApisCommand, + GetMethodCommand, GetResourcesCommand, + GetStagesCommand, PutIntegrationCommand, PutMethodCommand, type APIGatewayClient, @@ -176,6 +178,86 @@ describe('AwsApiGatewayAdapter', () => { }) }) + test('tests a deployed route and preserves its HTTP response', async () => { + const originalFetch = globalThis.fetch + const requests: Request[] = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)) + return new Response(JSON.stringify({error: 'invalid request'}), { + status: 400, + headers: {'content-type': 'application/json', 'x-request-id': 'request-123'}, + }) + }) as typeof fetch + try { + const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) + + const result = await adapter.testApiGatewayRoute('a1b2c3', { + path: '/hello', method: 'POST', stage: 'dev', body: '{"name":"Ada"}', + headers: {'x-client': 'console'}, query: 'verbose=true', + }) + + expect(requests).toHaveLength(1) + expect(requests[0]?.url).toBe('http://localhost:4566/execute-api/a1b2c3/dev/hello?verbose=true') + expect(requests[0]?.method).toBe('POST') + expect(requests[0]?.headers.get('content-type')).toBe('application/json') + expect(requests[0]?.headers.get('x-client')).toBe('console') + expect(await requests[0]?.text()).toBe('{"name":"Ada"}') + expect(result).toMatchObject({ + statusCode: 400, + headers: {'content-type': 'application/json', 'x-request-id': 'request-123'}, + body: '{"error":"invalid request"}', + }) + } finally { + globalThis.fetch = originalFetch + } + }) + + test('omits a body when testing a GET route', async () => { + const originalFetch = globalThis.fetch + const requests: Request[] = [] + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + requests.push(new Request(input, init)) + return new Response('{}') + }) as typeof fetch + try { + const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) + + await adapter.testApiGatewayRoute('a1b2c3', { + path: '/hello', method: 'GET', stage: 'dev', body: '{}', headers: {}, query: '', + }) + + expect(await requests[0]?.text()).toBe('') + } finally { + globalThis.fetch = originalFetch + } + }) + + test('lists deployed methods, Lambda integrations, and stages', async () => { + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + if (command instanceof GetResourcesCommand) { + return {items: [ + {id: 'root123', path: '/', resourceMethods: {}}, + {id: 'resource456', path: '/hello', resourceMethods: {GET: true, POST: true}}, + ]} + } + if (command instanceof GetMethodCommand) { + return {methodIntegration: { + uri: 'arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:000000000000:function:hello/invocations', + }} + } + if (command instanceof GetStagesCommand) return {item: [{stageName: 'prod'}, {stageName: 'dev'}]} + throw new Error('Unexpected command') + })) + + await expect(adapter.listApiGatewayRoutes('a1b2c3')).resolves.toEqual({ + routes: [ + {resourceId: 'resource456', path: '/hello', method: 'GET', functionName: 'hello'}, + {resourceId: 'resource456', path: '/hello', method: 'POST', functionName: 'hello'}, + ], + stages: ['dev', 'prod'], + }) + }) + test('returns the AWS API Gateway schema', () => { const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({}))) diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts index b681b56..dc3abf9 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts @@ -5,7 +5,9 @@ import { DeleteRestApiCommand, GetRestApiCommand, GetRestApisCommand, + GetMethodCommand, GetResourcesCommand, + GetStagesCommand, PutIntegrationCommand, PutMethodCommand, type APIGatewayClient, @@ -16,7 +18,7 @@ import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLamb import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema' import {ValidationError} from '../cloud-spi/errors' import {awsEndpoint} from '../service/runtimeProbe' -import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' +import type {ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types' export class AwsApiGatewayAdapter implements CloudServiceAdapter { readonly cloud = 'aws' as const @@ -132,6 +134,77 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { } } + async testApiGatewayRoute(apiId: string, input: ApiGatewayTestRouteInput): Promise { + const path = testRoutePath(input.path) + const method = input.method.toUpperCase() + const stage = stageName(input.stage) + if (!['GET', 'POST', 'PUT', 'DELETE'].includes(method)) { + throw new ValidationError('method must be GET, POST, PUT, or DELETE') + } + + const encodedPath = path.split('/').map(encodeURIComponent).join('/') + const url = new URL(`${awsEndpoint().replace(/\/$/, '')}/execute-api/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${encodedPath}`) + url.search = queryString(input.query) + const headers = new Headers(input.headers) + if (input.body && !headers.has('content-type')) headers.set('content-type', 'application/json') + + const startedAt = performance.now() + const response = await globalThis.fetch(url, { + method, + headers, + body: ['GET', 'HEAD', 'OPTIONS'].includes(method) ? undefined : input.body || undefined, + }) + return { + statusCode: response.status, + headers: Object.fromEntries(response.headers.entries()), + body: await response.text(), + durationMs: Math.round(performance.now() - startedAt), + } + } + + async listApiGatewayRoutes(apiId: string): Promise { + const resources = [] + let position: string | undefined + do { + const result = await this.apiGateway.send(new GetResourcesCommand({restApiId: apiId, position, embed: ['methods']})) + resources.push(...(result.items ?? [])) + position = result.position + } while (position) + + const routes: ApiGatewayRouteOverview[] = [] + for (const resource of resources) { + const methods = resource.resourceMethods + ? Object.keys(resource.resourceMethods).sort() + : ['DELETE', 'GET', 'HEAD', 'OPTIONS', 'PATCH', 'POST', 'PUT'] + for (const method of methods) { + if (!resource.id || !resource.path) continue + let result + try { + result = await this.apiGateway.send(new GetMethodCommand({ + restApiId: apiId, + resourceId: resource.id, + httpMethod: method, + })) + } catch (error) { + if (hasHttpStatus(error, 404)) continue + throw error + } + routes.push({ + resourceId: resource.id, + path: resource.path, + method, + functionName: lambdaFunctionName(result.methodIntegration?.uri), + }) + } + } + + const stages = await this.apiGateway.send(new GetStagesCommand({restApiId: apiId})) + return { + routes: routes.sort((left, right) => left.path.localeCompare(right.path) || left.method.localeCompare(right.method)), + stages: (stages.item ?? []).map((stage) => stage.stageName).filter((stage): stage is string => Boolean(stage)).sort(), + } + } + private async rootResource(apiId: string) { let position: string | undefined do { @@ -160,6 +233,25 @@ function stageName(stage: string): string { return value } +function queryString(query: string): string { + const value = stringValue(query) + if (value.length > 8_192) throw new ValidationError('query must be at most 8192 characters') + return value.replace(/^\?/, '') +} + +function testRoutePath(path: string): string { + const value = stringValue(path) + if (!/^\/(?:[A-Za-z0-9._~{}-]+\/)*[A-Za-z0-9._~{}-]+$/.test(value)) { + throw new ValidationError('path must be an API Gateway route path, for example /orders or /users/{id}') + } + return value +} + +function lambdaFunctionName(uri?: string): string | null { + const match = uri?.match(/\/functions\/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/) + return match?.[1] ?? null +} + function toResource(api: RestApi): CloudResource { const id = api.id ?? '' return { diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 5d9c076..6c309b2 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -463,6 +463,34 @@ export interface ApiGatewayLambdaRouteResult { invokeUrl: string } +export interface ApiGatewayTestRouteInput { + path: string + method: string + stage: string + body: string + headers: Record + query: string +} + +export interface ApiGatewayTestRouteResult { + statusCode: number + headers: Record + body: string + durationMs: number +} + +export interface ApiGatewayRouteOverview { + resourceId: string + path: string + method: string + functionName: string | null +} + +export interface ApiGatewayRoutesResult { + routes: ApiGatewayRouteOverview[] + stages: string[] +} + export type KmsEncryptionAlgorithm = | 'SYMMETRIC_DEFAULT' | 'RSAES_OAEP_SHA_1' @@ -525,6 +553,8 @@ export interface CloudServiceAdapter { listDatabaseOrderableInstanceClasses?(engine?: string): Promise invoke?(id: string, payload: string): Promise createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise + testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise + listApiGatewayRoutes?(apiId: string): Promise encrypt?(id: string, input: KmsEncryptInput): Promise decrypt?(id: string, input: KmsDecryptInput): Promise // Lifecycle verbs. Optional because most categories have no notion of them; diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index 91cc66d..1d181b9 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -522,6 +522,31 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.get('/:cloud/services/apigateway/resources/:id/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 () => c.json(await svc(c).listApiGatewayRoutes(cloud, c.req.param('id')))) + }) + + 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) + + return withRuntime(c, async () => { + const input = await c.req.json>() + const result = await svc(c).testApiGatewayRoute(cloud, c.req.param('id'), { + path: typeof input.path === 'string' ? input.path : '', + method: typeof input.method === 'string' ? input.method : '', + stage: typeof input.stage === 'string' ? input.stage : '', + body: typeof input.body === 'string' ? input.body : '', + headers: stringHeaders(input.headers), + query: typeof input.query === 'string' ? input.query : '', + }) + return c.json(result) + }) + }) + app.get('/:cloud/services/:service/resources/:id/collections', async (c) => { const target = childTarget(c) if (!target) return c.json({error: 'Unknown cloud or service'}, 404) @@ -857,6 +882,19 @@ function encryptionContext(value: unknown): Record | undefined { return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record } +function stringHeaders(value: unknown): Record { + if (value === undefined) return {} + if (!value || typeof value !== 'object' || Array.isArray(value)) { + throw new ValidationError('headers must be an object of string values') + } + + const entries = Object.entries(value) + if (entries.some(([, headerValue]) => typeof headerValue !== 'string')) { + throw new ValidationError('headers must be an object of string values') + } + return Object.fromEntries(entries) as Record +} + function decodeBase64(value: unknown, field: string, maxLength: number): Uint8Array { if (typeof value !== 'string') throw new ValidationError(`${field} must be a base64 string`) if (value.length > maxLength) throw new ValidationError(`${field} is too large`) diff --git a/packages/api/src/service/CloudProxyService.ts b/packages/api/src/service/CloudProxyService.ts index a2da280..beaecd4 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -22,6 +22,9 @@ import type { AppConfigHostedConfigurationVersion, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, + ApiGatewayTestRouteInput, + ApiGatewayTestRouteResult, + ApiGatewayRoutesResult, DatabaseSnapshot, KubernetesFargateProfile, KubernetesNodegroup, @@ -242,6 +245,22 @@ export class CloudProxyService { return adapter.createApiGatewayLambdaRoute(apiId, input) } + async testApiGatewayRoute(cloud: CloudProvider, apiId: string, input: ApiGatewayTestRouteInput): Promise { + const adapter = this.requireAdapter(cloud, 'apigateway') + if (!adapter.testApiGatewayRoute) { + throw new NotSupportedError(`API Gateway route testing is not supported for ${cloud}`) + } + return adapter.testApiGatewayRoute(apiId, input) + } + + async listApiGatewayRoutes(cloud: CloudProvider, apiId: string): Promise { + const adapter = this.requireAdapter(cloud, 'apigateway') + if (!adapter.listApiGatewayRoutes) { + throw new NotSupportedError(`API Gateway route discovery is not supported for ${cloud}`) + } + return adapter.listApiGatewayRoutes(apiId) + } + async updateResource(cloud: CloudProvider, service: CloudServiceType, id: string, input: UpdateResourceInput): Promise { const adapter = this.requireAdapter(cloud, service) if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`) diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index e8050ba..1330087 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -24,7 +24,9 @@ export const apiEndpointKeys = { invoke: "clouds.services.resources.invoke", encrypt: "clouds.services.resources.encrypt", decrypt: "clouds.services.resources.decrypt", - createApiGatewayRoute: "clouds.services.resources.apigateway.route.create", + createApiGatewayRoute: "clouds.services.resources.apigateway.route.create", + listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list", + testApiGatewayRoute: "clouds.services.resources.apigateway.route.test", }, storage: { objects: { @@ -300,6 +302,22 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "apigateway" }, }, ], + [ + apiEndpointKeys.clouds.resources.listApiGatewayRoutes, + { + path: "/clouds/:cloud/services/apigateway/resources/:id/routes", + method: "GET", + telemetry: { service: "apigateway" }, + }, + ], + [ + apiEndpointKeys.clouds.resources.testApiGatewayRoute, + { + path: "/clouds/:cloud/services/apigateway/resources/:id/routes/test", + method: "POST", + telemetry: { service: "apigateway" }, + }, + ], [ apiEndpointKeys.clouds.resources.get, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 3ce7db0..11367dc 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -217,6 +217,34 @@ export interface ApiGatewayLambdaRoute { invokeUrl: string; } +export interface TestApiGatewayRouteInput { + path: string; + method: string; + stage: string; + body: string; + headers: Record; + query: string; +} + +export interface ApiGatewayTestRouteResult { + statusCode: number; + headers: Record; + body: string; + durationMs: number; +} + +export interface ApiGatewayRouteOverview { + resourceId: string; + path: string; + method: string; + functionName: string | null; +} + +export interface ApiGatewayRoutesResult { + routes: ApiGatewayRouteOverview[]; + stages: string[]; +} + export async function createApiGatewayLambdaRoute( cloud: CloudProvider, apiId: string, @@ -231,6 +259,33 @@ export async function createApiGatewayLambdaRoute( return res.data; } +export async function listApiGatewayRoutes( + cloud: CloudProvider, + apiId: string, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.listApiGatewayRoutes, + requestOptions(cloud, "apigateway", { signal }), + { cloud, id: apiId }, + ); + return res.data; +} + +export async function testApiGatewayRoute( + cloud: CloudProvider, + apiId: string, + input: TestApiGatewayRouteInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.testApiGatewayRoute, + requestOptions(cloud, "apigateway", { signal, body: input, timeout: INVOKE_TIMEOUT_MS }), + { cloud, id: apiId }, + ); + return res.data; +} + export type KmsEncryptionAlgorithm = | "SYMMETRIC_DEFAULT" | "RSAES_OAEP_SHA_1" diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx index d25e537..96028b0 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx @@ -1,5 +1,5 @@ import {QueryClient, QueryClientProvider} from '@tanstack/react-query' -import {render, screen, waitFor} from '@testing-library/react' +import {fireEvent, render, screen, waitFor} from '@testing-library/react' import userEvent from '@testing-library/user-event' import {expect, test, vi} from 'vitest' import {ApiGatewayRoutePanel} from './ApiGatewayRoutePanel' @@ -7,7 +7,9 @@ import type {CloudResource} from '@/types/resource' const apiMocks = vi.hoisted(() => ({ createApiGatewayLambdaRoute: vi.fn(), + listApiGatewayRoutes: vi.fn(), listCloudResources: vi.fn(), + testApiGatewayRoute: vi.fn(), })) vi.mock('@/api/cloudProxyClient', () => apiMocks) @@ -24,6 +26,7 @@ const lambda: CloudResource = { test('creates and displays a deployed Lambda proxy route', async () => { apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) apiMocks.createApiGatewayLambdaRoute.mockResolvedValue({ resourceId: 'resource-123', deploymentId: 'deployment-123', invokeUrl: 'http://localhost:4566/execute-api/api-123/dev/orders', }) @@ -48,3 +51,76 @@ test('creates and displays a deployed Lambda proxy route', async () => { }) expect(await screen.findByText('Route deployed')).toBeInTheDocument() }) + +test('sends a route test request and displays its response', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) + apiMocks.testApiGatewayRoute.mockResolvedValue({ + statusCode: 200, + headers: {'content-type': 'application/json'}, + body: '{"message":"Hello"}', + durationMs: 12, + }) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render( + + + , + ) + + await user.click(screen.getByRole('button', {name: 'Send test request'})) + + await waitFor(() => { + expect(apiMocks.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', { + path: '/hello', method: 'GET', stage: 'dev', body: '{}', + headers: {'content-type': 'application/json'}, query: '', + }) + }) + expect(await screen.findByText('200', {selector: '.apigateway-test-result .status-success'})).toBeInTheDocument() + expect(screen.getByText(/"message": "Hello"/)).toBeInTheDocument() + await user.click(screen.getByRole('button', {name: 'Dismiss response'})) + expect(document.querySelector('.apigateway-test-result')).not.toBeInTheDocument() +}) + +test('formats the request body as JSON', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render( + + + , + ) + + const body = screen.getByLabelText('Request body') + fireEvent.change(body, {target: {value: '{"name":"Ada"}'}}) + await user.click(screen.getByRole('button', {name: 'Format JSON'})) + + expect(body).toHaveValue('{\n "name": "Ada"\n}') +}) + +test('selects a deployed route for testing', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({ + routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], + stages: ['dev'], + }) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render( + + + , + ) + + await user.click(await screen.findByRole('button', {name: /POST.*\/orders.*orders-handler/})) + + expect(screen.getByPlaceholderText('/orders')).toHaveValue('/orders') + expect(screen.getAllByRole('combobox')[0]).toHaveValue('POST') + expect(screen.getAllByRole('combobox')[1]).toHaveValue('orders-handler') +}) diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx index a53c186..cbdd102 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -1,7 +1,7 @@ import {useEffect, useState} from 'react' -import {Link2, Loader2, Rocket} from 'lucide-react' -import {useMutation, useQuery} from '@tanstack/react-query' -import {createApiGatewayLambdaRoute, listCloudResources} from '@/api/cloudProxyClient' +import {ArrowRight, Braces, Link2, ListTree, Loader2, Rocket, Send, Waypoints, X} from 'lucide-react' +import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' +import {createApiGatewayLambdaRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient' import type {CloudProvider} from '@/types/cloud' import type {CloudResource} from '@/types/resource' @@ -16,21 +16,44 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat const [method, setMethod] = useState('GET') const [stage, setStage] = useState('dev') const [functionName, setFunctionName] = useState('') + const [testBody, setTestBody] = useState('{}') + const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}') + const [bodyFormatError, setBodyFormatError] = useState(null) + const queryClient = useQueryClient() const functionsQuery = useQuery({ queryKey: ['cloud-resources', cloud, 'serverless'], queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal), enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, }) + const routesQuery = useQuery({ + queryKey: ['api-gateway-routes', cloud, resource?.id], + queryFn: ({signal}) => listApiGatewayRoutes(cloud, resource!.id, signal), + enabled: cloud === 'aws' && Boolean(resource) && runtimeReachable, + }) useEffect(() => { setPath('/hello') setMethod('GET') setStage('dev') setFunctionName('') + setTestBody('{}') + setTestHeaders('{\n "content-type": "application/json"\n}') + setBodyFormatError(null) }, [resource?.id]) const routeMutation = useMutation({ mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}), + onSuccess: () => queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}), + }) + const testMutation = useMutation({ + mutationFn: () => testApiGatewayRoute(cloud, resource!.id, { + path, + method, + stage, + body: testBody, + headers: parseHeaders(testHeaders), + query: '', + }), }) if (!resource || resource.service !== 'apigateway') { @@ -99,6 +122,152 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat )} +
+
+
+

Deployed Routes

+

Route explorer

+

Select a route to fill the test workspace from its deployed API Gateway configuration.

+
+
+ {routesQuery.isLoading &&

Loading deployed routes...

} + {routesQuery.isError &&

{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}

} + {routesQuery.data?.routes.length === 0 &&

No deployed methods yet. Create and deploy a route above.

} + {routesQuery.data && routesQuery.data.routes.length > 0 && ( +
+ {routesQuery.data.routes.map((route) => { + const selected = route.path === path && route.method === method + return + })} +
+ )} +
+
+
+
+

Test Route

+

Send request

+

Invokes {method} /{stage}{path} through the local API Gateway endpoint.

+
+
+
+
+ +
Test request{method} {path}
+
+ +
+ +
API Gateway{resource.name} ยท {stage}
+
+ +
+ +
Lambda proxy{functionName || 'Connected Lambda'}
+
+ +
+ +
ResponseStatus, headers, body
+
+
+
+
{ + event.preventDefault() + if (!testMutation.isPending) testMutation.mutate() + }}> +