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
4 changes: 2 additions & 2 deletions .credo.exs
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,12 @@
{Credo.Check.Refactor.CyclomaticComplexity,
[files: %{excluded: ["lib/bier/pg_error.ex"]}]},
# `Bier.Config` mirrors PostgREST's full option surface in
# `Bier.schema/0` (41 fields today, growing as PostgREST keys are
# `Bier.schema/0` (46 fields today, growing as PostgREST keys are
# implemented). The VM map-representation concern (structs >= 32
# fields) doesn't apply: Config is created once per instance at
# boot, never per request.
{Credo.Check.Warning.StructFieldAmount,
[max_fields: 45, files: %{included: ["lib/bier/config.ex"]}]},
[max_fields: 50, files: %{included: ["lib/bier/config.ex"]}]},
# `Bier.ErrorLogger` tags its entries with `:bier_instance` /
# `:bier_error_code` metadata for host log pipelines. A library has
# no say in the host's `:logger` formatter config, so the "key not
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@ defaults are sourced from application env, so you can also set them under
| `server_trace_header` | `nil` | Request header (e.g. `X-Request-Id`) echoed on the response. |
| `log_level` | `:error` | Access-log verbosity. |
| `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. |
| `openapi_version` | `"2.0"` | OpenAPI document version; `"3.0"` emits OpenAPI 3.0.3 (a Bier extension; PostgREST/postgrest#932). |
| `openapi_security_active` | `false` | Advertise JWT security definitions in the OpenAPI document. |

See `Bier.schema/0` for the complete, documented list.
Expand Down
11 changes: 11 additions & 0 deletions lib/bier.ex
Original file line number Diff line number Diff line change
Expand Up @@ -402,6 +402,17 @@ defmodule Bier do
openapi-security-active). Defaults to `false`.
"""
],
openapi_version: [
type: {:in, ["2.0", "3.0"]},
default: env(:openapi_version, "2.0"),
doc: """
Version of the generated root OpenAPI document. `"2.0"` (the default)
is the Swagger 2.0 document PostgREST emits, byte-for-byte; `"3.0"`
serves an OpenAPI 3.0.3 translation of the same content. Bier-only
option — PostgREST has no OpenAPI 3.x emitter (postgrest#932). Ignored
when `db_root_spec` overrides the document.
"""
],
admin_server_port: [
type: {:or, [:pos_integer, nil]},
default: env(:admin_server_port, nil),
Expand Down
2 changes: 2 additions & 0 deletions lib/bier/config.ex
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,7 @@ defmodule Bier.Config do
log_level: :crit | :error | :warn | :info | :debug,
openapi_mode: String.t(),
openapi_security_active: boolean(),
openapi_version: String.t(),
db_root_spec: String.t() | nil,
admin_server_port: pos_integer() | nil,
events_channels: [String.t()],
Expand Down Expand Up @@ -95,6 +96,7 @@ defmodule Bier.Config do
ssl: false,
openapi_mode: "follow-privileges",
openapi_security_active: false,
openapi_version: "2.0",
pool_size: 10,
db_schemas: ["public"],
db_extra_search_path: ["public"],
Expand Down
4 changes: 3 additions & 1 deletion lib/bier/openapi.ex
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ defmodule Bier.OpenAPI do
@moduledoc """
Builds the Swagger 2.0 (OpenAPI 2.0) root document from an introspection
snapshot. Wire-format match to PostgREST v14.12 is the contract; see
spec/openapi.yaml and spec/conformance/cases/16*.yaml.
spec/openapi.yaml and spec/conformance/cases/16*.yaml. An opt-in
OpenAPI 3.0.3 translation of this document is available via the
`openapi_version: "3.0"` config option (`Bier.OpenAPI.V3`).
"""

alias Bier.OpenAPI.Types
Expand Down
178 changes: 178 additions & 0 deletions lib/bier/openapi/v3.ex
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
defmodule Bier.OpenAPI.V3 do
@moduledoc """
Converts the generated Swagger 2.0 root document (`Bier.OpenAPI.build/1`)
into an OpenAPI 3.0.3 document.

Opt-in via the `openapi_version: "3.0"` config option; the default remains
the PostgREST-parity Swagger 2.0 wire format. Converting the finished 2.0
map (rather than emitting 3.0 from the introspection model in parallel)
keeps a single wire-format source of truth: parity fixes to the 2.0
emitter propagate here automatically. PostgREST core has no OpenAPI 3.x
emitter (PostgREST/postgrest#932), so this output has no conformance
surface and is shaped by the OpenAPI 3.0.3 spec alone.

The converter is intentionally NOT general purpose: it handles exactly the
shapes the 2.0 emitter produces (body params only as `body.*` shared
definitions or the inline RPC `args`, `application/json` as the implied
media type, `collectionFormat: "multi"` only on query params).

**Known limitation:** component keys inherit relation and column names
verbatim; names outside OAS 3.0.3's component-key charset (`^[a-zA-Z0-9.\-_]+$`)
produce technically invalid 3.0 component keys, and key sanitization is
deliberately out of scope for this parity-driven converter.
"""

@json "application/json"

# Parameter-object keys that stay at the parameter level in 3.0; everything
# else (type/format/enum/default/maxLength/items/...) nests under "schema".
@param_keys ~w(name in required description)

@doc "Converts a Swagger 2.0 document map into an OpenAPI 3.0.3 one."
@spec convert(map()) :: map()
def convert(doc) do
{bodies, params} = split_shared_params(doc["parameters"] || %{})

components =
%{}
|> put_nonempty("schemas", rewrite_refs(doc["definitions"] || %{}))
|> put_nonempty("parameters", Map.new(params, fn {k, p} -> {k, convert_param(p)} end))
|> put_nonempty("requestBodies", Map.new(bodies, fn {k, p} -> {k, convert_body(p)} end))
|> put_nonempty("securitySchemes", doc["securityDefinitions"])

body_keys = bodies |> Enum.map(&elem(&1, 0)) |> MapSet.new()

# This map enumerates the eight keys the 2.0 emitter can produce:
# swagger, info, externalDocs, basePath, paths, definitions, parameters,
# security/securityDefinitions/schemes/host (via their handlers). A new
# top-level emitter key must be added here or it will be silently dropped.
%{
"openapi" => "3.0.3",
"info" => doc["info"],
"externalDocs" => doc["externalDocs"],
"servers" => servers(doc),
"paths" => convert_paths(doc["paths"] || %{}, body_keys),
"components" => components
}
|> put_nonempty("security", doc["security"])
end

defp put_nonempty(map, _k, v) when v in [nil, %{}], do: map
defp put_nonempty(map, k, v), do: Map.put(map, k, v)

# ---- servers -------------------------------------------------------------

# With a proxy the 2.0 doc carries schemes/host/basePath; fold them into one
# server URL. Without one, the API lives at the document root.
defp servers(%{"host" => host, "schemes" => [scheme | _]} = doc) do
base = if doc["basePath"] in [nil, "/"], do: "", else: doc["basePath"]
[%{"url" => "#{scheme}://#{host}#{base}"}]
end

defp servers(_doc), do: [%{"url" => "/"}]

# ---- shared parameters ---------------------------------------------------

defp split_shared_params(params) do
Enum.split_with(params, fn {_k, p} -> p["in"] == "body" end)
end

defp convert_body(p) do
%{
"required" => p["required"],
"content" => %{@json => %{"schema" => rewrite_refs(p["schema"])}}
}
|> put_nonempty("description", p["description"])
end

defp convert_param(p) do
{kept, schema_keys} = Map.split(p, @param_keys)

schema =
case Map.pop(schema_keys, "collectionFormat") do
{"multi", rest} -> rest
{nil, rest} -> rest
end
|> rewrite_refs()

kept
|> Map.put("schema", schema)
|> then(fn param ->
if schema_keys["collectionFormat"] == "multi",
do: Map.merge(param, %{"style" => "form", "explode" => true}),
else: param
end)
end

# ---- paths ---------------------------------------------------------------

defp convert_paths(paths, body_keys) do
Map.new(paths, fn {path, item} ->
{path, Map.new(item, fn {verb, op} -> {verb, convert_operation(op, body_keys)} end)}
end)
end

defp convert_operation(op, body_keys) do
{body, params} = extract_body(op["parameters"] || [], body_keys)

op
|> Map.put("parameters", Enum.map(params, &convert_op_param/1))
|> Map.update("responses", %{}, &convert_responses/1)
|> put_nonempty("requestBody", body)
|> then(fn converted ->
if converted["parameters"] == [], do: Map.delete(converted, "parameters"), else: converted
end)
end

# One body param at most per operation (the 2.0 emitter guarantees it):
# either a $ref to a shared body.<table> definition or the inline RPC args.
defp extract_body(parameters, body_keys) do
Enum.reduce(parameters, {nil, []}, fn param, {body, rest} ->
case param do
%{"$ref" => "#/parameters/" <> key} ->
if MapSet.member?(body_keys, key) do
{%{"$ref" => "#/components/requestBodies/#{key}"}, rest}
else
{body, rest ++ [param]}
end

%{"in" => "body"} = inline ->
{convert_body(inline), rest}

inline ->
{body, rest ++ [inline]}
end
end)
end

defp convert_op_param(%{"$ref" => "#/parameters/" <> key}),
do: %{"$ref" => "#/components/parameters/#{key}"}

defp convert_op_param(inline), do: convert_param(inline)

defp convert_responses(responses) do
Map.new(responses, fn
{status, %{"schema" => schema} = resp} ->
{status,
resp
|> Map.delete("schema")
|> Map.put("content", %{@json => %{"schema" => rewrite_refs(schema)}})}

{status, resp} ->
{status, resp}
end)
end

# ---- $ref rewriting ------------------------------------------------------

# Walks any JSON-ish term and repoints definition refs at components.
defp rewrite_refs(%{} = map) do
Map.new(map, fn
{"$ref", "#/definitions/" <> name} -> {"$ref", "#/components/schemas/#{name}"}
{k, v} -> {k, rewrite_refs(v)}
end)
end

defp rewrite_refs(list) when is_list(list), do: Enum.map(list, &rewrite_refs/1)
defp rewrite_refs(other), do: other
end
29 changes: 20 additions & 9 deletions lib/bier/plugs/action_controller.ex
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ defmodule Bier.Plugs.ActionController do
alias Bier.MediaType
alias Bier.Mutation
alias Bier.Negotiation
alias Bier.OpenAPI.V3
alias Bier.Pagination
alias Bier.Plan
alias Bier.Plugs.FallbackController
Expand Down Expand Up @@ -190,7 +191,9 @@ defmodule Bier.Plugs.ActionController do
# advertises a relation the instance cannot serve. Only the per-role
# privileges lookup depends on the request, and it is served from
# Bier.PrivilegesCache (keyed by role + snapshot generation); the catalog is
# queried once per role per schema-cache load.
# queried once per role per schema-cache load. config.openapi_version toggles
# the wire format: "2.0" (default) returns this Swagger document as-is; "3.0"
# translates it through Bier.OpenAPI.V3.convert/1 before it is encoded.
defp build_openapi_document(config, role) do
schema = hd(config.db_schemas)
cache = Bier.SchemaCache.get(config.name)
Expand All @@ -205,14 +208,22 @@ defmodule Bier.Plugs.ActionController do
{relations, functions} =
filter_by_mode(config, role, schema, relations, functions, cache.generation)

Bier.OpenAPI.build(%{
relations: relations,
functions: function_inputs(functions),
schema_comment: cache.schema_comment,
security_active?: config.openapi_security_active,
proxy_uri: config.openapi_server_proxy_uri,
docs_version: "v14"
})
doc =
Bier.OpenAPI.build(%{
relations: relations,
functions: function_inputs(functions),
schema_comment: cache.schema_comment,
security_active?: config.openapi_security_active,
proxy_uri: config.openapi_server_proxy_uri,
docs_version: "v14"
})

# openapi_version: "3.0" serves an OpenAPI 3.0.3 translation of the same
# content; "2.0" (default) stays the PostgREST-parity Swagger wire format.
case config.openapi_version do
"3.0" -> V3.convert(doc)
_ -> doc
end
end

defp filter_by_mode(config, role, schema, relations, functions, generation) do
Expand Down
13 changes: 13 additions & 0 deletions test/bier/config_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -192,4 +192,17 @@ defmodule Bier.ConfigTest do
assert config.app_settings == %{"foo" => "bar"}
end
end

describe "openapi_version" do
test "defaults to 2.0 and accepts 3.0" do
assert Bier.Config.new!([], Bier.schema()).openapi_version == "2.0"
assert Bier.Config.new!([openapi_version: "3.0"], Bier.schema()).openapi_version == "3.0"
end

test "rejects unknown versions" do
assert_raise ArgumentError, ~r/openapi_version/, fn ->
Bier.Config.new!([openapi_version: "3.1"], Bier.schema())
end
end
end
end
48 changes: 48 additions & 0 deletions test/bier/openapi_v3_http_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
defmodule Bier.OpenAPIV3HttpTest do
@moduledoc """
Boots a dedicated instance with openapi_version: "3.0" against the
bier_test fixture database and asserts the root serves an OpenAPI 3.0.3
document (#53 item 3 follow-up: wiring Bier.OpenAPI.V3.convert/1 into the
root endpoint). Everything else about the root endpoint (negotiation, HEAD,
openapi-mode, db-root-spec precedence) is unchanged and covered elsewhere.
"""
use ExUnit.Case, async: false

alias Bier.TestPorts

@moduletag :integration

setup_all do
port = TestPorts.free_port()
name = :"openapi_v3_http_#{System.unique_integer([:positive])}"

opts =
Bier.ConformanceServer.base_opts()
|> Keyword.merge(
name: name,
router: [port: port, scheme: :http],
db_schemas: ["test"],
openapi_version: "3.0"
)

{:ok, pid} = Bier.start_link(opts)
on_exit(fn -> if Process.alive?(pid), do: Supervisor.stop(pid) end)
TestPorts.wait_until_listening(port)
%{url: "http://localhost:#{port}/"}
end

test "GET / serves an OpenAPI 3.0.3 document", %{url: url} do
resp = Req.get!(url, headers: [{"accept", "application/json"}], retry: false)

assert resp.status == 200
assert resp.body["openapi"] == "3.0.3"
refute Map.has_key?(resp.body, "swagger")
assert map_size(resp.body["components"]["schemas"]) > 0
assert resp.body["servers"] == [%{"url" => "/"}]
end

test "content negotiation is unchanged: csv at root is still 406", %{url: url} do
resp = Req.get!(url, headers: [{"accept", "text/csv"}], retry: false)
assert resp.status == 406
end
end
Loading
Loading