Loading learning content...
The API gateway sits at the boundary between clients and backend services—a boundary where formats, protocols, and contracts often don't align. Clients might send requests that backends can't understand directly. Legacy services might expect different header formats. New APIs might need to maintain backward compatibility with old clients. Request transformation is the gateway capability that bridges these gaps.
In production systems, request transformation goes far beyond simple header manipulation. It encompasses:
This page provides a Principal Engineer's deep dive into request transformation: the mechanics, the patterns, and the production considerations that make gateway transformation reliable and maintainable.
By the end of this page, you will master header manipulation (add, remove, modify), URL and path rewriting strategies, body transformation patterns including JSON-to-JSON mapping, protocol translation between REST and gRPC, request enrichment with external data, and production-grade transformation configurations.
Header manipulation is the most common form of request transformation. Gateways routinely add, remove, and modify headers as requests pass through.
Common Header Operations:
Use Cases for Header Manipulation:
| Operation | Example | Purpose |
|---|---|---|
| Add | X-Request-ID: uuid | Distributed tracing correlation |
| Add | X-Forwarded-For: client-ip | Preserve original client IP |
| Add | X-Authenticated-User: user-id | Pass identity to backends |
| Remove | Authorization (after validation) | Don't leak tokens to backends |
| Remove | Cookie (specific cookies) | Privacy, reduce payload size |
| Remove | X-Debug (production) | Disable debug features |
| Modify | Host: internal-service | Route to internal hostnames |
| Modify | Accept-Encoding: identity | Disable compression to backend |
| Rename | X-API-Key → X-Internal-API-Key | Normalize header names |
12345678910111213141516171819202122232425262728293031323334353637383940414243
# Envoy header manipulation configurationroutes: - match: prefix: "/api" route: cluster: backend-service request_headers_to_add: # Add static headers - header: key: "X-Gateway-Version" value: "1.2.3" append: false # Replace if exists # Add dynamic headers using Envoy substitutions - header: key: "X-Request-Start-Time" value: "%START_TIME%" append: false # Add downstream connection info - header: key: "X-Forwarded-For" value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" append: true # Append to existing request_headers_to_remove: # Security: remove internal headers that clients shouldn't set - "x-internal-user-id" - "x-admin-override" # Privacy: remove tracking headers - "x-analytics-id" # Response headers can also be manipulated response_headers_to_add: - header: key: "X-Served-By" value: "%UPSTREAM_HOST%" - header: key: "X-Response-Time" value: "%RESPONSE_DURATION%" response_headers_to_remove: - "server" # Don't leak internal server info - "x-powered-by"Header operations execute in a specific order: removes, then renames, then adds, then modifies. If you remove 'Authorization' and then try to add 'X-Original-Auth' based on it, the value is already gone. Plan your transformation pipeline carefully.
After authentication and authorization, the gateway possesses rich context about the request: the authenticated user, their permissions, the tenant, the geographic region, etc. Context injection makes this information available to backend services without requiring them to re-validate tokens or query identity systems.
The Context Propagation Pattern:
Client Request
→ Gateway: Authentication (JWT validation)
→ Gateway: Authorization (permission check)
→ Gateway: Context extraction (user ID, roles, tenant)
→ Gateway: Header injection (X-User-ID, X-Roles, X-Tenant-ID)
→ Backend: Trusts gateway-injected headers
Critical Security Model:
This pattern requires a trust boundary:
If clients can reach backends directly (bypassing the gateway), this model breaks—backends must not trust internal headers from arbitrary sources.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120
// Gateway middleware for context injectionimport jwt from 'jsonwebtoken'; interface UserContext { userId: string; tenantId: string; roles: string[]; permissions: string[]; email: string; sessionId: string;} interface GatewayRequest { headers: Map<string, string>; path: string; method: string;} /** * Extract and validate user context from JWT */async function extractUserContext(token: string): Promise<UserContext | null> { try { // Verify JWT signature and expiration const decoded = jwt.verify(token, process.env.JWT_PUBLIC_KEY!, { algorithms: ['RS256'], issuer: 'auth.example.com', }) as jwt.JwtPayload; return { userId: decoded.sub!, tenantId: decoded.tenant_id, roles: decoded.roles || [], permissions: decoded.permissions || [], email: decoded.email, sessionId: decoded.session_id, }; } catch (error) { return null; }} /** * Inject context headers into the request */function injectContextHeaders( request: GatewayRequest, context: UserContext): void { // First, remove any client-provided internal headers // This prevents header spoofing attacks const internalHeaderPrefixes = ['x-internal-', 'x-user-', 'x-tenant-', 'x-auth-']; for (const [key] of request.headers) { const lowerKey = key.toLowerCase(); if (internalHeaderPrefixes.some(prefix => lowerKey.startsWith(prefix))) { request.headers.delete(key); } } // Inject authenticated context request.headers.set('X-User-ID', context.userId); request.headers.set('X-Tenant-ID', context.tenantId); request.headers.set('X-User-Roles', context.roles.join(',')); request.headers.set('X-User-Email', context.email); request.headers.set('X-Session-ID', context.sessionId); // Inject permissions as JSON (for complex permission sets) request.headers.set( 'X-User-Permissions', Buffer.from(JSON.stringify(context.permissions)).toString('base64') ); // Mark request as gateway-authenticated // Backends can check this to ensure request came through gateway request.headers.set('X-Gateway-Auth', signGatewayHeader(context));} /** * Create HMAC signature proving gateway origin * Backends verify this to ensure request came through gateway */function signGatewayHeader(context: UserContext): string { const payload = `${context.userId}:${context.sessionId}:${Date.now()}`; const signature = createHmac('sha256', process.env.GATEWAY_SECRET!) .update(payload) .digest('hex'); return `${payload}:${signature}`;} // Full middleware exampleasync function authMiddleware( request: GatewayRequest, next: () => Promise<Response>): Promise<Response> { // Extract token const authHeader = request.headers.get('authorization'); if (!authHeader?.startsWith('Bearer ')) { // No auth - mark as anonymous request.headers.set('X-Auth-Status', 'anonymous'); return next(); } const token = authHeader.slice(7); const context = await extractUserContext(token); if (!context) { return new Response('Unauthorized', { status: 401 }); } // Inject context and continue injectContextHeaders(request, context); request.headers.set('X-Auth-Status', 'authenticated'); // Remove original Authorization header (optional) // Prevents token from reaching backend request.headers.delete('authorization'); return next();}To ensure backends only trust requests from the gateway, include a signed header (e.g., X-Gateway-Signature) using a shared secret. Backends verify this signature before trusting injected headers. This prevents attacks where malicious internal services spoof identity headers.
URL rewriting transforms the request path before forwarding to backends. This enables:
Rewriting Strategies:
1. Prefix Stripping
Public: /api/v2/users/123
Internal: /users/123
Rule: Strip /api/v2
2. Prefix Addition
Public: /users/123
Internal: /internal/accounts/users/123
Rule: Prepend /internal/accounts
3. Regex-Based Transformation
Public: /api/v2/users/123/profile
Internal: /v2/users/123?section=profile
Rule: Capture groups + substitution
4. Complete Path Replacement
Public: /health
Internal: /actuator/health
Rule: Replace entire path
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647
# Envoy URL rewriting examplesroutes: # Simple prefix rewrite - match: prefix: "/api/v2/users" route: cluster: user-service prefix_rewrite: "/users" # /api/v2/users/123 → /users/123 # Regex-based rewrite - match: safe_regex: google_re2: {} regex: "^/api/v([0-9]+)/(.*)$" route: cluster: versioned-api regex_rewrite: pattern: google_re2: {} regex: "^/api/v([0-9]+)/(.*)$" substitution: "/\2?api_version=\1" # /api/v2/users/123 → /users/123?api_version=2 # Host rewrite (change Host header) - match: prefix: "/external-service" route: cluster: external-api host_rewrite_literal: "api.external-service.com" prefix_rewrite: "/v1" # Also rewrites Host header # Auto host rewrite (use cluster's hostname) - match: prefix: "/internal" route: cluster: internal-service auto_host_rewrite: true # Host header set to cluster's address # Path redirect (301/302 instead of proxy) - match: path: "/old-endpoint" redirect: path_redirect: "/new-endpoint" response_code: MOVED_PERMANENTLYQuery String Handling:
When rewriting URLs, query strings require special consideration:
Original: /api/users?filter=active&page=2
Rewrite: /users?filter=active&page=2 (preserve query string)
/users (strip query string - usually wrong)
/users?version=2&filter=active&page=2 (merge query params)
Most gateways preserve query strings by default, but verify this behavior—losing query parameters is a common bug.
In non-production environments, inject debug headers showing the original and rewritten paths: X-Original-Path: /api/v2/users/123 and X-Rewritten-Path: /users/123. This simplifies debugging routing issues without examining gateway logs.
Body transformation modifies the request payload before forwarding. This is more complex and resource-intensive than header manipulation, but essential for:
Performance Considerations:
Body transformation requires:
For high-throughput endpoints, prefer streaming (no transformation) or limit transformation to small payloads.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135
// Body transformation patternsimport { XMLParser, XMLBuilder } from 'fast-xml-parser'; interface TransformationRule { type: 'rename' | 'remove' | 'add' | 'map' | 'default'; source?: string; // JSONPath or field name target?: string; // Target field name value?: any; // Static value (for 'add' or 'default') transform?: (value: any) => any; // Custom transform function} /** * Apply field-level transformations to a JSON object */function transformJsonBody( body: Record<string, any>, rules: TransformationRule[]): Record<string, any> { const result = { ...body }; for (const rule of rules) { switch (rule.type) { case 'rename': if (rule.source && rule.target && rule.source in result) { result[rule.target] = result[rule.source]; delete result[rule.source]; } break; case 'remove': if (rule.source) { delete result[rule.source]; } break; case 'add': if (rule.target && rule.value !== undefined) { result[rule.target] = rule.value; } break; case 'map': if (rule.source && rule.target && rule.transform) { const sourceValue = result[rule.source]; result[rule.target] = rule.transform(sourceValue); } break; case 'default': if (rule.target && !(rule.target in result)) { result[rule.target] = rule.value; } break; } } return result;} /** * Convert JSON to XML for legacy backend */function jsonToXml(json: Record<string, any>, rootElement: string): string { const builder = new XMLBuilder({ ignoreAttributes: false, format: true, }); const wrapped = { [rootElement]: json }; return builder.build(wrapped);} /** * Convert XML to JSON for modern clients */function xmlToJson(xml: string): Record<string, any> { const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: '@_', }); return parser.parse(xml);} // Example: API version migration transformconst v1ToV2Rules: TransformationRule[] = [ // Rename fields to new schema { type: 'rename', source: 'userName', target: 'username' }, { type: 'rename', source: 'emailAddress', target: 'email' }, // Remove deprecated fields { type: 'remove', source: 'legacyId' }, // Add required new fields with defaults { type: 'default', target: 'version', value: 2 }, // Transform nested structure { type: 'map', source: 'address', target: 'location', transform: (addr) => ({ street: addr?.street, city: addr?.city, country: addr?.countryCode || 'US', coordinates: null, // New field }), },]; // Middleware exampleasync function bodyTransformMiddleware( request: Request, rules: TransformationRule[]): Promise<Request> { const contentType = request.headers.get('content-type'); if (!contentType?.includes('application/json')) { return request; // Only transform JSON } try { const body = await request.json(); const transformed = transformJsonBody(body, rules); return new Request(request.url, { method: request.method, headers: request.headers, body: JSON.stringify(transformed), }); } catch (error) { // Invalid JSON - return original or error return request; }}Body transformation requires buffering the entire request in memory. For file uploads or large payloads, this can exhaust gateway memory. Set maximum body size limits and consider bypassing transformation for large requests (stream directly to backend).
Protocol translation bridges different API paradigms. The gateway accepts requests in one protocol and converts them to another for the backend.
Common Translation Scenarios:
| Client Protocol | Backend Protocol | Use Case |
|---|---|---|
| REST (JSON) | gRPC (Protobuf) | Modern clients → efficient backends |
| REST (JSON) | SOAP (XML) | Modern clients → legacy services |
| GraphQL | REST | Unified query interface → multiple REST APIs |
| REST | WebSocket | Request-response → streaming service |
| HTTP/1.1 | HTTP/2 | Legacy clients → modern infrastructure |
REST to gRPC Translation (gRPC-Gateway Pattern):
This is the most common translation scenario in modern microservices. The gateway:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576
// Protocol translation using gRPC-Gateway annotationssyntax = "proto3"; package api.v1; import "google/api/annotations.proto";import "google/protobuf/timestamp.proto"; // User service with REST mappingsservice UserService { // GET /users/{user_id} → GetUser RPC rpc GetUser(GetUserRequest) returns (User) { option (google.api.http) = { get: "/api/v1/users/{user_id}" }; } // POST /users → CreateUser RPC rpc CreateUser(CreateUserRequest) returns (User) { option (google.api.http) = { post: "/api/v1/users" body: "*" }; } // PUT /users/{user.user_id} → UpdateUser RPC rpc UpdateUser(UpdateUserRequest) returns (User) { option (google.api.http) = { put: "/api/v1/users/{user.user_id}" body: "user" }; } // GET /users → ListUsers RPC (with query params) rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) { option (google.api.http) = { get: "/api/v1/users" // Query params: ?page_size=10&page_token=xxx }; } // DELETE /users/{user_id} → DeleteUser RPC rpc DeleteUser(DeleteUserRequest) returns (DeleteUserResponse) { option (google.api.http) = { delete: "/api/v1/users/{user_id}" }; }} message User { string user_id = 1; string email = 2; string name = 3; google.protobuf.Timestamp created_at = 4;} message GetUserRequest { string user_id = 1;} message CreateUserRequest { string email = 1; string name = 2; string password = 3; // Only in request, not response} message ListUsersRequest { int32 page_size = 1; string page_token = 2; string filter = 3; // e.g., "status=active"} message ListUsersResponse { repeated User users = 1; string next_page_token = 2;}Browsers can't speak native gRPC (due to HTTP/2 trailer limitations). gRPC-Web is a modified protocol that browsers support. The gateway can translate between gRPC-Web (from browsers) and native gRPC (to backends), enabling browser clients to consume gRPC services.
Error Code Translation:
Protocols have different error semantics. The gateway must translate:
| gRPC Code | HTTP Status | REST Meaning |
|---|---|---|
| OK | 200 | Success |
| INVALID_ARGUMENT | 400 | Bad Request |
| NOT_FOUND | 404 | Resource Not Found |
| ALREADY_EXISTS | 409 | Conflict |
| PERMISSION_DENIED | 403 | Forbidden |
| UNAUTHENTICATED | 401 | Unauthorized |
| RESOURCE_EXHAUSTED | 429 | Too Many Requests |
| UNAVAILABLE | 503 | Service Unavailable |
| INTERNAL | 500 | Internal Server Error |
Request enrichment adds data from external sources to the request before forwarding. The gateway fetches data from other services, databases, or caches, and includes it in the request.
Common Enrichment Patterns:
Example: User Profile Enrichment
Client Request:
POST /orders
Authorization: Bearer <token>
Body: { "product_id": "123", "quantity": 2 }
Gateway Enrichment:
1. Validate token → extract user_id
2. Fetch user profile from user-service
3. Fetch user's credit limit from billing-service
Enriched Request to order-service:
POST /orders
X-User-ID: user-456
X-User-Tier: premium
X-Credit-Limit: 10000
X-Currency: USD
Body: { "product_id": "123", "quantity": 2 }
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
// Request enrichment middleware with cachinginterface EnrichmentSource { name: string; fetch: (context: RequestContext) => Promise<Record<string, string>>; cache?: { ttlSeconds: number; keyGenerator: (context: RequestContext) => string; };} interface RequestContext { userId?: string; tenantId?: string; ip: string; path: string; headers: Map<string, string>;} class RequestEnricher { private cache: Map<string, { data: Record<string, string>; expiresAt: number }> = new Map(); private sources: EnrichmentSource[] = []; addSource(source: EnrichmentSource): void { this.sources.push(source); } async enrich(request: GatewayRequest, context: RequestContext): Promise<void> { const enrichmentPromises = this.sources.map(async (source) => { try { let data: Record<string, string>; // Check cache if configured if (source.cache) { const cacheKey = source.cache.keyGenerator(context); const cached = this.cache.get(cacheKey); if (cached && cached.expiresAt > Date.now()) { data = cached.data; } else { data = await source.fetch(context); this.cache.set(cacheKey, { data, expiresAt: Date.now() + source.cache.ttlSeconds * 1000, }); } } else { data = await source.fetch(context); } // Inject as headers for (const [key, value] of Object.entries(data)) { request.headers.set(key, value); } } catch (error) { // Log but don't fail request on enrichment error console.error(`Enrichment source ${source.name} failed:`, error); // Optionally inject failure indicator request.headers.set(`X-Enrichment-${source.name}-Failed`, 'true'); } }); // Execute enrichments in parallel await Promise.all(enrichmentPromises); }} // Configurationconst enricher = new RequestEnricher(); // User profile enrichment (cached)enricher.addSource({ name: 'user-profile', fetch: async (ctx) => { if (!ctx.userId) return {}; const profile = await fetch(`http://user-service/users/${ctx.userId}`) .then(r => r.json()); return { 'X-User-Name': profile.name, 'X-User-Tier': profile.tier, 'X-User-Created': profile.createdAt, }; }, cache: { ttlSeconds: 300, // 5 min cache keyGenerator: (ctx) => `user:${ctx.userId}`, },}); // Feature flags enrichmentenricher.addSource({ name: 'feature-flags', fetch: async (ctx) => { const flags = await fetch( `http://feature-service/flags?user=${ctx.userId}&tenant=${ctx.tenantId}` ).then(r => r.json()); return { 'X-Features': Buffer.from(JSON.stringify(flags)).toString('base64'), }; }, cache: { ttlSeconds: 60, // 1 min cache keyGenerator: (ctx) => `flags:${ctx.userId}:${ctx.tenantId}`, },}); // Geo location enrichmentenricher.addSource({ name: 'geo', fetch: async (ctx) => { const geo = await geoLookup(ctx.ip); return { 'X-Geo-Country': geo.country, 'X-Geo-Region': geo.region, 'X-Geo-City': geo.city, 'X-Geo-Timezone': geo.timezone, }; }, cache: { ttlSeconds: 86400, // 24 hour cache (IPs rarely move) keyGenerator: (ctx) => `geo:${ctx.ip}`, },});Each enrichment source adds latency to the request path. Execute enrichments in parallel, use aggressive caching, set strict timeouts, and consider making enrichment failures non-fatal (proceed without enriched data). The gateway should never be slower than calling backends directly.
Request transformation in production requires careful attention to reliability, performance, and observability:
1. Transformation Failures
What happens when transformation fails?
Document and test failure behavior for each transformation.
2. Performance Budgeting
Allocate time budgets for transformation:
Monitor P99 transformation latency and alert on budget violations.
3. Testing Transformations
Transformations are code—they need testing:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849
// Transformation observability wrapperinterface TransformationMetrics { incrementCounter(name: string, tags: Record<string, string>): void; recordHistogram(name: string, value: number, tags: Record<string, string>): void;} function withTransformObservability<T>( transformName: string, transform: () => Promise<T>, metrics: TransformationMetrics): Promise<T> { const startTime = performance.now(); const tags = { transform: transformName }; return transform() .then((result) => { const duration = performance.now() - startTime; metrics.incrementCounter('transformation_success_total', tags); metrics.recordHistogram('transformation_duration_ms', duration, tags); if (duration > 50) { // Log slow transformations console.warn(`Slow transformation ${transformName}: ${duration.toFixed(2)}ms`); } return result; }) .catch((error) => { const duration = performance.now() - startTime; metrics.incrementCounter('transformation_failure_total', { ...tags, error_type: error.constructor.name, }); metrics.recordHistogram('transformation_duration_ms', duration, tags); console.error(`Transformation ${transformName} failed after ${duration.toFixed(2)}ms:`, error); throw error; });} // Usageconst enrichedRequest = await withTransformObservability( 'user-profile-enrichment', () => enrichUserProfile(request), metricsClient);Request transformation is the gateway capability that adapts, enriches, and translates requests to bridge the gap between clients and backend services. Mastering transformation enables flexible API evolution, security hardening, and seamless protocol bridging.
You now understand request transformation comprehensively—from simple header manipulation to complex protocol translation and enrichment patterns. The next page covers traffic splitting, which combines routing with weighted distribution for canary deployments, blue-green releases, and A/B testing at scale.