Current section
Files
Jump to
Current section
Files
lib/kazan/codegen/apis.ex
defmodule Kazan.Codegen.Apis do
@moduledoc false
# Macros for generating API clients from OAI specs.
import Kazan.Swagger, only: [swagger_to_op_map: 1]
alias Kazan.Codegen.Apis.{Operation, Parameter}
require EEx
@doc """
Generates API client modules for all the operations defined in an OAPI spec.
This reads the provided file at compile time and uses it to generate functions
and their corresponding models.
The modules will be defined by the tags for each of the operations, and the
functions will be named using a camel case version of the operationId.
Currently the operationId has some tag-related data embedded in it, which we
remove for the sake of brevity.
"""
defmacro from_spec(spec_file) do
operations =
File.read!(spec_file)
|> Poison.decode!
|> swagger_to_op_map
|> Map.values
|> Enum.flat_map(&duplicate_on_tags/1)
# Create the atoms for all the names of our operations.
:ok =
operations
|> Enum.map(fn (op_desc) -> op_desc["tag"] end)
|> Enum.uniq
|> Enum.each(&api_name &1, unsafe: true)
operations = Enum.map(operations, &Operation.from_oai_desc/1)
api_groups = Enum.group_by(operations, fn (op) -> op.api_name end)
module_forms = for {module_name, functions} <- api_groups do
function_forms = Enum.map(functions, &function_form/1)
module_doc = module_doc(module_name)
quote do
defmodule unquote(module_name) do
@moduledoc unquote(module_doc)
unquote_splicing(function_forms)
end
end
end
quote do
@external_resource unquote(spec_file)
unquote_splicing(module_forms)
defp operation_descs do
unquote(Macro.escape(operations))
end
end
end
@doc """
Builds an api_name from a tag on an OAI operation.
"""
@spec api_name(String.t, Keyword.t) :: atom
def api_name(operation_tag, opts \\ []) do
api_name = Macro.camelize(operation_tag)
if Keyword.get(opts, :unsafe, false) do
Module.concat(Kazan.Apis, api_name)
else
try do
Module.safe_concat(Kazan.Apis, api_name)
rescue ArgumentError ->
nil
end
end
end
@doc """
Builds a function name from the operationId of an OAI operation.
We take the operation tag in here too, because the Kube OAI operations use IDs
like listCoreV1ConfigMapForAllNamespaces where the operation is on the core_v1
API. We don't want to have such large function names, so we try to strip the
API name out.
"""
@spec function_name(String.t, String.t | atom) :: atom
def function_name(operation_id, tag, opts \\ []) when is_binary(tag) do
string_name =
operation_id
|> String.replace(Macro.camelize(tag), "")
|> Macro.underscore
if Keyword.get(opts, :unsafe, false) do
String.to_atom(string_name)
else
try do
String.to_existing_atom(string_name)
rescue ArgumentError ->
nil
end
end
end
# Swagger tags are a list. There _appears_ to only be one tag per operation,
# but there could be more. We handle that by duplicating on tags.
# Once this function is finished, we will have a bunch of operations with a
# single tag.
@spec duplicate_on_tags(Map.t) :: [Map.t]
defp duplicate_on_tags(operation) do
for tag <- operation["tags"] do
operation |> Map.put("tag", tag) |> Map.delete("tags")
end
end
# Builds the quoted function form for an operation function.
@spec function_form(Operation.t) :: term
defp function_form(operation) do
param_groups = Enum.group_by(
operation.parameters,
fn (param) -> param.type end
)
is_required = fn (param) -> param.required end
query_params = Map.get(param_groups, :query, [])
path_params =
param_groups |> Map.get(:path, []) |> sort_path_params(operation.path)
# The main arguments our function will take:
argument_params =
Map.get(param_groups, :body, [])
++ path_params
++ Enum.filter(query_params, is_required)
optional_params = Enum.reject(query_params, is_required)
arguments = argument_forms(argument_params, optional_params)
docs = function_docs(
operation.operation_id, operation.description, argument_params, optional_params, operation.response_schema
)
param_unpacking = if Enum.empty?(argument_params) do
quote do
%{}
end
else
argument_map_pairs = for arg <- argument_params do
{arg.var_name, Macro.var(arg.var_name, __MODULE__)}
end
quote location: :keep do
%{unquote_splicing(argument_map_pairs)}
end
end
option_merging = cond do
Enum.empty?(optional_params) ->
quote do
end
Enum.empty?(argument_params) ->
quote location: :keep do
Enum.into(options, %{})
end
:otherwise ->
quote location: :keep do
Map.merge(Enum.into(options, %{}), params)
end
end
transform_map = for parameter <- operation.parameters, into: %{} do
{parameter.var_name, parameter.field_name}
end
bang_function_name = String.to_atom(
Atom.to_string(operation.function_name) <> "!"
)
argument_forms_in_call = argument_call_forms(
argument_params, optional_params
)
quote location: :keep do
@doc unquote(docs)
def unquote(operation.function_name)(unquote_splicing(arguments)) do
params = unquote(param_unpacking)
params = unquote(option_merging)
{:ok, req} = Kazan.Request.create(
unquote(operation.operation_id),
Kazan.Codegen.Apis.transform_request_parameters(
unquote(Macro.escape(transform_map)),
params
)
)
end
@doc unquote(docs)
def unquote(bang_function_name)(unquote_splicing(arguments)) do
rv = unquote(operation.function_name)(
unquote_splicing(argument_forms_in_call)
)
case rv do
{:ok, result} -> result
{:err, reason} ->
raise Kazan.BuildRequestError,
reason: reason, operation: unquote(operation.function_name)
end
end
end
end
# Transforms a map of function arguments into a map of request parameters.
def transform_request_parameters(parameter_descs, parameters) do
for {k, v} <- parameters, into: %{} do
{parameter_descs[k], v}
end
end
# List of argument forms to go in function argument lists.
@spec argument_forms([Map.t], [Map.t]) :: [term]
defp argument_forms(argument_params, []) do
for param <- argument_params do
Macro.var(param.var_name, __MODULE__)
end
end
defp argument_forms(argument_params, _optional_params) do
argument_forms(argument_params, [])
++ [{:\\, [], [Macro.var(:options, __MODULE__), []]}]
end
# List of arugment forms to go in call to function from bang function.
@spec argument_call_forms([Map.t], [Map.t]) :: [term]
defp argument_call_forms(argument_params, []) do
for param <- argument_params do
Macro.var(param.var_name, __MODULE__)
end
end
defp argument_call_forms(argument_params, _optional_params) do
argument_forms(argument_params, [])
++ [Macro.var(:options, __MODULE__)]
end
# The Kube API specs provide path parameters in an unintuitive order,
# so we sort them by the order they appear in the request path here.
@spec sort_path_params([Parameter.t], String.t) :: [Parameter.t]
defp sort_path_params(parameters, path) do
Enum.sort(parameters, fn (param1, param2) ->
loc1 = str_index("{#{param1.field_name}}", path)
loc2 = str_index("{#{param2.field_name}}", path)
loc1 <= loc2
end)
end
# I can't believe I'm having to implement this myself :/
defp str_index(needle, haystack) do
case String.split(haystack, needle, parts: 2) do
[left, _] -> String.length(left)
[_] -> nil
end
end
EEx.function_from_string(:defp, :function_docs, """
<%= if description do description end %>
OpenAPI Operation ID: `<%= operation_id %>`
<%= unless Enum.empty?(parameters) do %>
### Parameters
<%= for param <- parameters do %>
* `<%= param.var_name %>` - <%= param.description %><%= if param.schema do %>See `<%= doc_ref(param.schema) %>`. <% end %> <% end %>
<% end %>
<%= unless Enum.empty?(options) do %>
### Options
<%= for option <- options do %>
* `<%= option.var_name %>` - <%= option.description %>
<% end %>
<% end %>
<%= if response_schema do %>
### Response
See `<%= doc_ref(response_schema) %>`
<% end %>
""", [:operation_id, :description, :parameters, :options, :response_schema])
defp module_doc(module_name) do
module_name =
module_name |> Atom.to_string |> String.split(".") |> List.last
"""
Contains functions for the #{module_name} API.
Each of these functions will output a Kazan.Request suitable for passing to
Kazan.Client.
"""
end
# Strips the `Elixir.` prefix from an atom for use in documentation.
# Atoms will not be linked if they include the Elixir. prefix.
defp doc_ref(str) do
str |> Atom.to_string |> String.replace(~r/^Elixir./, "")
end
end