defmodule Apical do @moduledoc """ Generates a web router from an OpenAPI document. Building an OpenAPI-compliant Phoenix router can be as simple as: ```elixir defmodule MyRouter do require Apical Apical.router_from_file( "path/to/openapi.yaml", controller: MyProjectWeb.ApiController ) end ``` See https://spec.openapis.org/oas/v3.1.0 for details on how to compose an OpenAPI schema. Using the macros `router_from_string/2` or `router_from_file/2` you may generate a `Phoenix.Router` that corresponds to OpenAPI document. > ### Tip {: .tip } > > In general, using `router_from_file/2` is should be preferred, especially if you > must maintain multiple versions of the schema, though you may find it easier to > iterate using `router_from_string/2` during early development. In that case, it > is possible to switch to `router_from_file/2` when you are ready to finalize your > API design or start versioning > ### Plug routers {: .info } > > `Plug`-only routers, which do not require `Phoenix`, are planned for a future release. The following activities are performed by the router generated by the macros: - Tagging inbound requests with API version - Constructing route and http verb matches in the router - Parameter operations - Supports: - Cookie parameters - Header parameters - Path parameters - Query parameters - Features: - Style decoding based on parameter styles (see https://spec.openapis.org/oas/v3.1.0#style-values) - Custom style decoding - Parameter marshalling (converting strings to types) - Parameter validation - Request body validation - content-length and content-type validation - matching content-type with request body plugs - Automatic json and `form-encoded` request body parsing - Parameter marshalling for `form-encoded` requests ### Options The following options are common to `router_from_string/2` and `router_from_file/2`. #### Global options - `encoding`: mimetype which describes how the schema is encoded. required in `router_from_string/2`, deduced from filename in `router_from_file/2`. - `decoders`: A proplist of mimetype to decoders. If you use an encoding that isn't `application/json` or `application/yaml` you should provide this proplist, which, at a minimum, contains `[{encoding_mimetype, {module, function}}]`. The call `module.function(string)` should return a map representing the OpenAPI schema, and should raise in the case that the content is not decodable. - `root`: the root path for the router. Defaults to `/v{major}` where `major` is the major version of the API, as declared under `info.version` in the schema. - `dump`: (For debugging), sends formatted code of the router to stdout. Defaults to `false`. If set to `:all`, will also pass `dump: true` to Exonerate. #### Scopable options The following options are *scopable*. They may be placed as top-level options or under the scopes (see below) - `controller`: Plug module which contains code implementing the API. It is recommended to `use Phoenix.Controller` in this plug module, or the functions may or may not be targetted as expected. Controller modules should implement public functions corresponding to the `operationId` of each operation in the schema. These functions must be cased in the same fashion as the `operationId`, and like all Phoenix Controller functions, take two arguments: - `conn`: the `Plug.Conn` for the request - `params`: a map containing the parameters for the operation. This is identical to `conn.params`. > ### Important {: .warning } > > Unlike standard Phoenix controller functions, parameters declared in the > `parameters` list of the operation are made avaliable in the `params` > argument as well as in `conn.params`. These parameters will overwrite > any fields present in body parameters that happen to have the same name. A single router may have its routes target more than one controller. - `extra_plugs`: a list of plugs to execute after the route has matched but before the parameter and body pipeline has been executed. These plugs are defined using `{atom, [args...]}` where `args` is a list of plug options to be applied to the plug, or `atom` which is equivalent to `{atom, []}`. These may be either a function plug or a module plug. > ### Route-level Security Plugs {: .tip } > > Route-level security checks should be performed in plugs declared in > `extra_plugs`, until `Apical` provides direct support for security > schemes. > ### Global plugs {: .tip } > > if you need plugs to be executed for all routes, declare those plugs > in the router module before the macro `Exonerate.router_from_*`. > ### Post-pipeline plugs {: .tip } > > if you need plugs to be executed after the parameter and body pipeline, > for example, for row-level security checks, declare those plugs in the > controller module. Note that these plugs should be able to match on > the `operationId` atom using `conn.private.operation_id`. - `styles`: a proplist of custom styles and their corresponding parsers. Each parser is represented as `{module, function, [args...]}` or `{module, function}` which is equivalent too `{module, function []}`. The parsers are functions that are called as `module.function(string, args...)`, and return `{:ok, value}` or `{:error, message}`. The message should be a string describing the error. The following styles are supported by default and do not need to be included in the styles proplist: - `"matrix"` - `"label"` - `"simple"` - `"form"` - `"space_delimited"` - `"pipe_delimited"` - `"deep_object"` see https://spec.openapis.org/oas/v3.1.0#style-values for description of these styles. > ### Custom styles {: .warning } > > If you need to support a custom style, you *must* add it to the > `styles` proplist. > ### Form-exploded objects {: .error } > > Form-exploded style parameters with type `object` in their schema are > not supported due to ambiguity in their definition per the OpenAPI > specification. - `content_sources`: A proplist of media-types (as **string** keys) and functions to act as the source for request body. These should be defined as `{media_type, {module, [opts...]}}`. These opts will be passed into the `c:Apical.Plugs.RequestBody.Source.fetch/3`. - `nest_all_json`: Analogous to the option in `Plug.Parsers.JSON`, this option will nest all json request body payloads under the `"_json"` key. if this is not true, objects payloads will be merged into `conn.params`. #### Available scopes The scopes have the following precedence: operation_ids > tags > parameters > global - `operation_ids`: A keywordlist of `operationId`s (as atom keys) and options to target to these operations. The keys must be cased in the same fashion as the `operationId` in the schema. - `tags`: A keywordlist of tags (as atom keys) and options to target to those tags. The tag keys must be cased in the same fashion as their tags in the schema. - `parameters`: A keywordlist of parameters (as atom keys) and options to target to those parameters. The parameter keys must be cased in the same fashion (including kebab-case) Note that this scope may be further nested inside of `tag` and `operation_ids` scopes. """ alias Apical.Tools @spec router_from_string(String.t(), Keyword.t()) :: any() @doc """ Generates a web router from a String containing an OpenAPI document. ### Example: ```elixir defmodule MyRouter do require Apical Apical.router_from_string( \""" openapi: 3.1.0 info: title: My API version: 1.0.0 paths: "/": get: operationId: getOperation responses: "200": description: OK \""", controller: MyProjectWeb.ApiController, encoding: "application/yaml" ) end ``` For options see `Apical` module docs. """ defmacro router_from_string(string, opts) do opts = Macro.expand_literals(opts, __CALLER__) router(string, opts) end @spec router_from_file(Path.t(), Keyword.t()) :: any() @doc """ Generates a web router from a String containing an OpenAPI document. ### Example: ```elixir defmodule MyRouter do require Apical Apical.router_from_file( "path/to/openapi.yaml", controller: MyProjectWeb.ApiController ) end ``` For options see `Apical` module docs. """ defmacro router_from_file(file, opts) do opts = opts |> Macro.expand_literals(__CALLER__) |> Keyword.merge(file: file) |> Keyword.put_new_lazy(:encoding, fn -> find_encoding(file, opts) end) file |> Macro.expand(__CALLER__) |> File.read!() |> router(opts) end defp router(string, opts) do string |> Tools.decode(opts) |> Apical.Phoenix.router(string, opts) |> Tools.maybe_dump(opts) end defp find_encoding(filename, _opts) do case Path.extname(filename) do ".json" -> "application/json" ".yaml" -> "application/yaml" _ -> raise "unsupported file extension" end end end