Divergence from v0.8.2 - PreVeil/maru_swagger GitHub Wiki

Overview

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 (using parameter.runtime data as the source)
  • In place parameter manipulation, via consumer-supplied type_transform and param_opt_keys options.
  • Ability to insert an unfiltered, consumer-supplied info section into the final result.

Implementation details

Several new functions were introduced to support this added functionality.

In place parameter manipulation via modify_parameter_types/2

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(%{}) :: %{}) :: [%{}]
  • routeconfig.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)

Atom to enum conversion via make_cast_atoms_to_enums

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.

Adding keys to parameter.information via inject_opt_keys/3

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.

Keys added to MaruSwagger.ConfigStruct

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.

⚠️ **GitHub.com Fallback** ⚠️