feat: Cloudformation + RDS
This commit is contained in:
@@ -1,12 +1,13 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
|
||||
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} 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
|
||||
@@ -17,7 +18,10 @@ interface ApiGatewayRoutePanelProps {
|
||||
|
||||
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('{}')
|
||||
@@ -48,7 +52,10 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
|
||||
useEffect(() => {
|
||||
setPath('/hello')
|
||||
setParentResourceId('')
|
||||
setPathPart('hello')
|
||||
setMethod('GET')
|
||||
setAnyTestMethod('GET')
|
||||
setStage('dev')
|
||||
setFunctionName('')
|
||||
setTestBody('{}')
|
||||
@@ -68,13 +75,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
}, [resource?.id])
|
||||
|
||||
const routeMutation = useMutation({
|
||||
mutationFn: () => createApiGatewayLambdaRoute(cloud, resource!.id, {path, method, stage, functionName}),
|
||||
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,
|
||||
method: deployedHttpMethod,
|
||||
stage,
|
||||
body: testBody,
|
||||
headers: parseHeaders(testHeaders),
|
||||
@@ -132,9 +146,14 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
}
|
||||
|
||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||
const canCreate = cloud === 'aws' && runtimeReachable && Boolean(functionName) && !routeMutation.isPending
|
||||
const existingPath = routesQuery.data?.routes.some((route) => route.path === path) ?? false
|
||||
const curlCommand = buildCurlCommand(resource.id, stage, path, method, testHeaders, testQuery, testBody)
|
||||
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
|
||||
|
||||
@@ -154,6 +173,7 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
autoRunKey={logsRefreshKey}
|
||||
/>
|
||||
)}
|
||||
{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">
|
||||
@@ -168,20 +188,27 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
if (canCreate) routeMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field">
|
||||
<span>Route path</span>
|
||||
<input className="input" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/orders" required/>
|
||||
<small>{existingPath ? `Adds ${method} to the existing ${path} resource.` : <>One path segment, for example <code>/qrcode</code>.</>}</small>
|
||||
<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 className="input" value={method} onChange={(event) => setMethod(event.target.value)}>
|
||||
{['GET', 'POST', 'PUT', 'DELETE'].map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
<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 className="input" value={functionName} onChange={(event) => setFunctionName(event.target.value)} required>
|
||||
<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>
|
||||
@@ -215,18 +242,28 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</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?.routes.length === 0 && <p className="muted compact-text">No configured methods yet. Create a route above.</p>}
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
const selected = route.path === path && route.method === method
|
||||
const key = `${route.resourceId}-${route.method}`
|
||||
const resourceMethods = routesQuery.data.routes
|
||||
.filter((item) => item.resourceId === route.resourceId && CORS_METHODS.includes(item.method))
|
||||
.map((item) => item.method)
|
||||
const isFirstForResource = routesQuery.data.routes.findIndex((item) => item.resourceId === route.resourceId) === routesQuery.data.routes.indexOf(route)
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
<button className="apigateway-route-select" type="button" onClick={() => {
|
||||
{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 ?? '')
|
||||
@@ -239,28 +276,26 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<code>{route.path}</code>
|
||||
<small>{route.integrationType ?? 'No integration'}{route.functionName ? ` · ${route.functionName}` : ''}</small>
|
||||
</button>
|
||||
{isFirstForResource && <button className="button compact" type="button" onClick={() => {
|
||||
setCorsConfirmation({resourceId: route.resourceId, path: route.path})
|
||||
setCorsMethods(resourceMethods)
|
||||
corsMutation.reset()
|
||||
}}>Enable CORS</button>}
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
{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>}
|
||||
{corsConfirmation?.resourceId === route.resourceId && isFirstForResource && <div className="apigateway-route-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((method) => method !== 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>}
|
||||
</div>
|
||||
})}
|
||||
</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}/>}
|
||||
@@ -274,11 +309,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</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">
|
||||
<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>
|
||||
@@ -294,22 +334,22 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
<div>
|
||||
<p className="eyebrow">Deployed HTTP Test</p>
|
||||
<h3><Send size={15}/> Send deployed request</h3>
|
||||
<p className="muted compact-text">Invokes <code>{method} /{stage}{path}</code> through the local API Gateway endpoint. Requires a deployed stage.</p>
|
||||
<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>{method} {path}</small></div>
|
||||
<div><strong>Test request</strong><small>{deployedHttpMethod} {path}</small></div>
|
||||
</div>
|
||||
<ArrowRight className="apigateway-flow-arrow" size={16}/>
|
||||
<div className="apigateway-flow-step">
|
||||
<Waypoints size={16}/>
|
||||
<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">
|
||||
<Braces size={16}/>
|
||||
<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}/>
|
||||
@@ -319,11 +359,18 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
</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">
|
||||
<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>
|
||||
@@ -400,7 +447,33 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
)
|
||||
}
|
||||
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
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">
|
||||
|
||||
Reference in New Issue
Block a user