chore: crush git history - reborn from consolidation on 2026-03-10
This commit is contained in:
@@ -0,0 +1,219 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
Reference in New Issue
Block a user