Status: reference notes, written 2026-08-10, updated 2026-08-12 for head
1ff9ba8. Findings from evaluating
JuliaComputing/OpenAPI.jl#103
("Pure-julia OpenAPI internals rewrite"), specifically from the angle of
rewriting Kuber.jl against it. Everything below was verified hands-on: against
the legacy petstore test servers, against synthetic streaming servers, and
against a live k3s v1.35 cluster (via kubectl proxy). Companion prototype
files live in gen/openapi_v1_prototype/.
Three evaluation rounds so far: bd96d53 (first full round), c2a5244
(re-verified everything after the tolerant-decode fix, the shared-runtime
refactor, and generation precompilation landed), and 1ff9ba8 (2026-08-12).
Every issue we raised upstream is now fixed and verified live, including
the watch-codec media-matching nuance from section 2 — raised on the PR
2026-08-12, fixed the same day in 1ff9ba8 (stream codecs fall back to the
accept= media type when the received Content-Type matches no registration).
At 1ff9ba8 the PR's own suite passes 700/700 (27 testsets), and generated
module output is byte-identical to c2a5244 — the fix is runtime-side only,
no regeneration needed.
The PR replaces the 0.2.x runtime-library model with a generator. There is no
OpenAPI.Clients / OpenAPI.Servers runtime anymore and no compatibility shims:
OpenAPI.client(spec; name, path) reads an OAS 3.0/3.1/3.2 document (JSON or YAML)
and emits a single Julia module containing the document's models, typed
operations, and embedded JSON Schemas for boundary validation. Constraints that
matter for Kuber: Julia 1.11+, HTTP.jl 2.x only, JSON.jl 1.7+; the
openapi-generator (Java) toolchain and Kuber's current generated src/ApiImpl
code stop working entirely. Generated code is precompile- and
JuliaC---trim-friendly (the PR tests this), which suits shipped products.
Since ae201a7 (BREAKING for generated-code shape), generated modules no longer
carry a pasted ~2,000-line runtime: they import OpenAPI.Runtime and contain
only their own spec data (_SPEC), models, and operations. A 4-operation spec
now emits ~190 lines (was ~2,400); petstore v3 dropped 165→99 KiB. The HTTP
transport itself lives in the package's HTTP extension (_request /
_stream_request seams), and TimeZones support moved to an
OpenAPITimeZonesExt weakdep extension — datetime = :zoned clients need
using TimeZones in the consumer environment. Consequence for Kuber: baked
modules are version-coupled to the OpenAPI package (they import internal
Runtime._decode/_encode names), so pin OpenAPI's version alongside the baked
matrix and regenerate on upgrades. c2a5244 precompiles the generation pipeline:
k8s core v1 generation dropped from ~31s to ~7s cold / ~3s warm per document.
As of bd96d53 the four gaps found in the first evaluation round are fixed and
were re-verified (again at c2a5244):
- undocumented 2xx statuses succeed (empty body →
nothing, payload → raw bytes) instead of throwing; - a missing/blank response Content-Type falls back to decoding by the documented
media type (
UnexpectedContentTypeonly remains for genuinely ambiguous cases); datetime = :zonedgeneration option mapsdate-timetoTimeZones.ZonedDateTimewith offsets preserved on the wire (default:utcmaps toDates.DateTime, offsets normalized to UTC);- streaming exists: every operation accepts
stream_to = Channel(n), returns at the response head, and a producer task decodes items onto the channel.
The streaming implementation reproduces the semantics Kuber depends on from the
legacy fixes (OpenAPI.jl PRs 97/98/100/101/102): incremental small-chunk delivery
(verified 0.0s first-item latency after JIT warmup while the server stalls 4s
before the next item), close(channel) aborts the transfer (server observes the
connection close in ~0.2–0.4s; a 250ms watcher plus explicit stream teardown),
streaming defaults to HTTP/1.1. One deliberate difference: a truncated final
JSON document closes the channel with a DecodeError instead of ending
silently. Kuber's watch loop must expect close-with-error, not just clean close —
today simpleapi.jl treats stream end leniently; the rewrite should catch the
channel exception and decide retry-vs-surface (k8s_retry semantics).
Both gaps raised after round one are fixed in fd4558c and verified live:
-
Client(validate_responses=false)now reaches nested model decoding. Verified against the live cluster with the pristine (unpatched) k8s spec: a tolerant client decodes real pod lists — all 170 nulllastProbeTimes on our cluster arrive asnothing— while a strict client still rejects them, as it should. Semantics: unknown response properties ignored, explicitnullon an optional non-nullable property →nothing, missing optional →ABSENT; values that can't fit the Julia type can still raiseDecodeError(e.g. a missing required field remains an error even when tolerant). -
Streaming consults
codec!custom decoders per framed item, with parameterized-media registration intended to scope the override (codec!(client, "application/json;stream=watch"; decode=...)).The media-matching nuance is fixed in
1ff9ba8(raised on the PR 2026-08-12, fixed the same day). Background: codecs are matched against the received Content-Type, and real k8s always replies plainContent-Type: application/json— even when you Accept thestream=watchvariant — so atc2a5244the parameterized registration never fired against a real cluster. Now, when no registration matches the received media type, streaming calls fall back to the media type the call requested via theaccept=keyword (_stream_codec_mediainsrc/runtime.jl). Verified live at1ff9ba8on the pristine spec, all on one shared client:K.codec!(client, "application/json;stream=watch"; decode = (bytes, _) -> JSON.parse(String(bytes))) K.listcorev1namespacedconfigmap(ns; client, watch = true, accept = "application/json;stream=watch", stream_to = events)
The codec fires for exactly the calls that pass that
accept; buffered calls on the same client still decode typed models. The previous workaround (plain-application/jsoncodec on a dedicated watch-only client) still works and remains the fallback for pre-1ff9ba8heads.
References are to Kuber.jl main at 0d91a51.
- Generation at build time (
gen/): openapi-generator emits one module per API group+version from a pinned k8s spec intosrc/ApiImpl. Nothing is fetched from the server's OpenAPI endpoints at runtime — "auto discovery" means group/version discovery, not spec discovery. - Runtime discovery (
src/helpers.jl:510set_api_versions!): calls the k8s discovery endpoints through the pre-generated client —GET /api(fetch_core_version,helpers.jl:452) andGET /apis(get_a_p_i_versions,helpers.jl:397) — then maps each server-reported group/version to a shipped module by symbol lookup (getfield(apimodule(ctx), Symbol("Core"*camel(vers)*"Api"))), tolerating gaps.build_model_api_mapthen maps model names → API version for theget/listsymbol API. - Dynamic return typing — the load-bearing legacy hook. The client is built
with
get_return_type = kuber_type(helpers.jl:110-112);kuber_type(helpers.jl:227-257) peeks at each response payload'skind/apiVersionto pick the Julia type, and recognizes watch frames (type+objectkeys →WatchEvent). This is what makes Kuber immune to the k8s spec declaring the wrong response schema — the type comes from the payload, not the spec. The new OpenAPI has no equivalent hook; section 5 explains why it's no longer needed. - Watch (
src/simpleapi.jl:30-51,list(...; watch=true)at:55): legacy channel streaming,KuberEventStream, per-chunk decoding through theget_return_typehook. int-or-string(helpers.jl:530-531): extends legacyOpenAPI.val_format(::Val{Symbol("int-or-string")}). Not needed in the new world: k8s OpenAPI v3 declaresIntOrStringas a properoneOf: [integer, string], which the new planner maps to a real union wrapper type. (Theval_formatextension point no longer exists anyway.)- Other legacy touchpoints that disappear:
(result, http_resp)tuple returns andcheck_api_response;OpenAPI.Clients.Client(...; client_kwargs...)andclient.headersmutation;Downloads.Responseheader handling (helpers.jl:237-242);kuber_obj'sconvert(T, ::Dict)construction.
Tested with a client generated from the pristine k8s core v1 OpenAPI v3
document (2.1 MB, 113 paths). Generation itself works in strict mode — the
PR's ac0689d fixed the parameterized-media-key rejection that the k8s documents
otherwise trip (application/json vs application/json;stream=watch on every
list operation).
- Pod lists are undecodable with the pristine spec. The spec declares
io.k8s.apimachinery.pkg.apis.meta.v1.Timeas a non-nullabledate-timestring; the live API returns"lastProbeTime": null(on our cluster: all 165 pod conditions had it). Model-level validation throwsSchemaValidationError, and per gap#1above no flag avoids it. - Watch frames don't match the declared schema. k8s declares the List type
for the
application/json;stream=watchmedia entry, but the wire carriesWatchEventobjects ({"type": "ADDED", "object": {...}}). Per-item streaming decode fails. - Content negotiation cannot fix
#2— verified and important. k8s repliesContent-Type: application/jsonregardless of theAcceptheader (confirmed with curl, with and withoutAccept: application/json;stream=watch). So patching the;stream=watchmedia entry is unreachable: media selection is driven by the response Content-Type, which always matches the plainapplication/json(List-typed) entry. The deeper truth: a single k8s list operation returns two different wire shapes depending on the?watch=query parameter, which OpenAPI cannot express on one operation. (Since1ff9ba8the decode side of this is solved without touching the spec: an accept-scopedcodec!override, see section 2. The spec statement is still wrong; the codec is the sanctioned escape hatch for it.) - Version-skew safety is good. k8s schemas don't close their objects, so
generated models park unknown JSON keys in
additional_properties::Dict{String,Any}without error (verified by decoding a payload with an invented future field). A baked client for k8s 1.N tolerates added fields from a 1.N+2 server; only contract violations (nulls, shape changes) bite, and GA APIs rarely do that.
Design decision this validates: runtime spec fetching/generation is out for a
shipped product (measured: ~31s generation + ~6.5s load for core v1 alone, times
~30 group documents, eval'd with no precompilation). Instead: a small matrix of
baked, spec-patched, pre-generated modules, switched at connect time.
gen/openapi_v1_prototype/patch_k8s_spec.jq,
applied to each pristine k8s group document before generation:
meta.v1.Timeandmeta.v1.MicroTimegetnullable: true(OAS 3.0nullablesibling — honored in strict mode because the schema declarestype: string). MicroTime was found by the all-groups sweep: Events carryeventTime: null(core v1 and events.k8s.io/v1 both);- every array-typed property gets
nullable: true: Go marshals nil slices as JSONnull, so any array — even spec-required ones — can arrive null. Found live:CSINodeSpec.drivers: nullon storage.k8s.io/v1. The blanket rule kills the whole class instead of chasing fields one at a time; - every dedicated
/watch/path'sapplication/jsonresponse schema is rewritten toWatchEvent. The/watch/paths are marked deprecated by k8s but remain served, are watch-only (so the patch is semantically honest), and exist in the document as separate operations — 70 of them in core v1. Watching goes through these operations, not throughlist(...; watch=true), because of finding#3. If the/watch/paths are ever removed upstream, the fallback is splitting each list operation into two in the patch step (one per?watch=shape).
Expect this patch list to grow as the fleet finds more spec lies; that's the
point of owning the patch step. The 2026-08-12 all-groups sweep (below) found
and fixed two new classes (MicroTime, nil-slice arrays) on the first pass —
the guarded patch (select(has(...)) on every rule) applies cleanly to
documents that lack the schema or have no /watch/ paths, including
CRD-backed group documents.
All 27 OpenAPI v3 group documents served by the k3s v1.35 cluster —
including CRD-backed groups (helm.cattle.io/v1, k3s.cattle.io/v1), Gateway
API (gateway.networking.k8s.io/v1 + v1beta1), and the aggregated
metrics.k8s.io/v1beta1 — swept through patch → strict generate → load →
live calls (smoke_groups.jl):
- 27/27 patch cleanly (guarded jq rules; CRD groups have no
/watch/paths, which is fine — CRD watch goes throughlist(...; watch=true)+ accept-scoped codec); - 27/27 generate in strict mode, ~39s and ~30 MiB total (core v1 6.7 MiB / 11s is the largest; most groups are 0.2–1.8 MiB and take under a second);
- all 27 modules load together in one session (~29s uncompiled, includes JIT — precompilation absorbs this in a real package);
- 74/74 zero-positional-arg list operations across all groups decode strictly against the live cluster (854 items) — after the MicroTime and array-nullable patch rules were added; before them 71/74 (the three failures are what motivated the rules);
- typed watch on apps/v1 via the patched
/watch/path deliversWatchEvents with second-stage decode toIoK8sApiAppsV1Deployment— 5/5.
jq -f patch_k8s_spec.jq api__v1_openapi.json > api__v1_patched.json
julia -e 'using OpenAPI, HTTP; OpenAPI.client("api__v1_patched.json"; name="K8sCoreV1", path="K8sCoreV1.jl")'~7s cold / ~3s warm per group document as of c2a5244 (was ~31s at bd96d53);
core v1 emits a ~6.7 MiB module (dominated by models and embedded schema data —
the shared-runtime refactor shrinks small specs dramatically but k8s-sized ones
only modestly) that loads in ~5–6.5s uncompiled (precompilation absorbs this in
a real package). Against the live cluster, with full validation on and strict
generation, the prototype test
(gen/openapi_v1_prototype/k8sbaked.jl)
passes 8/8 — verified at both bd96d53 and c2a5244:
- pod list across all namespaces decodes; all 165 null
lastProbeTimes arrive asnothing; watchcorev1namespacedconfigmaplist(ns; resourceversion=..., stream_to=events)delivers typedWatchEvents live — an ADDED (configmap created mid-watch via kubectl) and a DELETED on the same stream;close(events)cancels the watch cleanly.
WatchEvent.object is k8s's RawExtension, generated as an open struct whose
payload sits in .additional_properties::Dict{String,Any}. The second-stage
decode into a typed model is one call, verified working:
cm = K8sCoreV1._decode(K8sCoreV1.IoK8sApiCoreV1ConfigMap, event.object.additional_properties)So kuber_type's payload sniffing reduces to a per-module lookup table
(kind, apiVersion) → generated type feeding _decode. With patched specs the
declared schemas are finally true, so strict typed decoding does what the hook
used to fake. (Note _decode is runtime-internal — since ae201a7 it lives in
OpenAPI.Runtime and generated modules import + extend it; either use it
knowingly or ask upstream for a public decode(Module, T, json) entry point.)
With the accept-scoped codec fix (section 2), the pristine spec is now
fully workable end to end — no jq patch step at all — verified 11/11 live at
1ff9ba8 (k8spristine_v3.jl):
- lists/gets: a tolerant client (
validate_responses = false) decodes real pod lists on the pristine spec into typed models (verified: 34 pods, 170 null Times →nothing); a strict client still rejects them, as the spec says it must; - watch: on the same shared client, register
codec!(client, "application/json;stream=watch"; decode = (bytes, _) -> JSON.parse(String(bytes)))and passaccept = "application/json;stream=watch"on the watch calls. Raw JSON event dicts stream out (verified live: ADDED observed, buffered calls on the same client unaffected), and the kind→type table second-stage decodes:_decode(K.IoK8sApiCoreV1ConfigMap, ev["object"], false)— verified. This uses the regular list operations (watch = true), not the deprecated/watch/paths.
Choosing between the two routes. Both are live-verified at 1ff9ba8:
| Patched bake (section above) | Patch-free (this section) | |
|---|---|---|
| Spec patch step | jq: nullable Time + /watch/→WatchEvent |
none |
| Response validation | strict, on | off (validate_responses = false) |
| Watch operations | dedicated /watch/ paths (deprecated in k8s, still served) |
regular list ops with watch=true (not deprecated) |
| Watch item type | typed WatchEvent directly |
raw JSON dict + second-stage decode |
| Upstream dependency | none beyond fd4558c |
needs 1ff9ba8+ |
A middle option worth considering for the rewrite: patch only
meta.v1.Time nullable (one jq line — keeps strict validation on all buffered
calls) and use the accept-scoped codec for watch. That drops the largest and
most fragile part of the patch (70 /watch/ path rewrites), stays off
deprecated paths, and keeps validation everywhere except inside watch frames —
where the second-stage _decode re-establishes typing anyway.
- Spec matrix, checked in: per supported k8s minor (e.g. 1.33/1.34/1.35), the
per-group OpenAPI v3 documents for the groups Kuber actually surfaces (core,
apps, batch, rbac, networking, …) — vendored from
kubernetes/kubernetesrelease tags, or captured from reference clusters viakubectl get --raw /openapi/v3/<group>. - Patch + generate at build time (replaces today's
gen/openapi-generator flow): one generated module per (group, minor). If image size becomes a concern, split per-minor into sub-packages/extensions so deployments carry only what they need. - Connect-time switch:
GET /version→ server minor → nearest baked minor at or below it (simpler and cheaper than today's per-group probing). Keep the/apisprobe only for what it uniquely answers: which groups a managed cluster has disabled. Skew tolerance (finding#4) is what lets "a few" minors cover a fleet. - Facade unchanged: keep the
KuberContext+ symbol-basedget/list/watch/put!/delete!user API; the apis dict maps (group, version) → generated module,modelapibecomes the kind→type tables from section 5. - Watch: two verified routes — dedicated
/watch/operations on the patched spec (typedWatchEvents directly), orlist(...; watch = true)with an accept-scopedcodec!+ second-stage decode (section 5, needs1ff9ba8+; avoids deprecated paths and the 70-path patch). Either way,stream_to::Channelwrapped in the existingKuberEventStream/retry machinery, updated for close-with-error semantics (section 1). Given the preference to minimize spec patching, the accept-scoped codec route with a Time-only patch is the recommended starting point. - Rewrite scope: essentially all of
src/helpers.jland the call layer ofsrc/simpleapi.jl(returns are now value-or-throwApiError; client construction, headers, credentials all per generated module).test/server fixtures based on legacyOpenAPI.Serversneed regenerating withOpenAPI.server(the new PR generates HTTP.Router servers with aregister!contract deliberately matching the 0.2.x shape).
-
The— fixed invalidate_responsesmodel-decode gapfd4558c, verified live atc2a5244. Re-confirm it survives into the merged/tagged version. -
Raise the watch-codec media-matching nuance (section 2.2) upstream— raised on the PR 2026-08-12 (comment), fixed the same day in1ff9ba8, verified live 11/11. - PR #103 merged and tagged; pin the OpenAPI version the baked modules were
generated with (they import
OpenAPI.Runtimeinternals — regenerate on OpenAPI upgrades). - Product runtime environments on Julia ≥ 1.11, HTTP.jl 2.x, JSON.jl ≥ 1.7 (hard requirements of the new OpenAPI).
- Decide zoned vs UTC date-times per module (
datetime = :zonednow needsusing TimeZonesin the consumer env via theOpenAPITimeZonesExtextension;:utcis trim-friendlier — k8s timestamps are UTC anyway, so:utcis likely right for Kuber). -
Sweep the non-core k8s group documents through patch+generate— done 2026-08-12 at1ff9ba8: all 27 cluster-served group documents (incl. CRD-backed and aggregated APIs) patch, generate strict, load, and pass live list/watch calls; see the all-groups sweep in section 5. Two new patch rules came out of it (MicroTime, array-nullable). - Re-run
gen/openapi_v1_prototype/k8sbaked.jlandgen/openapi_v1_prototype/k8spristine_v3.jlagainst the then-current PR head and a real cluster.
| File | What it is |
|---|---|
gen/openapi_v1_prototype/patch_k8s_spec.jq |
The spec patch step (Time/MicroTime nullable + arrays nullable + /watch/ → WatchEvent), guarded so it applies to every group document incl. CRD-backed ones |
gen/openapi_v1_prototype/smoke_groups.jl |
The all-groups sweep: loads all 27 generated modules, strict-lists every cluster/all-namespaces resource, typed watch on apps/v1 — needs the 27 modules generated into groups/ per the README |
gen/openapi_v1_prototype/k8sbaked.jl |
Live-cluster test: pod list, live watch (ADDED+DELETED), cancel — 8/8 at bd96d53, c2a5244, and 1ff9ba8 |
gen/openapi_v1_prototype/k8spristine_v3.jl |
Live-cluster test of the unpatched-spec route: strict-vs-tolerant pod list, watch-codec media matching (parameterized key without accept= doesn't fire; with accept= it fires on a shared client since 1ff9ba8; plain-json codec on a dedicated client as pre-1ff9ba8 fallback) — 11/11 at 1ff9ba8 |
gen/openapi_v1_prototype/README.md |
How to re-run the prototype end to end |
The pristine/patched spec snapshots and the 6.6 MiB generated module are not checked in — they regenerate in under a minute from the commands in the README.