From cb6693fa788c8257531439c8c2d4afc937e8a8ad Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Sat, 26 Sep 2026 03:40:25 -0600 Subject: [PATCH 1/2] Support validated server replies with explicit status codes --- docs/src/artifacts.md | 5 + docs/src/reference.md | 6 ++ docs/src/servers.md | 49 ++++++++- ext/OpenAPIHTTPExt.jl | 9 +- src/OpenAPI.jl | 2 + src/runtime.jl | 29 +++++- src/servergen.jl | 46 ++++++--- test/generated_contract.jl | 21 +++- test/openapi_trim_workload.jl | 19 +++- test/runtests.jl | 1 + test/servergen.jl | 189 ++++++++++++++++++++++++++++++++++ test/trim_compile_tests.jl | 24 +++-- 12 files changed, 361 insertions(+), 39 deletions(-) diff --git a/docs/src/artifacts.md b/docs/src/artifacts.md index 97dd24f..0d759b8 100644 --- a/docs/src/artifacts.md +++ b/docs/src/artifacts.md @@ -1,5 +1,10 @@ # Generated modules and the runtime contract +The current runtime uses **contract 4**. Regenerate clients and servers stored +under contract 3 with `OpenAPI.client` or `OpenAPI.server` before loading them +with this runtime. This applies even when the application does not use +`OpenAPI.Reply`; clients and servers share the same exact contract guard. + 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 diff --git a/docs/src/reference.md b/docs/src/reference.md index af49311..0de1519 100644 --- a/docs/src/reference.md +++ b/docs/src/reference.md @@ -52,6 +52,12 @@ OpenAPI.server_source OpenAPI.server_module_source ``` +## Server replies + +```@docs +OpenAPI.Reply +``` + ## Document authoring ```@docs diff --git a/docs/src/servers.md b/docs/src/servers.md index 6125aba..ae4f698 100644 --- a/docs/src/servers.md +++ b/docs/src/servers.md @@ -4,6 +4,11 @@ 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 "Regenerate stored modules" + This runtime uses generated-code contract 4. Clients and servers generated + under contract 3 must be regenerated, even when their handlers do not use + `OpenAPI.Reply`. See [the runtime contract](@ref "Generated modules and the runtime contract"). + ```julia using OpenAPI, HTTP @@ -106,6 +111,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`, @@ -115,7 +161,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 diff --git a/ext/OpenAPIHTTPExt.jl b/ext/OpenAPIHTTPExt.jl index 1dd5287..e799905 100644 --- a/ext/OpenAPIHTTPExt.jl +++ b/ext/OpenAPIHTTPExt.jl @@ -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, diff --git a/src/OpenAPI.jl b/src/OpenAPI.jl index 315d7c6..66ff87f 100644 --- a/src/OpenAPI.jl +++ b/src/OpenAPI.jl @@ -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") @@ -79,6 +80,7 @@ function fetchresource end :Operation, :Param, :Resources, + :Reply, :SchemaEngine, :SchemaRegistry, :ServerPlan, diff --git a/src/runtime.jl b/src/runtime.jl index f41d6bf..f9e653d 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -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 @@ -21,7 +21,7 @@ semantics. Bump this whenever any of those change so previously generated modules fail loudly at load time instead of misbehaving; see [`require_contract`](@ref). """ -const CONTRACT_VERSION = 3 +const CONTRACT_VERSION = 4 """ Runtime.require_contract(version::Integer, generator::AbstractString) @@ -1109,6 +1109,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 diff --git a/src/servergen.jl b/src/servergen.jl index 19c6d67..2f2a19f 100644 --- a/src/servergen.jl +++ b/src/servergen.jl @@ -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 @@ -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 @@ -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( @@ -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) @@ -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") diff --git a/test/generated_contract.jl b/test/generated_contract.jl index 106ec46..b9e7cf0 100644 --- a/test/generated_contract.jl +++ b/test/generated_contract.jl @@ -115,9 +115,9 @@ current = OpenAPI.Runtime.CONTRACT_VERSION # Deliberate tripwire: update alongside every CONTRACT_VERSION bump. - @test current == 3 + @test current == 4 @test OpenAPI.Runtime.require_contract(current, OpenAPI.PACKAGE_VERSION) === nothing - for generated_contract in (1, 2, current + 1) + for generated_contract in (1, 2, 3, current + 1) mismatch = try OpenAPI.Runtime.require_contract(generated_contract, "0.0.0") nothing @@ -131,6 +131,23 @@ @test occursin("provides contract $current", message) @test occursin("regenerate", message) end + + # Stored clients and servers from contract 3 fail before runtime + # imports, even if their handlers never use an explicit Reply. + for source in (client_source, server_source) + old_source = replace(source, guard => "Runtime.require_contract(3, \"1.1.3\")") + mismatch = try + Base.include_string(Module(:OldContractHost), old_source, "old-generated.jl") + nothing + catch error + error + end + @test mismatch isa LoadError + @test mismatch.error isa ErrorException + @test occursin("contract 3", sprint(showerror, mismatch)) + @test occursin("provides contract 4", sprint(showerror, mismatch)) + @test occursin("regenerate", sprint(showerror, mismatch)) + end end @testset "dialects are emitted by name, never positionally" begin diff --git a/test/openapi_trim_workload.jl b/test/openapi_trim_workload.jl index 77605b0..a946f39 100644 --- a/test/openapi_trim_workload.jl +++ b/test/openapi_trim_workload.jl @@ -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") @@ -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 diff --git a/test/runtests.jl b/test/runtests.jl index 6c5c5a9..332ceda 100644 --- a/test/runtests.jl +++ b/test/runtests.jl @@ -32,6 +32,7 @@ getschema(reg, T) = OpenAPI.schemaof(reg, T) for name in ( :Operation, :Param, + :Reply, :check, :client, :document, diff --git a/test/servergen.jl b/test/servergen.jl index b0b6788..e21bb14 100644 --- a/test/servergen.jl +++ b/test/servergen.jl @@ -1129,3 +1129,192 @@ end close(server) end end + + +@testset "explicit server reply statuses" begin + @test OpenAPI.Reply === OpenAPI.Runtime.Reply + payload = [1, 2] + @test OpenAPI.Reply(Int16(202), payload).body === payload + @test OpenAPI.Reply(big(599), nothing).status === 599 + @test OpenAPI.Reply{Any}(200, payload).body === payload + for status in (true, -1, 0, 100, 199, 600, big(typemax(Int)) + 1) + @test_throws ArgumentError OpenAPI.Reply(status, payload) + @test_throws ArgumentError OpenAPI.Reply{Vector{Int}}(status, payload) + end + @test_throws MethodError OpenAPI.Reply(200.0, payload) + + ref(name) = OpenAPI.obj("\$ref" => "#/components/schemas/$name") + response(schema; media = "application/json") = OpenAPI.obj( + "description" => "reply", + "content" => OpenAPI.obj(media => OpenAPI.obj("schema" => schema)), + ) + record(field, type) = OpenAPI.obj( + "type" => "object", "required" => [field], + "properties" => OpenAPI.obj(field => OpenAPI.obj("type" => type)), + "additionalProperties" => false, + ) + route(id, responses) = OpenAPI.obj("get" => OpenAPI.obj( + "operationId" => id, "responses" => responses, + )) + document = OpenAPI.obj( + "openapi" => "3.1.0", + "info" => OpenAPI.obj("title" => "Explicit replies", "version" => "1.0.0"), + "paths" => OpenAPI.obj( + "/choose" => route("choose", OpenAPI.obj( + "default" => response(ref("Fallback")), + "4XX" => response(ref("RangeError")), + "404" => response(ref("MissingItem")), + "200" => response(ref("Widget")), + "201" => response(ref("Widget")), + "202" => response(ref("Accepted")), + )), + "/documented" => route("documented", OpenAPI.obj("200" => response(ref("Widget")))), + "/only-errors" => route("onlyErrors", OpenAPI.obj("404" => response(ref("MissingItem")))), + "/empty" => route("emptyReply", OpenAPI.obj("204" => OpenAPI.obj("description" => "empty"))), + "/nullable" => route("nullableReply", OpenAPI.obj( + "202" => response(OpenAPI.obj("type" => ["string", "null"])), + )), + "/text" => route("textReply", OpenAPI.obj( + "203" => response(OpenAPI.obj("type" => "string", "minLength" => 2); media = "text/plain"), + )), + "/binary" => route("binaryReply", OpenAPI.obj( + "206" => response(OpenAPI.obj("type" => "string", "format" => "binary"); media = "application/octet-stream"), + )), + "/sequence" => route("sequenceReply", OpenAPI.obj( + "202" => response(OpenAPI.obj("type" => "array", "items" => OpenAPI.obj("type" => "integer")); media = "application/x-ndjson"), + )), + "/named-reply" => route("namedReply", OpenAPI.obj("200" => response(ref("Reply")))), + ), + "components" => OpenAPI.obj("schemas" => OpenAPI.obj( + "Widget" => record("value", "integer"), + "Accepted" => record("ticket", "string"), + "MissingItem" => record("missing", "string"), + "RangeError" => record("problem", "string"), + "Fallback" => record("fallback", "string"), + "Reply" => record("value", "integer"), + )), + ) + source = OpenAPI.server(document; name = "ExplicitReplyServer") + @test source == OpenAPI.server(document; name = "ExplicitReplyServer") + @test occursin("# choose(request)\n", source) + @test !occursin("# choose(request) ->", source) + @test occursin("# OpenAPI.Reply(status, body)", source) + host = Module(:ExplicitReplyHost) + Base.include_string(host, source, "ExplicitReplyServer.jl") + S = Base.invokelatest(getfield, host, :ExplicitReplyServer) + sget(name) = Base.invokelatest(getfield, S, name) + @test sget(:Reply) !== OpenAPI.Reply + @test isconcretetype(sget(:Reply)) + @test all(entry -> !occursin(" -> ", entry.signature), sget(:_SERVER_OPS)) + model(name, value) = Base.invokelatest(sget(name), value) + current = Ref{Any}(nothing) + handler = req -> current[] + impl = (; choose = handler, documented = handler, onlyerrors = handler, + emptyreply = handler, nullablereply = handler, textreply = handler, + binaryreply = handler, sequencereply = handler, namedreply = handler) + router = HTTP.Router() + Base.invokelatest(sget(:register!), router, impl) + server = HTTP.serve!(router, "127.0.0.1", 0; verbose = false) + try + base = "http://127.0.0.1:$(HTTP.port(server))" + function request(path, value) + current[] = value + return HTTP.get(base * path; status_exception = false) + end + parsed(response) = JSON.parse(String(copy(response.body))) + widget = model(:Widget, 7) + accepted = model(:Accepted, "queued") + for (status, body, expected) in ( + (200, widget, Dict("value" => 7)), + (201, widget, Dict("value" => 7)), + (202, accepted, Dict("ticket" => "queued")), + (404, model(:MissingItem, "gone"), Dict("missing" => "gone")), + (409, model(:RangeError, "conflict"), Dict("problem" => "conflict")), + (599, model(:Fallback, "other"), Dict("fallback" => "other")), + ) + result = request("/choose", OpenAPI.Reply(status, body)) + @test result.status == status + @test parsed(result) == expected + @test HTTP.header(result, "Content-Type") == "application/json" + end + + plain = request("/choose", widget) + @test plain.status == 200 + @test parsed(plain) == Dict("value" => 7) + @test request("/choose", accepted).status == 500 + invalid = request("/choose", OpenAPI.Reply(202, widget)) + @test invalid.status == 500 + @test occursin("schema validation failed", String(invalid.body)) + @test request("/choose", OpenAPI.Reply(404, model(:RangeError, "wrong exact schema"))).status == 500 + @test request("/choose", OpenAPI.Reply(409, model(:Fallback, "wrong range schema"))).status == 500 + + missing = request("/documented", OpenAPI.Reply(418, widget)) + @test missing.status == 500 + @test occursin("operation documented does not document response status 418", String(missing.body)) + @test request("/only-errors", nothing).status == 200 + @test request("/only-errors", OpenAPI.Reply(404, model(:MissingItem, "gone"))).status == 404 + + empty = request("/empty", OpenAPI.Reply(204, nothing)) + @test empty.status == 204 + @test isempty(empty.body) + @test request("/empty", OpenAPI.Reply(204, "unexpected")).status == 500 + nullable = request("/nullable", OpenAPI.Reply(202, nothing)) + @test nullable.status == 202 + @test String(nullable.body) == "null" + @test request("/nullable", OpenAPI.Reply(202, 1)).status == 500 + text = request("/text", OpenAPI.Reply(203, "ok")) + @test text.status == 203 + @test String(text.body) == "ok" + @test HTTP.header(text, "Content-Type") == "text/plain" + @test request("/text", OpenAPI.Reply(203, "x")).status == 500 + @test request("/text", OpenAPI.Reply(203, nothing)).status == 500 + binary = request("/binary", OpenAPI.Reply(206, UInt8[0x00, 0xff])) + @test binary.status == 206 + @test binary.body == UInt8[0x00, 0xff] + sequence = request("/sequence", OpenAPI.Reply(202, [1, 2])) + @test sequence.status == 202 + @test String(sequence.body) == "1\n2\n" + @test request("/sequence", OpenAPI.Reply(202, ["bad"])).status == 500 + named = request("/named-reply", OpenAPI.Reply(200, model(:Reply, 9))) + @test named.status == 200 + @test parsed(named) == Dict("value" => 9) + + raw = request("/choose", HTTP.Response(418, ["X-Raw" => "yes"], "unvalidated")) + @test raw.status == 418 + @test HTTP.header(raw, "X-Raw") == "yes" + @test String(raw.body) == "unvalidated" + + client_source = OpenAPI.client(document; name = "ExplicitReplyClient") + client_host = Module(:ExplicitReplyClientHost) + Base.include_string(client_host, client_source, "ExplicitReplyClient.jl") + C = Base.invokelatest(getfield, client_host, :ExplicitReplyClient) + cget(name) = Base.invokelatest(getfield, C, name) + client = Base.invokelatest(cget(:Client), base) + @test cget(:Reply) !== OpenAPI.Reply + for (status, body, name, field, expected) in ( + (200, widget, :Widget, :value, 7), + (201, widget, :Widget, :value, 7), + (202, accepted, :Accepted, :ticket, "queued"), + ) + current[] = OpenAPI.Reply(status, body) + result = Base.invokelatest(cget(:choose); client, with_http_info = true) + @test result.status == status + @test result.body isa cget(name) + @test getfield(result.body, field) == expected + end + current[] = OpenAPI.Reply(404, model(:MissingItem, "gone")) + failure = try + Base.invokelatest(cget(:choose); client) + nothing + catch error + error + end + @test failure isa cget(:ApiError) + @test failure.status == 404 + @test failure.decode_error === nothing + @test failure.decoded isa cget(:MissingItem) + @test failure.decoded.missing == "gone" + finally + close(server) + end +end diff --git a/test/trim_compile_tests.jl b/test/trim_compile_tests.jl index 0e5496e..58287da 100644 --- a/test/trim_compile_tests.jl +++ b/test/trim_compile_tests.jl @@ -130,18 +130,20 @@ function _run_openapi_trim_case(trim_project::String)::Nothing Sys.iswindows() ? output_name * ".exe" : output_name, ) @test isfile(executable) - run_exit, run_output, run_timed_out = _run_openapi_command( - `$(abspath(executable))`; - timeout_s = _OPENAPI_TRIM_RUN_TIMEOUT_S, - label = "run", - ) - if run_timed_out || run_exit != 0 - println("---- trim executable output ----") - println(run_output) - println("---- end trim executable output ----") + for args in (("202", "queued", "100"), ("599", "other", "600")) + run_exit, run_output, run_timed_out = _run_openapi_command( + addenv(`$(abspath(executable)) $args`, "JULIA_LOAD_CODEGEN_LIB" => "0"); + timeout_s = _OPENAPI_TRIM_RUN_TIMEOUT_S, + label = "run", + ) + if run_timed_out || run_exit != 0 + println("---- trim executable output ----") + println(run_output) + println("---- end trim executable output ----") + end + @test !run_timed_out + @test run_exit == 0 end - @test !run_timed_out - @test run_exit == 0 end println( "[trim] compile DONE openapi_trim_workload.jl ($(round(time() - started; digits = 2))s)", From 420947766feee6c9eb19245ebe345945c2abae7a Mon Sep 17 00:00:00 2001 From: Jacob Quinn Date: Mon, 28 Sep 2026 09:12:52 -0600 Subject: [PATCH 2/2] Preserve compatibility with contract-3 generated modules --- docs/src/artifacts.md | 24 ++++++++++++++---------- docs/src/servers.md | 9 +++++---- src/runtime.jl | 18 ++++++++++++------ test/generated_contract.jl | 29 ++++++++++++----------------- 4 files changed, 43 insertions(+), 37 deletions(-) diff --git a/docs/src/artifacts.md b/docs/src/artifacts.md index 0d759b8..90dd1f2 100644 --- a/docs/src/artifacts.md +++ b/docs/src/artifacts.md @@ -1,9 +1,11 @@ # Generated modules and the runtime contract -The current runtime uses **contract 4**. Regenerate clients and servers stored -under contract 3 with `OpenAPI.client` or `OpenAPI.server` before loading them -with this runtime. This applies even when the application does not use -`OpenAPI.Reply`; clients and servers share the same exact contract guard. +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 @@ -21,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 diff --git a/docs/src/servers.md b/docs/src/servers.md index ae4f698..197612d 100644 --- a/docs/src/servers.md +++ b/docs/src/servers.md @@ -4,10 +4,11 @@ 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 "Regenerate stored modules" - This runtime uses generated-code contract 4. Clients and servers generated - under contract 3 must be regenerated, even when their handlers do not use - `OpenAPI.Reply`. See [the runtime contract](@ref "Generated modules and the runtime contract"). +!!! 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 diff --git a/src/runtime.jl b/src/runtime.jl index f9e653d..c4dbdae 100644 --- a/src/runtime.jl +++ b/src/runtime.jl @@ -17,17 +17,21 @@ 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 = 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 @@ -35,7 +39,7 @@ 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", @@ -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`.", ) diff --git a/test/generated_contract.jl b/test/generated_contract.jl index b9e7cf0..49cf2f5 100644 --- a/test/generated_contract.jl +++ b/test/generated_contract.jl @@ -116,8 +116,10 @@ current = OpenAPI.Runtime.CONTRACT_VERSION # Deliberate tripwire: update alongside every CONTRACT_VERSION bump. @test current == 4 - @test OpenAPI.Runtime.require_contract(current, OpenAPI.PACKAGE_VERSION) === nothing - for generated_contract in (1, 2, 3, current + 1) + 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") nothing @@ -128,25 +130,18 @@ 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 - # Stored clients and servers from contract 3 fail before runtime - # imports, even if their handlers never use an explicit Reply. - for source in (client_source, server_source) - old_source = replace(source, guard => "Runtime.require_contract(3, \"1.1.3\")") - mismatch = try - Base.include_string(Module(:OldContractHost), old_source, "old-generated.jl") - nothing - catch error - error + # 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 - @test mismatch isa LoadError - @test mismatch.error isa ErrorException - @test occursin("contract 3", sprint(showerror, mismatch)) - @test occursin("provides contract 4", sprint(showerror, mismatch)) - @test occursin("regenerate", sprint(showerror, mismatch)) end end