JsonUI
← GuidesReferencing the API canon from a specA dataFlow method that declares an `endpoint` is already pointing at an operation in your OpenAPI documents. Since jsonui-cli 1.7.0 it can reference that operation instead of restating it: `"params": "@canonical"` expands to the operation's parameters at build and validate time, and `"returnType": "@canonical.wire"` lifts the schema name its success response carries. Whatever the canon does not describe stays hand-written beside the mark.~7 min read
1. What a mark isTwo marks, each a string standing in the position it replaces. `@canonical` stands in for a method's `params` and expands to the operation's `parameters` plus the properties of a JSON `requestBody` — POST-shaped methods usually carry their arguments in the body, so a version of this feature that read only `parameters` would find a fraction of what is there. A parameter the canon marks `required` becomes a non-optional type; everything else gets the optional spelling — with one qualifier for body properties, in the next section. `@canonical.wire` stands in for `returnType` and resolves to the schema name the success response references. Since jsonui-cli 1.8.18, `jui build` also prints one NOTE line per spec naming the arguments whose canon type is an inline enum: they expand as `String` while the DTO generated from the same schema holds a generated enum, so a repository converts at that boundary. It is a NOTE, not a warning — and neither changes the build's exit code.Reference instead of restating:
dataFlow.repositories[].methods[]
{ "name": "listItems",
"endpoint": "GET /api/items",
"params": "@canonical",
"returnType": "@canonical.wire" }
• Marks are read in `dataFlow.repositories[].methods[]` and `dataFlow.useCases[].methods[]` — the sections that describe transport. A mark on a ViewModel method is an error, not a silent no-op: `'@canonical' is not expanded here. A ViewModel method does not declare a transport — an endpoint belongs on a repository method, and the ViewModel calls that. Move the declaration, or write the value out.` Until 1.7.2 the same spec passed validation with the marker left in place as the parameter list, which is worth knowing if you have specs written against an earlier version.• Both tools expand marks through one implementation, so `jui build`, which writes the repository stubs, and `jsonui-doc`, which validates the spec and renders its HTML, cannot disagree about what a mark meant.
2. What expansion producesExpansion yields a plain parameter list, written into the document before anything else reads it — the HTML, the other spec checks and the generated stubs all see the expanded list rather than the marker. Below is one operation with a required query parameter and an optional one, and one carrying a JSON body. The types are the spec's Swift-style spelling, which each platform then maps.
docs/api/items.json (abridged)
GET /api/items
parameters:
- name: page_size required: true type: integer
- name: cursor type: string
 
POST /api/items
requestBody (application/json):
required: [display_name]
display_name: string
note: string
Measured against the fixture above:
after expansion
// "params": "@canonical" on GET /api/items
[ { "name": "page_size", "type": "Int" },
{ "name": "cursor", "type": "String?" } ]
 
// "params": "@canonical" on POST /api/items — the body's properties
[ { "name": "display_name", "type": "String" },
{ "name": "note", "type": "String?" } ]
• Path variables are arguments too. The expansion does not filter by where a parameter lives, so `GET /api/venues/{venue_id}/items` contributes `venueId` alongside the query parameters. Route matching, by contrast, normalizes variable names away — `{id}` and `{item_id}` are the same route. The same rename therefore leaves resolution untouched and changes the generated signature, which is worth knowing before you read a conversion diff.• For body properties, `required` is an AND with `requestBody.required`. A caller may omit an optional body entirely, so a property inside it cannot be an unconditional argument: with `requestBody.required` unset, everything in the body expands optional however the schema marks it. Since 1.7.1 that says so rather than leaving you to notice, and it is a warning rather than an error because the declaration is legal and the expansion is correct.The warning, verbatim:
validate / build (warning)
the request body declares display_name as required, but
`requestBody.required` is not set — the caller may omit the body
entirely, so these expand optional. Set `requestBody.required: true`
in the API document if they are meant to be mandatory arguments.
3. Mixing hand-written entries inA method rarely takes exactly the canon's arguments and nothing else. Write the mark as one entry of a list and put your own entries beside it: the mark expands where you wrote it, so the surrounding order is yours. If a hand-written entry has the same name as a canonical one, yours wins and the canonical entry is dropped — that is how a stricter type survives, and how a client-side argument the API never declares gets in. Name comparison ignores case, so `pageSize` beside a canonical `page_size` is one argument, not two.The mark is an entry, not a mode:
mixed form
// declared
"params": [ "@canonical",
{ "name": "onProgress", "type": "((Int) -> Void)?" } ]
 
// expanded (POST /api/items)
[ { "name": "display_name", "type": "String" },
{ "name": "note", "type": "String?" },
{ "name": "onProgress", "type": "((Int) -> Void)?" } ]
 
// a hand-written entry of the same name wins, and keeps its own type
"params": [ "@canonical", { "name": "pageSize", "type": "Int32" } ]
-> [ { "name": "cursor", "type": "String?" },
{ "name": "pageSize", "type": "Int32" } ]
4. How the names are spelledAPIs and client code often disagree about casing, and the canon's spelling would otherwise become the argument label in generated code on three platforms. `spec.canonical_param_case` in jui.config.json decides: `asIs` (the default) keeps the canon's spelling, `camelCase` and `snake_case` convert it. It affects only names that came from a mark — hand-written entries are never re-spelled. Set it before converting a spec rather than after: it changes which canonical name collides with which hand-written one, and therefore which entries survive. Since jsonui-cli 1.7.18 a value outside those three is an error naming the config file and listing them; before that a typo was read by nothing and the run proceeded as `asIs`, so a project that meant `camelCase` and wrote `camelcase` got the document's own spelling everywhere, green.Same operation, three settings:
jui.config.json
"spec": { "canonical_param_case": "camelCase" }
 
asIs (default) page_size display_name
camelCase pageSize displayName
snake_case page_size display_name
5. Why returnType is different`returnType` does not accept `@canonical`; written there it is left alone rather than expanded. The two documents describe different layers and both are right: a spec's return type is the domain type the app works in (`[ItemSummary]`, `UserProfile`), while the canon's is the wire type the endpoint returns (`ItemSearchResponse`, `UserProfileResponse`). Lifting the wire type into every spec would quietly redefine what the method returns. `@canonical.wire` is the explicit opt-in — it says the wire type is the return type here — and it resolves only when the success response names a schema. An operation describing its body inline has no name to lift, and says so instead of inventing one.The message when there is no name to lift:
validate / build
'@canonical.wire' needs the operation's success response to name a
schema; this one describes its body inline, so there is no name to
lift — write the type directly
6. Three things that look like marks and are notEach is a place where a reader's expectation and the tool's behavior can part company, and each is pinned by the tool's own tests.• Omitting `params` is not a mark. A method with no `params` key takes no arguments — that predates this feature and still means the same thing. If you want the canon's arguments, write the mark.• A mark that cannot be resolved is an error, never an empty list. Falling back to nothing would generate an argument-less method on three platforms and complain nowhere. The message names the reason: no `endpoint` on the method, an endpoint that is not an HTTP route, one not spelled `<VERB> /path`, a path the canon declares but not for that verb, or a path no document under `api_directory` declares at all.• On `returnType` only `@canonical.wire` does anything. A plain `@canonical` there stays as written — the section above explains why the distinction is deliberate.Every unresolved-mark message, verbatim:
unresolved marks
'@canonical' cannot be resolved: the method declares no 'endpoint', so
there is no operation to read it from
'@canonical' cannot be resolved: 'local cache' is not an HTTP route, and
OpenAPI documents do not describe it — write the value directly
'@canonical' cannot be resolved: '/api/items' is not a '<METHOD> <path>'
declaration
'@canonical' cannot be resolved: 'DELETE /api/items' names a path the API
canon declares, but not for that method
'@canonical' cannot be resolved: 'GET /api/nope' is not declared in any
OpenAPI document under api_directory
7. When not to use a markA method that cannot take a mark is usually not a defect. Across four real projects, 320 endpoint-declaring methods were resolved against their canon and 63% could reference it; most of the rest were deliberate — a spec folding seven flat body fields into one `profile` argument, a method carrying a client-side progress callback, a return type stated in domain terms. That is the spec saying something the canon does not, which is what a spec is for. But not all of them: some are a spec that quietly fell behind the canon, missing a `limit` or a filter the API has had for months. Converting is how you find out which kind you have, because the mark expands to what the canon actually declares and the difference becomes visible. Read the declaration before deciding — the tool cannot tell these two apart, and neither can a reviewer working from the diff alone.• `useCases[].methods[].endpoint` is accepted and marks resolve there the same way, but no project in that census had one — the pattern is untried rather than unsupported.• A canon carries no `description` or `label` for its parameters, so expansion does not produce them. A parameter that needs prose gets a hand-written entry beside the mark; it wins on name and keeps its description.
8. Declaring how you differWriting the parameters out by hand is already a statement that this method is not the canon's — that is what the previous section is about. Since 1.7.4 you can say how it differs and have the tool hold you to it. `canonicalDivergence` sits beside a hand-written `params` and names the difference: `renamed` maps a canonical name to yours, `reason` says why. Validate then compares the difference you declared with the difference that is actually there, and errors when they part company.Declared, and held to it:
a hand-written method that says how it differs
"params": [ { "name": "pageLimit", "type": "Int" },
{ "name": "cursor", "type": "String?" } ],
"canonicalDivergence": {
"renamed": { "page_size": "pageLimit" },
"reason": "The canon abbreviates; the app spells it out."
}
• Why not simply warn on every difference: a hand-written declaration is how a spec says the canon is not the whole story, so turning every difference red would delete the means of saying it — in one real corpus it would have turned 115 hand-written declarations red at once. The question worth asking is not whether a difference exists, but whether the difference that exists is the one you declared.• The declaration is the switch. A method that declares nothing is checked for nothing — a divergence you never mentioned passes silently, which is what lets you adopt this one method at a time instead of in one sweep.• A stale declaration is the error that matters. If the canon is renamed and the difference disappears, the note outlives what it described: `'renamed' says the canon's 'page_size' appears here as 'pageLimit', but 'page_size' is what the params actually say — the divergence this describes is gone. Update the params, or drop the entry.`• Two more rules, both measured: `reason` must be non-empty (`canonicalDivergence needs a non-empty 'reason'`), and it cannot sit on a method that uses a mark — `a method using '@canonical' has no divergence to declare — the mark follows the canon by construction`.• `renamed` is one of four clauses, and on its own it covers less than it looks: in one real corpus it could describe 7 of 37 hand-written declarations, and the ones left out were the longest — the methods folding twenty or thirty canonical fields into a few arguments, which are exactly the ones where a canon change is hardest to notice. `omitted` names a parameter the caller never chooses (a build constant, an environment value). `wrapped` maps one argument to the several canonical fields it carries. `added` names an argument the canon does not declare at all.• `omitted` earns its place by removing a bad incentive. Without it, the way to make a check pass is to add the parameter — and a contract that grew an argument to satisfy a checker is worse than the check catching nothing. On one project a group of methods looked like a real bug (values the client was not sending) until the implementation was read: every one of them was sending a constant, correctly.• Every clause is checked against the canon, and the declaration subtracts from the comparison rather than exempting the method from it. Naming something the canon does not declare fails (`'omitted' names 'legacyFlag', which the operation does not declare — there is nothing here to leave out`), and so does a declaration that covers only part of the difference: `the declared divergence does not account for the whole difference: after applying 'renamed', this method adds 'pageSize'. A declaration subtracts from the comparison, it does not exempt the method from it.`• Check the implementation before you write one. The tool can tell you whether the difference you declared is the difference that is there; it cannot tell you whether that difference is intent or defect. One team, about to declare forty-eight of these in a sweep, found two real omissions first — written as they stood, those two would have been filed as deliberate divergence and stopped being findable.• The clauses do not ask for the same evidence, because they claim opposite things. `omitted` claims an absence: reading the call sites is enough to establish it, since one call passing the value refutes it. `wrapped` claims a presence — that this argument carries those canonical fields — and only the code building the map or DTO can establish that. Filling `wrapped` with whichever canonical fields the params do not mention treats missing and carried as the same thing, and they are not. Two teams arrived at that shortcut independently, at the same rate: in both, the entries written after reading the construction site were right, and the entries filled in without it were the wrong ones.• Getting them wrong is not equally visible, and the difference is measurable. Claim `omitted` for a parameter the method still takes and the residual check fails: the declaration no longer accounts for the whole difference. Claim that a wrapper carries a field it does not carry and validation passes — both names exist in the canon, the residual is empty, and nothing can see inside the wrapper. The first mistake stops you; the second files a real gap as a deliberate difference, where it will not be looked for again. One reported case had a single misreading surface across three clauses at once: a path variable written under `wrapped`, when it was a rename, with its id also listed under `added`.• Part of the wrapped case is machine-checkable, and the part is narrow on purpose: a path variable is interpolated into the URL, so no object carries it — naming one under `wrapped` is an error (`the id travels as its own argument, which makes this a rename or an omission rather than something the wrapper covers`). Query parameters are not included. A first version rejected everything outside the request body and had to be narrowed within the day, because a list endpoint puts its filters in the query and a screen legitimately holds them as one object — the same shape as a body wrapper. The check catches two of four reported mistakes; the other two are a wrapper not carrying a body field it claims, which no document can show.• The way that first version passed its own audit is worth carrying away. It was validated against a corpus and reported no false positives — while a separate defect meant the check never ran on the projects in that corpus, because they all set a naming convention and this one comparison still read the canon's raw spelling. An over-broad rule and a bug that stopped it from running cancelled out, and the result was green. One case in that corpus wrapped seven query parameters and should have failed the over-broad rule; it passed because the lookup missed and treated them as body fields. A green from a check you have not seen fail is not evidence the check agrees with you.• Where to read depends on what the wrapper is, and grep does not settle it. If the holder is a DTO generated from the canon, read its type definition; if it is a hand-written type or a plain map, read the code that assembles it; if it is a single primitive, the method body. Two audits tried shortcuts and both produced plausible, wrong output: grepping the method body reported 32 of 34 clauses as unsupported, because the implementation passes a DTO through and the field names never appear there; two regular expressions over the construction site missed carried fields both times, on a conditional spread and on a comment between entries. The mistakes run in both directions — listing a field the wrapper does not carry hides a real gap, and dropping one it does carry states an absence that is not there.• Write `renamed` keys in whatever spelling `canonical_param_case` produces, not the canon's raw one: with `camelCase` set, `"pageSize": "pageLimit"` matches and `"page_size": "pageLimit"` errors with `'renamed' maps 'page_size', which the operation does not declare`. Both sides of the map are read after the convention is applied. This is worth checking against your version — before 1.7.6, a project whose canon and convention came from different config files expanded under one spelling and compared under the other, and neither spelling passed.
9. One method, several specsA method reached from a shared component is declared in every screen spec that uses that component. The reason is traceability rather than generation — and structurally, a component spec has no `dataFlow` of its own, so the shared component cannot declare it. The consequence is that the same method legitimately appears in several specs, and only a disagreement between them is wrong: one implementation cannot satisfy two signatures. Since 1.7.5 validate compares them and names the files that disagree. It is visible only when you validate a directory — pointed at a single file, the other declaration is out of view and the file passes.The same two files, two ways:
validate spec
$ jsonui-doc validate spec docs/screens/json/a.spec.json
Result: PASSED
 
$ jsonui-doc validate spec docs/screens/json
[ERROR] BadgeRepository.loadBadge is declared differently by 2 spec(s) —
one implementation cannot match more than one of them:
a.spec.json: () -> BadgeCount
b.spec.json: () -> Int
Result: FAILED (0 of 2 spec file(s), 1 cross-spec disagreement(s))
• Platform-scoped declarations are not disagreements. Two specs declaring the same method with `platforms: ["ios"]` and `platforms: ["android"]` describe one implementation per platform, so `UIImage` against `Bitmap` is not a conflict. A version of this check that ignored `platforms` produced four false positives on a project with no real ones — which would have been the whole of its output there.
10. Which config answersTwo questions have to be answered from the same place: where the API documents are, and which spelling convention applies. A single-tree project never notices — one `jui.config.json` at the root answers both. A split tree does: specs under `docs/<face>/` while the build config lives in `<face>/`. There, the config nearest the spec is whichever partial one happens to sit on that path, and the face's own settings are not consulted at all. Since 1.7.8 a stub on the spec's own ancestry can name its owner, and the run resolves both answers from that one file.The stub, and what it changes:
split tree
// docs/web/jui.config.json — a stub on the spec's own ancestry,
// naming the config that owns this face
{ "extends": "../../web/jui.config.json" }
 
// web/jui.config.json — the owner: canon and convention together
{ "api_directory": "../docs/api",
"spec": { "canonical_param_case": "camelCase" } }
 
with the stub convention = camelCase (every entry point tried)
without it convention = unset, canon from the root config
• Measured on a split-tree fixture: with the stub, the face's `camelCase` answers from every entry point tried; without it, the convention comes back unset while the canon still resolves from the repository-root config — the two answers coming from different files is the failure this closes.• A stub that points nowhere is an error, because the settings it was supposed to bring do not arrive and the generated output changes: `'extends' names no file ('../../nope/jui.config.json') — the settings it points at, including `spec.canonical_param_case`, are not being read. Fix the path, or remove the key if this project does not use one.`• Misspelling the key is a different failure and gets a different treatment. `extend` instead of `extends` is not a broken reference — it is a key nothing reads, so the file looks like one that declares nothing. Since 1.7.10 any unrecognised key warns, `extends` included: `no tool reads 'extend'. Did you mean 'extends'? A key nothing recognises is silently ignored, so the settings under it never arrive and the run looks like one that never configured them. Prefix a key with '_' if it is a note.` A key starting with `_` is a note and is left alone, which is what `_note` and `_comment` are for.• The split between the two is deliberate: a broken `extends` value changes what is generated, so it is an error; an unread key changes nothing by itself, and the harm is that someone believes it does — so it is a warning. Diagnostics cover every config the run consulted along the way, not only the first one it stopped at (1.7.11), so a typo in the repository-root file surfaces even when a nearer stub answered the question.• The absence of a stub is not a defect, it is a project that has not adopted this yet. It is also the third time the same shape has been fixed: a declaration losing to a path walk. The mock directory, the parameter convention, and now the config that owns a tree — each was resolved by a search until a declaration was given precedence over it.
Keep reading
API data modelsWhere the OpenAPI documents live, and how DTOs are generated from them./guides/api-data-models
Verifying implementation against docsThe checker that compares your backend's OpenAPI with the committed one./guides/verifying-implementation-against-docs
Writing your first specThe dataFlow section these marks live in, from the beginning./guides/writing-your-first-spec