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
21 changes: 15 additions & 6 deletions docs/src/artifacts.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,12 @@
# Generated modules and the runtime contract

The current runtime supports **contracts 3 through 4** and generates
**contract 4** modules. Existing contract-3 clients and servers remain compatible
and keep their generated behavior. Regenerate a server to use `OpenAPI.Reply`.
Contract-4 modules require OpenAPI.jl 1.2 or later; older runtimes still reject
contract 4 at load time. Packages that commit contract-4 generated modules
should set `OpenAPI = "1.2"` in their `Project.toml` compatibility bounds.

A generated module targets an OpenAPI.jl generated-code contract version. It
also records the exact OpenAPI.jl version that produced it. The module imports
internal `OpenAPI.Runtime` machinery and bakes runtime data shapes — operation
Expand All @@ -16,12 +23,14 @@ Every generated module therefore records and checks its provenance:
`N` is the generated-code contract version
([`OpenAPI.Runtime.CONTRACT_VERSION`](@ref)) current at generation time.

A release that changes any part of the generated-code contract bumps
`CONTRACT_VERSION`, so a previously generated module fails at load time with
an error naming the release that generated it and asking for regeneration —
instead of failing mysteriously, or worse silently, inside the runtime.
Releases with the same contract version remain load-compatible, so compatible
runtime fixes do not require regeneration.
A release that introduces a new generated-code contract bumps
`CONTRACT_VERSION`. The runtime accepts every contract from
`OpenAPI.Runtime.MIN_CONTRACT_VERSION` through `CONTRACT_VERSION`,
inclusive. Additive changes can preserve support for older generated modules;
breaking changes raise `MIN_CONTRACT_VERSION`. An unsupported module fails at
load time with an error naming the release that generated it and asking for
regeneration, instead of failing inside the runtime. Compatible runtime fixes
do not require regeneration.

## When to regenerate

Expand Down
6 changes: 6 additions & 0 deletions docs/src/reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ OpenAPI.server_source
OpenAPI.server_module_source
```

## Server replies

```@docs
OpenAPI.Reply
```

## Document authoring

```@docs
Expand Down
50 changes: 49 additions & 1 deletion docs/src/servers.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ The same document generates a server-stub module. The document stays the
source of truth: generate the client and the server from one specification and
implement one handler function per operation.

!!! note "Generated-module compatibility"
This runtime supports generated-code contracts 3 through 4. Existing
contract-3 servers keep their generated behavior; regenerate them to use
`OpenAPI.Reply`. Contract-4 modules require OpenAPI.jl 1.2 or later.
See [the runtime contract](@ref "Generated modules and the runtime contract").

```julia
using OpenAPI, HTTP

Expand Down Expand Up @@ -106,6 +112,47 @@ handler contract — implementation module second, typed positional parameters,
typed-value-or-`HTTP.Response` returns — matches the shape OpenAPI.jl 0.2.x
users generated with `-g julia-server`.

## Choosing a response status

A plain return value uses the first documented success response. When an
operation documents several outcomes, return [`OpenAPI.Reply`](@ref) to choose
the status explicitly while retaining body validation and encoding:

```julia
using OpenAPI

function submit(request, body)
if needs_processing(body)
return OpenAPI.Reply(202, (; ticket = "queued"))
end
return (; id = body.id) # this operation documents its first success as 200
end

function get_item(request, id)
item = lookup_item(id)
item === nothing && return OpenAPI.Reply(404, (; message = "no such item"))
return item
end
```

The body can be a generated model, named tuple, dictionary or another value
the documented media type supports. It must satisfy the schema selected by
the status: an exact code takes precedence over a range such as `4XX`, which
takes precedence over `default`. The explicit status is sent unchanged; it
is never inferred from the body's Julia type. Two statuses may use the same
body type.

`Reply` accepts final HTTP statuses from 200 through 599. Informational `1xx`
responses are not handler results. A status with no exact, range or default
entry fails with an error naming the operation and status; the HTTP extension
reports this as a server error. Schema failures also produce a server error.
`OpenAPI.Reply(204, nothing)` sends an empty body when 204 documents no content;
`nothing` becomes JSON `null` only when the selected JSON schema accepts it.

For custom headers or output that deliberately bypasses generated validation,
return an `HTTP.Response` directly. `Reply` does not add header or media-type
overrides.

## Request decoding and response encoding

Request decoding mirrors client encoding: parameter styles (`simple`, `label`,
Expand All @@ -115,7 +162,8 @@ cookie parameters, JSON, `application/x-www-form-urlencoded`, and
before handlers run. Decoding failures produce structured JSON `400` (or `415`
for undocumented media types) responses without invoking the handler. Response
values are validated against the output-direction schema and encoded from the
first documented success response. Returning `nothing` follows that response:
first documented success response, or the status selected by `OpenAPI.Reply`.
Returning `nothing` follows that response:
it emits an empty body when the response has no content, or JSON `null` when
the selected JSON schema accepts null. A full `HTTP.Response` bypasses
generated status, header, and body validation. The handler owns that
Expand Down
9 changes: 5 additions & 4 deletions ext/OpenAPIHTTPExt.jl
Original file line number Diff line number Diff line change
Expand Up @@ -146,10 +146,11 @@ end
#
# Mount every documented operation on `router`, dispatching to the handler
# functions `impl` defines (one per operation; the expected signatures are
# listed at the top of this file). Handlers may return a documented typed
# value (encoded and validated automatically), `nothing` (a 204 response), or
# a full `HTTP.Response` for anything custom. `middleware` wraps each
# operation handler: `middleware(handler) -> handler`. `register` is an alias
# listed at the top of this file). Plain values use the first documented
# success response; OpenAPI.Reply selects an explicit status. Both paths
# validate and encode the body. A full HTTP.Response bypasses validation.
# `middleware` wraps each operation handler: `middleware(handler) -> handler`.
# `register` is an alias
# kept for familiarity with OpenAPI.jl 0.2.x generated servers.
function register!(
router::HTTP.Router,
Expand Down
2 changes: 2 additions & 0 deletions src/OpenAPI.jl
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ include("normalize.jl")
include("planning.jl")
include("read.jl")
include("runtime.jl")
using .Runtime: Reply
include("client.jl")
include("servergen.jl")
include("precompile.jl")
Expand Down Expand Up @@ -79,6 +80,7 @@ function fetchresource end
:Operation,
:Param,
:Resources,
:Reply,
:SchemaEngine,
:SchemaRegistry,
:ServerPlan,
Expand Down
47 changes: 39 additions & 8 deletions src/runtime.jl
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
"""
Runtime support for generated OpenAPI clients.
Runtime support for generated OpenAPI clients and servers.

Generated client modules import this module's machinery instead of carrying a
pasted copy: protocol encoding and decoding, parameter styling, content
Expand All @@ -17,25 +17,29 @@ using JSON, Base64, Dates, UUIDs
Version of the contract between this runtime and generated modules: the names
generated code imports, the shapes of the data it bakes ([`Spec`](@ref)
keywords, operation tables, schema descriptors, dialect literals), and their
semantics. Bump this whenever any of those change so previously generated
modules fail loudly at load time instead of misbehaving; see
[`require_contract`](@ref).
semantics. Bump this whenever generated code needs a new contract so older
runtimes reject it at load time; see [`require_contract`](@ref).
"""
const CONTRACT_VERSION = 3
const CONTRACT_VERSION = 4

# Oldest supported contract; raise only when runtime changes break names,
# data shapes, or semantics older generated modules depend on.
const MIN_CONTRACT_VERSION = 3

"""
Runtime.require_contract(version::Integer, generator::AbstractString)

Called at load time by every generated module to assert that the loaded
runtime still provides the contract the module was generated against;
runtime still provides the contract the module was generated against, from
`MIN_CONTRACT_VERSION` through [`CONTRACT_VERSION`](@ref), inclusive;
`generator` records the OpenAPI.jl version that produced the module. Throws
with regeneration guidance on mismatch. This function and
[`CONTRACT_VERSION`](@ref) are permanently stable names: renaming either would
make old generated modules fail with a bare `UndefVarError` instead of this
error.
"""
function require_contract(version::Integer, generator::AbstractString)
version == CONTRACT_VERSION && return nothing
MIN_CONTRACT_VERSION <= version <= CONTRACT_VERSION && return nothing
runtime = something(pkgversion(@__MODULE__), "unknown")
return error(
"this generated module was produced by OpenAPI.jl v",
Expand All @@ -44,7 +48,9 @@ function require_contract(version::Integer, generator::AbstractString)
version,
", but the loaded OpenAPI.jl v",
runtime,
" provides contract ",
" provides contracts ",
MIN_CONTRACT_VERSION,
" through ",
CONTRACT_VERSION,
"; regenerate the module with `OpenAPI.client` or `OpenAPI.server`.",
)
Expand Down Expand Up @@ -1109,6 +1115,31 @@ struct ApiResponse{T}
body::T
end

"""
OpenAPI.Reply(status::Integer, body)

Return a body from a generated server handler with an explicit final HTTP
status from 200 through 599. The server selects the documented response by
exact status, then status range, then `default`, and validates and encodes
`body` using that response. An undocumented status is an error.

Plain handler results retain the first documented success response. For custom
headers or unvalidated output, return the server framework's response object.
Use `OpenAPI.Reply(204, nothing)` for a documented response with no content.
"""
struct Reply{T}
status::Int
body::T

function Reply{T}(status::Integer, body) where {T}
200 <= status <= 599 ||
throw(ArgumentError("Reply status must be a final HTTP status from 200 through 599"))
return new{T}(Int(status), body)
end
end

Reply(status::Integer, body::T) where {T} = Reply{T}(status, body)

struct UnexpectedBody <: Exception
operation_id::String
status::Int
Expand Down
46 changes: 30 additions & 16 deletions src/servergen.jl
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import OpenAPI.Runtime:
_encode, _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, _validate_schema
_select_media, _select_response, _validate_schema
"""

# Emitted after the schema data constants; packages the document-specific
Expand Down Expand Up @@ -690,25 +690,36 @@ function _success_response(responses)
end

function _server_response(operation, result)
descriptor = _success_response(operation.responses)
if descriptor === nothing
# The OAS Responses Object is non-exhaustive documentation, and some
# documents cover only error codes (flagged at planning time as
# :missing_success_response). Answer `nothing` with an empty 200; a
# typed value has no documented media to encode against.
result === nothing && return (200, Pair{String,String}[], UInt8[])
throw(ArgumentError(string(
"operation ",
operation.id,
" documents no success response; return `nothing` for an empty 200 or a framework response",
if result isa OpenAPI.Reply
status = result.status
descriptor = _select_response(operation.responses, status)
descriptor === nothing && throw(ArgumentError(string(
"operation ", operation.id, " does not document response status ", status,
"; return a framework response for unvalidated output",
)))
result = result.body
else
descriptor = _success_response(operation.responses)
if descriptor === nothing
# The OAS Responses Object is non-exhaustive documentation, and some
# documents cover only error codes (flagged at planning time as
# :missing_success_response). Answer `nothing` with an empty 200; a
# typed value has no documented media to encode against.
result === nothing && return (200, Pair{String,String}[], UInt8[])
throw(ArgumentError(string(
"operation ",
operation.id,
" documents no success response; return `nothing` for an empty 200 or a framework response",
)))
end
status = _selector_status(descriptor.selector)
end
status = _selector_status(descriptor.selector)
if isempty(descriptor.media)
result === nothing || throw(ArgumentError(string(
"operation ",
operation.id,
" documents no success response content; return `nothing` or a framework response",
" documents no response content for status ", status,
"; return `nothing` or a framework response",
)))
return (status, Pair{String,String}[], UInt8[])
end
Expand All @@ -719,7 +730,7 @@ function _server_response(operation, result)
throw(ArgumentError(string(
"operation ",
operation.id,
" cannot encode `nothing` using its documented non-JSON success media types",
" cannot encode `nothing` using its documented non-JSON media types for status ", status,
)))
end
index = something(
Expand Down Expand Up @@ -835,7 +846,7 @@ function _server_stub_signature(operation::OperationPlan)
end
text = operation.name * "(" * join(positional, ", ")
isempty(keywords) || (text *= "; " * join(keywords, ", "))
return text * ") -> " * operation.return_type
return text * ")"
end

function _emit_server_operations(io::IO, plan::ServerPlan)
Expand Down Expand Up @@ -906,6 +917,9 @@ function server_module_source(
for operation in plan.operations
println(io, "# ", _server_stub_signature(operation))
end
println(io, "# Plain results use the first documented success response. Return")
println(io, "# OpenAPI.Reply(status, body) to select and validate a different response,")
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")
Expand Down
18 changes: 15 additions & 3 deletions test/generated_contract.jl
Original file line number Diff line number Diff line change
Expand Up @@ -115,8 +115,10 @@

current = OpenAPI.Runtime.CONTRACT_VERSION
# Deliberate tripwire: update alongside every CONTRACT_VERSION bump.
@test current == 3
@test OpenAPI.Runtime.require_contract(current, OpenAPI.PACKAGE_VERSION) === nothing
@test current == 4
for supported in (3, current)
@test OpenAPI.Runtime.require_contract(supported, OpenAPI.PACKAGE_VERSION) === nothing
end
for generated_contract in (1, 2, current + 1)
mismatch = try
OpenAPI.Runtime.require_contract(generated_contract, "0.0.0")
Expand All @@ -128,9 +130,19 @@
message = sprint(showerror, mismatch)
@test occursin("produced by OpenAPI.jl v0.0.0", message)
@test occursin("contract $generated_contract", message)
@test occursin("provides contract $current", message)
@test occursin("provides contracts 3 through $current", message)
@test occursin("regenerate", message)
end

# Generated clients and servers load with either supported contract.
for (source, name) in ((client_source, :ContractClient), (server_source, :ContractServer))
for supported in (3, current)
stored_source = replace(source, guard => "Runtime.require_contract($supported, \"1.1.3\")")
host = Module(:StoredContractHost)
Base.include_string(host, stored_source, "stored-generated.jl")
@test isdefined(host, name)
end
end
end

@testset "dialects are emitted by name, never positionally" begin
Expand Down
19 changes: 16 additions & 3 deletions test/openapi_trim_workload.jl
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,18 @@ function exercise_openapi_public_entrypoints()::Nothing
return nothing
end

function exercise_explicit_reply(status::Int, payload::String, invalid_status::Int)::Nothing
reply = OpenAPI.Reply(status, payload)
checked(reply.status == status && reply.body == payload, "explicit reply lost status or body")
try
OpenAPI.Reply{String}(invalid_status, payload)
error("invalid final reply status was accepted")
catch error
error isa ArgumentError || rethrow()
end
return nothing
end

function exercise_generated_client()::Nothing
client = TrimClient.Client("https://override.example.test")
checked(client.server == "https://override.example.test", "Client server was not set")
Expand All @@ -58,15 +70,16 @@ function exercise_generated_client()::Nothing
return nothing
end

function run_openapi_trim_workload()::Nothing
function run_openapi_trim_workload(args::Vector{String})::Nothing
length(args) == 3 || error("expected status, payload, and invalid status")
exercise_openapi_public_entrypoints()
exercise_explicit_reply(parse(Int, args[1]), args[2], parse(Int, args[3]))
exercise_generated_client()
return nothing
end

function @main(args::Vector{String})::Cint
_ = args
run_openapi_trim_workload()
run_openapi_trim_workload(args)
return 0
end

Expand Down
Loading
Loading