Files
floci-ui/packages/frontend/src/components/ApiGatewayRoutePanel.tsx
T
2026-10-05 11:06:57 -04:00

571 lines
36 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'
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 a REST API</h3>
<p>Select an API Gateway REST API to connect a route to a Lambda function.</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 invokeEndpoint(runtimeEndpoint?: string): string | null {
if (!runtimeEndpoint) return null
try {
const endpoint = new URL(runtimeEndpoint)
if (isDockerBridgeAddress(endpoint.hostname) && typeof window !== 'undefined') {
endpoint.hostname = window.location.hostname
}
return endpoint.toString().replace(/\/$/, '')
} catch {
return runtimeEndpoint.replace(/\/$/, '')
}
}
function isDockerBridgeAddress(hostname: string): boolean {
const octets = hostname.split('.').map(Number)
return octets.length === 4
&& octets.every((octet) => Number.isInteger(octet) && octet >= 0 && octet <= 255)
&& octets[0] === 172
&& octets[1] >= 16
&& octets[1] <= 31
}
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)'
}
}
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(' ')
}