import { CamError } from "./errors.ts" import { parseExpressionPayload } from "./expressions.ts" import { requiredNonEmptyString, requiredRecord, } from "./guards.ts" import { CAM_VERSION, CAM_ROUTE_CALL_NAMESPACE_TYPES, camRouteThenNamespaceTypes, collectCamInvocationFact, collectCamRoutePresentationsFact, collectCamNamespaceFacts, collectCamResourceDeclarationFacts, collectCamRootFact, collectCamRouteInputsFact, collectCamRouteResolutionFact, collectCamRouteExpressionDiagnostics, createStringMap, hasOwn, isCamRouteKind, } from "@cam/protocol" import type { CamContractNamespace, CamDocument, CamInvocation, CamNamespace, CamRoute, CamRoutesNamespace, CamUiNamespace, } from "./types.ts" import type { CamFactDiagnostic, CamInvocationFact, CamNamespaceFact, CamResourceDeclarationFact, CamRootFact, CamNamespaceType, InertValue, } from "@cam/protocol" const CONTRACT_NAMESPACE_KEYS = new Set(["type", "abiURI", "integrity"]) const ROUTES_NAMESPACE_KEYS = new Set(["type"]) const UI_NAMESPACE_KEYS = new Set(["type", "uri", "integrity"]) const BASE_ROUTE_KEYS = new Set(["kind", "inputs", "call", "then"]) const WRITE_ROUTE_KEYS = new Set([...BASE_ROUTE_KEYS, "value", "resolve", "review", "receipt"]) const INVOCATION_KEYS = new Set(["namespace", "function", "args"]) const TARGET_INVOCATION_KEYS = new Set([...INVOCATION_KEYS, "target"]) export function parseCam(input: unknown): CamDocument { const root = parseRootFact(input) const protocolResult = collectCamResourceDeclarationFacts([], root) const protocolDiagnostic = protocolResult.diagnostics[0] if (protocolDiagnostic !== undefined) throw camErrorFromFactDiagnostic(protocolDiagnostic) const protocol = protocolResult.declarations[0] if (protocol === undefined) { throw new CamError("CAM_INVALID_FIELD", "CAM protocol resource declaration is missing", "protocol") } const namespaces = parseNamespaces(root) const routes = parseRoutes(requiredRecord(root.value.routes, "routes"), namespaces) const entry = requiredNonEmptyString(root.value.entry, "entry") if (!hasOwn(routes, entry)) { throw new CamError("CAM_ENTRY_ROUTE_MISSING", `entry route does not exist: ${entry}`, "entry") } return { protocol: { uri: protocol.uri, integrity: protocol.integrity }, cam: root.version, entry, namespaces, routes, } } function parseRootFact(input: unknown): CamRootFact { const result = collectCamRootFact(input, { resource: "CAM root" }) const diagnostic = result.diagnostics[0] if (diagnostic !== undefined) { throw camErrorFromFactDiagnostic(diagnostic) } if (result.value === undefined) { throw new CamError("CAM_NOT_OBJECT", "CAM root document must be a JSON object") } return result.value } function parseNamespaces(root: CamRootFact): Record { const namespaceResult = collectCamNamespaceFacts(root) const namespaceDiagnostic = namespaceResult.diagnostics[0] if (namespaceDiagnostic !== undefined) { throw camErrorFromFactDiagnostic(namespaceDiagnostic) } return parseNamespaceFacts(namespaceResult.namespaces) } function parseNamespaceFacts( facts: readonly CamNamespaceFact[], ): Record { const namespaces = createStringMap() for (const fact of facts) { namespaces[fact.name] = parseNamespaceFact(fact) } return namespaces } function parseNamespaceFact(fact: CamNamespaceFact): CamNamespace { switch (fact.type) { case "contract": return parseContractNamespace(fact) case "routes": return parseRoutesNamespace(fact) case "ui": return parseUiNamespace(fact) } } function parseContractNamespace(fact: CamNamespaceFact): CamContractNamespace { rejectUnknownCamFields(fact.declaration, CONTRACT_NAMESPACE_KEYS, fact.path) const resource = parseResourceDeclaration(fact) return { type: "contract", abiURI: resource.uri, integrity: resource.integrity, } } function parseRoutesNamespace(fact: CamNamespaceFact): CamRoutesNamespace { rejectUnknownCamFields(fact.declaration, ROUTES_NAMESPACE_KEYS, fact.path) return { type: "routes", } } function parseUiNamespace(fact: CamNamespaceFact): CamUiNamespace { rejectUnknownCamFields(fact.declaration, UI_NAMESPACE_KEYS, fact.path) const resource = parseResourceDeclaration(fact) return { type: "ui", uri: resource.uri, integrity: resource.integrity, } } function parseResourceDeclaration(fact: CamNamespaceFact): CamResourceDeclarationFact { const result = collectCamResourceDeclarationFacts([fact]) const diagnostic = result.diagnostics[0] if (diagnostic !== undefined) { throw camErrorFromFactDiagnostic(diagnostic) } const declaration = result.declarations[0] if (declaration !== undefined) return declaration // Earlier fact collection is fail-fast for resource diagnostics. Reaching // this means the runtime adapter and fact collector disagree about usability. throw new CamError("CAM_INVALID_FIELD", `CAM resource declaration is not usable: ${fact.name}`, fact.path) } function parseRoutes( source: Record, namespaces: Record, ): Record { const routes = createStringMap() for (const [name, value] of Object.entries(source)) { if (name.length === 0) { throw new CamError("CAM_INVALID_FIELD", "route name must not be empty", "routes") } const path = `routes.${name}` const route = requiredRecord(value, path) const kind = parseRouteKind(route.kind, `${path}.kind`) rejectUnknownCamFields(route, kind === "write" ? WRITE_ROUTE_KEYS : BASE_ROUTE_KEYS, path) const inputs = parseInputNames(route.inputs, `${path}.inputs`, name) const resolution = collectCamRouteResolutionFact({ resource: "CAM root", path, route, namespaceTypes: namespaceTypeMap(namespaces), declaredInputs: new Set(inputs), }) const resolutionDiagnostic = resolution.diagnostics[0] if (resolutionDiagnostic !== undefined) throw camErrorFromFactDiagnostic(resolutionDiagnostic) const call = parseInvocation(route.call, `${path}.call`, namespaces, CAM_ROUTE_CALL_NAMESPACE_TYPES, resolution.value !== undefined) const then = parseInvocation( route.then, `${path}.then`, namespaces, camRouteThenNamespaceTypes(kind), ) const presentations = collectCamRoutePresentationsFact({ resource: "CAM root", path, route, namespaceTypes: namespaceTypeMap(namespaces), declaredInputs: new Set(inputs), }) const presentationDiagnostic = presentations.diagnostics[0] if (presentationDiagnostic !== undefined) throw camErrorFromFactDiagnostic(presentationDiagnostic) const review = presentations.value?.review const receipt = presentations.value?.receipt if (resolution.value !== undefined) { for (const [value, field, allowOutputs] of [[call.args, "call.args", true], [then.args, "then.args", false], [route.value, "value", false]] as const) { const diagnostic = collectCamRouteExpressionDiagnostics({ resource: "CAM root", path: `${path}.${field}`, value, declaredInputs: new Set(inputs), allowOutputs, outputUnavailableMessage: "resolver outputs are available only to the final write target and arguments", })[0] if (diagnostic !== undefined) throw camErrorFromFactDiagnostic(diagnostic) } } if (kind === "read") { routes[name] = { kind, inputs, call, then, } continue } routes[name] = { kind, inputs, call, then, ...(review === undefined ? {} : { review: { call: parseInvocationFact(review.call), then: parseInvocationFact(review.then) } }), ...(receipt === undefined ? {} : { receipt: { event: receipt.event, then: parseInvocationFact(receipt.then), ...(receipt.empty === undefined ? {} : { empty: parseInvocationFact(receipt.empty) }), } }), ...(resolution.value === undefined ? {} : { resolve: parseInvocation(route.resolve, `${path}.resolve`, namespaces, CAM_ROUTE_CALL_NAMESPACE_TYPES), call: { ...call, target: resolution.value.target }, }), ...(hasOwn(route, "value") ? { value: parseExpressionPayload(route.value, `${path}.value`) } : {}), } } return routes } function parseRouteKind(value: unknown, path: string): CamRoute["kind"] { const kind = requiredNonEmptyString(value, path) if (!isCamRouteKind(kind)) { throw new CamError("CAM_INVALID_FIELD", `route kind must be read or write: ${kind}`, path) } return kind } function parseInputNames(value: unknown, path: string, routeName: string): readonly string[] { const result = collectCamRouteInputsFact({ resource: "CAM root", path, routeName, inputs: value, }) const diagnostic = result.diagnostics[0] if (diagnostic !== undefined) { throw camErrorFromFactDiagnostic(diagnostic) } if (result.value === undefined) { throw new CamError("CAM_INVALID_FIELD", "CAM route inputs are not usable", path) } return result.value.inputs } function parseInvocation( value: unknown, path: string, namespaces: Record, allowedNamespaceTypes: ReadonlySet, allowTarget?: boolean, ): CamInvocation { const source = requiredRecord(value, path) rejectUnknownCamFields(source, allowTarget === true ? TARGET_INVOCATION_KEYS : INVOCATION_KEYS, path) const result = collectCamInvocationFact({ resource: "CAM root", path, invocation: source, namespaceTypes: namespaceTypeMap(namespaces), allowedNamespaceTypes, purpose: path.endsWith(".call") ? "route call" : "route continuation", allowTarget: allowTarget === true, }) const diagnostic = result.diagnostics[0] if (diagnostic !== undefined) { throw camErrorFromFactDiagnostic(diagnostic) } const fact = result.value if (fact === undefined) { throw new CamError("CAM_INVALID_FIELD", "CAM invocation is not usable", path) } return { namespace: fact.namespace, function: fact.function, args: parseNamedArgs(fact.args, `${path}.args`), } } function namespaceTypeMap(namespaces: Record): ReadonlyMap { const namespaceTypes = new Map() for (const [name, namespace] of Object.entries(namespaces)) { namespaceTypes.set(name, namespace.type) } return namespaceTypes } function parseNamedArgs(source: Record, path: string): Record { const args = createStringMap() for (const [name, value] of Object.entries(source)) { if (name.length === 0) { throw new CamError("CAM_INVALID_FIELD", "argument name must not be empty", path) } args[name] = parseExpressionPayload(value, `${path}.${name}`) } return args } function rejectUnknownCamFields( source: Record, allowedKeys: ReadonlySet, path: string, ): void { // CAM documents are intentionally closed-world. Unknown fields are rejected // so older or richer shapes cannot be partially interpreted as another version. for (const key of Object.keys(source)) { if (!allowedKeys.has(key)) { const fieldPath = path.length === 0 ? key : `${path}.${key}` throw new CamError("CAM_UNKNOWN_FIELD", `field is not allowed in CAM ${CAM_VERSION}: ${key}`, fieldPath) } } } function camErrorFromFactDiagnostic(diagnostic: CamFactDiagnostic): CamError { switch (diagnostic.code) { case "CAM_FACT_ROOT_NOT_OBJECT": return new CamError("CAM_NOT_OBJECT", diagnostic.message, diagnostic.path) case "CAM_FACT_ROOT_FIELD_UNKNOWN": return new CamError("CAM_UNKNOWN_FIELD", diagnostic.message, diagnostic.path) case "CAM_FACT_RESOURCE_URI_POLICY_INVALID": return new CamError("CAM_INVALID_URI", diagnostic.message, diagnostic.path) case "CAM_FACT_ROOT_VERSION_INVALID": case "CAM_FACT_NAMESPACES_NOT_OBJECT": case "CAM_FACT_NAMESPACE_NAME_EMPTY": case "CAM_FACT_NAMESPACE_NOT_OBJECT": case "CAM_FACT_NAMESPACE_TYPE_INVALID": case "CAM_FACT_NAMESPACE_NAME_INVALID": case "CAM_FACT_RESOURCE_URI_INVALID": case "CAM_FACT_RESOURCE_INTEGRITY_INVALID": case "CAM_FACT_INVOCATION_NOT_OBJECT": case "CAM_FACT_INVOCATION_NAMESPACE_INVALID": case "CAM_FACT_INVOCATION_NAMESPACE_UNKNOWN": case "CAM_FACT_INVOCATION_NAMESPACE_TYPE_INVALID": case "CAM_FACT_INVOCATION_FUNCTION_INVALID": case "CAM_FACT_INVOCATION_ARGS_INVALID": case "CAM_FACT_INVOCATION_ARG_NAME_INVALID": case "CAM_FACT_INVOCATION_FIELD_UNKNOWN": case "CAM_FACT_ROUTE_RESOLUTION_INVALID": case "CAM_FACT_ROUTE_INPUTS_NOT_ARRAY": case "CAM_FACT_ROUTE_INPUT_NAME_INVALID": case "CAM_FACT_ROUTE_INPUT_NAME_DUPLICATE": case "CAM_FACT_ROUTE_EXPRESSION_SYNTAX_INVALID": case "CAM_FACT_ROUTE_EXPRESSION_ROOT_INVALID": case "CAM_FACT_ROUTE_EXPRESSION_INPUT_INVALID": case "CAM_FACT_ROUTE_EXPRESSION_INPUT_UNDECLARED": case "CAM_FACT_ROUTE_EXPRESSION_OUTPUT_UNAVAILABLE": return new CamError("CAM_INVALID_FIELD", diagnostic.message, diagnostic.path) } } function parseInvocationFact(fact: CamInvocationFact): CamInvocation { return { namespace: fact.namespace, function: fact.function, args: parseNamedArgs(fact.args, `${fact.path}.args`) } }