feat: add API Gateway route testing and discovery
This commit is contained in:
@@ -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)'
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user