@RestOptions — external REST options
@RestOptions fills a field’s select options from an arbitrary (non-Mateu) REST endpoint,
fetched client-side: the renderer calls the URL directly — no Mateu server mediating — navigates
to the array in the JSON response, and maps each item into an option. The field renders as a select.
It is the first surface of decoupling the Mateu UI from the Mateu backend: a Mateu form talking to any REST API.
Target: FIELD (and ANNOTATION_TYPE, so it composes as a semantic annotation).
Example
Section titled “Example”@UI("/rest-options")public class RestOptionsForm {
@RestOptions( url = "https://restcountries.com/v3.1/all?fields=cca2,name", itemsPath = "", // the response root is already the array valuePath = "cca2", labelPath = "name.common") // dot paths navigate nested JSON String country;}Attributes
Section titled “Attributes”| Attribute | Default | Meaning |
|---|---|---|
url | — | The endpoint URL. Supports ${state.x} interpolation, so the request can depend on other field values. |
method | GET | The HTTP method. |
headers | {} | Request headers as "Name: Value" strings (values interpolated) — e.g. an Authorization bearer token. |
body | "" | A request body template (interpolated) for non-GET methods. |
itemsPath | "" | A dot path to the array inside the response (data.countries); blank means the response root is the array. |
valuePath | value | A dot path within each item to the option value. |
labelPath | label | A dot path within each item to the option label. |
A primitive array element becomes its own value and label; a missing valuePath/labelPath falls
back to the other side, so half-specified mappings still render.
How it works
Section titled “How it works”@RestOptions travels on the wire as FormFieldDto.optionsSource (a RestDataSource descriptor:
url, method, headers, body, itemsPath, valuePath, labelPath). The renderer interpolates
the url/headers/body against the live field state, fetches the endpoint, and shapes the JSON into
the select’s options — a pure client-side call, so the endpoint must be reachable from the browser
(CORS-friendly for cross-origin APIs; same-origin needs nothing).
Interpolation and dependent options
Section titled “Interpolation and dependent options”Because url/headers/body support ${state.x}, a select can depend on another field. The
options refetch whenever the interpolated url changes:
@RestOptions(url = "/api/cities?country=${state.country}", valuePath = "id", labelPath = "name")String city;Serving with no Mateu server — what you are signing up for
Section titled “Serving with no Mateu server — what you are signing up for”A statically deployed mount has no Mateu server behind it, which is the point — and it has a consequence worth stating plainly rather than discovering:
So the choice is real, not a detail:
| direct (no server) | proxy = true (server) | |
|---|---|---|
| Who fetches | the browser | the Mateu server |
| CORS | your endpoint must allow the static host | not involved |
| Secrets | whatever the browser can hold | ${secret.X} injected server-side |
| Works statically | yes | no |
A public or read-only API is a fine fit for the direct mode. An API that needs a real credential is not — and a screen that talks to one should stay backend-served.
Server-proxy mode (proxy = true) — CORS & auth hardening
Section titled “Server-proxy mode (proxy = true) — CORS & auth hardening”By default the browser fetches the endpoint directly, so the endpoint must be CORS-friendly and
any auth token would have to live in the client. Set proxy = true (available on all four
annotations — @RestOptions, @RestListing, @RestAction, @RestData) to route the fetch through
the Mateu server instead:
@RestOptions( proxy = true, url = "https://api.example.com/countries", headers = "Authorization: Bearer ${secret.COUNTRIES_TOKEN}", valuePath = "cca2", labelPath = "name.common")String country;- CORS is solved — the browser only talks to Mateu (same-origin); the server talks to the external API server-to-server, where CORS does not apply.
- Secrets stay server-side —
${secret.KEY}placeholders in the url/headers/body are resolved on the server by aSecretsProvider(see below), so the token never reaches the browser. Only the placeholder (${secret.COUNTRIES_TOKEN}) travels on the wire, never its value.
The server resolves the endpoint from what the view declared, never from a url supplied by the
client, so the proxy can’t be turned into an open relay. ${state.x} interpolation works the same
as in direct mode (resolved from the component state).
Views with no annotation to read — RestSourceSupplier
Section titled “Views with no annotation to read — RestSourceSupplier”Reading the annotation is enough for a view written as a Java class. A view assembled at
runtime — a form built from a stored definition, a screen composed from configuration — has no
annotated field for that lookup to find, and proxy mode was closed to exactly the views that most
need it. Such a view implements io.mateu.uidl.interfaces.RestSourceSupplier to say the same thing
programmatically:
public class TaskForm implements RestSourceSupplier {
@Override public List<DeclaredRestSource> declaredRestSources() { return definition.fields().stream() // ← server-side state .filter(f -> f.optionsSource() != null) .map(f -> new DeclaredRestSource(RestSourceKind.OPTIONS, f.id(), sourceOf(f))) .toList(); }}The declarations are read on the server and gate __restfetch__ exactly as the annotations do:
a view declaring at least one proxy = true source advertises the reserved action, and a fetch for
kind + id resolves to the source declared under them (OPTIONS by field id, ACTION by action
id; ROWS and DATA have one per view, so their id is ignored).
Supplying secrets — SecretsProvider
Section titled “Supplying secrets — SecretsProvider”Implement io.mateu.uidl.interfaces.SecretsProvider and register it as a bean:
@Servicepublic class VaultSecrets implements SecretsProvider { public String getSecret(String key) { return vault.read(key); } // null if unknown}With no SecretsProvider registered (or when it answers null), Mateu falls back to reading an
environment variable prefixed MATEU_SECRET_: ${secret.COUNTRIES_TOKEN} →
System.getenv("MATEU_SECRET_COUNTRIES_TOKEN") (a key that already starts with MATEU_SECRET_ is read
as is).
Values in the url are percent-encoded
Section titled “Values in the url are percent-encoded”Every ${…} substituted into a url is percent-encoded according to where it lands, on both legs
(the browser’s direct fetch and the server’s proxied one reach exactly the same url):
| Where the placeholder is | What happens to the value |
|---|---|
the origin — scheme + host (https://${secret.HOST}/x), or a template that starts with a placeholder (${secret.API_BASE}/people) | substituted raw: the origin is configuration. A ${state.x} there is refused — the client must not choose which host is called |
the path (/people/${state.id}) | encoded as one path segment (/ → %2F, ? → %3F…); a value that is exactly . or .. is refused |
the query or fragment (after a literal ? or #) | encoded as a query component (& → %26, = → %3D…) |
The encoding keeps only the RFC 3986 unreserved characters (A-Z a-z 0-9 - . _ ~) — what
encodeURIComponent does plus !'()*. So an id of 1/../../admin?x= reaches /people/{id} as
/people/1%2F..%2F..%2Fadmin%3Fx%3D instead of turning the request into GET /admin. A refused value
fails the call (an error toast), it never goes somewhere else. Headers and the body are not
url-encoded; a JSON body keeps its JSON escaping.
Client-side auth for the direct path (registerExternalAuthProvider)
Section titled “Client-side auth for the direct path (registerExternalAuthProvider)”Proxy mode keeps secrets on the server. If instead you keep the direct (client-side) fetch — the default — but still need to authenticate it (e.g. an OAuth token the SPA already holds), register a client-side auth provider. The renderer calls it right before every direct external fetch and merges the returned headers, so the token is added at fetch time on the client and never travels in the annotation or the Mateu wire:
// web (import from the `mateu` package, alongside registerNeutralNotifier)import { registerExternalAuthProvider } from 'mateu'
registerExternalAuthProvider(({ url }) => url.startsWith('https://api.example.com') ? { Authorization: `Bearer ${tokenStore.current()}` } : {})The provider may be async (e.g. refresh the token first), its headers win over any statically
declared header of the same name, and a throwing provider is swallowed (it never breaks the fetch).
It is ignored on the proxy path (there, secrets are injected server-side). The native renderers
expose the same hook: React Native registerExternalAuthProvider (from src/core/restFetch) and the
IntelliJ plugin RestFetch.externalAuthProvider = { url, method -> … }.
Other backends
Section titled “Other backends”Mateu.NET and the Python backend emit the same optionsSource descriptor, so any renderer fetches
the endpoint the same way:
[RestOptions("https://api.example.com/countries", ItemsPath = "data.countries", ValuePath = "code", LabelPath = "name.common")]public string Country { get; set; } = "";# Pythoncountry: Annotated[str, RestOptions( url="https://api.example.com/countries", items_path="data.countries", value_path="code", label_path="name.common")] = ""Proxy mode ports. proxy is available on all four annotations in every backend — [RestOptions(Proxy = true)] / RestOptions(proxy=True), likewise for listing/action/data. The server resolves the declared source, injects ${secret.X} and fetches server-side (__restfetch__). Supply secrets in .NET via the SyncHandler’s secrets delegate (Func<string, string?>) and in Python via the SyncHandler(secrets_provider=…) argument; both fall back to the MATEU_SECRET_-prefixed environment variable (MATEU_SECRET_<KEY>) when no provider answers, and both percent-encode the url exactly like the Java backend.