Skip to content
Merged
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
11 changes: 10 additions & 1 deletion docs/src/clients.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 2 additions & 0 deletions docs/src/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
102 changes: 87 additions & 15 deletions src/client.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand Down Expand Up @@ -526,7 +593,7 @@ function _emit_model(
io,
" ",
local_name,
" = _decode(",
" = ", decode, "(",
type,
", _required(_openapi_object, ",
repr(field.wire_name),
Expand All @@ -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),
Expand All @@ -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), "))")
Expand All @@ -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, ")")
Expand Down Expand Up @@ -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(;")
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand Down
56 changes: 56 additions & 0 deletions src/planning.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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,
Expand All @@ -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") ||
Expand Down
2 changes: 1 addition & 1 deletion src/runtime.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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))"))
Expand Down
7 changes: 6 additions & 1 deletion src/servergen.jl
Original file line number Diff line number Diff line change
Expand Up @@ -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')
Expand All @@ -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')
Expand Down
Loading
Loading