571 lines
36 KiB
TypeScript
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(' ')
|
|
}
|