diff --git a/docs/guides/transform-route-params-url-rewrite.mdx b/docs/guides/transform-route-params-url-rewrite.mdx new file mode 100644 index 000000000..3f40885b3 --- /dev/null +++ b/docs/guides/transform-route-params-url-rewrite.mdx @@ -0,0 +1,307 @@ +--- +title: Transform Route Parameters for URL Rewrite +sidebar_label: Transform Route Parameters +description: + Learn how to use an inbound policy to transform route parameter values before + the URL Rewrite handler forwards the request to your backend. +tags: + - custom-code + - backends +--- + +This guide explains how to transform incoming route parameter values in an +inbound policy before the [URL Rewrite handler](../handlers/url-rewrite.mdx) +uses them to build the upstream URL. This pattern is useful when your public API +paths use different naming conventions than your internal backend. + +## Overview + +When you use the URL Rewrite handler, it builds the upstream URL by +interpolating values like `${params.resourceType}` directly from the incoming +route parameters. Sometimes, however, you need to **change** those values before +the rewrite happens. Common scenarios include: + +- **Value mapping** — translating a public-facing parameter like `order` to an + internal value like `customerorder` +- **Case normalization** — converting `Products` to `products` before forwarding +- **Path translation** — mapping user-friendly slugs to internal identifiers + +The recommended approach is to read the route parameters in an inbound policy, +transform them, store the results on +[`context.custom`](../programmable-api/zuplo-context.mdx), and reference the +transformed values in the URL Rewrite pattern. + +## Step-by-Step Example + +The solution has three parts: an **inbound policy** that reads `request.params` +and stores transformed values on `context.custom`, a **URL Rewrite handler** +that references those values using `${context.custom.*}` in the +`rewritePattern`, and **route configuration** that wires the two together. + +Imagine your public API exposes a route like `/api/:resourceType/:resourceId`, +but your backend expects the resource type to be prefixed with `customer`. A +request to `/api/order/123` should be forwarded to +`https://backend.example.com/api/customerorder/123`. + +### 1. Write the Inbound Policy + +Create a custom inbound policy that reads the route parameters, transforms the +values, and stores them on `context.custom`: + +```ts title="modules/transform-params.ts" +import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; + +export default async function ( + request: ZuploRequest, + context: ZuploContext, + options: any, + policyName: string, +): Promise { + // Read the original route parameter + const resourceType = request.params.resourceType; + + // Transform the value — prefix with "customer" + const transformedResourceType = `customer${resourceType}`; + + // Store the transformed value on context.custom + context.custom.transformedResourceType = transformedResourceType; + + context.log.info({ + message: "Transformed route parameter", + original: resourceType, + transformed: transformedResourceType, + }); + + return request; +} +``` + +### 2. Register the Policy + +Add the policy to `config/policies.json`: + +```json title="config/policies.json" +{ + "policies": [ + { + "name": "transform-params", + "policyType": "custom-code-inbound", + "handler": { + "export": "default", + "module": "$import(./modules/transform-params)" + } + } + ] +} +``` + +### 3. Configure the Route + +Define the route in `config/routes.oas.json` with the inbound policy and a URL +Rewrite handler that references `context.custom`: + +```json title="config/routes.oas.json" +{ + "paths": { + "/api/{resourceType}/{resourceId}": { + "x-zuplo-path": { + "pathMode": "open-api" + }, + "get": { + "summary": "Get resource by type and ID", + "x-zuplo-route": { + "corsPolicy": "none", + "handler": { + "export": "urlRewriteHandler", + "module": "$import(@zuplo/runtime)", + "options": { + "rewritePattern": "https://backend.example.com/api/${context.custom.transformedResourceType}/${params.resourceId}" + } + }, + "policies": { + "inbound": ["transform-params"] + } + } + } + } + } +} +``` + +With this configuration, a request to `/api/order/123` flows through the +pipeline as follows: + +1. The route matches with `params.resourceType = "order"` and + `params.resourceId = "123"` +2. The `transform-params` inbound policy runs and sets + `context.custom.transformedResourceType = "customerorder"` +3. The URL Rewrite handler builds the upstream URL: + `https://backend.example.com/api/customerorder/123` + +## Common Pitfall: Modifying `request.params` Directly + +:::caution + +Do not try to transform route parameters by constructing a new `ZuploRequest` +with modified `params` and expecting the URL Rewrite handler to pick them up. + +::: + +A common first attempt is to create a new +[`ZuploRequest`](../programmable-api/zuplo-request.mdx) with different `params`: + +```ts +// ⚠️ This approach does NOT work as expected with URL Rewrite +const newRequest = new ZuploRequest(request, { + params: { + ...request.params, + resourceType: "customerorder", + }, +}); +return newRequest; +``` + +In practice, the URL Rewrite handler evaluates `${params.*}` against the +route-level parameters rather than the request object returned by a policy. This +means the rewritten URL may contain `undefined` segments instead of your +transformed values. Use `context.custom` for reliable interpolation of +transformed values — the URL Rewrite handler's `rewritePattern` fully supports +`${context.custom.*}`, and values set in an inbound policy are available when +the handler runs. + +## Variations + +### Using a Lookup Map + +For more complex mappings where the transformation is not a simple string +operation, use a lookup object: + +```ts title="modules/transform-params-map.ts" +import { ZuploContext, ZuploRequest, HttpProblems } from "@zuplo/runtime"; + +// Map public resource types to internal names +const RESOURCE_TYPE_MAP: Record = { + order: "customerorder", + invoice: "billing-invoice", + profile: "user-profile", + subscription: "recurring-plan", +}; + +export default async function ( + request: ZuploRequest, + context: ZuploContext, + options: any, + policyName: string, +): Promise { + const resourceType = request.params.resourceType; + const mappedType = RESOURCE_TYPE_MAP[resourceType]; + + if (!mappedType) { + return HttpProblems.notFound(request, context, { + detail: `Unknown resource type: ${resourceType}`, + }); + } + + context.custom.transformedResourceType = mappedType; + + return request; +} +``` + +### Transforming Multiple Parameters + +You can transform any number of route parameters and store each on +`context.custom`. Reference them individually in the rewrite pattern: + +```ts title="modules/transform-multiple-params.ts" +import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; + +export default async function ( + request: ZuploRequest, + context: ZuploContext, + options: any, + policyName: string, +): Promise { + // Normalize casing + context.custom.version = request.params.version?.toLowerCase(); + + // Map resource type + context.custom.resource = + request.params.resource === "users" ? "customers" : request.params.resource; + + return request; +} +``` + +Then use both values in the rewrite pattern: + +```json +{ + "rewritePattern": "https://backend.example.com/${context.custom.version}/${context.custom.resource}/${params.id}" +} +``` + +### Combining with Body Transformation + +If your API also needs to transform values in the request body alongside route +parameters, you can handle both in the same inbound policy. Create a new +`ZuploRequest` with a modified body while storing the route parameter +transformations on `context.custom`: + +```ts title="modules/transform-params-and-body.ts" +import { ZuploContext, ZuploRequest } from "@zuplo/runtime"; + +export default async function ( + request: ZuploRequest, + context: ZuploContext, + options: any, + policyName: string, +): Promise { + // Transform route parameter + context.custom.transformedResourceType = `customer${request.params.resourceType}`; + + // Transform the request body if present + if (request.headers.get("content-type")?.includes("application/json")) { + const body = await request.json(); + + // Map fields in the body to match the backend schema + const transformedBody = { + ...body, + type: context.custom.transformedResourceType, + }; + + // Return a new request with the modified body + return new ZuploRequest(request, { + body: JSON.stringify(transformedBody), + }); + } + + return request; +} +``` + +## Best Practices + +- **Use descriptive keys on `context.custom`** — names like + `context.custom.transformedResourceType` are easier to debug than generic keys + like `context.custom.value` +- **Log transformations** — use `context.log` to record original and transformed + values so you can trace issues in production +- **Validate before transforming** — return an appropriate error response (using + [`HttpProblems`](../programmable-api/http-problems.mdx)) if a parameter value + is unexpected, rather than forwarding bad data to your backend +- **Keep the policy focused** — if your transformation logic is complex, + consider splitting it into a separate utility module imported by the policy + +## Next Steps + +- [URL Rewrite Handler](../handlers/url-rewrite.mdx) — full reference for + rewrite patterns and available interpolation variables +- [Custom Code Patterns](../articles/custom-code-patterns.md) — common patterns + for writing inbound policies, outbound policies, and handlers +- [ZuploContext](../programmable-api/zuplo-context.mdx) — reference for + `context.custom` and other context properties +- [ZuploRequest](../programmable-api/zuplo-request.mdx) — reference for + `request.params` and constructing new requests +- [User-Based Backend Routing](./user-based-backend-routing.mdx) — a related + pattern using `context.custom` with URL Rewrite for routing by user identity diff --git a/sidebar.ts b/sidebar.ts index d370e7d71..283cb80f2 100644 --- a/sidebar.ts +++ b/sidebar.ts @@ -393,6 +393,7 @@ export const documentation: Navigation = [ "guides/canary-routing-for-employees", "guides/geolocation-backend-routing", "guides/user-based-backend-routing", + "guides/transform-route-params-url-rewrite", "articles/bypass-policy-for-testing", "articles/testing-graphql", "articles/health-checks",