feat: CORS enable button...ish still now understanding this
This commit is contained in:
@@ -1,18 +1,20 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {ArrowRight, Braces, Copy, Link2, ListTree, Loader2, Rocket, Send, Trash2, Waypoints, X} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||
import {createApiGatewayLambdaRoute, deleteApiGatewayRoute, enableApiGatewayCors, listApiGatewayRoutes, listCloudResources, testApiGatewayRoute} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {LogsQueryPanel} from './LogsQueryPanel'
|
||||
|
||||
interface ApiGatewayRoutePanelProps {
|
||||
cloud: CloudProvider
|
||||
resource?: CloudResource
|
||||
runtimeReachable: boolean
|
||||
runtimeEndpoint?: string
|
||||
}
|
||||
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGatewayRoutePanelProps) {
|
||||
export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtimeEndpoint}: ApiGatewayRoutePanelProps) {
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [stage, setStage] = useState('dev')
|
||||
@@ -24,6 +26,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
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'],
|
||||
@@ -48,6 +55,11 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
setBodyFormatError(null)
|
||||
setLogsRefreshKey(0)
|
||||
setDeleteConfirmation(null)
|
||||
setCorsConfirmation(null)
|
||||
setCorsOrigin('*')
|
||||
setCorsHeaders('Content-Type')
|
||||
setCorsMethods([])
|
||||
setCorsProxyMethods([])
|
||||
}, [resource?.id])
|
||||
|
||||
const routeMutation = useMutation({
|
||||
@@ -73,6 +85,20 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
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 (
|
||||
@@ -89,6 +115,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
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 endpoint = invokeEndpoint(runtimeEndpoint)
|
||||
const baseInvokeUrl = endpoint ? `${endpoint}/execute-api/${resource.id}/${stage}` : null
|
||||
|
||||
return (
|
||||
<section className="table-panel">
|
||||
@@ -108,6 +136,13 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
)}
|
||||
</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()
|
||||
@@ -161,12 +196,16 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
{routesQuery.isLoading && <p className="muted compact-text">Loading deployed routes...</p>}
|
||||
{routesQuery.isError && <p className="error-text compact-text">{routesQuery.error instanceof Error ? routesQuery.error.message : 'Unable to load API routes.'}</p>}
|
||||
{routesQuery.data?.routes.length === 0 && <p className="muted compact-text">No deployed methods yet. Create and deploy a route above.</p>}
|
||||
{routesQuery.data && routesQuery.data.routes.length > 0 && (
|
||||
<div className="apigateway-route-list">
|
||||
{routesQuery.data.routes.map((route) => {
|
||||
{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}`
|
||||
return <div className={`apigateway-route-item${selected ? ' selected' : ''}`} key={key}>
|
||||
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={() => {
|
||||
setPath(route.path)
|
||||
setMethod(route.method)
|
||||
@@ -176,18 +215,34 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
}}>
|
||||
<span className="apigateway-route-method">{route.method}</span>
|
||||
<code>{route.path}</code>
|
||||
<small>{route.functionName ?? 'No Lambda integration'}</small>
|
||||
</button>
|
||||
{deleteConfirmation === key ? <div className="apigateway-route-confirm">
|
||||
<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">
|
||||
<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>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{corsProxyMethods.length > 0 && <ProxyCorsWarning methods={corsProxyMethods} origin={corsOrigin} headers={corsHeaders}/>}
|
||||
</div>
|
||||
<div className="apigateway-test-panel">
|
||||
<div className="dynamic-stage-header">
|
||||
<div>
|
||||
@@ -299,6 +354,45 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable}: ApiGat
|
||||
)
|
||||
}
|
||||
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS']
|
||||
|
||||
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 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 {
|
||||
|
||||
Reference in New Issue
Block a user