Skip to content

[Feature] Add RequestBuilder #531

@yordis

Description

@yordis

Context

One of the popular OpenAPI generators creates per codegen a file called RequestBuilder, for example:

# NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
# https://openapi-generator.tech
# Do not edit the class manually.

defmodule OpenAPIPetstore.RequestBuilder do
  @moduledoc """
  Helper functions for building Tesla requests
  """

  @doc """
  Specify the request method when building a request

  ## Parameters

  - request (Map) - Collected request options
  - m (atom) - Request method

  ## Returns

  Map
  """
  @spec method(map(), atom) :: map()
  def method(request, m) do
    Map.put_new(request, :method, m)
  end

  @doc """
  Specify the request method when building a request

  ## Parameters

  - request (Map) - Collected request options
  - u (String) - Request URL

  ## Returns

  Map
  """
  @spec url(map(), String.t) :: map()
  def url(request, u) do
    Map.put_new(request, :url, u)
  end

  @doc """
  Add optional parameters to the request

  ## Parameters

  - request (Map) - Collected request options
  - definitions (Map) - Map of parameter name to parameter location.
  - options (KeywordList) - The provided optional parameters

  ## Returns

  Map
  """
  @spec add_optional_params(map(), %{optional(atom) => atom}, keyword()) :: map()
  def add_optional_params(request, _, []), do: request
  def add_optional_params(request, definitions, [{key, value} | tail]) do
    case definitions do
      %{^key => location} ->
        request
        |> add_param(location, key, value)
        |> add_optional_params(definitions, tail)
      _ ->
        add_optional_params(request, definitions, tail)
    end
  end

  @doc """
  Add optional parameters to the request

  ## Parameters

  - request (Map) - Collected request options
  - location (atom) - Where to put the parameter
  - key (atom) - The name of the parameter
  - value (any) - The value of the parameter

  ## Returns

  Map
  """
  @spec add_param(map(), atom, atom, any()) :: map()
  def add_param(request, :body, :body, value), do: Map.put(request, :body, value)
  def add_param(request, :body, key, value) do
    request
    |> Map.put_new_lazy(:body, &Tesla.Multipart.new/0)
    |> Map.update!(:body, &(Tesla.Multipart.add_field(&1, key, Poison.encode!(value), headers: [{:"Content-Type", "application/json"}])))
  end
  def add_param(request, :headers, key, value) do
    request
    |> Tesla.put_header(key, value)
  end
  def add_param(request, :file, name, path) do
    request
    |> Map.put_new_lazy(:body, &Tesla.Multipart.new/0)
    |> Map.update!(:body, &(Tesla.Multipart.add_file(&1, path, name: name)))
  end
  def add_param(request, :form, name, value) do
    request
    |> Map.update(:body, %{name => value}, &(Map.put(&1, name, value)))
  end
  def add_param(request, location, key, value) do
    Map.update(request, location, [{key, value}], &(&1 ++ [{key, value}]))
  end

  @doc """
  Due to a bug in httpc, POST, PATCH and PUT requests will fail, if the body is empty

  This function will ensure, that the body param is always set

  ## Parameters

  - request (Map) - Collected request options

  ## Returns

  Map
  """
  @spec ensure_body(map()) :: map()
  def ensure_body(%{body: nil} = request) do
    %{request | body: ""}
  end

  def ensure_body(request) do
    Map.put_new(request, :body, "")
  end

  @doc """
  Handle the response for a Tesla request

  ## Parameters

  - arg1 (Tesla.Env.t | term) - The response object
  - arg2 (:false | struct | [struct]) - The shape of the struct to deserialize into

  ## Returns

  {:ok, struct} on success
  {:error, term} on failure
  """
  @spec decode(Tesla.Env.t() | term(), false | struct() | [struct()]) ::
          {:ok, struct()} | {:ok, Tesla.Env.t()} | {:error, any}
  def decode(%Tesla.Env{} = env, false), do: {:ok, env}
  def decode(%Tesla.Env{body: body}, struct), do: Poison.decode(body, as: struct)

  def evaluate_response({:ok, %Tesla.Env{} = env}, mapping) do
    resolve_mapping(env, mapping)
  end

  def evaluate_response({:error, _} = error, _), do: error

  def resolve_mapping(env, mapping, default \\ nil)

  def resolve_mapping(%Tesla.Env{status: status} = env, [{mapping_status, struct} | _], _)
      when status == mapping_status do
    decode(env, struct)
  end

  def resolve_mapping(env, [{:default, struct} | tail], _), do: resolve_mapping(env, tail, struct)
  def resolve_mapping(env, [_ | tail], struct), do: resolve_mapping(env, tail, struct)
  def resolve_mapping(env, [], nil), do: {:error, env}
  def resolve_mapping(env, [], struct), do: decode(env, struct)
end

I also keep copying the same exact code whenever I can relate to it,

defmodule Myapp.Tesla.Request do
  def new do
    []
  end

  def put_method(request, m) do
    Keyword.put(request, :method, m)
  end

  def put_headers(request, h) do
    Keyword.put(request, :headers, h)
  end

  def put_url(request, u) do
    Keyword.put(request, :url, u)
  end

  def put_body(request, value) do
    Keyword.put(request, :body, value)
  end
end

My version is much simpler based on my needs, but it morph based on the use case.

The intention behind these modules is to give a declarative pipelining to put together some request.

Example:

defmodule Myapp.Plaid.Client do
  alias Myapp.Tesla.Request
  alias Myapp.Tesla.Response

  def link_token_create(body) do
    Request.new()
    |> Request.put_method(:post)
    |> Request.put_url("/link/token/create")
    |> Request.put_body(body)
    |> request()
  end

  def item_public_token_exchange(body) do
    Request.new()
    |> Request.put_method(:post)
    |> Request.put_url("/item/public_token/exchange")
    |> Request.put_body(body)
    |> request()
  end

  defp request(request) do
    client()
    |> Tesla.request!(request)
    |> Response.map_resp()
  end
end

Or from the codegen

# NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech).
# https://openapi-generator.tech
# Do not edit the class manually.

defmodule OpenAPIPetstore.Api.Store do
  @moduledoc """
  API calls for all endpoints tagged `Store`.
  """

  alias OpenAPIPetstore.Connection
  import OpenAPIPetstore.RequestBuilder


  @doc """
  Delete purchase order by ID
  For valid response try integer IDs with value < 1000. Anything above 1000 or nonintegers will generate API errors

  ## Parameters

  - connection (OpenAPIPetstore.Connection): Connection to server
  - order_id (String.t): ID of the order that needs to be deleted
  - opts (KeywordList): [optional] Optional parameters
  ## Returns

  {:ok, nil} on success
  {:error, Tesla.Env.t} on failure
  """
  @spec delete_order(Tesla.Env.client, String.t, keyword()) :: {:ok, nil} | {:error, Tesla.Env.t}
  def delete_order(connection, order_id, _opts \\ []) do
    %{}
    |> method(:delete)
    |> url("/store/order/#{order_id}")
    |> Enum.into([])
    |> (&Connection.request(connection, &1)).()
    |> evaluate_response([
      { 400, false},
      { 404, false}
    ])
  end

  @doc """
  Returns pet inventories by status
  Returns a map of status codes to quantities

  ## Parameters

  - connection (OpenAPIPetstore.Connection): Connection to server
  - opts (KeywordList): [optional] Optional parameters
  ## Returns

  {:ok, %{}} on success
  {:error, Tesla.Env.t} on failure
  """
  @spec get_inventory(Tesla.Env.client, keyword()) :: {:ok, map()} | {:error, Tesla.Env.t}
  def get_inventory(connection, _opts \\ []) do
    %{}
    |> method(:get)
    |> url("/store/inventory")
    |> Enum.into([])
    |> (&Connection.request(connection, &1)).()
    |> evaluate_response([
      { 200, %{}}
    ])
  end

  @doc """
  Find purchase order by ID
  For valid response try integer IDs with value <= 5 or > 10. Other values will generated exceptions

  ## Parameters

  - connection (OpenAPIPetstore.Connection): Connection to server
  - order_id (integer()): ID of pet that needs to be fetched
  - opts (KeywordList): [optional] Optional parameters
  ## Returns

  {:ok, OpenAPIPetstore.Model.Order.t} on success
  {:error, Tesla.Env.t} on failure
  """
  @spec get_order_by_id(Tesla.Env.client, integer(), keyword()) :: {:ok, nil} | {:ok, OpenAPIPetstore.Model.Order.t} | {:error, Tesla.Env.t}
  def get_order_by_id(connection, order_id, _opts \\ []) do
    %{}
    |> method(:get)
    |> url("/store/order/#{order_id}")
    |> Enum.into([])
    |> (&Connection.request(connection, &1)).()
    |> evaluate_response([
      { 200, %OpenAPIPetstore.Model.Order{}},
      { 400, false},
      { 404, false}
    ])
  end

  @doc """
  Place an order for a pet

  ## Parameters

  - connection (OpenAPIPetstore.Connection): Connection to server
  - order (Order): order placed for purchasing the pet
  - opts (KeywordList): [optional] Optional parameters
  ## Returns

  {:ok, OpenAPIPetstore.Model.Order.t} on success
  {:error, Tesla.Env.t} on failure
  """
  @spec place_order(Tesla.Env.client, OpenAPIPetstore.Model.Order.t, keyword()) :: {:ok, nil} | {:ok, OpenAPIPetstore.Model.Order.t} | {:error, Tesla.Env.t}
  def place_order(connection, order, _opts \\ []) do
    %{}
    |> method(:post)
    |> url("/store/order")
    |> add_param(:body, :body, order)
    |> Enum.into([])
    |> (&Connection.request(connection, &1)).()
    |> evaluate_response([
      { 200, %OpenAPIPetstore.Model.Order{}},
      { 400, false}
    ])
  end
end

Would be prudent to try to add something similar to Tesla, the intention is to have some pipelining of composing together a request without caring too much about the underline data type of the request.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions