220 lines
7.5 KiB
TypeScript
220 lines
7.5 KiB
TypeScript
/**
|
|
* 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
|
|
}
|