lib/bureaucrat/postman_writer.ex

defmodule Bureaucrat.PostmanWriter do
  @moduledoc """
  Writes records to Postman Collection v2.1 json file that can be imported directly to Postman.
  [JSON Schema](https://schema.postman.com/json/collection/v2.1.0/docs/index.html)

  Writes one record per postman request and each response is a postman example.
  Test descriptions are used as example names, along with the response status.
  Key ended on `_id` on params/query/body have value substituted by environment `{{variables}}`.
  Params/query/body that aren't sent on all requests are disabled.
  Uses filename as collection name and human controller names as folders for actions.
  Supports bearer authentication header.
  """

  alias Bureaucrat.JSON

  def write(records, path) do
    json = build_collection(records, path)
    file = File.open!(path, [:write, :utf8])
    IO.puts(file, JSON.encode!(json, pretty: true))
    File.close(file)
  end

  defp build_collection(records, path) do
    %{
      item:
        records
        |> group_records()
        |> Enum.map(fn {controller, actions} ->
          content_type =
            records
            |> List.first()
            |> Map.get(:req_headers)
            |> Enum.find_value(fn {header, value} -> if header == "content-type", do: value end)

          %{
            name: controller_name(controller),
            item:
              Enum.map(actions, fn {_action, records} ->
                %{
                  name: records |> List.first() |> build_path(),
                  request: %{
                    auth: build_auth(records),
                    body: build_req_body(records, content_type),
                    header: records |> Enum.map(& &1.req_headers) |> build_key_value(),
                    method: records |> List.first() |> Map.get(:method),
                    url: build_url(records)
                  },
                  response:
                    Enum.map(records, fn record ->
                      %{
                        body: prettify_json(record.resp_body),
                        code: record.status,
                        header: build_key_value([record.resp_headers]),
                        name: build_description(record),
                        originalRequest: %{
                          auth: build_auth([record]),
                          body: build_req_body([record], content_type),
                          header: build_key_value([record.req_headers]),
                          method: record.method,
                          url: build_url([record])
                        },
                        status: Plug.Conn.Status.reason_phrase(record.status),
                        _postman_previewlanguage: "json"
                      }
                    end)
                }
              end)
          }
        end),
      info: %{
        name: path |> Path.basename(".json") |> Macro.camelize(),
        schema: "https://schema.getpostman.com/json/collection/v2.1.0/collection.json"
      }
    }
  end

  defp group_records(records) do
    records
    |> Bureaucrat.Util.stable_group_by(&get_controller/1)
    |> Enum.map(fn {controller, records} ->
      {controller, Bureaucrat.Util.stable_group_by(records, &get_action/1)}
    end)
  end

  defp get_action(record), do: record.private.phoenix_action
  defp get_controller(record), do: record.private.phoenix_controller

  defp controller_name(controller) do
    prefix = Application.get_env(:bureaucrat, :prefix)

    ~r/#{prefix}(.+)/
    |> Regex.run(Atom.to_string(controller), capture: :all_but_first)
    |> List.first()
    |> String.trim_trailing("Controller")
    |> String.replace(".", " ")
  end

  defp prettify_json(nil), do: nil
  defp prettify_json(""), do: ""
  defp prettify_json(json), do: json |> JSON.decode!() |> JSON.encode!(pretty: true)

  defp build_url([record | _] = records) do
    path = build_path(record)
    host = "{{host}}"

    %{
      raw: host <> "/" <> path,
      host: [host],
      path: String.split(path, "/"),
      query: records |> Enum.map(&URI.decode_query(&1.query_string)) |> build_key_value(),
      variable: records |> Enum.map(& &1.path_params) |> build_key_value()
    }
  end

  defp build_path(record) do
    path_keys = Map.new(record.path_params, fn {k, v} -> {v, k} end)
    ["" | paths] = String.split(record.request_path, "/")

    Enum.map_join(paths, "/", fn subpath ->
      key = path_keys[subpath]
      if key, do: ":" <> key, else: subpath
    end)
  end

  # Postman only supports one example record. Take the first one and disregard the rest
  defp build_req_body([first_record | _rest], "application/json") do
    %{
      mode: "raw",
      raw: first_record.body_params |> strip__json_key() |> JSON.encode!(),
      options: %{
        raw: %{
          language: "json"
        }
      }
    }
  end

  defp build_req_body(records, _) do
    %{
      mode: "formdata",
      formdata: records |> Enum.map(& &1.body_params) |> build_key_value()
    }
  end

  defp strip__json_key(%{"_json" => params}), do: params |> strip__json_key()
  defp strip__json_key(params), do: params

  defp build_auth(records) do
    has_bearer =
      Enum.all?(records, fn record ->
        record
        |> Plug.Conn.get_req_header("authorization")
        |> Enum.any?()
      end)

    if has_bearer do
      %{type: "bearer", bearer: [%{key: "token", value: "{{token}}"}]}
    else
      %{type: "noauth"}
    end
  end

  defp build_key_value([%{"_json" => list}]), do: list |> build_key_value()

  defp build_key_value([first | _] = lists) when is_list(first) do
    lists |> Enum.map(&Map.new/1) |> build_key_value()
  end

  defp build_key_value([]), do: []

  defp build_key_value([first | _] = maps) when is_map(first) do
    all_keys = maps |> Enum.flat_map(fn map -> Map.keys(map) end) |> Enum.uniq()
    disabled_keys = maps |> Enum.flat_map(fn map -> all_keys -- Map.keys(map) end) |> MapSet.new()

    maps
    |> Enum.flat_map(fn map -> Map.to_list(map) end)
    |> Enum.uniq_by(fn {k, _v} -> k end)
    |> Enum.map(fn {k, v} ->
      if is_binary(k) and k =~ "_id" do
        {k, "{{#{k}}}"}
      else
        {k, v}
      end
    end)
    |> Enum.map(fn {k, v} -> %{key: k, value: v, disabled: k in disabled_keys} end)
  end

  defp build_description(record) do
    action = record |> get_action() |> Atom.to_string()
    description = record.assigns.bureaucrat_opts[:description]

    description =
      if String.starts_with?(description, action) and String.contains?(description, " ") do
        [_ | description] = String.split(description, " ")
        Enum.join(description, " ")
      else
        description
      end

    "#{Integer.to_string(record.status)}: " <> description
  end
end