diff --git a/.credo.exs b/.credo.exs index 83ce9bb..05d9daa 100644 --- a/.credo.exs +++ b/.credo.exs @@ -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 diff --git a/README.md b/README.md index 8b0060b..47ea136 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/lib/bier.ex b/lib/bier.ex index 5a7d7d4..98ab013 100644 --- a/lib/bier.ex +++ b/lib/bier.ex @@ -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), diff --git a/lib/bier/config.ex b/lib/bier/config.ex index f227b6e..a918e78 100644 --- a/lib/bier/config.ex +++ b/lib/bier/config.ex @@ -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()], @@ -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"], diff --git a/lib/bier/openapi.ex b/lib/bier/openapi.ex index 76103a7..a246f63 100644 --- a/lib/bier/openapi.ex +++ b/lib/bier/openapi.ex @@ -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 diff --git a/lib/bier/openapi/v3.ex b/lib/bier/openapi/v3.ex new file mode 100644 index 0000000..6dec598 --- /dev/null +++ b/lib/bier/openapi/v3.ex @@ -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. 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 diff --git a/lib/bier/plugs/action_controller.ex b/lib/bier/plugs/action_controller.ex index d5c42ec..d17a84e 100644 --- a/lib/bier/plugs/action_controller.ex +++ b/lib/bier/plugs/action_controller.ex @@ -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 @@ -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) @@ -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 diff --git a/test/bier/config_test.exs b/test/bier/config_test.exs index 7eac7ba..f6dafa3 100644 --- a/test/bier/config_test.exs +++ b/test/bier/config_test.exs @@ -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 diff --git a/test/bier/openapi_v3_http_test.exs b/test/bier/openapi_v3_http_test.exs new file mode 100644 index 0000000..9ee35b4 --- /dev/null +++ b/test/bier/openapi_v3_http_test.exs @@ -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 diff --git a/test/bier/openapi_v3_test.exs b/test/bier/openapi_v3_test.exs new file mode 100644 index 0000000..ca810d6 --- /dev/null +++ b/test/bier/openapi_v3_test.exs @@ -0,0 +1,213 @@ +defmodule Bier.OpenAPIV3Test do + use ExUnit.Case, async: true + + alias Bier.OpenAPI.V3 + + # Mirrors col_map/2 in test/bier/openapi_test.exs, which is the authority on + # the %Bier.Introspection.Relation{}.columns shape (see the `column()` type + # in lib/bier/introspection.ex): pk?/composite?/data_rep are unused by + # Bier.OpenAPI.build/1 but included here to match the real shape exactly. + defp col(name, type, opts \\ []) do + %{ + name: name, + type: type, + pk?: Keyword.get(opts, :pk?, false), + notnull?: Keyword.get(opts, :notnull?, false), + max_length: Keyword.get(opts, :max_length), + enum_labels: Keyword.get(opts, :enum_labels), + default: Keyword.get(opts, :default), + composite?: false, + data_rep: nil, + comment: Keyword.get(opts, :comment) + } + end + + defp relation do + %Bier.Introspection.Relation{ + schema: "test", + name: "items", + kind: :table, + primary_key: ["id"], + foreign_keys: [], + comment: "items comment", + columns: [col("id", "integer", notnull?: true), col("label", "text")] + } + end + + defp fun do + %{ + name: "add", + comment: "Adds\nTwo numbers", + volatility: :immutable, + in_params: [ + %{name: "a", type: "integer", variadic?: false, has_default?: false}, + %{name: "b", type: "integer", variadic?: false, has_default?: true} + ] + } + end + + defp build(opts \\ []) do + Bier.OpenAPI.build(%{ + relations: [relation()], + functions: [fun()], + schema_comment: nil, + security_active?: Keyword.get(opts, :security_active?, false), + proxy_uri: Keyword.get(opts, :proxy_uri), + docs_version: "v14" + }) + end + + test "top level: openapi version, no swagger/definitions/parameters keys" do + doc = V3.convert(build()) + + assert doc["openapi"] == "3.0.3" + refute Map.has_key?(doc, "swagger") + refute Map.has_key?(doc, "definitions") + refute Map.has_key?(doc, "parameters") + refute Map.has_key?(doc, "basePath") + assert doc["info"]["title"] == "PostgREST API" + assert doc["externalDocs"]["description"] == "PostgREST Documentation" + end + + test "servers: root url without proxy, full url with proxy" do + assert V3.convert(build())["servers"] == [%{"url" => "/"}] + + doc = V3.convert(build(proxy_uri: "https://api.example.com:8443/v1")) + assert doc["servers"] == [%{"url" => "https://api.example.com:8443/v1"}] + refute Map.has_key?(doc, "host") + refute Map.has_key?(doc, "schemes") + end + + test "definitions move to components.schemas with rewritten refs" do + doc = V3.convert(build()) + + schema = doc["components"]["schemas"]["items"] + assert schema["type"] == "object" + assert schema["required"] == ["id"] + assert schema["properties"]["id"]["format"] == "int32" + + get = doc["paths"]["/items"]["get"] + + assert get["responses"]["200"]["content"]["application/json"]["schema"] == %{ + "type" => "array", + "items" => %{"$ref" => "#/components/schemas/items"} + } + + # responses without a schema stay plain + assert get["responses"]["206"] == %{"description" => "Partial Content"} + end + + test "shared non-body params move to components.parameters with schema nesting" do + doc = V3.convert(build()) + params = doc["components"]["parameters"] + + assert params["select"] == %{ + "name" => "select", + "in" => "query", + "required" => false, + "description" => "Filtering Columns", + "schema" => %{"type" => "string"} + } + + # header param with a default: default nests under schema + assert params["rangeUnit"]["schema"] == %{"type" => "string", "default" => "items"} + + # Prefer enum nests under schema (depends on #53 items 2-3 for the + # resolution values; before that merge the enum list is 3 entries) + assert params["preferPost"]["schema"]["enum"] |> hd() == "return=representation" + + # operation $refs are rewritten + get = doc["paths"]["/items"]["get"] + assert %{"$ref" => "#/components/parameters/select"} in get["parameters"] + refute Enum.any?(get["parameters"], &match?(%{"$ref" => "#/parameters/" <> _}, &1)) + end + + test "table body params become requestBodies; operations reference them" do + doc = V3.convert(build()) + + assert doc["components"]["requestBodies"]["body.items"] == %{ + "description" => "items", + "required" => false, + "content" => %{ + "application/json" => %{ + "schema" => %{"$ref" => "#/components/schemas/items"} + } + } + } + + post = doc["paths"]["/items"]["post"] + assert post["requestBody"] == %{"$ref" => "#/components/requestBodies/body.items"} + refute Enum.any?(post["parameters"], &match?(%{"in" => "body"}, &1)) + + refute Enum.any?( + post["parameters"], + &match?(%{"$ref" => "#/components/requestBodies/" <> _}, &1) + ) + end + + test "rpc: inline args body becomes an inline requestBody; GET params nest schemas" do + doc = V3.convert(build()) + post = doc["paths"]["/rpc/add"]["post"] + + # (depends on #53 items 2-3: required true + preferParams ref; before that + # merge, required is false and parameters is empty after body extraction) + body = post["requestBody"] + assert body["required"] == true + schema = body["content"]["application/json"]["schema"] + assert schema["type"] == "object" + assert schema["properties"]["a"] == %{"type" => "integer", "format" => "int32"} + assert schema["required"] == ["a"] + + assert post["parameters"] == [%{"$ref" => "#/components/parameters/preferParams"}] + + get = doc["paths"]["/rpc/add"]["get"] + + assert %{ + "name" => "a", + "in" => "query", + "required" => true, + "schema" => %{"type" => "integer", "format" => "int32"} + } in get["parameters"] + end + + test "variadic collectionFormat multi becomes style form + explode" do + doc = + Bier.OpenAPI.build(%{ + relations: [], + functions: [ + %{ + name: "vparam", + comment: nil, + volatility: :immutable, + in_params: [%{name: "v", type: "text[]", variadic?: true, has_default?: false}] + } + ], + schema_comment: nil, + security_active?: false, + proxy_uri: nil, + docs_version: "v14" + }) + |> V3.convert() + + [param] = doc["paths"]["/rpc/vparam"]["get"]["parameters"] + + assert param["style"] == "form" + assert param["explode"] == true + refute Map.has_key?(param, "collectionFormat") + + assert param["schema"] == %{ + "type" => "array", + "items" => %{"type" => "string", "format" => "text"} + } + end + + test "securityDefinitions move to components.securitySchemes; security stays" do + doc = V3.convert(build(security_active?: true)) + + assert doc["security"] == [%{"JWT" => []}] + refute Map.has_key?(doc, "securityDefinitions") + + assert doc["components"]["securitySchemes"]["JWT"]["type"] == "apiKey" + assert doc["components"]["securitySchemes"]["JWT"]["in"] == "header" + end +end