Skip to content

API Design

This document consolidates conventions for Arda’s REST API design: URL naming, required headers, error responses, filtering and pagination, endpoint usage patterns, known limitations, and hard-won gotchas.

Arda’s platform uses four network domains:

DomainPurpose
app.arda.cardsEnd-user-oriented applications
io.arda.cards / api.arda.cardsAPIs exposing platform functionality
auth.arda.cardsOAuth2 authorization endpoints

Each Purpose defines subdomains:

<purpose>.<infrastructure>.app.arda.cards
<purpose>.<infrastructure>.io.arda.cards
<purpose>.<infrastructure>.api.arda.cards
<purpose>.<infrastructure>.auth.arda.cards

The main production system uses canonical short hostnames:

live.app.arda.cards
live.io.arda.cards
live.api.arda.cards
live.auth.arda.cards

API endpoint routes follow:

https://<purpose>.<infrastructure>.api.arda.cards/<major-version>/<endpoint-name>/<specific-route>

Where:

  • <major-version> is the SemVer major version of the API definition (not the module or component version)
  • <endpoint-name> is the functional name of the endpoint, following REST naming conventions, in hyphenated lower-kebab-case plural form (e.g., lookup-suppliers, kanban-card)
  • <specific-route> is the route as understood in OpenAPI specification

Existing routes use hyphenated, plural naming: lookup-suppliers, lookup-units. New routes must follow the same pattern.

Every API call requires:

Authorization: Bearer <token>
X-Author: <author-uuid>
X-Tenant-Id: <tenant-uuid>
X-Request-ID: <fresh-uuid-per-request> # Must be valid UUID format
Content-Type: application/json

Critical notes:

  • Missing X-Request-ID causes a misleading 400 error about “Invalid UUID format”
  • Missing X-Tenant-Id also causes 400
  • Generate a fresh UUID for each request (e.g., $(uuidgen) in bash)

All error responses use standard HTTP status codes:

Client Errors (4xx):

  • 400 Bad Request — Invalid request payload, malformed JSON, missing required parameters
  • 401 Unauthorized — Missing or invalid authentication token
  • 403 Forbidden — Authenticated user lacks permission
  • 404 Not Found — Requested resource does not exist
  • 405 Method Not Allowed
  • 409 Conflict — Conflict in current resource state (e.g., edit conflict, update without draft)
  • 422 Unprocessable Entity
  • 429 Too Many Requests

Server Errors (5xx):

  • 500 Internal Server Error — Unexpected server error
  • 501 Not Implemented
  • 502 Bad Gateway
  • 503 Service Unavailable
  • 504 Gateway Timeout

All error responses conform to the ErrorResponse data class:

@Serializable
data class ErrorResponse(
override val responseMessage: String,
override val code: Int, // HTTP Status Code
override val details: JsonElement? = null
) : Throwable(...), HttpResponse

The details field can contain:

  1. Simple Cause: JSON representation of a cause ErrorResponse
  2. Contextual Information (SingleDetails):
    data class SingleDetails(
    val error: ErrorResponse,
    override val context: String?
    ): ErrorDetails
  3. Composite Errors (CompositeDetails):
    data class CompositeDetails(
    override val context: String?,
    val errors: List<ErrorResponse>
    ): ErrorDetails

Example composite error JSON:

{
"responseMessage": "Multiple errors occurred",
"code": 500,
"details": {
"context": "Validating user input",
"errors": [
{ "responseMessage": "username is Invalid: cannot be empty", "code": 400, "details": null },
{ "responseMessage": "email is Invalid: must be a valid email format", "code": 400, "details": null }
]
}
}

The system uses Ktor’s StatusPages plugin for centralized exception handling:

install(StatusPages) {
exception<Throwable> { call, ktorExc ->
val toProcess = if(ktorExc.message == ktorExc.cause?.message) ktorExc.cause else ktorExc
val response = toProcess.toErrorResponse()
app.log.warn("Error: {}", call.request.path(), toProcess)
call.respond(status=response.httpCode, message=response)
}
}

Any unhandled Throwable is caught and normalized through toErrorResponse(). Throwing AppError subtypes is the preferred pattern:

get("/my-resource/{id}") {
val id = call.parameters["id"]
?: throw AppError.ArgumentValidation("id", "ID path parameter is missing")
val resource = fetchResource(id)
?: throw AppError.NotFound("MyResource", context = { "Resource ID: $id" })
call.respond(resource)
}

For functions returning Result<T>, use Result.getOrThrow() to propagate to StatusPages.

Prefer strong types (UUID/EntityId) over String at API boundaries to reduce parsing boilerplate.

The Money type uses value (not amount) for the numeric field:

// CORRECT
"unitCost": { "value": 0.15, "currency": "USD" }
// WRONG — returns 400
"unitCost": { "amount": 0.15, "currency": "USD" }

The Quantity type uses amount and unit:

"quantityPerOrder": { "amount": 100, "unit": "EA" }

The API is strict about types for Quantity objects. Float values fail:

{"amount": 1.0, "unit": "each"} // Returns 400
{"amount": 1, "unit": "each"} // Correct

Watch for camelCase variations — defaultSupplyEid (lowercase ‘id’) vs supplyEId.

For kotlinx.serializable data classes with UUID fields, the @Contextual annotation is required.

The /query sub-path supports filtering, sorting, and pagination:

  • Root path: /<version>/<resource> (e.g., /v1/items)
  • Query sub-path: /<version>/<resource>/query (e.g., /v1/items/query)

POST /<version>/<resource>/query with a JSON Query object body:

{
"filter": { "EQ": { "locator": "status", "value": "ACTIVE" } },
"sort": [
{ "field": "name", "direction": "ASC" }
],
"paginate": { "index": 0, "size": 20 }
}

Response is a PageResult:

@Serializable
data class PageResult<P: EntityPayload, M : PayloadMetadata>(
val thisPage: String,
val nextPage: String,
val previousPage: String?,
val results: List<EntityRecord<P, M>>
)

GET /<version>/<resource>/query/{page} — retrieve a specific page using a token from nextPage or previousPage. Returns the same PageResult structure.

If {page} cannot be decoded to a proper Query, returns an Argument Validation error.

  • The /query endpoints return only non-retired, visible records by default
  • An empty result does not mean no data exists — verify with direct GET /entity-id calls
  • The filter parameter is optional; omitting it returns all records
  • Query parameter defaults: effectiveAsOf and recordedAsOf default to TimeCoordinates.now() when absent

See Query DSL for filter syntax.

Universes configured with EntityServiceConfiguration accept locators as JSON field paths (camelCase, e.g., identity.email, cardQuantity.amount) in addition to raw database column names (snake_case, e.g., identity_email, card_quantity_amount). The structured translator resolves both forms, so API clients can use whichever style matches their context. See Query DSL: EntityServiceConfiguration.

Updating an item requires a strict Draft → Publish workflow due to the bitemporal model:

  1. Get/Create Draft: GET /v1/item/item/{eId}/draft
  2. Publish: PUT /v1/item/item/{eId} with the Item payload as the body

Calling PUT /item/{eId} without a draft existing returns 400 Cannot update... without a draft.

Delete is a logical retirement, not physical removal — data is preserved to maintain bitemporal history. The response includes "retired": true.

Items with kanban card “successors” in the bitemporal timeline cannot be deleted. The API returns: "Record[ITEM] cannot be deleted because it has a successor". Retire items instead, or delete all associated cards first.

The /lookup-* endpoints (suppliers, facilities, units, types, subtypes, usecases, departments) are typeahead/autocomplete endpoints. They require a name query parameter and return matches, not full lists. Use the /query endpoints for full data listing.

All API responses wrap data in a payload property. For queries:

  • payload.data is the results array
  • payload.total is the count

The locator field on items is a nested object {facility, department, location, subLocation}, not a flat string.

  1. Query Object Complexity: Clients are responsible for constructing the JSON Query object; errors produce runtime failures, not schema validation errors.
  2. Page Token Security: Current page tokens are strings (serialized Query); no integrity checking is implemented.
  3. Performance: Query performance depends on Universe implementation and database indexing. No projection support — full EntityRecord is always returned.
  4. No Full-Text Search: The filter mechanism is for structured data only.
  5. No Aggregations: No standard mechanism for COUNT, SUM, AVG via query API.
  6. GET Pagination: Filters only apply on the initial POST request; GET /query/{page} does not accept a filter body.

Universes with EntityServiceConfiguration accept both camelCase JSON field paths and snake_case column names as locators. For universes not yet configured with a structured translator, only raw column names are accepted — test before relying on camelCase paths.

Orphaned card references (cards that reference deleted items) can cause 500 errors on the /details endpoint.

The frontend does not automatically poll for data changes. After API mutations, manual browser refresh is required to see changes in the UI.