Files
apophis-fastify/src/domain/request-builder.ts
T

220 lines
7.5 KiB
TypeScript
Raw Normal View History

/**
* 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<string, string>
query?: Record<string, string>
body?: unknown
contentType?: string
multipart?: {
fields: Record<string, unknown>
files: Record<string, { originalname: string; mimetype: string; size: number; buffer: Buffer } | Array<{ originalname: string; mimetype: string; size: number; buffer: Buffer }>>
}
}
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<string, unknown>,
bodySchema: Record<string, unknown>
): Record<string, unknown> => {
const properties = bodySchema.properties as Record<string, Record<string, unknown>> | undefined
if (!properties) return data
const body: Record<string, unknown> = {}
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<string, unknown>,
querySchema: Record<string, unknown>
): Record<string, string> => {
const properties = querySchema.properties as Record<string, Record<string, unknown>> | undefined
if (!properties) return {}
const query: Record<string, string> = {}
for (const key of Object.keys(properties)) {
if (key in data) {
query[key] = String(data[key])
}
}
return query
}
const extractRemainingParams = (
data: Record<string, unknown>,
pathParams: string[],
body?: Record<string, unknown>
): Record<string, string> => {
const usedKeys = new Set(pathParams)
if (body) {
Object.keys(body).forEach((k) => usedKeys.add(k))
}
const query: Record<string, string> = {}
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<string, unknown>,
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<string, string>,
data: Record<string, unknown>,
_state: ModelState
): Record<string, string> => {
const headers: Record<string, string> = { ...scopeHeaders }
// Content-Type for body requests
if (route.schema?.body) {
headers['content-type'] = CONTENT_TYPE.JSON
}
return headers
}
const getString = (obj: Record<string, unknown>, key: string): string | undefined => {
const val = obj[key]
return typeof val === 'string' ? val : undefined
}
export const buildRequest = (
route: RouteContract,
generatedData: Record<string, unknown>,
scopeHeaders: Record<string, string>,
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<string, unknown> | undefined
// Check for multipart
const isMultipart = bodySchema && getString(bodySchema, 'x-content-type') === CONTENT_TYPE.MULTIPART
if (isMultipart) {
const multipartData = (generatedData ?? {}) as Record<string, unknown>
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<string, unknown>,
files: (files ?? {}) as NonNullable<RequestStructure['multipart']>['files'],
},
contentType: CONTENT_TYPE.MULTIPART,
}
}
const body = bodySchema
? extractBodyParams(generatedData, bodySchema)
: undefined
// Extract query params from schema
const querySchema = route.schema?.querystring as Record<string, unknown> | 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<string, unknown> => {
const routeSegments = routePath.split('/').filter(Boolean)
const urlSegments = url.split('/').filter(Boolean)
const params: Record<string, unknown> = {}
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
}