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