The REST source catalogue
A screen can declare a REST endpoint inline, right where it is consumed:
@RestOptions(url = "https://api.example.com/countries", valuePath = "code", labelPath = "name")String country;That is the right thing for an endpoint exactly one screen uses. It stops being the right thing the moment a second screen needs the same one:
- Five screens consuming one endpoint are five copies to edit when the host changes.
- There is nowhere to state what is true of the endpoint rather than of one screen — its base URL, its auth, whether it should be fetched through the server, which secrets it needs.
- A statically deployed bundle cannot be re-pointed at another environment without a rebuild, because the URL is baked into every screen that uses it.
- Nothing says the five declarations are one endpoint, so anything derived from them has to guess by comparing URL strings.
The catalogue fixes all four by naming each endpoint once.
Declaring it
Section titled “Declaring it”Two producers feed one table, exactly like the route registry: annotations are
the derived half, an authored specs/ui/sources.yaml is merged on top, and the authored entry
wins.
sources: - name: countries description: ISO country codes source: url: https://restcountries.com/v3.1/all?fields=cca2,name valuePath: cca2 labelPath: name.common
- name: orders description: The orders a screen lists source: url: /api/orders?since=${state.since} itemsPath: data totalPath: meta.total fields: customerName: customer.nameor in Java, wherever it reads best — typically the @UI app class or a dedicated catalogue class:
@UI("")@RestSource(name = "countries", url = "https://restcountries.com/v3.1/all", valuePath = "cca2", labelPath = "name.common")@RestSource(name = "orders", url = "/api/orders?since=${state.since}", itemsPath = "data", totalPath = "meta.total", fields = {"customerName=customer.name"})public class Home {}Source names are global, not relative to a mount: a source is an endpoint, not a screen, and two mounts consuming the same endpoint should say so with the same name.
For a catalogue that comes from configuration or differs per environment, implement
RestSourceCatalogSupplier on a bean.
Referencing it
Section titled “Referencing it”Any of the four surfaces takes a source instead of a url:
@RestOptions(source = "countries")String country;What the surface declares still wins over the entry, so it can point at a shared endpoint and still map the response its own way:
@RestOptions(source = "countries", labelPath = "name.official")String country;The reference travels to the renderer as a name, and the catalogue travels with the app metadata —
or, for a static deployment, once in the bundle’s manifest.json. That one table is what makes
re-pointing a CDN deployment an edit instead of a rebuild.
A reference the catalogue does not carry is never resolved to something half-built: the fetch fails visibly rather than quietly calling an endpoint nobody declared.
fields: reading a nested value under a flat name
Section titled “fields: reading a nested value under a flat name”A listing reads each column by using the column id directly as the dot path, and a Java record
field cannot be called customer.name. So a nested response field could not reach a flat column at
all — and no surface can fix that on its own, because the surface is where the constraint lives.
Declaring it on the source fixes it once for every screen that consumes it:
fields: customerName: customer.name roomNumber: room.numberprovenance: what you serve versus what you consume
Section titled “provenance: what you serve versus what you consume”Each entry says whether the endpoint is one somebody already serves or one this project still has to build:
| value | meaning |
|---|---|
existing | somebody else’s. Documented, verifiable against their published OpenAPI, never generated. |
generate | yours to build. It is what mateu:server generates a controller for. |
auto (default) | inferred from the URL: relative or same-origin is yours, another origin is somebody else’s. |
Declare it explicitly when you disagree with the inference:
- name: invoices provenance: generate source: url: https://api.acme-internal.example.com/invoicesWithout the distinction the derived artifacts would be wrong in both directions: generating a Spring controller for a third party’s public API, and silently omitting an endpoint you owe.
Proxy mode
Section titled “Proxy mode”proxy: true makes the fetch go through the Mateu server instead of the browser — no CORS, and
${secret.X} injected server-side. With a catalogue this gets safer, not just tidier: the
endpoint the proxy calls is resolved from a table the server holds, rather than from an annotation
scattered across fields, and never from the request. That is what keeps the proxy from becoming an
open relay.
Per-environment overrides
Section titled “Per-environment overrides”pre and pro usually differ only in where the endpoints live. An
environment file re-points named sources — baseUrl, url,
headers, proxy — for MATEU_ENVIRONMENT=pre, without editing sources.yaml, and the overlay
reaches the wire, the proxy and the bundle manifest alike.
Sample data: designing without an API
Section titled “Sample data: designing without an API”A source can carry sample data — the response the endpoint would return — so a listing, a select or a record shows realistic rows with no backend and no API at all:
sources: - name: orders source: {url: /api/orders, itemsPath: data} totalPath: meta.total sample: # the RESPONSE, so itemsPath / totalPath / fields apply as usual data: - {id: 1, customer: Acme, status: OPEN, total: 120.5} meta: {total: 1} - name: customers source: {url: /api/customers} sampleFile: fixtures/customers.json # JSON or YAML, relative to specs/uiAn inline source may carry sample: too (rowsSource: {url: …, sample: […]}); the surface’s own
sample wins over the catalogue entry’s.
When is the sample used? Only in sample mode, and the rule is the same for every renderer and on both legs:
| Where | Sample mode |
|---|---|
| The visual editor — canvas and Play | always |
A bundle built with -Dmateu.bundle.mock=true | on (the manifest says mockSources: true) |
| A running app | only when it opts in: -Dmateu.sources.mock=true or MATEU_SOURCES_MOCK=true (the app metadata then says mockSources: true) |
| Anywhere else | never — the endpoint is called |
Outside sample mode the samples do not even travel: neither the app metadata nor a bundle manifest carries them.
What happens in sample mode. A sampled source is never called and never proxied: a read answers
with a copy of the sample; a write (POST/PUT/PATCH/DELETE) succeeds without persisting anything —
the success message shows, nothing is stored, and a reload shows the sample as it was. A listing over
a sampled source searches, filters, sorts and pages the sample in memory, even when the source
declares a totalPath (a sample cannot honour ${state.page}). Both legs agree: the browser’s direct
fetch and the server’s proxied __restfetch__ short-circuit identically.
What is not covered
Section titled “What is not covered”The mapping reaches any JSON whose pieces are reachable by a dot path — no envelope convention is imposed. What it does not do today:
- Non-JSON responses (XML, CSV, plain text).
- Value transformation — formatting a date, changing units, joining two fields.
- Cursor or offset paging:
${searchText},${page}and${size}interpolate, but there is no arithmetic, sooffset = page × sizecannot be expressed. - A 200 carrying an error in its body; only a non-2xx status is treated as failure.
Why it stops there
Section titled “Why it stops there”What the catalogue gives you is selection, not transformation. A path says where a value is; nothing says what to make of it. That is a deliberate stopping point rather than an oversight, and the reason is worth knowing before you reach for a way past it.
Whatever normalises a response has to work on BOTH legs. A source is fetched in one of two
places: the browser (direct) or the server (proxy: true — which is what keeps an API key out of the
browser and sidesteps CORS). A mechanism that only exists on one leg means the same source behaves
differently depending on a flag the author set for an unrelated reason, and the two halves drift.
Every defect this area has produced has had that shape: the proxy flag read from the declared
source instead of the resolved one, and JSON body values escaped on one side and not the other.
Three ways past it, in the order they are worth trying:
- A mapping expression instead of a bare path.
${…}interpolation already exists on both legs (TemplateInterpolatorserver-side,interpolateclient-side), so extendingfieldsfromcustomer.nameto${row.first} ${row.last}costs no new machinery and stays symmetric. It covers most of what is missing above: joining, formatting, defaults. - A transform registered by the host app — the shape
registerExternalAuthProvideralready has for headers. Real code, no build step, but client-side only: a proxied source would be unnormalised, so this suits an app that does not proxy. - A web worker supplied by the developer. What it buys over (2) is genuine isolation — third- party code with no reach into the DOM or app state — and parsing off the main thread. It costs a JS file to serve, version and allow through the CSP, and it is client-side only, so a proxied source would need an equivalent server-side adapter or it stops agreeing with itself.
None of this is implemented. If your API is under your control, the cheapest fix is usually not here
at all: have the endpoint serve the shape the screen needs. demo-starwars is the worked example
— it uses only itemsPath and totalPath, no fields at all, because the service it reads
denormalises on the way out (homeworldName beside homeworldId). That is also why it is not
evidence that the mapping is sufficient: it never exercises the gap.