adding a real test Method for API gateway
This commit is contained in:
@@ -14,6 +14,7 @@ import {
|
|||||||
PutIntegrationResponseCommand,
|
PutIntegrationResponseCommand,
|
||||||
PutMethodCommand,
|
PutMethodCommand,
|
||||||
PutMethodResponseCommand,
|
PutMethodResponseCommand,
|
||||||
|
TestInvokeMethodCommand,
|
||||||
type APIGatewayClient,
|
type APIGatewayClient,
|
||||||
} from '@aws-sdk/client-api-gateway'
|
} from '@aws-sdk/client-api-gateway'
|
||||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
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 () => {
|
test('lists deployed methods, Lambda integrations, and stages', async () => {
|
||||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||||
if (command instanceof GetResourcesCommand) {
|
if (command instanceof GetResourcesCommand) {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
PutIntegrationResponseCommand,
|
PutIntegrationResponseCommand,
|
||||||
PutMethodCommand,
|
PutMethodCommand,
|
||||||
PutMethodResponseCommand,
|
PutMethodResponseCommand,
|
||||||
|
TestInvokeMethodCommand,
|
||||||
type APIGatewayClient,
|
type APIGatewayClient,
|
||||||
type Integration,
|
type Integration,
|
||||||
type IntegrationResponse,
|
type IntegrationResponse,
|
||||||
@@ -24,7 +25,7 @@ import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLamb
|
|||||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||||
import {ValidationError} from '../cloud-spi/errors'
|
import {ValidationError} from '../cloud-spi/errors'
|
||||||
import {awsEndpoint} from '../service/runtimeProbe'
|
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 {
|
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||||
readonly cloud = 'aws' as const
|
readonly cloud = 'aws' as const
|
||||||
@@ -173,6 +174,38 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async testApiGatewayMethod(apiId: string, input: ApiGatewayMethodTestInput): Promise<ApiGatewayMethodTestResult> {
|
||||||
|
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<ApiGatewayRoutesResult> {
|
async listApiGatewayRoutes(apiId: string): Promise<ApiGatewayRoutesResult> {
|
||||||
const resources = []
|
const resources = []
|
||||||
let position: string | undefined
|
let position: string | undefined
|
||||||
|
|||||||
@@ -503,6 +503,25 @@ export interface ApiGatewayTestRouteResult {
|
|||||||
durationMs: number
|
durationMs: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ApiGatewayMethodTestInput {
|
||||||
|
resourceId: string
|
||||||
|
httpMethod: string
|
||||||
|
pathWithQueryString: string
|
||||||
|
body: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
multiValueHeaders: Record<string, string[]>
|
||||||
|
stageVariables: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiGatewayMethodTestResult {
|
||||||
|
statusCode: number
|
||||||
|
body: string
|
||||||
|
headers: Record<string, string>
|
||||||
|
multiValueHeaders: Record<string, string[]>
|
||||||
|
log: string | null
|
||||||
|
latency: number | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiGatewayRouteOverview {
|
export interface ApiGatewayRouteOverview {
|
||||||
resourceId: string
|
resourceId: string
|
||||||
path: string
|
path: string
|
||||||
@@ -679,6 +698,7 @@ export interface CloudServiceAdapter {
|
|||||||
deleteSnsSubscription?(subscriptionArn: string): Promise<void>
|
deleteSnsSubscription?(subscriptionArn: string): Promise<void>
|
||||||
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
|
createApiGatewayLambdaRoute?(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult>
|
||||||
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
|
testApiGatewayRoute?(apiId: string, input: ApiGatewayTestRouteInput): Promise<ApiGatewayTestRouteResult>
|
||||||
|
testApiGatewayMethod?(apiId: string, input: ApiGatewayMethodTestInput): Promise<ApiGatewayMethodTestResult>
|
||||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||||
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
||||||
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
||||||
|
|||||||
@@ -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', () => {
|
describe('S3 website routes', () => {
|
||||||
test('routes normalized website configuration lifecycle requests through the storage adapter', async () => {
|
test('routes normalized website configuration lifecycle requests through the storage adapter', async () => {
|
||||||
const calls: Array<{operation: string; args: unknown[]}> = []
|
const calls: Array<{operation: string; args: unknown[]}> = []
|
||||||
|
|||||||
@@ -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<Record<string, unknown>>()
|
||||||
|
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) => {
|
app.get('/:cloud/services/storage/resources/:id/lambda-triggers', async (c) => {
|
||||||
const cloud = c.req.param('cloud') as CloudProvider
|
const cloud = c.req.param('cloud') as CloudProvider
|
||||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||||
@@ -1178,6 +1197,13 @@ function stringHeaders(value: unknown): Record<string, string> {
|
|||||||
return Object.fromEntries(entries) as Record<string, string>
|
return Object.fromEntries(entries) as Record<string, string>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stringArrayHeaders(value: unknown): Record<string, string[]> {
|
||||||
|
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 {
|
function decodeBase64(value: unknown, field: string, maxLength: number): Uint8Array {
|
||||||
if (typeof value !== 'string') throw new ValidationError(`${field} must be a base64 string`)
|
if (typeof value !== 'string') throw new ValidationError(`${field} must be a base64 string`)
|
||||||
if (value.length > maxLength) throw new ValidationError(`${field} is too large`)
|
if (value.length > maxLength) throw new ValidationError(`${field} is too large`)
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ import type {
|
|||||||
ApiGatewayLambdaRouteResult,
|
ApiGatewayLambdaRouteResult,
|
||||||
ApiGatewayTestRouteInput,
|
ApiGatewayTestRouteInput,
|
||||||
ApiGatewayTestRouteResult,
|
ApiGatewayTestRouteResult,
|
||||||
|
ApiGatewayMethodTestInput,
|
||||||
|
ApiGatewayMethodTestResult,
|
||||||
ApiGatewayRoutesResult,
|
ApiGatewayRoutesResult,
|
||||||
ApiGatewayDeleteRouteInput,
|
ApiGatewayDeleteRouteInput,
|
||||||
ApiGatewayCorsInput,
|
ApiGatewayCorsInput,
|
||||||
@@ -269,6 +271,14 @@ export class CloudProxyService {
|
|||||||
return adapter.testApiGatewayRoute(apiId, input)
|
return adapter.testApiGatewayRoute(apiId, input)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async testApiGatewayMethod(cloud: CloudProvider, apiId: string, input: ApiGatewayMethodTestInput): Promise<ApiGatewayMethodTestResult> {
|
||||||
|
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<ApiGatewayRoutesResult> {
|
async listApiGatewayRoutes(cloud: CloudProvider, apiId: string): Promise<ApiGatewayRoutesResult> {
|
||||||
const adapter = this.requireAdapter(cloud, 'apigateway')
|
const adapter = this.requireAdapter(cloud, 'apigateway')
|
||||||
if (!adapter.listApiGatewayRoutes) {
|
if (!adapter.listApiGatewayRoutes) {
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ export const apiEndpointKeys = {
|
|||||||
deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete",
|
deleteApiGatewayRoute: "clouds.services.resources.apigateway.route.delete",
|
||||||
enableApiGatewayCors: "clouds.services.resources.apigateway.cors.enable",
|
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",
|
createS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.create",
|
||||||
listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list",
|
listS3LambdaTriggers: "clouds.services.resources.storage.lambda-triggers.list",
|
||||||
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
|
deleteS3LambdaTrigger: "clouds.services.resources.storage.lambda-trigger.delete",
|
||||||
@@ -393,6 +394,14 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
|||||||
telemetry: { service: "apigateway" },
|
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,
|
apiEndpointKeys.clouds.resources.enableApiGatewayCors,
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -263,6 +263,25 @@ export interface ApiGatewayTestRouteResult {
|
|||||||
durationMs: number;
|
durationMs: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface TestApiGatewayMethodInput {
|
||||||
|
resourceId: string;
|
||||||
|
httpMethod: string;
|
||||||
|
pathWithQueryString: string;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
multiValueHeaders: Record<string, string[]>;
|
||||||
|
stageVariables: Record<string, string>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ApiGatewayMethodTestResult {
|
||||||
|
statusCode: number;
|
||||||
|
body: string;
|
||||||
|
headers: Record<string, string>;
|
||||||
|
multiValueHeaders: Record<string, string[]>;
|
||||||
|
log: string | null;
|
||||||
|
latency: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ApiGatewayRouteOverview {
|
export interface ApiGatewayRouteOverview {
|
||||||
resourceId: string;
|
resourceId: string;
|
||||||
path: string;
|
path: string;
|
||||||
@@ -445,6 +464,20 @@ export async function testApiGatewayRoute(
|
|||||||
return res.data;
|
return res.data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function testApiGatewayMethod(
|
||||||
|
cloud: CloudProvider,
|
||||||
|
apiId: string,
|
||||||
|
input: TestApiGatewayMethodInput,
|
||||||
|
signal?: AbortSignal,
|
||||||
|
): Promise<ApiGatewayMethodTestResult> {
|
||||||
|
const res = await apiClient.call<ApiGatewayMethodTestResult, TestApiGatewayMethodInput>(
|
||||||
|
apiEndpointKeys.clouds.resources.testApiGatewayMethod,
|
||||||
|
requestOptions(cloud, "apigateway", { signal, body: input, timeout: INVOKE_TIMEOUT_MS }),
|
||||||
|
{ cloud, id: apiId },
|
||||||
|
);
|
||||||
|
return res.data;
|
||||||
|
}
|
||||||
|
|
||||||
export async function createS3LambdaTrigger(
|
export async function createS3LambdaTrigger(
|
||||||
cloud: CloudProvider,
|
cloud: CloudProvider,
|
||||||
bucketName: string,
|
bucketName: string,
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const apiMocks = vi.hoisted(() => ({
|
|||||||
listApiGatewayRoutes: vi.fn(),
|
listApiGatewayRoutes: vi.fn(),
|
||||||
listCloudResources: vi.fn(),
|
listCloudResources: vi.fn(),
|
||||||
queryLogs: vi.fn(),
|
queryLogs: vi.fn(),
|
||||||
|
testApiGatewayMethod: vi.fn(),
|
||||||
testApiGatewayRoute: vi.fn(),
|
testApiGatewayRoute: vi.fn(),
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -106,7 +107,7 @@ test('sends a route test request and displays its response', async () => {
|
|||||||
</QueryClientProvider>,
|
</QueryClientProvider>,
|
||||||
)
|
)
|
||||||
|
|
||||||
await user.click(screen.getByRole('button', {name: 'Send test request'}))
|
await user.click(screen.getByRole('button', {name: 'Send deployed HTTP request'}))
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(apiMocks.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'api-123', {
|
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()
|
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(<QueryClientProvider client={queryClient}><ApiGatewayRoutePanel cloud="aws" resource={api} runtimeReachable/></QueryClientProvider>)
|
||||||
|
|
||||||
|
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 () => {
|
test('formats the request body as JSON', async () => {
|
||||||
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
apiMocks.listCloudResources.mockResolvedValue([lambda])
|
||||||
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import {useEffect, useState} from 'react'
|
import {useEffect, useState} from 'react'
|
||||||
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-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 {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 {CloudProvider} from '@/types/cloud'
|
||||||
import type {CloudResource} from '@/types/resource'
|
import type {CloudResource} from '@/types/resource'
|
||||||
import {CopyValueButton} from './CopyValueButton'
|
import {CopyValueButton} from './CopyValueButton'
|
||||||
@@ -22,6 +23,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
const [testBody, setTestBody] = useState('{}')
|
const [testBody, setTestBody] = useState('{}')
|
||||||
const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}')
|
const [testHeaders, setTestHeaders] = useState('{\n "content-type": "application/json"\n}')
|
||||||
const [testQuery, setTestQuery] = useState('')
|
const [testQuery, setTestQuery] = useState('')
|
||||||
|
const [stageVariables, setStageVariables] = useState('{}')
|
||||||
|
const [selectedResourceId, setSelectedResourceId] = useState<string | null>(null)
|
||||||
const [curlCopied, setCurlCopied] = useState(false)
|
const [curlCopied, setCurlCopied] = useState(false)
|
||||||
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
|
const [bodyFormatError, setBodyFormatError] = useState<string | null>(null)
|
||||||
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
|
const [logsRefreshKey, setLogsRefreshKey] = useState(0)
|
||||||
@@ -51,6 +54,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
setTestBody('{}')
|
setTestBody('{}')
|
||||||
setTestHeaders('{\n "content-type": "application/json"\n}')
|
setTestHeaders('{\n "content-type": "application/json"\n}')
|
||||||
setTestQuery('')
|
setTestQuery('')
|
||||||
|
setStageVariables('{}')
|
||||||
|
setSelectedResourceId(null)
|
||||||
setCurlCopied(false)
|
setCurlCopied(false)
|
||||||
setBodyFormatError(null)
|
setBodyFormatError(null)
|
||||||
setLogsRefreshKey(0)
|
setLogsRefreshKey(0)
|
||||||
@@ -77,6 +82,21 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
}),
|
}),
|
||||||
onSuccess: () => setLogsRefreshKey((value) => value + 1),
|
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({
|
const deleteRouteMutation = useMutation({
|
||||||
mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}),
|
mutationFn: ({resourceId, path, method}: {resourceId: string; path: string; method: string}) => deleteApiGatewayRoute(cloud, resource!.id, {resourceId, path, method, stage}),
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
@@ -126,7 +146,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
<h3><Link2 size={15}/> Connect Lambda</h3>
|
<h3><Link2 size={15}/> Connect Lambda</h3>
|
||||||
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
|
<p className="muted compact-text">Creates and deploys one Lambda proxy route to the selected REST API.</p>
|
||||||
</div>
|
</div>
|
||||||
{testMutation.data && functionName && (
|
{(testMutation.data || methodTestMutation.data) && functionName && (
|
||||||
<LogsQueryPanel
|
<LogsQueryPanel
|
||||||
cloud={cloud}
|
cloud={cloud}
|
||||||
logGroupName={`/aws/lambda/${functionName}`}
|
logGroupName={`/aws/lambda/${functionName}`}
|
||||||
@@ -188,14 +208,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
<div className="apigateway-route-explorer">
|
<div className="apigateway-route-explorer">
|
||||||
<div className="dynamic-stage-header">
|
<div className="dynamic-stage-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Deployed Routes</p>
|
<p className="eyebrow">Configured Methods</p>
|
||||||
<h3><ListTree size={15}/> Route explorer</h3>
|
<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>
|
<p className="muted compact-text">Select a configured method for Method Test, or to fill the deployed HTTP test workspace.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{routesQuery.isLoading && <p className="muted compact-text">Loading deployed routes...</p>}
|
{routesQuery.isLoading && <p className="muted compact-text">Loading configured methods...</p>}
|
||||||
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API 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?.routes.length === 0 && <p className="muted compact-text">No configured methods yet. Create a route above.</p>}
|
||||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||||
<div className="apigateway-route-list">
|
<div className="apigateway-route-list">
|
||||||
{routesQuery.data.routes.map((route) => {
|
{routesQuery.data.routes.map((route) => {
|
||||||
@@ -210,8 +230,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
setPath(route.path)
|
setPath(route.path)
|
||||||
setMethod(route.method)
|
setMethod(route.method)
|
||||||
setFunctionName(route.functionName ?? '')
|
setFunctionName(route.functionName ?? '')
|
||||||
|
setSelectedResourceId(route.resourceId)
|
||||||
setStage(routesQuery.data.stages.includes(stage) ? stage : routesQuery.data.stages[0] ?? stage)
|
setStage(routesQuery.data.stages.includes(stage) ? stage : routesQuery.data.stages[0] ?? stage)
|
||||||
testMutation.reset()
|
testMutation.reset()
|
||||||
|
methodTestMutation.reset()
|
||||||
}}>
|
}}>
|
||||||
<span className="apigateway-route-method">{route.method}</span>
|
<span className="apigateway-route-method">{route.method}</span>
|
||||||
<code>{route.path}</code>
|
<code>{route.path}</code>
|
||||||
@@ -246,9 +268,33 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
<div className="apigateway-test-panel">
|
<div className="apigateway-test-panel">
|
||||||
<div className="dynamic-stage-header">
|
<div className="dynamic-stage-header">
|
||||||
<div>
|
<div>
|
||||||
<p className="eyebrow">Test Route</p>
|
<p className="eyebrow">Method Test</p>
|
||||||
<h3><Send size={15}/> Send request</h3>
|
<h3><Send size={15}/> Test configured method</h3>
|
||||||
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint.</p>
|
<p className="muted compact-text">Uses API Gateway TestInvokeMethod and does not require a deployed stage. Select a method above first.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="apigateway-test-workspace">
|
||||||
|
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||||
|
event.preventDefault()
|
||||||
|
if (selectedResourceId && !methodTestMutation.isPending) methodTestMutation.mutate()
|
||||||
|
}}>
|
||||||
|
<label className="dynamic-field">
|
||||||
|
<span>Stage variables (JSON)</span>
|
||||||
|
<textarea className="textarea code-textarea small" value={stageVariables} onChange={(event) => setStageVariables(event.target.value)} spellCheck={false}/>
|
||||||
|
<small>Optional variables available during this method test only.</small>
|
||||||
|
</label>
|
||||||
|
<button className="button primary apigateway-test-submit" type="submit" disabled={!selectedResourceId || methodTestMutation.isPending}>
|
||||||
|
{methodTestMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>} {methodTestMutation.isPending ? 'Testing method' : 'Test method'}
|
||||||
|
</button>
|
||||||
|
{methodTestMutation.isError && <p className="error-text compact-text">{methodTestMutation.error instanceof Error ? methodTestMutation.error.message : 'Unable to test API method.'}</p>}
|
||||||
|
</form>
|
||||||
|
<MethodTestResponse result={methodTestMutation.data} dismiss={() => methodTestMutation.reset()}/>
|
||||||
|
</div>
|
||||||
|
<div className="dynamic-stage-header">
|
||||||
|
<div>
|
||||||
|
<p className="eyebrow">Deployed HTTP Test</p>
|
||||||
|
<h3><Send size={15}/> Send deployed request</h3>
|
||||||
|
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="apigateway-request-flow" aria-label="API request flow">
|
<div className="apigateway-request-flow" aria-label="API request flow">
|
||||||
@@ -319,7 +365,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
|||||||
</div>
|
</div>
|
||||||
<button className="button primary apigateway-test-submit" type="submit" disabled={testMutation.isPending}>
|
<button className="button primary apigateway-test-submit" type="submit" disabled={testMutation.isPending}>
|
||||||
{testMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}
|
{testMutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}
|
||||||
{testMutation.isPending ? 'Sending' : 'Send test request'}
|
{testMutation.isPending ? 'Sending' : 'Send deployed HTTP request'}
|
||||||
</button>
|
</button>
|
||||||
<button className="button" type="button" onClick={() => {
|
<button className="button" type="button" onClick={() => {
|
||||||
void navigator.clipboard.writeText(curlCommand)
|
void navigator.clipboard.writeText(curlCommand)
|
||||||
@@ -371,6 +417,29 @@ func handler(w http.ResponseWriter, r *http.Request) {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestResult; dismiss: () => void}) {
|
||||||
|
if (!result) return <p className="muted compact-text">Select a configured method and test it without deploying a stage.</p>
|
||||||
|
return <div className="apigateway-test-result">
|
||||||
|
<div className="apigateway-test-result-header">
|
||||||
|
<strong>Method response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
|
||||||
|
<div className="apigateway-test-result-actions">
|
||||||
|
{result.latency !== null && <small>{result.latency} ms</small>}
|
||||||
|
<button className="icon-button" type="button" onClick={dismiss} aria-label="Dismiss method response"><X size={14}/></button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>Response headers</summary>
|
||||||
|
<pre>{JSON.stringify(result.headers, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>Multi-value response headers</summary>
|
||||||
|
<pre>{JSON.stringify(result.multiValueHeaders, null, 2)}</pre>
|
||||||
|
</details>
|
||||||
|
{result.log && <details><summary>Execution log</summary><pre>{result.log}</pre></details>}
|
||||||
|
<pre>{formatResponseBody(result.body)}</pre>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
function invokeEndpoint(runtimeEndpoint?: string): string | null {
|
function invokeEndpoint(runtimeEndpoint?: string): string | null {
|
||||||
if (!runtimeEndpoint) return null
|
if (!runtimeEndpoint) return null
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user