REST API
Problem
The platform already had a transport-agnostic function invocation pipeline — JSON Schema generation from virtual entity metadata, JSON-to-entity mapping, function execution, and entity-to-JSON output mapping. This pipeline powered MCP tool exposure, where functions with typed input/output entities became callable tools.
But there was no way to expose application logic as REST APIs. No route declarations, no OpenAPI specs, no API key authentication, no entity CRUD endpoints, and no concept of an API consumer as a distinct identity. Applications that needed to interoperate with external systems had no standard HTTP surface.
The goal: expose platform functions and entity CRUD as REST endpoints, with full OpenAPI documentation, configurable authentication, and per-consumer access control — declared entirely in the DSL and compiled to metadata like everything else in the platform.
Design
Reusing the Function Invocation Pipeline
A function is “API-exposable” when it has an InputEntityType set — a virtual entity whose properties become the API’s input parameters. OutputEntityType is optional. The existing schema generator already produces JSON Schema with description, examples, maxLength, required, and all type mappings.
REST-exposed functions must be synchronous — functions that enter a wait state (claims-based or otherwise) cannot be exposed as REST endpoints. This is a compile-time constraint: the compiler rejects an endpoint referencing a function whose state machine includes wait states.
The api: Block
A top-level DSL block configures a REST API surface for the application. Multiple api: blocks may exist — for domain grouping (Orders API, Inventory API) or parallel versions (v1 and v2 simultaneously). Each block has a unique PascalCase name (the compiler identifier) and a route: field (the URL path segment). This separation allows the same logical API to exist at multiple versions without name collisions, and keeps URLs clean.
Version routing: The version field is semver-style (e.g., "1.0", "2.3"). The route uses only the major version as the prefix: v1, v2. The full version string appears in the OpenAPI spec’s info.version.
api OrderApiV1:
route: orders # URL segment: /rest/v1/orders/...
version: "1.0"
name: "Order Management API"
description: "REST API for managing orders and inventory"
auth:
apiKey # Enable API key authentication
bearer # Enable JWT bearer token
# Entity CRUD exposure (default: none)
crud:
expose Order # Opt-in: read, create, patch, delete
expose Product
expose Customer:
read # Only expose read operations
expose Invoice:
read, create # Expose read + create only
# Function endpoints
endpoint CreateOrder:
function: CreateOrderFunction
method: POST
path: /orders
summary: "Create a new order"
description: "Creates an order with line items and calculates totals"
tags: Orders
response: 201 # Override default 200; emits Location header
endpoint GetOrderStatus:
function: GetOrderStatusFunction
method: GET
path: /orders/{orderId}/status
summary: "Get order status"
tags: Orders
params:
orderId -> OrderId # path param -> input property (PascalCase)
endpoint CancelOrder:
function: CancelOrderFunction
method: POST
path: /orders/{orderId}/cancel
summary: "Cancel an existing order"
tags: Orders
params:
orderId -> OrderId
Multiple API blocks coexist naturally:
# Same logical API at v2 — same route, different version
api OrderApiV2:
route: orders # /rest/v2/orders/...
version: "2.0"
name: "Order Management API"
description: "v2: expanded line item model"
auth:
apiKey
endpoint CreateOrder:
function: CreateOrderFunctionV2
method: POST
path: /orders
summary: "Create a new order"
tags: Orders
response: 201
# Separate domain API — different route, coexists with Order APIs
api InventoryApi:
route: inventory # /rest/v1/inventory/...
version: "1.0"
name: "Inventory API"
description: "Stock levels and warehouse management"
auth:
apiKey
crud:
expose Product:
read
expose WarehouseStock
Deprecation
An api: block may be marked deprecated to signal that consumers should migrate. This emits standard OpenAPI deprecation markers and injects Deprecation and Sunset HTTP response headers on every request. Deprecated APIs remain fully functional until explicitly removed from the DSL.
api OrderApiLegacy:
route: orders-legacy
version: "1.0"
deprecated: true
sunset: "2027-01-01"
name: "Order Management API (Legacy)"
description: "Superseded by OrderApiV2. Migrate before 2027-01-01."
auth:
apiKey
bearer
When deprecated, all responses include:
Deprecation: true
Sunset: Sun, 01 Jan 2027 00:00:00 GMT
Link: </{appId}/rest/v2/orders/openapi.json>; rel="successor-version"
The successor link is resolved automatically by finding the non-deprecated API with the highest major version at the same route.
Compiler Enforcement
The compiler validates the entire API surface at compile time:
- Unique names — duplicate
ApiNamewithin an application is a compile error - Route collision — two APIs with the same
routeand same major version is a compile error - Function resolution — all function references in
endpoint:blocks must resolve - Role resolution — role references in
apiuser:blocks must resolve to declared roles - Path uniqueness — duplicate
method + pathwithin an API block is a compile error - Synchronous functions — endpoints referencing functions with wait states are rejected
- GET coverage — GET endpoints must have all input properties covered by
params:mappings; no request body on GET expose allwarning — emits a diagnostic: “All entities exposed via CRUD — ensure SecurityManager rules are configured for all entity types.”
API Consumers: The apiuser: Block
API consumers are declared with credentials, access scope, and role-based permissions. Each API user is provisioned as a full application user — with a system-generated email — and assigned the declared roles. This means API users appear in audit trails with a meaningful identity, and all RBAC rules apply identically.
apiuser WebhookService:
description: "External webhook integration"
roles: ApiConsumer
key: @secret.WebhookApiKey
api:
OrderApiV1 # References DSL block name
apiuser MobileApp:
description: "Mobile application"
roles: ApiConsumer, OrderManager
key: @secret.MobileApiKey
key2: @secret.MobileApiKeyRotation # Secondary key for zero-downtime rotation
rateLimit: 1000 # Requests per minute (stored, not enforced in v1)
api:
OrderApiV2
InventoryApi
The provisioned application user record has:
- Email:
apiuser.{Name}@system.{appId}.internal - DisplayName: the
descriptionvalue from theapiuser:block - Roles: assigned via the platform’s existing
UserRolemechanism - No password — authentication is exclusively via API key
This user appears in audit logs, security rule evaluations (@CurrentUser.Roles), and any entity-level row/value filter that references the current user. There is no special-casing for API users anywhere in the stack.
Authentication
API Key Authentication
A new authentication provider implements hash-based O(1) key lookup:
- Client sends
X-API-Key: <key>header - Compute
HMAC-SHA256(key, appId)→ candidate hash - Query API users where the hash matches (indexed lookup, not a scan)
- Decrypt the stored secret and perform constant-time comparison as final verification
- On match: resolve the provisioned application user → issue JWT with that user’s identity and roles
The hash is computed on key ingestion (at compile + apply time) and stored alongside the encrypted secret. The constant-time comparison in step 4 guards against hash collisions — the hash lookup is just the index.
Dual-key zero-downtime rotation:
- Generate new key → store as secondary key with its hash
- Distribute new key to consumers
- Once all consumers are updated: promote to primary, clear secondary
Both keys are valid simultaneously during the rotation window.
Bearer Token
Existing JWT bearer tokens work as-is. Application tokens include a user identity that resolves to an app user with roles. Bearer tokens are valid on any API block that includes bearer in its auth: section.
Choice → Enum Mapping
The platform’s choice system (typed enumerations) maps to OpenAPI enums through a SemanticRole mechanism. Choice columns already have semantic roles for UI hints (icon, color, badge). A new api role designates which column value becomes the enum string in OpenAPI specs.
Resolution order for OpenAPI enums:
- If the choice has a column with
SemanticRole = "api"→ use that column’s value - Else if the choice has a scalar value property → use the stored value, with display labels as
x-enum-descriptions - Else (EntityRef choice) → emit
format: "uuid"with a description listing valid options
choice OrderStatus:
Label: String(100)
ApiValue: String(50) api # SemanticRole = "api"
items:
"Draft", "draft"
"Shipped", "shipped"
"Delivered", "delivered"
Generated OpenAPI schema:
{
"OrderStatus": {
"type": "string",
"enum": ["draft", "shipped", "delivered"],
"description": "Order status"
}
}
Without an api column, scalar choices fall back to their stored values:
choice Priority:
Label: String(100)
Value: Int32
value: Value
items:
"Low", 0
"Medium", 1
"High", 2
{
"Priority": {
"type": "integer",
"enum": [0, 1, 2],
"x-enum-descriptions": ["Low", "Medium", "High"],
"description": "Task priority"
}
}
Bidirectional Resolution
The forward mapping (stored value → API value) renders enum values in responses. The reverse mapping handles inbound requests:
- If the choice has an
apicolumn: look up the choice item where the API value matches (case-insensitive). For EntityRef choices, return the matched item’s entity ID; for scalar choices, return the stored value. - If no
apicolumn and scalar: parse the input as the value type and validate against defined items. - EntityRef without
apicolumn: accept either a GUID (direct reference) or the display label (convenience lookup).
Invalid values return a validation error listing all valid options. The API returns exactly what it accepts — symmetry is guaranteed.
Entity CRUD Endpoints
When CRUD is enabled for an entity via the crud: block, the platform auto-generates REST endpoints:
GET /rest/v1/orders/entities/{entityType} # Query (paginated)
GET /rest/v1/orders/entities/{entityType}/{id} # Get by ID
POST /rest/v1/orders/entities/{entityType} # Create
PATCH /rest/v1/orders/entities/{entityType}/{id} # Partial update
DELETE /rest/v1/orders/entities/{entityType}/{id} # Delete
No PUT endpoint. Only PATCH is supported for updates. The platform’s delta model is inherently partial — it applies changes to a working copy, not a full replacement. Exposing PUT would require treating the entire inbound body as authoritative, which would silently clear fields not included in the request. PATCH is correct by construction.
Query Parameters
Query endpoints use OData-style parameter naming for compatibility with standard tooling (PowerBI, Swagger UI, client generators):
| Parameter | Type | Default | Description |
|---|---|---|---|
$top | int | 50 | Max items to return (capped at 1000) |
$skip | int | 0 | Items to skip |
$filter | string | OData subset filter expression | |
$orderby | string | Sort expression (e.g., CreatedAt desc) | |
$expand | string | Comma-separated related entity paths |
Supported $filter operators (v1): eq, ne, gt, ge, lt, le, and, or, not, contains(), startswith(), endswith(), null. Unsupported operators (lambda, arithmetic, collection functions) are rejected with a descriptive error identifying the unsupported operator.
Query response:
{
"items": [...],
"included": {
"LineItem": [...],
"Product": [...]
},
"total": 147,
"top": 50,
"skip": 0,
"hasMore": true
}
Security Composition
Caller-supplied $filter expressions compose with SecurityManager policies — they never bypass them:
- Parse and validate the caller’s OData filter expression
- Apply SecurityManager row-level filters for the current user (always applied, unconditionally)
- Apply the caller’s filter on top of the already-security-filtered result set
A caller cannot construct a filter that reaches records their roles do not permit. Attempting to filter on a field the caller’s role cannot read returns FORBIDDEN — not a silent empty result.
When a row filter excludes a record, the API returns 404 — identical to a genuinely missing record. This prevents information disclosure about the existence of records the caller cannot see.
Response Conventions
| Operation | Success Status | Response Body |
|---|---|---|
| Query (GET list) | 200 | Paginated result with items, total, hasMore |
| Get by ID | 200 | Entity body |
| Create (POST) | 201 | Created entity body + Location header |
| Patch | 200 | Updated entity body |
| Delete | 204 | No body |
CRUD requests go through the same WorkContext pipeline as the platform’s internal context API, including full SecurityManager enforcement. The API layer checks that the entity type is exposed via the crud: block (surface check), then SecurityManager enforces role-based permissions as usual.
Two-Layer Access Model
Access control uses two independent layers:
api:crud block — defines which entity types have REST endpoints (the surface exists or it doesn’t)- Security rules via roles — controls who can actually perform operations on those entities, including row filters and value masks
Exposing an entity via CRUD makes the endpoint available. Whether a specific API consumer can actually create, read, update, or delete records depends entirely on their roles and the SecurityManager rules — the same enforcement used everywhere else in the platform. Nothing bypasses security.
Request Validation and Error Responses
All validation failures return a structured JSON error body. The goal is to be maximally helpful — the consumer should never have to guess what went wrong.
{
"error": {
"code": "VALIDATION_FAILED",
"message": "One or more validation errors occurred.",
"correlationId": "3fa85f64-5717-4562-b3fc-2c963f66afa6",
"details": [
{
"field": "status",
"code": "INVALID_ENUM_VALUE",
"message": "Invalid value 'pending' for status. Valid values: draft, shipped, delivered.",
"received": "pending"
},
{
"field": "quantity",
"code": "TYPE_MISMATCH",
"message": "Expected integer, received string.",
"received": "\"five\""
}
]
}
}
Error codes:
| Code | HTTP Status | Description |
|---|---|---|
INVALID_ENUM_VALUE | 400 | Choice field received an unrecognised enum string. Message includes valid values. |
TYPE_MISMATCH | 400 | Field received wrong JSON type. |
REQUIRED_FIELD_MISSING | 400 | A required input property was absent. |
UNKNOWN_FIELD | 400 | Request body contains an undefined property. Rejected, not silently ignored. |
INVALID_PATH_PARAM | 400 | Path parameter could not be parsed to expected type. |
MALFORMED_JSON | 400 | Request body is not valid JSON. |
INVALID_FILTER | 400 | $filter could not be parsed as a valid expression. |
UNSUPPORTED_FILTER_OPERATOR | 400 | $filter uses an operator not supported in v1. |
UNSUPPORTED_EXPAND_OPTION | 400 | $expand includes inline sub-options not supported in v1. |
UNAUTHORIZED | 401 | No credentials or API key not recognised. |
FORBIDDEN | 403 | Credentials valid but role-based access denied. |
NOT_FOUND | 404 | Entity not found or excluded by row filter (indistinguishable). |
ENDPOINT_NOT_FOUND | 404 | No endpoint matches the path + method. |
FUNCTION_ERROR | 500 | Function execution failed. Correlation ID included; detail omitted in production. |
INTERNAL_ERROR | 500 | Unexpected error. Correlation ID included. |
Unknown fields are rejected — not silently ignored. This is intentional: silent field ignoring masks client bugs (typos in field names, stale SDK usage after a schema change). Strict rejection surfaces the problem immediately.
All error responses include a correlationId for log correlation.
OpenAPI Specification Generation
Each API block produces a dynamic OpenAPI 3.1 spec:
GET /api/applications/{appId}/rest/v{major}/{route}/openapi.json
Schema Deduplication
All entity and choice schemas are emitted once under components/schemas and referenced via $ref throughout. Choice types used as enum fields are always top-level $ref schemas — never inlined — so code generators produce proper enum types.
TypePatch variants are emitted for PATCH request bodies with all properties marked optional — the correct OpenAPI representation of a partial update body.
Spec Structure
- Info section: title, version, description from the API configuration. Deprecated APIs include
x-deprecated: trueand a migration notice. - Authentication: API key and/or bearer schemes declared in
securitySchemes. The globalsecurityarray requires at least one. - Function endpoints: each has
operationId, summary, description, tags, request/response schema via$ref, path parameters with types, and standard error responses (400, 401, 403, 404, 500). - CRUD endpoints: per-entity paths for permitted operations, with query parameter documentation including supported
$filteroperators and$expandpaths. - Error schema: a single
ErrorResponseschema incomponents/schemasreferenced by all error responses.
Metadata Entities
The API surface is backed by metadata entities compiled from the DSL:
| Entity | Description |
|---|---|
ApiConfiguration | One per api: block. Stores name, route, version, description, auth methods, deprecation state, CRUD-all flag. |
ApiEndpoint | One per exposed function. Stores HTTP method, path, summary, tags, success status code, function reference. |
ApiEndpointPathParam | Path parameter → input property mappings for each endpoint. |
ApiCrudExposure | Per-entity CRUD permissions: AllowRead, AllowCreate, AllowPatch, AllowDelete. |
ApiUser | API consumer identity with key hashes, rate limit, and reference to the provisioned application user. |
ApiUserApi | Join entity granting an API user access to a specific API. |
Implementation Phases
Phase 1: Core Infrastructure
API definition module registering metadata entities. REST endpoint routing that loads API configuration from metadata, matches requests to function endpoints, handles path parameter extraction and mapping, validates request bodies (required fields, type checking, unknown field rejection), and returns structured error responses. Bidirectional choice-to-API-value resolver. Dynamic OpenAPI spec generation with $ref deduplication.
Phase 2: Authentication
API key authentication provider with hash-based O(1) lookup and constant-time verification. API user and access entities. Application user provisioning on compile. Dual-key rotation support. DSL syntax for apiuser: blocks with role resolution.
Phase 3: Entity CRUD
Generic entity CRUD endpoints (GET, POST, PATCH, DELETE) backed by the platform’s existing context pipeline. OData $filter parser for the supported operator subset, translating to the platform’s native query format. Security composition ensuring caller filters never bypass SecurityManager row filters. $expand mapping to the platform’s include resolution. Pagination with hasMore.
Phase 4: DSL Syntax and Compilation
Full parser, resolver, emitter, and decompiler for api: and apiuser: blocks. All compiler validation rules (unique names, route collision detection, function synchronicity check, GET parameter coverage, role resolution). Deprecation and sunset support. DSL language reference updates.
Key Design Decisions
| Decision | Rationale |
|---|---|
| PATCH only, no PUT | The platform’s delta model is inherently partial. PUT semantics (full replacement) are incompatible and would silently clear unspecified fields. |
| Multiple APIs per app | Domain separation (Orders API, Inventory API) and versioning (v1, v2) are both first-class. Compile-time route collision detection prevents ambiguity. |
| DSL name vs. route | The DSL block name is a compiler identifier; the route: field controls URLs. Allows the same logical API at multiple versions without naming collisions. |
OData $filter subset | Adopted for PowerBI compatibility and standard tooling. Unsupported operators are explicitly rejected, not silently ignored. |
| Unknown fields rejected | Silent ignoring masks client bugs. Strict validation surfaces typos and stale SDK usage immediately. |
| API users as full app users | API consumers appear in audit trails, security rule evaluations, and row filters with a deterministic identity. No special-casing anywhere. |
| Hash-based key lookup | HMAC-SHA256 hash stored as a lookup index. Authentication is O(1), not a scan of all API users. Constant-time comparison as final verification. |
| Synchronous functions only | Functions with wait states cannot be exposed as REST endpoints. Compile-time enforcement. |
| Anonymous access deferred | Not supported in v1. All endpoints require authentication. Will be revisited when callback endpoint design is finalised. |
| Rate limiting stored, not enforced | RateLimit on API users is persisted from day one but enforcement is deferred. The entity design accommodates it without schema changes later. |
| Security never bypassed | CRUD exposure makes the endpoint exist. Whether the API consumer can actually use it depends on their roles and SecurityManager rules — identical to every other access path in the platform. |
| 404 for row-filtered records | When a security row filter excludes a record, the API returns 404 — identical to genuinely missing records. No information disclosure about records the caller cannot see. |