Skip to content

feat(client): map date-time-local and require an offset for zoned date-time - #135

Merged
tanmaykm merged 3 commits into
JuliaComputing:mainfrom
QXT-Energy:jd/date-time-local
Nov 1, 2026
Merged

tanmaykm merged 3 commits into
JuliaComputing:mainfrom
QXT-Energy:jd/date-time-local

Conversation

@jd-lara

@jd-lara jd-lara commented Oct 28, 2026

Copy link
Copy Markdown
Contributor

Summary

The OpenAPI Format Registry
defines date-time-local as an RFC 3339 date-time without a time zone. OpenAPI.jl did not
map it, so the field became a String. Under datetime = :zoned, a date-time with no
offset decoded as UTC, which RFC 3339 does not allow.

This PR replaces #134 (closed), which proposed a custom datetime = :as_written mode.
It uses the registry format instead, so the schema states the form of each timestamp.

Behavior

Format datetime = :utc (default) datetime = :zoned
date-time Dates.DateTime, offset normalized to UTC (unchanged) ZonedDateTime; Z or an offset is now required
date-time-local String (unchanged) Dates.DateTime, a wall clock with no offset
anyOf of both wrapper struct, as before wrapper whose value is either type; the branch is chosen by schema

Under :utc, Dates.DateTime already means a UTC instant, so date-time-local stays a
String there. Under :zoned, date-time is a ZonedDateTime, so a Dates.DateTime
can only mean the wall clock.

Changes

  • src/planning.jl: format: date-time-local maps through _local_datetime_type!,
    dispatched on Val(datetime).
  • src/client.jl, src/servergen.jl: _emit_model takes an encode keyword. A :zoned
    module encodes model fields, aliases, union values and additional properties through
    _encode_zoned. :utc output does not change.
  • src/runtime.jl: _encode_zoned(::Dates.DateTime) writes yyyy-mm-ddTHH:MM:SS.sss
    with no offset, and recurses into vectors, tuples, dicts and named tuples. Any other
    value goes to _encode.
  • ext/OpenAPITimeZonesExt.jl: the ZonedDateTime decoder requires Z or an offset.
    Its regex ends with \z, so a trailing newline is rejected. PCRE $ matches before
    one.
  • CONTRACT_VERSION is 5, because generated code imports _encode_zoned. Contract 3 and
    4 modules still load.
  • Docs: clients.md, models.md, artifacts.md, servers.md, and the client
    docstring.

Breaking change for :zoned users

A :zoned module rejects a date-time value with no offset. Before, it read the value as
UTC. The existing test that pinned that behavior now asserts a DecodeError. A producer
that writes zoneless values should declare them date-time-local.

Limits

A date-time-local value sent directly as a query, header or cookie parameter, or as a
top-level request body, still goes through _encode / _scalar, which add Z. Only
model values use _encode_zoned.

The release that carries contract 5 needs a version bump. I left that to you.

Tests

test/runtime.jl has a new test set, "date-time-local is a wall clock under datetime =
:zoned". It covers:

  • :utc keeps String;
  • :zoned field types for a required, an array and an anyOf field;
  • decode and encode of each form;
  • anyOf branch selection by schema pattern;
  • rejection of an offset on a wall-clock field.

The :zoned test set now asserts that a zoneless value and a trailing newline are
rejected. test/generated_contract.jl moves its tripwire to contract 5 and checks that
contracts 3, 4 and 5 load.

julia --project=. -e 'using Pkg; Pkg.test()': "OpenAPI tests passed", 1442 passes, 0
failures, 0 errors. generated runtime units: 392 of 392.

…e-time

The OpenAPI Format Registry defines date-time-local as an RFC 3339
date-time without a time zone. It fell through to String, and under
datetime = :zoned a date-time with no offset decoded as UTC.

- Under :zoned, date-time-local maps to Dates.DateTime, a wall clock.
  Generated :zoned models encode through _encode_zoned, which writes a
  DateTime with no offset. Under :utc, date-time-local stays String,
  because DateTime already means a UTC instant there.
- The ZonedDateTime decoder requires Z or an offset, as RFC 3339
  date-time does. The regex ends with \z, so a trailing newline fails.
- CONTRACT_VERSION is 5: generated code imports _encode_zoned.
@jd-lara

jd-lara commented Oct 30, 2026

Copy link
Copy Markdown
Contributor Author

@tanmaykm can we get a review of this PR to support undefined timezones? Currently only Julia OpenAPI generator forces an automatic use of UTC

@tanmaykm

Copy link
Copy Markdown
Member

Thanks for this, and for switching from :as_written to the registry format. Mapping date-time-local and requiring an offset for zoned date-time both look right per RFC 3339. Before merging, I'd like to settle a few things.

1. Avoid the contract bump for modules that don't use date-time-local

The client and server templates import _encode_zoned unconditionally, so every generated module becomes contract 5, including default datetime = :utc modules that never call it:

utc = OpenAPI.client(doc)          # no datetime option
occursin("_encode_zoned", utc)     # true
# emits Runtime.require_contract(5, ...)

A contract bump costs downstream users: a regenerated module can't load on older runtimes, so each consumer has to raise its OpenAPI compat bound and move its environments in step. For modules whose output is unchanged, that cost buys nothing. Could we keep them at contract 4? Two options:

  • emit the _encode_zoned import and require_contract(5, …) only when the plan is :zoned and contains a date-time-local type; or
  • emit the small _encode_zoned helper into the generated module itself under :zoned, so the runtime gains no new symbol and the contract stays at 4.

2. An offset on a wall-clock field is silently shifted, not rejected

date-time-local fields decode through _decode(::Type{Dates.DateTime}, …). That decoder accepts an optional offset and converts to UTC. Without a pattern on the schema:

# wall: {type: string, format: date-time-local}, datetime = :zoned
_decode(R, JSON.parse("""{"wall":"2026-08-07T15:00:00-04:00"}""")).wall
# 2026-08-07T19:00:00   (expected: DecodeError)

The test "an offset on a wall-clock field is rejected" passes only because its schema has a pattern forbidding offsets. A separate decode path for wall clocks under :zoned, one that rejects Z and offsets, would make the type itself enforce the format. Please also add a test that has no pattern.

3. Parameters and top-level bodies send a wall clock as UTC

The PR notes this as a limit, but the result is wrong bytes on the wire rather than an unsupported case. A date-time-local query, header or cookie parameter, or top-level body, encodes through _scalar/_encode and gets a Z:

OpenAPI.Runtime._scalar(DateTime(2026, 8, 7, 15))   # "2026-08-07T15:00:00.000Z"

The server then reads 15:00 local as 15:00 UTC. The maintainer guide asks generators to fail rather than approximate wire behavior. Please either route these paths through the wall-clock encoder, or have the planner reject date-time-local in those positions under :zoned until they're supported.

4. Release notes for the runtime change

The stricter ZonedDateTime decoder lives in OpenAPITimeZonesExt, so it applies to existing contract 3 and 4 :zoned modules as soon as an environment resolves the new release, without regeneration. I checked this against an existing contract-4 module: "2026-08-07T15:00:00" decodes on 1.2.0 and throws DecodeError on this branch. That's the intended fix, but it's a behavior change for deployed clients and servers, so the release notes should call it out explicitly. We should also decide whether it fits a minor release.

Minor: the \z anchor in the zoned decoder is a good catch. The same trailing-newline issue applies to the $ in _decode(::Type{Dates.DateTime}, …) in src/runtime.jl, so it might be worth fixing there too, here or in a follow-up.

@jd-lara

jd-lara commented Oct 31, 2026 •

Copy link
Copy Markdown
Contributor Author

tanmaykm Thanks for the careful review and the repros. I confirmed all five points. The next push has a change for each.

  1. Contract. The runtime stays at contract 4. :zoned modules get _encode_zoned and _decode_zoned as module-local code. :utc modules generate the same output as on main.
  2. Offsets on wall clocks. _decode_zoned rejects Z, offsets, and trailing newlines on date-time-local values, also in arrays and maps. The test schemas have no pattern.
  3. Parameters and top-level bodies. Under :zoned, planning fails for date-time-local in a parameter, a header, or a whole body. This includes aliases. The docs state the rule.
  4. Release notes. I reverted the change to OpenAPITimeZonesExt, so existing contract 3 and 4 modules keep their behavior. Only new :zoned modules require an offset. I think this fits a minor release.
  5. \z. I fixed _decode(::Type{Dates.DateTime}, …) and added a test. This change reaches existing modules: a value with a trailing newline does not decode.

The same trailing-newline issue applies to schema pattern and patternProperties. #136 (QXT-Energy:jd/ecma-262-patterns) fixes it there: it compiles patterns with DOLLAR_ENDONLY, as ECMA-262 requires. The two PRs change different files and merge without conflict. The full test suite passes with both applied.

Address the review on JuliaComputing#135. The runtime stays at contract 4.

- Emit `_encode_zoned` and `_decode_zoned` into `datetime = :zoned`
  modules. The runtime and the `:utc` output do not change.
- `_decode_zoned` rejects `Z`, offsets, and trailing newlines on a
  `date-time-local` value. It requires `Z` or an offset on a `date-time`
  value. Arrays, maps, and optional fields use the same rules.
- Revert `OpenAPITimeZonesExt` to the lenient decoder, so existing
  contract 3 and 4 modules keep their behavior.
- Under `:zoned`, planning rejects `date-time-local` in a parameter, a
  header, or a whole body. The shared runtime writes these as UTC.
- Anchor the runtime `Dates.DateTime` decoder with `\z`, so a trailing
  newline does not decode.
- `_decode_zoned` tries each variant of a union with the zoned decoders.
  A field with `type: [string, integer]` and `format: date-time-local`
  now rejects `Z` and offsets instead of reading the value as UTC.
- A variant that the value already is goes first, as in the runtime, and
  a failure reports "value does not match any variant".
- Add tests for a union of types and for a map of wall clocks.
@jd-lara

jd-lara commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Suggested release notes for #135 and #136, as discussed in point 4:

  • datetime = :zoned maps format: date-time-local to Dates.DateTime, a wall clock with no offset. Regenerate to use it.
  • Newly generated :zoned modules reject a date-time value without Z or an offset, and a date-time-local value with one. Existing modules do not change.
  • Under :zoned, planning rejects date-time-local in parameters, headers, and whole bodies.
  • The runtime Dates.DateTime decoder rejects a value with a trailing newline. This applies to existing modules without regeneration.
  • JSON Schema pattern and patternProperties follow ECMA-262 (fix(schema_engine): follow ECMA-262 semantics in pattern #136): $ does not match before a trailing newline, \d and \w are ASCII, \s and \S use the ECMA-262 white-space set, and . does not match a line terminator. This applies to existing modules without regeneration.

@tanmaykm

Copy link
Copy Markdown
Member

Thanks for the quick turnaround. I re-checked each point against ddd945b9:

  1. Contract stays at 4, and :utc output is byte-identical to main. I checked the client and the server for a large real-world spec.
  2. An offset on a date-time-local field is rejected with no pattern in the schema.
  3. A date-time-local parameter fails planning with unsupported_date_time_local_position.
  4. Existing contract-4 :zoned modules keep their behavior on this runtime. The suggested release notes look right.

Two non-blocking points:

Module-local codecs on every field. Under :zoned, every model field decodes through the module-local _decode_zoned, not just the date-time ones. For the spec above, that changes about 1,100 lines of :zoned output (548 call sites). It also means each generated module carries its own union decoder. That decoder ranks variants with an exact value isa V, while the runtime's _decode_union uses _direct_union_match (any non-Bool Integer, AbstractFloat, AbstractVector, and so on) and has the single-candidate fast path. It behaved the same in my tests, but it's a second copy that can drift. The planner knows each field's type at emission, so could _decode_zoned/_encode_zoned be used only where the type contains Dates.DateTime or TimeZones.ZonedDateTime? Other fields would stay on the runtime _decode/_encode. That would keep the diff for regenerated modules small and limit the copy to the date-time paths.

Fields vs. parameters. In a new :zoned module, a date-time field rejects a value without an offset, but a date-time query or header parameter still decodes it as UTC through the runtime. That seems acceptable, given the lenient runtime policy, but a sentence in clients.md stating it would help.

Neither point blocks merging; I'm happy to take them as follow-ups if you'd prefer.

@jd-lara

jd-lara commented Oct 31, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the quick turnaround. I re-checked each point against ddd945b9:

  1. Contract stays at 4, and :utc output is byte-identical to main. I checked the client and the server for a large real-world spec.
  2. An offset on a date-time-local field is rejected with no pattern in the schema.
  3. A date-time-local parameter fails planning with unsupported_date_time_local_position.
  4. Existing contract-4 :zoned modules keep their behavior on this runtime. The suggested release notes look right.

Two non-blocking points:

Module-local codecs on every field. Under :zoned, every model field decodes through the module-local _decode_zoned, not just the date-time ones. For the spec above, that changes about 1,100 lines of :zoned output (548 call sites). It also means each generated module carries its own union decoder. That decoder ranks variants with an exact value isa V, while the runtime's _decode_union uses _direct_union_match (any non-Bool Integer, AbstractFloat, AbstractVector, and so on) and has the single-candidate fast path. It behaved the same in my tests, but it's a second copy that can drift. The planner knows each field's type at emission, so could _decode_zoned/_encode_zoned be used only where the type contains Dates.DateTime or TimeZones.ZonedDateTime? Other fields would stay on the runtime _decode/_encode. That would keep the diff for regenerated modules small and limit the copy to the date-time paths.

Fields vs. parameters. In a new :zoned module, a date-time field rejects a value without an offset, but a date-time query or header parameter still decodes it as UTC through the runtime. That seems acceptable, given the lenient runtime policy, but a sentence in clients.md stating it would help.

Neither point blocks merging; I'm happy to take them as follow-ups if you'd prefer.

Yes, I can open new PRs as follow ups to these changes so we can get unblocked and contribute the rest

@tanmaykm tanmaykm left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approving. The remaining points will be handled in follow-up PRs, as discussed above. Thanks!

@tanmaykm
tanmaykm merged commit cf05b65 into JuliaComputing:main Nov 1, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants