← Back to design archive

REST API

architecture draft Updated

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 ApiName within an application is a compile error
  • Route collision — two APIs with the same route and 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 + path within 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 all warning — 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 description value from the apiuser: block
  • Roles: assigned via the platform’s existing UserRole mechanism
  • 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:

  1. Client sends X-API-Key: <key> header
  2. Compute HMAC-SHA256(key, appId) → candidate hash
  3. Query API users where the hash matches (indexed lookup, not a scan)
  4. Decrypt the stored secret and perform constant-time comparison as final verification
  5. 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:

  1. Generate new key → store as secondary key with its hash
  2. Distribute new key to consumers
  3. 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:

  1. If the choice has a column with SemanticRole = "api" → use that column’s value
  2. Else if the choice has a scalar value property → use the stored value, with display labels as x-enum-descriptions
  3. 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:

  1. If the choice has an api column: 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.
  2. If no api column and scalar: parse the input as the value type and validate against defined items.
  3. EntityRef without api column: 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):

ParameterTypeDefaultDescription
$topint50Max items to return (capped at 1000)
$skipint0Items to skip
$filterstringOData subset filter expression
$orderbystringSort expression (e.g., CreatedAt desc)
$expandstringComma-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:

  1. Parse and validate the caller’s OData filter expression
  2. Apply SecurityManager row-level filters for the current user (always applied, unconditionally)
  3. 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

OperationSuccess StatusResponse Body
Query (GET list)200Paginated result with items, total, hasMore
Get by ID200Entity body
Create (POST)201Created entity body + Location header
Patch200Updated entity body
Delete204No 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:

  1. api: crud block — defines which entity types have REST endpoints (the surface exists or it doesn’t)
  2. 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:

CodeHTTP StatusDescription
INVALID_ENUM_VALUE400Choice field received an unrecognised enum string. Message includes valid values.
TYPE_MISMATCH400Field received wrong JSON type.
REQUIRED_FIELD_MISSING400A required input property was absent.
UNKNOWN_FIELD400Request body contains an undefined property. Rejected, not silently ignored.
INVALID_PATH_PARAM400Path parameter could not be parsed to expected type.
MALFORMED_JSON400Request body is not valid JSON.
INVALID_FILTER400$filter could not be parsed as a valid expression.
UNSUPPORTED_FILTER_OPERATOR400$filter uses an operator not supported in v1.
UNSUPPORTED_EXPAND_OPTION400$expand includes inline sub-options not supported in v1.
UNAUTHORIZED401No credentials or API key not recognised.
FORBIDDEN403Credentials valid but role-based access denied.
NOT_FOUND404Entity not found or excluded by row filter (indistinguishable).
ENDPOINT_NOT_FOUND404No endpoint matches the path + method.
FUNCTION_ERROR500Function execution failed. Correlation ID included; detail omitted in production.
INTERNAL_ERROR500Unexpected 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: true and a migration notice.
  • Authentication: API key and/or bearer schemes declared in securitySchemes. The global security array 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 $filter operators and $expand paths.
  • Error schema: a single ErrorResponse schema in components/schemas referenced by all error responses.

Metadata Entities

The API surface is backed by metadata entities compiled from the DSL:

EntityDescription
ApiConfigurationOne per api: block. Stores name, route, version, description, auth methods, deprecation state, CRUD-all flag.
ApiEndpointOne per exposed function. Stores HTTP method, path, summary, tags, success status code, function reference.
ApiEndpointPathParamPath parameter → input property mappings for each endpoint.
ApiCrudExposurePer-entity CRUD permissions: AllowRead, AllowCreate, AllowPatch, AllowDelete.
ApiUserAPI consumer identity with key hashes, rate limit, and reference to the provisioned application user.
ApiUserApiJoin 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

DecisionRationale
PATCH only, no PUTThe platform’s delta model is inherently partial. PUT semantics (full replacement) are incompatible and would silently clear unspecified fields.
Multiple APIs per appDomain separation (Orders API, Inventory API) and versioning (v1, v2) are both first-class. Compile-time route collision detection prevents ambiguity.
DSL name vs. routeThe 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 subsetAdopted for PowerBI compatibility and standard tooling. Unsupported operators are explicitly rejected, not silently ignored.
Unknown fields rejectedSilent ignoring masks client bugs. Strict validation surfaces typos and stale SDK usage immediately.
API users as full app usersAPI consumers appear in audit trails, security rule evaluations, and row filters with a deterministic identity. No special-casing anywhere.
Hash-based key lookupHMAC-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 onlyFunctions with wait states cannot be exposed as REST endpoints. Compile-time enforcement.
Anonymous access deferredNot supported in v1. All endpoints require authentication. Will be revisited when callback endpoint design is finalised.
Rate limiting stored, not enforcedRateLimit on API users is persisted from day one but enforcement is deferred. The entity design accommodates it without schema changes later.
Security never bypassedCRUD 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 recordsWhen 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.