Divergence from v0.8.2 - PreVeil/maru_swagger GitHub Wiki
This branches from v0.8.2 of the original, with changes in the custom-types branch (diffs since v0.8.2). The starting point was chosen for compatibility with projects using:
elixir 1.11
maru v0.11
-
cowboy
/plug_cowboy
1.0
This adds:
- Automatic conversion of
atom
->enum
types, with allowed values populated (usingparameter.runtime
data as the source) - In place parameter manipulation, via consumer-supplied
type_transform
andparam_opt_keys
options. - Ability to insert an unfiltered, consumer-supplied
info
section into the final result.
Several new functions were introduced to support this added functionality.
To support parameter manipulations for the first two additions, a new function was added. This function is modify_parameter_types/2
, and it is called within the existing MaruSwagger.Plug.generate
function.
Effectively, the signature is
modify_parameter_types(route, fun(%{}) :: %{}) :: [%{}]
-
route
∈config.module.__routes__
-
%{}
≈Maru.Struct.Parameter.Information
(additional keys may be included) -
fun/1
may be provided as nil (defaults to identity function)
After walking the tree of route parameters information, this invokes a composite function on each node. The composite function casts atoms to enums, then performs any consumer supplied transformations.
defp modify_parameters_types(route, user_type_transform_fn) do
user_type_transform_fn = user_type_transform_fn || &(&1)
route.parameters
|> Enum.map(fn parameter ->
rt = parameter.runtime
atom_values = runtime_to_atom_values(rt)
cast_atoms_to_enums = make_cast_atoms_to_enums(atom_values)
walk_fn = fn info ->
info
|> cast_atoms_to_enums.()
|> user_type_transform_fn.()
end
postwalk_params(parameter.information, walk_fn)
end)
end
Parameters can be nested, so we define a simple recursive function for walking those.
# Navigate parameter information (and potential children):
defp postwalk_params(%{children: [_ | _]=children}=info, f) do
info = f.(info)
children = Enum.map(children, &postwalk_params(&1, f))
Map.merge(info, %{children: children})
# With this merge order, children may not be modified at the parent level.
# (Any modifications that are done to them will be overwritten.)
end
defp postwalk_params(info, f), do: f.(info)
Notice that make_cast_atoms_to_enums
requires data from the parameter.runtime
, which is an Elixir Abstract Syntax Tree (AST). Within that AST for parameters that are of atom
type exists a corresponding validate_func
, and within that, the corresponding values for the atom parameter can be found:
example `validate_func` within a `parameter.runtime`
# Example: suppose you have an atom-type parameter :platform.
# The corresponding `validate_func` could look like this:
[
validate_func: {:fn, [],
[
{:->, [],
[
[{:value, [], Maru.Builder.Params}],
{:__block__, [],
[
{{:., [],
[Maru.Validations.Values,
:validate_param!]}, [],
[
:platform,
{:value, [], Maru.Builder.Params},
[:macos, :windows, :linux, :ios,
:android, :express]
]}
]}
]}
]}
]
To extract those allowed values, a new runtime_to_atom_values/1
was added:
defp runtime_to_atom_values(ast) do
# Within the runtime AST, there are a couple tuples relatively unique
# to the body of a :validate_func that we can match on:
maru_validate_tuple = {:., [], [Maru.Validations.Values, :validate_param!]}
maru_param_metadata = {:value, [], Maru.Builder.Params}
# As we walk over the AST:
# - always return the original form unchanged
# - if we match on a param & allowed values pair, grab and merge into acc
walk_capturing_param_kw_and_vals = fn
({^maru_validate_tuple, [],
[param_kw,
^maru_param_metadata,
[_ | _]=param_vs] # ensure list, not tuple (want atom values, not integer)
}=form, acc) -> {form, Map.merge(acc, %{param_kw => param_vs})}
(form, acc) -> {form, acc}
end
{_, acc} = Macro.postwalk(ast, %{}, walk_capturing_param_kw_and_vals)
acc
end
Which, at the end of the day, returns a map of %{parameter_kw => parameter_vs}
, an example being:
%{platform: [:macos, :windows, :linux, :ios, :android, :express]}
Once we have that map, casting from atom
to enum
types is relatively simple:
defp make_cast_atoms_to_enums(atom_keys_to_allowed_values) do
fn %{type: "atom", children: [], attr_name: attr}=info -> (
vs = atom_keys_to_allowed_values[attr]
if vs do # this atom param matches, cast it to enum:
Map.merge(info, %{type: "string", enum: vs})
else # or it doesn't, leave it unchanged:
info
end)
info -> info
end
end
Notice that this function actually introduces a new key, :enum
, into the parameter.information
structure. In order to keep this from being lost in the JSON conversion at the end, inject_opt_keys/3
is defined, which we'll cover next.
Within lib/maru_swagger/params_extractor.ex
, the routes we walked earlier in modify_parameter_types/2
are prepared for the final JSON conversion. Any added keys need to be accounted for, or they will be lost. To that end, we now have inject_opt_keys/3
:
def inject_opt_keys(map, param, config) do
opts = [:enum | (Map.get(config, :param_opt_keys) || [])]
opts_map = Map.take(param, opts)
Map.merge(map, opts_map)
end
The automatic atom
to enum
conversion discussed earlier introduces :enum
keys, so that is also included here. The provided user_type_transform_function
may introduce further keys; to account for that, we check the :param_opt_keys
within our config
structure.
This struct includes three new keys:
defmodule MaruSwagger.ConfigStruct do
defstruct [
:path, # [string] where to mount the Swagger JSON
:module, # [atom] Maru API module
:force_json, # [boolean] force JSON for all params instead of formData
:pretty, # [boolean] should JSON output be prettified?
:swagger_inject, # [keyword list] key-values to inject directly into root of Swagger JSON
:info, # [map] added beneath "info" key of produced JSON
# For custom parameter types, additional conversions may be needed for OAS compliance:
:type_transform, # %{} :: %{}, %{} ≈ Maru.Struct.Parameter.Information
:param_opt_keys, # [keywords] additional keywords that type_transform may add to params
]
# ...
end
The optional :type_transform
and :param_opt_keys
ones were discussed earlier, and allow for further manipulation of parameter types. The :info
one is even more straightforward: if the user supplies a value for this key, then it is injected straight into the final response (no additional filtering):
# lib/maru_swagger/response_formatter.ex
defp wrap_in_swagger_info(paths, tags, config=%ConfigStruct{}) do
res = %{
swagger: "2.0",
info:
case config.info do
%{} -> config.info # No need to format; use unchanged.
_ -> format_default(config)
end,
paths: paths,
tags: tags,
}
for {k,v} <- (config.swagger_inject || []), into: res, do: {k,v}
end
defp format_default(config) do
%{title: "Swagger API for #{elixir_module_name(config.module)}"}
end
Later versions of the original MaruSwagger included similar behavior for info
, but only allow specific keys within this. Notably, version
is not one of them.