Skip to content

Commit 472ad6f

Browse files
authored
Opt-in OpenAPI 3.0 root document (#53) (#84)
Adds Bier.OpenAPI.V3, converting the generated Swagger 2.0 document to OpenAPI 3.0.3, opt-in via openapi_version: "3.0". Default wire format remains PostgREST-parity Swagger 2.0.
1 parent f5b3bca commit 472ad6f

10 files changed

Lines changed: 491 additions & 12 deletions

File tree

.credo.exs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,12 @@
2828
{Credo.Check.Refactor.CyclomaticComplexity,
2929
[files: %{excluded: ["lib/bier/pg_error.ex"]}]},
3030
# `Bier.Config` mirrors PostgREST's full option surface in
31-
# `Bier.schema/0` (41 fields today, growing as PostgREST keys are
31+
# `Bier.schema/0` (46 fields today, growing as PostgREST keys are
3232
# implemented). The VM map-representation concern (structs >= 32
3333
# fields) doesn't apply: Config is created once per instance at
3434
# boot, never per request.
3535
{Credo.Check.Warning.StructFieldAmount,
36-
[max_fields: 45, files: %{included: ["lib/bier/config.ex"]}]},
36+
[max_fields: 50, files: %{included: ["lib/bier/config.ex"]}]},
3737
# `Bier.ErrorLogger` tags its entries with `:bier_instance` /
3838
# `:bier_error_code` metadata for host log pipelines. A library has
3939
# no say in the host's `:logger` formatter config, so the "key not

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,7 @@ defaults are sourced from application env, so you can also set them under
111111
| `server_trace_header` | `nil` | Request header (e.g. `X-Request-Id`) echoed on the response. |
112112
| `log_level` | `:error` | Access-log verbosity. |
113113
| `openapi_mode` | `"follow-privileges"` | How the root OpenAPI document is served; under `follow-privileges`, per-role privilege filtering is cached and refreshes on schema-cache reload. |
114+
| `openapi_version` | `"2.0"` | OpenAPI document version; `"3.0"` emits OpenAPI 3.0.3 (a Bier extension; PostgREST/postgrest#932). |
114115
| `openapi_security_active` | `false` | Advertise JWT security definitions in the OpenAPI document. |
115116

116117
See `Bier.schema/0` for the complete, documented list.

lib/bier.ex

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -402,6 +402,17 @@ defmodule Bier do
402402
openapi-security-active). Defaults to `false`.
403403
"""
404404
],
405+
openapi_version: [
406+
type: {:in, ["2.0", "3.0"]},
407+
default: env(:openapi_version, "2.0"),
408+
doc: """
409+
Version of the generated root OpenAPI document. `"2.0"` (the default)
410+
is the Swagger 2.0 document PostgREST emits, byte-for-byte; `"3.0"`
411+
serves an OpenAPI 3.0.3 translation of the same content. Bier-only
412+
option — PostgREST has no OpenAPI 3.x emitter (postgrest#932). Ignored
413+
when `db_root_spec` overrides the document.
414+
"""
415+
],
405416
admin_server_port: [
406417
type: {:or, [:pos_integer, nil]},
407418
default: env(:admin_server_port, nil),

lib/bier/config.ex

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ defmodule Bier.Config do
6060
log_level: :crit | :error | :warn | :info | :debug,
6161
openapi_mode: String.t(),
6262
openapi_security_active: boolean(),
63+
openapi_version: String.t(),
6364
db_root_spec: String.t() | nil,
6465
admin_server_port: pos_integer() | nil,
6566
events_channels: [String.t()],
@@ -95,6 +96,7 @@ defmodule Bier.Config do
9596
ssl: false,
9697
openapi_mode: "follow-privileges",
9798
openapi_security_active: false,
99+
openapi_version: "2.0",
98100
pool_size: 10,
99101
db_schemas: ["public"],
100102
db_extra_search_path: ["public"],

lib/bier/openapi.ex

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ defmodule Bier.OpenAPI do
22
@moduledoc """
33
Builds the Swagger 2.0 (OpenAPI 2.0) root document from an introspection
44
snapshot. Wire-format match to PostgREST v14.12 is the contract; see
5-
spec/openapi.yaml and spec/conformance/cases/16*.yaml.
5+
spec/openapi.yaml and spec/conformance/cases/16*.yaml. An opt-in
6+
OpenAPI 3.0.3 translation of this document is available via the
7+
`openapi_version: "3.0"` config option (`Bier.OpenAPI.V3`).
68
"""
79

810
alias Bier.OpenAPI.Types

lib/bier/openapi/v3.ex

Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
defmodule Bier.OpenAPI.V3 do
2+
@moduledoc """
3+
Converts the generated Swagger 2.0 root document (`Bier.OpenAPI.build/1`)
4+
into an OpenAPI 3.0.3 document.
5+
6+
Opt-in via the `openapi_version: "3.0"` config option; the default remains
7+
the PostgREST-parity Swagger 2.0 wire format. Converting the finished 2.0
8+
map (rather than emitting 3.0 from the introspection model in parallel)
9+
keeps a single wire-format source of truth: parity fixes to the 2.0
10+
emitter propagate here automatically. PostgREST core has no OpenAPI 3.x
11+
emitter (PostgREST/postgrest#932), so this output has no conformance
12+
surface and is shaped by the OpenAPI 3.0.3 spec alone.
13+
14+
The converter is intentionally NOT general purpose: it handles exactly the
15+
shapes the 2.0 emitter produces (body params only as `body.*` shared
16+
definitions or the inline RPC `args`, `application/json` as the implied
17+
media type, `collectionFormat: "multi"` only on query params).
18+
19+
**Known limitation:** component keys inherit relation and column names
20+
verbatim; names outside OAS 3.0.3's component-key charset (`^[a-zA-Z0-9.\-_]+$`)
21+
produce technically invalid 3.0 component keys, and key sanitization is
22+
deliberately out of scope for this parity-driven converter.
23+
"""
24+
25+
@json "application/json"
26+
27+
# Parameter-object keys that stay at the parameter level in 3.0; everything
28+
# else (type/format/enum/default/maxLength/items/...) nests under "schema".
29+
@param_keys ~w(name in required description)
30+
31+
@doc "Converts a Swagger 2.0 document map into an OpenAPI 3.0.3 one."
32+
@spec convert(map()) :: map()
33+
def convert(doc) do
34+
{bodies, params} = split_shared_params(doc["parameters"] || %{})
35+
36+
components =
37+
%{}
38+
|> put_nonempty("schemas", rewrite_refs(doc["definitions"] || %{}))
39+
|> put_nonempty("parameters", Map.new(params, fn {k, p} -> {k, convert_param(p)} end))
40+
|> put_nonempty("requestBodies", Map.new(bodies, fn {k, p} -> {k, convert_body(p)} end))
41+
|> put_nonempty("securitySchemes", doc["securityDefinitions"])
42+
43+
body_keys = bodies |> Enum.map(&elem(&1, 0)) |> MapSet.new()
44+
45+
# This map enumerates the eight keys the 2.0 emitter can produce:
46+
# swagger, info, externalDocs, basePath, paths, definitions, parameters,
47+
# security/securityDefinitions/schemes/host (via their handlers). A new
48+
# top-level emitter key must be added here or it will be silently dropped.
49+
%{
50+
"openapi" => "3.0.3",
51+
"info" => doc["info"],
52+
"externalDocs" => doc["externalDocs"],
53+
"servers" => servers(doc),
54+
"paths" => convert_paths(doc["paths"] || %{}, body_keys),
55+
"components" => components
56+
}
57+
|> put_nonempty("security", doc["security"])
58+
end
59+
60+
defp put_nonempty(map, _k, v) when v in [nil, %{}], do: map
61+
defp put_nonempty(map, k, v), do: Map.put(map, k, v)
62+
63+
# ---- servers -------------------------------------------------------------
64+
65+
# With a proxy the 2.0 doc carries schemes/host/basePath; fold them into one
66+
# server URL. Without one, the API lives at the document root.
67+
defp servers(%{"host" => host, "schemes" => [scheme | _]} = doc) do
68+
base = if doc["basePath"] in [nil, "/"], do: "", else: doc["basePath"]
69+
[%{"url" => "#{scheme}://#{host}#{base}"}]
70+
end
71+
72+
defp servers(_doc), do: [%{"url" => "/"}]
73+
74+
# ---- shared parameters ---------------------------------------------------
75+
76+
defp split_shared_params(params) do
77+
Enum.split_with(params, fn {_k, p} -> p["in"] == "body" end)
78+
end
79+
80+
defp convert_body(p) do
81+
%{
82+
"required" => p["required"],
83+
"content" => %{@json => %{"schema" => rewrite_refs(p["schema"])}}
84+
}
85+
|> put_nonempty("description", p["description"])
86+
end
87+
88+
defp convert_param(p) do
89+
{kept, schema_keys} = Map.split(p, @param_keys)
90+
91+
schema =
92+
case Map.pop(schema_keys, "collectionFormat") do
93+
{"multi", rest} -> rest
94+
{nil, rest} -> rest
95+
end
96+
|> rewrite_refs()
97+
98+
kept
99+
|> Map.put("schema", schema)
100+
|> then(fn param ->
101+
if schema_keys["collectionFormat"] == "multi",
102+
do: Map.merge(param, %{"style" => "form", "explode" => true}),
103+
else: param
104+
end)
105+
end
106+
107+
# ---- paths ---------------------------------------------------------------
108+
109+
defp convert_paths(paths, body_keys) do
110+
Map.new(paths, fn {path, item} ->
111+
{path, Map.new(item, fn {verb, op} -> {verb, convert_operation(op, body_keys)} end)}
112+
end)
113+
end
114+
115+
defp convert_operation(op, body_keys) do
116+
{body, params} = extract_body(op["parameters"] || [], body_keys)
117+
118+
op
119+
|> Map.put("parameters", Enum.map(params, &convert_op_param/1))
120+
|> Map.update("responses", %{}, &convert_responses/1)
121+
|> put_nonempty("requestBody", body)
122+
|> then(fn converted ->
123+
if converted["parameters"] == [], do: Map.delete(converted, "parameters"), else: converted
124+
end)
125+
end
126+
127+
# One body param at most per operation (the 2.0 emitter guarantees it):
128+
# either a $ref to a shared body.<table> definition or the inline RPC args.
129+
defp extract_body(parameters, body_keys) do
130+
Enum.reduce(parameters, {nil, []}, fn param, {body, rest} ->
131+
case param do
132+
%{"$ref" => "#/parameters/" <> key} ->
133+
if MapSet.member?(body_keys, key) do
134+
{%{"$ref" => "#/components/requestBodies/#{key}"}, rest}
135+
else
136+
{body, rest ++ [param]}
137+
end
138+
139+
%{"in" => "body"} = inline ->
140+
{convert_body(inline), rest}
141+
142+
inline ->
143+
{body, rest ++ [inline]}
144+
end
145+
end)
146+
end
147+
148+
defp convert_op_param(%{"$ref" => "#/parameters/" <> key}),
149+
do: %{"$ref" => "#/components/parameters/#{key}"}
150+
151+
defp convert_op_param(inline), do: convert_param(inline)
152+
153+
defp convert_responses(responses) do
154+
Map.new(responses, fn
155+
{status, %{"schema" => schema} = resp} ->
156+
{status,
157+
resp
158+
|> Map.delete("schema")
159+
|> Map.put("content", %{@json => %{"schema" => rewrite_refs(schema)}})}
160+
161+
{status, resp} ->
162+
{status, resp}
163+
end)
164+
end
165+
166+
# ---- $ref rewriting ------------------------------------------------------
167+
168+
# Walks any JSON-ish term and repoints definition refs at components.
169+
defp rewrite_refs(%{} = map) do
170+
Map.new(map, fn
171+
{"$ref", "#/definitions/" <> name} -> {"$ref", "#/components/schemas/#{name}"}
172+
{k, v} -> {k, rewrite_refs(v)}
173+
end)
174+
end
175+
176+
defp rewrite_refs(list) when is_list(list), do: Enum.map(list, &rewrite_refs/1)
177+
defp rewrite_refs(other), do: other
178+
end

lib/bier/plugs/action_controller.ex

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ defmodule Bier.Plugs.ActionController do
2323
alias Bier.MediaType
2424
alias Bier.Mutation
2525
alias Bier.Negotiation
26+
alias Bier.OpenAPI.V3
2627
alias Bier.Pagination
2728
alias Bier.Plan
2829
alias Bier.Plugs.FallbackController
@@ -190,7 +191,9 @@ defmodule Bier.Plugs.ActionController do
190191
# advertises a relation the instance cannot serve. Only the per-role
191192
# privileges lookup depends on the request, and it is served from
192193
# Bier.PrivilegesCache (keyed by role + snapshot generation); the catalog is
193-
# queried once per role per schema-cache load.
194+
# queried once per role per schema-cache load. config.openapi_version toggles
195+
# the wire format: "2.0" (default) returns this Swagger document as-is; "3.0"
196+
# translates it through Bier.OpenAPI.V3.convert/1 before it is encoded.
194197
defp build_openapi_document(config, role) do
195198
schema = hd(config.db_schemas)
196199
cache = Bier.SchemaCache.get(config.name)
@@ -205,14 +208,22 @@ defmodule Bier.Plugs.ActionController do
205208
{relations, functions} =
206209
filter_by_mode(config, role, schema, relations, functions, cache.generation)
207210

208-
Bier.OpenAPI.build(%{
209-
relations: relations,
210-
functions: function_inputs(functions),
211-
schema_comment: cache.schema_comment,
212-
security_active?: config.openapi_security_active,
213-
proxy_uri: config.openapi_server_proxy_uri,
214-
docs_version: "v14"
215-
})
211+
doc =
212+
Bier.OpenAPI.build(%{
213+
relations: relations,
214+
functions: function_inputs(functions),
215+
schema_comment: cache.schema_comment,
216+
security_active?: config.openapi_security_active,
217+
proxy_uri: config.openapi_server_proxy_uri,
218+
docs_version: "v14"
219+
})
220+
221+
# openapi_version: "3.0" serves an OpenAPI 3.0.3 translation of the same
222+
# content; "2.0" (default) stays the PostgREST-parity Swagger wire format.
223+
case config.openapi_version do
224+
"3.0" -> V3.convert(doc)
225+
_ -> doc
226+
end
216227
end
217228

218229
defp filter_by_mode(config, role, schema, relations, functions, generation) do

test/bier/config_test.exs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -192,4 +192,17 @@ defmodule Bier.ConfigTest do
192192
assert config.app_settings == %{"foo" => "bar"}
193193
end
194194
end
195+
196+
describe "openapi_version" do
197+
test "defaults to 2.0 and accepts 3.0" do
198+
assert Bier.Config.new!([], Bier.schema()).openapi_version == "2.0"
199+
assert Bier.Config.new!([openapi_version: "3.0"], Bier.schema()).openapi_version == "3.0"
200+
end
201+
202+
test "rejects unknown versions" do
203+
assert_raise ArgumentError, ~r/openapi_version/, fn ->
204+
Bier.Config.new!([openapi_version: "3.1"], Bier.schema())
205+
end
206+
end
207+
end
195208
end

test/bier/openapi_v3_http_test.exs

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
defmodule Bier.OpenAPIV3HttpTest do
2+
@moduledoc """
3+
Boots a dedicated instance with openapi_version: "3.0" against the
4+
bier_test fixture database and asserts the root serves an OpenAPI 3.0.3
5+
document (#53 item 3 follow-up: wiring Bier.OpenAPI.V3.convert/1 into the
6+
root endpoint). Everything else about the root endpoint (negotiation, HEAD,
7+
openapi-mode, db-root-spec precedence) is unchanged and covered elsewhere.
8+
"""
9+
use ExUnit.Case, async: false
10+
11+
alias Bier.TestPorts
12+
13+
@moduletag :integration
14+
15+
setup_all do
16+
port = TestPorts.free_port()
17+
name = :"openapi_v3_http_#{System.unique_integer([:positive])}"
18+
19+
opts =
20+
Bier.ConformanceServer.base_opts()
21+
|> Keyword.merge(
22+
name: name,
23+
router: [port: port, scheme: :http],
24+
db_schemas: ["test"],
25+
openapi_version: "3.0"
26+
)
27+
28+
{:ok, pid} = Bier.start_link(opts)
29+
on_exit(fn -> if Process.alive?(pid), do: Supervisor.stop(pid) end)
30+
TestPorts.wait_until_listening(port)
31+
%{url: "http://localhost:#{port}/"}
32+
end
33+
34+
test "GET / serves an OpenAPI 3.0.3 document", %{url: url} do
35+
resp = Req.get!(url, headers: [{"accept", "application/json"}], retry: false)
36+
37+
assert resp.status == 200
38+
assert resp.body["openapi"] == "3.0.3"
39+
refute Map.has_key?(resp.body, "swagger")
40+
assert map_size(resp.body["components"]["schemas"]) > 0
41+
assert resp.body["servers"] == [%{"url" => "/"}]
42+
end
43+
44+
test "content negotiation is unchanged: csv at root is still 406", %{url: url} do
45+
resp = Req.get!(url, headers: [{"accept", "text/csv"}], retry: false)
46+
assert resp.status == 406
47+
end
48+
end

0 commit comments

Comments
 (0)