Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 811791603c |
@@ -1,26 +1,23 @@
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-black.svg#gh-light-mode-only" alt="Floci UI" width="460" />
|
||||
<img src="docs/images/floci-white.svg#gh-dark-mode-only" alt="Floci UI" width="460" />
|
||||
<img src="docs/images/thecloud-logo.png" alt="TheCloud" width="160" />
|
||||
</p>
|
||||
|
||||
<h1 align="center">TheCloud</h1>
|
||||
|
||||
<p align="center">
|
||||
<strong>Any Cloud. Locally.</strong><br />
|
||||
A local-first, cloud-aware runtime console for Floci and compatible local cloud emulators.
|
||||
A self-hosted cloud console for local AWS, Azure and GCP runtimes.
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/floci-io/floci-ui/releases/latest"><img src="https://img.shields.io/github/v/release/floci-io/floci-ui?label=latest%20release&color=blue" alt="Latest Release"></a>
|
||||
<a href="https://github.com/floci-io/floci-ui/actions/workflows/ci.yml"><img src="https://img.shields.io/github/actions/workflow/status/floci-io/floci-ui/ci.yml?branch=main&label=ci" alt="CI Status"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/pulls/floci/floci-ui?label=docker%20pulls" alt="Docker Pulls"></a>
|
||||
<a href="https://hub.docker.com/r/floci/floci-ui"><img src="https://img.shields.io/docker/image-size/floci/floci-ui/latest?label=image%20size" alt="Docker Image Size"></a>
|
||||
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-green" alt="License: MIT"></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-ui-console.png" alt="Floci UI console" width="900" />
|
||||
<img src="docs/images/thecloud-console.png" alt="TheCloud's Cloud Explorer showing S3 buckets and the selected bucket's details" width="900" />
|
||||
</p>
|
||||
|
||||
Floci UI is the web console for the Floci ecosystem. The current app is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`. It renders only real data returned by local runtimes and explicit placeholders for work that is not wired yet.
|
||||
TheCloud is an AWS-Console-style web console for locally running cloud runtimes. It is centered on a unified `Cloud Explorer` and a cloud-aware `Console Home`, and it renders only real data returned by the runtimes, with explicit placeholders for work that is not wired yet.
|
||||
|
||||
No fake resources, no demo rows, and no mock operational data are shown in normal mode.
|
||||
|
||||
@@ -42,9 +39,9 @@ Open [http://localhost:4500](http://localhost:4500).
|
||||
|
||||
## Exposing To The Internet
|
||||
|
||||
Floci UI requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
||||
TheCloud requires an administrator username, an Argon2id password hash, and a session secret. Copy `.env.example` to `.env`, generate the hash with `cd packages/api && bun run scripts/hash-password.ts '<password>'`, and generate a session secret with `openssl rand -base64 48`. Do not put plaintext passwords in `.env`.
|
||||
|
||||
Put only Floci UI behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind Floci core (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
|
||||
Put only TheCloud's console behind Nginx Proxy Manager, terminating TLS there and forwarding to port `4500`. Keep `FLOCI_UI_SECURE_COOKIES=true` in that deployment. Bind TheCloud's AWS runtime (`4566`) to the Docker network or localhost only: it must not be publicly reachable, because the authenticated UI proxy is the intended access path. Configure Nginx Proxy Manager to pass `X-Forwarded-Proto` and `X-Forwarded-For`, and place Cloudflare or another edge proxy in front of it for public deployments. Set `FLOCI_UI_TRUSTED_PROXIES` to the proxy's IP or CIDR (comma-separated for several hops, e.g. Cloudflare's published ranges when it sits in front of Nginx Proxy Manager); `X-Forwarded-For` is ignored from any other peer, and without it sign-in throttling treats every visitor as the proxy.
|
||||
|
||||
Set `FLOCI_UI_AUTH_MODE=oidc` to use an OIDC provider such as Authentik. Configure `FLOCI_UI_OIDC_ISSUER`, `FLOCI_UI_OIDC_CLIENT_ID`, `FLOCI_UI_OIDC_CLIENT_SECRET`, and `FLOCI_UI_PUBLIC_BASE_URL`; the callback is `${FLOCI_UI_PUBLIC_BASE_URL}/api/auth/oidc/callback`. OIDC mode also requires an allow-list: `FLOCI_UI_OIDC_ALLOWED_GROUPS` (matched against the `groups` claim, read from UserInfo when the ID token omits it) and/or `FLOCI_UI_OIDC_ALLOWED_EMAILS` (verified emails only). Set `FLOCI_UI_OIDC_ALLOW_ALL_USERS=true` only if every user of the identity provider should have console access.
|
||||
|
||||
@@ -110,13 +107,9 @@ Services marked `No` render as a disabled sidebar row whose tooltip carries the
|
||||
server-supplied reason. Adding one is a catalog row in
|
||||
`packages/api/src/cloud-spi/serviceCatalog.ts` plus an adapter — no frontend change.
|
||||
|
||||
<p align="center">
|
||||
<img src="docs/images/floci-ui-console-azure.png" alt="Azure console home, showing services grouped by category with per-cloud naming and coming-soon reasons" width="900" />
|
||||
</p>
|
||||
|
||||
Azure on the same build: the nav is grouped by category, `k8s Engine` is labelled
|
||||
`AKS` for this provider, and every unavailable service carries a reason — Serverless
|
||||
reads `coming soon` because the Floci-AZ runtime answers 501 for Azure Functions,
|
||||
With Azure selected, the nav is grouped the same way, `k8s Engine` is labelled
|
||||
`AKS` for that provider, and every unavailable service carries a reason: Serverless
|
||||
reads `coming soon` because the Azure runtime answers 501 for Azure Functions,
|
||||
even though an adapter is registered.
|
||||
|
||||
## Current Capability Snapshot
|
||||
@@ -159,7 +152,7 @@ All three clouds, through the unified shell.
|
||||
Current gaps:
|
||||
|
||||
- EKS and AKS are read-only. On AKS this is a runtime limit rather than a choice:
|
||||
the shipped floci-az config runs AKS unmocked with no Docker socket to start k3s
|
||||
the shipped Azure runtime (`floci-az`) config runs AKS unmocked with no Docker socket to start k3s
|
||||
with, so a created cluster never leaves `provisioningState: Failed`.
|
||||
|
||||
</details>
|
||||
@@ -169,7 +162,7 @@ Current gaps:
|
||||
|
||||
Relational and document database workflows across providers:
|
||||
|
||||
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because the current local Floci RDS emulator does not support modifying them.
|
||||
- AWS RDS: list, inspect, create, update, and delete DB instances (PostgreSQL, MySQL, MariaDB) with provider defaults (class `db.t3.micro`, storage 20 GB, username `root`). Updates use generic `PATCH /api/clouds/:cloud/services/:service/resources/:id` mapping to `ModifyDBInstance` for password rotation, IAM authentication, DB subnet group, VPC security groups, option group, and auto minor version upgrade. Instance class, storage, engine, and version are omitted from edit operations because TheCloud's RDS runtime does not support modifying them yet.
|
||||
- AWS RDS Snapshots: account-scoped Snapshots tab listing DB snapshots and supporting snapshot creation.
|
||||
- Azure Cosmos DB NoSQL: database, container, and document workflows.
|
||||
- AWS DynamoDB: table management, item browsing, and Add record.
|
||||
@@ -196,8 +189,8 @@ Frontend regression tests use mocked `/api/*` responses and need no running emul
|
||||
|
||||
Current gaps:
|
||||
|
||||
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but the current Floci runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
||||
- AWS RDS instance stop/start operations are not implemented in the current local Floci runtime.
|
||||
- AWS RDS snapshot creation: the Cloud Proxy operation is available, but TheCloud's runtime does not implement `CreateDBSnapshot` (returns a typed 501 `operation_not_implemented`). Snapshot listing returns a valid empty list.
|
||||
- AWS RDS instance stop/start operations are not implemented in TheCloud's runtime yet.
|
||||
|
||||
</details>
|
||||
|
||||
@@ -259,18 +252,14 @@ Current gaps:
|
||||
<details>
|
||||
<summary><strong>API Gateway</strong></summary>
|
||||
|
||||
AWS only, through the generic apigateway service category. REST APIs (v1) and HTTP and WebSocket APIs (v2) are listed together, with a Type column.
|
||||
AWS only, through the generic apigateway service category.
|
||||
|
||||
- REST APIs: create, delete and inspect; add Lambda proxy routes on a resource tree, enable CORS, test a method with TestInvokeMethod, and send a request to a deployed stage.
|
||||
- HTTP APIs: quick-create with a Lambda (a catch-all route on an auto-deployed `$default` stage); add routes such as `GET /notes/{id}` to a Lambda or an HTTP backend; add stages with or without automatic deployment and deploy them; add JWT authorizers, filled in from a Cognito user pool and app client or from any OpenID Connect issuer, and protect routes with them; configure CORS; send a real request to a stage through its routes and authorizers.
|
||||
- WebSocket APIs: create with a route selection expression and a stage; add `$connect`, `$disconnect`, `$default` and action routes to Lambda, optionally sending the function's response back; connect to a stage, send a message and read the replies.
|
||||
|
||||
Verified against TheCloud's runtime: quick create, Lambda and HTTP routes, a Cognito JWT authorizer (401 without a token, the token's claims reach the Lambda with one), CORS preflight, a stage deployed on demand, and a WebSocket round trip.
|
||||
- List and inspect REST APIs.
|
||||
- Create and delete REST APIs.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- WebSocket APIs: Lambda (REQUEST) authorizers, `@connections` messages pushed from a backend, and route request and response models are not exposed.
|
||||
- Custom domain names, API mappings, VPC links, usage plans and API keys are not exposed.
|
||||
- Resources, methods, deployments, and stages are not yet exposed.
|
||||
- No Azure or GCP API Gateway adapter yet.
|
||||
|
||||
</details>
|
||||
@@ -280,7 +269,7 @@ Current gaps:
|
||||
|
||||
AWS SES email capture through the unified Cloud Explorer.
|
||||
|
||||
- Lists emails actually captured by Floci SES.
|
||||
- Lists emails actually captured by TheCloud's SES.
|
||||
- Filters by subject, sender, and recipient.
|
||||
- Inspects sender, recipients, timestamp, and message type.
|
||||
- Displays HTML in a sandboxed preview, text bodies, and captured raw MIME data.
|
||||
@@ -297,10 +286,10 @@ aws ses send-email \
|
||||
--endpoint-url http://localhost:4566 \
|
||||
--from sender@example.test \
|
||||
--destination 'ToAddresses=recipient@example.test' \
|
||||
--message 'Subject={Data="Floci SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from Floci</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
||||
--message 'Subject={Data="TheCloud SES test",Charset=utf-8},Body={Text={Data="Plain-text test email.",Charset=utf-8},Html={Data="<h1>Hello from TheCloud</h1><p>This should render in the SES preview.</p>",Charset=utf-8}}'
|
||||
```
|
||||
|
||||
The email is captured by the local Floci runtime; it is not delivered externally. Open
|
||||
The email is captured by TheCloud's runtime, and is delivered externally only when an SMTP relay is configured. Open
|
||||
`/cloud-explorer/aws/email` and refresh the mailbox to inspect its Preview, Text, and
|
||||
Raw views. You can also inspect the captured messages directly with:
|
||||
|
||||
@@ -328,7 +317,7 @@ AWS and GCP, both through the unified shell.
|
||||
|
||||
Current gaps:
|
||||
|
||||
- Azure Functions is registered but the Floci-AZ runtime answers 501 NotImplemented,
|
||||
- Azure Functions is registered but the Azure runtime answers 501 NotImplemented,
|
||||
so it reports `coming_soon` with that reason rather than appearing available.
|
||||
- GCP Cloud Functions invoke is not wired yet; the capability is advertised as
|
||||
`coming_soon` instead of being silently missing.
|
||||
@@ -381,7 +370,7 @@ Current gaps:
|
||||
|
||||
## Product Direction
|
||||
|
||||
Floci UI is evolving toward a metadata-driven, cloud-aware console where one web app can render multiple local runtimes through the same shell.
|
||||
TheCloud is a metadata-driven, cloud-aware console: one web app renders multiple local runtimes through the same shell.
|
||||
|
||||
The guiding rules are:
|
||||
|
||||
@@ -393,7 +382,7 @@ The guiding rules are:
|
||||
|
||||
## Architecture
|
||||
|
||||

|
||||

|
||||
|
||||
Short implementation notes live in [docs/implementation-notes.md](docs/implementation-notes.md).
|
||||
|
||||
@@ -435,9 +424,9 @@ Browser
|
||||
|
||||
Default compose stack:
|
||||
|
||||
- `floci-ui` on `http://localhost:4500`
|
||||
- `floci-api` on `http://localhost:4501`
|
||||
- `floci` on `http://localhost:4566`
|
||||
- `floci-ui` (the console) on `http://localhost:4500`
|
||||
- `floci-api` (TheCloud's API) on `http://localhost:4501`
|
||||
- `floci` (TheCloud's AWS runtime) on `http://localhost:4566`
|
||||
|
||||
Start AWS-only:
|
||||
|
||||
@@ -467,7 +456,7 @@ Prerequisites:
|
||||
- Node.js 20+
|
||||
- pnpm 9+
|
||||
- Bun
|
||||
- A running local runtime: Floci core, and optionally Floci-AZ / Floci-GCP
|
||||
- A running local runtime: TheCloud's AWS runtime, and optionally the Azure and GCP runtimes
|
||||
|
||||
Install dependencies:
|
||||
|
||||
@@ -483,29 +472,18 @@ cp .env.example packages/api/.env
|
||||
|
||||
Important: the API runs from `packages/api` and loads environment variables from `packages/api/.env`.
|
||||
|
||||
Start Floci AWS Core with Docker:
|
||||
Start TheCloud's AWS runtime from its repository:
|
||||
|
||||
```bash
|
||||
docker run -d --name floci \
|
||||
-p 4566:4566 \
|
||||
-v /var/run/docker.sock:/var/run/docker.sock \
|
||||
-e FLOCI_DEFAULT_REGION=us-east-1 \
|
||||
-u root \
|
||||
floci/floci:latest
|
||||
```
|
||||
|
||||
Or from a local clone:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/floci-io/floci.git ../floci
|
||||
git clone https://gitea.zacksolutions.dev/Zakaria/FlociClone.git ../floci
|
||||
cd ../floci
|
||||
./mvnw clean quarkus:dev
|
||||
docker compose up -d # or ./mvnw clean quarkus:dev with JDK 25
|
||||
```
|
||||
|
||||
Optional local runtimes:
|
||||
|
||||
- Floci-AZ on `http://localhost:4577`
|
||||
- Floci-GCP on `http://localhost:4588`
|
||||
- Azure runtime (`floci-az`) on `http://localhost:4577`
|
||||
- GCP runtime (`floci-gcp`) on `http://localhost:4588`
|
||||
|
||||
Start the UI stack:
|
||||
|
||||
@@ -614,18 +592,6 @@ When adding new UI surface:
|
||||
- Keep placeholders explicit instead of inventing fake data.
|
||||
- Update this README when the visible UI surface changes.
|
||||
|
||||
## Community Projects
|
||||
|
||||
Floci UI is the first-party console, but it is not the only one. The community builds
|
||||
consoles for Floci too:
|
||||
|
||||
- [floci-dash](https://github.com/ofsazib/floci-dash) — an AWS-Console-style dashboard
|
||||
for the Floci AWS runtime built on Cloudscape Design. It ships as a single Docker image
|
||||
and includes an EC2 web terminal. It targets the AWS runtime only, while Floci UI also
|
||||
covers Azure and GCP, so pick whichever fits your stack.
|
||||
|
||||
Building something for Floci? Open a PR to add it here.
|
||||
|
||||
## License
|
||||
|
||||
[MIT](LICENSE) — part of the [Floci](https://floci.io) ecosystem.
|
||||
[MIT](LICENSE). TheCloud builds on the open-source [Floci](https://floci.io) project.
|
||||
|
||||
@@ -1,200 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
|
||||
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
|
||||
<defs>
|
||||
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
|
||||
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #151515;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #5559A7;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #151515;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #262425;
|
||||
}
|
||||
|
||||
#logoBuild {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: logoBreath 5s ease-in-out 2.7s infinite;
|
||||
}
|
||||
|
||||
.logo-piece {
|
||||
opacity: 0;
|
||||
animation: reveal .5s ease forwards;
|
||||
}
|
||||
|
||||
.cloud-a { animation-delay: .55s; }
|
||||
.cloud-b { animation-delay: .75s; }
|
||||
.word-a { animation-delay: 1.72s; }
|
||||
.word-b { animation-delay: 1.92s; }
|
||||
.word-c { animation-delay: 2.12s; }
|
||||
.word-d { animation-delay: 2.32s; }
|
||||
|
||||
.guide-path {
|
||||
fill: none;
|
||||
opacity: 1;
|
||||
stroke-opacity: .9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
|
||||
guideFade .42s ease forwards;
|
||||
}
|
||||
|
||||
.guide-dark {
|
||||
stroke: #151515;
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.guide-word {
|
||||
stroke: #262425;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.guide-cloud-a { animation-delay: .08s, 1.25s; }
|
||||
.guide-cloud-b { animation-delay: .24s, 1.45s; }
|
||||
.guide-word-a { animation-delay: 1.18s, 2.28s; }
|
||||
.guide-word-b { animation-delay: 1.36s, 2.48s; }
|
||||
.guide-word-c { animation-delay: 1.54s, 2.68s; }
|
||||
.guide-word-d { animation-delay: 1.72s, 2.88s; }
|
||||
|
||||
.dot-pop {
|
||||
opacity: 0;
|
||||
filter: url(#logoGlow);
|
||||
transform: scale(.15);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
|
||||
softPulse 3.2s ease-in-out 2.2s infinite;
|
||||
}
|
||||
|
||||
.dot-cloud { animation-delay: 1.45s, 3.1s; }
|
||||
.dot-i { animation-delay: 2.62s, 3.45s; }
|
||||
|
||||
.cloud-shadow {
|
||||
opacity: 0;
|
||||
animation: shadowIn .8s ease 1.2s forwards;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes guideFade {
|
||||
from { opacity: 1; stroke-opacity: .9; }
|
||||
to { opacity: 0; stroke-opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dotPop {
|
||||
0% { opacity: 0; transform: scale(.15); }
|
||||
62% { opacity: 1; transform: scale(1.18); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes softPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes shadowIn {
|
||||
from { opacity: 0; transform: translateY(4px) scale(.94); }
|
||||
to { opacity: .14; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes shineSweep {
|
||||
0%, 18% { opacity: 0; transform: translateX(-220px); }
|
||||
30% { opacity: .7; }
|
||||
54% { opacity: 0; transform: translateX(560px); }
|
||||
100% { opacity: 0; transform: translateX(560px); }
|
||||
}
|
||||
|
||||
@keyframes logoBreath {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.008); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#logoBuild,
|
||||
#logoBuild *,
|
||||
.shine {
|
||||
animation: none !important;
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.guide-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="logoBuild">
|
||||
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
|
||||
<g id="finalWordmark" class="st2">
|
||||
<g class="st2">
|
||||
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
||||
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
||||
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
||||
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="finalMark">
|
||||
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
</g>
|
||||
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
||||
<g id="drawGuides">
|
||||
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
||||
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
||||
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
||||
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
||||
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
||||
</g>
|
||||
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
|
Before Width: | Height: | Size: 141 KiB |
|
Before Width: | Height: | Size: 131 KiB |
|
Before Width: | Height: | Size: 119 KiB |
@@ -1,200 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg id="Layer_1" xmlns="http://www.w3.org/2000/svg" version="1.1" viewBox="0 0 531.25 156.71">
|
||||
<!-- Generator: Adobe Illustrator 30.4.0, SVG Export Plug-In . SVG Version: 2.1.4 Build 226) -->
|
||||
<defs>
|
||||
<filter id="logoGlow" x="-20%" y="-40%" width="140%" height="180%">
|
||||
<feGaussianBlur in="SourceGraphic" stdDeviation="2.2" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.333 0 0 0 0 0.349 0 0 0 0 0.655 0 0 0 .55 0" result="glow"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="glow"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<linearGradient id="shineGradient" x1="-120" y1="0" x2="120" y2="0" gradientUnits="userSpaceOnUse">
|
||||
<stop offset="0" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".45" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset=".5" stop-color="#fff" stop-opacity=".8"/>
|
||||
<stop offset=".55" stop-color="#fff" stop-opacity="0"/>
|
||||
<stop offset="1" stop-color="#fff" stop-opacity="0"/>
|
||||
</linearGradient>
|
||||
<style>
|
||||
.st0 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.st1 {
|
||||
fill: #5559A7;
|
||||
}
|
||||
|
||||
.st2 {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.st3 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
.st4 {
|
||||
fill: #FFFFFF;
|
||||
}
|
||||
|
||||
#logoBuild {
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: logoBreath 5s ease-in-out 2.7s infinite;
|
||||
}
|
||||
|
||||
.logo-piece {
|
||||
opacity: 0;
|
||||
animation: reveal .5s ease forwards;
|
||||
}
|
||||
|
||||
.cloud-a { animation-delay: .55s; }
|
||||
.cloud-b { animation-delay: .75s; }
|
||||
.word-a { animation-delay: 1.72s; }
|
||||
.word-b { animation-delay: 1.92s; }
|
||||
.word-c { animation-delay: 2.12s; }
|
||||
.word-d { animation-delay: 2.32s; }
|
||||
|
||||
.guide-path {
|
||||
fill: none;
|
||||
opacity: 1;
|
||||
stroke-opacity: .9;
|
||||
stroke-linecap: round;
|
||||
stroke-linejoin: round;
|
||||
stroke-dasharray: 1;
|
||||
stroke-dashoffset: 1;
|
||||
vector-effect: non-scaling-stroke;
|
||||
animation: drawLine 1.25s cubic-bezier(.34, .01, .14, 1) forwards,
|
||||
guideFade .42s ease forwards;
|
||||
}
|
||||
|
||||
.guide-dark {
|
||||
stroke: #FFFFFF;
|
||||
stroke-width: 2.6;
|
||||
}
|
||||
|
||||
.guide-word {
|
||||
stroke: #FFFFFF;
|
||||
stroke-width: 2;
|
||||
}
|
||||
|
||||
.guide-cloud-a { animation-delay: .08s, 1.25s; }
|
||||
.guide-cloud-b { animation-delay: .24s, 1.45s; }
|
||||
.guide-word-a { animation-delay: 1.18s, 2.28s; }
|
||||
.guide-word-b { animation-delay: 1.36s, 2.48s; }
|
||||
.guide-word-c { animation-delay: 1.54s, 2.68s; }
|
||||
.guide-word-d { animation-delay: 1.72s, 2.88s; }
|
||||
|
||||
.dot-pop {
|
||||
opacity: 0;
|
||||
filter: url(#logoGlow);
|
||||
transform: scale(.15);
|
||||
transform-box: fill-box;
|
||||
transform-origin: center;
|
||||
animation: dotPop .8s cubic-bezier(.17, .89, .28, 1.28) forwards,
|
||||
softPulse 3.2s ease-in-out 2.2s infinite;
|
||||
}
|
||||
|
||||
.dot-cloud { animation-delay: 1.45s, 3.1s; }
|
||||
.dot-i { animation-delay: 2.62s, 3.45s; }
|
||||
|
||||
.cloud-shadow {
|
||||
opacity: 0;
|
||||
animation: shadowIn .8s ease 1.2s forwards;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
mix-blend-mode: screen;
|
||||
animation: shineSweep 2.4s ease-in-out 2.8s infinite;
|
||||
}
|
||||
|
||||
@keyframes drawLine {
|
||||
to { stroke-dashoffset: 0; }
|
||||
}
|
||||
|
||||
@keyframes guideFade {
|
||||
from { opacity: 1; stroke-opacity: .9; }
|
||||
to { opacity: 0; stroke-opacity: 0; }
|
||||
}
|
||||
|
||||
@keyframes reveal {
|
||||
from { opacity: 0; }
|
||||
to { opacity: 1; }
|
||||
}
|
||||
|
||||
@keyframes dotPop {
|
||||
0% { opacity: 0; transform: scale(.15); }
|
||||
62% { opacity: 1; transform: scale(1.18); }
|
||||
100% { opacity: 1; transform: scale(1); }
|
||||
}
|
||||
|
||||
@keyframes softPulse {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.08); }
|
||||
}
|
||||
|
||||
@keyframes shadowIn {
|
||||
from { opacity: 0; transform: translateY(4px) scale(.94); }
|
||||
to { opacity: .14; transform: translateY(0) scale(1); }
|
||||
}
|
||||
|
||||
@keyframes shineSweep {
|
||||
0%, 18% { opacity: 0; transform: translateX(-220px); }
|
||||
30% { opacity: .7; }
|
||||
54% { opacity: 0; transform: translateX(560px); }
|
||||
100% { opacity: 0; transform: translateX(560px); }
|
||||
}
|
||||
|
||||
@keyframes logoBreath {
|
||||
0%, 100% { transform: scale(1); }
|
||||
50% { transform: scale(1.008); }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
#logoBuild,
|
||||
#logoBuild *,
|
||||
.shine {
|
||||
animation: none !important;
|
||||
opacity: 1;
|
||||
stroke-dashoffset: 0;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.guide-path {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.shine {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</defs>
|
||||
<g id="logoBuild">
|
||||
<ellipse class="cloud-shadow" cx="89" cy="149.5" rx="75" ry="5.8"/>
|
||||
<g id="finalWordmark" class="st2">
|
||||
<g class="st2">
|
||||
<path class="st4 logo-piece word-a" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
||||
<path class="st4 logo-piece word-b" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
||||
<path class="st4 logo-piece word-c" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
||||
<path class="st4 logo-piece word-d" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
||||
</g>
|
||||
</g>
|
||||
<g id="finalMark">
|
||||
<path class="st0 logo-piece cloud-a" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01ZM44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<ellipse class="st1 dot-pop dot-cloud" cx="173.57" cy="127.13" rx="20.36" ry="20.4"/>
|
||||
</g>
|
||||
<ellipse class="st1 dot-pop dot-i" cx="513.02" cy="37.71" rx="13.22" ry="13.25"/>
|
||||
<g id="drawGuides">
|
||||
<path class="guide-path guide-dark guide-cloud-a" pathLength="1" d="M44.46,52.16l-1.44,9.05c-.1.64-35.57,8.08-30.51,43.05,2.04,14.08,14.69,31.06,31.94,31.22l84.34.78c3.64.03,7.49.5,9.97,2.81,1.69,1.57,1.95,6.1-.29,9.22l-83.49.43c-22.68.12-43.45-11.19-51.76-32.9-8.64-22.56.61-49.48,22.58-59.54,5.11-2.34,7.7-4,8.39-9.37l10.28,5.24h-.01Z"/>
|
||||
<path class="guide-path guide-dark guide-cloud-b" pathLength="1" d="M44.46,52.16l-10.28-5.24c1.94-15.09,10.61-28.25,23.15-36.38C74.85-.82,94.57-3.69,115.01,5.35c14.93,6.6,26.96,21.97,32.12,39.33,26.07,3.63,46.19,26.18,40.75,53.8-3.06.86-9.18-1.15-11.34-3.72,1.28-12.11-3.1-22.27-10.75-29.43-8.19-7.67-17.88-9.28-30.09-8.04-.09-25.91-19.81-43.97-43.82-45.33-22.91-1.29-43.59,16.13-47.42,40.2h0Z"/>
|
||||
<path class="guide-path guide-word guide-word-a" pathLength="1" d="M305.44,141.35h-15.94V39.04h-17.08c-13.18,0-16.75,7.81-16.59,17.4v8.95h17.4v13.34h-17.4v62.62h-15.94v-62.62h-14.64v-13.34h14.64v-7.16c0-20.66,8.95-34.16,29.6-34.16h35.95v117.28Z"/>
|
||||
<path class="guide-path guide-word guide-word-b" pathLength="1" d="M324.31,103.29c0-24.07,18.22-39.85,39.85-39.85s40.01,15.78,40.01,39.85-18.38,40.01-40.01,40.01-39.85-15.94-39.85-40.01ZM388.24,103.29c0-14.64-11.06-24.89-24.07-24.89s-23.91,10.25-23.91,24.89,10.9,25.05,23.91,25.05,24.07-10.25,24.07-25.05Z"/>
|
||||
<path class="guide-path guide-word guide-word-c" pathLength="1" d="M418,103.29c0-24.07,19.03-39.85,40.99-39.85,12.36,0,22.93,5.04,29.6,13.01l-10.57,9.92c-4.39-4.88-11.06-7.97-18.71-7.97-14.15,0-25.38,10.41-25.38,24.89s11.22,25.05,25.38,25.05c7.65,0,14.31-3.09,18.71-7.97l10.57,9.92c-6.67,7.97-17.24,13.01-29.6,13.01-21.96,0-40.99-15.94-40.99-40.01Z"/>
|
||||
<path class="guide-path guide-word guide-word-d" pathLength="1" d="M502.91,37.74c0-5.86,5.04-10.25,10.9-10.25s10.9,4.39,10.9,10.25-4.88,10.25-10.9,10.25-10.9-4.39-10.9-10.25ZM505.84,141.35v-75.96h15.94v75.96h-15.94Z"/>
|
||||
</g>
|
||||
<rect class="shine" x="-80" y="0" width="120" height="156.71" fill="url(#shineGradient)" transform="skewX(-18)"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 8.8 KiB |
@@ -0,0 +1,72 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 960 620" width="960" height="620" font-family="-apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif" role="img" aria-labelledby="title desc">
|
||||
<title id="title">TheCloud architecture</title>
|
||||
<desc id="desc">The browser talks only to TheCloud's API, which dispatches each request through one proxy service and an adapter registry to a provider adapter, which calls the local AWS, Azure or GCP runtime with that cloud's real API.</desc>
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M0,0 L10,5 L0,10 z" fill="#5b6473"/>
|
||||
</marker>
|
||||
</defs>
|
||||
<rect width="960" height="620" rx="16" fill="#f7f4ee"/>
|
||||
|
||||
<text x="480" y="44" text-anchor="middle" font-size="24" font-weight="700" fill="#1d2330">TheCloud architecture</text>
|
||||
<text x="480" y="70" text-anchor="middle" font-size="14" fill="#5b6473">One console for local AWS, Azure and GCP runtimes, using each cloud's real API</text>
|
||||
|
||||
<!-- Browser -->
|
||||
<rect x="250" y="96" width="460" height="74" rx="10" fill="#ffffff" stroke="#c9b48a" stroke-width="2"/>
|
||||
<text x="270" y="124" font-size="16" font-weight="700" fill="#1d2330">Console (React, port 4500)</text>
|
||||
<text x="270" y="148" font-size="13" fill="#5b6473">Nav and pages rendered from each service's schema; calls only /api/*</text>
|
||||
|
||||
<line x1="480" y1="170" x2="480" y2="202" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- API -->
|
||||
<rect x="160" y="206" width="640" height="186" rx="10" fill="#ffffff" stroke="#1d2330" stroke-width="2"/>
|
||||
<text x="180" y="234" font-size="16" font-weight="700" fill="#1d2330">TheCloud API (Bun and Hono, port 4501)</text>
|
||||
<text x="180" y="256" font-size="13" fill="#5b6473">Sign-in, then the generic /api/clouds/:cloud/services/:service routes</text>
|
||||
|
||||
<rect x="180" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
|
||||
<text x="196" y="294" font-size="14" font-weight="600" fill="#1d2330">CloudProxyService</text>
|
||||
<text x="196" y="312" font-size="12" fill="#5b6473">One dispatcher for every request</text>
|
||||
|
||||
<rect x="500" y="272" width="280" height="50" rx="8" fill="#efe7d6"/>
|
||||
<text x="516" y="294" font-size="14" font-weight="600" fill="#1d2330">Adapter registry and service catalog</text>
|
||||
<text x="516" y="312" font-size="12" fill="#5b6473">Registered adapters decide what is shown</text>
|
||||
|
||||
<line x1="460" y1="297" x2="496" y2="297" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<text x="180" y="350" font-size="13" fill="#1d2330"><tspan font-weight="600">Schemas</tspan> describe fields, columns and capabilities; a capability</text>
|
||||
<text x="180" y="370" font-size="13" fill="#1d2330">is only advertised when its adapter can perform it (checked by tests).</text>
|
||||
|
||||
<line x1="240" y1="392" x2="200" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="480" y1="392" x2="480" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="720" y1="392" x2="760" y2="438" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- Adapters -->
|
||||
<rect x="60" y="442" width="270" height="60" rx="10" fill="#fff3e3" stroke="#e08a2c" stroke-width="2"/>
|
||||
<text x="76" y="468" font-size="15" font-weight="700" fill="#a85a0c">AWS adapters</text>
|
||||
<text x="76" y="488" font-size="12" fill="#5b6473">AWS SDK v3, one per service</text>
|
||||
|
||||
<rect x="345" y="442" width="270" height="60" rx="10" fill="#e8f0fd" stroke="#2f6fd6" stroke-width="2"/>
|
||||
<text x="361" y="468" font-size="15" font-weight="700" fill="#1f4fa3">Azure adapters</text>
|
||||
<text x="361" y="488" font-size="12" fill="#5b6473">Azure runtime client</text>
|
||||
|
||||
<rect x="630" y="442" width="270" height="60" rx="10" fill="#e8f6ec" stroke="#2f9a55" stroke-width="2"/>
|
||||
<text x="646" y="468" font-size="15" font-weight="700" fill="#1f7140">GCP adapters</text>
|
||||
<text x="646" y="488" font-size="12" fill="#5b6473">GCP runtime client</text>
|
||||
|
||||
<line x1="195" y1="502" x2="195" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="480" y1="502" x2="480" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
<line x1="765" y1="502" x2="765" y2="530" stroke="#5b6473" stroke-width="2" marker-end="url(#arrow)"/>
|
||||
|
||||
<!-- Runtimes -->
|
||||
<rect x="60" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||
<text x="76" y="558" font-size="14" font-weight="700" fill="#f7f4ee">TheCloud AWS runtime</text>
|
||||
<text x="76" y="578" font-size="12" fill="#c9cfd8">port 4566</text>
|
||||
|
||||
<rect x="345" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||
<text x="361" y="558" font-size="14" font-weight="700" fill="#f7f4ee">Azure runtime</text>
|
||||
<text x="361" y="578" font-size="12" fill="#c9cfd8">port 4577, optional</text>
|
||||
|
||||
<rect x="630" y="534" width="270" height="56" rx="10" fill="#1d2330"/>
|
||||
<text x="646" y="558" font-size="14" font-weight="700" fill="#f7f4ee">GCP runtime</text>
|
||||
<text x="646" y="578" font-size="12" fill="#c9cfd8">port 4588, optional</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 175 KiB |
|
After Width: | Height: | Size: 91 KiB |
@@ -11,7 +11,6 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-api-gateway": "^3.1076.0",
|
||||
"@aws-sdk/client-apigatewayv2": "^3.1076.0",
|
||||
"@aws-sdk/client-appconfig": "^3.1076.0",
|
||||
"@aws-sdk/client-cloudformation": "^3.1111.0",
|
||||
"@aws-sdk/client-cloudfront": "^3.1146.0",
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
type APIGatewayClient,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {GetApisCommand, type ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AwsApiGatewayAdapter} from './AwsApiGatewayAdapter'
|
||||
|
||||
function fakeClient(handler: (command: unknown) => unknown): APIGatewayClient {
|
||||
@@ -33,18 +32,6 @@ function fakeLambda(handler: (command: unknown) => unknown): LambdaClient {
|
||||
} as unknown as LambdaClient
|
||||
}
|
||||
|
||||
/** An API Gateway v2 runtime with no HTTP or WebSocket APIs, for tests about REST APIs. */
|
||||
const noHttpApis = {
|
||||
send: async (command: unknown) => {
|
||||
if (command instanceof GetApisCommand) return {Items: []}
|
||||
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
},
|
||||
} as unknown as ApiGatewayV2Client
|
||||
|
||||
function restOnlyAdapter(client: APIGatewayClient) {
|
||||
return new AwsApiGatewayAdapter(client, fakeLambda(() => ({})), '000000000000', 'us-east-1', noHttpApis)
|
||||
}
|
||||
|
||||
const orders = {
|
||||
id: 'a1b2c3',
|
||||
name: 'orders-api',
|
||||
@@ -57,7 +44,7 @@ const orders = {
|
||||
|
||||
describe('AwsApiGatewayAdapter', () => {
|
||||
test('lists every page and maps REST APIs to normalized resources', async () => {
|
||||
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
if (!(command instanceof GetRestApisCommand)) throw new Error('Unexpected command')
|
||||
if (!command.input.position) return {items: [orders], position: 'next'}
|
||||
return {items: [{...orders, id: 'd4e5f6', name: 'accounts-api'}]}
|
||||
@@ -83,7 +70,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('filters APIs by search term', async () => {
|
||||
const adapter = restOnlyAdapter(fakeClient(() => ({
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => ({
|
||||
items: [orders, {...orders, id: 'd4e5f6', name: 'accounts-api'}],
|
||||
})))
|
||||
|
||||
@@ -105,7 +92,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('returns null when API Gateway reports a missing REST API', async () => {
|
||||
const adapter = restOnlyAdapter(fakeClient(() => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient(() => {
|
||||
throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
}))
|
||||
|
||||
@@ -136,7 +123,7 @@ describe('AwsApiGatewayAdapter', () => {
|
||||
})
|
||||
|
||||
test('deletes the requested REST API', async () => {
|
||||
const adapter = restOnlyAdapter(fakeClient((command) => {
|
||||
const adapter = new AwsApiGatewayAdapter(fakeClient((command) => {
|
||||
expect(command).toBeInstanceOf(DeleteRestApiCommand)
|
||||
expect((command as DeleteRestApiCommand).input.restApiId).toBe('a1b2c3')
|
||||
return {}
|
||||
|
||||
@@ -22,13 +22,10 @@ import {
|
||||
type RestApi,
|
||||
} from '@aws-sdk/client-api-gateway'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import type {ApiGatewayV2Client} from '@aws-sdk/client-apigatewayv2'
|
||||
import {apiGateway as defaultApiGateway, apiGatewayV2 as defaultApiGatewayV2, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||
import {apiGateway as defaultApiGateway, defaultAccountId, lambda as defaultLambda} from '../aws'
|
||||
import {awsApiGatewaySchema} from '../cloud-spi/apigatewaySchema'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import {AwsHttpApiOperations} from './AwsHttpApiOperations'
|
||||
import type {HttpApiAuthorizerInput, HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteInput, HttpApiRouteOverview, HttpApiStageInput, HttpApiStageOverview, HttpApiWebSocketTestInput, HttpApiWebSocketTestResult} from '../cloud-spi/types'
|
||||
import type {ApiGatewayCorsInput, ApiGatewayCorsResult, ApiGatewayDeleteRouteInput, ApiGatewayLambdaRouteInput, ApiGatewayLambdaRouteResult, ApiGatewayMethodTestInput, ApiGatewayMethodTestResult, ApiGatewayRouteOverview, ApiGatewayRoutesResult, ApiGatewayTestRouteInput, ApiGatewayTestRouteResult, CloudResource, CloudServiceAdapter, CreateResourceInput, ResourceQuery, ServiceSchema} from '../cloud-spi/types'
|
||||
|
||||
export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
@@ -40,13 +37,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
private readonly lambda: LambdaClient = defaultLambda,
|
||||
private readonly accountId = defaultAccountId(),
|
||||
private readonly region = process.env.AWS_REGION ?? 'us-east-1',
|
||||
apiGatewayV2: ApiGatewayV2Client = defaultApiGatewayV2,
|
||||
) {
|
||||
this.httpApis = new AwsHttpApiOperations(apiGatewayV2, lambda, accountId, region)
|
||||
}
|
||||
|
||||
/** HTTP and WebSocket APIs (API Gateway v2), listed and managed alongside REST APIs. */
|
||||
private readonly httpApis: AwsHttpApiOperations
|
||||
) {}
|
||||
|
||||
schema(): ServiceSchema {
|
||||
return awsApiGatewaySchema()
|
||||
@@ -62,17 +53,7 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
position = res.position
|
||||
} while (position)
|
||||
|
||||
return filterBySearch([...apis.map(toResource), ...await this.listHttpApis()], query.search)
|
||||
}
|
||||
|
||||
/** A runtime without API Gateway v2 still lists its REST APIs. */
|
||||
private async listHttpApis(): Promise<CloudResource[]> {
|
||||
try {
|
||||
return await this.httpApis.list()
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404) || hasHttpStatus(error, 501)) return []
|
||||
throw error
|
||||
}
|
||||
return filterBySearch(apis.map(toResource), query.search)
|
||||
}
|
||||
|
||||
async get(id: string): Promise<CloudResource | null> {
|
||||
@@ -80,15 +61,12 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
const res = await this.apiGateway.send(new GetRestApiCommand({restApiId: id}))
|
||||
return toResource(res)
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 404)) throw error
|
||||
if (hasHttpStatus(error, 404)) return null
|
||||
throw error
|
||||
}
|
||||
return this.httpApis.get(id)
|
||||
}
|
||||
|
||||
async create(input: CreateResourceInput): Promise<CloudResource> {
|
||||
const protocol = stringValue(input.values.protocol) || 'REST'
|
||||
if (protocol === 'HTTP' || protocol === 'WEBSOCKET') return this.httpApis.create(input.values, protocol)
|
||||
if (protocol !== 'REST') throw new ValidationError('protocol must be REST, HTTP or WEBSOCKET')
|
||||
const name = stringValue(input.values.name)
|
||||
const description = stringValue(input.values.description)
|
||||
if (!name) throw new ValidationError('name is required')
|
||||
@@ -101,54 +79,9 @@ export class AwsApiGatewayAdapter implements CloudServiceAdapter {
|
||||
}
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
if (await this.httpApis.get(id)) return this.httpApis.delete(id)
|
||||
await this.apiGateway.send(new DeleteRestApiCommand({restApiId: id}))
|
||||
}
|
||||
|
||||
describeHttpApi(apiId: string): Promise<HttpApiOverview> {
|
||||
return this.httpApis.describe(apiId)
|
||||
}
|
||||
|
||||
createHttpApiRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApis.createRoute(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiRoute(apiId: string, routeId: string): Promise<void> {
|
||||
return this.httpApis.deleteRoute(apiId, routeId)
|
||||
}
|
||||
|
||||
setHttpApiRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApis.setRouteAuthorizer(apiId, routeId, authorizerId)
|
||||
}
|
||||
|
||||
createHttpApiStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
return this.httpApis.createStage(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiStage(apiId: string, stageName: string): Promise<void> {
|
||||
return this.httpApis.deleteStage(apiId, stageName)
|
||||
}
|
||||
|
||||
deployHttpApi(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
return this.httpApis.deploy(apiId, stageName)
|
||||
}
|
||||
|
||||
createHttpApiAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
return this.httpApis.createAuthorizer(apiId, input)
|
||||
}
|
||||
|
||||
deleteHttpApiAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||
return this.httpApis.deleteAuthorizer(apiId, authorizerId)
|
||||
}
|
||||
|
||||
updateHttpApiCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
return this.httpApis.updateCors(apiId, cors)
|
||||
}
|
||||
|
||||
testWebSocketApi(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||
return this.httpApis.testWebSocket(apiId, input)
|
||||
}
|
||||
|
||||
async createApiGatewayLambdaRoute(apiId: string, input: ApiGatewayLambdaRouteInput): Promise<ApiGatewayLambdaRouteResult> {
|
||||
const method = input.method.toUpperCase()
|
||||
const stage = stageName(input.stage)
|
||||
@@ -537,8 +470,7 @@ function childPath(parentPath: string, pathPart: string): string {
|
||||
|
||||
function stageName(stage: string): string {
|
||||
const value = stringValue(stage)
|
||||
// $default is an HTTP API's catch-all stage; the deployed-request test serves both kinds of API.
|
||||
if (!/^(?:\$default|[A-Za-z0-9_-]+)$/.test(value)) {
|
||||
if (!/^[A-Za-z0-9_-]+$/.test(value)) {
|
||||
throw new ValidationError('stage must contain only letters, numbers, underscores, or hyphens')
|
||||
}
|
||||
return value
|
||||
@@ -586,7 +518,6 @@ function toResource(api: RestApi): CloudResource {
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
apigatewayService: 'rest',
|
||||
protocol: 'REST',
|
||||
description: api.description,
|
||||
version: api.version,
|
||||
endpointTypes: api.endpointConfiguration?.types,
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
import {afterEach, describe, expect, test} from 'bun:test'
|
||||
import {
|
||||
CreateApiCommand,
|
||||
CreateAuthorizerCommand,
|
||||
CreateDeploymentCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateRouteCommand,
|
||||
CreateRouteResponseCommand,
|
||||
CreateStageCommand,
|
||||
DeleteApiCommand,
|
||||
DeleteCorsConfigurationCommand,
|
||||
DeleteIntegrationCommand,
|
||||
DeleteRouteCommand,
|
||||
GetApiCommand,
|
||||
GetApisCommand,
|
||||
GetAuthorizersCommand,
|
||||
GetIntegrationsCommand,
|
||||
GetRoutesCommand,
|
||||
GetStageCommand,
|
||||
GetStagesCommand,
|
||||
UpdateApiCommand,
|
||||
UpdateRouteCommand,
|
||||
type ApiGatewayV2Client,
|
||||
} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {AwsHttpApiOperations, httpRouteKey, webSocketRouteKey} from './AwsHttpApiOperations'
|
||||
import {ValidationError} from '../cloud-spi/errors'
|
||||
|
||||
const CREATED = new Date('2026-10-25T10:00:00Z')
|
||||
const httpApi = {ApiId: 'h1', Name: 'notes', ProtocolType: 'HTTP', ApiEndpoint: 'https://h1.execute-api.us-east-1.amazonaws.com', CreatedDate: CREATED}
|
||||
const webSocketApi = {ApiId: 'w1', Name: 'chat', ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action', CreatedDate: CREATED}
|
||||
const functionArn = 'arn:aws:lambda:us-east-1:000000000000:function:notes'
|
||||
|
||||
interface Runtime {
|
||||
apis?: Record<string, unknown>
|
||||
routes?: unknown[]
|
||||
integrations?: unknown[]
|
||||
stages?: unknown[]
|
||||
authorizers?: unknown[]
|
||||
reply?: (command: object) => unknown
|
||||
}
|
||||
|
||||
function stubRuntime(runtime: Runtime = {}) {
|
||||
const sent: object[] = []
|
||||
const lambdaSent: object[] = []
|
||||
const apis = runtime.apis ?? {h1: httpApi, w1: webSocketApi}
|
||||
const client = {
|
||||
async send(command: object) {
|
||||
sent.push(command)
|
||||
const custom = runtime.reply?.(command)
|
||||
if (custom !== undefined) return custom
|
||||
if (command instanceof GetApisCommand) return {Items: Object.values(apis)}
|
||||
if (command instanceof GetApiCommand) {
|
||||
const api = apis[command.input.ApiId!]
|
||||
if (!api) throw Object.assign(new Error('NotFoundException'), {$metadata: {httpStatusCode: 404}})
|
||||
return api
|
||||
}
|
||||
if (command instanceof GetRoutesCommand) return {Items: runtime.routes ?? []}
|
||||
if (command instanceof GetIntegrationsCommand) return {Items: runtime.integrations ?? []}
|
||||
if (command instanceof GetStagesCommand) return {Items: runtime.stages ?? []}
|
||||
if (command instanceof GetAuthorizersCommand) return {Items: runtime.authorizers ?? []}
|
||||
if (command instanceof CreateIntegrationCommand) return {IntegrationId: 'new-integration', ...command.input}
|
||||
if (command instanceof CreateRouteCommand) return {RouteId: 'new-route', ...command.input}
|
||||
if (command instanceof CreateApiCommand) return {ApiId: 'new-api', CreatedDate: CREATED, ...command.input}
|
||||
if (command instanceof CreateStageCommand) return {...command.input}
|
||||
return {}
|
||||
},
|
||||
} as unknown as ApiGatewayV2Client
|
||||
const lambda = {
|
||||
async send(command: object) {
|
||||
lambdaSent.push(command)
|
||||
if (command instanceof GetFunctionCommand) return {Configuration: {FunctionArn: functionArn}}
|
||||
return {}
|
||||
},
|
||||
} as unknown as LambdaClient
|
||||
const discoveries: string[] = []
|
||||
const fetchJson = async (url: string) => {
|
||||
discoveries.push(url)
|
||||
return {issuer: 'http://floci:4566/us-east-1_pool1'}
|
||||
}
|
||||
const operations = new AwsHttpApiOperations(client, lambda, '000000000000', 'us-east-1', fetchJson)
|
||||
const of = <T extends object>(type: new (...args: never[]) => T): T[] => sent.filter((command) => command instanceof type) as T[]
|
||||
const lambdaOf = <T extends object>(type: new (...args: never[]) => T): T[] => lambdaSent.filter((command) => command instanceof type) as T[]
|
||||
return {operations, sent, of, lambdaOf, discoveries}
|
||||
}
|
||||
|
||||
describe('AwsHttpApiOperations APIs', () => {
|
||||
test('lists HTTP and WebSocket APIs with their protocol', async () => {
|
||||
const {operations} = stubRuntime()
|
||||
|
||||
const apis = await operations.list()
|
||||
expect(apis.map((api) => [api.id, api.type, api.metadata.protocol, api.metadata.apigatewayService])).toEqual([
|
||||
['h1', 'http-api', 'HTTP', 'http'],
|
||||
['w1', 'websocket-api', 'WEBSOCKET', 'websocket'],
|
||||
])
|
||||
expect(await operations.get('rest-id')).toBeNull()
|
||||
})
|
||||
|
||||
test('quick-creates an HTTP API that sends every request to a Lambda', async () => {
|
||||
const {operations, of, lambdaOf} = stubRuntime()
|
||||
|
||||
await operations.create({name: 'notes', functionName: 'notes'}, 'HTTP')
|
||||
|
||||
expect(of(CreateApiCommand)[0].input).toMatchObject({Name: 'notes', ProtocolType: 'HTTP', Target: functionArn})
|
||||
expect(of(CreateStageCommand)).toHaveLength(0)
|
||||
expect(lambdaOf(AddPermissionCommand)[0].input).toMatchObject({
|
||||
FunctionName: 'notes', Principal: 'apigateway.amazonaws.com', SourceArn: 'arn:aws:execute-api:us-east-1:000000000000:new-api/*',
|
||||
})
|
||||
})
|
||||
|
||||
test('gives an HTTP API without a target an auto-deployed $default stage', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.create({name: 'empty'}, 'HTTP')
|
||||
expect(of(CreateStageCommand)[0].input).toEqual({ApiId: 'new-api', StageName: '$default', AutoDeploy: true})
|
||||
})
|
||||
|
||||
test('creates a WebSocket API with its route selection expression and a stage', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.create({name: 'chat', stageName: 'prod'}, 'WEBSOCKET')
|
||||
expect(of(CreateApiCommand)[0].input).toMatchObject({ProtocolType: 'WEBSOCKET', RouteSelectionExpression: '$request.body.action'})
|
||||
expect(of(CreateStageCommand)[0].input).toMatchObject({StageName: 'prod', AutoDeploy: true})
|
||||
await expect(operations.create({name: 'chat', routeSelectionExpression: 'action'}, 'WEBSOCKET')).rejects.toThrow(/routeSelectionExpression/)
|
||||
})
|
||||
|
||||
test('deletes an API and the Lambda permissions it granted', async () => {
|
||||
const {operations, of, lambdaOf} = stubRuntime({integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}]})
|
||||
|
||||
await operations.delete('h1')
|
||||
expect(of(DeleteApiCommand)[0].input).toEqual({ApiId: 'h1'})
|
||||
expect(lambdaOf(RemovePermissionCommand)[0].input).toEqual({FunctionName: 'notes', StatementId: 'apigatewayv2-h1'})
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsHttpApiOperations routes', () => {
|
||||
test('describes routes with their integration, stages, authorizers and CORS', async () => {
|
||||
const {operations} = stubRuntime({
|
||||
apis: {h1: {...httpApi, CorsConfiguration: {AllowOrigins: ['*'], AllowMethods: ['GET'], MaxAge: 300}}},
|
||||
routes: [
|
||||
{RouteId: 'r2', RouteKey: 'POST /notes', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
||||
{RouteId: 'r1', RouteKey: 'GET /notes', Target: 'integrations/i1', AuthorizationType: 'NONE', AuthorizerId: 'a1'},
|
||||
],
|
||||
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn, PayloadFormatVersion: '2.0'}],
|
||||
stages: [{StageName: '$default', AutoDeploy: true}],
|
||||
authorizers: [{AuthorizerId: 'a1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'], JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']}}],
|
||||
})
|
||||
|
||||
const overview = await operations.describe('h1')
|
||||
expect(overview.routes.map((route) => [route.routeKey, route.integration?.functionName, route.authorizerId])).toEqual([
|
||||
['GET /notes', 'notes', null],
|
||||
['POST /notes', 'notes', 'a1'],
|
||||
])
|
||||
expect(overview.stages).toEqual([{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}])
|
||||
expect(overview.authorizers[0]).toMatchObject({name: 'cognito', issuer: 'http://floci:4566/us-east-1_pool1', audience: ['client1']})
|
||||
expect(overview.cors).toMatchObject({allowOrigins: ['*'], allowMethods: ['GET'], maxAgeSeconds: 300})
|
||||
})
|
||||
|
||||
test('adds a Lambda route, reusing the integration the function already has', async () => {
|
||||
const {operations, of, lambdaOf} = stubRuntime({
|
||||
integrations: [{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn}],
|
||||
})
|
||||
|
||||
const route = await operations.createRoute('h1', {routeKey: 'get /notes/{id}', functionName: 'notes', authorizerId: 'a1'})
|
||||
expect(of(CreateIntegrationCommand)).toHaveLength(0)
|
||||
expect(of(CreateRouteCommand)[0].input).toMatchObject({RouteKey: 'GET /notes/{id}', Target: 'integrations/i1', AuthorizationType: 'JWT', AuthorizerId: 'a1'})
|
||||
expect(lambdaOf(AddPermissionCommand)).toHaveLength(1)
|
||||
expect(route.integration?.functionName).toBe('notes')
|
||||
})
|
||||
|
||||
test('adds an HTTP proxy route with a payload 1.0 integration', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.createRoute('h1', {routeKey: 'ANY /legacy/{proxy+}', httpUrl: 'https://example.com/{proxy}'})
|
||||
expect(of(CreateIntegrationCommand)[0].input).toMatchObject({IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com/{proxy}', IntegrationMethod: 'ANY', PayloadFormatVersion: '1.0'})
|
||||
})
|
||||
|
||||
test('a two-way WebSocket route gets a route response and an invocation-URI integration', async () => {
|
||||
const {operations, of} = stubRuntime()
|
||||
|
||||
await operations.createRoute('w1', {routeKey: 'sendMessage', functionName: 'notes', returnsResponse: true})
|
||||
expect(of(CreateIntegrationCommand)[0].input.IntegrationUri).toBe(`arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/${functionArn}/invocations`)
|
||||
expect(of(CreateRouteCommand)[0].input.RouteResponseSelectionExpression).toBe('$default')
|
||||
expect(of(CreateRouteResponseCommand)[0].input).toEqual({ApiId: 'w1', RouteId: 'new-route', RouteResponseKey: '$default'})
|
||||
})
|
||||
|
||||
test('checks a route before creating anything', async () => {
|
||||
const {operations, sent} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /notes'}]})
|
||||
const before = sent.length
|
||||
|
||||
await expect(operations.createRoute('h1', {routeKey: 'GET /notes', functionName: 'notes'})).rejects.toThrow(/already has/)
|
||||
await expect(operations.createRoute('h1', {routeKey: 'GET /notes/x', functionName: 'notes', httpUrl: 'https://example.com'})).rejects.toThrow(/either/)
|
||||
await expect(operations.createRoute('h1', {routeKey: 'FETCH /notes', functionName: 'notes'})).rejects.toThrow(ValidationError)
|
||||
await expect(operations.createRoute('w1', {routeKey: 'send', httpUrl: 'https://example.com'})).rejects.toThrow(/Lambda functions only/)
|
||||
await expect(operations.createRoute('w1', {routeKey: 'send', functionName: 'notes', authorizerId: 'a1'})).rejects.toThrow(/Lambda authorizers/)
|
||||
expect(sent.slice(before).some((command) => command instanceof CreateRouteCommand || command instanceof CreateIntegrationCommand)).toBe(false)
|
||||
})
|
||||
|
||||
test('deleting a route removes the integration and permission only it used', async () => {
|
||||
const integrations = [
|
||||
{IntegrationId: 'i1', IntegrationType: 'AWS_PROXY', IntegrationUri: functionArn},
|
||||
{IntegrationId: 'i2', IntegrationType: 'HTTP_PROXY', IntegrationUri: 'https://example.com'},
|
||||
]
|
||||
const shared = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}, {RouteId: 'r2', Target: 'integrations/i1'}], integrations})
|
||||
await shared.operations.deleteRoute('h1', 'r1')
|
||||
expect(shared.of(DeleteRouteCommand)).toHaveLength(1)
|
||||
expect(shared.of(DeleteIntegrationCommand)).toHaveLength(0)
|
||||
|
||||
const last = stubRuntime({routes: [{RouteId: 'r1', Target: 'integrations/i1'}], integrations})
|
||||
await last.operations.deleteRoute('h1', 'r1')
|
||||
expect(last.of(DeleteIntegrationCommand)[0].input).toEqual({ApiId: 'h1', IntegrationId: 'i1'})
|
||||
expect(last.lambdaOf(RemovePermissionCommand)[0].input.FunctionName).toBe('notes')
|
||||
})
|
||||
|
||||
test('protects a route with an authorizer and removes it again', async () => {
|
||||
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateRouteCommand ? {RouteKey: 'GET /me', ...command.input} : undefined})
|
||||
|
||||
expect((await operations.setRouteAuthorizer('h1', 'r1', 'a1')).authorizerId).toBe('a1')
|
||||
expect((await operations.setRouteAuthorizer('h1', 'r1', null)).authorizerId).toBeNull()
|
||||
expect(of(UpdateRouteCommand).map((command) => command.input)).toEqual([
|
||||
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'JWT', AuthorizerId: 'a1'},
|
||||
{ApiId: 'h1', RouteId: 'r1', AuthorizationType: 'NONE', AuthorizerId: undefined},
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsHttpApiOperations stages, authorizers and CORS', () => {
|
||||
test('a stage without auto-deploy is deployed once when created', async () => {
|
||||
const {operations, of} = stubRuntime({reply: (command) => command instanceof GetStageCommand ? {StageName: 'prod', DeploymentId: 'd1'} : undefined})
|
||||
|
||||
const stage = await operations.createStage('h1', {stageName: 'prod', autoDeploy: false})
|
||||
expect(of(CreateDeploymentCommand)[0].input).toMatchObject({ApiId: 'h1', StageName: 'prod'})
|
||||
expect(stage).toMatchObject({stageName: 'prod', deploymentId: 'd1', invokePath: '/execute-api/h1/prod'})
|
||||
await expect(operations.createStage('h1', {stageName: 'pro d', autoDeploy: true})).rejects.toThrow(/stage/)
|
||||
})
|
||||
|
||||
test('fills a JWT authorizer from a Cognito pool discovery document', async () => {
|
||||
const {operations, of, discoveries} = stubRuntime({reply: (command) => command instanceof CreateAuthorizerCommand ? {AuthorizerId: 'a1', ...command.input} : undefined})
|
||||
|
||||
const authorizer = await operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1', userPoolClientId: 'client1'})
|
||||
expect(discoveries[0]).toEndWith('/us-east-1_pool1/.well-known/openid-configuration')
|
||||
expect(of(CreateAuthorizerCommand)[0].input).toEqual({
|
||||
ApiId: 'h1', Name: 'cognito', AuthorizerType: 'JWT', IdentitySource: ['$request.header.Authorization'],
|
||||
JwtConfiguration: {Issuer: 'http://floci:4566/us-east-1_pool1', Audience: ['client1']},
|
||||
})
|
||||
expect(authorizer.issuer).toBe('http://floci:4566/us-east-1_pool1')
|
||||
await expect(operations.createAuthorizer('h1', {name: 'cognito', userPoolId: 'us-east-1_pool1'})).rejects.toThrow(/app client/)
|
||||
await expect(operations.createAuthorizer('h1', {name: 'other', issuer: 'https://issuer.example.com', audience: []})).rejects.toThrow(/audience/)
|
||||
await expect(operations.createAuthorizer('w1', {name: 'x', issuer: 'https://issuer.example.com', audience: ['a']})).rejects.toThrow(/HTTP APIs/)
|
||||
})
|
||||
|
||||
test('will not delete an authorizer that still protects a route', async () => {
|
||||
const {operations} = stubRuntime({routes: [{RouteId: 'r1', RouteKey: 'GET /me', AuthorizationType: 'JWT', AuthorizerId: 'a1'}]})
|
||||
|
||||
await expect(operations.deleteAuthorizer('h1', 'a1')).rejects.toThrow('Remove the authorizer from GET /me first')
|
||||
})
|
||||
|
||||
test('sets and removes the CORS configuration', async () => {
|
||||
const {operations, of} = stubRuntime({reply: (command) => command instanceof UpdateApiCommand ? {...httpApi, CorsConfiguration: command.input.CorsConfiguration} : undefined})
|
||||
const cors = {allowOrigins: ['https://app.example.com'], allowMethods: ['get', 'POST'], allowHeaders: ['content-type'], exposeHeaders: [], maxAgeSeconds: 600, allowCredentials: true}
|
||||
|
||||
expect(await operations.updateCors('h1', cors)).toEqual({...cors, allowMethods: ['GET', 'POST']})
|
||||
expect(await operations.updateCors('h1', null)).toBeNull()
|
||||
expect(of(DeleteCorsConfigurationCommand)).toHaveLength(1)
|
||||
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['*']})).rejects.toThrow(/Credentials/)
|
||||
await expect(operations.updateCors('h1', {...cors, allowOrigins: ['app.example.com']})).rejects.toThrow(/origin/)
|
||||
await expect(operations.updateCors('w1', cors)).rejects.toThrow(/HTTP APIs only/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('AwsHttpApiOperations WebSocket test', () => {
|
||||
const endpoint = process.env.FLOCI_ENDPOINT
|
||||
afterEach(() => {
|
||||
if (endpoint === undefined) delete process.env.FLOCI_ENDPOINT
|
||||
else process.env.FLOCI_ENDPOINT = endpoint
|
||||
})
|
||||
|
||||
test('connects to the stage, sends the message and collects the replies', async () => {
|
||||
const paths: string[] = []
|
||||
const server = Bun.serve({
|
||||
port: 0,
|
||||
fetch(request, upgrade) {
|
||||
paths.push(new URL(request.url).pathname + new URL(request.url).search)
|
||||
return upgrade.upgrade(request) ? undefined : new Response('no', {status: 400})
|
||||
},
|
||||
websocket: {message(socket, message) { socket.send(`echo ${message}`) }},
|
||||
})
|
||||
process.env.FLOCI_ENDPOINT = `http://localhost:${server.port}`
|
||||
try {
|
||||
const {operations} = stubRuntime()
|
||||
const result = await operations.testWebSocket('w1', {stage: 'dev', message: '{"action":"ping"}', query: '?token=abc'})
|
||||
expect(result).toMatchObject({connected: true, messages: ['echo {"action":"ping"}'], error: null})
|
||||
expect(paths).toEqual(['/ws/w1/dev?token=abc'])
|
||||
} finally {
|
||||
server.stop(true)
|
||||
}
|
||||
})
|
||||
|
||||
test('reports a refused connection', async () => {
|
||||
process.env.FLOCI_ENDPOINT = 'http://127.0.0.1:1'
|
||||
const {operations} = stubRuntime()
|
||||
|
||||
const result = await operations.testWebSocket('w1', {stage: 'dev', message: 'hi'}, 2000)
|
||||
expect(result.connected).toBe(false)
|
||||
expect(result.error).toBeTruthy()
|
||||
await expect(operations.testWebSocket('h1', {stage: 'dev', message: 'hi'})).rejects.toThrow(/not a WebSocket API/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('route keys', () => {
|
||||
test('HTTP route keys are $default or a method and a path', () => {
|
||||
expect(httpRouteKey('$default')).toBe('$default')
|
||||
expect(httpRouteKey(' any /files/{proxy+} ')).toBe('ANY /files/{proxy+}')
|
||||
expect(httpRouteKey('GET /')).toBe('GET /')
|
||||
for (const bad of ['GET', '/notes', 'GET notes', 'GET /{proxy+}/more', 'GET /a b', 'GET /notes/']) {
|
||||
expect(() => httpRouteKey(bad), bad).toThrow(ValidationError)
|
||||
}
|
||||
})
|
||||
|
||||
test('WebSocket route keys are the system routes or an action name', () => {
|
||||
expect(webSocketRouteKey('$connect')).toBe('$connect')
|
||||
expect(webSocketRouteKey('sendMessage')).toBe('sendMessage')
|
||||
expect(() => webSocketRouteKey('$other')).toThrow(ValidationError)
|
||||
expect(() => webSocketRouteKey('send message')).toThrow(ValidationError)
|
||||
})
|
||||
})
|
||||
@@ -1,592 +0,0 @@
|
||||
import {
|
||||
CreateApiCommand,
|
||||
CreateAuthorizerCommand,
|
||||
CreateDeploymentCommand,
|
||||
CreateIntegrationCommand,
|
||||
CreateRouteCommand,
|
||||
CreateRouteResponseCommand,
|
||||
CreateStageCommand,
|
||||
DeleteApiCommand,
|
||||
DeleteAuthorizerCommand,
|
||||
DeleteCorsConfigurationCommand,
|
||||
DeleteIntegrationCommand,
|
||||
DeleteRouteCommand,
|
||||
DeleteStageCommand,
|
||||
GetApiCommand,
|
||||
GetApisCommand,
|
||||
GetAuthorizersCommand,
|
||||
GetIntegrationsCommand,
|
||||
GetRoutesCommand,
|
||||
GetStageCommand,
|
||||
GetStagesCommand,
|
||||
UpdateApiCommand,
|
||||
UpdateRouteCommand,
|
||||
type Api,
|
||||
type ApiGatewayV2Client,
|
||||
type Authorizer,
|
||||
type Cors,
|
||||
type Integration,
|
||||
type Route,
|
||||
type Stage,
|
||||
} from '@aws-sdk/client-apigatewayv2'
|
||||
import {AddPermissionCommand, GetFunctionCommand, RemovePermissionCommand, type LambdaClient} from '@aws-sdk/client-lambda'
|
||||
import {NotFoundError, ValidationError} from '../cloud-spi/errors'
|
||||
import {awsEndpoint} from '../service/runtimeProbe'
|
||||
import type {
|
||||
CloudResource,
|
||||
HttpApiAuthorizerInput,
|
||||
HttpApiAuthorizerOverview,
|
||||
HttpApiCors,
|
||||
HttpApiIntegrationOverview,
|
||||
HttpApiOverview,
|
||||
HttpApiProtocol,
|
||||
HttpApiRouteInput,
|
||||
HttpApiRouteOverview,
|
||||
HttpApiStageInput,
|
||||
HttpApiStageOverview,
|
||||
HttpApiWebSocketTestInput,
|
||||
HttpApiWebSocketTestResult,
|
||||
} from '../cloud-spi/types'
|
||||
|
||||
export const HTTP_ROUTE_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', 'ANY']
|
||||
export const CORS_ALLOWED_METHODS = ['GET', 'POST', 'PUT', 'DELETE', 'PATCH', 'HEAD', 'OPTIONS', '*']
|
||||
export const DEFAULT_ROUTE_SELECTION_EXPRESSION = '$request.body.action'
|
||||
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||
|
||||
/**
|
||||
* API Gateway v2: HTTP and WebSocket APIs. A separate AWS API from REST APIs, with its own
|
||||
* routes, integrations, stages and JWT authorizers, served by the same API Gateway page.
|
||||
*/
|
||||
export class AwsHttpApiOperations {
|
||||
constructor(
|
||||
private readonly client: ApiGatewayV2Client,
|
||||
private readonly lambda: LambdaClient,
|
||||
private readonly accountId: string,
|
||||
private readonly region: string,
|
||||
private readonly fetchJson: (url: string) => Promise<unknown> = defaultFetchJson,
|
||||
) {}
|
||||
|
||||
async list(): Promise<CloudResource[]> {
|
||||
const apis: Api[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const page = await this.client.send(new GetApisCommand({NextToken: next}))
|
||||
apis.push(...(page.Items ?? []))
|
||||
next = page.NextToken
|
||||
} while (next)
|
||||
return apis.map(toResource)
|
||||
}
|
||||
|
||||
/** The API, or null when no HTTP or WebSocket API has this id (it may be a REST API). */
|
||||
async get(apiId: string): Promise<CloudResource | null> {
|
||||
const api = await this.findApi(apiId)
|
||||
return api ? toResource(api) : null
|
||||
}
|
||||
|
||||
async create(values: Record<string, unknown>, protocol: HttpApiProtocol): Promise<CloudResource> {
|
||||
const name = stringValue(values.name)
|
||||
if (!name) throw new ValidationError('name is required')
|
||||
const description = stringValue(values.description) || undefined
|
||||
|
||||
if (protocol === 'WEBSOCKET') {
|
||||
const routeSelectionExpression = stringValue(values.routeSelectionExpression) || DEFAULT_ROUTE_SELECTION_EXPRESSION
|
||||
if (!/^\$request\.body\.[A-Za-z0-9_.]+$/.test(routeSelectionExpression)) {
|
||||
throw new ValidationError('routeSelectionExpression must name a field of the message body, for example $request.body.action')
|
||||
}
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'WEBSOCKET', RouteSelectionExpression: routeSelectionExpression}))
|
||||
const stageName = stageNameValue(stringValue(values.stageName) || 'dev')
|
||||
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: stageName, AutoDeploy: true}))
|
||||
return toResource(api)
|
||||
}
|
||||
|
||||
// Quick create: a Lambda target gives the API a catch-all route and an auto-deployed $default stage.
|
||||
const functionName = stringValue(values.functionName)
|
||||
if (functionName) {
|
||||
const functionArn = await this.functionArn(functionName)
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP', Target: functionArn}))
|
||||
await this.allowInvoke(api.ApiId!, functionName)
|
||||
return toResource(api)
|
||||
}
|
||||
const api = await this.client.send(new CreateApiCommand({Name: name, Description: description, ProtocolType: 'HTTP'}))
|
||||
await this.client.send(new CreateStageCommand({ApiId: api.ApiId, StageName: '$default', AutoDeploy: true}))
|
||||
return toResource(api)
|
||||
}
|
||||
|
||||
async delete(apiId: string): Promise<void> {
|
||||
const {integrations} = await this.describeParts(apiId)
|
||||
await this.client.send(new DeleteApiCommand({ApiId: apiId}))
|
||||
for (const functionName of new Set(integrations.map(integrationFunctionName).filter((name): name is string => Boolean(name)))) {
|
||||
await this.removeInvokePermission(apiId, functionName)
|
||||
}
|
||||
}
|
||||
|
||||
async describe(apiId: string): Promise<HttpApiOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const {routes, integrations, stages, authorizers} = await this.describeParts(apiId)
|
||||
const protocol = protocolOf(api)
|
||||
const integrationsById = new Map(integrations.map((integration) => [integration.IntegrationId, integration]))
|
||||
return {
|
||||
apiId,
|
||||
protocol,
|
||||
apiEndpoint: api.ApiEndpoint ?? null,
|
||||
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression ?? null : null,
|
||||
routes: routes.map((route) => toRouteOverview(route, integrationsById)).sort(compareRoutes),
|
||||
stages: stages.map((stage) => toStageOverview(apiId, protocol, stage)).sort((left, right) => left.stageName.localeCompare(right.stageName)),
|
||||
authorizers: authorizers.map(toAuthorizerOverview).sort((left, right) => left.name.localeCompare(right.name)),
|
||||
cors: protocol === 'HTTP' ? fromCors(api.CorsConfiguration) : null,
|
||||
}
|
||||
}
|
||||
|
||||
async createRoute(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const protocol = protocolOf(api)
|
||||
const routeKey = protocol === 'HTTP' ? httpRouteKey(input.routeKey) : webSocketRouteKey(input.routeKey)
|
||||
const functionName = stringValue(input.functionName)
|
||||
const httpUrl = stringValue(input.httpUrl)
|
||||
if (Boolean(functionName) === Boolean(httpUrl)) throw new ValidationError('Give the route either a Lambda function or an HTTP URL')
|
||||
if (httpUrl && protocol === 'WEBSOCKET') throw new ValidationError('TheCloud connects WebSocket routes to Lambda functions only')
|
||||
if (httpUrl && !/^https?:\/\/[^\s/]+/.test(httpUrl)) throw new ValidationError('httpUrl must be an http:// or https:// URL')
|
||||
|
||||
const {routes, integrations} = await this.describeParts(apiId)
|
||||
if (routes.some((route) => route.RouteKey === routeKey)) throw new ValidationError(`The API already has a ${routeKey} route`)
|
||||
const authorizerId = stringValue(input.authorizerId)
|
||||
if (authorizerId && protocol === 'WEBSOCKET') throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||
|
||||
let integration: Partial<Integration> | undefined
|
||||
if (functionName) {
|
||||
const functionArn = await this.functionArn(functionName)
|
||||
const uri = protocol === 'HTTP' ? functionArn : lambdaInvocationUri(this.region, functionArn)
|
||||
integration = integrations.find((candidate) => candidate.IntegrationType === 'AWS_PROXY' && candidate.IntegrationUri === uri)
|
||||
?? await this.client.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'AWS_PROXY',
|
||||
IntegrationUri: uri,
|
||||
IntegrationMethod: 'POST',
|
||||
PayloadFormatVersion: protocol === 'HTTP' ? '2.0' : undefined,
|
||||
}))
|
||||
await this.allowInvoke(apiId, functionName)
|
||||
} else {
|
||||
const method = routeKey === '$default' ? 'ANY' : routeKey.split(' ')[0]
|
||||
integration = integrations.find((candidate) => candidate.IntegrationType === 'HTTP_PROXY' && candidate.IntegrationUri === httpUrl && candidate.IntegrationMethod === method)
|
||||
?? await this.client.send(new CreateIntegrationCommand({
|
||||
ApiId: apiId,
|
||||
IntegrationType: 'HTTP_PROXY',
|
||||
IntegrationUri: httpUrl,
|
||||
IntegrationMethod: method,
|
||||
PayloadFormatVersion: '1.0',
|
||||
}))
|
||||
}
|
||||
|
||||
const route = await this.client.send(new CreateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteKey: routeKey,
|
||||
Target: `integrations/${integration.IntegrationId}`,
|
||||
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||
AuthorizerId: authorizerId || undefined,
|
||||
RouteResponseSelectionExpression: protocol === 'WEBSOCKET' && input.returnsResponse ? '$default' : undefined,
|
||||
}))
|
||||
// A WebSocket route sends its integration's response back only once it has a route response.
|
||||
if (protocol === 'WEBSOCKET' && input.returnsResponse) {
|
||||
await this.client.send(new CreateRouteResponseCommand({ApiId: apiId, RouteId: route.RouteId, RouteResponseKey: '$default'}))
|
||||
}
|
||||
return toRouteOverview(route, new Map([[integration.IntegrationId, integration]]))
|
||||
}
|
||||
|
||||
async deleteRoute(apiId: string, routeId: string): Promise<void> {
|
||||
const {routes, integrations} = await this.describeParts(apiId)
|
||||
const route = routes.find((candidate) => candidate.RouteId === routeId)
|
||||
if (!route) throw new NotFoundError(`Route ${routeId} not found`)
|
||||
await this.client.send(new DeleteRouteCommand({ApiId: apiId, RouteId: routeId}))
|
||||
|
||||
// Clean up what only this route used: its integration, then the Lambda's permission for this API.
|
||||
const integrationId = targetIntegrationId(route.Target)
|
||||
const stillUsed = routes.some((candidate) => candidate.RouteId !== routeId && targetIntegrationId(candidate.Target) === integrationId)
|
||||
const integration = integrations.find((candidate) => candidate.IntegrationId === integrationId)
|
||||
if (!integration || stillUsed) return
|
||||
await this.client.send(new DeleteIntegrationCommand({ApiId: apiId, IntegrationId: integrationId}))
|
||||
const functionName = integrationFunctionName(integration)
|
||||
const functionStillUsed = integrations.some((candidate) => candidate.IntegrationId !== integrationId && integrationFunctionName(candidate) === functionName)
|
||||
if (functionName && !functionStillUsed) await this.removeInvokePermission(apiId, functionName)
|
||||
}
|
||||
|
||||
async setRouteAuthorizer(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) === 'WEBSOCKET' && authorizerId) throw new ValidationError('WebSocket APIs use Lambda authorizers, which TheCloud does not set up yet')
|
||||
const route = await this.client.send(new UpdateRouteCommand({
|
||||
ApiId: apiId,
|
||||
RouteId: routeId,
|
||||
AuthorizationType: authorizerId ? 'JWT' : 'NONE',
|
||||
AuthorizerId: authorizerId ?? undefined,
|
||||
}))
|
||||
const {integrations} = await this.describeParts(apiId)
|
||||
return toRouteOverview(route, new Map(integrations.map((integration) => [integration.IntegrationId, integration])))
|
||||
}
|
||||
|
||||
async createStage(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const stage = await this.client.send(new CreateStageCommand({ApiId: apiId, StageName: stageNameValue(input.stageName), AutoDeploy: Boolean(input.autoDeploy)}))
|
||||
// A stage without auto-deploy serves nothing until its first deployment.
|
||||
if (!input.autoDeploy) return this.deploy(apiId, stage.StageName!)
|
||||
return toStageOverview(apiId, protocolOf(api), stage)
|
||||
}
|
||||
|
||||
async deleteStage(apiId: string, stageName: string): Promise<void> {
|
||||
await this.client.send(new DeleteStageCommand({ApiId: apiId, StageName: stageNameValue(stageName)}))
|
||||
}
|
||||
|
||||
async deploy(apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
const name = stageNameValue(stageName)
|
||||
await this.client.send(new CreateDeploymentCommand({ApiId: apiId, StageName: name, Description: 'Deployed from TheCloud'}))
|
||||
const stage = await this.client.send(new GetStageCommand({ApiId: apiId, StageName: name}))
|
||||
return toStageOverview(apiId, protocolOf(api), stage)
|
||||
}
|
||||
|
||||
async createAuthorizer(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) === 'WEBSOCKET') throw new ValidationError('JWT authorizers are for HTTP APIs; WebSocket APIs use Lambda authorizers')
|
||||
const name = stringValue(input.name)
|
||||
if (!/^[A-Za-z0-9_-]{1,128}$/.test(name)) throw new ValidationError('name must be 1-128 letters, numbers, hyphens or underscores')
|
||||
|
||||
let issuer = stringValue(input.issuer)
|
||||
let audience = (input.audience ?? []).map(stringValue).filter(Boolean)
|
||||
const userPoolId = stringValue(input.userPoolId)
|
||||
if (userPoolId) {
|
||||
const clientId = stringValue(input.userPoolClientId)
|
||||
if (!clientId) throw new ValidationError('Choose the app client whose tokens the authorizer accepts')
|
||||
issuer = await this.userPoolIssuer(userPoolId)
|
||||
audience = [clientId]
|
||||
}
|
||||
if (!/^https?:\/\/[^\s]+$/.test(issuer)) throw new ValidationError('issuer must be the token issuer URL, for example https://cognito-idp.us-east-1.amazonaws.com/us-east-1_abc')
|
||||
if (audience.length === 0) throw new ValidationError('audience needs at least one value, such as an app client id')
|
||||
|
||||
const authorizer = await this.client.send(new CreateAuthorizerCommand({
|
||||
ApiId: apiId,
|
||||
Name: name,
|
||||
AuthorizerType: 'JWT',
|
||||
IdentitySource: ['$request.header.Authorization'],
|
||||
JwtConfiguration: {Issuer: issuer, Audience: audience},
|
||||
}))
|
||||
return toAuthorizerOverview(authorizer)
|
||||
}
|
||||
|
||||
async deleteAuthorizer(apiId: string, authorizerId: string): Promise<void> {
|
||||
const {routes} = await this.describeParts(apiId)
|
||||
const users = routes.filter((route) => route.AuthorizationType !== 'NONE' && route.AuthorizerId === authorizerId).map((route) => route.RouteKey)
|
||||
if (users.length > 0) throw new ValidationError(`Remove the authorizer from ${users.join(', ')} first`)
|
||||
await this.client.send(new DeleteAuthorizerCommand({ApiId: apiId, AuthorizerId: authorizerId}))
|
||||
}
|
||||
|
||||
async updateCors(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) !== 'HTTP') throw new ValidationError('CORS applies to HTTP APIs only')
|
||||
if (!cors) {
|
||||
await this.client.send(new DeleteCorsConfigurationCommand({ApiId: apiId}))
|
||||
return null
|
||||
}
|
||||
const configuration = toCors(cors)
|
||||
const updated = await this.client.send(new UpdateApiCommand({ApiId: apiId, CorsConfiguration: configuration}))
|
||||
return fromCors(updated.CorsConfiguration)
|
||||
}
|
||||
|
||||
/** Opens a real connection to the deployed stage, sends one message and collects the replies. */
|
||||
async testWebSocket(apiId: string, input: HttpApiWebSocketTestInput, timeoutMs = 4000): Promise<HttpApiWebSocketTestResult> {
|
||||
const api = await this.requireApi(apiId)
|
||||
if (protocolOf(api) !== 'WEBSOCKET') throw new ValidationError('This is not a WebSocket API')
|
||||
const stage = stageNameValue(input.stage)
|
||||
const query = stringValue(input.query).replace(/^\?/, '')
|
||||
const url = `${awsEndpoint().replace(/\/$/, '').replace(/^http/, 'ws')}/ws/${encodeURIComponent(apiId)}/${encodeURIComponent(stage)}${query ? `?${query}` : ''}`
|
||||
return exchangeWebSocketMessage(url, input.message, timeoutMs)
|
||||
}
|
||||
|
||||
private async describeParts(apiId: string) {
|
||||
const [routes, integrations, stages, authorizers] = await Promise.all([
|
||||
this.pages((NextToken) => this.client.send(new GetRoutesCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetIntegrationsCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetStagesCommand({ApiId: apiId, NextToken}))),
|
||||
this.pages((NextToken) => this.client.send(new GetAuthorizersCommand({ApiId: apiId, NextToken}))),
|
||||
])
|
||||
return {routes: routes as Route[], integrations: integrations as Integration[], stages: stages as Stage[], authorizers: authorizers as Authorizer[]}
|
||||
}
|
||||
|
||||
private async pages<T>(fetchPage: (next?: string) => Promise<{Items?: T[]; NextToken?: string}>): Promise<T[]> {
|
||||
const items: T[] = []
|
||||
let next: string | undefined
|
||||
do {
|
||||
const page = await fetchPage(next)
|
||||
items.push(...(page.Items ?? []))
|
||||
next = page.NextToken
|
||||
} while (next)
|
||||
return items
|
||||
}
|
||||
|
||||
private async findApi(apiId: string): Promise<Partial<Api> | null> {
|
||||
try {
|
||||
return await this.client.send(new GetApiCommand({ApiId: apiId}))
|
||||
} catch (error) {
|
||||
if (hasHttpStatus(error, 404)) return null
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async requireApi(apiId: string): Promise<Partial<Api>> {
|
||||
const api = await this.findApi(apiId)
|
||||
if (!api) throw new NotFoundError(`HTTP or WebSocket API ${apiId} not found`)
|
||||
return api
|
||||
}
|
||||
|
||||
private async functionArn(functionName: string): Promise<string> {
|
||||
const result = await this.lambda.send(new GetFunctionCommand({FunctionName: functionName}))
|
||||
const arn = result.Configuration?.FunctionArn
|
||||
if (!arn) throw new ValidationError(`Lambda function ${functionName} did not return an ARN`)
|
||||
return arn
|
||||
}
|
||||
|
||||
/** API Gateway may invoke the function for any route and stage of this API, as the AWS console grants it. */
|
||||
private async allowInvoke(apiId: string, functionName: string) {
|
||||
try {
|
||||
await this.lambda.send(new AddPermissionCommand({
|
||||
FunctionName: functionName,
|
||||
StatementId: invokeStatementId(apiId),
|
||||
Action: 'lambda:InvokeFunction',
|
||||
Principal: 'apigateway.amazonaws.com',
|
||||
SourceArn: `arn:aws:execute-api:${this.region}:${this.accountId}:${apiId}/*`,
|
||||
}))
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 409)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
private async removeInvokePermission(apiId: string, functionName: string) {
|
||||
try {
|
||||
await this.lambda.send(new RemovePermissionCommand({FunctionName: functionName, StatementId: invokeStatementId(apiId)}))
|
||||
} catch (error) {
|
||||
if (!hasHttpStatus(error, 404)) throw error
|
||||
}
|
||||
}
|
||||
|
||||
/** The pool's token issuer, read from its OpenID Connect discovery document as any relying party would. */
|
||||
private async userPoolIssuer(userPoolId: string): Promise<string> {
|
||||
if (!/^[\w-]+_[0-9A-Za-z]+$/.test(userPoolId)) throw new ValidationError('userPoolId is not a Cognito user pool id')
|
||||
const discovery = await this.fetchJson(`${awsEndpoint().replace(/\/$/, '')}/${userPoolId}/.well-known/openid-configuration`)
|
||||
const issuer = (discovery as {issuer?: unknown} | null)?.issuer
|
||||
if (typeof issuer !== 'string' || !issuer) throw new NotFoundError(`User pool ${userPoolId} has no OpenID Connect discovery document`)
|
||||
return issuer
|
||||
}
|
||||
}
|
||||
|
||||
async function defaultFetchJson(url: string): Promise<unknown> {
|
||||
const response = await globalThis.fetch(url)
|
||||
if (!response.ok) throw new NotFoundError(`${url} answered ${response.status}`)
|
||||
return response.json()
|
||||
}
|
||||
|
||||
function exchangeWebSocketMessage(url: string, message: string, timeoutMs: number): Promise<HttpApiWebSocketTestResult> {
|
||||
const startedAt = performance.now()
|
||||
return new Promise((resolve) => {
|
||||
const messages: string[] = []
|
||||
let connected = false
|
||||
let settled = false
|
||||
let socket: WebSocket
|
||||
const finish = (closeCode: number | null, error: string | null) => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
clearTimeout(timer)
|
||||
if (socket && socket.readyState === WebSocket.OPEN) socket.close(1000)
|
||||
resolve({connected, messages, closeCode, error, durationMs: Math.round(performance.now() - startedAt)})
|
||||
}
|
||||
// After the first reply, wait briefly for any more before hanging up.
|
||||
let quiet: ReturnType<typeof setTimeout> | undefined
|
||||
const timer = setTimeout(() => finish(null, connected ? null : 'Timed out connecting'), timeoutMs)
|
||||
try {
|
||||
socket = new WebSocket(url)
|
||||
} catch (error) {
|
||||
finish(null, error instanceof Error ? error.message : 'Could not connect')
|
||||
return
|
||||
}
|
||||
socket.onopen = () => {
|
||||
connected = true
|
||||
if (message) socket.send(message)
|
||||
else finish(null, null)
|
||||
}
|
||||
socket.onmessage = (event) => {
|
||||
messages.push(typeof event.data === 'string' ? event.data : String(event.data))
|
||||
clearTimeout(quiet)
|
||||
quiet = setTimeout(() => finish(null, null), 500)
|
||||
}
|
||||
socket.onerror = () => finish(null, connected ? 'The connection failed' : 'The stage refused the connection')
|
||||
socket.onclose = (event) => finish(event.code, connected ? null : `The stage refused the connection (${event.code})`)
|
||||
})
|
||||
}
|
||||
|
||||
function toResource(api: Partial<Api>): CloudResource {
|
||||
const protocol = protocolOf(api)
|
||||
return {
|
||||
id: api.ApiId ?? '',
|
||||
name: api.Name ?? '',
|
||||
cloud: 'aws',
|
||||
service: 'apigateway',
|
||||
type: protocol === 'HTTP' ? 'http-api' : 'websocket-api',
|
||||
region: null,
|
||||
createdAt: api.CreatedDate?.toISOString() ?? null,
|
||||
metadata: {
|
||||
provider: 'aws',
|
||||
apigatewayService: protocol === 'HTTP' ? 'http' : 'websocket',
|
||||
protocol,
|
||||
description: api.Description,
|
||||
apiEndpoint: api.ApiEndpoint,
|
||||
routeSelectionExpression: protocol === 'WEBSOCKET' ? api.RouteSelectionExpression : undefined,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function protocolOf(api: Partial<Api>): HttpApiProtocol {
|
||||
return api.ProtocolType === 'WEBSOCKET' ? 'WEBSOCKET' : 'HTTP'
|
||||
}
|
||||
|
||||
function toRouteOverview(route: Partial<Route>, integrations: Map<string | undefined, Partial<Integration>>): HttpApiRouteOverview {
|
||||
const integration = integrations.get(targetIntegrationId(route.Target))
|
||||
return {
|
||||
routeId: route.RouteId ?? '',
|
||||
routeKey: route.RouteKey ?? '',
|
||||
integration: integration ? toIntegrationOverview(integration) : null,
|
||||
authorizationType: route.AuthorizationType ?? 'NONE',
|
||||
// Real API Gateway keeps a route's last AuthorizerId after its authorization goes back to NONE.
|
||||
authorizerId: route.AuthorizationType && route.AuthorizationType !== 'NONE' ? route.AuthorizerId || null : null,
|
||||
authorizationScopes: route.AuthorizationScopes ?? [],
|
||||
returnsResponse: Boolean(route.RouteResponseSelectionExpression),
|
||||
}
|
||||
}
|
||||
|
||||
function toIntegrationOverview(integration: Partial<Integration>): HttpApiIntegrationOverview {
|
||||
return {
|
||||
integrationId: integration.IntegrationId ?? '',
|
||||
type: integration.IntegrationType ?? '',
|
||||
uri: integration.IntegrationUri ?? null,
|
||||
functionName: integrationFunctionName(integration),
|
||||
payloadFormatVersion: integration.PayloadFormatVersion ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
function toStageOverview(apiId: string, protocol: HttpApiProtocol, stage: Partial<Stage>): HttpApiStageOverview {
|
||||
const stageName = stage.StageName ?? ''
|
||||
return {
|
||||
stageName,
|
||||
autoDeploy: Boolean(stage.AutoDeploy),
|
||||
deploymentId: stage.DeploymentId ?? null,
|
||||
lastUpdatedAt: stage.LastUpdatedDate?.toISOString() ?? null,
|
||||
invokePath: `${protocol === 'WEBSOCKET' ? '/ws' : '/execute-api'}/${apiId}/${stageName}`,
|
||||
}
|
||||
}
|
||||
|
||||
function toAuthorizerOverview(authorizer: Partial<Authorizer>): HttpApiAuthorizerOverview {
|
||||
return {
|
||||
authorizerId: authorizer.AuthorizerId ?? '',
|
||||
name: authorizer.Name ?? '',
|
||||
type: authorizer.AuthorizerType ?? '',
|
||||
identitySource: authorizer.IdentitySource ?? [],
|
||||
issuer: authorizer.JwtConfiguration?.Issuer ?? null,
|
||||
audience: authorizer.JwtConfiguration?.Audience ?? [],
|
||||
}
|
||||
}
|
||||
|
||||
function fromCors(cors: Cors | undefined): HttpApiCors | null {
|
||||
if (!cors) return null
|
||||
return {
|
||||
allowOrigins: cors.AllowOrigins ?? [],
|
||||
allowMethods: cors.AllowMethods ?? [],
|
||||
allowHeaders: cors.AllowHeaders ?? [],
|
||||
exposeHeaders: cors.ExposeHeaders ?? [],
|
||||
maxAgeSeconds: cors.MaxAge ?? null,
|
||||
allowCredentials: Boolean(cors.AllowCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
function toCors(cors: HttpApiCors): Cors {
|
||||
const clean = (values: string[]) => [...new Set((values ?? []).map(stringValue).filter(Boolean))]
|
||||
const allowOrigins = clean(cors.allowOrigins)
|
||||
const allowMethods = clean(cors.allowMethods).map((method) => method.toUpperCase())
|
||||
if (allowOrigins.length === 0) throw new ValidationError('allowOrigins needs at least one origin, or *')
|
||||
if (allowOrigins.some((origin) => origin !== '*' && !/^https?:\/\/[^\s/]+$/.test(origin))) {
|
||||
throw new ValidationError('Each allowed origin must be * or a scheme and host, such as https://app.example.com')
|
||||
}
|
||||
if (allowMethods.some((method) => !CORS_ALLOWED_METHODS.includes(method))) throw new ValidationError(`allowMethods may contain ${CORS_ALLOWED_METHODS.join(', ')}`)
|
||||
if (cors.allowCredentials && allowOrigins.includes('*')) throw new ValidationError('Credentials cannot be allowed for every origin (*); list the origins instead')
|
||||
const maxAge = cors.maxAgeSeconds
|
||||
if (maxAge !== null && maxAge !== undefined && (!Number.isInteger(maxAge) || maxAge < 0 || maxAge > 86_400)) {
|
||||
throw new ValidationError('maxAgeSeconds must be a whole number from 0 to 86400')
|
||||
}
|
||||
return {
|
||||
AllowOrigins: allowOrigins,
|
||||
AllowMethods: allowMethods,
|
||||
AllowHeaders: clean(cors.allowHeaders),
|
||||
ExposeHeaders: clean(cors.exposeHeaders),
|
||||
MaxAge: maxAge ?? undefined,
|
||||
AllowCredentials: Boolean(cors.allowCredentials),
|
||||
}
|
||||
}
|
||||
|
||||
/** `$default`, or a method and a path such as `GET /orders/{id}` or `ANY /files/{proxy+}`. */
|
||||
export function httpRouteKey(value: string): string {
|
||||
const key = stringValue(value).replace(/\s+/g, ' ')
|
||||
if (key === '$default') return key
|
||||
const [method, path, ...rest] = key.split(' ')
|
||||
if (rest.length > 0 || !HTTP_ROUTE_METHODS.includes(method?.toUpperCase() ?? '') || !path) {
|
||||
throw new ValidationError('routeKey must be $default or a method and a path, for example GET /orders/{id}')
|
||||
}
|
||||
const segments = path === '/' ? [] : path.split('/').slice(1)
|
||||
if (!path.startsWith('/') || segments.some((segment, index) => !/^(?:[A-Za-z0-9._~-]+|\{[A-Za-z0-9_]+\}|\{[A-Za-z0-9_]+\+\})$/.test(segment)
|
||||
|| (segment.endsWith('+}') && index !== segments.length - 1))) {
|
||||
throw new ValidationError('The path must start with / and use segments such as orders, {id}, or a final {proxy+}')
|
||||
}
|
||||
return `${method.toUpperCase()} ${path}`
|
||||
}
|
||||
|
||||
/** `$connect`, `$disconnect`, `$default`, or a value of the route selection expression such as `sendMessage`. */
|
||||
export function webSocketRouteKey(value: string): string {
|
||||
const key = stringValue(value)
|
||||
if (WEBSOCKET_SYSTEM_ROUTES.includes(key)) return key
|
||||
if (!/^[A-Za-z0-9_.:-]{1,128}$/.test(key)) {
|
||||
throw new ValidationError('routeKey must be $connect, $disconnect, $default, or a message action such as sendMessage')
|
||||
}
|
||||
return key
|
||||
}
|
||||
|
||||
function stageNameValue(value: string): string {
|
||||
const name = stringValue(value)
|
||||
if (!/^(?:\$default|[A-Za-z0-9_-]{1,128})$/.test(name)) {
|
||||
throw new ValidationError('stage must be $default or letters, numbers, hyphens and underscores')
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
function compareRoutes(left: HttpApiRouteOverview, right: HttpApiRouteOverview): number {
|
||||
const path = (route: HttpApiRouteOverview) => route.routeKey.split(' ')[1] ?? route.routeKey
|
||||
return path(left).localeCompare(path(right)) || left.routeKey.localeCompare(right.routeKey)
|
||||
}
|
||||
|
||||
function lambdaInvocationUri(region: string, functionArn: string): string {
|
||||
return `arn:aws:apigateway:${region}:lambda:path/2015-03-31/functions/${functionArn}/invocations`
|
||||
}
|
||||
|
||||
function integrationFunctionName(integration: Partial<Integration>): string | null {
|
||||
if (integration.IntegrationType !== 'AWS_PROXY') return null
|
||||
return integration.IntegrationUri?.match(/arn:aws:lambda:[^:]+:\d+:function:([^/:]+)/)?.[1] ?? null
|
||||
}
|
||||
|
||||
function targetIntegrationId(target: string | undefined): string | undefined {
|
||||
return target?.startsWith('integrations/') ? target.slice('integrations/'.length) : undefined
|
||||
}
|
||||
|
||||
function invokeStatementId(apiId: string): string {
|
||||
return `apigatewayv2-${apiId}`
|
||||
}
|
||||
|
||||
function stringValue(value: unknown): string {
|
||||
return typeof value === 'string' ? value.trim() : ''
|
||||
}
|
||||
|
||||
function hasHttpStatus(error: unknown, status: number): boolean {
|
||||
if (typeof error !== 'object' || error === null) return false
|
||||
return (error as {$metadata?: {httpStatusCode?: number}}).$metadata?.httpStatusCode === status
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { SQSClient } from "@aws-sdk/client-sqs";
|
||||
import { IAMClient } from "@aws-sdk/client-iam";
|
||||
import { DynamoDBClient } from "@aws-sdk/client-dynamodb";
|
||||
import { APIGatewayClient } from "@aws-sdk/client-api-gateway";
|
||||
import { ApiGatewayV2Client } from "@aws-sdk/client-apigatewayv2";
|
||||
import { CloudFormationClient } from "@aws-sdk/client-cloudformation";
|
||||
import { AppConfigClient } from "@aws-sdk/client-appconfig";
|
||||
import { SNSClient } from "@aws-sdk/client-sns";
|
||||
@@ -80,7 +79,6 @@ export type AwsClients = {
|
||||
iam: IAMClient;
|
||||
dynamodb: DynamoDBClient;
|
||||
apiGateway: APIGatewayClient;
|
||||
apiGatewayV2: ApiGatewayV2Client;
|
||||
cloudformation: CloudFormationClient;
|
||||
appConfig: AppConfigClient;
|
||||
sns: SNSClient;
|
||||
@@ -129,7 +127,6 @@ function buildClients(accountId: string): AwsClients {
|
||||
iam: new IAMClient(base),
|
||||
dynamodb: new DynamoDBClient(base),
|
||||
apiGateway: new APIGatewayClient(base),
|
||||
apiGatewayV2: new ApiGatewayV2Client(base),
|
||||
cloudformation: new CloudFormationClient(base),
|
||||
appConfig: new AppConfigClient(base),
|
||||
sns: new SNSClient(base),
|
||||
@@ -184,7 +181,6 @@ export const sqs = awsClients.sqs;
|
||||
export const iam = awsClients.iam;
|
||||
export const dynamodb = awsClients.dynamodb;
|
||||
export const apiGateway = awsClients.apiGateway;
|
||||
export const apiGatewayV2 = awsClients.apiGatewayV2;
|
||||
export const cloudformation = awsClients.cloudformation;
|
||||
export const ecr = awsClients.ecr;
|
||||
export const kinesis = awsClients.kinesis;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type {CapabilitySchema, HttpApiActionName, ResourceActionName, ServiceSchema} from './types'
|
||||
import type {CapabilitySchema, ResourceActionName, ServiceSchema} from './types'
|
||||
|
||||
const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
||||
{name: 'list', label: 'List APIs', enabled: true, status: 'available', runtimeRequired: true},
|
||||
@@ -7,42 +7,12 @@ const resourceActions: CapabilitySchema<ResourceActionName>[] = [
|
||||
{name: 'inspect', label: 'Inspect API', enabled: true, status: 'available', runtimeRequired: false},
|
||||
]
|
||||
|
||||
const httpApiActions: CapabilitySchema<HttpApiActionName>[] = [
|
||||
{name: 'describe', label: 'Show routes, stages and authorizers', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createRoute', label: 'Add a route', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteRoute', label: 'Delete a route', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'setRouteAuthorizer', label: 'Protect a route with an authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createStage', label: 'Add a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteStage', label: 'Delete a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deploy', label: 'Deploy to a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'createAuthorizer', label: 'Add a JWT authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'deleteAuthorizer', label: 'Delete an authorizer', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'updateCors', label: 'Configure CORS', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'sendRequest', label: 'Send a request to a stage', enabled: true, status: 'available', runtimeRequired: true},
|
||||
{name: 'testWebSocket', label: 'Connect and send a WebSocket message', enabled: true, status: 'available', runtimeRequired: true},
|
||||
]
|
||||
|
||||
const httpOnly = {field: 'protocol', values: ['HTTP']}
|
||||
const webSocketOnly = {field: 'protocol', values: ['WEBSOCKET']}
|
||||
|
||||
export function awsApiGatewaySchema(): ServiceSchema {
|
||||
return {
|
||||
cloud: 'aws',
|
||||
service: 'apigateway',
|
||||
displayName: 'AWS API Gateway',
|
||||
fields: [
|
||||
{
|
||||
name: 'protocol',
|
||||
label: 'API type',
|
||||
type: 'select',
|
||||
required: true,
|
||||
defaultValue: 'REST',
|
||||
options: [
|
||||
{label: 'REST API (resources, methods and stages)', value: 'REST'},
|
||||
{label: 'HTTP API (routes to Lambda or HTTP backends, JWT authorizers)', value: 'HTTP'},
|
||||
{label: 'WebSocket API (two-way messages routed to Lambda)', value: 'WEBSOCKET'},
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: 'API Name',
|
||||
@@ -60,47 +30,20 @@ export function awsApiGatewaySchema(): ServiceSchema {
|
||||
label: 'Description',
|
||||
type: 'text',
|
||||
required: false,
|
||||
description: 'Optional description for the API.',
|
||||
description: 'Optional description for the REST API.',
|
||||
validation: {
|
||||
maxLength: 1024,
|
||||
message: 'Description must be 1024 characters or fewer.',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'functionName',
|
||||
label: 'Lambda function',
|
||||
type: 'text',
|
||||
required: false,
|
||||
visibleWhen: httpOnly,
|
||||
description: 'Optional. Sends every request to this function through a catch-all route, on a $default stage that deploys automatically. Leave empty to add routes yourself.',
|
||||
},
|
||||
{
|
||||
name: 'routeSelectionExpression',
|
||||
label: 'Route selection expression',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: '$request.body.action',
|
||||
visibleWhen: webSocketOnly,
|
||||
description: 'Which field of each message picks its route. With $request.body.action, {"action": "sendMessage"} goes to the sendMessage route.',
|
||||
},
|
||||
{
|
||||
name: 'stageName',
|
||||
label: 'Stage',
|
||||
type: 'text',
|
||||
required: false,
|
||||
defaultValue: 'dev',
|
||||
visibleWhen: webSocketOnly,
|
||||
description: 'Created with automatic deployment, so route changes go live right away.',
|
||||
validation: {pattern: '^[A-Za-z0-9_-]{1,128}$', message: 'Use letters, numbers, hyphens and underscores'},
|
||||
},
|
||||
],
|
||||
actions: ['list', 'create', 'delete', 'inspect'],
|
||||
capabilities: {resourceActions, httpApiActions},
|
||||
capabilities: {resourceActions},
|
||||
filters: [{name: 'search', label: 'Search', type: 'text', required: false}],
|
||||
columns: [
|
||||
{name: 'name', label: 'API Name'},
|
||||
{name: 'protocol', label: 'Type', path: 'metadata.protocol', format: 'badge'},
|
||||
{name: 'createdAt', label: 'Created At', format: 'relative'},
|
||||
{name: 'type', label: 'Type'},
|
||||
{name: 'createdAt', label: 'Created At'},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -182,19 +182,6 @@ export type UserPoolActionName =
|
||||
| 'updateClientOAuth'
|
||||
export type DistributionActionName = 'setEnabled' | 'createInvalidation'
|
||||
export type MonitoringActionName = 'listMetrics' | 'getMetricData' | 'alarmHistory' | 'setAlarmState' | 'setAlarmActions'
|
||||
export type HttpApiActionName =
|
||||
| 'describe'
|
||||
| 'createRoute'
|
||||
| 'deleteRoute'
|
||||
| 'setRouteAuthorizer'
|
||||
| 'createStage'
|
||||
| 'deleteStage'
|
||||
| 'deploy'
|
||||
| 'createAuthorizer'
|
||||
| 'deleteAuthorizer'
|
||||
| 'updateCors'
|
||||
| 'sendRequest'
|
||||
| 'testWebSocket'
|
||||
export type CapabilityStatus = 'available' | 'blocked' | 'partial' | 'coming_soon'
|
||||
|
||||
export interface CapabilitySchema<TAction extends string> {
|
||||
@@ -241,8 +228,6 @@ export interface ServiceSchema {
|
||||
userPoolActions?: CapabilitySchema<UserPoolActionName>[]
|
||||
distributionActions?: CapabilitySchema<DistributionActionName>[]
|
||||
monitoringActions?: CapabilitySchema<MonitoringActionName>[]
|
||||
/** API Gateway v2 (HTTP and WebSocket APIs): what the routes, stages and security panel can do. */
|
||||
httpApiActions?: CapabilitySchema<HttpApiActionName>[]
|
||||
// Child levels advertise separately from the resource level because a
|
||||
// store can be readable at the leaf and writable at the collection —
|
||||
// CloudWatch Logs creates streams but cannot delete an event.
|
||||
@@ -784,107 +769,6 @@ export interface ApiGatewayCorsResult {
|
||||
proxyMethods: string[]
|
||||
}
|
||||
|
||||
export type HttpApiProtocol = 'HTTP' | 'WEBSOCKET'
|
||||
|
||||
export interface HttpApiIntegrationOverview {
|
||||
integrationId: string
|
||||
type: string
|
||||
uri: string | null
|
||||
/** The Lambda function a proxy integration invokes, when it is one. */
|
||||
functionName: string | null
|
||||
payloadFormatVersion: string | null
|
||||
}
|
||||
|
||||
export interface HttpApiRouteOverview {
|
||||
routeId: string
|
||||
/** `GET /orders/{id}`, `$default`, or a WebSocket key such as `$connect` or `sendMessage`. */
|
||||
routeKey: string
|
||||
integration: HttpApiIntegrationOverview | null
|
||||
authorizationType: string
|
||||
authorizerId: string | null
|
||||
authorizationScopes: string[]
|
||||
/** WebSocket only: whether the integration's response is sent back to the client. */
|
||||
returnsResponse: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiStageOverview {
|
||||
stageName: string
|
||||
autoDeploy: boolean
|
||||
deploymentId: string | null
|
||||
lastUpdatedAt: string | null
|
||||
/** Where the stage is served on the runtime endpoint: /execute-api/{api}/{stage} or, for WebSocket, /ws/{api}/{stage}. */
|
||||
invokePath: string
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerOverview {
|
||||
authorizerId: string
|
||||
name: string
|
||||
type: string
|
||||
identitySource: string[]
|
||||
issuer: string | null
|
||||
audience: string[]
|
||||
}
|
||||
|
||||
export interface HttpApiCors {
|
||||
allowOrigins: string[]
|
||||
allowMethods: string[]
|
||||
allowHeaders: string[]
|
||||
exposeHeaders: string[]
|
||||
maxAgeSeconds: number | null
|
||||
allowCredentials: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiOverview {
|
||||
apiId: string
|
||||
protocol: HttpApiProtocol
|
||||
apiEndpoint: string | null
|
||||
routeSelectionExpression: string | null
|
||||
routes: HttpApiRouteOverview[]
|
||||
stages: HttpApiStageOverview[]
|
||||
authorizers: HttpApiAuthorizerOverview[]
|
||||
cors: HttpApiCors | null
|
||||
}
|
||||
|
||||
export interface HttpApiRouteInput {
|
||||
routeKey: string
|
||||
/** A Lambda function name; exactly one of functionName and httpUrl. */
|
||||
functionName?: string
|
||||
/** An HTTP backend the route proxies to; HTTP APIs only. */
|
||||
httpUrl?: string
|
||||
authorizerId?: string
|
||||
/** WebSocket only: send the integration's response back to the client. */
|
||||
returnsResponse?: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiStageInput {
|
||||
stageName: string
|
||||
autoDeploy: boolean
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerInput {
|
||||
name: string
|
||||
/** Fill the issuer and audience from a Cognito user pool and one of its app clients. */
|
||||
userPoolId?: string
|
||||
userPoolClientId?: string
|
||||
issuer?: string
|
||||
audience?: string[]
|
||||
}
|
||||
|
||||
export interface HttpApiWebSocketTestInput {
|
||||
stage: string
|
||||
message: string
|
||||
/** Sent as query string parameters on the connection request, for a $connect authorizer. */
|
||||
query?: string
|
||||
}
|
||||
|
||||
export interface HttpApiWebSocketTestResult {
|
||||
connected: boolean
|
||||
messages: string[]
|
||||
closeCode: number | null
|
||||
error: string | null
|
||||
durationMs: number
|
||||
}
|
||||
|
||||
export interface S3LambdaTriggerInput {
|
||||
functionName: string
|
||||
events: string[]
|
||||
@@ -1079,17 +963,6 @@ export interface CloudServiceAdapter {
|
||||
listApiGatewayRoutes?(apiId: string): Promise<ApiGatewayRoutesResult>
|
||||
deleteApiGatewayRoute?(apiId: string, input: ApiGatewayDeleteRouteInput): Promise<void>
|
||||
enableApiGatewayCors?(apiId: string, input: ApiGatewayCorsInput): Promise<ApiGatewayCorsResult>
|
||||
describeHttpApi?(apiId: string): Promise<HttpApiOverview>
|
||||
createHttpApiRoute?(apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview>
|
||||
deleteHttpApiRoute?(apiId: string, routeId: string): Promise<void>
|
||||
setHttpApiRouteAuthorizer?(apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview>
|
||||
createHttpApiStage?(apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview>
|
||||
deleteHttpApiStage?(apiId: string, stageName: string): Promise<void>
|
||||
deployHttpApi?(apiId: string, stageName: string): Promise<HttpApiStageOverview>
|
||||
createHttpApiAuthorizer?(apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview>
|
||||
deleteHttpApiAuthorizer?(apiId: string, authorizerId: string): Promise<void>
|
||||
updateHttpApiCors?(apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null>
|
||||
testWebSocketApi?(apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult>
|
||||
createS3LambdaTrigger?(bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview>
|
||||
listS3LambdaTriggers?(bucketName: string): Promise<S3LambdaTriggerOverview[]>
|
||||
deleteS3LambdaTrigger?(bucketName: string, triggerId: string): Promise<void>
|
||||
|
||||
@@ -2,7 +2,7 @@ import {describe, expect, test} from 'bun:test'
|
||||
import {createCloudAdapterRegistry} from './cloudProxy'
|
||||
import {checkChildCapabilities} from './cloud-spi/childCapabilities'
|
||||
import {isServiceType} from './cloud-spi/serviceCatalog'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, HttpApiActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
import type {CloudProvider, CloudServiceAdapter, CloudServiceType, DatabaseActionName, DistributionActionName, KubernetesActionName, MonitoringActionName, ResourceActionName, UserPoolActionName} from './cloud-spi/types'
|
||||
|
||||
/**
|
||||
* Guards the schema-to-implementation contract for every registered adapter.
|
||||
@@ -55,21 +55,6 @@ const METHOD_FOR_MONITORING_ACTION: Record<MonitoringActionName, keyof CloudServ
|
||||
setAlarmActions: 'setAlarmActionsEnabled',
|
||||
}
|
||||
|
||||
const METHOD_FOR_HTTP_API_ACTION: Record<HttpApiActionName, keyof CloudServiceAdapter> = {
|
||||
describe: 'describeHttpApi',
|
||||
createRoute: 'createHttpApiRoute',
|
||||
deleteRoute: 'deleteHttpApiRoute',
|
||||
setRouteAuthorizer: 'setHttpApiRouteAuthorizer',
|
||||
createStage: 'createHttpApiStage',
|
||||
deleteStage: 'deleteHttpApiStage',
|
||||
deploy: 'deployHttpApi',
|
||||
createAuthorizer: 'createHttpApiAuthorizer',
|
||||
deleteAuthorizer: 'deleteHttpApiAuthorizer',
|
||||
updateCors: 'updateHttpApiCors',
|
||||
sendRequest: 'testApiGatewayRoute',
|
||||
testWebSocket: 'testWebSocketApi',
|
||||
}
|
||||
|
||||
const METHOD_FOR_DISTRIBUTION_ACTION: Record<DistributionActionName, keyof CloudServiceAdapter> = {
|
||||
setEnabled: 'setDistributionEnabled',
|
||||
createInvalidation: 'createDistributionInvalidation',
|
||||
@@ -179,15 +164,6 @@ describe('registered adapters honour their schema', () => {
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const httpApiClaims = (adapter.schema().capabilities?.httpApiActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of httpApiClaims) {
|
||||
const method = METHOD_FOR_HTTP_API_ACTION[capability.name]
|
||||
expect(
|
||||
typeof adapter[method],
|
||||
`${label} advertises ${capability.name} as available but has no ${String(method)}()`,
|
||||
).toBe('function')
|
||||
}
|
||||
|
||||
const distributionClaims = (adapter.schema().capabilities?.distributionActions ?? []).filter((capability) => capability.status === 'available')
|
||||
for (const capability of distributionClaims) {
|
||||
const method = METHOD_FOR_DISTRIBUTION_ACTION[capability.name]
|
||||
@@ -211,7 +187,6 @@ describe('registered adapters honour their schema', () => {
|
||||
...(adapter.schema().capabilities?.userPoolActions ?? []),
|
||||
...(adapter.schema().capabilities?.distributionActions ?? []),
|
||||
...(adapter.schema().capabilities?.monitoringActions ?? []),
|
||||
...(adapter.schema().capabilities?.httpApiActions ?? []),
|
||||
...(adapter.schema().capabilities?.collectionActions ?? []),
|
||||
...(adapter.schema().capabilities?.itemActions ?? []),
|
||||
]
|
||||
|
||||
@@ -85,7 +85,7 @@ export function createCloudAdapterRegistry(accountId?: string | null): CloudAdap
|
||||
new AwsTargetGroupAdapter(clients.elbv2, clients.ec2),
|
||||
new AwsEventBridgeAdapter(clients.eventbridge, clients.lambda),
|
||||
new AwsIamAdapter(clients.iam),
|
||||
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId), undefined, clients.apiGatewayV2),
|
||||
new AwsApiGatewayAdapter(clients.apiGateway, clients.lambda, resolveAccountId(accountId)),
|
||||
new AwsCloudFormationAdapter(clients.cloudformation),
|
||||
new AwsSecretsAdapter(clients.secretsManager),
|
||||
new AwsSesAdapter(),
|
||||
|
||||
@@ -803,131 +803,6 @@ export function createCloudRoutes(injectedService?: CloudProxyService) {
|
||||
})
|
||||
})
|
||||
|
||||
// HTTP and WebSocket APIs (API Gateway v2) live on the same page as REST APIs.
|
||||
app.get('/:cloud/services/apigateway/resources/:id/http-api', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).describeHttpApi(cloud, c.req.param('id'))))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/routes', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const route = await svc(c).createHttpApiRoute(cloud, c.req.param('id'), {
|
||||
routeKey: typeof input.routeKey === 'string' ? input.routeKey : '',
|
||||
functionName: typeof input.functionName === 'string' ? input.functionName : undefined,
|
||||
httpUrl: typeof input.httpUrl === 'string' ? input.httpUrl : undefined,
|
||||
authorizerId: typeof input.authorizerId === 'string' ? input.authorizerId : undefined,
|
||||
returnsResponse: input.returnsResponse === true,
|
||||
})
|
||||
return c.json(route, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteHttpApiRoute(cloud, c.req.param('id'), c.req.param('routeId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<{authorizerId?: unknown}>()
|
||||
const authorizerId = typeof input.authorizerId === 'string' && input.authorizerId ? input.authorizerId : null
|
||||
return c.json(await svc(c).setHttpApiRouteAuthorizer(cloud, c.req.param('id'), c.req.param('routeId'), authorizerId))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const stage = await svc(c).createHttpApiStage(cloud, c.req.param('id'), {
|
||||
stageName: typeof input.stageName === 'string' ? input.stageName : '',
|
||||
autoDeploy: input.autoDeploy === true,
|
||||
})
|
||||
return c.json(stage, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteHttpApiStage(cloud, c.req.param('id'), c.req.param('stageName'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => c.json(await svc(c).deployHttpApi(cloud, c.req.param('id'), c.req.param('stageName')), 201))
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/authorizers', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
const authorizer = await svc(c).createHttpApiAuthorizer(cloud, c.req.param('id'), {
|
||||
name: typeof input.name === 'string' ? input.name : '',
|
||||
userPoolId: typeof input.userPoolId === 'string' ? input.userPoolId : undefined,
|
||||
userPoolClientId: typeof input.userPoolClientId === 'string' ? input.userPoolClientId : undefined,
|
||||
issuer: typeof input.issuer === 'string' ? input.issuer : undefined,
|
||||
audience: Array.isArray(input.audience) ? input.audience.filter((value): value is string => typeof value === 'string') : undefined,
|
||||
})
|
||||
return c.json(authorizer, 201)
|
||||
})
|
||||
})
|
||||
|
||||
app.delete('/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
await svc(c).deleteHttpApiAuthorizer(cloud, c.req.param('id'), c.req.param('authorizerId'))
|
||||
return c.json({ok: true})
|
||||
})
|
||||
})
|
||||
|
||||
app.put('/:cloud/services/apigateway/resources/:id/http-api/cors', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<{cors?: Record<string, unknown> | null}>()
|
||||
const cors = input.cors ? {
|
||||
allowOrigins: stringList(input.cors.allowOrigins),
|
||||
allowMethods: stringList(input.cors.allowMethods),
|
||||
allowHeaders: stringList(input.cors.allowHeaders),
|
||||
exposeHeaders: stringList(input.cors.exposeHeaders),
|
||||
maxAgeSeconds: typeof input.cors.maxAgeSeconds === 'number' ? input.cors.maxAgeSeconds : null,
|
||||
allowCredentials: input.cors.allowCredentials === true,
|
||||
} : null
|
||||
return c.json({cors: await svc(c).updateHttpApiCors(cloud, c.req.param('id'), cors)})
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/http-api/websocket-test', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
return withRuntime(c, async () => {
|
||||
const input = await c.req.json<Record<string, unknown>>()
|
||||
return c.json(await svc(c).testWebSocketApi(cloud, c.req.param('id'), {
|
||||
stage: typeof input.stage === 'string' ? input.stage : '',
|
||||
message: typeof input.message === 'string' ? input.message : '',
|
||||
query: typeof input.query === 'string' ? input.query : undefined,
|
||||
}))
|
||||
})
|
||||
})
|
||||
|
||||
app.post('/:cloud/services/apigateway/resources/:id/routes/test', async (c) => {
|
||||
const cloud = c.req.param('cloud') as CloudProvider
|
||||
if (cloud !== 'aws') return c.json({error: 'Unknown cloud or service'}, 404)
|
||||
@@ -1787,10 +1662,6 @@ function isStringRecord(value: unknown): value is Record<string, string> {
|
||||
&& Object.values(value).every((item) => typeof item === 'string')
|
||||
}
|
||||
|
||||
function stringList(value: unknown): string[] {
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : []
|
||||
}
|
||||
|
||||
function stringHeaders(value: unknown): Record<string, string> {
|
||||
if (value === undefined) return {}
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
||||
|
||||
@@ -30,16 +30,6 @@ import type {
|
||||
ApiGatewayDeleteRouteInput,
|
||||
ApiGatewayCorsInput,
|
||||
ApiGatewayCorsResult,
|
||||
HttpApiAuthorizerInput,
|
||||
HttpApiAuthorizerOverview,
|
||||
HttpApiCors,
|
||||
HttpApiOverview,
|
||||
HttpApiRouteInput,
|
||||
HttpApiRouteOverview,
|
||||
HttpApiStageInput,
|
||||
HttpApiStageOverview,
|
||||
HttpApiWebSocketTestInput,
|
||||
HttpApiWebSocketTestResult,
|
||||
S3LambdaTriggerInput,
|
||||
S3LambdaTriggerOverview,
|
||||
S3BucketWebsiteConfiguration,
|
||||
@@ -340,56 +330,6 @@ export class CloudProxyService {
|
||||
return adapter.enableApiGatewayCors(apiId, input)
|
||||
}
|
||||
|
||||
async describeHttpApi(cloud: CloudProvider, apiId: string): Promise<HttpApiOverview> {
|
||||
return this.httpApiAdapter(cloud, 'describeHttpApi').describeHttpApi!(apiId)
|
||||
}
|
||||
|
||||
async createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApiAdapter(cloud, 'createHttpApiRoute').createHttpApiRoute!(apiId, input)
|
||||
}
|
||||
|
||||
async deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
|
||||
await this.httpApiAdapter(cloud, 'deleteHttpApiRoute').deleteHttpApiRoute!(apiId, routeId)
|
||||
}
|
||||
|
||||
async setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
return this.httpApiAdapter(cloud, 'setHttpApiRouteAuthorizer').setHttpApiRouteAuthorizer!(apiId, routeId, authorizerId)
|
||||
}
|
||||
|
||||
async createHttpApiStage(cloud: CloudProvider, apiId: string, input: HttpApiStageInput): Promise<HttpApiStageOverview> {
|
||||
return this.httpApiAdapter(cloud, 'createHttpApiStage').createHttpApiStage!(apiId, input)
|
||||
}
|
||||
|
||||
async deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
|
||||
await this.httpApiAdapter(cloud, 'deleteHttpApiStage').deleteHttpApiStage!(apiId, stageName)
|
||||
}
|
||||
|
||||
async deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
return this.httpApiAdapter(cloud, 'deployHttpApi').deployHttpApi!(apiId, stageName)
|
||||
}
|
||||
|
||||
async createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
return this.httpApiAdapter(cloud, 'createHttpApiAuthorizer').createHttpApiAuthorizer!(apiId, input)
|
||||
}
|
||||
|
||||
async deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
|
||||
await this.httpApiAdapter(cloud, 'deleteHttpApiAuthorizer').deleteHttpApiAuthorizer!(apiId, authorizerId)
|
||||
}
|
||||
|
||||
async updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
return this.httpApiAdapter(cloud, 'updateHttpApiCors').updateHttpApiCors!(apiId, cors)
|
||||
}
|
||||
|
||||
async testWebSocketApi(cloud: CloudProvider, apiId: string, input: HttpApiWebSocketTestInput): Promise<HttpApiWebSocketTestResult> {
|
||||
return this.httpApiAdapter(cloud, 'testWebSocketApi').testWebSocketApi!(apiId, input)
|
||||
}
|
||||
|
||||
private httpApiAdapter(cloud: CloudProvider, method: keyof CloudServiceAdapter): CloudServiceAdapter {
|
||||
const adapter = this.requireAdapter(cloud, 'apigateway')
|
||||
if (typeof adapter[method] !== 'function') throw new NotSupportedError(`HTTP and WebSocket APIs are not supported for ${cloud}`)
|
||||
return adapter
|
||||
}
|
||||
|
||||
async createS3LambdaTrigger(cloud: CloudProvider, bucketName: string, input: S3LambdaTriggerInput): Promise<S3LambdaTriggerOverview> {
|
||||
const adapter = this.requireAdapter(cloud, 'storage')
|
||||
if (!adapter.createS3LambdaTrigger) throw new NotSupportedError(`S3 Lambda triggers are not supported for ${cloud}`)
|
||||
|
||||
@@ -130,19 +130,6 @@ export const apiEndpointKeys = {
|
||||
messages: {send: "clouds.services.messaging.messages.send", receive: "clouds.services.messaging.messages.receive", delete: "clouds.services.messaging.messages.delete", purge: "clouds.services.messaging.messages.purge"},
|
||||
moveTasks: "clouds.services.messaging.move-tasks",
|
||||
},
|
||||
httpApi: {
|
||||
describe: "clouds.services.apigateway.http-api.describe",
|
||||
createRoute: "clouds.services.apigateway.http-api.routes.create",
|
||||
deleteRoute: "clouds.services.apigateway.http-api.routes.delete",
|
||||
setRouteAuthorizer: "clouds.services.apigateway.http-api.routes.authorizer",
|
||||
createStage: "clouds.services.apigateway.http-api.stages.create",
|
||||
deleteStage: "clouds.services.apigateway.http-api.stages.delete",
|
||||
deploy: "clouds.services.apigateway.http-api.stages.deploy",
|
||||
createAuthorizer: "clouds.services.apigateway.http-api.authorizers.create",
|
||||
deleteAuthorizer: "clouds.services.apigateway.http-api.authorizers.delete",
|
||||
cors: "clouds.services.apigateway.http-api.cors",
|
||||
webSocketTest: "clouds.services.apigateway.http-api.websocket-test",
|
||||
},
|
||||
monitoring: {
|
||||
metrics: "clouds.services.monitoring.metrics",
|
||||
metricData: "clouds.services.monitoring.metric-data",
|
||||
@@ -910,17 +897,6 @@ export const endpointRegistry: EndpointRegistry = new Map([
|
||||
method: "POST",
|
||||
},
|
||||
],
|
||||
[apiEndpointKeys.clouds.httpApi.describe, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.httpApi.createRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deleteRoute, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/routes/:routeId/authorizer", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.httpApi.createStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deleteStage, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deploy, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/stages/:stageName/deployments", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.createAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.httpApi.deleteAuthorizer, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/authorizers/:authorizerId", method: "DELETE"}],
|
||||
[apiEndpointKeys.clouds.httpApi.cors, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/cors", method: "PUT"}],
|
||||
[apiEndpointKeys.clouds.httpApi.webSocketTest, {path: "/clouds/:cloud/services/apigateway/resources/:id/http-api/websocket-test", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.monitoring.metrics, {path: "/clouds/:cloud/services/monitoring/metrics", method: "GET"}],
|
||||
[apiEndpointKeys.clouds.monitoring.metricData, {path: "/clouds/:cloud/services/monitoring/metric-data", method: "POST"}],
|
||||
[apiEndpointKeys.clouds.monitoring.history, {path: "/clouds/:cloud/services/monitoring/resources/:id/history", method: "GET"}],
|
||||
|
||||
@@ -1546,139 +1546,6 @@ export async function setAlarmActionsEnabled(
|
||||
);
|
||||
}
|
||||
|
||||
export type HttpApiProtocol = "HTTP" | "WEBSOCKET";
|
||||
|
||||
export interface HttpApiIntegrationOverview {
|
||||
integrationId: string;
|
||||
type: string;
|
||||
uri: string | null;
|
||||
functionName: string | null;
|
||||
payloadFormatVersion: string | null;
|
||||
}
|
||||
|
||||
export interface HttpApiRouteOverview {
|
||||
routeId: string;
|
||||
routeKey: string;
|
||||
integration: HttpApiIntegrationOverview | null;
|
||||
authorizationType: string;
|
||||
authorizerId: string | null;
|
||||
authorizationScopes: string[];
|
||||
returnsResponse: boolean;
|
||||
}
|
||||
|
||||
export interface HttpApiStageOverview {
|
||||
stageName: string;
|
||||
autoDeploy: boolean;
|
||||
deploymentId: string | null;
|
||||
lastUpdatedAt: string | null;
|
||||
invokePath: string;
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerOverview {
|
||||
authorizerId: string;
|
||||
name: string;
|
||||
type: string;
|
||||
identitySource: string[];
|
||||
issuer: string | null;
|
||||
audience: string[];
|
||||
}
|
||||
|
||||
export interface HttpApiCors {
|
||||
allowOrigins: string[];
|
||||
allowMethods: string[];
|
||||
allowHeaders: string[];
|
||||
exposeHeaders: string[];
|
||||
maxAgeSeconds: number | null;
|
||||
allowCredentials: boolean;
|
||||
}
|
||||
|
||||
export interface HttpApiOverview {
|
||||
apiId: string;
|
||||
protocol: HttpApiProtocol;
|
||||
apiEndpoint: string | null;
|
||||
routeSelectionExpression: string | null;
|
||||
routes: HttpApiRouteOverview[];
|
||||
stages: HttpApiStageOverview[];
|
||||
authorizers: HttpApiAuthorizerOverview[];
|
||||
cors: HttpApiCors | null;
|
||||
}
|
||||
|
||||
export interface HttpApiRouteInput {
|
||||
routeKey: string;
|
||||
functionName?: string;
|
||||
httpUrl?: string;
|
||||
authorizerId?: string;
|
||||
returnsResponse?: boolean;
|
||||
}
|
||||
|
||||
export interface HttpApiAuthorizerInput {
|
||||
name: string;
|
||||
userPoolId?: string;
|
||||
userPoolClientId?: string;
|
||||
issuer?: string;
|
||||
audience?: string[];
|
||||
}
|
||||
|
||||
export interface HttpApiWebSocketTestResult {
|
||||
connected: boolean;
|
||||
messages: string[];
|
||||
closeCode: number | null;
|
||||
error: string | null;
|
||||
durationMs: number;
|
||||
}
|
||||
|
||||
export async function describeHttpApi(cloud: CloudProvider, apiId: string, signal?: AbortSignal): Promise<HttpApiOverview> {
|
||||
const res = await apiClient.call<HttpApiOverview>(apiEndpointKeys.clouds.httpApi.describe, requestOptions(cloud, "apigateway", { signal }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHttpApiRoute(cloud: CloudProvider, apiId: string, input: HttpApiRouteInput): Promise<HttpApiRouteOverview> {
|
||||
const res = await apiClient.call<HttpApiRouteOverview, HttpApiRouteInput>(apiEndpointKeys.clouds.httpApi.createRoute, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHttpApiRoute(cloud: CloudProvider, apiId: string, routeId: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteRoute, requestOptions(cloud, "apigateway"), { cloud, id: apiId, routeId });
|
||||
}
|
||||
|
||||
export async function setHttpApiRouteAuthorizer(cloud: CloudProvider, apiId: string, routeId: string, authorizerId: string | null): Promise<HttpApiRouteOverview> {
|
||||
const res = await apiClient.call<HttpApiRouteOverview, { authorizerId: string | null }>(apiEndpointKeys.clouds.httpApi.setRouteAuthorizer, requestOptions(cloud, "apigateway", { body: { authorizerId } }), { cloud, id: apiId, routeId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHttpApiStage(cloud: CloudProvider, apiId: string, input: { stageName: string; autoDeploy: boolean }): Promise<HttpApiStageOverview> {
|
||||
const res = await apiClient.call<HttpApiStageOverview, typeof input>(apiEndpointKeys.clouds.httpApi.createStage, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHttpApiStage(cloud: CloudProvider, apiId: string, stageName: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteStage, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
|
||||
}
|
||||
|
||||
export async function deployHttpApi(cloud: CloudProvider, apiId: string, stageName: string): Promise<HttpApiStageOverview> {
|
||||
const res = await apiClient.call<HttpApiStageOverview>(apiEndpointKeys.clouds.httpApi.deploy, requestOptions(cloud, "apigateway"), { cloud, id: apiId, stageName });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function createHttpApiAuthorizer(cloud: CloudProvider, apiId: string, input: HttpApiAuthorizerInput): Promise<HttpApiAuthorizerOverview> {
|
||||
const res = await apiClient.call<HttpApiAuthorizerOverview, HttpApiAuthorizerInput>(apiEndpointKeys.clouds.httpApi.createAuthorizer, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function deleteHttpApiAuthorizer(cloud: CloudProvider, apiId: string, authorizerId: string): Promise<void> {
|
||||
await apiClient.call<void>(apiEndpointKeys.clouds.httpApi.deleteAuthorizer, requestOptions(cloud, "apigateway"), { cloud, id: apiId, authorizerId });
|
||||
}
|
||||
|
||||
export async function updateHttpApiCors(cloud: CloudProvider, apiId: string, cors: HttpApiCors | null): Promise<HttpApiCors | null> {
|
||||
const res = await apiClient.call<{ cors: HttpApiCors | null }, { cors: HttpApiCors | null }>(apiEndpointKeys.clouds.httpApi.cors, requestOptions(cloud, "apigateway", { body: { cors } }), { cloud, id: apiId });
|
||||
return res.data.cors;
|
||||
}
|
||||
|
||||
export async function testWebSocketApi(cloud: CloudProvider, apiId: string, input: { stage: string; message: string; query?: string }): Promise<HttpApiWebSocketTestResult> {
|
||||
const res = await apiClient.call<HttpApiWebSocketTestResult, typeof input>(apiEndpointKeys.clouds.httpApi.webSocketTest, requestOptions(cloud, "apigateway", { body: input }), { cloud, id: apiId });
|
||||
return res.data;
|
||||
}
|
||||
|
||||
export async function setDistributionEnabled(
|
||||
cloud: CloudProvider,
|
||||
distributionId: string,
|
||||
|
||||
@@ -8,7 +8,6 @@ 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
|
||||
@@ -139,8 +138,8 @@ export function ApiGatewayRoutePanel({cloud, resource, runtimeReachable, runtime
|
||||
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>
|
||||
<h3>Select a REST API</h3>
|
||||
<p>Select an API Gateway REST API to connect a route to a Lambda function.</p>
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
@@ -514,6 +513,49 @@ function MethodTestResponse({result, dismiss}: {result?: ApiGatewayMethodTestRes
|
||||
</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 {
|
||||
|
||||
@@ -48,7 +48,6 @@ import { DatabaseLaunchProgress } from "@/components/DatabaseLaunchProgress";
|
||||
import { AppConfigPanel } from "@/components/AppConfigPanel";
|
||||
import { KmsCryptoPanel } from "@/components/KmsCryptoPanel";
|
||||
import { ApiGatewayRoutePanel } from "@/components/ApiGatewayRoutePanel";
|
||||
import { HttpApiPanel } from "@/components/HttpApiPanel";
|
||||
import { S3LambdaTriggerPanel } from "@/components/S3LambdaTriggerPanel";
|
||||
import { ScheduledLambdaTriggerPanel } from "@/components/ScheduledLambdaTriggerPanel";
|
||||
import { SqsLambdaTriggerPanel } from "@/components/SqsLambdaTriggerPanel";
|
||||
@@ -1038,21 +1037,14 @@ export function DynamicResourceView({
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{service === "apigateway" && (activeSelected && ["http", "websocket"].includes(String(activeSelected.metadata.apigatewayService)) ? (
|
||||
<HttpApiPanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||
/>
|
||||
) : (
|
||||
{service === "apigateway" && (
|
||||
<ApiGatewayRoutePanel
|
||||
cloud={cloud}
|
||||
resource={activeSelected}
|
||||
runtimeReachable={canUseRuntime}
|
||||
runtimeEndpoint={cloudStatus?.endpoint ?? undefined}
|
||||
/>
|
||||
))}
|
||||
)}
|
||||
{service === "events" && (
|
||||
<EventBridgeRulePanel
|
||||
cloud={cloud}
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
import {QueryClient, QueryClientProvider} from '@tanstack/react-query'
|
||||
import {render, screen, within} from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import {beforeEach, expect, test, vi} from 'vitest'
|
||||
import {HttpApiPanel} from './HttpApiPanel'
|
||||
import type {HttpApiOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
|
||||
const api = vi.hoisted(() => ({
|
||||
describeHttpApi: vi.fn(),
|
||||
listCloudResources: vi.fn(),
|
||||
createHttpApiRoute: vi.fn(),
|
||||
deleteHttpApiRoute: vi.fn(),
|
||||
setHttpApiRouteAuthorizer: vi.fn(),
|
||||
createHttpApiStage: vi.fn(),
|
||||
deleteHttpApiStage: vi.fn(),
|
||||
deployHttpApi: vi.fn(),
|
||||
createHttpApiAuthorizer: vi.fn(),
|
||||
deleteHttpApiAuthorizer: vi.fn(),
|
||||
updateHttpApiCors: vi.fn(),
|
||||
testApiGatewayRoute: vi.fn(),
|
||||
testWebSocketApi: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@/api/cloudProxyClient', () => api)
|
||||
|
||||
const httpResource: CloudResource = {
|
||||
id: 'h1', name: 'notes-api', cloud: 'aws', service: 'apigateway', type: 'http-api', region: null, createdAt: null,
|
||||
metadata: {apigatewayService: 'http', protocol: 'HTTP'},
|
||||
}
|
||||
|
||||
const httpOverview: HttpApiOverview = {
|
||||
apiId: 'h1', protocol: 'HTTP', apiEndpoint: null, routeSelectionExpression: null,
|
||||
routes: [
|
||||
{routeId: 'r1', routeKey: 'GET /notes/{id}', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: false},
|
||||
{routeId: 'r2', routeKey: 'GET /me', integration: {integrationId: 'i1', type: 'AWS_PROXY', uri: 'arn', functionName: 'notes', payloadFormatVersion: '2.0'}, authorizationType: 'JWT', authorizerId: 'a1', authorizationScopes: [], returnsResponse: false},
|
||||
],
|
||||
stages: [{stageName: '$default', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/execute-api/h1/$default'}],
|
||||
authorizers: [{authorizerId: 'a1', name: 'cognito', type: 'JWT', identitySource: ['$request.header.Authorization'], issuer: 'http://floci:4566/us-east-1_pool', audience: ['client1']}],
|
||||
cors: null,
|
||||
}
|
||||
|
||||
function renderPanel(resource: CloudResource = httpResource) {
|
||||
const client = new QueryClient({defaultOptions: {queries: {retry: false}}})
|
||||
return render(<QueryClientProvider client={client}><HttpApiPanel cloud="aws" resource={resource} runtimeReachable runtimeEndpoint="http://localhost:4566"/></QueryClientProvider>)
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
api.describeHttpApi.mockResolvedValue(httpOverview)
|
||||
api.listCloudResources.mockImplementation(async (_cloud: string, service: string) => service === 'serverless'
|
||||
? [{id: 'notes', name: 'notes', type: 'lambda', cloud: 'aws', service: 'serverless', region: null, createdAt: null, metadata: {}}]
|
||||
: [{id: 'us-east-1_pool', name: 'notes-users', type: 'user-pool', cloud: 'aws', service: 'userpools', region: null, createdAt: null, metadata: {appClients: [{clientId: 'client1', clientName: 'web'}]}}])
|
||||
api.createHttpApiRoute.mockResolvedValue(httpOverview.routes[0])
|
||||
api.setHttpApiRouteAuthorizer.mockResolvedValue(httpOverview.routes[0])
|
||||
api.createHttpApiAuthorizer.mockResolvedValue(httpOverview.authorizers[0])
|
||||
api.updateHttpApiCors.mockResolvedValue({allowOrigins: ['*'], allowMethods: ['GET'], allowHeaders: [], exposeHeaders: [], maxAgeSeconds: null, allowCredentials: false})
|
||||
api.testApiGatewayRoute.mockResolvedValue({statusCode: 200, headers: {}, body: '{"ok":true}', durationMs: 12})
|
||||
})
|
||||
|
||||
test('shows the stage URL, routes with their targets and who may call them', async () => {
|
||||
renderPanel()
|
||||
|
||||
expect(await screen.findByText('http://localhost:4566/execute-api/h1/$default')).toBeInTheDocument()
|
||||
const routes = within(screen.getByRole('region', {name: 'Routes'}))
|
||||
expect(routes.getByText('GET /notes/{id}')).toBeInTheDocument()
|
||||
expect(routes.getAllByText('Lambda: notes')).toHaveLength(2)
|
||||
expect(routes.getByRole('combobox', {name: 'Access for GET /me'})).toHaveValue('a1')
|
||||
expect(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'})).toHaveValue('')
|
||||
})
|
||||
|
||||
test('adds a Lambda route and protects an existing one', async () => {
|
||||
renderPanel()
|
||||
const routes = within(await screen.findByRole('region', {name: 'Routes'}))
|
||||
|
||||
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Method'}), 'POST')
|
||||
await userEvent.clear(routes.getByRole('textbox', {name: 'Path'}))
|
||||
await userEvent.type(routes.getByRole('textbox', {name: 'Path'}), '/notes')
|
||||
await routes.findByRole('option', {name: 'notes'})
|
||||
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Lambda function'}), 'notes')
|
||||
await userEvent.click(routes.getByRole('button', {name: 'Add route'}))
|
||||
expect(api.createHttpApiRoute).toHaveBeenCalledWith('aws', 'h1', {routeKey: 'POST /notes', functionName: 'notes', httpUrl: undefined, authorizerId: undefined, returnsResponse: undefined})
|
||||
|
||||
await userEvent.selectOptions(routes.getByRole('combobox', {name: 'Access for GET /notes/{id}'}), 'a1')
|
||||
expect(api.setHttpApiRouteAuthorizer).toHaveBeenCalledWith('aws', 'h1', 'r1', 'a1')
|
||||
})
|
||||
|
||||
test('creates a JWT authorizer from a Cognito pool and app client', async () => {
|
||||
renderPanel()
|
||||
const security = within(await screen.findByRole('region', {name: 'Security'}))
|
||||
|
||||
await security.findByRole('option', {name: 'notes-users'})
|
||||
await userEvent.selectOptions(security.getByRole('combobox', {name: 'User pool'}), 'us-east-1_pool')
|
||||
await userEvent.selectOptions(security.getByRole('combobox', {name: 'App client'}), 'client1')
|
||||
await userEvent.click(security.getByRole('button', {name: 'Add authorizer'}))
|
||||
expect(api.createHttpApiAuthorizer).toHaveBeenCalledWith('aws', 'h1', {name: 'cognito', userPoolId: 'us-east-1_pool', userPoolClientId: 'client1'})
|
||||
})
|
||||
|
||||
test('sends a request to the stage and shows the response', async () => {
|
||||
renderPanel()
|
||||
const tester = within(await screen.findByRole('region', {name: 'Send a request'}))
|
||||
|
||||
expect(tester.getByRole('textbox', {name: 'Request path'})).toHaveValue('/notes/1')
|
||||
await userEvent.click(tester.getByRole('button', {name: 'Send request'}))
|
||||
expect(api.testApiGatewayRoute).toHaveBeenCalledWith('aws', 'h1', expect.objectContaining({stage: '$default', method: 'GET', path: '/notes/1'}))
|
||||
expect(await tester.findByText('200')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
test('a WebSocket API shows its wss URL and sends a message, without HTTP-only security', async () => {
|
||||
api.describeHttpApi.mockResolvedValue({
|
||||
apiId: 'w1', protocol: 'WEBSOCKET', apiEndpoint: null, routeSelectionExpression: '$request.body.action',
|
||||
routes: [{routeId: 'r1', routeKey: 'sendMessage', integration: null, authorizationType: 'NONE', authorizerId: null, authorizationScopes: [], returnsResponse: true}],
|
||||
stages: [{stageName: 'dev', autoDeploy: true, deploymentId: null, lastUpdatedAt: null, invokePath: '/ws/w1/dev'}],
|
||||
authorizers: [], cors: null,
|
||||
} satisfies HttpApiOverview)
|
||||
api.testWebSocketApi.mockResolvedValue({connected: true, messages: ['{"echo":"hello"}'], closeCode: null, error: null, durationMs: 40})
|
||||
renderPanel({...httpResource, id: 'w1', type: 'websocket-api', metadata: {apigatewayService: 'websocket', protocol: 'WEBSOCKET'}})
|
||||
|
||||
expect(await screen.findByText('ws://localhost:4566/ws/w1/dev')).toBeInTheDocument()
|
||||
expect(screen.queryByRole('region', {name: 'Security'})).not.toBeInTheDocument()
|
||||
const tester = within(screen.getByRole('region', {name: 'Send a message'}))
|
||||
expect(tester.getByRole('textbox', {name: 'Message'})).toHaveValue(JSON.stringify({action: 'sendMessage', data: 'hello'}, null, 2))
|
||||
await userEvent.click(tester.getByRole('button', {name: 'Connect and send'}))
|
||||
expect(api.testWebSocketApi).toHaveBeenCalledWith('aws', 'w1', expect.objectContaining({stage: 'dev'}))
|
||||
expect(await tester.findByText('Connected')).toBeInTheDocument()
|
||||
})
|
||||
@@ -1,610 +0,0 @@
|
||||
import {useEffect, useState} from 'react'
|
||||
import {Loader2, Plus, RefreshCw, Rocket, Send, ShieldCheck, Trash2} from 'lucide-react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {
|
||||
createHttpApiAuthorizer,
|
||||
createHttpApiRoute,
|
||||
createHttpApiStage,
|
||||
deleteHttpApiAuthorizer,
|
||||
deleteHttpApiRoute,
|
||||
deleteHttpApiStage,
|
||||
deployHttpApi,
|
||||
describeHttpApi,
|
||||
listCloudResources,
|
||||
setHttpApiRouteAuthorizer,
|
||||
testApiGatewayRoute,
|
||||
testWebSocketApi,
|
||||
updateHttpApiCors,
|
||||
} from '@/api/cloudProxyClient'
|
||||
import type {HttpApiAuthorizerOverview, HttpApiCors, HttpApiOverview, HttpApiRouteOverview, HttpApiStageOverview} from '@/api/cloudProxyClient'
|
||||
import type {CloudProvider} from '@/types/cloud'
|
||||
import type {CloudResource} from '@/types/resource'
|
||||
import {formatDateTime} from '@/lib/format'
|
||||
import {CopyValueButton} from './CopyValueButton'
|
||||
import {formatResponseBody, invokeEndpoint, parseHeaders} from '@/lib/apiGateway'
|
||||
|
||||
interface HttpApiPanelProps {
|
||||
cloud: CloudProvider
|
||||
resource: CloudResource
|
||||
runtimeReachable: boolean
|
||||
runtimeEndpoint?: string
|
||||
}
|
||||
|
||||
const ROUTE_METHODS = ['ANY', 'GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS']
|
||||
const TEST_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD']
|
||||
const CORS_METHODS = ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD', 'OPTIONS', '*']
|
||||
const WEBSOCKET_SYSTEM_ROUTES = ['$connect', '$disconnect', '$default']
|
||||
|
||||
/** API Gateway v2: an HTTP or WebSocket API's routes, stages, security and a live tester. */
|
||||
export function HttpApiPanel({cloud, resource, runtimeReachable, runtimeEndpoint}: HttpApiPanelProps) {
|
||||
const queryKey = ['http-api', cloud, resource.id]
|
||||
const query = useQuery({
|
||||
queryKey,
|
||||
queryFn: ({signal}) => describeHttpApi(cloud, resource.id, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const api = query.data
|
||||
const endpoint = invokeEndpoint(runtimeEndpoint)
|
||||
const isWebSocket = (api?.protocol ?? resource.metadata.protocol) === 'WEBSOCKET'
|
||||
|
||||
return <div className="http-api-workspace">
|
||||
<section className="table-panel" aria-label={`${isWebSocket ? 'WebSocket' : 'HTTP'} API ${resource.name}`}>
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">{isWebSocket ? 'WebSocket API' : 'HTTP API'}</p>
|
||||
<span className="muted">{isWebSocket
|
||||
? <>Messages are routed by <code>{api?.routeSelectionExpression ?? String(resource.metadata.routeSelectionExpression ?? '')}</code>.</>
|
||||
: 'Routes send requests to Lambda functions or HTTP backends.'}</span>
|
||||
</div>
|
||||
<button className="button" type="button" disabled={!runtimeReachable || query.isFetching} onClick={() => void query.refetch()}><RefreshCw size={14}/>{query.isFetching ? 'Loading' : 'Refresh'}</button>
|
||||
</div>
|
||||
{query.isLoading && <p className="muted padded">Loading the API</p>}
|
||||
{query.error instanceof Error && <p className="error-text compact-text padded" role="alert">{query.error.message}</p>}
|
||||
{api && <StageUrls api={api} endpoint={endpoint}/>}
|
||||
</section>
|
||||
{api && <>
|
||||
<RoutesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
<StagesSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
{api.protocol === 'HTTP' && <SecuritySection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>}
|
||||
{api.protocol === 'HTTP'
|
||||
? <HttpTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>
|
||||
: <WebSocketTester cloud={cloud} api={api} runtimeReachable={runtimeReachable}/>}
|
||||
</>}
|
||||
</div>
|
||||
}
|
||||
|
||||
function StageUrls({api, endpoint}: {api: HttpApiOverview; endpoint: string | null}) {
|
||||
if (api.stages.length === 0) return <p className="muted padded">No stages yet. Add one below to serve the API.</p>
|
||||
return <div className="http-api-urls">
|
||||
{api.stages.map((stage) => {
|
||||
const url = stageUrl(api, stage, endpoint)
|
||||
return <div className="dynamic-field" key={stage.stageName}>
|
||||
<span>{stage.stageName} {stage.autoDeploy && <small className="muted">· deploys automatically</small>}</span>
|
||||
{url ? <div className="copy-value"><code>{url}</code><CopyValueButton value={url} label={`${stage.stageName} URL`}/></div> : <code>{stage.invokePath}</code>}
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
}
|
||||
|
||||
type SectionProps = {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean; queryKey: unknown[]}
|
||||
|
||||
function RoutesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const isWebSocket = api.protocol === 'WEBSOCKET'
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [path, setPath] = useState('/hello')
|
||||
const [catchAll, setCatchAll] = useState(false)
|
||||
const [webSocketKey, setWebSocketKey] = useState('$default')
|
||||
const [targetKind, setTargetKind] = useState<'lambda' | 'http'>('lambda')
|
||||
const [functionName, setFunctionName] = useState('')
|
||||
const [httpUrl, setHttpUrl] = useState('')
|
||||
const [authorizerId, setAuthorizerId] = useState('')
|
||||
const [returnsResponse, setReturnsResponse] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmDelete(null)
|
||||
setAuthorizerId('')
|
||||
}, [api.apiId])
|
||||
|
||||
const functionsQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'serverless'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'serverless', undefined, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const functions = functionsQuery.data?.filter((item) => item.type === 'lambda') ?? []
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||
|
||||
const routeKey = isWebSocket ? webSocketKey.trim() : catchAll ? '$default' : `${method} ${path.trim()}`
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createHttpApiRoute(cloud, api.apiId, {
|
||||
routeKey,
|
||||
functionName: targetKind === 'lambda' ? functionName : undefined,
|
||||
httpUrl: targetKind === 'http' ? httpUrl.trim() : undefined,
|
||||
authorizerId: authorizerId || undefined,
|
||||
returnsResponse: isWebSocket ? returnsResponse : undefined,
|
||||
}),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (routeId: string) => deleteHttpApiRoute(cloud, api.apiId, routeId),
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const authorizerMutation = useMutation({
|
||||
mutationFn: ({routeId, authorizer}: {routeId: string; authorizer: string | null}) => setHttpApiRouteAuthorizer(cloud, api.apiId, routeId, authorizer),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
|
||||
const target = targetKind === 'lambda' ? functionName : httpUrl.trim()
|
||||
const canCreate = runtimeReachable && Boolean(routeKey && target) && !createMutation.isPending
|
||||
const authorizerName = (id: string | null) => api.authorizers.find((authorizer) => authorizer.authorizerId === id)?.name ?? id
|
||||
const error = createMutation.error ?? deleteMutation.error ?? authorizerMutation.error
|
||||
|
||||
return <section className="table-panel" aria-label="Routes">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Routes</p>
|
||||
<span className="muted">{isWebSocket
|
||||
? '$connect and $disconnect run when a client connects and leaves; other messages go to the route their action names, or $default.'
|
||||
: 'The most specific route wins; $default catches every request no other route matches.'}</span>
|
||||
</div>
|
||||
</div>
|
||||
{api.routes.length === 0 && <p className="muted padded">No routes yet.</p>}
|
||||
{api.routes.length > 0 && <div className="cognito-table"><table className="table">
|
||||
<thead><tr><th>Route</th><th>Target</th>{!isWebSocket && <th>Access</th>}<th aria-label="Actions"/></tr></thead>
|
||||
<tbody>{api.routes.map((route) => <tr key={route.routeId}>
|
||||
<td><code>{route.routeKey}</code>{isWebSocket && route.returnsResponse && <small className="muted"> · replies</small>}</td>
|
||||
<td>{describeTarget(route)}</td>
|
||||
{!isWebSocket && <td>
|
||||
<select className="input http-api-access" aria-label={`Access for ${route.routeKey}`} value={route.authorizerId ?? ''} disabled={!runtimeReachable || authorizerMutation.isPending}
|
||||
onChange={(event) => authorizerMutation.mutate({routeId: route.routeId, authorizer: event.target.value || null})}>
|
||||
<option value="">Open to anyone</option>
|
||||
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
|
||||
</select>
|
||||
{route.authorizerId && !api.authorizers.some((authorizer) => authorizer.authorizerId === route.authorizerId) && <small className="muted">Authorizer {authorizerName(route.authorizerId)}</small>}
|
||||
</td>}
|
||||
<td className="cognito-row-actions-cell">
|
||||
{confirmDelete === route.routeId
|
||||
? <span className="apigateway-route-confirm">
|
||||
<span>Delete {route.routeKey}?</span>
|
||||
<button className="button danger compact" type="button" disabled={deleteMutation.isPending} onClick={() => deleteMutation.mutate(route.routeId)}>Delete</button>
|
||||
<button className="button compact" type="button" disabled={deleteMutation.isPending} onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||
</span>
|
||||
: <button className="icon-btn" type="button" aria-label={`Delete ${route.routeKey}`} disabled={!runtimeReachable} onClick={() => setConfirmDelete(route.routeId)}><Trash2 size={13}/></button>}
|
||||
</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
|
||||
<form className="dynamic-form" aria-label="Add a route" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (canCreate) createMutation.mutate()
|
||||
}}>
|
||||
{isWebSocket ? <label className="dynamic-field">
|
||||
<span>Route key</span>
|
||||
<input className="input" list="websocket-route-keys" value={webSocketKey} onChange={(event) => setWebSocketKey(event.target.value)} placeholder="sendMessage"/>
|
||||
<datalist id="websocket-route-keys">{WEBSOCKET_SYSTEM_ROUTES.map((key) => <option key={key} value={key}/>)}</datalist>
|
||||
<small>$connect, $disconnect, $default, or an action such as sendMessage.</small>
|
||||
</label> : <>
|
||||
<label className="dynamic-field">
|
||||
<span>Method</span>
|
||||
<select className="input" value={method} disabled={catchAll} onChange={(event) => setMethod(event.target.value)}>
|
||||
{ROUTE_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>Path</span>
|
||||
<input className="input" aria-label="Path" value={path} disabled={catchAll} onChange={(event) => setPath(event.target.value)} placeholder="/notes/{id}"/>
|
||||
<small>Use {'{id}'} for a path parameter and a final {'{proxy+}'} for everything below.</small>
|
||||
</label>
|
||||
<label className="dynamic-field http-api-check-field">
|
||||
<input type="checkbox" checked={catchAll} onChange={(event) => setCatchAll(event.target.checked)}/>
|
||||
<span>Catch-all ($default)</span>
|
||||
</label>
|
||||
</>}
|
||||
{!isWebSocket && <label className="dynamic-field">
|
||||
<span>Target</span>
|
||||
<select className="input" value={targetKind} onChange={(event) => setTargetKind(event.target.value as 'lambda' | 'http')}>
|
||||
<option value="lambda">Lambda function</option>
|
||||
<option value="http">HTTP backend</option>
|
||||
</select>
|
||||
</label>}
|
||||
{targetKind === 'lambda' || isWebSocket
|
||||
? <label className="dynamic-field">
|
||||
<span>Lambda function</span>
|
||||
<select className="input" aria-label="Lambda function" value={functionName} onChange={(event) => setFunctionName(event.target.value)}>
|
||||
<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 first.' : isWebSocket ? 'Receives the message as a WebSocket event.' : 'Receives a version 2.0 proxy event.'}</small>
|
||||
</label>
|
||||
: <label className="dynamic-field">
|
||||
<span>Backend URL</span>
|
||||
<input className="input" value={httpUrl} onChange={(event) => setHttpUrl(event.target.value)} placeholder="https://backend.example.com/{proxy}"/>
|
||||
<small>Requests are proxied here as they are.</small>
|
||||
</label>}
|
||||
{!isWebSocket && api.authorizers.length > 0 && <label className="dynamic-field">
|
||||
<span>Access</span>
|
||||
<select className="input" aria-label="Access for the new route" value={authorizerId} onChange={(event) => setAuthorizerId(event.target.value)}>
|
||||
<option value="">Open to anyone</option>
|
||||
{api.authorizers.map((authorizer) => <option key={authorizer.authorizerId} value={authorizer.authorizerId}>JWT: {authorizer.name}</option>)}
|
||||
</select>
|
||||
</label>}
|
||||
{isWebSocket && <label className="dynamic-field http-api-check-field">
|
||||
<input type="checkbox" checked={returnsResponse} onChange={(event) => setReturnsResponse(event.target.checked)}/>
|
||||
<span>Send the function's response back to the client</span>
|
||||
</label>}
|
||||
<button className="button primary" type="submit" disabled={!canCreate}>
|
||||
{createMutation.isPending ? <Loader2 size={13}/> : <Plus size={13}/>}{createMutation.isPending ? 'Adding' : 'Add route'}
|
||||
</button>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function StagesSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [stageName, setStageName] = useState('prod')
|
||||
const [autoDeploy, setAutoDeploy] = useState(true)
|
||||
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
||||
const [notice, setNotice] = useState('')
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||
|
||||
useEffect(() => {
|
||||
setConfirmDelete(null)
|
||||
setNotice('')
|
||||
}, [api.apiId])
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createHttpApiStage(cloud, api.apiId, {stageName: stageName.trim(), autoDeploy}),
|
||||
onSuccess: (stage) => {
|
||||
setNotice(stage.autoDeploy ? `${stage.stageName} deploys every change automatically.` : `${stage.stageName} is deployed. Deploy it again after changing routes.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deployMutation = useMutation({
|
||||
mutationFn: (name: string) => deployHttpApi(cloud, api.apiId, name),
|
||||
onSuccess: (stage) => {
|
||||
setNotice(`Deployed the current routes to ${stage.stageName}.`)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (name: string) => deleteHttpApiStage(cloud, api.apiId, name),
|
||||
onSuccess: () => {
|
||||
setConfirmDelete(null)
|
||||
return refresh()
|
||||
},
|
||||
})
|
||||
const error = createMutation.error ?? deployMutation.error ?? deleteMutation.error
|
||||
const busy = !runtimeReachable || createMutation.isPending || deployMutation.isPending || deleteMutation.isPending
|
||||
|
||||
return <section className="table-panel" aria-label="Stages">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow">Stages</p>
|
||||
<span className="muted">A stage serves a deployment of the routes. With automatic deployment, every change is live right away.</span>
|
||||
</div>
|
||||
</div>
|
||||
{api.stages.length > 0 && <div className="cognito-table"><table className="table">
|
||||
<thead><tr><th>Stage</th><th>Deployment</th><th>Updated</th><th aria-label="Actions"/></tr></thead>
|
||||
<tbody>{api.stages.map((stage) => <tr key={stage.stageName}>
|
||||
<td><code>{stage.stageName}</code></td>
|
||||
<td>{stage.autoDeploy ? 'Automatic' : stage.deploymentId ? <code>{stage.deploymentId}</code> : 'Not deployed'}</td>
|
||||
<td>{formatDateTime(stage.lastUpdatedAt) ?? '—'}</td>
|
||||
<td className="cognito-row-actions-cell">
|
||||
{!stage.autoDeploy && <button className="button compact" type="button" disabled={busy} onClick={() => deployMutation.mutate(stage.stageName)}><Rocket size={13}/>Deploy</button>}
|
||||
{confirmDelete === stage.stageName
|
||||
? <span className="apigateway-route-confirm">
|
||||
<span>Delete {stage.stageName}?</span>
|
||||
<button className="button danger compact" type="button" disabled={busy} onClick={() => deleteMutation.mutate(stage.stageName)}>Delete</button>
|
||||
<button className="button compact" type="button" onClick={() => setConfirmDelete(null)}>Cancel</button>
|
||||
</span>
|
||||
: <button className="icon-btn" type="button" aria-label={`Delete stage ${stage.stageName}`} disabled={busy} onClick={() => setConfirmDelete(stage.stageName)}><Trash2 size={13}/></button>}
|
||||
</td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
<form className="dynamic-form" aria-label="Add a stage" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNotice('')
|
||||
if (stageName.trim()) createMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Stage name</span><input className="input" value={stageName} onChange={(event) => setStageName(event.target.value)} placeholder="prod"/></label>
|
||||
<label className="dynamic-field http-api-check-field">
|
||||
<input type="checkbox" checked={autoDeploy} onChange={(event) => setAutoDeploy(event.target.checked)}/>
|
||||
<span>Deploy changes automatically</span>
|
||||
</label>
|
||||
<button className="button primary" type="submit" disabled={busy || !stageName.trim()}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add stage'}</button>
|
||||
</form>
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||
</section>
|
||||
}
|
||||
|
||||
function SecuritySection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
return <section className="table-panel" aria-label="Security">
|
||||
<div className="input-row resource-table-bar">
|
||||
<div>
|
||||
<p className="eyebrow"><ShieldCheck size={13}/> Security</p>
|
||||
<span className="muted">A JWT authorizer checks each request's bearer token; protect a route by choosing it under Access above.</span>
|
||||
</div>
|
||||
</div>
|
||||
<AuthorizersSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
<CorsSection cloud={cloud} api={api} runtimeReachable={runtimeReachable} queryKey={queryKey}/>
|
||||
</section>
|
||||
}
|
||||
|
||||
function AuthorizersSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [source, setSource] = useState<'cognito' | 'issuer'>('cognito')
|
||||
const [name, setName] = useState('cognito')
|
||||
const [userPoolId, setUserPoolId] = useState('')
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [issuer, setIssuer] = useState('')
|
||||
const [audience, setAudience] = useState('')
|
||||
const refresh = () => queryClient.invalidateQueries({queryKey})
|
||||
|
||||
const poolsQuery = useQuery({
|
||||
queryKey: ['cloud-resources', cloud, 'userpools'],
|
||||
queryFn: ({signal}) => listCloudResources(cloud, 'userpools', undefined, signal),
|
||||
enabled: runtimeReachable,
|
||||
})
|
||||
const pools = poolsQuery.data ?? []
|
||||
const pool = pools.find((candidate) => candidate.id === userPoolId)
|
||||
const clients = Array.isArray(pool?.metadata.appClients) ? pool.metadata.appClients as Array<{clientId: string; clientName: string}> : []
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: () => createHttpApiAuthorizer(cloud, api.apiId, source === 'cognito'
|
||||
? {name: name.trim(), userPoolId, userPoolClientId: clientId}
|
||||
: {name: name.trim(), issuer: issuer.trim(), audience: audience.split(',').map((value) => value.trim()).filter(Boolean)}),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (authorizerId: string) => deleteHttpApiAuthorizer(cloud, api.apiId, authorizerId),
|
||||
onSuccess: refresh,
|
||||
})
|
||||
const ready = source === 'cognito' ? Boolean(userPoolId && clientId) : Boolean(issuer.trim() && audience.trim())
|
||||
const error = createMutation.error ?? deleteMutation.error
|
||||
|
||||
return <>
|
||||
{api.authorizers.length > 0 && <div className="cognito-table"><table className="table">
|
||||
<thead><tr><th>Authorizer</th><th>Issuer</th><th>Audience</th><th aria-label="Actions"/></tr></thead>
|
||||
<tbody>{api.authorizers.map((authorizer: HttpApiAuthorizerOverview) => <tr key={authorizer.authorizerId}>
|
||||
<td>{authorizer.name} <small className="muted">· {authorizer.type}</small></td>
|
||||
<td className="mono http-api-wrap">{authorizer.issuer ?? '—'}</td>
|
||||
<td className="mono http-api-wrap">{authorizer.audience.join(', ') || '—'}</td>
|
||||
<td className="cognito-row-actions-cell"><button className="icon-btn" type="button" aria-label={`Delete authorizer ${authorizer.name}`} disabled={!runtimeReachable || deleteMutation.isPending} onClick={() => deleteMutation.mutate(authorizer.authorizerId)}><Trash2 size={13}/></button></td>
|
||||
</tr>)}</tbody>
|
||||
</table></div>}
|
||||
<form className="dynamic-form" aria-label="Add a JWT authorizer" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (ready && name.trim()) createMutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Name</span><input className="input" value={name} onChange={(event) => setName(event.target.value)}/></label>
|
||||
<label className="dynamic-field">
|
||||
<span>Tokens from</span>
|
||||
<select className="input" value={source} onChange={(event) => setSource(event.target.value as 'cognito' | 'issuer')}>
|
||||
<option value="cognito">A Cognito user pool</option>
|
||||
<option value="issuer">Another OpenID Connect issuer</option>
|
||||
</select>
|
||||
</label>
|
||||
{source === 'cognito' ? <>
|
||||
<label className="dynamic-field">
|
||||
<span>User pool</span>
|
||||
<select className="input" aria-label="User pool" value={userPoolId} onChange={(event) => { setUserPoolId(event.target.value); setClientId('') }}>
|
||||
<option value="">Select a user pool</option>
|
||||
{pools.map((candidate) => <option key={candidate.id} value={candidate.id}>{candidate.name}</option>)}
|
||||
</select>
|
||||
{poolsQuery.isSuccess && pools.length === 0 && <small>Create a Cognito user pool first.</small>}
|
||||
</label>
|
||||
<label className="dynamic-field">
|
||||
<span>App client</span>
|
||||
<select className="input" aria-label="App client" value={clientId} disabled={!pool} onChange={(event) => setClientId(event.target.value)}>
|
||||
<option value="">Select an app client</option>
|
||||
{clients.map((client) => <option key={client.clientId} value={client.clientId}>{client.clientName || client.clientId}</option>)}
|
||||
</select>
|
||||
<small>Only this client's tokens are accepted.</small>
|
||||
</label>
|
||||
</> : <>
|
||||
<label className="dynamic-field"><span>Issuer URL</span><input className="input" value={issuer} onChange={(event) => setIssuer(event.target.value)} placeholder="https://login.example.com/"/></label>
|
||||
<label className="dynamic-field"><span>Audience</span><input className="input" value={audience} onChange={(event) => setAudience(event.target.value)} placeholder="client-id, another-client-id"/></label>
|
||||
</>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || !ready || !name.trim() || createMutation.isPending}><Plus size={13}/>{createMutation.isPending ? 'Adding' : 'Add authorizer'}</button>
|
||||
</form>
|
||||
{error instanceof Error && <p className="error-text compact-text padded" role="alert">{error.message}</p>}
|
||||
</>
|
||||
}
|
||||
|
||||
function CorsSection({cloud, api, runtimeReachable, queryKey}: SectionProps) {
|
||||
const queryClient = useQueryClient()
|
||||
const [origins, setOrigins] = useState('')
|
||||
const [methods, setMethods] = useState<string[]>([])
|
||||
const [headers, setHeaders] = useState('')
|
||||
const [exposeHeaders, setExposeHeaders] = useState('')
|
||||
const [maxAge, setMaxAge] = useState('')
|
||||
const [credentials, setCredentials] = useState(false)
|
||||
const [notice, setNotice] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
const cors = api.cors
|
||||
setOrigins(cors?.allowOrigins.join(', ') ?? '*')
|
||||
setMethods(cors?.allowMethods ?? ['GET', 'POST', 'OPTIONS'])
|
||||
setHeaders(cors?.allowHeaders.join(', ') ?? 'content-type, authorization')
|
||||
setExposeHeaders(cors?.exposeHeaders.join(', ') ?? '')
|
||||
setMaxAge(cors?.maxAgeSeconds === null || cors?.maxAgeSeconds === undefined ? '' : String(cors.maxAgeSeconds))
|
||||
setCredentials(cors?.allowCredentials ?? false)
|
||||
setNotice('')
|
||||
}, [api.apiId, api.cors])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (cors: HttpApiCors | null) => updateHttpApiCors(cloud, api.apiId, cors),
|
||||
onSuccess: (cors) => {
|
||||
setNotice(cors ? 'CORS saved. API Gateway now answers preflight requests itself.' : 'CORS removed.')
|
||||
return queryClient.invalidateQueries({queryKey})
|
||||
},
|
||||
})
|
||||
const split = (value: string) => value.split(',').map((item) => item.trim()).filter(Boolean)
|
||||
|
||||
return <>
|
||||
<div className="input-row resource-table-bar"><div>
|
||||
<p className="eyebrow">CORS</p>
|
||||
<span className="muted">{api.cors ? 'On: browsers on the allowed origins can call this API.' : 'Off: browsers on other sites cannot call this API.'}</span>
|
||||
</div></div>
|
||||
<form className="dynamic-form" aria-label="CORS" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
setNotice('')
|
||||
mutation.mutate({allowOrigins: split(origins), allowMethods: methods, allowHeaders: split(headers), exposeHeaders: split(exposeHeaders), maxAgeSeconds: maxAge.trim() ? Number(maxAge) : null, allowCredentials: credentials})
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Allowed origins</span><input className="input" value={origins} onChange={(event) => setOrigins(event.target.value)} placeholder="https://app.example.com"/><small>Comma separated, or * for any site.</small></label>
|
||||
<div className="dynamic-field"><span>Allowed methods</span><div className="http-api-checks">{CORS_METHODS.map((value) => <label key={value}><input type="checkbox" checked={methods.includes(value)} onChange={() => setMethods((current) => current.includes(value) ? current.filter((item) => item !== value) : [...current, value])}/>{value}</label>)}</div></div>
|
||||
<label className="dynamic-field"><span>Allowed headers</span><input className="input" value={headers} onChange={(event) => setHeaders(event.target.value)}/></label>
|
||||
<label className="dynamic-field"><span>Exposed headers</span><input className="input" value={exposeHeaders} onChange={(event) => setExposeHeaders(event.target.value)} placeholder="x-request-id"/></label>
|
||||
<label className="dynamic-field"><span>Max age (seconds)</span><input className="input" inputMode="numeric" value={maxAge} onChange={(event) => setMaxAge(event.target.value)} placeholder="300"/></label>
|
||||
<label className="dynamic-field http-api-check-field"><input type="checkbox" checked={credentials} onChange={(event) => setCredentials(event.target.checked)}/><span>Allow credentials (cookies)</span></label>
|
||||
<div className="cloudwatch-form-actions">
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || mutation.isPending}>{mutation.isPending ? 'Saving' : 'Save CORS'}</button>
|
||||
{api.cors && <button className="button" type="button" disabled={!runtimeReachable || mutation.isPending} onClick={() => { setNotice(''); mutation.mutate(null) }}>Remove CORS</button>}
|
||||
</div>
|
||||
</form>
|
||||
{notice && <p className="cognito-outcome cognito-outcome--ok" role="status">{notice}</p>}
|
||||
{mutation.error instanceof Error && <p className="error-text compact-text padded" role="alert">{mutation.error.message}</p>}
|
||||
</>
|
||||
}
|
||||
|
||||
function HttpTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
|
||||
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '$default')
|
||||
const [method, setMethod] = useState('GET')
|
||||
const [path, setPath] = useState(suggestedPath(api.routes))
|
||||
const [query, setQuery] = useState('')
|
||||
const [headers, setHeaders] = useState('{\n "content-type": "application/json"\n}')
|
||||
const [body, setBody] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setStage(api.stages[0]?.stageName ?? '$default')
|
||||
setPath(suggestedPath(api.routes))
|
||||
// Only when another API is opened; keep what the user typed while this one refreshes.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api.apiId])
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () => testApiGatewayRoute(cloud, api.apiId, {path: path.trim(), method, stage, body, headers: parseHeaders(headers), query}),
|
||||
})
|
||||
const result = mutation.data
|
||||
|
||||
return <section className="table-panel apigateway-test-panel" aria-label="Send a request">
|
||||
<div className="input-row resource-table-bar"><div>
|
||||
<p className="eyebrow"><Send size={13}/> Send a request</p>
|
||||
<span className="muted">Sends a real request to the stage, through its routes and authorizers. For a protected route, add an <code>authorization</code> header with <code>Bearer</code> and a token.</span>
|
||||
</div></div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!mutation.isPending) mutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Stage</span>
|
||||
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
|
||||
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Method</span>
|
||||
<select className="input" value={method} onChange={(event) => setMethod(event.target.value)}>{TEST_METHODS.map((value) => <option key={value} value={value}>{value}</option>)}</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Path</span><input className="input" aria-label="Request path" value={path} onChange={(event) => setPath(event.target.value)} placeholder="/notes/123"/></label>
|
||||
<label className="dynamic-field"><span>Query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="page=1"/></label>
|
||||
<label className="dynamic-field"><span>Headers (JSON)</span><textarea className="textarea code-textarea small" value={headers} onChange={(event) => setHeaders(event.target.value)} spellCheck={false}/></label>
|
||||
{!['GET', 'HEAD'].includes(method) && <label className="dynamic-field"><span>Body</span><textarea className="textarea code-textarea" aria-label="Request body" value={body} onChange={(event) => setBody(event.target.value)} spellCheck={false}/></label>}
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || api.stages.length === 0 || mutation.isPending}>
|
||||
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Sending' : 'Send request'}
|
||||
</button>
|
||||
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
|
||||
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
|
||||
</form>
|
||||
<div className="apigateway-test-response">
|
||||
{result ? <div className="apigateway-test-result">
|
||||
<div className="apigateway-test-result-header">
|
||||
<strong>Response <span className={result.statusCode < 400 ? 'status-success' : 'status-error'}>{result.statusCode}</span></strong>
|
||||
<small>{result.durationMs} ms</small>
|
||||
</div>
|
||||
<details><summary>Response headers</summary><pre>{JSON.stringify(result.headers, null, 2)}</pre></details>
|
||||
<pre>{formatResponseBody(result.body)}</pre>
|
||||
</div> : <p className="muted compact-text">Send a request to see the response.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function WebSocketTester({cloud, api, runtimeReachable}: {cloud: CloudProvider; api: HttpApiOverview; runtimeReachable: boolean}) {
|
||||
const actionField = api.routeSelectionExpression?.match(/^\$request\.body\.(.+)$/)?.[1] ?? 'action'
|
||||
const firstAction = api.routes.find((route) => !WEBSOCKET_SYSTEM_ROUTES.includes(route.routeKey))?.routeKey ?? 'sendMessage'
|
||||
const [stage, setStage] = useState(api.stages[0]?.stageName ?? '')
|
||||
const [message, setMessage] = useState(JSON.stringify({[actionField]: firstAction, data: 'hello'}, null, 2))
|
||||
const [query, setQuery] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
setStage(api.stages[0]?.stageName ?? '')
|
||||
// Only when another API is opened.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [api.apiId])
|
||||
|
||||
const mutation = useMutation({mutationFn: () => testWebSocketApi(cloud, api.apiId, {stage, message, query})})
|
||||
const result = mutation.data
|
||||
|
||||
return <section className="table-panel apigateway-test-panel" aria-label="Send a message">
|
||||
<div className="input-row resource-table-bar"><div>
|
||||
<p className="eyebrow"><Send size={13}/> Send a message</p>
|
||||
<span className="muted">Connects to the stage like a client would, sends one message, and shows what comes back. Replies need a route that sends the function's response back.</span>
|
||||
</div></div>
|
||||
<div className="apigateway-test-workspace">
|
||||
<form className="apigateway-test-form" onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
if (!mutation.isPending && stage) mutation.mutate()
|
||||
}}>
|
||||
<label className="dynamic-field"><span>Stage</span>
|
||||
<select className="input" value={stage} onChange={(event) => setStage(event.target.value)}>
|
||||
{api.stages.map((item) => <option key={item.stageName} value={item.stageName}>{item.stageName}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="dynamic-field"><span>Connection query string</span><input className="input" value={query} onChange={(event) => setQuery(event.target.value)} placeholder="token=abc"/><small>Passed to $connect.</small></label>
|
||||
<label className="dynamic-field"><span>Message</span><textarea className="textarea code-textarea" aria-label="Message" value={message} onChange={(event) => setMessage(event.target.value)} spellCheck={false}/></label>
|
||||
<button className="button primary" type="submit" disabled={!runtimeReachable || !stage || mutation.isPending}>
|
||||
{mutation.isPending ? <Loader2 size={13}/> : <Send size={13}/>}{mutation.isPending ? 'Connecting' : 'Connect and send'}
|
||||
</button>
|
||||
{api.stages.length === 0 && <small className="muted">Add a stage first.</small>}
|
||||
{mutation.error instanceof Error && <p className="error-text compact-text" role="alert">{mutation.error.message}</p>}
|
||||
</form>
|
||||
<div className="apigateway-test-response">
|
||||
{result ? <div className="apigateway-test-result">
|
||||
<div className="apigateway-test-result-header">
|
||||
<strong>{result.connected ? <span className="status-success">Connected</span> : <span className="status-error">Not connected</span>}</strong>
|
||||
<small>{result.durationMs} ms</small>
|
||||
</div>
|
||||
{result.error && <p className="error-text compact-text">{result.error}</p>}
|
||||
{result.connected && result.messages.length === 0 && <p className="muted compact-text">No reply. Check the route sends the function's response back.</p>}
|
||||
{result.messages.map((reply, index) => <pre key={index}>{formatResponseBody(reply)}</pre>)}
|
||||
</div> : <p className="muted compact-text">Send a message to see the replies.</p>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
function describeTarget(route: HttpApiRouteOverview): string {
|
||||
const integration = route.integration
|
||||
if (!integration) return 'No target'
|
||||
if (integration.functionName) return `Lambda: ${integration.functionName}`
|
||||
if (integration.type === 'HTTP_PROXY') return `HTTP: ${integration.uri ?? ''}`
|
||||
return integration.type
|
||||
}
|
||||
|
||||
function stageUrl(api: HttpApiOverview, stage: HttpApiStageOverview, endpoint: string | null): string | null {
|
||||
if (!endpoint) return null
|
||||
return `${api.protocol === 'WEBSOCKET' ? endpoint.replace(/^http/, 'ws') : endpoint}${stage.invokePath}`
|
||||
}
|
||||
|
||||
/** A path to try first: the first route's path with its parameters filled in. */
|
||||
function suggestedPath(routes: HttpApiRouteOverview[]): string {
|
||||
const path = routes.map((route) => route.routeKey.split(' ')[1]).find(Boolean)
|
||||
if (!path || path === '/') return '/hello'
|
||||
return path.replace(/\{[^}]+\+\}/g, 'anything').replace(/\{[^}]+\}/g, '1')
|
||||
}
|
||||
@@ -6566,19 +6566,6 @@ tr[aria-current="true"] td { background: var(--hover-md); }
|
||||
.cloudwatch-form-actions { display: flex; flex-wrap: wrap; gap: 8px; align-self: end; }
|
||||
.cognito-row-actions-cell { text-align: right; }
|
||||
|
||||
/* API Gateway v2 (HTTP and WebSocket APIs): one column of sections, unlike the REST API's two. */
|
||||
.http-api-workspace { display: grid; gap: 16px; min-width: 0; }
|
||||
.http-api-workspace .dynamic-form { padding: 4px 16px 16px; }
|
||||
.http-api-workspace .apigateway-test-workspace { padding: 0 16px 16px; }
|
||||
.http-api-urls { display: grid; gap: 8px; padding: 0 16px 12px; }
|
||||
.http-api-urls .copy-value { overflow-wrap: anywhere; min-width: 0; }
|
||||
.http-api-check-field { display: flex; align-items: center; gap: 8px; align-self: end; min-height: 36px; }
|
||||
.http-api-check-field span { text-transform: none; font-size: 13px; font-weight: 500; color: var(--text-1); }
|
||||
.http-api-checks { display: flex; flex-wrap: wrap; gap: 4px 12px; }
|
||||
.http-api-checks label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.http-api-access { min-width: 0; max-width: 220px; }
|
||||
.http-api-wrap { overflow-wrap: anywhere; }
|
||||
|
||||
.metric-chart { position: relative; width: 100%; min-width: 0; }
|
||||
.metric-chart svg { display: block; max-width: 100%; height: auto; overflow: visible; }
|
||||
.metric-chart svg:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: 4px; }
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
// Shared by the REST API and HTTP/WebSocket API panels.
|
||||
|
||||
/** The runtime endpoint as the browser can reach it, for building invoke URLs. */
|
||||
export 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
|
||||
}
|
||||
|
||||
export 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>
|
||||
}
|
||||
|
||||
export function formatResponseBody(body: string): string {
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(body), null, 2)
|
||||
} catch {
|
||||
return body || '(empty response body)'
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,6 @@ importers:
|
||||
'@aws-sdk/client-api-gateway':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1096.0
|
||||
'@aws-sdk/client-apigatewayv2':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1156.0
|
||||
'@aws-sdk/client-appconfig':
|
||||
specifier: ^3.1076.0
|
||||
version: 3.1130.0
|
||||
@@ -302,10 +299,6 @@ packages:
|
||||
resolution: {integrity: sha512-RPzDojG3/YTdOMWGQ9mhE51db0IxL8YSjN9fKe5UawwgGrLa915ik71SMYk4z7hywkANuPTqRtt/kNJ863/j0g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-apigatewayv2@3.1156.0':
|
||||
resolution: {integrity: sha512-+PbvEXc5ZbfDARcjf6hCeWneq0pQ/ToyuhujoA/2c5eNXnEzi4SSAvh9lMDyWMx/iXOSYrARvdBw0up0D8sFag==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
'@aws-sdk/client-appconfig@3.1130.0':
|
||||
resolution: {integrity: sha512-CPVT+sEZLpYYLs6gIUr+O76QWzwjCIMp8ih7VZOQTzUhM8jXlDBwarupCMOkk830STVmKu5zhQjCzYI8i+XU3A==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
@@ -2908,17 +2901,6 @@ snapshots:
|
||||
'@smithy/types': 4.16.1
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-apigatewayv2@3.1156.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.5
|
||||
'@aws-sdk/credential-provider-node': 3.972.88
|
||||
'@aws-sdk/types': 3.975.0
|
||||
'@smithy/core': 3.36.2
|
||||
'@smithy/fetch-http-handler': 5.8.0
|
||||
'@smithy/node-http-handler': 4.12.1
|
||||
'@smithy/types': 4.19.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@aws-sdk/client-appconfig@3.1130.0':
|
||||
dependencies:
|
||||
'@aws-sdk/core': 3.978.0
|
||||
|
||||