JsonUI
← ConceptsData models from OpenAPI`jui build` reads OpenAPI files in `docs/api/` and emits two layers per platform: a fully regenerated DTO (wire-shape 1:1) plus a one-time Domain scaffold (user-owned). The same schema source of truth drives iOS, Android, and Web — no more rewriting three platform-specific models. Shared swagger consumed by many apps can be scoped per-app via `api.schemas.*` path / schema filters. MCP Group E exposes discovery + dry-run tools so an agent can reason about your API surface without parsing files itself.~9 min read
The drift problemWithout a generator, every shared API schema gets rewritten three times — once for Swift `Codable`, once for Kotlin `data class`, once for TypeScript `interface`. Each rewrite is a place the wire shape can drift away from the contract. The fix is not 'write better tests'; it is 'never write three copies in the first place'.
DTO + Domain — two layers, one boundaryThe split runs along the codegen / user boundary: DTO files are owned by the generator, Domain scaffolds are owned by you after the first build. Read the table top to bottom — the further you go, the clearer it gets that the two layers exist to be touched by different people on different cadences.
Owner`jui build` (regenerated)You (first build then yours)
Regeneration cadenceEvery buildOnce; skipped if file exists
What the file holdsWire-shape mirror — fields, optionality, enumsWrapper + proxies + computed + stored extras
What the DTO looks likeThe DTO mirrors the schema 1:1. It is regenerated on every build, so editing it by hand has no effect — your changes get overwritten next build. Pick a platform tab to see the shape. A field constrained to a fixed set of values becomes a generated enum type, named after the schema and the field (`kind` on `Msg` gives `MsgKind`), and a schema that is nothing but such a constraint becomes an enum in its own right. Since jsonui-cli 1.6.14 the OpenAPI 3.1 spelling `const: "chat"` produces exactly the same thing as `enum: ["chat"]` — with or without an explicit `type` (bare `const` infers it from the value, which is the form FastAPI's `Literal` emits), keeping any `default` alongside it, down to byte-identical generated DTOs on all three platforms. Before 1.6.14 the const spelling was not read here and the field quietly came out as a plain string, so v1.6.14 is the floor if your schemas use it. The contract checker learned the same equivalence one release earlier, in 1.6.11, so both halves of the pipeline now read the two spellings as one thing. A schema's `description` travels into the generated type as a doc comment, and since jsonui-cli 1.7.25 the generator escapes it on the way. The hazard is real on every platform but takes a different shape on each: Kotlin block comments nest, so a `/*` in prose swallows the KDoc and everything after it; TypeScript's do not nest, so the dangerous character is the closing `*/`, which ends the JSDoc early and leaves the rest of the sentence as code. Measured on a one-field schema whose description contains both: on 1.7.24 the generated `.ts` does not compile — `TS1131: Property or signature expected` and five more on that line — and on 1.7.25 the same schema produces `/api/admin/ *` and `* /` inside an intact comment, which type-checks. Nothing about the field, the type or the wire shape changes; only the comment does.
Generated/UserDto.swift
// Generated. Do not edit by hand.
struct UserDto: Codable, Sendable {
let id: String
let displayName: String
let createdAt: String // wire shape: ISO-8601 string
let role: String // wire shape: "admin" | "member" | "guest"
}
Discriminated unions — oneOf + discriminator (since 2026-05)Field-level `oneOf` with an explicit `discriminator.propertyName` + `discriminator.mapping` is supported in v1 — codegen materializes it as a sealed enum (iOS), sealed class with custom KSerializer (Android, kotlinx mode only), or discriminated TS union with `kind: ...` tags. Codegen also synthesizes a forward-compatible `.unknown` case so unknown wire tags decode without throwing. Since 2026-07, schema-level `oneOf` + `discriminator` (the top-level envelope) is supported too: the generated `{Name}Dto` is a self-decoding union that reads the tag inside the payload and re-injects it on encode, with the same `.unknown` forward-compat arm. `anyOf` halts permanently (see §9).
docs/api/stream.json (excerpt)
// swagger.json — field-level oneOf with explicit mapping
StreamEvent:
type: object
properties:
type: { type: string }
content:
oneOf:
- $ref: '#/components/schemas/StreamConvIdContent'
- $ref: '#/components/schemas/StreamThinkingContent'
discriminator:
propertyName: type
mapping:
conversation_id: '#/components/schemas/StreamConvIdContent'
thinking: '#/components/schemas/StreamThinkingContent'
Model/Generated/StreamEventDto.swift
// iOS — generated DTO with sealed Content + dispatching init/encode
struct StreamEventDto: Codable, Sendable, Equatable, Hashable {
let type: String
let content: Content
 
enum Content: Codable, Sendable, Equatable, Hashable {
case conversationId(StreamConvIdContentDto)
case thinking(StreamThinkingContentDto)
case unknown // forward-compat
}
 
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.type = try c.decode(String.self, forKey: .type)
switch self.type {
case "conversation_id": self.content = .conversationId(try c.decode(StreamConvIdContentDto.self, forKey: .content))
case "thinking": self.content = .thinking(try c.decode(StreamThinkingContentDto.self, forKey: .content))
default: self.content = .unknown
}
}
}
Constraints: at field level `mapping` is required and `propertyName` must name a sibling property of the same parent schema. At schema level (since 2026-07) `mapping` may be omitted when every variant declares the tag property as a `const` string or a single-value string enum — tags must be strings and unique; explicit mappings are cross-checked against variant tags (`discriminator-tag-conflict`). Variants must be top-level schemas referenced by `$ref`, and a union schema cannot be a variant of another union (permanent). Android requires `api.platforms.android.serializer: "kotlinx"` in `jui.config.json` — `moshi` and `none` halt for oneOf-bearing schemas. `type: string` / `type: array` top-level schemas (typical NDJSON streaming variants) are also supported as wrapper DTOs.
What the Domain scaffold looks likeThe Domain scaffold is a thin wrapper around the DTO, generated once. After the first build it is yours to extend — add proxies, computed properties, type conversions, or stored state the wire shape doesn't carry.
Domain/User.swift (scaffold)
// Domain/User.swift — generated ONCE, then yours.
// Add proxies / computed / Date conversion as needed.
import Foundation
 
struct User {
let dto: UserDto
}
 
extension User {
// proxy
var id: String { dto.id }
}
The customization zoneFour patterns cover the common cases: a simple proxy that forwards a DTO field unchanged; a type conversion that exposes a richer type (Date from an ISO-8601 String); a computed property derived from one or more DTO fields; and stored state the wire shape never had. Here is one Swift Domain with all four in the same file:
Domain/User.swift
// Domain/User.swift — fully worked example.
import Foundation
 
struct User {
let dto: UserDto
// stored — extra state the wire shape doesn't carry
var lastViewedAt: Date?
}
 
extension User {
// proxy
var id: String { dto.id }
 
// Date conversion (wire shape was ISO-8601 String)
var createdAt: Date {
ISO8601DateFormatter().date(from: dto.createdAt) ?? .distantPast
}
 
// computed / derived
var isAdmin: Bool { dto.role == "admin" }
}
Kotlin and TypeScript variants follow the same recipe — see the guide §8 'Customize Domain' for the per-platform code.
Scope what gets generated — path filterOne shared swagger can be consumed by many apps. Each consumer scopes what gets generated via `api.schemas.{include_paths, exclude_paths, include_schemas, exclude_schemas}`. The reachable schema set is computed transitively from the kept endpoints — anything the kept paths can `$ref` to is pulled in, the rest is dropped as dead code.
jui.config.json (excerpt)
// jui.config.json — only generate schemas reachable from /api/auth/* and /api/users/*
{
"api": {
"schemas": {
"include_paths": ["/api/auth/*", "/api/users/*"]
}
}
}
Full filter mechanics — evaluation order, glob rules, 0-schema warning behavior — live in /guides/api-data-models §3. One source of truth.
Discovery from your agent — MCP Group EThree MCP tools form Group E (API Model Discovery). They let an agent enumerate swagger files, list generated DTOs / Domain scaffolds / orphans per platform, and dry-run filter changes before you write any code or commit anything. Per-tool input / output schemas live on /reference/mcp-tools.
list_api_specsEnumerate swagger files in `docs/api/`Run at session start to learn whether the project consumes OpenAPI at all, and surface parse halts before any codegen.
list_api_modelsPer-platform inventory of generated DTOs, Domain scaffolds, and orphansUse when you want to know what exists today — useful before deleting a schema (find the Domain scaffold first) or auditing for orphans after a filter change.
preview_api_model_syncDry-run filter changes — returns kept / filtered / skip_domain / halts as JSONCall before committing a filter change so an agent can show kept_schemas vs filtered_out and you can confirm intent before writing any files.
Lifecycle — what happens on every buildFive steps run, in order, on every `jui build` that touches `docs/api/`:
DTO full regenerationEvery kept schema produces a fresh DTO file. Existing DTO content is overwritten byte-for-byte — no merge, no preservation.
Domain scaffold skip-if-existsFirst build creates `Domain/<Name>.swift` (or `domain/<Name>.kt` / `src/domain/<Name>.ts`). Subsequent builds detect the existing file and leave your customizations alone.
Filter application`api.schemas.*` is evaluated. Schemas outside the kept set are simply not generated — they leave no file behind. Reachability is transitive from kept endpoints.
Orphan handlingIf a schema was previously generated and is now filtered out, the DTO file becomes an orphan. `jui ls api-models` lists the orphans (`--json` for CI); cleanup is your decision (you may still want the Domain scaffold for historical reasons).
Drift check`jui verify --fail-on-diff` recomputes the DTO from the spec and byte-compares against the committed file. If you (or a bad merge) edited a DTO by hand, this trips and the build refuses to advance.
ERROR halts — permanent & conditionalThe halt table below is split in two: permanent halts and conditional halts. Permanent (2+1, by design — not pending features): `anyOf` (untagged unions have no portable native representation), direct self-reference without a collection boundary, and using a union schema as another union's variant. Conditional halts fire only in specific configurations: a discriminator mapping that can be neither declared nor inferred, kotlinx-only features on a moshi/none Android serializer (unions, wrappers, `api.format_mapping`), URL `$ref`s / refs escaping the api directory, and YAML 1.1 type coercion. Everything else that used to halt — YAML input, multi-file `$ref`, schema-level `oneOf`, format-aware mapping — is supported as of 2026-07. When a halt fires you fix the schema, opt out (`skip_domain`, `api.format_mapping_exclude`), or restructure as the message suggests; there is still no silent fallback.
format-aware mappingSupported since 2026-07, opt-in: set `api.format_mapping: true` and `date-time` / `uuid` / `binary` map to native DTO types — iOS `Date` / `UUID` / `Data`, Android `kotlinx.datetime.Instant` / `Uuid` typealias / base64 `ByteArray`, Web `Date` + generated wire parse/serialize helpers. Default stays off (existing output is byte-identical); `api.format_mapping_exclude: ["legacy.json"]` opts individual docs out. Android requires the kotlinx serializer (+ kotlinx-datetime dependency) — moshi/none halts rather than silently keeping `String` while iOS gets `Date`.Mind serialization normalization: re-emitted values are semantically equal, not byte-equal — UUIDs re-encode uppercase on iOS, `+09:00` offsets normalize to UTC `Z`, fractional-second digits follow the formatter. If your server echo-validates raw strings, keep that doc in `api.format_mapping_exclude`. Web note: `Date` DTOs are mutable references — treat parsed DTOs as immutable, and expect naive serializable-state middleware to flag them.
anyOf (untagged union)`anyOf` (untagged union) halts — permanently. There is no portable native representation across Swift / Kotlin / TypeScript, so this is a design decision, not a pending feature. Schema-level `oneOf` + `discriminator` (the envelope form) is supported since 2026-07, and field-level `oneOf` + `discriminator` + `mapping` since 2026-05 — see §3.Restructure as `oneOf` with a `discriminator` tag — either a schema-level union (tag property inside each variant) or a field-level union (sibling tag in the parent schema). A union schema cannot itself be a variant of another union (`union-variant-not-supported`, permanent) — flatten nested unions into a single oneOf.
discriminator mapping missing / not inferrableSince 2026-07, a schema-level union without an explicit `mapping` is inferred safely from each variant's internal tag property — a `const` string or a single-value string enum; tags must be strings and unique, and every variant must declare one, else the loader halts with the reason. The inferred mapping is printed as a WARNING. Field-level oneOf still requires an explicit `mapping`. Android codegen requires `serializer: "kotlinx"` for any oneOf/union schema; `moshi` and `none` halt with a dedicated message.Either add `mapping` explicitly (`mapping: { foo: '#/components/schemas/FooContent' }`) or, for a schema-level union, declare the tag on each variant (`pet_type: { type: string, enum: [dog] }`). The loader cross-checks explicit mappings against variant-internal tags and halts on contradictions (`discriminator-tag-conflict`). For Android, set `api.platforms.android.serializer: "kotlinx"` in `jui.config.json`.
multi-file $refSupported since 2026-07 — relative `$ref` between files inside `docs/api/` (with or without `./`) is resolved before parsing. Shared schemas referenced from multiple docs are generated once; their bodies must be identical or the load halts with `cross-doc-schema-conflict`.Still halting: URL `$ref`s, refs escaping `docs/api/` (`ref-outside-api-dir`), pointers outside `#/components/schemas/` / `#/definitions/` (`ref-non-schema-pointer`), and cross-file cycles (`cross-file-ref-cycle` — co-locate mutually recursive schemas in one file).
YAML inputSupported since 2026-07 — `.yaml` / `.yml` swagger files are parsed in memory (PyYAML required; `jui` halts with install guidance when it is missing). The canonical authoring format remains JSON and nothing is converted on disk.Watch YAML 1.1 implicit typing: unquoted `NO` / date literals in enum members or mapping keys halt with `yaml-type-coercion` (quote the value, e.g. `enum: ['NO']`). Keeping a stale converted `foo.json` next to `foo.yaml` halts with `duplicate-swagger-basename` — keep exactly one.
direct self-refA property whose `$ref` points back to the same schema (without an array boundary) creates a value-type cycle that has no portable native representation. This halt is permanent — the collection boundary is the supported design, not a stopgap.Break the cycle with a collection — `children: { type: array, items: { $ref: '#/.../Self' } }`. Arrays absorb the cycle.
Where to go next
API data models — the cookbookCopy-pasteable recipes: setup, filter syntax, MCP preview, Domain patterns, Android serializer choice, halt table./guides/api-data-models
Why spec-firstThe same one-source-of-truth principle that drives the API data model layer is what makes the whole spec-first workflow tick./concepts/why-spec-first
One Layout JSON per screenOne Layout JSON drives three native UIs — the API model side now mirrors that property with one OpenAPI schema./concepts/one-layout-json