Files
floci-ui/packages/frontend/src/components/ApiGatewayRoutePanel.tsx
T
Zakaria bb5ca85a88
CI / checks (pull_request) Successful in 26m49s
feat(api): add HTTP and WebSocket APIs (API Gateway v2) to the API Gateway page
API Gateway v2 is a separate AWS API from REST APIs, so HTTP and WebSocket APIs
made with the SDK, SAM, CDK or Terraform never showed up. The API Gateway page
now lists all three kinds with a Type column, creates each from the same form,
and opens HTTP and WebSocket APIs in their own panel:

- Quick create: an HTTP API with a Lambda gets a catch-all route on an
  auto-deployed $default stage, plus the Lambda permission.
- Routes to Lambda (payload 2.0) or HTTP backends; deleting a route removes
  the integration and Lambda permission only it used.
- Stages with or without automatic deployment, and deploy on demand.
- JWT authorizers filled in from a Cognito pool's OpenID Connect discovery
  document and an app client, or from any issuer; protect routes with them.
- CORS configuration.
- A tester that sends a real request to a stage, and for WebSocket APIs one
  that connects, sends a message and collects the replies.

Capabilities are advertised under a new httpApiActions group, covered by the
cloudProxy guard test. Verified end to end against TheCloud's runtime.
2026-10-25 09:18:50 -04:00

529 lines
34 KiB
TypeScript

import {useEffect, useState, type CSSProperties} from 'react'
import {ArrowRight, Copy, Link2, ListTree, Loader2, Rocket, ScrollText, Send, Trash2, X} from 'lucide-react'
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayMethod, testApiGatewayRoute} from '@/api/cloudProxyClient'
import type {ApiGatewayMethodTestResult, ApiGatewayResourceOverview} from '@/api/cloudProxyClient'
import type {CloudProvider} from '@/types/cloud'
import type {CloudResource} from '@/types/resource'
import {CopyValueButton} from './CopyValueButton'
import {LogsQueryPanel} from './LogsQueryPanel'
import {ServiceIcon} from '@/components/serviceIcons'
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
interface ApiGatewayRoutePanelProps {
cloud: CloudProvider
resource?: CloudResource
runtimeReachable: boolean
runtimeEndpoint?: string
}
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
const [path, setPath] = useState('/hello')
const [parentResourceId, setParentResourceId] = useState('')
const [pathPart, setPathPart] = useState('hello')
const [method, setMethod] = useState('GET')
const [anyTestMethod, setAnyTestMethod] = 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 [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)
const [deleteConfirmation, setDeleteConfirmation] = useState<string | null>(null)
const [corsConfirmation, setCorsConfirmation] = useState<{resourceId: string; path: string} | null>(null)
const [corsOrigin, setCorsOrigin] = useState('*')
const [corsHeaders, setCorsHeaders] = useState('Content-Type')
const [corsMethods, setCorsMethods] = useState<string[]>([])
const [corsProxyMethods, setCorsProxyMethods] = useState<string[]>([])
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')
setParentResourceId('')
setPathPart('hello')
setMethod('GET')
setAnyTestMethod('GET')
setStage('dev')
setFunctionName('')
setTestBody('{}')
setTestHeaders('{\n "content-type": "application/json"\n}')
setTestQuery('')
setStageVariables('{}')
setSelectedResourceId(null)
setCurlCopied(false)
setBodyFormatError(null)
setLogsRefreshKey(0)
setDeleteConfirmation(null)
setCorsConfirmation(null)
setCorsOrigin('*')
setCorsHeaders('Content-Type')
setCorsMethods([])
setCorsProxyMethods([])
}, [resource?.id])
const routeMutation = useMutation({
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {
path: createPath,
parentResourceId: selectedParent?.resourceId,
pathPart,
method,
stage,
functionName,
}),
onSuccess: () => queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]}),
})
const testMutation = useMutation({
mutationFn: () => testApiGatewayRoute(cloud, resource!.id, {
path,
method: deployedHttpMethod,
stage,
body: testBody,
headers: parseHeaders(testHeaders),
query: testQuery,
}),
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: () => {
setDeleteConfirmation(null)
testMutation.reset()
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
},
})
const corsMutation = useMutation({
mutationFn: () => enableApiGatewayCors(cloud, resource!.id, {
resourceId: corsConfirmation!.resourceId,
stage,
origin: corsOrigin,
headers: corsHeaders,
methods: corsMethods,
}),
onSuccess: (result) => {
setCorsProxyMethods(result.proxyMethods)
setCorsConfirmation(null)
queryClient.invalidateQueries({queryKey: ['api-gateway-routes', cloud, resource!.id]})
},
})
if (!resource || resource.service !== 'apigateway') {
return (
<section className="table-panel">
<div className="empty compact">
<h3>Select an API</h3>
<p>Select an API to see its routes, or create one.</p>
</div>
</section>
)
}
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
const apiResources = routesQuery.data?.resources ?? []
const rootResource = apiResources.find((item) => item.path === '/')
const selectedParent = apiResources.find((item) => item.resourceId === parentResourceId) ?? rootResource
const createPath = selectedParent ? childResourcePath(selectedParent.path, pathPart) : `/${pathPart}`
const deployedHttpMethod = method === 'ANY' ? anyTestMethod : method
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName && selectedParent && pathPart) && !routeMutation.isPending
const existingPath = apiResources.some((item) => item.path === createPath)
const curlCommand = buildCurlCommand(resource.id, stage, path, deployedHttpMethod, testHeaders, testQuery, testBody)
const endpoint = invokeEndpoint(runtimeEndpoint)
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
return (
<div className="apigateway-workspace">
<section className="table-panel">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">REST API Route</p>
<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>
{functionName && <a className="button" href={`/cloud-explorer/aws/logs?resource=${encodeURIComponent(`/aws/lambda/${functionName}`)}`}><ScrollText size={13}/> Open Lambda logs</a>}
</div>
<div className="resource-create-inline">
{baseInvokeUrl && <div className="dynamic-field">
<span>Base invoke URL</span>
<div className="copy-value">
<code>{baseInvokeUrl}</code>
<CopyValueButton value={baseInvokeUrl} label="Base invoke URL"/>
</div>
</div>}
<form className="inspector-action-form apigateway-route-form" onSubmit={(event) => {
event.preventDefault()
if (canCreate) routeMutation.mutate()
}}>
<label className="dynamic-field">
<span>Parent resource</span>
<select aria-label="Parent resource" className="input" value={selectedParent?.resourceId ?? ''} onChange={(event) => setParentResourceId(event.target.value)} required>
{apiResources.map((item) => <option key={item.resourceId} value={item.resourceId}>{item.path}</option>)}
</select>
<small>Select where the new resource belongs.</small>
</label>
<label className="dynamic-field">
<span>Path part</span>
<input aria-label="Path part" className="input" value={pathPart} onChange={(event) => setPathPart(event.target.value)} placeholder="orders or {id}" required/>
<small>{existingPath ? `Adds ${method} to existing ${createPath}.` : <>Creates <code>{createPath}</code>. Path parameters and greedy parameters are supported.</>}</small>
</label>
<label className="dynamic-field">
<span>Method</span>
<select aria-label="Method" className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
{CONFIGURABLE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
<small>Lambda proxy method.</small>
</label>
<label className="dynamic-field">
<span>Lambda function</span>
<select aria-label="Lambda function" className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
<option value="">Select a Lambda function</option>
{functions.map((fn) => <option key={fn.id} value={fn.name}>{fn.name}</option>)}
</select>
<small>{functionsQuery.isLoading ? 'Loading functions...' : functions.length === 0 ? 'Create a Lambda function before adding a route.' : 'Receives this route\'s proxy event.'}</small>
</label>
<label className="dynamic-field">
<span>Stage</span>
<input className="input" value={stage} onChange={(event) => setStage(event.target.value)} placeholder="dev" required/>
<small>Deployment stage name.</small>
</label>
<button className="button primary apigateway-route-submit" type="submit" disabled={!canCreate}>
{routeMutation.isPending ? <Loader2 size={13}/> : <Rocket size={13}/>}
{routeMutation.isPending ? 'Deploying' : 'Create and deploy route'}
</button>
</form>
{routeMutation.isError && <p className="error-text compact-text">{routeMutation.error instanceof Error ? routeMutation.error.message : 'Unable to create API route.'}</p>}
{routeMutation.data && (
<div className="apigateway-route-result">
<strong>Route deployed</strong>
<a href={routeMutation.data.invokeUrl} target="_blank" rel="noreferrer">{routeMutation.data.invokeUrl}</a>
</div>
)}
</div>
<div className="apigateway-route-explorer">
<div className="dynamic-stage-header">
<div>
<p className="eyebrow">Configured Methods</p>
<h3><ListTree size={15}/> Route explorer</h3>
<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 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 && apiResources.length === 0 && <p className="muted compact-text">API Gateway returned no resources.</p>}
{routesQuery.data && apiResources.length > 0 && (
<div className="apigateway-resource-tree" role="tree" aria-label="API Gateway resources">
{resourceTree(apiResources).map(({resource: treeResource, children, depth}) => {
const resourceMethods = treeResource.methods.filter((item) => CORS_METHODS.includes(item.method)).map((item) => item.method)
return <div className="apigateway-resource-node" role="treeitem" aria-level={depth + 1} key={treeResource.resourceId} style={{'--tree-depth': depth} as CSSProperties}>
<div className="apigateway-resource-heading">
<button type="button" className="apigateway-resource-select" onClick={() => setParentResourceId(treeResource.resourceId)}>
<code>{treeResource.path}</code>
<small>{treeResource.methods.length === 0 ? 'No methods' : `${treeResource.methods.length} method${treeResource.methods.length === 1 ? '' : 's'}`}</small>
</button>
{resourceMethods.length > 0 && <button className="button compact" type="button" onClick={() => {
setCorsConfirmation({resourceId: treeResource.resourceId, path: treeResource.path})
setCorsMethods(resourceMethods)
corsMutation.reset()
}}>Enable CORS</button>}
</div>
{treeResource.methods.map((route) => {
const selected = route.path === path && route.method === method
const key = `${route.resourceId}-${route.method}`
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 ?? '')
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>
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
</button>
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
<span>Delete {route.method}?</span>
<button className="button danger compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => deleteRouteMutation.mutate(route)}>Delete</button>
<button className="button compact" type="button" disabled={deleteRouteMutation.isPending} onClick={() => setDeleteConfirmation(null)}>Cancel</button>
</div> : <button className="icon-btn" type="button" aria-label={`Delete ${route.method} ${route.path}`} onClick={() => setDeleteConfirmation(key)}><Trash2 size={13}/></button>}
</div>
})}
{corsConfirmation?.resourceId === treeResource.resourceId && <div className="apigateway-route-confirm apigateway-cors-confirm">
<strong>Enable CORS for {corsConfirmation.path}</strong>
<label className="dynamic-field"><span>Allowed origin</span><input className="input" value={corsOrigin} onChange={(event) => setCorsOrigin(event.target.value)}/></label>
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={corsHeaders} onChange={(event) => setCorsHeaders(event.target.value)}/></label>
<div className="dynamic-field"><span>Allowed methods</span>{resourceMethods.map((value) => <label key={value}><input type="checkbox" checked={corsMethods.includes(value)} onChange={() => setCorsMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div>
<span>Updates this resource's OPTIONS preflight and non-proxy method responses, then deploys {stage}.</span>
<button className="button primary compact" type="button" disabled={corsMutation.isPending || corsMethods.length === 0} onClick={() => corsMutation.mutate()}>{corsMutation.isPending ? 'Enabling' : 'Confirm enable CORS'}</button>
<button className="button compact" type="button" disabled={corsMutation.isPending} onClick={() => setCorsConfirmation(null)}>Cancel</button>
{corsMutation.isError && <p className="error-text compact-text">{corsMutation.error instanceof Error ? corsMutation.error.message : 'Unable to enable CORS.'}</p>}
</div>}
{children.length > 0 && <span className="sr-only">{children.length} child resources</span>}
</div>
})}
</div>
)}
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
</div>
</section>
<section className="table-panel 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>Request path</span>
<input aria-label="Request path" className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders/123" required/>
<small>Replace path parameters with concrete values for testing.</small>
</label>
<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>{deployedHttpMethod} /{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>{deployedHttpMethod} {path}</small></div>
</div>
<ArrowRight className="apigateway-flow-arrow" size={16}/>
<div className="apigateway-flow-step">
<ServiceIcon cloud="aws" iconKey="apigateway" size={22}/>
<div><strong>API Gateway</strong><small>{resource.name} · {stage}</small></div>
</div>
<ArrowRight className="apigateway-flow-arrow" size={16}/>
<div className="apigateway-flow-step">
<ServiceIcon cloud="aws" iconKey="serverless" size={22}/>
<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()
}}>
{method === 'ANY' && <label className="dynamic-field">
<span>HTTP verb for deployed request</span>
<select aria-label="HTTP verb for deployed request" className="input" value={anyTestMethod} onChange={(event) => setAnyTestMethod(event.target.value)}>
{DEPLOYED_HTTP_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
</select>
<small>ANY is API Gateway configuration, not an HTTP request verb.</small>
</label>}
<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>
<label className="dynamic-field">
<span>Query parameters</span>
<input className="input" value={testQuery} onChange={(event) => setTestQuery(event.target.value)} placeholder="page=1&limit=20"/>
<small>URL-encoded query string.</small>
</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 deployed HTTP request'}
</button>
<button className="button" type="button" onClick={() => {
void navigator.clipboard.writeText(curlCommand)
setCurlCopied(true)
window.setTimeout(() => setCurlCopied(false), 1200)
}}>
<Copy size={13}/>{curlCopied ? 'Copied' : 'Copy cURL'}
</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>
{(testMutation.data || methodTestMutation.data) && functionName && <LogsQueryPanel
cloud={cloud}
logGroupName={`/aws/lambda/${functionName}`}
runtimeReachable={runtimeReachable}
autoRunKey={logsRefreshKey}
/>}
</section>
</div>
)
}
const CONFIGURABLE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'ANY', 'HEAD']
const DEPLOYED_HTTP_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD']
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS']
function childResourcePath(parentPath: string, pathPart: string): string {
return parentPath === '/' ? `/${pathPart}` : `${parentPath}/${pathPart}`
}
function resourceTree(resources: ApiGatewayResourceOverview[]): Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> {
const byParent = new Map<string | null, ApiGatewayResourceOverview[]>()
for (const resource of resources) {
const siblings = byParent.get(resource.parentId) ?? []
siblings.push(resource)
byParent.set(resource.parentId, siblings)
}
for (const siblings of byParent.values()) siblings.sort((left, right) => left.path.localeCompare(right.path))
const flattened: Array<{resource: ApiGatewayResourceOverview; children: ApiGatewayResourceOverview[]; depth: number}> = []
const visit = (resource: ApiGatewayResourceOverview, depth: number) => {
const children = byParent.get(resource.resourceId) ?? []
flattened.push({resource, children, depth})
for (const child of children) visit(child, depth + 1)
}
const roots = resources.filter((resource) => resource.parentId === null || !resources.some((candidate) => candidate.resourceId === resource.parentId))
for (const root of roots.sort((left, right) => left.path.localeCompare(right.path))) visit(root, 0)
return flattened
}
function ProxyCorsWarning({methods, origin, headers}: {methods: string[]; origin: string; headers: string}) {
return <div className="error-text compact-text">
<strong>AWS_PROXY methods need their backend to return CORS headers: {methods.join(', ')}.</strong>
<pre>{`import "net/http"
func handler(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "${origin}")
w.Header().Set("Access-Control-Allow-Headers", "${headers}")
w.Header().Set("Access-Control-Allow-Methods", "${methods.join(',')},OPTIONS")
if r.Method == http.MethodOptions { w.WriteHeader(http.StatusOK); return }
// Handle the proxy request.
}`}</pre>
</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 buildCurlCommand(apiId: string, stage: string, path: string, method: string, headersText: string, query: string, body: string): string {
let headers: Record<string, string> = {}
try {
headers = parseHeaders(headersText)
} catch {
// Keep cURL available while the user is editing invalid headers.
}
const url = `http://localhost:4566/execute-api/${apiId}/${stage}${path}${query ? `?${query}` : ''}`
const args = [`curl -X ${method}`, `'${url}'`, ...Object.entries(headers).map(([name, value]) => `-H '${name}: ${value.replace(/'/g, "'\\''")}'`)]
if (!['GET', 'HEAD', 'OPTIONS'].includes(method) && body) args.push(`--data '${body.replace(/'/g, "'\\''")}'`)
return args.join(' ')
}