diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts index 6c2cf50..9379533 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.test.ts @@ -14,6 +14,7 @@ import { PutIntegrationResponseCommand, PutMethodCommand, PutMethodResponseCommand, + TestInvokeMethodCommand, type APIGatewayClient, } from '@aws-sdk/client-api-gateway' import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda' @@ -263,6 +264,47 @@ describe('AwsApiGatewayAdapter', () => { } }) + test('tests a configured method through the AWS SDK without a deployed stage', async () => { + const commands: unknown[] = [] + const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { + commands.push(command) + if (command instanceof TestInvokeMethodCommand) { + return { + status: 201, + body: '{"id":"order-123"}', + headers: {'x-request-id': 'request-123'}, + multiValueHeaders: {'set-cookie': ['first', 'second']}, + log: 'Execution completed', + latency: 17, + } + } + throw new Error('Unexpected command') + })) + + await expect(adapter.testApiGatewayMethod('a1b2c3', { + resourceId: 'resource456', + httpMethod: 'POST', + pathWithQueryString: '/orders?dryRun=true', + body: '{"name":"Ada"}', + headers: {'content-type': 'application/json'}, + multiValueHeaders: {'x-tag': ['one', 'two']}, + stageVariables: {environment: 'test'}, + })).resolves.toEqual({ + statusCode: 201, + body: '{"id":"order-123"}', + headers: {'x-request-id': 'request-123'}, + multiValueHeaders: {'set-cookie': ['first', 'second']}, + log: 'Execution completed', + latency: 17, + }) + expect((commands[0] as TestInvokeMethodCommand).input).toEqual({ + restApiId: 'a1b2c3', resourceId: 'resource456', httpMethod: 'POST', + pathWithQueryString: '/orders?dryRun=true', body: '{"name":"Ada"}', + headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, + stageVariables: {environment: 'test'}, + }) + }) + test('lists deployed methods, Lambda integrations, and stages', async () => { const adapter = new AwsApiGatewayAdapter(fakeClient((command) => { if (command instanceof GetResourcesCommand) { diff --git a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts index 052703b..dc0d4a8 100644 --- a/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts +++ b/packages/api/src/adapter-aws/AwsApiGatewayAdapter.ts @@ -13,6 +13,7 @@ import { PutIntegrationResponseCommand, PutMethodCommand, PutMethodResponseCommand, + TestInvokeMethodCommand, type APIGatewayClient, type Integration, type IntegrationResponse, @@ -24,7 +25,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 {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} 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 { readonly cloud = 'aws' as const @@ -173,6 +174,38 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter { } } + async testApiGatewayMethod(apiId: string, input: ApiGatewayMethodTestInput): Promise { + const resourceId = stringValue(input.resourceId) + const httpMethod = input.httpMethod.toUpperCase() + const pathWithQueryString = stringValue(input.pathWithQueryString) + if (!resourceId) throw new ValidationError('resourceId is required') + if (!['GET', 'POST', 'PUT', 'DELETE'].includes(httpMethod)) { + throw new ValidationError('httpMethod must be GET, POST, PUT, or DELETE') + } + if (!pathWithQueryString.startsWith('/')) { + throw new ValidationError('pathWithQueryString must start with /') + } + + const result = await this.apiGateway.send(new TestInvokeMethodCommand({ + restApiId: apiId, + resourceId, + httpMethod, + pathWithQueryString, + body: input.body, + headers: input.headers, + multiValueHeaders: input.multiValueHeaders, + stageVariables: input.stageVariables, + })) + return { + statusCode: result.status ?? 0, + body: result.body ?? '', + headers: result.headers ?? {}, + multiValueHeaders: result.multiValueHeaders ?? {}, + log: result.log ?? null, + latency: result.latency ?? null, + } + } + async listApiGatewayRoutes(apiId: string): Promise { const resources = [] let position: string | undefined diff --git a/packages/api/src/cloud-spi/types.ts b/packages/api/src/cloud-spi/types.ts index 72171a4..232f5b8 100644 --- a/packages/api/src/cloud-spi/types.ts +++ b/packages/api/src/cloud-spi/types.ts @@ -503,6 +503,25 @@ export interface ApiGatewayTestRouteResult { durationMs: number } +export interface ApiGatewayMethodTestInput { + resourceId: string + httpMethod: string + pathWithQueryString: string + body: string + headers: Record + multiValueHeaders: Record + stageVariables: Record +} + +export interface ApiGatewayMethodTestResult { + statusCode: number + body: string + headers: Record + multiValueHeaders: Record + log: string | null + latency: number | null +} + export interface ApiGatewayRouteOverview { resourceId: string path: string @@ -679,6 +698,7 @@ export interface CloudServiceAdapter { deleteSnsSubscription?(subscriptionArn: string): Promise createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise + testApiGatewayMethod?(apiId: string, input: ApiGatewayMethodTestInput): Promise listApiGatewayRoutes?(apiId: string): Promise deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise diff --git a/packages/api/src/routes/clouds.test.ts b/packages/api/src/routes/clouds.test.ts index 20cce13..24fa236 100644 --- a/packages/api/src/routes/clouds.test.ts +++ b/packages/api/src/routes/clouds.test.ts @@ -169,6 +169,34 @@ describe('API Gateway CORS routes', () => { }) }) +describe('API Gateway method test routes', () => { + test('dispatches the AWS TestInvokeMethod input without requiring a stage', async () => { + const calls: unknown[] = [] + const app = appWithRoutes([mockAdapter('aws', { + service: 'apigateway', + testApiGatewayMethod: async (apiId, input) => { + calls.push({apiId, input}) + return {statusCode: 200, body: '{}', headers: {}, multiValueHeaders: {}, log: 'ok', latency: 4} + }, + })]) + + const response = await app.request('/api/clouds/aws/services/apigateway/resources/api-123/methods/test', { + method: 'POST', headers: {'content-type': 'application/json'}, + body: JSON.stringify({ + resourceId: 'resource-456', httpMethod: 'POST', pathWithQueryString: '/orders?dryRun=true', body: '{}', + headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'}, + }), + }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({statusCode: 200, body: '{}', headers: {}, multiValueHeaders: {}, log: 'ok', latency: 4}) + expect(calls).toEqual([{apiId: 'api-123', input: { + resourceId: 'resource-456', httpMethod: 'POST', pathWithQueryString: '/orders?dryRun=true', body: '{}', + headers: {'content-type': 'application/json'}, multiValueHeaders: {'x-tag': ['one', 'two']}, stageVariables: {environment: 'test'}, + }}]) + }) +}) + describe('S3 website routes', () => { test('routes normalized website configuration lifecycle requests through the storage adapter', async () => { const calls: Array<{operation: string; args: unknown[]}> = [] diff --git a/packages/api/src/routes/clouds.ts b/packages/api/src/routes/clouds.ts index dab2cf4..c966266 100644 --- a/packages/api/src/routes/clouds.ts +++ b/packages/api/src/routes/clouds.ts @@ -589,6 +589,25 @@ export function createCloudRoutes(injectedService?: CloudProxyService) { }) }) + app.post('/:cloud/services/apigateway/resources/:id/methods/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).testApiGatewayMethod(cloud, c.req.param('id'), { + resourceId: typeof input.resourceId === 'string' ? input.resourceId : '', + httpMethod: typeof input.httpMethod === 'string' ? input.httpMethod : '', + pathWithQueryString: typeof input.pathWithQueryString === 'string' ? input.pathWithQueryString : '', + body: typeof input.body === 'string' ? input.body : '', + headers: stringHeaders(input.headers), + multiValueHeaders: stringArrayHeaders(input.multiValueHeaders), + stageVariables: stringHeaders(input.stageVariables), + }) + return c.json(result) + }) + }) + app.get('/:cloud/services/storage/resources/:id/lambda-triggers', async (c) => { const cloud = c.req.param('cloud') as CloudProvider if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404) @@ -1178,6 +1197,13 @@ function stringHeaders(value: unknown): Record { return Object.fromEntries(entries) as Record } +function stringArrayHeaders(value: unknown): Record { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {} + return Object.fromEntries(Object.entries(value).flatMap(([name, values]) => + Array.isArray(values) && values.every((header) => typeof header === 'string') ? [[name, values]] : [], + )) +} + 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 14dd801..8e8aea0 100644 --- a/packages/api/src/service/CloudProxyService.ts +++ b/packages/api/src/service/CloudProxyService.ts @@ -24,6 +24,8 @@ import type { ApiGatewayLambdaRouteResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, + ApiGatewayMethodTestInput, + ApiGatewayMethodTestResult, ApiGatewayRoutesResult, ApiGatewayDeleteRouteInput, ApiGatewayCorsInput, @@ -269,6 +271,14 @@ export class CloudProxyService { return adapter.testApiGatewayRoute(apiId, input) } + async testApiGatewayMethod(cloud: CloudProvider, apiId: string, input: ApiGatewayMethodTestInput): Promise { + const adapter = this.requireAdapter(cloud, 'apigateway') + if (!adapter.testApiGatewayMethod) { + throw new NotSupportedError(`API Gateway method testing is not supported for ${cloud}`) + } + return adapter.testApiGatewayMethod(apiId, input) + } + async listApiGatewayRoutes(cloud: CloudProvider, apiId: string): Promise { const adapter = this.requireAdapter(cloud, 'apigateway') if (!adapter.listApiGatewayRoutes) { diff --git a/packages/frontend/src/api/api.ts b/packages/frontend/src/api/api.ts index 0c9f375..ff9f24f 100644 --- a/packages/frontend/src/api/api.ts +++ b/packages/frontend/src/api/api.ts @@ -33,7 +33,8 @@ export const apiEndpointKeys = { listApiGatewayRoutes: "clouds.services.resources.apigateway.routes.list", deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete", enableApiGatewayCors: "clouds.services.resources.apigateway.cors.enable", - testApiGatewayRoute: "clouds.services.resources.apigateway.route.test", + testApiGatewayRoute: "clouds.services.resources.apigateway.route.test", + testApiGatewayMethod: "clouds.services.resources.apigateway.method.test", createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create", listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list", deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete", @@ -393,6 +394,14 @@ export const endpointRegistry: EndpointRegistry = new Map([ telemetry: { service: "apigateway" }, }, ], + [ + apiEndpointKeys.clouds.resources.testApiGatewayMethod, + { + path: "/clouds/:cloud/services/apigateway/resources/:id/methods/test", + method: "POST", + telemetry: { service: "apigateway" }, + }, + ], [ apiEndpointKeys.clouds.resources.enableApiGatewayCors, { diff --git a/packages/frontend/src/api/cloudProxyClient.ts b/packages/frontend/src/api/cloudProxyClient.ts index 5604eda..22620a6 100644 --- a/packages/frontend/src/api/cloudProxyClient.ts +++ b/packages/frontend/src/api/cloudProxyClient.ts @@ -263,6 +263,25 @@ export interface ApiGatewayTestRouteResult { durationMs: number; } +export interface TestApiGatewayMethodInput { + resourceId: string; + httpMethod: string; + pathWithQueryString: string; + body: string; + headers: Record; + multiValueHeaders: Record; + stageVariables: Record; +} + +export interface ApiGatewayMethodTestResult { + statusCode: number; + body: string; + headers: Record; + multiValueHeaders: Record; + log: string | null; + latency: number | null; +} + export interface ApiGatewayRouteOverview { resourceId: string; path: string; @@ -445,6 +464,20 @@ export async function testApiGatewayRoute( return res.data; } +export async function testApiGatewayMethod( + cloud: CloudProvider, + apiId: string, + input: TestApiGatewayMethodInput, + signal?: AbortSignal, +): Promise { + const res = await apiClient.call( + apiEndpointKeys.clouds.resources.testApiGatewayMethod, + requestOptions(cloud, "apigateway", { signal, body: input, timeout: INVOKE_TIMEOUT_MS }), + { cloud, id: apiId }, + ); + return res.data; +} + export async function createS3LambdaTrigger( cloud: CloudProvider, bucketName: string, diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx index 00eb239..8463a3d 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.test.tsx @@ -12,6 +12,7 @@ const apiMocks = vi.hoisted(() => ({ listApiGatewayRoutes: vi.fn(), listCloudResources: vi.fn(), queryLogs: vi.fn(), + testApiGatewayMethod: vi.fn(), testApiGatewayRoute: vi.fn(), })) @@ -106,7 +107,7 @@ test('sends a route test request and displays its response', async () => { , ) - await user.click(screen.getByRole('button', {name: 'Send test request'})) + await user.click(screen.getByRole('button', {name: 'Send deployed HTTP request'})) await waitFor(() => { expect(apiMocks.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', { @@ -120,6 +121,33 @@ test('sends a route test request and displays its response', async () => { expect(document.querySelector('.apigateway-test-result')).not.toBeInTheDocument() }) +test('tests a selected method through TestInvokeMethod without a deployed stage', async () => { + apiMocks.listCloudResources.mockResolvedValue([lambda]) + apiMocks.listApiGatewayRoutes.mockResolvedValue({ + routes: [{resourceId: 'resource-123', path: '/orders', method: 'POST', functionName: 'orders-handler'}], stages: [], + }) + apiMocks.testApiGatewayMethod.mockResolvedValue({ + statusCode: 200, body: '{"message":"Hello"}', headers: {'content-type': 'application/json'}, + multiValueHeaders: {'set-cookie': ['session=1']}, log: 'Execution completed', latency: 8, + }) + const user = userEvent.setup() + const queryClient = new QueryClient({defaultOptions: {queries: {retry: false}}}) + + render() + + const methodTest = screen.getByRole('button', {name: 'Test method'}) + expect(methodTest).toBeDisabled() + await user.click(await screen.findByRole('button', {name: /POST.*\/orders.*orders-handler/})) + await user.click(methodTest) + + await waitFor(() => expect(apiMocks.testApiGatewayMethod).toHaveBeenCalledWith('aws', 'api-123', { + resourceId: 'resource-123', httpMethod: 'POST', pathWithQueryString: '/orders', body: '{}', + headers: {'content-type': 'application/json'}, multiValueHeaders: {'content-type': ['application/json']}, stageVariables: {}, + })) + expect(await screen.findByText('Execution completed')).toBeInTheDocument() + expect(screen.getByText(/session=1/)).toBeInTheDocument() +}) + test('formats the request body as JSON', async () => { apiMocks.listCloudResources.mockResolvedValue([lambda]) apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']}) diff --git a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx index 100d4d3..b83630e 100644 --- a/packages/frontend/src/components/ApiGatewayRoutePanel.tsx +++ b/packages/frontend/src/components/ApiGatewayRoutePanel.tsx @@ -1,7 +1,8 @@ import {useEffect, useState} from 'react' import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react' import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query' -import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient' +import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayMethod, testApiGatewayRoute} from '@/api/cloudProxyClient' +import type {ApiGatewayMethodTestResult} from '@/api/cloudProxyClient' import type {CloudProvider} from '@/types/cloud' import type {CloudResource} from '@/types/resource' import {CopyValueButton} from './CopyValueButton' @@ -22,6 +23,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime const [testBody, setTestBody] = useState('{}') const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}') const [testQuery, setTestQuery] = useState('') + const [stageVariables, setStageVariables] = useState('{}') + const [selectedResourceId, setSelectedResourceId] = useState(null) const [curlCopied, setCurlCopied] = useState(false) const [bodyFormatError, setBodyFormatError] = useState(null) const [logsRefreshKey, setLogsRefreshKey] = useState(0) @@ -51,6 +54,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime setTestBody('{}') setTestHeaders('{\n "content-type": "application/json"\n}') setTestQuery('') + setStageVariables('{}') + setSelectedResourceId(null) setCurlCopied(false) setBodyFormatError(null) setLogsRefreshKey(0) @@ -77,6 +82,21 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime }), onSuccess: () => setLogsRefreshKey((value) => value + 1), }) + const methodTestMutation = useMutation({ + mutationFn: () => { + const headers = parseHeaders(testHeaders) + return testApiGatewayMethod(cloud, resource!.id, { + resourceId: selectedResourceId!, + httpMethod: method, + pathWithQueryString: `${path}${testQuery ? `?${testQuery.replace(/^\?/, '')}` : ''}`, + body: testBody, + headers, + multiValueHeaders: Object.fromEntries(Object.entries(headers).map(([name, value]) => [name, [value]])), + stageVariables: parseHeaders(stageVariables), + }) + }, + onSuccess: () => setLogsRefreshKey((value) => value + 1), + }) const deleteRouteMutation = useMutation({ mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}), onSuccess: () => { @@ -126,7 +146,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime

Connect Lambda

Creates and deploys one Lambda proxy route to the selected REST API.

- {testMutation.data && functionName && ( + {(testMutation.data || methodTestMutation.data) && functionName && (
-

Deployed Routes

+

Configured Methods

Route explorer

-

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

+

Select a configured method for Method Test, or to fill the deployed HTTP test workspace.

- {routesQuery.isLoading &&

Loading deployed routes...

} + {routesQuery.isLoading &&

Loading configured methods...

} {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?.routes.length === 0 &&

No configured methods yet. Create a route above.

} {routesQuery.data && routesQuery.data.routes.length > 0 && (
{routesQuery.data.routes.map((route) => { @@ -208,10 +228,12 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime return
-
-
-
-

Test Route

-

Send request

-

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

-
-
-
+
+
+
+

Method Test

+

Test configured method

+

Uses API Gateway TestInvokeMethod and does not require a deployed stage. Select a method above first.

+
+
+
+
{ + event.preventDefault() + if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate() + }}> +