Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions docs/src/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
5 changes: 4 additions & 1 deletion docs/src/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
42 changes: 40 additions & 2 deletions ext/OpenAPITimeZonesExt.jl
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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
25 changes: 15 additions & 10 deletions src/client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
"""

Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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, ")")
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
20 changes: 15 additions & 5 deletions src/planning.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down
24 changes: 24 additions & 0 deletions src/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 6 additions & 3 deletions src/servergen.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand All @@ -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')
Expand Down
Loading
Loading