|
| 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 |
0 commit comments