diff --git a/docs/src/clients.md b/docs/src/clients.md index 85b7573..0404743 100644 --- a/docs/src/clients.md +++ b/docs/src/clients.md @@ -141,6 +141,15 @@ 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. +Generate with `datetime = :as_written` when a timestamp without an offset is a +wall clock rather than UTC. Fields map to +`Union{Dates.DateTime,TimeZones.ZonedDateTime}`: a timestamp without an offset +decodes to a `Dates.DateTime` and encodes without one, and a timestamp with `Z` +or a `±HH:MM` offset decodes to a `ZonedDateTime` with that offset and encodes +with it. Model fields round-trip in the form they were written. Date-times sent +directly as parameters or request bodies still encode as with `:utc`. The +generated module depends on TimeZones.jl. + ## Source privacy Generated schema graphs use content-derived resource identifiers. Local paths, diff --git a/docs/src/models.md b/docs/src/models.md index 79c1644..694d43e 100644 --- a/docs/src/models.md +++ b/docs/src/models.md @@ -21,7 +21,10 @@ Implemented model behavior includes: - `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), or with + `datetime = :as_written` to map to `Union{Dates.DateTime,TimeZones.ZonedDateTime}`, + keeping a timestamp without an offset as a wall clock and one with an offset as + written; - deterministic names with protection against Julia keywords, Base/Core names, and generated runtime names. diff --git a/ext/OpenAPITimeZonesExt.jl b/ext/OpenAPITimeZonesExt.jl index 6c822bd..24fc5ab 100644 --- a/ext/OpenAPITimeZonesExt.jl +++ b/ext/OpenAPITimeZonesExt.jl @@ -1,9 +1,10 @@ # TimeZones extension: zoned date-time codecs for generated OpenAPI modules -# emitted with `datetime = :zoned` (their `using TimeZones` loads this). +# emitted with `datetime = :zoned` or `:as_written` (their `using TimeZones` loads this). module OpenAPITimeZonesExt using TimeZones, Dates -import OpenAPI.Runtime: _decode, _encode, DecodeError +import OpenAPI.Runtime: + _as_written_zoned, _decode, _decode_as_written, _encode, _encode_as_written, DecodeError function _decode(::Type{TimeZones.ZonedDateTime}, value::AbstractString) matched = match( @@ -37,4 +38,41 @@ end _encode(value::TimeZones.ZonedDateTime) = Dates.format(value, TimeZones.ISOZonedDateTimeFormat) +_as_written_zoned(::Type{TimeZones.ZonedDateTime}) = true + +_with_fraction(clock::Dates.DateTime, ::Nothing) = clock +function _with_fraction(clock::Dates.DateTime, fraction::AbstractString) + milliseconds = parse(Int, rpad(first(fraction, min(3, length(fraction))), 3, '0')) + return clock + Dates.Millisecond(milliseconds) +end + +_as_written(clock::Dates.DateTime, ::Nothing, ::Nothing) = clock +function _as_written(clock::Dates.DateTime, ::AbstractString, ::Nothing) + return TimeZones.ZonedDateTime(clock, TimeZones.tz"UTC") +end +function _as_written(clock::Dates.DateTime, ::Nothing, offset::AbstractString) + sign = parse(Int, offset[1:1] * "1") + hours = parse(Int, offset[2:3]) + minutes = parse(Int, offset[5:6]) + zone = TimeZones.FixedTimeZone(offset, sign * (3600 * hours + 60 * minutes)) + return TimeZones.ZonedDateTime(clock, zone) +end + +function _decode_as_written(value::AbstractString) + matched = match( + r"^(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2})(?:\.(\d+))?(?:(Z)|([+-]\d{2}:\d{2}))?$", + value, + ) + matched === nothing && throw(DecodeError("invalid RFC 3339 date-time $(repr(value))")) + try + clock = _with_fraction(Dates.DateTime(matched.captures[1]), matched.captures[2]) + return _as_written(clock, matched.captures[3], matched.captures[4]) + catch error + error isa DecodeError && rethrow() + throw(DecodeError("invalid RFC 3339 date-time: $(sprint(showerror, error))")) + end +end + +_encode_as_written(value::TimeZones.ZonedDateTime) = _encode(value) + end # module OpenAPITimeZonesExt diff --git a/src/client.jl b/src/client.jl index b310e16..be13857 100644 --- a/src/client.jl +++ b/src/client.jl @@ -9,7 +9,7 @@ import OpenAPI.Runtime: BasicCredential, BearerCredential, DecodeError, HttpCredential, MultipartPartHeaders, MutualTLSCredential, SchemaValidationError, UnexpectedBody, UnexpectedContentType, UnsupportedMediaType, Upload, - _decode, _encode, _form_fields, _object, _required, _request, + _decode, _encode, _encode_as_written, _form_fields, _object, _required, _request, _schema_valid, _validate_schema """ @@ -300,7 +300,8 @@ function _emit_model( index, indices, abstract_targets, - wrapped_aliases, + wrapped_aliases; + encode::String = "_encode", ) doc = _model_docstring(model) doc === nothing || println(io, "@doc ", repr(doc)) @@ -334,7 +335,7 @@ function _emit_model( 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 @@ -372,7 +373,7 @@ function _emit_model( 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") @@ -467,7 +468,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 @@ -580,12 +581,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, ")") @@ -1297,7 +1298,7 @@ function _generate(plan::ClientPlan) ) println(io, "module ", plan.module_name, "\n") println(io, "using HTTP, JSON, OpenAPI, Base64, Dates, UUIDs") - plan.datetime === :zoned && println(io, "using TimeZones") + _uses_timezones(plan.datetime) && println(io, "using TimeZones") _emit_contract_guard(io) print(io, GENERATED_CLIENT_IMPORTS, '\n') _emit_security(io, plan) @@ -1328,7 +1329,8 @@ function _generate(plan::ClientPlan) index, indices, abstract_targets, - wrapped_aliases, + wrapped_aliases; + encode = _datetime_encoder(plan.datetime), ) end for operation in plan.operations @@ -1350,7 +1352,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. +module depend on TimeZones.jl; `datetime = :as_written` maps to +`Union{Dates.DateTime,TimeZones.ZonedDateTime}`, keeping a zone-less timestamp +as a `Dates.DateTime` and an offset timestamp as a `ZonedDateTime`, and also +depends on TimeZones.jl. """ function client( source; diff --git a/src/planning.jl b/src/planning.jl index a210750..b745f88 100644 --- a/src/planning.jl +++ b/src/planning.jl @@ -540,15 +540,26 @@ function PlanningContext( end function _validate_datetime_option(datetime::Symbol) - datetime in (:utc, :zoned) || throw( + datetime in (:utc, :zoned, :as_written) || throw( ArgumentError( - "datetime must be :utc (Dates.DateTime, offsets normalized to UTC) " * - "or :zoned (TimeZones.ZonedDateTime, offsets preserved), got $(repr(datetime))", + "datetime must be :utc (Dates.DateTime, offsets normalized to UTC), " * + "or :zoned (TimeZones.ZonedDateTime, offsets preserved), " * + "or :as_written (Dates.DateTime without offset, ZonedDateTime with one), " * + "got $(repr(datetime))", ), ) return datetime end +_uses_timezones(datetime::Symbol) = datetime in (:zoned, :as_written) +_datetime_encoder(datetime::Symbol) = _datetime_encoder(Val(datetime)) +_datetime_encoder(::Val) = "_encode" +_datetime_encoder(::Val{:as_written}) = "_encode_as_written" +_datetime_type(::Val) = "Dates.DateTime" +_datetime_type(::Val{:zoned}) = "TimeZones.ZonedDateTime" +_datetime_type(::Val{:as_written}) = + "Union{Dates.DateTime,TimeZones.ZonedDateTime}" + function _model_name!(context::PlanningContext, view::SchemaView, suggested, mode) direction = _direction(context, view, mode) resolved = _resolved_view(view) @@ -679,8 +690,7 @@ function _primitive_type!( return "Dates.Date" elseif format == "date-time" context.uses_dates = true - return context.datetime === :zoned ? "TimeZones.ZonedDateTime" : - "Dates.DateTime" + return _datetime_type(Val(context.datetime)) elseif format == "time" context.uses_dates = true return "Dates.Time" diff --git a/src/runtime.jl b/src/runtime.jl index 42e4cdb..1315cb2 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -672,6 +672,15 @@ function _direct_union_match(::Type{T}, value) where {T} return value isa T end +# `datetime = :as_written` fields are typed Union{DateTime,ZonedDateTime}. The +# TimeZones extension marks the zoned member and implements the decoder; trial +# decoding would turn an offset into UTC. +_as_written_zoned(::Type) = false +_as_written_pair(variants) = + Dates.DateTime in variants && any(_as_written_zoned, variants) +_decode_as_written(value) = + throw(DecodeError("cannot decode $(typeof(value)) as an RFC 3339 date-time")) + function _decode_union( ::Type{T}, value; @@ -682,6 +691,7 @@ function _decode_union( if value === nothing && Nothing in variants return nothing end + _as_written_pair(variants) && return _decode_as_written(value) # `Union{Absent, Nothing, X}` is how every optional field is typed. With one variant to # try, the ordering and collection below reduce to decoding it, so skip them. candidate = nothing @@ -771,6 +781,20 @@ function _encode(value::NamedTuple) end _encode(value) = value +# Used by `datetime = :as_written` modules: a DateTime stays zoneless. +_encode_as_written(value::Dates.DateTime) = + Dates.format(value, dateformat"yyyy-mm-ddTHH:MM:SS.sss") +_encode_as_written(value::AbstractVector{UInt8}) = _encode(value) +_encode_as_written(value::AbstractVector) = Any[_encode_as_written(item) for item in value] +function _encode_as_written(value::AbstractDict) + output = JSON.Object{String,Any}() + for (key, item) in value + output[String(key)] = _encode_as_written(item) + end + return output +end +_encode_as_written(value) = _encode(value) + function _safe_header(name, value) header = String(name) content = String(value) diff --git a/src/servergen.jl b/src/servergen.jl index 2f2a19f..278a087 100644 --- a/src/servergen.jl +++ b/src/servergen.jl @@ -12,7 +12,7 @@ import OpenAPI.Runtime: UnsupportedMediaType, Upload, _DeepObjectLeaf, _base_media_type, _decode, _decode_schema_header, _decode_sequential_json, _deep_object_assign!, _deep_object_coerce, _deep_object_path, - _encode, _encode_sequential_json, _form_fields, _header_atom, _header_scalar, + _encode, _encode_as_written, _encode_sequential_json, _form_fields, _header_atom, _header_scalar, _header_type_variant, _header_values, _is_json_media, _is_sequential_json_media, _media_type, _object, _parse_json, _required, _safe_header, _schema_valid, _select_media, _select_response, _validate_schema @@ -922,7 +922,7 @@ function server_module_source( println(io, "# or a framework response for custom, unvalidated output.") println(io, "module ", plan.module_name, "\n") println(io, imports) - plan.datetime === :zoned && println(io, "using TimeZones") + _uses_timezones(plan.datetime) && println(io, "using TimeZones") _emit_contract_guard(io) print(io, GENERATED_SERVER_IMPORTS, '\n') _emit_security(io, plan) @@ -937,7 +937,10 @@ 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), + ) end _emit_server_operations(io, plan) print(io, glue, '\n') diff --git a/test/runtime.jl b/test/runtime.jl index 3dfa550..e26236c 100644 --- a/test/runtime.jl +++ b/test/runtime.jl @@ -297,6 +297,133 @@ @test occursin("at::Dates.DateTime", default_source) end + @testset "datetime = :as_written keeps zoneless and offset spellings" begin + stamp = OpenAPI.obj("type" => "string", "format" => "date-time") + written_document = OpenAPI.obj( + "openapi" => "3.2.0", + "info" => OpenAPI.obj("title" => "Written", "version" => "1"), + "paths" => OpenAPI.obj( + "/event" => OpenAPI.obj( + "get" => OpenAPI.obj( + "operationId" => "getEvent", + "responses" => OpenAPI.obj( + "200" => OpenAPI.obj( + "description" => "event", + "content" => OpenAPI.obj( + "application/json" => OpenAPI.obj( + "schema" => OpenAPI.obj( + "\$ref" => "#/components/schemas/Event", + ), + ), + ), + ), + ), + ), + ), + ), + "components" => OpenAPI.obj( + "schemas" => OpenAPI.obj( + "Event" => OpenAPI.obj( + "type" => "object", + "required" => ["at"], + "properties" => OpenAPI.obj( + "at" => stamp, + "opt" => stamp, + "many" => OpenAPI.obj("type" => "array", "items" => stamp), + "maybe" => OpenAPI.obj( + "type" => ["string", "null"], + "format" => "date-time", + ), + ), + "additionalProperties" => false, + ), + ), + ), + ) + source = OpenAPI.client( + written_document; + name = "WrittenUnitClient", + datetime = :as_written, + ) + @test occursin("using TimeZones", source) + @test occursin("at::Union{Dates.DateTime,TimeZones.ZonedDateTime}", source) + host = Module(:WrittenUnitClientHost) + Base.include_string(host, source, "WrittenUnitClient.jl") + Cw = Base.invokelatest(getfield, host, :WrittenUnitClient) + written(function_name, args...; kwargs...) = + Base.invokelatest(getfield(Cw, function_name), args...; kwargs...) + Written = Union{Dates.DateTime,TimeZones.ZonedDateTime} + WrittenDecodeError = Base.invokelatest(getfield, Cw, :DecodeError) + decode(text) = written(:_decode, Written, text) + offset(value) = Dates.value(value.zone.offset) + + @test decode("2024-01-01T00:00:00") === Dates.DateTime(2024, 1, 1) + @test decode("2024-01-01T00:00:00.1239") === Dates.DateTime(2024, 1, 1, 0, 0, 0, 123) + utc = decode("2024-01-01T00:00:00Z") + @test utc isa TimeZones.ZonedDateTime + @test Dates.DateTime(utc) == Dates.DateTime(2024, 1, 1) + @test offset(utc) == 0 + plus = decode("2024-01-01T02:00:00.5+02:00") + @test plus isa TimeZones.ZonedDateTime + @test Dates.DateTime(plus) == Dates.DateTime(2024, 1, 1, 2, 0, 0, 500) + @test offset(plus) == 2 * 3600 + minus = decode("2024-01-01T00:00:00-07:00") + @test Dates.DateTime(minus) == Dates.DateTime(2024, 1, 1) + @test offset(minus) == -7 * 3600 + for bad in ("2024-01-01", "garbage", "2024-01-01T00:00:00+02", "2024-13-01T00:00:00") + @test_throws WrittenDecodeError decode(bad) + end + @test_throws WrittenDecodeError decode(5) + + @test written(:_encode_as_written, Dates.DateTime(2024, 1, 1)) == + "2024-01-01T00:00:00.000" + @test written(:_encode_as_written, plus) == "2024-01-01T02:00:00.500+02:00" + @test written(:_encode_as_written, minus) == "2024-01-01T00:00:00.000-07:00" + for text in ( + "2024-01-01T00:00:00.000", + "2024-01-01T02:00:00.500+02:00", + "2024-01-01T00:00:00.000-07:00", + ) + @test written(:_encode_as_written, decode(text)) == text + end + @test decode(written(:_encode_as_written, utc)) == utc + + Event = Base.invokelatest(getfield, Cw, :EventModel) + event = written( + :_decode, + Event, + JSON.parse("""{"at":"2024-01-01T00:00:00","opt":"2024-01-01T02:00:00+02:00","many":["2024-01-01T00:00:00","2024-01-01T00:00:00Z"]}"""), + ) + @test event.at === Dates.DateTime(2024, 1, 1) + @test event.opt isa TimeZones.ZonedDateTime + @test event.many[1] isa Dates.DateTime + @test event.many[2] isa TimeZones.ZonedDateTime + encoded = written(:_encode, event) + @test encoded["at"] == "2024-01-01T00:00:00.000" + @test encoded["opt"] == "2024-01-01T02:00:00.000+02:00" + @test encoded["many"][1] == "2024-01-01T00:00:00.000" + @test written(:_decode, Event, encoded).at === event.at + nulled = written(:_decode, Event, JSON.parse("""{"at":"2024-01-01T00:00:00","maybe":null}""")) + @test nulled.maybe === nothing + @test written(:_encode, nulled)["maybe"] === nothing + maybe = written(:_decode, Event, JSON.parse("""{"at":"2024-01-01T00:00:00","maybe":"2024-01-01T00:00:00-07:00"}""")) + @test offset(maybe.maybe) == -7 * 3600 + + # :utc and :zoned output is unchanged + utc_source = OpenAPI.client(written_document; name = "WrittenUnitClient") + @test !occursin("TimeZones", utc_source) + @test !occursin("_encode_as_written(", utc_source) + @test occursin("at::Dates.DateTime", utc_source) + zoned_source = OpenAPI.client( + written_document; + name = "WrittenUnitClient", + datetime = :zoned, + ) + @test occursin("at::TimeZones.ZonedDateTime", zoned_source) + @test !occursin("_encode_as_written(", zoned_source) + @test Dates.DateTime(2024, 1, 1) |> OpenAPI.Runtime._encode == "2024-01-01T00:00:00.000Z" + end + @testset "query, header, and cookie styles" begin @test invoke(:_query_parameter, "q", ["a", "b"], :form, false, false) == [("q", "a,b", true)]