zuplo 6.70.69 → 6.70.71
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/ai-gateway/getting-started.mdx +14 -9
- package/docs/ai-gateway/integrations/ai-sdk.mdx +17 -0
- package/docs/ai-gateway/introduction.mdx +12 -10
- package/docs/ai-gateway/providers.mdx +2 -0
- package/docs/analytics/access-and-entitlements.md +71 -0
- package/docs/analytics/overview.md +63 -0
- package/docs/analytics/reference/metrics-glossary.md +105 -0
- package/docs/analytics/reference/url-parameters.md +66 -0
- package/docs/analytics/shared-controls.md +121 -0
- package/docs/analytics/tabs/agents.md +88 -0
- package/docs/analytics/tabs/consumers.md +73 -0
- package/docs/analytics/tabs/graphql.md +77 -0
- package/docs/analytics/tabs/mcp.md +80 -0
- package/docs/analytics/tabs/origins.md +82 -0
- package/docs/analytics/tabs/requests.md +96 -0
- package/docs/articles/api-key-buckets.mdx +4 -2
- package/docs/articles/archiving-requests-to-storage.mdx +4 -4
- package/docs/articles/branch-based-deployments.mdx +10 -8
- package/docs/articles/ci-cd-github/basic-deployment.mdx +10 -1
- package/docs/articles/ci-cd-github/cleanup-on-branch-delete.mdx +52 -31
- package/docs/articles/ci-cd-github/deploy-and-test.mdx +14 -1
- package/docs/articles/ci-cd-github/local-testing.mdx +3 -1
- package/docs/articles/ci-cd-github/pr-preview-environments.mdx +53 -10
- package/docs/articles/custom-ci-cd-azure.mdx +1 -1
- package/docs/articles/custom-ci-cd-bitbucket.mdx +1 -1
- package/docs/articles/custom-ci-cd-circleci.mdx +1 -1
- package/docs/articles/custom-ci-cd-github.mdx +12 -3
- package/docs/articles/custom-ci-cd-gitlab.mdx +1 -1
- package/docs/articles/graphql.mdx +276 -0
- package/docs/articles/monetization/api-access.mdx +184 -0
- package/docs/articles/monetization/meters.mdx +4 -4
- package/docs/articles/monetization/monetization-policy.md +4 -1
- package/docs/articles/monetization/private-plans.md +3 -4
- package/docs/articles/monetization/stripe-integration.md +9 -0
- package/docs/articles/monetization/subscription-lifecycle.md +12 -11
- package/docs/articles/monorepo-deployment.mdx +37 -5
- package/docs/articles/opentelemetry.mdx +5 -2
- package/docs/articles/securing-the-gateway-with-client-mtls.mdx +68 -43
- package/docs/articles/step-1-setup-basic-gateway.mdx +1 -3
- package/docs/articles/step-2-add-rate-limiting.mdx +1 -1
- package/docs/articles/testing.mdx +1 -1
- package/docs/articles/troubleshooting.md +7 -3
- package/docs/articles/waf-ddos-akamai.md +35 -16
- package/docs/articles/waf-ddos-aws-waf-shield.mdx +35 -16
- package/docs/articles/waf-ddos-fastly.mdx +10 -7
- package/docs/cli/deploy.mdx +44 -9
- package/docs/cli/deploy.partial.mdx +44 -9
- package/docs/concepts/api-keys.md +2 -2
- package/docs/dev-portal/zudoku/components/callout.mdx +11 -18
- package/docs/dev-portal/zudoku/components/sidecar-box.mdx +131 -0
- package/docs/dev-portal/zudoku/configuration/api-catalog.md +62 -42
- package/docs/dev-portal/zudoku/configuration/api-reference.md +5 -4
- package/docs/dev-portal/zudoku/configuration/navigation.mdx +70 -7
- package/docs/dev-portal/zudoku/configuration/search.md +36 -0
- package/docs/dev-portal/zudoku/configuration/site.md +38 -0
- package/docs/dev-portal/zudoku/customization/colors-theme.mdx +51 -40
- package/docs/errors/rate-limit-exceeded.mdx +30 -3
- package/docs/guides/canary-routing-for-employees.mdx +103 -39
- package/docs/guides/modify-openapi-paths.mdx +3 -3
- package/docs/handlers/legacy-dev-portal-handler.mdx +1 -1
- package/docs/handlers/mcp-server.mdx +13 -11
- package/docs/handlers/url-forward.mdx +5 -1
- package/docs/handlers/url-rewrite.mdx +7 -2
- package/docs/handlers/websocket-handler.mdx +5 -1
- package/docs/mcp-gateway/observability/logging.mdx +19 -12
- package/docs/mcp-server/resources.mdx +27 -15
- package/docs/mcp-server/testing.mdx +0 -2
- package/docs/policies/_index.md +2 -0
- package/docs/policies/archive-request-azure-storage-inbound/doc.md +1 -1
- package/docs/policies/archive-response-azure-storage-outbound/doc.md +1 -1
- package/docs/policies/data-loss-prevention-inbound/doc.md +116 -0
- package/docs/policies/data-loss-prevention-inbound/intro.md +15 -0
- package/docs/policies/data-loss-prevention-inbound/schema.json +220 -0
- package/docs/policies/data-loss-prevention-outbound/doc.md +116 -0
- package/docs/policies/data-loss-prevention-outbound/intro.md +18 -0
- package/docs/policies/data-loss-prevention-outbound/schema.json +220 -0
- package/docs/policies/ip-restriction-inbound/policy.ts +1 -1
- package/docs/programmable-api/background-dispatcher.mdx +6 -8
- package/docs/programmable-api/http-problems.mdx +0 -18
- package/docs/programmable-api/jwt-service-plugin.mdx +131 -109
- package/docs/programmable-api/runtime-behaviors.mdx +4 -2
- package/docs/programmable-api/streaming-zone-cache.mdx +4 -6
- package/docs/programmable-api/web-crypto-apis.mdx +10 -6
- package/docs/programmable-api/zone-cache.mdx +1 -1
- package/docs/rate-limiting/combining-policies.mdx +293 -0
- package/docs/rate-limiting/dynamic-rate-limiting.mdx +240 -0
- package/docs/rate-limiting/getting-started.mdx +339 -0
- package/docs/rate-limiting/how-it-works.md +225 -0
- package/docs/rate-limiting/monitoring-and-troubleshooting.mdx +243 -0
- package/docs/{articles → rate-limiting}/per-user-rate-limits-using-db.mdx +39 -28
- package/package.json +4 -4
- package/docs/concepts/rate-limiting.md +0 -246
- package/docs/errors/get-head-body-error.mdx +0 -41
|
@@ -127,6 +127,44 @@ export const NotFound = () => (
|
|
|
127
127
|
|
|
128
128
|
## Layout
|
|
129
129
|
|
|
130
|
+
### Collapsible Sidebar
|
|
131
|
+
|
|
132
|
+
The navigation sidebar is collapsible by default. A small toggle button on the sidebar's right
|
|
133
|
+
border lets users hide and reveal it. Configure the behavior under `site.sidebar`:
|
|
134
|
+
|
|
135
|
+
```tsx title=zudoku.config.tsx
|
|
136
|
+
{
|
|
137
|
+
site: {
|
|
138
|
+
sidebar: {
|
|
139
|
+
collapsible: true, // default: true. Set to false to disable the toggle entirely.
|
|
140
|
+
toggleVisibility: "always", // "always" (default) or "hover" — show button only when hovering the sidebar's right edge
|
|
141
|
+
togglePosition: "bottom", // "top", "center", or "bottom" (default)
|
|
142
|
+
},
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
For finer vertical placement, override the `--sidebar-toggle-y` CSS variable in your stylesheet:
|
|
148
|
+
|
|
149
|
+
```css
|
|
150
|
+
:root {
|
|
151
|
+
--sidebar-toggle-y: 30%;
|
|
152
|
+
}
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
The toggle button carries `aria-expanded="true"` when the sidebar is open and `"false"` when
|
|
156
|
+
collapsed. Combine it with the `[data-sidebar-toggle]` selector to position the button differently
|
|
157
|
+
per state:
|
|
158
|
+
|
|
159
|
+
```css
|
|
160
|
+
[data-sidebar-toggle][aria-expanded="true"] {
|
|
161
|
+
--sidebar-toggle-y: 20%;
|
|
162
|
+
}
|
|
163
|
+
[data-sidebar-toggle][aria-expanded="false"] {
|
|
164
|
+
--sidebar-toggle-y: 80%;
|
|
165
|
+
}
|
|
166
|
+
```
|
|
167
|
+
|
|
130
168
|
### Banner
|
|
131
169
|
|
|
132
170
|
Add a banner message to the top of the page:
|
|
@@ -164,29 +164,55 @@ const config = {
|
|
|
164
164
|
};
|
|
165
165
|
```
|
|
166
166
|
|
|
167
|
-
Alternatively,
|
|
167
|
+
Alternatively, paste the copied CSS into a stylesheet and import it from your config:
|
|
168
|
+
|
|
169
|
+
```css title=styles.css
|
|
170
|
+
/* Copied CSS code */
|
|
171
|
+
```
|
|
168
172
|
|
|
169
173
|
```ts title=zudoku.config.ts
|
|
170
|
-
|
|
171
|
-
theme: {
|
|
172
|
-
customCss: `
|
|
173
|
-
/* Copied CSS code */
|
|
174
|
-
`,
|
|
175
|
-
},
|
|
176
|
-
};
|
|
174
|
+
import "./styles.css";
|
|
177
175
|
```
|
|
178
176
|
|
|
179
177
|
## Custom CSS
|
|
180
178
|
|
|
181
|
-
|
|
179
|
+
The recommended way to add custom styles is to write a `.css` file alongside your config and import
|
|
180
|
+
it. This gives you HMR during development, syntax highlighting, autocompletion, and lets you split
|
|
181
|
+
styles across files as your site grows.
|
|
182
182
|
|
|
183
|
-
|
|
183
|
+
```css title=styles.css
|
|
184
|
+
.custom {
|
|
185
|
+
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
|
186
|
+
}
|
|
187
|
+
```
|
|
184
188
|
|
|
185
|
-
|
|
189
|
+
```ts title=zudoku.config.ts
|
|
190
|
+
import "./styles.css";
|
|
186
191
|
|
|
187
|
-
|
|
192
|
+
const config = {
|
|
193
|
+
// ...
|
|
194
|
+
};
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
If TypeScript reports `Cannot find module './styles.css'`, add `zudoku/client` to your tsconfig
|
|
198
|
+
types so CSS side-effect imports are recognized:
|
|
199
|
+
|
|
200
|
+
```json title=tsconfig.json
|
|
201
|
+
{
|
|
202
|
+
"compilerOptions": {
|
|
203
|
+
"types": ["zudoku/client"]
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
```
|
|
207
|
+
|
|
208
|
+
Projects created with `create-zudoku` include this by default.
|
|
209
|
+
|
|
210
|
+
### Inline alternatives (deprecated)
|
|
188
211
|
|
|
189
|
-
|
|
212
|
+
The `theme.customCss` option is deprecated and will be removed in a future release. It still accepts
|
|
213
|
+
a CSS string or object for backwards compatibility, but every change requires restarting the dev
|
|
214
|
+
server and you lose syntax highlighting, autocompletion, and HMR. Migrate to an imported `.css`
|
|
215
|
+
file.
|
|
190
216
|
|
|
191
217
|
```ts title=zudoku.config.ts
|
|
192
218
|
const config = {
|
|
@@ -200,37 +226,22 @@ const config = {
|
|
|
200
226
|
};
|
|
201
227
|
```
|
|
202
228
|
|
|
203
|
-
### CSS Object
|
|
204
|
-
|
|
205
|
-
```ts title=zudoku.config.ts
|
|
206
|
-
const config = {
|
|
207
|
-
theme: {
|
|
208
|
-
customCss: {
|
|
209
|
-
".custom": {
|
|
210
|
-
background: "linear-gradient(135deg, #667eea 0%, #764ba2 100%)",
|
|
211
|
-
},
|
|
212
|
-
},
|
|
213
|
-
},
|
|
214
|
-
};
|
|
215
|
-
```
|
|
216
|
-
|
|
217
229
|
### Enabling Code Ligatures
|
|
218
230
|
|
|
219
231
|
Dev Portal disables ligatures in code blocks by default to avoid unwanted glyph joining in fonts like
|
|
220
232
|
Geist Mono (e.g. `---`, `###`, `|--|`). If you're using a coding font designed around ligatures
|
|
221
|
-
(Fira Code, JetBrains Mono, etc.), re-enable them
|
|
222
|
-
|
|
223
|
-
```
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
};
|
|
233
|
+
(Fira Code, JetBrains Mono, etc.), re-enable them in your CSS file:
|
|
234
|
+
|
|
235
|
+
```css title=styles.css
|
|
236
|
+
code,
|
|
237
|
+
pre,
|
|
238
|
+
kbd,
|
|
239
|
+
samp,
|
|
240
|
+
.shiki,
|
|
241
|
+
.shiki span {
|
|
242
|
+
font-variant-ligatures: normal;
|
|
243
|
+
font-feature-settings: normal;
|
|
244
|
+
}
|
|
234
245
|
```
|
|
235
246
|
|
|
236
247
|
## Default Theme
|
|
@@ -4,6 +4,29 @@ title: Rate Limit Exceeded (RATE_LIMIT_EXCEEDED)
|
|
|
4
4
|
|
|
5
5
|
The request was rejected because the client exceeded the configured rate limit.
|
|
6
6
|
|
|
7
|
+
## Response format
|
|
8
|
+
|
|
9
|
+
The 429 response uses the
|
|
10
|
+
[Problem Details](../programmable-api/http-problems.mdx) format:
|
|
11
|
+
|
|
12
|
+
```json
|
|
13
|
+
{
|
|
14
|
+
"type": "https://httpproblems.com/http-status/429",
|
|
15
|
+
"title": "Too Many Requests",
|
|
16
|
+
"status": 429,
|
|
17
|
+
"detail": "Rate limit exceeded",
|
|
18
|
+
"instance": "/your-route",
|
|
19
|
+
"trace": {
|
|
20
|
+
"requestId": "4d54e4ee-c003-4d75-aba9-e09a6d707b08",
|
|
21
|
+
"timestamp": "2026-04-14T12:00:00.000Z",
|
|
22
|
+
"buildId": "ec44e831-3a02-467e-a26c-7e401e4473bf"
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
If `headerMode` is set to `"retry-after"` (the default), the response includes a
|
|
28
|
+
`Retry-After` header with the number of seconds to wait before retrying.
|
|
29
|
+
|
|
7
30
|
## Common Causes
|
|
8
31
|
|
|
9
32
|
- **Too many requests** — The client sent more requests than the rate limit
|
|
@@ -24,8 +47,12 @@ The request was rejected because the client exceeded the configured rate limit.
|
|
|
24
47
|
|
|
25
48
|
## For API Operators
|
|
26
49
|
|
|
27
|
-
- Review the rate limiting policy configuration in the route settings.
|
|
50
|
+
- Review the rate limiting policy configuration in the route settings. Check the
|
|
51
|
+
`requestsAllowed` and `timeWindowMinutes` values and verify that the
|
|
52
|
+
`rateLimitBy` identifier is resolving correctly.
|
|
28
53
|
- Consider using
|
|
29
|
-
[dynamic rate limiting](../
|
|
54
|
+
[dynamic rate limiting](../rate-limiting/dynamic-rate-limiting.mdx) to set
|
|
30
55
|
different limits per customer tier.
|
|
31
|
-
-
|
|
56
|
+
- Use your [logging integration](../articles/logging.mdx) to filter for 429
|
|
57
|
+
responses and identify which consumers are being throttled. Break down by user
|
|
58
|
+
or IP to spot noisy neighbors.
|
|
@@ -32,6 +32,10 @@ If any condition is met, the request routes to canary backends.
|
|
|
32
32
|
|
|
33
33
|
## Creating a Canary Routing Policy
|
|
34
34
|
|
|
35
|
+
The policy doesn't set the backend URL directly. Instead, it stores the chosen
|
|
36
|
+
backend on `context.custom`, and the route's handler reads that value to forward
|
|
37
|
+
the request (see "Adding the Policy to Routes" below).
|
|
38
|
+
|
|
35
39
|
Create a new policy file in your project:
|
|
36
40
|
|
|
37
41
|
```typescript
|
|
@@ -62,9 +66,9 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
62
66
|
const isCanary =
|
|
63
67
|
stageParam === "canary" || stageHeader === "canary" || canaryUser;
|
|
64
68
|
|
|
65
|
-
//
|
|
69
|
+
// Store the backend URL for the route's handler to use
|
|
66
70
|
if (isCanary) {
|
|
67
|
-
context.
|
|
71
|
+
context.custom.backendUrl = environment.API_URL_CANARY;
|
|
68
72
|
|
|
69
73
|
// Log canary routing for debugging
|
|
70
74
|
context.log.info("Routing to canary backend", {
|
|
@@ -73,13 +77,19 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
73
77
|
stage: stageParam || stageHeader || "canary",
|
|
74
78
|
});
|
|
75
79
|
} else {
|
|
76
|
-
context.
|
|
80
|
+
context.custom.backendUrl = environment.API_URL_PRODUCTION;
|
|
77
81
|
}
|
|
78
82
|
|
|
79
83
|
// Remove stage query parameter to avoid passing it to backend
|
|
80
84
|
if (stageParam) {
|
|
81
85
|
url.searchParams.delete("stage");
|
|
82
|
-
return new ZuploRequest(url.toString(),
|
|
86
|
+
return new ZuploRequest(url.toString(), {
|
|
87
|
+
method: request.method,
|
|
88
|
+
headers: request.headers,
|
|
89
|
+
body: request.body,
|
|
90
|
+
user: request.user,
|
|
91
|
+
params: request.params,
|
|
92
|
+
});
|
|
83
93
|
}
|
|
84
94
|
|
|
85
95
|
return request;
|
|
@@ -139,24 +149,30 @@ export const multiServiceCanaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
139
149
|
// Clean up query parameter
|
|
140
150
|
if (stageParam) {
|
|
141
151
|
url.searchParams.delete("stage");
|
|
142
|
-
return new ZuploRequest(url.toString(),
|
|
152
|
+
return new ZuploRequest(url.toString(), {
|
|
153
|
+
method: request.method,
|
|
154
|
+
headers: request.headers,
|
|
155
|
+
body: request.body,
|
|
156
|
+
user: request.user,
|
|
157
|
+
params: request.params,
|
|
158
|
+
});
|
|
143
159
|
}
|
|
144
160
|
|
|
145
161
|
return request;
|
|
146
162
|
};
|
|
147
163
|
```
|
|
148
164
|
|
|
165
|
+
Each service's route then reads the matching value in its handler options. For
|
|
166
|
+
example, the user API route would use a URL Forward handler with
|
|
167
|
+
`"baseUrl": "${context.custom.userApiUrl}"`.
|
|
168
|
+
|
|
149
169
|
## Percentage-Based Canary Routing
|
|
150
170
|
|
|
151
171
|
Roll out canary deployments gradually with percentage-based routing:
|
|
152
172
|
|
|
153
173
|
```typescript
|
|
154
174
|
// policies/percentage-canary-routing.ts
|
|
155
|
-
import {
|
|
156
|
-
InboundPolicyHandler,
|
|
157
|
-
ZuploRequest,
|
|
158
|
-
environment,
|
|
159
|
-
} from "@zuplo/runtime";
|
|
175
|
+
import { InboundPolicyHandler, environment } from "@zuplo/runtime";
|
|
160
176
|
|
|
161
177
|
export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
|
|
162
178
|
request,
|
|
@@ -168,7 +184,7 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
168
184
|
: [];
|
|
169
185
|
|
|
170
186
|
if (request.user?.sub && CANARY_USERS.includes(request.user.sub)) {
|
|
171
|
-
context.
|
|
187
|
+
context.custom.backendUrl = environment.API_URL_CANARY;
|
|
172
188
|
return request;
|
|
173
189
|
}
|
|
174
190
|
|
|
@@ -176,8 +192,12 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
176
192
|
const CANARY_PERCENTAGE = parseInt(environment.CANARY_PERCENTAGE || "0", 10);
|
|
177
193
|
|
|
178
194
|
if (CANARY_PERCENTAGE > 0) {
|
|
179
|
-
// Use a consistent hash for sticky sessions
|
|
180
|
-
|
|
195
|
+
// Use a consistent hash for sticky sessions. The client IP is
|
|
196
|
+
// available on the true-client-ip header.
|
|
197
|
+
const sessionId =
|
|
198
|
+
request.headers.get("x-session-id") ??
|
|
199
|
+
request.headers.get("true-client-ip") ??
|
|
200
|
+
"unknown";
|
|
181
201
|
const hash = await crypto.subtle.digest(
|
|
182
202
|
"SHA-256",
|
|
183
203
|
new TextEncoder().encode(sessionId),
|
|
@@ -186,13 +206,13 @@ export const percentageCanaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
186
206
|
const hashValue = hashArray[0] / 255; // Value between 0 and 1
|
|
187
207
|
|
|
188
208
|
if (hashValue * 100 < CANARY_PERCENTAGE) {
|
|
189
|
-
context.
|
|
209
|
+
context.custom.backendUrl = environment.API_URL_CANARY;
|
|
190
210
|
request.headers.set("X-Canary-Route", "percentage");
|
|
191
211
|
} else {
|
|
192
|
-
context.
|
|
212
|
+
context.custom.backendUrl = environment.API_URL_PRODUCTION;
|
|
193
213
|
}
|
|
194
214
|
} else {
|
|
195
|
-
context.
|
|
215
|
+
context.custom.backendUrl = environment.API_URL_PRODUCTION;
|
|
196
216
|
}
|
|
197
217
|
|
|
198
218
|
return request;
|
|
@@ -221,7 +241,9 @@ CANARY_PERCENTAGE=10 # Route 10% of traffic to canary
|
|
|
221
241
|
|
|
222
242
|
### Adding the Policy to Routes
|
|
223
243
|
|
|
224
|
-
Add the policy to your route configuration
|
|
244
|
+
Add the policy to your route configuration. Use the built-in
|
|
245
|
+
[URL Forward handler](../handlers/url-forward.mdx) and reference the value the
|
|
246
|
+
policy stored on `context.custom` in the `baseUrl` option:
|
|
225
247
|
|
|
226
248
|
```json
|
|
227
249
|
{
|
|
@@ -231,14 +253,14 @@ Add the policy to your route configuration:
|
|
|
231
253
|
"x-zuplo-route": {
|
|
232
254
|
"corsPolicy": "anything-goes",
|
|
233
255
|
"handler": {
|
|
234
|
-
"export": "
|
|
256
|
+
"export": "urlForwardHandler",
|
|
235
257
|
"module": "$import(@zuplo/runtime)",
|
|
236
258
|
"options": {
|
|
237
|
-
"
|
|
259
|
+
"baseUrl": "${context.custom.backendUrl}"
|
|
238
260
|
}
|
|
239
261
|
},
|
|
240
262
|
"policies": {
|
|
241
|
-
"inbound": ["
|
|
263
|
+
"inbound": ["auth-policy", "canary-routing"]
|
|
242
264
|
}
|
|
243
265
|
}
|
|
244
266
|
}
|
|
@@ -247,6 +269,9 @@ Add the policy to your route configuration:
|
|
|
247
269
|
}
|
|
248
270
|
```
|
|
249
271
|
|
|
272
|
+
The authentication policy runs first so that `request.user` is populated when
|
|
273
|
+
the canary routing policy checks the allow list.
|
|
274
|
+
|
|
250
275
|
## Testing Your Policy
|
|
251
276
|
|
|
252
277
|
### 1. Using Query Parameters
|
|
@@ -289,7 +314,9 @@ curl https://your-api.zuplo.app/api/v1/users \
|
|
|
289
314
|
|
|
290
315
|
## Monitoring and Observability
|
|
291
316
|
|
|
292
|
-
Add comprehensive logging to track canary routing
|
|
317
|
+
Add comprehensive logging to track canary routing. Inbound policies run before a
|
|
318
|
+
response exists, so set debug response headers from a
|
|
319
|
+
[response sending hook](../programmable-api/hooks.mdx):
|
|
293
320
|
|
|
294
321
|
```typescript
|
|
295
322
|
export const canaryRoutingPolicy: InboundPolicyHandler = async (
|
|
@@ -298,20 +325,26 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
298
325
|
) => {
|
|
299
326
|
const startTime = Date.now();
|
|
300
327
|
|
|
328
|
+
const CANARY_USERS = environment.CANARY_USERS
|
|
329
|
+
? environment.CANARY_USERS.split(",").map((user) => user.trim())
|
|
330
|
+
: [];
|
|
331
|
+
|
|
301
332
|
// Check stage conditions
|
|
302
333
|
const url = new URL(request.url);
|
|
303
334
|
const stageParam = url.searchParams.get("stage");
|
|
304
335
|
const stageHeader = request.headers.get("x-stage");
|
|
305
|
-
const canaryUser =
|
|
336
|
+
const canaryUser =
|
|
337
|
+
request.user?.sub && CANARY_USERS.includes(request.user.sub);
|
|
306
338
|
|
|
307
339
|
const isCanary =
|
|
308
|
-
stageParam === "canary" ||
|
|
309
|
-
stageHeader === "canary" ||
|
|
310
|
-
canaryUser;
|
|
340
|
+
stageParam === "canary" || stageHeader === "canary" || canaryUser;
|
|
311
341
|
|
|
312
|
-
|
|
313
|
-
|
|
342
|
+
const backendType = isCanary ? "canary" : "release";
|
|
343
|
+
context.custom.backendUrl = isCanary
|
|
344
|
+
? environment.API_URL_CANARY
|
|
345
|
+
: environment.API_URL_PRODUCTION;
|
|
314
346
|
|
|
347
|
+
if (isCanary) {
|
|
315
348
|
// Log canary routing metrics
|
|
316
349
|
context.log.info("Canary route selected", {
|
|
317
350
|
userId: request.user?.sub,
|
|
@@ -320,14 +353,14 @@ export const canaryRoutingPolicy: InboundPolicyHandler = async (
|
|
|
320
353
|
stage: "canary",
|
|
321
354
|
duration: Date.now() - startTime,
|
|
322
355
|
});
|
|
323
|
-
|
|
324
|
-
// Add response header for debugging
|
|
325
|
-
context.response.headers.set("X-Backend-Type", "canary");
|
|
326
|
-
} else {
|
|
327
|
-
context.route.url = environment.API_URL_PRODUCTION;
|
|
328
|
-
context.response.headers.set("X-Backend-Type", "release");
|
|
329
356
|
}
|
|
330
357
|
|
|
358
|
+
// Add a response header for debugging
|
|
359
|
+
context.addResponseSendingHook((response) => {
|
|
360
|
+
response.headers.set("X-Backend-Type", backendType);
|
|
361
|
+
return response;
|
|
362
|
+
});
|
|
363
|
+
|
|
331
364
|
return request;
|
|
332
365
|
};
|
|
333
366
|
```
|
|
@@ -359,14 +392,45 @@ if (isCanaryUser) {
|
|
|
359
392
|
|
|
360
393
|
### 3. Fallback Handling
|
|
361
394
|
|
|
362
|
-
|
|
395
|
+
To fall back to production when the canary backend fails, replace the URL
|
|
396
|
+
Forward handler with a [custom handler](../handlers/custom-handler.mdx) that
|
|
397
|
+
retries against production on server errors:
|
|
363
398
|
|
|
364
399
|
```typescript
|
|
365
|
-
//
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
400
|
+
// modules/canary-fallback-handler.ts
|
|
401
|
+
import { ZuploContext, ZuploRequest, environment } from "@zuplo/runtime";
|
|
402
|
+
|
|
403
|
+
export default async function handler(
|
|
404
|
+
request: ZuploRequest,
|
|
405
|
+
context: ZuploContext,
|
|
406
|
+
) {
|
|
407
|
+
const url = new URL(request.url);
|
|
408
|
+
const backendUrl = context.custom.backendUrl;
|
|
409
|
+
const target = `${backendUrl}${url.pathname}${url.search}`;
|
|
410
|
+
|
|
411
|
+
// Buffer the body so the request can be retried
|
|
412
|
+
const body = request.body ? await request.arrayBuffer() : undefined;
|
|
413
|
+
|
|
414
|
+
const response = await fetch(target, {
|
|
415
|
+
method: request.method,
|
|
416
|
+
headers: request.headers,
|
|
417
|
+
body,
|
|
418
|
+
});
|
|
419
|
+
|
|
420
|
+
// Retry against production if the canary backend returns a server error
|
|
421
|
+
if (backendUrl === environment.API_URL_CANARY && response.status >= 500) {
|
|
422
|
+
context.log.warn("Canary backend failed, falling back to production", {
|
|
423
|
+
status: response.status,
|
|
424
|
+
});
|
|
425
|
+
const fallbackBase = environment.API_URL_PRODUCTION;
|
|
426
|
+
return fetch(`${fallbackBase}${url.pathname}${url.search}`, {
|
|
427
|
+
method: request.method,
|
|
428
|
+
headers: request.headers,
|
|
429
|
+
body,
|
|
430
|
+
});
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
return response;
|
|
370
434
|
}
|
|
371
435
|
```
|
|
372
436
|
|
|
@@ -106,8 +106,8 @@ You can generate multiple versions as part of your build process:
|
|
|
106
106
|
```json title="package.json"
|
|
107
107
|
{
|
|
108
108
|
"scripts": {
|
|
109
|
-
"build:api:v1": "
|
|
110
|
-
"build:api:v2": "
|
|
109
|
+
"build:api:v1": "node add-path-prefix.mjs /v1 openapi.json dist/openapi-v1.json",
|
|
110
|
+
"build:api:v2": "node add-path-prefix.mjs /v2 openapi.json dist/openapi-v2.json",
|
|
111
111
|
"build:api:all": "npm run build:api:v1 && npm run build:api:v2"
|
|
112
112
|
}
|
|
113
113
|
}
|
|
@@ -128,7 +128,7 @@ mkdir -p $OUTPUT_DIR
|
|
|
128
128
|
for prefix in "${PREFIXES[@]}"; do
|
|
129
129
|
echo "Building with prefix: /$prefix"
|
|
130
130
|
|
|
131
|
-
|
|
131
|
+
node add-path-prefix.mjs \
|
|
132
132
|
"/$prefix" \
|
|
133
133
|
"$BASE_FILE" \
|
|
134
134
|
"$OUTPUT_DIR/openapi-$prefix.json"
|
|
@@ -35,14 +35,15 @@ Open the **Route Designer** by navigating to the **Code** tab, then click
|
|
|
35
35
|
**routes.oas.json**. For any route definition, select **MCP Server** from the
|
|
36
36
|
**Request Handlers** drop-down. Set the method to **POST**.
|
|
37
37
|
|
|
38
|
-
Configure the handler with the following
|
|
38
|
+
Configure the handler with the following options:
|
|
39
39
|
|
|
40
|
-
- **Server Name** - The name of the MCP server. AI MCP clients will
|
|
41
|
-
name when they initialize with the server.
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
40
|
+
- **Server Name** (optional) - The name of the MCP server. AI MCP clients will
|
|
41
|
+
read this name when they initialize with the server. If not set, the server
|
|
42
|
+
advertises the default name `Zuplo MCP Server`.
|
|
43
|
+
- **Server Version** (optional) - The version of your MCP server. AI MCP clients
|
|
44
|
+
read this version when they initialize with the server and may make autonomous
|
|
45
|
+
decisions based on the versioning of your MCP server and the instructions
|
|
46
|
+
they've been given. If not set, the version defaults to `0.0.0`.
|
|
46
47
|
|
|
47
48
|
Next, configure your routes to be transformed into MCP tools or prompts (see
|
|
48
49
|
Configuration section below).
|
|
@@ -65,7 +66,7 @@ with the following route configuration:
|
|
|
65
66
|
"handler": {
|
|
66
67
|
|
|
67
68
|
// The MCP Server Handler handler
|
|
68
|
-
// and
|
|
69
|
+
// and example options
|
|
69
70
|
|
|
70
71
|
"export": "mcpServerHandler",
|
|
71
72
|
"module": "$import(@zuplo/runtime)",
|
|
@@ -86,10 +87,11 @@ with the following route configuration:
|
|
|
86
87
|
|
|
87
88
|
## Configuration
|
|
88
89
|
|
|
89
|
-
The MCP Server handler
|
|
90
|
+
The MCP Server handler supports the following configuration options:
|
|
90
91
|
|
|
91
|
-
- `name` (optional) - The name identifier of the MCP
|
|
92
|
-
|
|
92
|
+
- `name` (optional, default `Zuplo MCP Server`) - The name identifier of the MCP
|
|
93
|
+
server.
|
|
94
|
+
- `version` (optional, default `0.0.0`) - The version of the MCP server.
|
|
93
95
|
- `debugMode` (optional, default `false`) - Verbose logs on server startup,
|
|
94
96
|
initialization, tool listing, and tool calls. NOT recommended for production
|
|
95
97
|
environments.
|
|
@@ -53,6 +53,8 @@ The following objects are available for substitution:
|
|
|
53
53
|
`params.productId` would be the value of `:productId` in a route.
|
|
54
54
|
- `query: Record<string, string>` - The query parameters of the route. For
|
|
55
55
|
example, `query.filterBy` would be the value of `?filterBy=VALUE`.
|
|
56
|
+
- `method: string` - The HTTP method of the incoming request. For example, `GET`
|
|
57
|
+
or `POST`.
|
|
56
58
|
- `headers: Headers` - the incoming request's
|
|
57
59
|
[headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
|
|
58
60
|
- `url: string` - The full incoming request as a string
|
|
@@ -62,6 +64,9 @@ The following objects are available for substitution:
|
|
|
62
64
|
- `hostname: string` - The
|
|
63
65
|
[`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
|
|
64
66
|
portion of the incoming URL
|
|
67
|
+
- `origin: string` - The
|
|
68
|
+
[`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
|
|
69
|
+
portion of the incoming URL
|
|
65
70
|
- `pathname: string` - The
|
|
66
71
|
[`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
|
|
67
72
|
portion of the incoming URL
|
|
@@ -93,7 +98,6 @@ A few examples of the values of various substitutions.
|
|
|
93
98
|
- `${params.productId}` - `":productId"`
|
|
94
99
|
- `${pathname}` - `"/v1/products/:productId"`
|
|
95
100
|
- `${port}` - `"8080"`
|
|
96
|
-
- `${protocol}` - `"https:"`
|
|
97
101
|
- `${query.category}` - `"cars"`
|
|
98
102
|
- `${search}` - `"?category=cars"`
|
|
99
103
|
- `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
|
|
@@ -43,6 +43,8 @@ The following objects are available for substitution:
|
|
|
43
43
|
`params.productId` would be the value of `:productId` in a route.
|
|
44
44
|
- `query: Record<string, string>` - The query parameters of the route. For
|
|
45
45
|
example, `query.filterBy` would be the value of `?filterBy=VALUE`.
|
|
46
|
+
- `method: string` - The HTTP method of the incoming request. For example, `GET`
|
|
47
|
+
or `POST`.
|
|
46
48
|
- `headers: Headers` - the incoming request's
|
|
47
49
|
[headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
|
|
48
50
|
- `url: string` - The full incoming request as a string
|
|
@@ -52,6 +54,9 @@ The following objects are available for substitution:
|
|
|
52
54
|
- `hostname: string` - The
|
|
53
55
|
[`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
|
|
54
56
|
portion of the incoming URL
|
|
57
|
+
- `origin: string` - The
|
|
58
|
+
[`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
|
|
59
|
+
portion of the incoming URL
|
|
55
60
|
- `pathname: string` - The
|
|
56
61
|
[`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
|
|
57
62
|
portion of the incoming URL
|
|
@@ -83,7 +88,6 @@ A few examples of the values of various substitutions.
|
|
|
83
88
|
- `${params.productId}` - `":productId"`
|
|
84
89
|
- `${pathname}` - `"/v1/products/:productId"`
|
|
85
90
|
- `${port}` - `"8080"`
|
|
86
|
-
- `${protocol}` - `"https:"`
|
|
87
91
|
- `${query.category}` - `"cars"`
|
|
88
92
|
- `${search}` - `"?category=cars"`
|
|
89
93
|
- `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
|
|
@@ -132,7 +136,8 @@ use-cases.
|
|
|
132
136
|
- Type: `string`
|
|
133
137
|
- Supports JavaScript template interpolation with request context
|
|
134
138
|
- Available variables: `env`, `request`, `context`, `params`, `query`,
|
|
135
|
-
`headers`, `url`, `host`, `hostname`, `
|
|
139
|
+
`method`, `headers`, `url`, `host`, `hostname`, `origin`, `pathname`,
|
|
140
|
+
`port`, `search`
|
|
136
141
|
- Example: `"https://api-${params.version}.example.com/users/${params.id}"`
|
|
137
142
|
|
|
138
143
|
- **`forwardSearch`** (optional): Controls whether query parameters are
|
|
@@ -84,6 +84,8 @@ The following objects are available for substitution:
|
|
|
84
84
|
`params.productId` would be the value of `:productId` in a route.
|
|
85
85
|
- `query: Record<string, string>` - The query parameters of the route. For
|
|
86
86
|
example, `query.filterBy` would be the value of `?filterBy=VALUE`.
|
|
87
|
+
- `method: string` - The HTTP method of the incoming request. For example, `GET`
|
|
88
|
+
or `POST`.
|
|
87
89
|
- `headers: Headers` - the incoming request's
|
|
88
90
|
[headers object](https://developer.mozilla.org/en-US/docs/Web/API/Headers)
|
|
89
91
|
- `url: string` - The full incoming request as a string
|
|
@@ -93,6 +95,9 @@ The following objects are available for substitution:
|
|
|
93
95
|
- `hostname: string` - The
|
|
94
96
|
[`hostname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/hostname)
|
|
95
97
|
portion of the incoming URL
|
|
98
|
+
- `origin: string` - The
|
|
99
|
+
[`origin`](https://developer.mozilla.org/en-US/docs/Web/API/URL/origin)
|
|
100
|
+
portion of the incoming URL
|
|
96
101
|
- `pathname: string` - The
|
|
97
102
|
[`pathname`](https://developer.mozilla.org/en-US/docs/Web/API/URL/pathname)
|
|
98
103
|
portion of the incoming URL
|
|
@@ -124,7 +129,6 @@ A few examples of the values of various substitutions.
|
|
|
124
129
|
- `${params.productId}` - `":productId"`
|
|
125
130
|
- `${pathname}` - `"/v1/products/:productId"`
|
|
126
131
|
- `${port}` - `"8080"`
|
|
127
|
-
- `${protocol}` - `"https:"`
|
|
128
132
|
- `${query.category}` - `"cars"`
|
|
129
133
|
- `${search}` - `"?category=cars"`
|
|
130
134
|
- `${url}` - `"https://example.com:8080/v1/products/:productId?category=cars"`
|