/** * Request Builder — Schema-aware request construction with path/body/query/header discrimination */ import type { ResourceHierarchy, ModelState } from './stateful.js' import { SeededRng } from '../infrastructure/seeded-rng.js' import { CONTENT_TYPE } from '../infrastructure/http-executor.js' import type { RouteContract } from '../types.js' export interface RequestStructure { method: string url: string headers: Record query?: Record body?: unknown contentType?: string multipart?: { fields: Record files: Record> } } export const parseRouteParams = (path: string): string[] => { const params: string[] = [] const segments = path.split('/') for (const segment of segments) { if (segment.startsWith(':')) { params.push(segment.slice(1)) } else if (segment.startsWith('{') && segment.endsWith('}')) { params.push(segment.slice(1, -1)) } } return params } const extractBodyParams = ( data: Record, bodySchema: Record ): Record => { const properties = bodySchema.properties as Record> | undefined if (!properties) return data const body: Record = {} for (const key of Object.keys(properties)) { if (key in data) { const propSchema = properties[key] if (propSchema?.type === 'object' && propSchema.properties) { body[key] = extractBodyParams(data, propSchema) } else { body[key] = data[key] } } } return body } const extractQueryParams = ( data: Record, querySchema: Record ): Record => { const properties = querySchema.properties as Record> | undefined if (!properties) return {} const query: Record = {} for (const key of Object.keys(properties)) { if (key in data) { query[key] = String(data[key]) } } return query } const extractRemainingParams = ( data: Record, pathParams: string[], body?: Record ): Record => { const usedKeys = new Set(pathParams) if (body) { Object.keys(body).forEach((k) => usedKeys.add(k)) } const query: Record = {} for (const [key, value] of Object.entries(data)) { if (!usedKeys.has(key)) { query[key] = String(value) } } return query } const PARAM_PATTERN = /:([a-zA-Z_][a-zA-Z0-9_]*)/g const validateParamName = (paramName: string): void => { if (!/^[a-zA-Z_][a-zA-Z0-9_]*$/.test(paramName)) { throw new Error(`Invalid path parameter name: ${paramName}. Must match /^[a-zA-Z_][a-zA-Z0-9_]*$/`) } } /** * Infer resource type from path parameter name. * Supports patterns like: tenantId → tenant, user_id → user, id → resource */ const inferResourceTypeFromParam = (param: string): string | null => { // Pattern: tenantId, projectId, userId → tenant, project, user if (param.endsWith('Id')) { return param.replace(/Id$/, '').toLowerCase() } // Pattern: tenant_id, user_id → tenant, user if (param.endsWith('_id')) { return param.replace(/_id$/, '').toLowerCase() } // Pattern: just 'id' — infer from context or return null if (param === 'id') { return null // Can't infer from just 'id' } return null } const sanitizeParamValue = (value: unknown): string => { const str = String(value) // URL-encode the value to prevent injection return encodeURIComponent(str) } const substitutePathParams = ( path: string, data: Record, state: ModelState, rng?: SeededRng ): string => { let url = path const pathParams = parseRouteParams(path) for (const param of pathParams) { // Validate param name against whitelist validateParamName(param) let value = data[param] // If param is an ID reference, try to find it in state if (value === undefined) { // Try various patterns: tenantId, tenant_id, id, userId, etc. const resourceType = inferResourceTypeFromParam(param) if (resourceType) { const resources = state.resources.get(resourceType) if (resources && resources.size > 0) { const ids = Array.from(resources.keys()) value = rng ? rng.pick(ids) : ids[0] // Deterministic fallback: use first ID } } } if (value !== undefined) { // Sanitize value before substitution to prevent injection url = url.replace(`:${param}`, sanitizeParamValue(value)) } } return url } const buildHeaders = ( route: RouteContract, scopeHeaders: Record, data: Record, _state: ModelState ): Record => { const headers: Record = { ...scopeHeaders } // Content-Type for body requests if (route.schema?.body) { headers['content-type'] = CONTENT_TYPE.JSON } return headers } const getString = (obj: Record, key: string): string | undefined => { const val = obj[key] return typeof val === 'string' ? val : undefined } export const buildRequest = ( route: RouteContract, generatedData: Record, scopeHeaders: Record, state: ModelState, rng?: SeededRng ): RequestStructure => { const url = substitutePathParams(route.path, generatedData, state, rng) // Extract body params from schema const bodySchema = route.schema?.body as Record | undefined // Check for multipart const isMultipart = bodySchema && getString(bodySchema, 'x-content-type') === CONTENT_TYPE.MULTIPART if (isMultipart) { const multipartData = (generatedData ?? {}) as Record const headers = buildHeaders(route, scopeHeaders, generatedData, state) headers['content-type'] = CONTENT_TYPE.MULTIPART const files = multipartData['files'] const fields = multipartData['fields'] return { method: route.method, url, headers, query: extractRemainingParams(generatedData, parseRouteParams(route.path)), multipart: { fields: (fields ?? {}) as Record, files: (files ?? {}) as NonNullable['files'], }, contentType: CONTENT_TYPE.MULTIPART, } } const body = bodySchema ? extractBodyParams(generatedData, bodySchema) : undefined // Extract query params from schema const querySchema = route.schema?.querystring as Record | undefined const query = querySchema ? extractQueryParams(generatedData, querySchema) : extractRemainingParams(generatedData, parseRouteParams(route.path), body) // Build headers const headers = buildHeaders(route, scopeHeaders, generatedData, state) // Determine content type const contentType = body ? CONTENT_TYPE.JSON : undefined return { method: route.method, url, headers, query, body, contentType } } export const extractPathParams = (routePath: string, url: string): Record => { const routeSegments = routePath.split('/').filter(Boolean) const urlSegments = url.split('/').filter(Boolean) const params: Record = {} for (let i = 0; i < routeSegments.length; i++) { const routeSeg = routeSegments[i] const urlSeg = urlSegments[i] if (routeSeg?.startsWith(':')) { params[routeSeg.slice(1)] = urlSeg } else if (routeSeg?.startsWith('{') && routeSeg.endsWith('}')) { params[routeSeg.slice(1, -1)] = urlSeg } } return params }