Skip to content

Latest commit

 

History

History
362 lines (287 loc) · 17.2 KB

File metadata and controls

362 lines (287 loc) · 17.2 KB

Cross-port REST API contract

The browser-side TypeScript client (@metaobjectsdev/runtime-web + @metaobjectsdev/react + @metaobjectsdev/tanstack) is universal: it ships with the React + TanStack runtime and the generated query hooks + column defs, but it makes no assumption about which language wrote the backend. Any HTTP server — TypeScript Fastify, Java Spring, Kotlin Ktor, C# ASP.NET, Python FastAPI — that speaks the URL grammar and JSON wire format on this page can serve the same React app, with the same generated hooks and grids.

This page is the contract. Implementations that pass it interoperate with the TS client; implementations that don't, don't.

Throughout the doc the worked example is an Author entity in the acme::blog package.

What this is

The contract has two halves: the URL grammar (paths + query-string shape) and the wire format (JSON request / response bodies). Both halves are language-agnostic and stable across the four shipped language ports. Routes can be generated (where the port ships a route generator) or hand-written (where it doesn't) — the wire behavior is identical either way.

The EntityFetcher contract

The browser client never calls fetch directly. Every generated hook delegates to a single EntityFetcher function pulled from React context:

// from @metaobjectsdev/runtime-web
export type EntityFetcher = <T>(path: string, init?: RequestInit) => Promise<T>;

Responsibilities of the fetcher (supplied by the consumer's app, not generated):

  • Resolve the path argument (always starts with apiPrefix, e.g. /api/author?...) to a fully-qualified URL.
  • Attach auth (cookies / bearer token / API key) per the app's policy.
  • Parse the JSON response and return it typed as T.
  • Surface non-2xx as a thrown Error (hooks rely on this for React Query's error state).

The fetcher is supplied once via <EntityFetcherProvider value={fetcher}> at the React tree root; every generated hook reads it via useEntityFetcher() from @metaobjectsdev/tanstack.

URL grammar

These are the routes a generated TanStack hook calls. The apiPrefix prefix (default /api) is set in metaobjects.config.ts and is baked into the generated entity-constants file as $apiPrefix, so the client and server agree on it without runtime configuration.

Routes per entity

Verb Path Purpose
GET /<apiPrefix>/<entity>?filter[...][...]=...&sort=...&limit=N&offset=N&withCount=1 List (with filter / sort / pagination)
GET /<apiPrefix>/<entity>/:id Get by id
POST /<apiPrefix>/<entity> Create
PATCH /<apiPrefix>/<entity>/:id Update (partial)
PUT /<apiPrefix>/<entity>/:id Update (replace) — optional; same body shape as PATCH
DELETE /<apiPrefix>/<entity>/:id Delete

<entity> is lowercased + pluralized per the codegen's pluralization helper (so Authorauthors). Generated TS hooks read $path from the entity-constants file, so the client and the server agree on the path segment without hand-coordination.

Filter operators (8)

Filters use a bracketed qs shape: filter[<field>][<op>]=<value>. A bare value (filter[<field>]=<value>) is sugar for eq. Multiple filters AND together.

filter[or] / filter[and] nesting is a TS-only extension, not part of the cross-port contract — see "TS-only filter extensions" below.

Operator Strings Numbers / Dates Booleans
eq, ne, isNull yes yes yes (eq + isNull only)
in, like yes in only
gt, gte, lt, lte yes

The operator set is gated by field subtype in the generated <Entity>FilterAllowlist. A request with an operator that the field subtype doesn't support → HTTP 400. The operator list is a Tier 1 cross-port invariant — every port's parser must implement these eight and only these eight.

TS-only filter extensions (not part of the cross-port contract)

The TypeScript runtime parser ships five filter behaviors beyond the eight operators. They are NOT part of the cross-port REST contract — the other ports (Java, Kotlin, Python, C#) do not implement them, and a relying adopter must not assume them on a non-TS backend. They are deliberately deferred until real consumer demand (none touch the metamodel vocabulary, so any of them can be added cross-port later as a purely additive, non-breaking change):

Extension What it does
?search=<term> ORs a like across the entity's @filterable string fields
filter[or][N] / filter[and][N] boolean combinators (recursive nesting)
leading-wildcard gating opt-in allow of like patterns starting with %
filter nesting-depth cap rejects deeply-nested or/and (tied to the combinators)

The in-list size cap (reject an in list longer than 100 → HTTP 400) is a safety limit, not a feature — TS enforces it, the other ports currently do not. Unifying that cap cross-port is the one item here worth doing regardless of feature demand (it is a consistency/safety divergence, not a capability).

Sort + pagination

  • sort=<field>:asc|desc — single sort key (multi-sort not in the default contract). Field must appear in <Entity>SortAllowlist.
  • limit=N — page size.
  • offset=N — page offset.
  • withCount=1 — opt-in flag that switches the list response from [<row>...] to { rows: [<row>...], total: <N> } (needed for grid pagination). The grid hook always sends withCount=1.

apiPrefix policy

apiPrefix in metaobjects.config.ts flows through codegen to both sides: the generated server routes mount under it, and the generated client hooks bake it into their fetch URLs via $apiPrefix on the entity-constants file.

export default defineConfig({
  apiPrefix: "/api",     // server mounts /api/author; hooks call /api/author
});

Wire format

Encoding

  • JSON for all request and response bodies (application/json; charset=utf-8).
  • No envelope on single-row responses (GET /:id, POST, PATCH, PUT) — the body is the row.
  • List responses are either [<row>...] (default) or { rows, total } (when withCount=1).

Type encodings (Tier 1 invariant)

Metadata field type JSON type Notes
field.string, field.uuid, field.enum string UUID is canonical hex (8-4-4-4-12).
field.int, field.long, field.double number long MAY be string on overflow; defer to per-port docs.
field.boolean boolean
field.date string ISO 8601 calendar date (YYYY-MM-DD).
field.timestamp string ISO 8601 with timezone (YYYY-MM-DDTHH:mm:ss.sssZ).
field.currency integer minor units Cents for USD, yen for JPY. Float arithmetic is forbidden. Server never formats.
field.object (@storage: jsonb) object Nested per the sub-object schema.
field.object (@storage: flattened) object Same JSON shape — only the storage differs.

The currency invariant is the load-bearing one: every port emits and expects integer minor units on the wire, and the features/field-types.md reference enforces this for each port's codegen output. Float arithmetic for money has bitten every language at least once.

Error response

Non-2xx responses MUST return:

{ "error": "<short_code>", "message": "<optional human string>" }
  • HTTP 400 — validation / filter-parser errors ({ "error": "validation", "issues": [...] } in TS; { "error": "FILTER_UNKNOWN_FIELD", ... } for filter errors).
  • HTTP 404 — { "error": "not_found" }.
  • HTTP 5xx — implementation-defined.

The exact error code vocabulary is not yet a hard cross-port invariant; consumers should treat any 4xx as user-facing and any 5xx as retryable / log-only.

Per-port route codegen status

Port Route codegen Notes
TypeScript shipped — @metaobjectsdev/codegen-ts routesFile() → Fastify (@metaobjectsdev/runtime-ts/drizzle-fastify) AND routesFileHono() → Hono (@metaobjectsdev/runtime-ts/hono) Reference implementation; full filter/sort + withCount support. Both flavors emit byte-identical on-the-wire responses for the same metadata (same envelopes, same status codes, same filter operator parser), so consumers can pick the server framework that matches their runtime (Fastify for long-lived Node, Hono for Workers / Bun / edge).
C# shipped — MetaObjects.Codegen RoutesGenerator → ASP.NET Minimal API MapGet / MapPost / MapPut / MapDelete mounted under apiPrefix; full CRUD.
Java shipped — metaobjects-codegen-spring SpringControllerGenerator + SpringDtoGenerator + SpringRepositoryGenerator → Spring @RestController (Spring Boot 3.x / Spring Web MVC) One controller per writable entity (source.rdb @kind="table"); 5 CRUD endpoints (GET list / GET by id / POST / PATCH + PUT / DELETE); ?sort, ?limit/?offset, ?withCount=1 envelope, 404 + 400 envelopes per the contract. Java 21 record DTOs for request/response; a stubbed <Entity>Repository interface the consumer implements against their persistence layer (JPA / jOOQ / JDBC). Filter operators (eq/ne/...) deferred — see the module's KNOWN_GAPS.md.
Kotlin shipped — metaobjects-codegen-kotlin KotlinSpringControllerGenerator → Spring @RestController One controller per writable entity (source.rdb @kind="table"); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); ?sort, ?limit/?offset, ?withCount=1 envelope, 404 + 400 envelopes per the contract. Filter operators (eq/ne/...) deferred.
Python shipped — metaobjects.codegen.generators.router_generator → FastAPI APIRouter One router per writable entity (source.rdb @kind="table"); 5 CRUD endpoints (GET list / GET by id / POST / PATCH+PUT / DELETE); ?sort, ?limit/?offset, ?withCount=1 envelope, 404 + 400 envelopes per the contract. Consumer wires the repository via FastAPI app.dependency_overrides; the generator emits a Protocol interface so the persistence layer (SQLAlchemy / asyncpg / etc.) is the consumer's choice. Filter operators (eq/ne/...) deferred — see the module's KNOWN_GAPS.md.

Hand-writing a conforming controller

While the planned route-codegen work matures, here is the minimum controller needed to make useAuthors, useAuthor, etc. work against each non-TS / non-C# backend. The shape is the same in every language — mount five (or six, if you want PUT) routes under apiPrefix that match the URL grammar above.

Java — Spring @RestController

// AuthorController.java
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;

@RestController
@RequestMapping("/api/authors")
public class AuthorController {
  private final AuthorRepository repo;

  public AuthorController(AuthorRepository repo) { this.repo = repo; }

  @GetMapping
  public Object list(@RequestParam Map<String, String> qs) {
    var page = repo.find(FilterParser.parse(qs, AuthorFilterAllowlist.INSTANCE));
    return qs.containsKey("withCount")
      ? Map.of("rows", page.rows(), "total", page.total())
      : page.rows();
  }

  @GetMapping("/{id}")           public Author get(@PathVariable long id)              { return repo.findById(id).orElseThrow(() -> new NotFound()); }
  @PostMapping                   public ResponseEntity<Author> create(@RequestBody AuthorInsert in) { return ResponseEntity.status(201).body(repo.insert(in)); }
  @PatchMapping("/{id}")         public Author update(@PathVariable long id, @RequestBody AuthorUpdate in) { return repo.update(id, in); }
  @DeleteMapping("/{id}")        public ResponseEntity<Void> delete(@PathVariable long id) { repo.delete(id); return ResponseEntity.noContent().build(); }
}

Kotlin — Spring @RestController

// AuthorController.kt
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.*

@RestController
@RequestMapping("/api/authors")
class AuthorController(private val repo: AuthorRepository) {

  @GetMapping
  fun list(@RequestParam qs: Map<String, String>): Any {
    val page = repo.find(FilterParser.parse(qs, AuthorFilterAllowlist))
    return if ("withCount" in qs) mapOf("rows" to page.rows, "total" to page.total) else page.rows
  }

  @GetMapping("/{id}")           fun get(@PathVariable id: Long): Author          = repo.findById(id) ?: throw NotFound()
  @PostMapping                   fun create(@RequestBody input: AuthorInsert): ResponseEntity<Author> = ResponseEntity.status(201).body(repo.insert(input))
  @PatchMapping("/{id}")         fun update(@PathVariable id: Long, @RequestBody input: AuthorUpdate): Author = repo.update(id, input)
  @DeleteMapping("/{id}")        fun delete(@PathVariable id: Long): ResponseEntity<Void> { repo.delete(id); return ResponseEntity.noContent().build() }
}

Python — FastAPI router

# author_router.py
from fastapi import APIRouter, HTTPException, Request, status
from .author import Author, AuthorInsert, AuthorUpdate, AuthorFilterAllowlist
from .repo import AuthorRepository
from .filter_parser import parse_filter_qs

router = APIRouter(prefix="/api/authors")
repo = AuthorRepository()

@router.get("")
async def list_authors(request: Request):
    qs = dict(request.query_params)
    page = repo.find(parse_filter_qs(qs, AuthorFilterAllowlist))
    return {"rows": page.rows, "total": page.total} if "withCount" in qs else page.rows

@router.get("/{id}")
async def get_author(id: int) -> Author:
    row = repo.find_by_id(id)
    if row is None: raise HTTPException(status_code=404, detail={"error": "not_found"})
    return row

@router.post("", status_code=status.HTTP_201_CREATED)
async def create_author(input: AuthorInsert) -> Author:
    return repo.insert(input)

@router.patch("/{id}")
async def update_author(id: int, input: AuthorUpdate) -> Author:
    return repo.update(id, input)

@router.delete("/{id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_author(id: int) -> None:
    repo.delete(id)

The filter-parser implementation is the bulk of the work; the route shapes themselves are trivial. The TS parseFilterParams (in @metaobjectsdev/runtime-ts/drizzle-fastify) is the reference — port it into your framework's idiomatic query-builder, gated by the generated <Entity>FilterAllowlist.

Future direction

Cross-port route codegen — for Java (Spring), Kotlin (Spring-Kotlin / Ktor), Python (FastAPI), and a browser-side Angular client — is planned but not yet specced. The planned FR will track:

  • A shared route-shape oracle (analogous to the persistence-conformance corpus) covering list / get / create / update / delete + filter / sort / withCount + the error-response shape.
  • Per-port route generators emitting idiomatic controllers / routers against that oracle.
  • An Angular client (browser-side) that consumes the same generated hook surface as the React client.

Until then, hand-written controllers per the templates above are expected, and they remain the conformance gate.

Verified by

The query semantics behind these routes — filter operators, sort, withCount, identity-by-id, projection read-only-ness — are exercised by the shared corpus at fixtures/persistence-conformance/queries/, which every port runs against an ephemeral Postgres container via scripts/integration-test.sh. Identical normalized results across every port is the contract; deviation is a port bug.

The URL-grammar half (qs parsing, route mounting, status codes, JSON envelope shape) is exercised by the cross-port corpus at fixtures/api-contract-conformance/ (10 scenarios — list-empty, list-with-pagination, list-with-withcount, sort-asc-desc, get-by-id, get-by-id-not-found, create-201, update-patch-and-put, delete-204-and-404, invalid-sort-400). Each port's runner spins up a real HTTP server hosting its emitted routes for the canonical Author entity, walks the scenarios, and asserts byte-shape identical responses against the cross-port expect.body.* vocabulary. TypeScript + Kotlin runners ship; Java / C# / Python runners are planned follow-ups. See docs/CONFORMANCE.md for per-port pass status.

Filter operator coverage (eq / ne / gt / like / in / isNull) is deferred from the API-contract corpus on purpose — backends defer those per their KNOWN_GAPS.md, so a scenario for filter[name][like]=... would fail on every port today. Filter-operator scenarios land alongside the per-port operator implementations.

See also