diff --git a/docs/src/clients.md b/docs/src/clients.md index 85b7573..277a7c9 100644 --- a/docs/src/clients.md +++ b/docs/src/clients.md @@ -139,7 +139,16 @@ Generated clients support: `format: date-time` maps to `Dates.DateTime` by default, decoding RFC 3339 offsets by normalizing to UTC. Generate with `datetime = :zoned` to map to `TimeZones.ZonedDateTime` instead, preserving offsets end to end; the -generated module then depends on TimeZones.jl. +generated module then depends on TimeZones.jl. Under `:zoned`, a `date-time` +value must carry `Z` or an offset, as RFC 3339 requires. + +`format: date-time-local` (the OpenAPI Format Registry's RFC 3339 date-time +without a time zone) maps to `Dates.DateTime` under `:zoned`: a wall clock that +decodes and encodes with no offset. A field that is an `anyOf` of both formats +holds either type, chosen by the schema. Under the default `:utc` mapping, +`Dates.DateTime` already means a UTC instant, so `date-time-local` stays a +`String`. Under `:zoned`, planning rejects `date-time-local` in a parameter, +a header, or a whole request or response body; use it inside an object schema. ## Source privacy diff --git a/docs/src/models.md b/docs/src/models.md index 79c1644..f875671 100644 --- a/docs/src/models.md +++ b/docs/src/models.md @@ -22,6 +22,8 @@ Implemented model behavior includes: offsets by normalizing to UTC; generate with `datetime = :zoned` to map to `TimeZones.ZonedDateTime` instead, preserving offsets end to end (the generated module then depends on TimeZones.jl); +- `format: date-time-local` maps to `Dates.DateTime`, a wall clock with no + offset, under `datetime = :zoned`, and to `String` under the default `:utc`; - deterministic names with protection against Julia keywords, Base/Core names, and generated runtime names. diff --git a/src/client.jl b/src/client.jl index b310e16..cc1c2f9 100644 --- a/src/client.jl +++ b/src/client.jl @@ -13,6 +13,71 @@ import OpenAPI.Runtime: _schema_valid, _validate_schema """ +# Emitted into `datetime = :zoned` client and server modules, which route model +# codecs through these. Keeping them module-local leaves the runtime contract +# unchanged and keeps the stricter decoding away from already generated modules. +const GENERATED_ZONED_CODECS = raw""" +# RFC 3339 `date-time` needs `Z` or an offset. `date-time-local` is a wall clock +# with no offset, which is the only meaning `Dates.DateTime` has here. +function _decode_zoned(::Type{TimeZones.ZonedDateTime}, value::AbstractString, ::Bool) + occursin(r"(Z|[+-]\d{2}:\d{2})\z", value) || throw( + DecodeError("invalid RFC 3339 date-time $(repr(value)) (`Z` or an offset is required)"), + ) + return _decode(TimeZones.ZonedDateTime, value) +end +function _decode_zoned(::Type{Dates.DateTime}, value::AbstractString, ::Bool) + occursin(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?\z", value) || throw( + DecodeError("invalid RFC 3339 date-time-local $(repr(value)) (`Z` and offsets are not allowed)"), + ) + return _decode(Dates.DateTime, value) +end +_decode_zoned(::Type{Vector{T}}, value::AbstractVector, validate::Bool) where {T} = + T[_decode_zoned(T, item, validate) for item in value] +function _decode_zoned(::Type{Dict{String,T}}, value::AbstractDict, validate::Bool) where {T} + output = Dict{String,T}() + for (key, item) in value + output[String(key)] = _decode_zoned(T, item, validate) + end + return output +end +# Like the runtime's union decoding, a variant that the value already is goes first, +# so `5` stays an `Int64` when `Float64` is also allowed. +_zoned_rank(::Type{V}, ::V) where {V} = 0 +_zoned_rank(::Type, value) = 1 +function _decode_zoned(T::Union, value, validate::Bool) + variants = Base.uniontypes(T) + value === nothing && Nothing in variants && return nothing + failures = String[] + for variant in sort!(variants; by = variant -> _zoned_rank(variant, value)) + (variant === Absent || variant === Nothing) && continue + try + return _decode_zoned(variant, value, validate) + catch error + error isa DecodeError || rethrow() + push!(failures, error.message) + end + end + throw(DecodeError("value does not match any variant of $T: " * join(failures, "; "))) +end +_decode_zoned(::Type{T}, value, validate::Bool) where {T} = _decode(T, value, validate) + +_encode_zoned(value::Dates.DateTime) = Dates.format(value, Dates.dateformat"yyyy-mm-ddTHH:MM:SS.sss") +_encode_zoned(value::AbstractVector{UInt8}) = _encode(value) +_encode_zoned(value::Union{AbstractVector,Tuple}) = Any[_encode_zoned(item) for item in value] +function _encode_zoned(value::Union{AbstractDict,NamedTuple}) + output = JSON.Object{String,Any}() + for (key, item) in pairs(value) + output[String(key)] = _encode_zoned(item) + end + return output +end +_encode_zoned(value) = _encode(value) +""" + +_emit_datetime_codecs(io::IO, datetime::Symbol) = _emit_datetime_codecs(io, Val(datetime)) +_emit_datetime_codecs(::IO, ::Val) = nothing +_emit_datetime_codecs(io::IO, ::Val{:zoned}) = print(io, GENERATED_ZONED_CODECS, '\n') + # Emitted after the generated module's `_SPEC` constant. These forwarders keep # the public surface of a generated module (Client(), DEFAULT_CLIENT, # server!, credential!, ...) while delegating to OpenAPI.Runtime. They are @@ -300,7 +365,9 @@ function _emit_model( index, indices, abstract_targets, - wrapped_aliases, + wrapped_aliases; + encode::String = "_encode", + decode::String = "_decode", ) doc = _model_docstring(model) doc === nothing || println(io, "@doc ", repr(doc)) @@ -331,10 +398,10 @@ function _emit_model( println(io, "_decode(::Type{", model.name, "}, value) = _decode(", model.name, ", value, true)") println(io, "function _decode(::Type{", model.name, "}, value, _openapi_validate::Bool)") println(io, " _openapi_validate && _validate_schema(_SPEC, ", schema, ", value, ", repr("decoding " * model.name), "; direction = ", direction, ")") - println(io, " return ", model.name, "(_decode(", type, ", value, _openapi_validate))") + println(io, " return ", model.name, "(", decode, "(", type, ", value, _openapi_validate))") println(io, "end") println(io, "function _encode(value::", model.name, ")") - println(io, " output = _encode(value.value)") + println(io, " output = ", encode, "(value.value)") println(io, " return _validate_schema(_SPEC, ", schema, ", output, ", repr("encoding " * model.name), "; direction = ", direction, ")") println(io, "end\n") return @@ -369,10 +436,10 @@ function _emit_model( println(io, "_decode(::Type{", model.name, "}, value) = _decode(", model.name, ", value, true)") println(io, "function _decode(::Type{", model.name, "}, value, _openapi_validate::Bool)") println(io, " _openapi_validate && _validate_schema(_SPEC, ", schema, ", value, ", repr("decoding " * model.name), "; direction = ", direction, ")") - println(io, " return ", model.name, "(_decode(", model.alias, ", value, _openapi_validate))") + println(io, " return ", model.name, "(", decode, "(", model.alias, ", value, _openapi_validate))") println(io, "end") println(io, "function _encode(value::", model.name, ")") - println(io, " output = _encode(value.value)") + println(io, " output = ", encode, "(value.value)") println(io, " return _validate_schema(_SPEC, ", schema, ", output, ", repr("encoding " * model.name), "; direction = ", direction, ")") println(io, "end") println(io, "Base.string(value::", model.name, ") = string(value.value)\n") @@ -452,7 +519,7 @@ function _emit_model( descriptor = _schema_descriptor(node) println(io, " if !_openapi_validate || _schema_valid(_SPEC, ", descriptor, ", value; direction = ", direction, ")") println(io, " try") - println(io, " push!(matches, _decode(", type, ", value, _openapi_validate))") + println(io, " push!(matches, ", decode, "(", type, ", value, _openapi_validate))") println(io, " catch error") println(io, " error isa DecodeError || rethrow()") println(io, " end") @@ -467,7 +534,7 @@ function _emit_model( println(io, "end") end println(io, "function _encode(value::", model.name, ")") - println(io, " output = _encode(value.value)") + println(io, " output = ", encode, "(value.value)") println(io, " return _validate_schema(_SPEC, ", schema, ", output, ", repr("encoding " * model.name), "; direction = ", direction, ")") println(io, "end\n") return @@ -526,7 +593,7 @@ function _emit_model( io, " ", local_name, - " = _decode(", + " = ", decode, "(", type, ", _required(_openapi_object, ", repr(field.wire_name), @@ -541,7 +608,7 @@ function _emit_model( local_name, " = haskey(_openapi_object, ", repr(field.wire_name), - ") ? _decode(", + ") ? ", decode, "(", type, ", _openapi_object[", repr(field.wire_name), @@ -559,7 +626,7 @@ function _emit_model( println(io, " _openapi_additional_properties = Dict{String,", additional_type, "}()") println(io, " for (_openapi_key, _openapi_item) in _openapi_object") println(io, " String(_openapi_key) in ", _julia_literal(known), " && continue") - println(io, " _openapi_additional_properties[String(_openapi_key)] = _decode(", additional_type, ", _openapi_item, _openapi_validate)") + println(io, " _openapi_additional_properties[String(_openapi_key)] = ", decode, "(", additional_type, ", _openapi_item, _openapi_validate)") println(io, " end") else println(io, " _openapi_unknown = setdiff(String.(collect(keys(_openapi_object))), collect(", _julia_literal(known), "))") @@ -580,12 +647,12 @@ function _emit_model( println(io, "function _encode(_openapi_value::", model.name, ")") println(io, " _openapi_output = JSON.Object{String,Any}()") for field in model.fields - println(io, " _openapi_value.", field.name, " isa Absent || (_openapi_output[", repr(field.wire_name), "] = _encode(_openapi_value.", field.name, "))") + println(io, " _openapi_value.", field.name, " isa Absent || (_openapi_output[", repr(field.wire_name), "] = ", encode, "(_openapi_value.", field.name, "))") end if model.additional_type !== nothing println(io, " for (_openapi_key, _openapi_item) in _openapi_value.additional_properties") println(io, " haskey(_openapi_output, _openapi_key) && throw(ArgumentError(\"additional property conflicts with declared field: \" * _openapi_key))") - println(io, " _openapi_output[_openapi_key] = _encode(_openapi_item)") + println(io, " _openapi_output[_openapi_key] = ", encode, "(_openapi_item)") println(io, " end") end println(io, " return _validate_schema(_SPEC, ", schema, ", _openapi_output, ", repr("encoding " * model.name), "; direction = ", direction, ")") @@ -1300,6 +1367,7 @@ function _generate(plan::ClientPlan) plan.datetime === :zoned && println(io, "using TimeZones") _emit_contract_guard(io) print(io, GENERATED_CLIENT_IMPORTS, '\n') + _emit_datetime_codecs(io, plan.datetime) _emit_security(io, plan) _emit_schema_data(io, plan) println(io, "const _SPEC = Runtime.Spec(;") @@ -1328,7 +1396,9 @@ function _generate(plan::ClientPlan) index, indices, abstract_targets, - wrapped_aliases, + wrapped_aliases; + encode = _datetime_encoder(plan.datetime), + decode = _datetime_decoder(plan.datetime), ) end for operation in plan.operations @@ -1349,8 +1419,10 @@ styles, content negotiation, and security requirements. `datetime = :utc` (the default) maps `format: date-time` to `Dates.DateTime` and normalizes RFC 3339 offsets to UTC while decoding; `datetime = :zoned` -maps to `TimeZones.ZonedDateTime` and preserves offsets, making the generated -module depend on TimeZones.jl. +maps to `TimeZones.ZonedDateTime`, requires an offset, and preserves it, making +the generated module depend on TimeZones.jl. Under `:zoned`, `format: +date-time-local` maps to `Dates.DateTime`, a wall clock written with no offset; +under `:utc` it stays a `String`. """ function client( source; diff --git a/src/planning.jl b/src/planning.jl index a210750..cb35277 100644 --- a/src/planning.jl +++ b/src/planning.jl @@ -549,6 +549,21 @@ function _validate_datetime_option(datetime::Symbol) return datetime end +# `date-time-local` (OpenAPI Format Registry) has no offset. Under `:zoned`, where +# `date-time` is a `ZonedDateTime`, a `Dates.DateTime` can only mean that wall clock. +# Under `:utc`, `Dates.DateTime` already means a UTC instant, so the field stays a string. +_local_datetime_type!(::PlanningContext, ::Val) = "String" +function _local_datetime_type!(context::PlanningContext, ::Val{:zoned}) + context.uses_dates = true + return "Dates.DateTime" +end +_datetime_encoder(datetime::Symbol) = _datetime_encoder(Val(datetime)) +_datetime_encoder(::Val) = "_encode" +_datetime_encoder(::Val{:zoned}) = "_encode_zoned" +_datetime_decoder(datetime::Symbol) = _datetime_decoder(Val(datetime)) +_datetime_decoder(::Val) = "_decode" +_datetime_decoder(::Val{:zoned}) = "_decode_zoned" + function _model_name!(context::PlanningContext, view::SchemaView, suggested, mode) direction = _direction(context, view, mode) resolved = _resolved_view(view) @@ -681,6 +696,8 @@ function _primitive_type!( context.uses_dates = true return context.datetime === :zoned ? "TimeZones.ZonedDateTime" : "Dates.DateTime" + elseif format == "date-time-local" + return _local_datetime_type!(context, Val(context.datetime)) elseif format == "time" context.uses_dates = true return "Dates.Time" @@ -1468,6 +1485,7 @@ function _plan_operation!(context, operation, used_functions) parameters = _plan_parameters!(context, operation, name) request = _plan_request_body!(context, operation, name) responses, return_type = _plan_responses!(context, operation, name) + _check_wall_clock_positions!(context, Val(context.datetime), parameters, request, responses) return OperationPlan( name, operation, @@ -1478,6 +1496,44 @@ function _plan_operation!(context, operation, used_functions) ) end +# Parameters, headers, and top-level bodies encode through the shared runtime, +# which writes every `Dates.DateTime` as UTC. Under `:zoned` that type is a +# `date-time-local` wall clock, so refuse these positions instead of shifting it. +_check_wall_clock_positions!(context, ::Val, parameters, request, responses) = nothing +function _check_wall_clock_positions!(context, ::Val{:zoned}, parameters, request, responses) + aliases = Dict(model.name => model.alias for model in context.models if model.kind === :alias) + positions = Tuple{String,Provenance}[] + for parameter in parameters + push!(positions, (parameter.type, parameter.parameter.provenance)) + end + request === nothing || push!(positions, (request.type, request.body.provenance)) + for response in responses, (_, type) in (response.media_types..., response.header_types...) + push!(positions, (type, response.response.provenance)) + end + for (type, provenance) in positions + _has_wall_clock(type, aliases) || continue + _error!( + context.bag, + :unsupported_date_time_local_position, + "date-time-local is supported under datetime = :zoned only inside object " * + "schemas, not as a parameter, header, or whole body", + SourceLocation(provenance.node.resource, provenance.node.pointer), + ) + end + return +end + +function _has_wall_clock(type::AbstractString, aliases, seen = Set{String}()) + occursin(r"\bDates\.DateTime\b", type) && return true + for (name, alias) in aliases + name in seen && continue + occursin(Regex("\\b" * name * "\\b"), type) || continue + push!(seen, name) + _has_wall_clock(alias, aliases, seen) && return true + end + return false +end + function _has_positional_encoding(encoding::NormalizedEncoding) return haskey(encoding.raw, "itemEncoding") || haskey(encoding.raw, "prefixEncoding") || diff --git a/src/runtime.jl b/src/runtime.jl index 42e4cdb..45ebb8a 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -593,7 +593,7 @@ function _decode(::Type{Dates.DateTime}, value::AbstractString) # timestamps). Accept input liberally: a missing offset means UTC, the # same convention _encode uses when it stamps naive DateTimes with `Z`. matched = match( - r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?$", + r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(Z|[+-]\d{2}:\d{2})?\z", value, ) matched === nothing && throw(DecodeError("invalid RFC 3339 date-time $(repr(value))")) diff --git a/src/servergen.jl b/src/servergen.jl index 2f2a19f..e8178e5 100644 --- a/src/servergen.jl +++ b/src/servergen.jl @@ -925,6 +925,7 @@ function server_module_source( plan.datetime === :zoned && println(io, "using TimeZones") _emit_contract_guard(io) print(io, GENERATED_SERVER_IMPORTS, '\n') + _emit_datetime_codecs(io, plan.datetime) _emit_security(io, plan) _emit_schema_data(io, plan) print(io, GENERATED_SERVER_SPEC, '\n') @@ -937,7 +938,11 @@ function server_module_source( end isempty(abstract_targets) || println(io) for (index, model) in enumerate(plan.models) - _emit_model(io, model, index, indices, abstract_targets, wrapped_aliases) + _emit_model( + io, model, index, indices, abstract_targets, wrapped_aliases; + encode = _datetime_encoder(plan.datetime), + decode = _datetime_decoder(plan.datetime), + ) end _emit_server_operations(io, plan) print(io, glue, '\n') diff --git a/test/runtime.jl b/test/runtime.jl index 3dfa550..3013946 100644 --- a/test/runtime.jl +++ b/test/runtime.jl @@ -194,6 +194,7 @@ @test_throws DecodeError decode("2026-08-07") @test_throws DecodeError decode("2026-08-07T15:00:00+02") @test_throws DecodeError decode("garbage") + @test_throws DecodeError decode("2026-08-07T15:00:00Z\n") end @testset "datetime = :zoned preserves time zone offsets" begin @@ -271,12 +272,22 @@ Dates.DateTime(2026, 8, 7, 15), TimeZones.tz"UTC", ) + # The runtime stays lenient so modules generated before the module-local + # `_decode_zoned` keep their behavior. @test zoned_invoke(:_decode, TimeZones.ZonedDateTime, "2026-08-07T15:00:00") == TimeZones.ZonedDateTime( Dates.DateTime(2026, 8, 7, 15), TimeZones.tz"UTC", ) ZonedDecodeError = Base.invokelatest(getfield, Cz, :DecodeError) + # RFC 3339 `date-time` needs `Z` or an offset; a wall clock is not read as UTC. + for bad in ("2026-08-07T15:00:00", "2026-08-07T15:00:00Z\n") + @test_throws ZonedDecodeError zoned_invoke( + :_decode, + Base.invokelatest(getfield, Cz, :EventModel), + JSON.Object{String,Any}("at" => bad), + ) + end @test_throws ZonedDecodeError zoned_invoke( :_decode, TimeZones.ZonedDateTime, @@ -297,6 +308,138 @@ @test occursin("at::Dates.DateTime", default_source) end + @testset "date-time-local is a wall clock under datetime = :zoned" begin + # No `pattern`: the generated decoders alone tell the two forms apart. + offset_form = OpenAPI.obj("type" => "string", "format" => "date-time") + local_form = OpenAPI.obj("type" => "string", "format" => "date-time-local") + local_document = OpenAPI.obj( + "openapi" => "3.2.0", + "info" => OpenAPI.obj("title" => "Local", "version" => "1"), + "paths" => OpenAPI.obj( + "/reading" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getReading", + "responses" => OpenAPI.obj( + "200" => OpenAPI.obj( + "description" => "reading", + "content" => OpenAPI.obj( + "application/json" => OpenAPI.obj( + "schema" => OpenAPI.obj( + "\$ref" => "#/components/schemas/Reading", + ), + ), + ), + ), + ), + ), + ), + ), + "components" => OpenAPI.obj( + "schemas" => OpenAPI.obj( + "Reading" => OpenAPI.obj( + "type" => "object", + "required" => ["wall", "either"], + "properties" => OpenAPI.obj( + "wall" => local_form, + "either" => OpenAPI.obj("anyOf" => [offset_form, local_form]), + "walls" => OpenAPI.obj("type" => "array", "items" => local_form), + "mixed" => OpenAPI.obj( + "type" => ["string", "integer"], + "format" => "date-time-local", + ), + ), + "additionalProperties" => false, + ), + "WallMap" => OpenAPI.obj( + "type" => "object", + "additionalProperties" => local_form, + ), + ), + ), + ) + + utc_source = OpenAPI.client(local_document; name = "LocalUnitClient") + @test occursin("wall::String", utc_source) + @test !occursin("_encode_zoned", utc_source) + @test occursin("require_contract(4,", utc_source) + + source = OpenAPI.client(local_document; name = "LocalUnitClient", datetime = :zoned) + @test occursin("wall::Dates.DateTime", source) + @test occursin("require_contract(4,", source) + @test occursin("walls::Union{Absent,Nothing,Vector{Dates.DateTime}}", source) + host = Module(:LocalUnitClientHost) + Base.include_string(host, source, "LocalUnitClient.jl") + Cl = Base.invokelatest(getfield, host, :LocalUnitClient) + local_invoke(function_name, args...; kwargs...) = + Base.invokelatest(getfield(Cl, function_name), args...; kwargs...) + Reading = Base.invokelatest(getfield, Cl, :Reading) + WallMap = Base.invokelatest(getfield, Cl, :WallMap) + LocalDecodeError = Base.invokelatest(getfield, Cl, :DecodeError) + read_back(text) = local_invoke(:_decode, Reading, JSON.parse(text)) + + reading = read_back( + """{"wall":"2026-08-07T15:00:00.250","either":"2026-08-07T15:00:00","walls":["2026-08-07T16:00:00"]}""", + ) + @test reading.wall === Dates.DateTime(2026, 8, 7, 15, 0, 0, 250) + @test reading.either.value === Dates.DateTime(2026, 8, 7, 15) + @test reading.walls == [Dates.DateTime(2026, 8, 7, 16)] + encoded = local_invoke(:_encode, reading) + @test encoded["wall"] == "2026-08-07T15:00:00.250" + @test encoded["either"] == "2026-08-07T15:00:00.000" + @test encoded["walls"] == ["2026-08-07T16:00:00.000"] + + zoned = read_back("""{"wall":"2026-08-07T15:00:00","either":"2026-08-07T15:00:00-04:00"}""") + @test zoned.either.value isa TimeZones.ZonedDateTime + @test Dates.value(zoned.either.value.zone.offset) == -4 * 3600 + @test local_invoke(:_encode, zoned)["either"] == "2026-08-07T15:00:00.000-04:00" + + for bad in ("2026-08-07T15:00:00Z", "2026-08-07T15:00:00-04:00", "2026-08-07T15:00:00\n") + @test_throws LocalDecodeError read_back( + JSON.json(Dict("wall" => bad, "either" => "2026-08-07T15:00:00")), + ) + @test_throws LocalDecodeError read_back( + JSON.json(Dict("wall" => "2026-08-07T15:00:00", "either" => "2026-08-07T15:00:00", "walls" => [bad])), + ) + @test_throws LocalDecodeError read_back( + JSON.json(Dict("wall" => "2026-08-07T15:00:00", "either" => "2026-08-07T15:00:00", "mixed" => bad)), + ) + @test_throws LocalDecodeError local_invoke(:_decode, WallMap, JSON.Object{String,Any}("a" => bad)) + end + + # A union of types still decodes the wall clock strictly and keeps other variants. + mixed(value) = read_back( + JSON.json(Dict("wall" => "2026-08-07T15:00:00", "either" => "2026-08-07T15:00:00", "mixed" => value)), + ).mixed + @test mixed("2026-08-07T15:00:00") === Dates.DateTime(2026, 8, 7, 15) + @test mixed(5) === 5 + walls = local_invoke(:_decode, WallMap, JSON.Object{String,Any}("a" => "2026-08-07T15:00:00")) + @test walls.additional_properties["a"] === Dates.DateTime(2026, 8, 7, 15) + @test local_invoke(:_encode, walls)["a"] == "2026-08-07T15:00:00.000" + + # Positions that encode through the shared runtime would write a wall clock as UTC. + query = OpenAPI.obj("name" => "at", "in" => "query", "schema" => local_form) + positions = merge( + local_document, + OpenAPI.obj( + "paths" => OpenAPI.obj( + "/reading" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getReading", + "parameters" => [query], + "responses" => OpenAPI.obj("204" => OpenAPI.obj("description" => "none")), + ), + ), + ), + ), + ) + @test occursin("at::Union{Absent,String}", OpenAPI.client(positions; name = "LocalUnitClient")) + for generate in (OpenAPI.plan, OpenAPI.serverplan) + error = @test_throws OpenAPI.OpenAPIError generate(positions; datetime = :zoned) + codes = Set(diagnostic.code for diagnostic in error.value.diagnostics) + @test :unsupported_date_time_local_position in codes + end + end + @testset "query, header, and cookie styles" begin @test invoke(:_query_parameter, "q", ["a", "b"], :form, false, false) == [("q", "a,b", true)]