adding a real test Method for API gateway

This commit is contained in:
Zakaria
2026-09-27 20:14:59 -04:00
parent e8843a462b
commit ce8400757c
10 changed files with 321 additions and 23 deletions
+10 -1
View File
@@ -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,
{
@@ -263,6 +263,25 @@ export interface ApiGatewayTestRouteResult {
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 {
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<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(
cloud: CloudProvider,
bucketName: string,
@@ -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 () => {
</QueryClientProvider>,
)
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(<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 () => {
apiMocks.listCloudResources.mockResolvedValue([lambda])
apiMocks.listApiGatewayRoutes.mockResolvedValue({routes: [], stages: ['dev']})
@@ -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<string | null>(null)
const [curlCopied, setCurlCopied] = useState(false)
const [bodyFormatError, setBodyFormatError] = useState<string | null>(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
<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>
</div>
{testMutation.data && functionName && (
{(testMutation.data || methodTestMutation.data) && functionName && (
<LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${functionName}`}
@@ -188,14 +208,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
<div className="apigateway-route-explorer">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Deployed Routes</p>
<p className="eyebrow">Configured Methods</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>
<p className="muted compact-text">Select a configured method for Method Test, or to fill the deployed HTTP test workspace.</p>
</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.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 && (
<div className="apigateway-route-list">
{routesQuery.data.routes.map((route) => {
@@ -208,10 +228,12 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
<button className="apigateway-route-select" type="button" onClick={() => {
setPath(route.path)
setMethod(route.method)
setFunctionName(route.functionName ?? '')
setStage(routesQuery.data.stages.includes(stage) ? stage : routesQuery.data.stages[0] ?? stage)
testMutation.reset()
setMethod(route.method)
setFunctionName(route.functionName ?? '')
setSelectedResourceId(route.resourceId)
setStage(routesQuery.data.stages.includes(stage) ? stage : routesQuery.data.stages[0] ?? stage)
testMutation.reset()
methodTestMutation.reset()
}}>
<span className="apigateway-route-method">{route.method}</span>
<code>{route.path}</code>
@@ -243,15 +265,39 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
)}
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
</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-test-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Method Test</p>
<h3><Send size={15}/> Test configured method</h3>
<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 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>
@@ -319,7 +365,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
</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'}
{testMutation.isPending ? 'Sending' : 'Send deployed HTTP request'}
</button>
<button className="button" type="button" onClick={() => {
void navigator.clipboard.writeText(curlCommand)
@@ -371,6 +417,29 @@ func handler(w http.ResponseWriter, r *http.Request) {
</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 {
if (!runtimeEndpoint) return null
try {