feat: add API Gateway route testing and discovery
This commit is contained in:
@@ -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(() => ({})))
|
||||
|
||||
|
||||
@@ -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<ApiGatewayTestRouteResult> {
|
||||
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<ApiGatewayRoutesResult> {
|
||||
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 {
|
||||
|
||||
@@ -463,6 +463,34 @@ export interface ApiGatewayLambdaRouteResult {
|
||||
invokeUrl: string
|
||||
}
|
||||
|
||||
export interface ApiGatewayTestRouteInput {
|
||||
path: string
|
||||
method: string
|
||||
stage: string
|
||||
body: string
|
||||
headers: Record<string, string>
|
||||
query: string
|
||||
}
|
||||
|
||||
export interface ApiGatewayTestRouteResult {
|
||||
statusCode: number
|
||||
headers: Record<string, string>
|
||||
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<string[]>
|
||||
invoke?(id: string, payload: string): Promise<ServerlessInvokeResult>
|
||||
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
|
||||
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
|
||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||
encrypt?(id: string, input: KmsEncryptInput): Promise<KmsEncryptResult>
|
||||
decrypt?(id: string, input: KmsDecryptInput): Promise<KmsDecryptResult>
|
||||
// Lifecycle verbs. Optional because most categories have no notion of them;
|
||||
|
||||
@@ -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<Record<string, unknown>>()
|
||||
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<string, string> | undefined {
|
||||
return entries.length === 0 ? undefined : Object.fromEntries(entries) as Record<string, string>
|
||||
}
|
||||
|
||||
function stringHeaders(value: unknown): Record<string, string> {
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
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`)
|
||||
|
||||
@@ -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<ApiGatewayTestRouteResult> {
|
||||
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<ApiGatewayRoutesResult> {
|
||||
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<CloudResource> {
|
||||
const adapter = this.requireAdapter(cloud, service)
|
||||
if (!adapter.update) throw new NotSupportedError(`Resource updates are not supported for ${cloud}/${service}`)
|
||||
|
||||
@@ -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,
|
||||
{
|
||||
|
||||
@@ -217,6 +217,34 @@ export interface ApiGatewayLambdaRoute {
|
||||
invokeUrl: string;
|
||||
}
|
||||
|
||||
export interface TestApiGatewayRouteInput {
|
||||
path: string;
|
||||
method: string;
|
||||
stage: string;
|
||||
body: string;
|
||||
headers: Record<string, string>;
|
||||
query: string;
|
||||
}
|
||||
|
||||
export interface ApiGatewayTestRouteResult {
|
||||
statusCode: number;
|
||||
headers: Record<string, string>;
|
||||
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<ApiGatewayRoutesResult> {
|
||||
const res = await apiClient.call<ApiGatewayRoutesResult>(
|
||||
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<ApiGatewayTestRouteResult> {
|
||||
const res = await apiClient.call<ApiGatewayTestRouteResult, TestApiGatewayRouteInput>(
|
||||
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"
|
||||
|
||||
@@ -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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/>
|
||||
</QueryClientProvider>,
|
||||
)
|
||||
|
||||
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')
|
||||
})
|
||||
|
||||
@@ -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<string | null>(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
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="apigateway-route-explorer">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Deployed Routes</p>
|
||||
<h3><ListTree size={15}/> Route explorer</h3>
|
||||
<p className="muted compact-text">Select a route to fill the test workspace from its deployed API Gateway configuration.</p>
|
||||
</div>
|
||||
</div>
|
||||
{routesQuery.isLoading && <p className="muted compact-text">Loading deployed routes...</p>}
|
||||
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
|
||||
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No deployed methods yet. Create and deploy a route above.</p>}
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
return <button className={`apigateway-route-item${selected ? ' selected' : ''}`} type="button" key={`${route.resourceId}-${route.method}`} onClick={() => {
|
||||
setPath(route.path)
|
||||
setMethod(route.method)
|
||||
setFunctionName(route.functionName ?? '')
|
||||
setStage(routesQuery.data.stages.includes(stage) ? stage : routesQuery.data.stages[0] ?? stage)
|
||||
testMutation.reset()
|
||||
}}>
|
||||
<span className="apigateway-route-method">{route.method}</span>
|
||||
<code>{route.path}</code>
|
||||
<small>{route.functionName ?? 'No Lambda integration'}</small>
|
||||
</button>
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="apigateway-test-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
<p className="eyebrow">Test Route</p>
|
||||
<h3><Send size={15}/> Send request</h3>
|
||||
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-request-flow" aria-label="API request flow">
|
||||
<div className="apigateway-flow-step">
|
||||
<Send size={16}/>
|
||||
<div><strong>Test request</strong><small>{method} {path}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Waypoints size={16}/>
|
||||
<div><strong>API Gateway</strong><small>{resource.name} · {stage}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Braces size={16}/>
|
||||
<div><strong>Lambda proxy</strong><small>{functionName || 'Connected Lambda'}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step apigateway-flow-response">
|
||||
<ArrowRight size={16}/>
|
||||
<div><strong>Response</strong><small>Status, headers, body</small></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!testMutation.isPending) testMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Request headers (JSON)</span>
|
||||
<textarea className="textarea code-textarea small" value={testHeaders} onChange={(event) => setTestHeaders(event.target.value)} spellCheck={false}/>
|
||||
</label>
|
||||
<div className="dynamic-field apigateway-test-body">
|
||||
<div className="apigateway-test-body-label">
|
||||
<span>Request body</span>
|
||||
<button className="button ghost apigateway-format-button" type="button" onClick={() => {
|
||||
try {
|
||||
setTestBody(JSON.stringify(JSON.parse(testBody || '{}'), null, 2))
|
||||
setBodyFormatError(null)
|
||||
} catch {
|
||||
setBodyFormatError('Request body must be valid JSON to format it.')
|
||||
}
|
||||
}}>Format JSON</button>
|
||||
</div>
|
||||
<textarea aria-label="Request body" className="textarea code-textarea" value={testBody} onChange={(event) => {
|
||||
setTestBody(event.target.value)
|
||||
setBodyFormatError(null)
|
||||
}} onKeyDown={(event) => {
|
||||
if (event.key !== 'Tab') return
|
||||
event.preventDefault()
|
||||
const textarea = event.currentTarget
|
||||
const start = textarea.selectionStart
|
||||
const end = textarea.selectionEnd
|
||||
const indentation = event.shiftKey && testBody.slice(Math.max(0, start - 2), start) === ' ' ? '' : ' '
|
||||
const indentStart = indentation ? start : Math.max(0, start - 2)
|
||||
setTestBody(`${testBody.slice(0, indentStart)}${indentation}${testBody.slice(end)}`)
|
||||
requestAnimationFrame(() => {
|
||||
textarea.selectionStart = indentStart + indentation.length
|
||||
textarea.selectionEnd = indentStart + indentation.length
|
||||
})
|
||||
}} spellCheck={false}/>
|
||||
{bodyFormatError && <small className="error-text">{bodyFormatError}</small>}
|
||||
</div>
|
||||
<button className="button primary apigateway-test-submit" type="submit" disabled={testMutation.isPending}>
|
||||
{testMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}
|
||||
{testMutation.isPending ? 'Sending' : 'Send test request'}
|
||||
</button>
|
||||
{testMutation.isError && <p className="error-text compact-text">{testMutation.error instanceof Error ? testMutation.error.message : 'Unable to test API route.'}</p>}
|
||||
</form>
|
||||
<div className="apigateway-test-response">
|
||||
{testMutation.data ? (
|
||||
<div className="apigateway-test-result">
|
||||
<div className="apigateway-test-result-header">
|
||||
<strong>Response <span className={testMutation.data.statusCode < 400 ? 'status-success' : 'status-error'}>{testMutation.data.statusCode}</span></strong>
|
||||
<div className="apigateway-test-result-actions">
|
||||
<small>{testMutation.data.durationMs} ms</small>
|
||||
<button className="icon-button" type="button" onClick={() => testMutation.reset()} aria-label="Dismiss response"><X size={14}/></button>
|
||||
</div>
|
||||
</div>
|
||||
<details>
|
||||
<summary>Response headers</summary>
|
||||
<pre>{JSON.stringify(testMutation.data.headers, null, 2)}</pre>
|
||||
</details>
|
||||
<pre>{formatResponseBody(testMutation.data.body)}</pre>
|
||||
</div>
|
||||
) : <p className="muted compact-text">Send a request to inspect the API response.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
function parseHeaders(value: string): Record<string, string> {
|
||||
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<string, string>
|
||||
}
|
||||
|
||||
function formatResponseBody(body: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2)
|
||||
} catch {
|
||||
return body || '(empty response body)'
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2557,6 +2557,223 @@ pre,
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.apigateway-test-panel {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.apigateway-route-explorer {
|
||||
border-top: 1px solid var(--border);
|
||||
margin-top: 18px;
|
||||
padding-top: 16px;
|
||||
}
|
||||
|
||||
.apigateway-route-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
.apigateway-route-item {
|
||||
align-items: center;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
color: var(--text);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 7px 9px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.apigateway-route-item:hover,
|
||||
.apigateway-route-item.selected {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.apigateway-route-item small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.apigateway-route-method {
|
||||
color: var(--accent);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.apigateway-test-form {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.apigateway-test-workspace {
|
||||
display: grid;
|
||||
gap: 22px;
|
||||
grid-template-columns: minmax(300px, 360px) minmax(0, 1fr);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.apigateway-test-response {
|
||||
border-left: 1px solid var(--border);
|
||||
min-height: 280px;
|
||||
padding-left: 22px;
|
||||
}
|
||||
|
||||
.apigateway-request-flow {
|
||||
align-items: center;
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
margin-top: 12px;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.apigateway-flow-step {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
flex: 1 0 144px;
|
||||
gap: 8px;
|
||||
min-width: 144px;
|
||||
}
|
||||
|
||||
.apigateway-flow-step > svg {
|
||||
color: var(--accent);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.apigateway-flow-step strong,
|
||||
.apigateway-flow-step small {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.apigateway-flow-step strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.apigateway-flow-step small {
|
||||
color: var(--text-muted);
|
||||
font-size: 11px;
|
||||
margin-top: 2px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.apigateway-flow-arrow {
|
||||
color: var(--text-muted);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.apigateway-flow-response > svg {
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.apigateway-test-form .dynamic-field {
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.apigateway-test-form .code-textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.apigateway-test-body {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.apigateway-test-body-label {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.apigateway-format-button {
|
||||
font-size: 11px;
|
||||
min-height: 24px;
|
||||
padding: 2px 7px;
|
||||
}
|
||||
|
||||
.apigateway-test-body .code-textarea {
|
||||
min-height: 120px;
|
||||
}
|
||||
|
||||
.apigateway-test-submit {
|
||||
grid-column: auto;
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.apigateway-test-result {
|
||||
background: var(--surface-subtle);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
margin: 0;
|
||||
max-width: none;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.apigateway-test-result-header {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.apigateway-test-result-actions {
|
||||
align-items: center;
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.apigateway-test-result pre {
|
||||
margin: 8px 0 0;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.apigateway-test-result details {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.status-success {
|
||||
color: var(--status-success);
|
||||
}
|
||||
|
||||
.status-error {
|
||||
color: var(--status-error);
|
||||
}
|
||||
|
||||
@media (max-width: 700px) {
|
||||
.apigateway-test-form {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.apigateway-test-body {
|
||||
grid-column: auto;
|
||||
}
|
||||
|
||||
.apigateway-test-workspace {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.apigateway-test-response {
|
||||
border-left: 0;
|
||||
border-top: 1px solid var(--border);
|
||||
min-height: 0;
|
||||
padding: 16px 0 0;
|
||||
}
|
||||
|
||||
.apigateway-request-flow {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.snapshot-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
|
||||
Reference in New Issue
Block a user