defmodule Apical.Path do @moduledoc false # module which handles the `pathsObject` and `pathItemObject` from the # OpenAPI spec. # # note: only the `pathsObject` may be substituted with a `$ref` pointer. # # see: https://spec.openapis.org/oas/v3.1.0#paths-object alias Apical.Plugs.Parameter alias Apical.Plugs.RequestBody alias Apical.Tools @routes {[], MapSet.new()} # to make passing into the adapter easier we're going to make the parameters # a struct. @enforce_keys ~w(parameter_validators body_validators extra_plugs version operation_id parameter_plugs body_plugs root verb canonical_path controller function)a defstruct @enforce_keys def to_plug_routes(_pointer, path, %{"$ref" => ref}, schema, opts) do # for now, don't handle the remote ref scenario, or the id scenario. new_pointer = JsonPtr.from_uri(ref) subschema = JsonPtr.resolve_json!(schema, new_pointer) to_plug_routes(new_pointer, path, subschema, schema, opts) end def to_plug_routes(pointer, path, _subschema, schema, opts) do # each route contains a map of verbs to operations. # map over that content to generate routes. JsonPtr.reduce(pointer, schema, @routes, &to_plug_route(&1, &2, &3, &4, schema, path, opts)) end @verb_mapping Map.new(~w(get put post delete options head patch trace)a, &{"#{&1}", &1}) @verbs Map.keys(@verb_mapping) defp to_plug_route( pointer, verb, operation, {routes_so_far, operation_ids_so_far}, schema, path, opts ) when verb in @verbs do Tools.assert( Map.has_key?(operation, "operationId"), "that all operations have an operationId: (missing for operation at `#{JsonPtr.to_path(pointer)}`)" ) operation_id = Map.fetch!(operation, "operationId") {canonical_path, path_parameters} = case parse_path(path, context: %{path_parameters: []}) do {:ok, canonical, "", context, _, _} -> {"#{canonical}", context.path_parameters} _ -> raise CompileError, description: "path #{path} is not a valid path template" end verb = Map.fetch!(@verb_mapping, verb) tags = Map.get(operation, "tags", []) opts = opts |> fold_tag_opts(tags) |> fold_group_opts(operation_id) |> fold_operation_id_opts(operation_id) version = Keyword.fetch!(opts, :version) root = Keyword.fetch!(opts, :root) function = Keyword.get(opts, :alias, String.to_atom(operation_id)) plug_opts = opts |> Keyword.take( ~w(styles parameters nest_all_json content_sources version resource dump dump_validator)a ) |> Keyword.merge(path_parameters: path_parameters, path: path) controller = case Keyword.fetch(opts, :controller) do {:ok, controller} when is_atom(controller) -> controller {:ok, controller} -> raise CompileError, description: "invalid controller for operation #{operation_id}, got #{inspect(controller)} (expected a module atom)" :error -> raise CompileError, description: "can't find controller for operation #{operation_id}" end extra_plugs = opts |> Keyword.get(:extra_plugs, []) |> Enum.map(fn {plug, plug_opts} -> quote do plug(unquote(plug), unquote(plug_opts)) end plug -> quote do plug(unquote(plug)) end end) {parameter_plugs, parameter_validators} = Parameter.make(pointer, schema, operation_id, plug_opts) {body_plugs, body_validators} = RequestBody.make(pointer, schema, operation_id, plug_opts) framework = opts |> Keyword.get(:for, Phoenix) |> Macro.expand(%Macro.Env{}) adapter = Module.concat(Apical.Adapters, framework) route = __MODULE__ |> struct(Keyword.take(binding(), @enforce_keys)) |> adapter.build_path() {[route | routes_so_far], MapSet.put(operation_ids_so_far, operation_id)} end @folded_opts ~w(controller styles parameters extra_plugs nest_all_json content_sources dump dump_validator)a defp fold_tag_opts(opts, tags) do # NB it's totallgroup_opts(operation_id)y okay if this process is unoptimized since it # should be running at compile time. tags |> Enum.reverse() |> Enum.reduce(opts, &merge_opts(&2, &1, :tags)) end defp fold_group_opts(opts, operation_id) do group_opts = opts |> Keyword.get(:groups, []) |> Enum.find_value(fn group_spec -> {ids, opts} = Enum.split_while(group_spec, &is_atom/1) if Enum.any?(ids, &(Atom.to_string(&1) == operation_id)) do opts end end) |> List.wrap() Tools.deepmerge(opts, group_opts) end defp fold_operation_id_opts(opts, operation_id), do: merge_opts(opts, operation_id, :operation_ids) defp merge_opts(opts, key, class) do opts_to_fold = case class do :operation_ids -> [:alias | @folded_opts] _ -> @folded_opts end merge_opts = opts |> Keyword.get(class, []) |> Enum.find_value(&if Atom.to_string(elem(&1, 0)) == key, do: elem(&1, 1)) |> List.wrap() |> Keyword.take(opts_to_fold) Tools.deepmerge(opts, merge_opts) end require Pegasus import NimbleParsec Pegasus.parser_from_string( """ # see https://datatracker.ietf.org/doc/html/rfc6570#section-2.1 ALPHA <- [A-Za-z] DIGIT <- [0-9] HEXDIG <- DIGIT / [A-Fa-f] pct_encoded <- '%' HEXDIG HEXDIG # see https://datatracker.ietf.org/doc/html/rfc6570#section-2.1 literal <- (ascii_literals / ucschar / pct_encoded)+ # expressions must be identifiers identifiers <- [A-Za-z_] [A-Za-z0-9_]* LBRACKET <- "{" RBRACKET <- "}" expression <- LBRACKET identifiers+ RBRACKET parse_path <- (expression / literal)+ eof eof <- !. """, LBRACKET: [ignore: true], RBRACKET: [ignore: true], expression: [post_traverse: :to_colon_form], parse_path: [parser: true] ) defcombinatorp(:unreserved_extra, ascii_char(~C'-._~')) defcombinatorp( :ucschar, utf8_char([ 0xA0..0xD7FF, 0xF900..0xFDCF, 0xFDF0..0xFFEF, 0x10000..0x1FFFD, 0x20000..0x2FFFD, 0x30000..0x3FFFD, 0x40000..0x4FFFD, 0x50000..0x5FFFD, 0x60000..0x6FFFD, 0x70000..0x7FFFD, 0x80000..0x8FFFD, 0x90000..0x9FFFD, 0xA0000..0xAFFFD, 0xB0000..0xBFFFD, 0xC0000..0xCFFFD, 0xD0000..0xDFFFD, 0xE1000..0xEFFFD ]) ) defcombinatorp( :ascii_literals, ascii_char([ 0x21, 0x23, 0x24, 0x26, 0x28..0x3B, 0x3D, 0x3F..0x5B, 0x5D, 0x5F, 0x61..0x7A, 0x7E ]) ) defp to_colon_form(rest, var, context, _line, _offset) do parameter = var |> Enum.reverse() |> IO.iodata_to_binary() {rest, var ++ ~C':', Map.update!(context, :path_parameters, &[parameter | &1])} end end