← GuidesAPI data modelsCookbook companion to /concepts/data-models-from-openapi. Fifteen short recipes covering setup, the add-schema/build/proxy loop, path + schema filter syntax, the two `skip_domain` levels, MCP-driven preview + discovery, the Repository return-type rule, the four Domain customization patterns, Android serializer and Web case-convention picks, gradual migration of hand-written models, reserved-word collisions, cycle rules, drift detection in CI, and the ERROR-halt table with concrete workarounds.~18 min read
Set up docs/api/Place each consumed swagger as a JSON file under `docs/api/` (the directory name is configurable via `api.directory` in `jui.config.json`). For a shared swagger living elsewhere, point at it with a relative path — e.g. `"directory": "../shared-api"`. The directory is enumerated at every build.
jui.config.json
{ "api": { "directory": "docs/api", "schemas": { } }}Add a new schema → run build → write proxiesEnd-to-end: drop a `User` schema into `docs/api/example.json`, regenerate via `jui g api`, then add a `displayName` proxy to the Domain scaffold. Subsequent builds preserve your Domain edits.
shell
# 1. Drop docs/api/example.json with a User schema in components.schemas.# 2. Regenerate DTO + Domain scaffold.jui g api # 3. Open Domain/User.swift (or domain/User.kt / src/domain/User.ts)# and add a proxy:# var displayName: String { dto.displayName.uppercased() }Scope what gets generated — path / schema filterWhen several apps share one swagger, each app uses `api.schemas.*` to scope what gets generated. The reachable schema set is the transitive closure of `$ref`s reachable from the kept endpoints; everything else falls out as dead code (no file written). Globs use `*` to match any character including `/`, are case-sensitive, and have no `**`.api.schemas.* settings referenceEvaluation orderIf the kept set is empty after evaluation, `jui build` logs a WARNING (filter is lenient) and emits no DTO / Domain files. The parser itself remains strict — bad schemas still halt.
include_pathsstring[]glob OK
default: [] (all paths kept)If non-empty, only operations whose path matches at least one entry are kept. Order: applied first.exclude_pathsstring[]glob OK
default: []Drop any operation whose path matches any entry. Applied after include_paths.include_schemasstring[]no glob — exact name match
default: []Pin specific schemas in regardless of path filter (useful for schemas referenced only via inheritance or non-endpoint refs).exclude_schemasstring[]no glob — exact name match
default: []Drop named schemas explicitly. Applied last; wins over include_schemas if both list the same name.skip_domainstring[]no glob — exact name match
default: []App-side opt-out: schemas listed here keep their DTO but skip Domain scaffold creation. OR-evaluated with schema-side `x-jui-skip-domain`.1
Path includesIf `include_paths` is non-empty, narrow the set of operations to those whose path matches any entry.
2
Path excludesRemove any operation whose path matches `exclude_paths`. After this step the kept-endpoint set is final.
3
Schema includesCompute the transitive closure of schemas reachable from kept endpoints, then union with `include_schemas`. This is the candidate set.
4
Schema excludesSubtract `exclude_schemas` from the candidate set. The result is the kept_schemas — what gets generated.
jui.config.json
// Full example: shared swagger, only ship /api/auth/* and /api/users/*// to this app, drop the /api/admin/* surface, and exclude InternalNote.{ "api": { "schemas": { "include_paths": ["/api/auth/*", "/api/users/*"], "exclude_paths": ["/api/admin/*"], "exclude_schemas": ["InternalNote"] } }}Two levels of skip_domainTwo opt-out levels exist: schema-side `x-jui-skip-domain: true` (everyone-skips) and app-side `api.schemas.skip_domain: [...]` (per-app overlay). They are OR-evaluated — either one is enough to skip Domain scaffold generation for that schema. The DTO is still generated either way.
Where it livesswagger schema — `x-jui-skip-domain: true`jui.config.json — `api.schemas.skip_domain: [...]`
ScopeAll consumers of this swagger skip the Domain scaffold for this schema.Only this app skips; other consumers still get the scaffold.
When to pick whichUse when the schema is conceptually 'just a DTO' for everyone (audit events, raw event payloads).Use when you specifically already have a hand-written replacement for this app and don't want a scaffold overwriting it.
evaluation rule
# OR-evaluated pseudo-codeskip_domain(schema) := schema.x-jui-skip-domain == true OR schema.name in api.schemas.skip_domainPreview filter changes — MCP preview_api_model_syncCall `preview_api_model_sync` from your agent before committing a filter change. It returns `kept_schemas` / `filtered_out` / `skip_domain_matches` / `halts` as JSON without writing any file. CLI equivalent: `jui g api --dry-run`.Loop: ask the agent to propose a filter, run preview, eyeball kept vs filtered_out, iterate. The 'show me what changes before you change it' contract keeps an agent honest.
mcp output
// preview_api_model_sync — sample output{ "kept_schemas": ["User", "LoginRequest", "Order"], "filtered_out": ["InternalNote"], "skip_domain_matches": ["AuditEvent"], "halts": []}preview_api_model_synckept_schemasstring[]
Names of schemas that would survive the filter. Sorted alphabetically.preview_api_model_syncfiltered_outstring[]
Names of schemas the filter would drop. Use this to verify your filter is doing what you think it is.preview_api_model_syncskip_domain_matchesstring[]
Names of schemas matched by `skip_domain` (either tier). Their DTOs still generate; their Domain scaffolds do not.preview_api_model_synchaltsHalt[]
Any parser-level halts that would fire on the next build. Empty array means the build would proceed.Inspecting current state — list_api_specs / list_api_modelsTwo read-only tools answer 'what is in the repo right now?'. `list_api_specs` enumerates swagger files with title / version / schema_count / endpoint_count. `list_api_models` returns per-platform DTO files, Domain scaffolds, and orphans (the same set `jui ls api-models` reports).
list_api_specsapi_directorystring
Resolved absolute path of the `api.directory` config — e.g. `/abs/path/to/repo/docs/api`.list_api_specsfilesFileEntry[]
Per-file entry: `{ path, title, version, schema_count, endpoint_count }`. One row per swagger file found.list_api_specshaltsHalt[]
Parser halts that fire on the listed files. Empty array means the discovery completed cleanly.list_api_modelsdto_filesstring[]
Paths of generated DTO files for the requested platform.list_api_modelsdomain_scaffoldsstring[]
Paths of Domain scaffolds for the requested platform — these are user-owned after first creation.list_api_modelsorphansstring[]
Generated files whose source schema is no longer in `kept_schemas` (filtered out, schema deleted, or filter tightened). Same set as `jui ls api-models`.DTO vs Domain — Repository return typeWhen a Repository method declares its `returnType` as a swagger schema name (`User`), the codegen auto-resolves to the Domain type. To get the raw DTO instead, write `UserDto` explicitly. This rule lets you flip between layers per method without touching either generated file.
Repository (return-type rule)
// UserRepository.swiftprotocol UserRepository { // returnType: "User" in the spec -> auto-resolves to Domain User. func loadCurrent() async throws -> User // returnType: "UserDto" -> raw wire shape passes through. func loadCurrentRaw() async throws -> UserDto}Customize Domain — four patternsPick a tab — proxy / type conversion / computed / stored. Each panel shows one Swift snippet; Kotlin and TypeScript equivalents follow the same shape (the relevant Domain file is identical except for syntax).
Domain/User.swift
// Pattern 1 — proxy. Forward a wire-shape property unchanged.extension User { var id: String { dto.id }}Choose the Android serializerThree options for Android DTO (de)serialization. Moshi (default) generates adapters at compile time via ksp; kotlinx.serialization uses `@Serializable` annotations and a runtime format; `none` skips annotations entirely and lets you supply your own. Pick based on what is already in the codebase — switching later is mechanical but touches every DTO.Note (since 2026-05): in `kotlinx` mode the Domain wrapper also carries `@Serializable(with = {Name}Serializer::class)` plus an AUTO-GENERATED `KSerializer` object (marker-fenced `// ╔═══ AUTO-GENERATED Serializer ═══`) that forwards (de)serialization to the DTO. This makes the Domain type usable directly as a Retrofit return type or as a field of another `@Serializable` composite. Existing Domain wrappers (from before 2026-05) get retroactively patched in place on the next `jui build` — no manual migration needed, and the patcher skips files whose shape doesn't match `val dto: {Name}Dto` (e.g. consumer hand-written `data class` replacements). The serializer block is overwritten on every build — keep user code outside the markers.
Annotations@JsonClass + @Json@Serializable + @SerialNameNone — you bring your own
Build setupAdd the ksp plugin + moshi-kotlin-codegenAdd the kotlin-serialization pluginNo extra setup
Pick whenDefault. Compile-time adapters, friendly errors.Already using kotlinx.serialization elsewhere.Custom JSON layer / non-JSON wire format.
build.gradle.kts (excerpt)
// android/app/build.gradle.kts — add the Moshi codegen ksp plugin.plugins { id("com.google.devtools.ksp")}dependencies { ksp("com.squareup.moshi:moshi-kotlin-codegen:1.15.0")}Choose the Web case convention`snake_case` is the zero-cost default — the generated TypeScript DTO mirrors the wire shape, no runtime conversion. `camelCase` requires a runtime case transform on every (de)serialize. Prefer snake_case unless the rest of the codebase is pervasively camelCase.
Wire shape mapping1:1 — `display_name` → `display_name`transformed — `display_name` ↔ `displayName`
Runtime costZeroConversion per (de)serialize
Pick whenDefault. Recommended.Existing codebase is pervasively camelCase.
Migrating hand-written modelsMigrate one schema at a time. For schemas you do NOT want a Domain scaffold for (because you already have a hand-written model), use the §4 opt-outs: schema-side `x-jui-skip-domain: true` for everyone, or app-side `api.schemas.skip_domain` for just this app. The DTO is still generated — you delete your hand-written DTO and keep the hand-written Domain replacement.Type-map shadow (since 2026-05): a name listed in `.jsonui-type-map.json` (with or without a trailing `?`) is treated as a shadowed schema — its Domain scaffold is skipped AND no kotlinx serializer patch runs against it, but the DTO is still emitted so other DTOs that `$ref` it still compile. This is the right opt-out when you want to keep a hand-written `data class` / `struct` that does NOT follow the `val dto: {Name}Dto` wrapper shape (a non-wrapper replacement). Patchers also defensively skip any file whose body doesn't contain `val dto: {Name}Dto`, so accidental corruption of hand-written replacements is prevented.
two ways to opt-out
# Schema-side opt-out (everyone skips this schema's Domain):# docs/api/example.json# components.schemas.LegacyThing:# x-jui-skip-domain: true # App-side overlay (only this app skips):# jui.config.json{ "api": { "schemas": { "skip_domain": ["LegacyThing"] } }}Reserved word collisionsIf a schema property or enum name hits a language keyword (`class`, `public`, `private` on Swift / Kotlin; `default`, `class` on TypeScript), the codegen auto-escapes — backticks on Kotlin, `_`-prefix on Swift, and so on per platform. You see the escaped name in the generated DTO. Vendor extension `x-jui-name: "safer"` lets you override the generated property name explicitly.
Cycles — what fails, what is allowedDirect self-reference (a property whose `$ref` points back to the containing schema, without an intervening array) is ERROR-halt — there is no portable native value-type representation. Collection-mediated cycles are fine: `children` as an array of self is allowed because the array boundary breaks the cycle.
cycle examples
# HALT — direct self-ref (cycle has no array boundary)Node: properties: parent: { $ref: '#/components/schemas/Node' } # OK — cycle broken by the array (collection-mediated)Node: properties: children: type: array items: { $ref: '#/components/schemas/Node' }Drift detection in CIAdd `jui verify --fail-on-diff` to your CI pipeline. It re-runs the build, byte-compares each generated DTO against the committed file, and exits non-zero on any diff. Filter changes + a normal build are SEMANTICALLY identical to what verify does — `filter` is lenient (warns on empty kept set), `parser` is strict (halts on bad schema). Both invariants together mean a green CI is a real guarantee — of the comparisons it performed. Since jsonui-cli 1.8.5 verify prints `verified N of M`, which is what tells you whether the guarantee covered anything. Since 2026-07, docs↔ implementation drift can also be machine-verified via `jsonui-doc check` (which runs a builtin:openapi-diff between the impl-declared OpenAPI and docs/api/). This complements `jui verify --fail-on-diff` — verify catches DTO drift within the doc→code pipeline, check catches API drift between docs and the running server.
.github/workflows/verify.yml
# .github/workflows/verify.yml — drift detection in CIname: jui-verifyon: [push, pull_request]jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: { node-version: 20 } - run: npm install -g @jsonui/cli - run: jui build - run: jui verify --fail-on-diffWhen the build halts — cookbook tablePermanent halts: 2+1 (`anyOf`, direct self-ref, union-as-variant) — design decisions with a restructuring recipe, not pending features. Conditional halts fire only in specific configurations: a non-inferrable discriminator mapping, kotlinx-only features on moshi/none (unions, wrappers, `api.format_mapping`), URL / out-of-dir `$ref`s, YAML type coercion. YAML input, multi-file `$ref`, schema-level `oneOf`, and format-aware mapping are supported since 2026-07 and appear below only for their remaining edges. Each row is a recipe: do this exact step to keep moving.
format-aware mappingSupported since 2026-07, opt-in via `api.format_mapping: true` (default off; while off, output is byte-identical): `date-time` → `Date` / `Instant`, `uuid` → `UUID` / typealias, `binary` → `Data` / base64 `ByteArray`. Remaining halt: Android moshi/none with format fields — the kotlinx serializer (+ kotlinx-datetime) is required.Recipe: set `api.platforms.android.serializer: "kotlinx"` and add kotlinx-datetime to the consumer's build.gradle; a legacy doc that lies about formats goes in `api.format_mapping_exclude`. Web migration is compiler-guided: turning the flag on makes `tsc` list every Domain spot that must switch from `string` to `Date`. Expect semantic (not byte) round-trips: UUID uppercasing, TZ normalization to `Z`.
anyOf (untagged union)ERROR halt — `anyOf` (untagged union) halts, permanently: no portable native representation. Schema-level `oneOf` + `discriminator` (envelope form) IS supported since 2026-07 (mapping inferred from variant-internal tags when omitted), and field-level `oneOf` + `discriminator` + `mapping` since 2026-05.Recipe: model the union as `oneOf` + `discriminator` — schema-level (declare the tag inside each variant: `pet_type: { type: string, enum: [dog] }`) or field-level (sibling tag + explicit `mapping`). For untagged anyOf, push the tag into the schema as a literal enum first. Never nest a union as a variant of another union — flatten instead (permanent halt).
discriminator mapping missing / not inferrableERROR halt — a field-level `discriminator` without explicit `mapping` halts. Schema-level unions (since 2026-07) infer the mapping from each variant's internal tag (const string / single-value string enum, unique, all variants tagged) and halt with the reason otherwise; the inferred mapping is logged as a WARNING. On Android any oneOf/union-bearing schema requires `serializer: "kotlinx"` (`moshi` / `none` halt with a dedicated message).Recipe: add `mapping: { conversation_id: '#/components/schemas/StreamConvIdContent', thinking: '#/components/schemas/StreamThinkingContent' }` (field-level), or for a schema-level union declare the tag on each variant so inference kicks in. Explicit mappings are cross-checked against variant-internal tags (`discriminator-tag-conflict`). On Android set `api.platforms.android.serializer: "kotlinx"` in `jui.config.json`.
multi-file $refSupported since 2026-07 — relative `$ref` between files inside `docs/api/` resolves before parsing; shared schemas are generated once (bodies must match, else `cross-doc-schema-conflict`).Still halting: URL refs, refs escaping `docs/api/`, non-schema pointers, cross-file cycles. Recipe: vendor the shared schema into `docs/api/` and target `#/components/schemas/<Name>`.
YAML inputSupported since 2026-07 — YAML swagger parses in memory (PyYAML required; guided halt when missing).Recipe: quote YAML 1.1-coerced values (`enum: ['NO']`) — unquoted forms halt with `yaml-type-coercion`; don't keep a stale converted `.json` next to its `.yaml` (`duplicate-swagger-basename`).
direct self-refERROR halt — schema property `$ref`-ing the same schema with no array boundary makes a value-type cycle. Permanent: the collection boundary is the supported design, not a stopgap.Recipe: change `parent: { $ref: '#/.../Node' }` to `children: { type: array, items: { $ref: '#/.../Node' } }`.
Keep reading
Data models from OpenAPI — the conceptBack to the why: drift problem, two-layer split, lifecycle, what v1 does not support./concepts/data-models-from-openapi
CLI commands — `jui g api`, `jui ls`Reference page for `jui g api` and the `ls api-specs` / `ls api-models` discovery commands./reference/cli-commands
MCP tool API — Group EPer-tool input / output schemas for list_api_specs / list_api_models / preview_api_model_sync./tools/mcp