|
| 1 | +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. |
| 2 | + |
| 3 | +/** |
| 4 | + * `/data` domain — extracted dispatcher body (ADR-0076 D11 step ③, PR-10, |
| 5 | + * the terminal cut). CRUD + query over `callData` (protocol-first with |
| 6 | + * ObjectQL fallback, ADR-0049 exposure gate inside). On a multi-tenant |
| 7 | + * host (a KernelResolver is registered) an unresolved environment answers |
| 8 | + * 428 instead of silently serving the control plane. |
| 9 | + */ |
| 10 | + |
| 11 | +import * as actionExec from '../action-execution.js'; |
| 12 | +import type { HttpProtocolContext, HttpDispatcherResult } from '../http-dispatcher.js'; |
| 13 | +import type { DomainHandlerDeps, DomainRoute } from '../domain-handler-registry.js'; |
| 14 | + |
| 15 | +export function createDataDomain(deps: DomainHandlerDeps): DomainRoute { |
| 16 | + return { |
| 17 | + prefix: '/data', |
| 18 | + handler: (req, context) => |
| 19 | + handleDataRequest(deps, req.path.substring(5), req.method, req.body, req.query, context), |
| 20 | + }; |
| 21 | +} |
| 22 | + |
| 23 | +/** |
| 24 | + * Handles Data requests |
| 25 | + * path: sub-path after /data/ (e.g. "contacts", "contacts/123", "contacts/query") |
| 26 | + */ |
| 27 | +export async function handleDataRequest(deps: DomainHandlerDeps, path: string, method: string, body: any, query: any, _context: HttpProtocolContext): Promise<HttpDispatcherResult> { |
| 28 | + const parts = path.replace(/^\/+/, '').split('/'); |
| 29 | + const objectName = parts[0]; |
| 30 | + |
| 31 | + if (!objectName) { |
| 32 | + return { handled: true, response: deps.error('Object name required', 400) }; |
| 33 | + } |
| 34 | + |
| 35 | + // Check if environment is resolved for data-plane requests. A |
| 36 | + // registered KernelResolver marks this host as multi-tenant (ADR-0006 |
| 37 | + // Phase 5 — previously signalled by the env-registry service): a |
| 38 | + // data-plane request that the resolver did not attach to an |
| 39 | + // environment must not silently fall through to the host kernel. |
| 40 | + if (!_context.dataDriver && deps.isMultiTenantHost()) { |
| 41 | + return { |
| 42 | + handled: true, |
| 43 | + response: deps.error('Project not resolved. Please specify X-Environment-Id header or ensure hostname maps to a project.', 428) |
| 44 | + }; |
| 45 | + } |
| 46 | + |
| 47 | + const m = method.toUpperCase(); |
| 48 | + |
| 49 | + // 1. Custom Actions (query, batch) |
| 50 | + if (parts.length > 1) { |
| 51 | + const action = parts[1]; |
| 52 | + |
| 53 | + // POST /data/:object/query |
| 54 | + if (action === 'query' && m === 'POST') { |
| 55 | + // Spec: returns FindDataResponse = { object, records, total?, hasMore? } |
| 56 | + const result = await actionExec.callData(deps, 'query', { object: objectName, ...body }, _context.dataDriver, _context.environmentId, _context.executionContext); |
| 57 | + return { handled: true, response: deps.success(result) }; |
| 58 | + } |
| 59 | + |
| 60 | + // GET /data/:object/:id |
| 61 | + if (parts.length === 2 && m === 'GET') { |
| 62 | + const id = parts[1]; |
| 63 | + // Spec: Only select/expand are allowlisted query params for GET by ID. |
| 64 | + // All other query parameters are discarded to prevent parameter pollution. |
| 65 | + const { select, expand } = query || {}; |
| 66 | + const allowedParams: Record<string, unknown> = {}; |
| 67 | + if (select != null) allowedParams.select = select; |
| 68 | + if (expand != null) allowedParams.expand = expand; |
| 69 | + // Spec: returns GetDataResponse = { object, id, record } |
| 70 | + const result = await actionExec.callData(deps, 'get', { object: objectName, id, ...allowedParams }, _context.dataDriver, _context.environmentId, _context.executionContext); |
| 71 | + return { handled: true, response: deps.success(result) }; |
| 72 | + } |
| 73 | + |
| 74 | + // PATCH /data/:object/:id |
| 75 | + if (parts.length === 2 && m === 'PATCH') { |
| 76 | + const id = parts[1]; |
| 77 | + // Spec: returns UpdateDataResponse = { object, id, record } |
| 78 | + const result = await actionExec.callData(deps, 'update', { object: objectName, id, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); |
| 79 | + return { handled: true, response: deps.success(result) }; |
| 80 | + } |
| 81 | + |
| 82 | + // DELETE /data/:object/:id |
| 83 | + if (parts.length === 2 && m === 'DELETE') { |
| 84 | + const id = parts[1]; |
| 85 | + // Spec: returns DeleteDataResponse = { object, id, deleted } |
| 86 | + const result = await actionExec.callData(deps, 'delete', { object: objectName, id }, _context.dataDriver, _context.environmentId, _context.executionContext); |
| 87 | + return { handled: true, response: deps.success(result) }; |
| 88 | + } |
| 89 | + } else { |
| 90 | + // GET /data/:object (List) |
| 91 | + if (m === 'GET') { |
| 92 | + // ── Normalize HTTP transport params → Spec canonical (QueryAST) ── |
| 93 | + // HTTP GET query params use transport-level names (filter, sort, top, |
| 94 | + // skip, select, expand) which are normalized here to canonical |
| 95 | + // QueryAST field names (where, orderBy, limit, offset, fields, |
| 96 | + // expand) before forwarding to the data service layer. |
| 97 | + // The protocol.ts findData() method performs a deeper normalization |
| 98 | + // pass, but pre-normalizing here ensures the data service always receives |
| 99 | + // Spec-canonical keys. |
| 100 | + const normalized: Record<string, unknown> = { ...query }; |
| 101 | + |
| 102 | + // filter/filters → where |
| 103 | + // Note: `filter` is the canonical HTTP *transport* parameter name |
| 104 | + // (see HttpFindQueryParamsSchema). It is normalized here to the |
| 105 | + // canonical *QueryAST* field name `where` before data dispatch. |
| 106 | + // `filters` (plural) is a deprecated alias for `filter`. |
| 107 | + if (normalized.filter != null || normalized.filters != null) { |
| 108 | + normalized.where = normalized.where ?? normalized.filter ?? normalized.filters; |
| 109 | + delete normalized.filter; |
| 110 | + delete normalized.filters; |
| 111 | + } |
| 112 | + // select → fields |
| 113 | + if (normalized.select != null && normalized.fields == null) { |
| 114 | + normalized.fields = normalized.select; |
| 115 | + delete normalized.select; |
| 116 | + } |
| 117 | + // sort → orderBy |
| 118 | + if (normalized.sort != null && normalized.orderBy == null) { |
| 119 | + normalized.orderBy = normalized.sort; |
| 120 | + delete normalized.sort; |
| 121 | + } |
| 122 | + // top → limit |
| 123 | + if (normalized.top != null && normalized.limit == null) { |
| 124 | + normalized.limit = normalized.top; |
| 125 | + delete normalized.top; |
| 126 | + } |
| 127 | + // skip → offset |
| 128 | + if (normalized.skip != null && normalized.offset == null) { |
| 129 | + normalized.offset = normalized.skip; |
| 130 | + delete normalized.skip; |
| 131 | + } |
| 132 | + |
| 133 | + // Spec: returns FindDataResponse = { object, records, total?, hasMore? } |
| 134 | + const result = await actionExec.callData(deps, 'query', { object: objectName, query: normalized }, _context.dataDriver, _context.environmentId, _context.executionContext); |
| 135 | + return { handled: true, response: deps.success(result) }; |
| 136 | + } |
| 137 | + |
| 138 | + // POST /data/:object (Create) |
| 139 | + if (m === 'POST') { |
| 140 | + // Spec: returns CreateDataResponse = { object, id, record } |
| 141 | + const result = await actionExec.callData(deps, 'create', { object: objectName, data: body }, _context.dataDriver, _context.environmentId, _context.executionContext); |
| 142 | + const res = deps.success(result); |
| 143 | + res.status = 201; |
| 144 | + return { handled: true, response: res }; |
| 145 | + } |
| 146 | + } |
| 147 | + |
| 148 | + return { handled: false }; |
| 149 | +} |
0 commit comments