├── test ├── test_helper.exs └── openapi_test.exs ├── examples ├── github │ ├── test │ │ ├── test_helper.exs │ │ └── github_test.exs │ ├── priv │ │ └── api.github.com.json │ ├── .formatter.exs │ ├── lib │ │ └── github.ex │ ├── mix.exs │ ├── README.md │ ├── .gitignore │ └── mix.lock └── stripe │ ├── test │ ├── test_helper.exs │ └── stripe_test.exs │ ├── priv │ └── spec3.json │ ├── .formatter.exs │ ├── mix.lock │ ├── lib │ └── stripe.ex │ ├── mix.exs │ ├── README.md │ └── .gitignore ├── .formatter.exs ├── .gitmodules ├── mix.lock ├── mix.exs ├── .gitignore ├── README.md └── lib └── openapi.ex /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /examples/github/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /examples/stripe/test/test_helper.exs: -------------------------------------------------------------------------------- 1 | ExUnit.start() 2 | -------------------------------------------------------------------------------- /examples/stripe/priv/spec3.json: -------------------------------------------------------------------------------- 1 | ../openapi/openapi/spec3.json -------------------------------------------------------------------------------- /test/openapi_test.exs: -------------------------------------------------------------------------------- 1 | defmodule OpenAPITest do 2 | use ExUnit.Case, async: true 3 | end 4 | -------------------------------------------------------------------------------- /examples/github/priv/api.github.com.json: -------------------------------------------------------------------------------- 1 | ../rest-api-description/descriptions/api.github.com/api.github.com.json -------------------------------------------------------------------------------- /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /examples/github/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /examples/stripe/.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /examples/github/lib/github.ex: -------------------------------------------------------------------------------- 1 | defmodule GitHub do 2 | import OpenAPI 3 | 4 | defopenapi_client( 5 | path: Application.app_dir(:github, "priv/api.github.com.json"), 6 | base_url: "https://api.github.com", 7 | only: ["/user"] 8 | ) 9 | end 10 | -------------------------------------------------------------------------------- /.gitmodules: -------------------------------------------------------------------------------- 1 | [submodule "examples/github/rest-api-description"] 2 | path = examples/github/rest-api-description 3 | url = https://github.com/github/rest-api-description 4 | [submodule "examples/stripe/openapi"] 5 | path = examples/stripe/openapi 6 | url = https://github.com/stripe/openapi 7 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 3 | } 4 | -------------------------------------------------------------------------------- /examples/stripe/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 3 | } 4 | -------------------------------------------------------------------------------- /examples/stripe/lib/stripe.ex: -------------------------------------------------------------------------------- 1 | defmodule Stripe do 2 | import OpenAPI 3 | 4 | defopenapi_client( 5 | path: Application.app_dir(:stripe, "priv/spec3.json"), 6 | base_url: "https://api.stripe.com", 7 | only: [ 8 | "/v1/account", 9 | "/v1/charges" 10 | ] 11 | ) 12 | end 13 | -------------------------------------------------------------------------------- /examples/stripe/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Stripe.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :stripe, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | {:openapi, path: "../.."} 23 | ] 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /examples/github/mix.exs: -------------------------------------------------------------------------------- 1 | defmodule GitHub.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :github, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | deps: deps() 11 | ] 12 | end 13 | 14 | def application do 15 | [ 16 | extra_applications: [:logger] 17 | ] 18 | end 19 | 20 | defp deps do 21 | [ 22 | {:openapi, path: "../.."}, 23 | {:bypass, "~> 2.1"} 24 | ] 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule OpenAPI.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :openapi, 7 | version: "0.1.0", 8 | elixir: "~> 1.11", 9 | start_permanent: Mix.env() == :prod, 10 | xref: [ 11 | exclude: [ 12 | :httpc 13 | ] 14 | ], 15 | deps: deps() 16 | ] 17 | end 18 | 19 | def application do 20 | [ 21 | extra_applications: [:logger, :ssl] 22 | ] 23 | end 24 | 25 | defp deps do 26 | [ 27 | {:jason, "~> 1.0"} 28 | ] 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /examples/github/test/github_test.exs: -------------------------------------------------------------------------------- 1 | defmodule GitHubTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "it works" do 5 | assert {:users_get_authenticated, 2} in GitHub.__info__(:functions) 6 | end 7 | end 8 | 9 | defmodule GitHubIntegrationTest do 10 | use ExUnit.Case, async: true 11 | 12 | @moduletag :integration 13 | 14 | test "it works" do 15 | token = System.fetch_env!("GITHUB_TOKEN") 16 | 17 | client = GitHub.new(token: token) 18 | {:ok, response} = GitHub.users_get_authenticated(client) 19 | assert response.body["login"] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /examples/github/README.md: -------------------------------------------------------------------------------- 1 | # GitHub 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `github` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:github, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/github](https://hexdocs.pm/github). 21 | 22 | -------------------------------------------------------------------------------- /examples/stripe/README.md: -------------------------------------------------------------------------------- 1 | # Stripe 2 | 3 | **TODO: Add description** 4 | 5 | ## Installation 6 | 7 | If [available in Hex](https://hex.pm/docs/publish), the package can be installed 8 | by adding `stripe` to your list of dependencies in `mix.exs`: 9 | 10 | ```elixir 11 | def deps do 12 | [ 13 | {:stripe, "~> 0.1.0"} 14 | ] 15 | end 16 | ``` 17 | 18 | Documentation can be generated with [ExDoc](https://github.com/elixir-lang/ex_doc) 19 | and published on [HexDocs](https://hexdocs.pm). Once published, the docs can 20 | be found at [https://hexdocs.pm/stripe](https://hexdocs.pm/stripe). 21 | 22 | -------------------------------------------------------------------------------- /examples/stripe/test/stripe_test.exs: -------------------------------------------------------------------------------- 1 | defmodule StripeTest do 2 | use ExUnit.Case, async: true 3 | 4 | test "it works" do 5 | assert {:get_account, 1} in Stripe.__info__(:functions) 6 | assert {:get_charges, 1} in Stripe.__info__(:functions) 7 | end 8 | end 9 | 10 | defmodule StripeIntegrationTest do 11 | use ExUnit.Case, async: true 12 | 13 | @moduletag :integration 14 | 15 | test "it works" do 16 | token = System.fetch_env!("STRIPE_TOKEN") 17 | 18 | client = Stripe.new(token: token) 19 | {:ok, response} = Stripe.get_account(client) 20 | assert response.body["display_name"] 21 | end 22 | end 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | openapi-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /examples/github/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | github-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /examples/stripe/.gitignore: -------------------------------------------------------------------------------- 1 | # The directory Mix will write compiled artifacts to. 2 | /_build/ 3 | 4 | # If you run "mix test --cover", coverage assets end up here. 5 | /cover/ 6 | 7 | # The directory Mix downloads your dependencies sources to. 8 | /deps/ 9 | 10 | # Where third-party dependencies like ExDoc output generated docs. 11 | /doc/ 12 | 13 | # Ignore .fetch files in case you like to edit your project deps locally. 14 | /.fetch 15 | 16 | # If the VM crashes, it generates a dump, let's ignore it too. 17 | erl_crash.dump 18 | 19 | # Also ignore archive artifacts (built via "mix archive.build"). 20 | *.ez 21 | 22 | # Ignore package tarball (built via "mix hex.build"). 23 | stripe-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenAPI 2 | 3 | A proof-of-concept for generating [OpenAPI](https://www.openapis.org) clients in Elixir. 4 | 5 | ``` 6 | $ git clone --recursive git@github.com:wojtekmach/openapi.git 7 | ``` 8 | 9 | ## GitHub example: 10 | 11 | ``` 12 | $ (cd openapi/examples/github && mix deps.get && iex -S mix) 13 | ``` 14 | 15 | ```elixir 16 | iex> client = GitHub.new(token: System.fetch_env!("GITHUB_TOKEN")) 17 | iex> GitHub.users_get_authenticated(client) 18 | {:ok, %{status: 200, body: %{"login" => "alice", ...}, ...}} 19 | ``` 20 | 21 | See [`examples/github/lib/github.ex`](examples/github/lib/github.ex) 22 | 23 | ## Stripe example: 24 | 25 | ``` 26 | $ (cd openapi/examples/stripe && mix deps.get && iex -S mix) 27 | ``` 28 | 29 | ```elixir 30 | iex> client = Stripe.new(token: System.fetch_env!("STRIPE_TOKEN")) 31 | iex> Stripe.get_account(client) 32 | {:ok, %{status: 200, body: %{"display_name" => "ACME, Inc", ...}, ...}} 33 | ``` 34 | 35 | See [`examples/stripe/lib/stripe.ex`](examples/stripe/lib/stripe.ex) 36 | 37 | ## License 38 | 39 | Copyright 2021 Wojtek Mach 40 | 41 | Licensed under the Apache License, Version 2.0 (the "License"); 42 | you may not use this file except in compliance with the License. 43 | You may obtain a copy of the License at 44 | 45 | http://www.apache.org/licenses/LICENSE-2.0 46 | 47 | Unless required by applicable law or agreed to in writing, software 48 | distributed under the License is distributed on an "AS IS" BASIS, 49 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 50 | See the License for the specific language governing permissions and 51 | limitations under the License. 52 | -------------------------------------------------------------------------------- /examples/github/mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bypass": {:hex, :bypass, "2.1.0", "909782781bf8e20ee86a9cabde36b259d44af8b9f38756173e8f5e2e1fabb9b1", [:mix], [{:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.0", [hex: :plug_cowboy, repo: "hexpm", optional: false]}, {:ranch, "~> 1.3", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "d9b5df8fa5b7a6efa08384e9bbecfe4ce61c77d28a4282f79e02f1ef78d96b80"}, 3 | "cowboy": {:hex, :cowboy, "2.8.0", "f3dc62e35797ecd9ac1b50db74611193c29815401e53bac9a5c0577bd7bc667d", [:rebar3], [{:cowlib, "~> 2.9.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "~> 1.7.1", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "4643e4fba74ac96d4d152c75803de6fad0b3fa5df354c71afdd6cbeeb15fac8a"}, 4 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.3.1", "ebd1a1d7aff97f27c66654e78ece187abdc646992714164380d8a041eda16754", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "3a6efd3366130eab84ca372cbd4a7d3c3a97bdfcfb4911233b035d117063f0af"}, 5 | "cowlib": {:hex, :cowlib, "2.9.1", "61a6c7c50cf07fdd24b2f45b89500bb93b6686579b069a89f88cb211e1125c78", [:rebar3], [], "hexpm", "e4175dc240a70d996156160891e1c62238ede1729e45740bdd38064dad476170"}, 6 | "jason": {:hex, :jason, "1.2.2", "ba43e3f2709fd1aa1dce90aaabfd039d000469c05c56f0b8e31978e03fa39052", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "18a228f5f0058ee183f29f9eae0805c6e59d61c3b006760668d8d18ff0d12179"}, 7 | "mime": {:hex, :mime, "1.5.0", "203ef35ef3389aae6d361918bf3f952fa17a09e8e43b5aa592b93eba05d0fb8d", [:mix], [], "hexpm", "55a94c0f552249fc1a3dd9cd2d3ab9de9d3c89b559c2bd01121f824834f24746"}, 8 | "plug": {:hex, :plug, "1.11.0", "f17217525597628298998bc3baed9f8ea1fa3f1160aa9871aee6df47a6e4d38e", [:mix], [{:mime, "~> 1.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "2d9c633f0499f9dc5c2fd069161af4e2e7756890b81adcbb2ceaa074e8308876"}, 9 | "plug_cowboy": {:hex, :plug_cowboy, "2.4.1", "779ba386c0915027f22e14a48919a9545714f849505fa15af2631a0d298abf0f", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "d72113b6dff7b37a7d9b2a5b68892808e3a9a752f2bf7e503240945385b70507"}, 10 | "plug_crypto": {:hex, :plug_crypto, "1.2.1", "5c854427528bf61d159855cedddffc0625e2228b5f30eff76d5a4de42d896ef4", [:mix], [], "hexpm", "6961c0e17febd9d0bfa89632d391d2545d2e0eb73768f5f50305a23961d8782c"}, 11 | "ranch": {:hex, :ranch, "1.7.1", "6b1fab51b49196860b733a49c07604465a47bdb78aa10c1c16a3d199f7f8c881", [:rebar3], [], "hexpm", "451d8527787df716d99dc36162fca05934915db0b6141bbdac2ea8d3c7afc7d7"}, 12 | "telemetry": {:hex, :telemetry, "0.4.2", "2808c992455e08d6177322f14d3bdb6b625fbcfd233a73505870d8738a2f4599", [:rebar3], [], "hexpm", "2d1419bd9dda6a206d7b5852179511722e2b18812310d304620c7bd92a13fcef"}, 13 | } 14 | -------------------------------------------------------------------------------- /lib/openapi.ex: -------------------------------------------------------------------------------- 1 | defmodule OpenAPI do 2 | def parse(opts) do 3 | path = Keyword.fetch!(opts, :path) 4 | only = opts[:only] 5 | 6 | map = path |> File.read!() |> Jason.decode!() 7 | 8 | operations = 9 | for {path, map} <- map["paths"], 10 | !only or path in only, 11 | {method, map} <- map do 12 | name = 13 | map["operationId"] 14 | |> String.replace(["/", "-"], "_") 15 | |> Macro.underscore() 16 | |> String.to_atom() 17 | 18 | description = map["description"] 19 | 20 | %{ 21 | id: map["operationId"], 22 | method: String.to_atom(method), 23 | path: path, 24 | name: name, 25 | description: description, 26 | parameters: map["parameters"] 27 | } 28 | end 29 | 30 | %{ 31 | operations: operations 32 | } 33 | end 34 | 35 | defmacro defopenapi_client(opts) do 36 | {path, _} = Code.eval_quoted(opts[:path]) 37 | api = OpenAPI.parse(path: path, only: opts[:only]) 38 | 39 | new = 40 | quote do 41 | defstruct [ 42 | :token, 43 | base_url: unquote(opts[:base_url]), 44 | http_client: OpenAPI.HTTPClient.HTTPC 45 | ] 46 | 47 | @doc """ 48 | Returns new client. 49 | """ 50 | def new(opts) do 51 | client = struct!(__MODULE__, opts) 52 | client.http_client.init() 53 | client 54 | end 55 | end 56 | 57 | operations = 58 | for operation <- api.operations do 59 | quote do 60 | @operation unquote(Macro.escape(operation)) 61 | @doc OpenAPI.operation_doc(@operation) 62 | def unquote(operation.name)(client, params \\ []) do 63 | OpenAPI.request(@operation, client, params) 64 | end 65 | end 66 | end 67 | 68 | [new] ++ operations 69 | end 70 | 71 | def request(operation, client, params) do 72 | url = client.base_url <> operation.path 73 | 74 | headers = [ 75 | {"user-agent", "openapi"}, 76 | {"authorization", "Bearer #{client.token}"} 77 | ] 78 | 79 | body = Jason.encode!(params || %{}) 80 | opts = [] 81 | 82 | with {:ok, resp} <- client.http_client.request(operation.method, url, headers, body, opts) do 83 | {:ok, %{resp | body: Jason.decode!(resp.body)}} 84 | end 85 | end 86 | 87 | def operation_doc(operation) do 88 | """ 89 | #{operation.id} operation. 90 | 91 | #{operation.description} 92 | #{operation_doc_parameters(operation.parameters)} 93 | """ 94 | end 95 | 96 | defp operation_doc_parameters([]), do: "" 97 | 98 | defp operation_doc_parameters(parameters) do 99 | """ 100 | ## Parameters: 101 | 102 | #{Enum.map_join(parameters || [], "\n", &" * :#{&1["name"]}")} 103 | """ 104 | end 105 | end 106 | 107 | defmodule OpenAPI.HTTPClient do 108 | @callback init() :: :ok 109 | 110 | @callback request( 111 | method :: atom(), 112 | url :: binary(), 113 | headers :: [{binary(), binary()}], 114 | body :: binary(), 115 | opts :: keyword() 116 | ) :: 117 | {:ok, 118 | %{ 119 | status: 200..599, 120 | headers: [{binary(), binary()}], 121 | body: binary() 122 | }} 123 | | {:error, term()} 124 | end 125 | 126 | defmodule OpenAPI.HTTPClient.HTTPC do 127 | @behaviour OpenAPI.HTTPClient 128 | 129 | @impl true 130 | def init() do 131 | {:ok, _} = Application.ensure_all_started(:inets) 132 | :ok 133 | end 134 | 135 | @impl true 136 | def request(method, url, headers, _body, []) do 137 | headers = for {k, v} <- headers, do: {String.to_charlist(k), String.to_charlist(v)} 138 | request = {String.to_charlist(url), headers} 139 | 140 | case :httpc.request(method, request, [], body_format: :binary) do 141 | {:ok, {{_, status, _}, headers, body}} -> 142 | headers = for {k, v} <- headers, do: {List.to_string(k), List.to_string(v)} 143 | {:ok, %{status: status, headers: headers, body: body}} 144 | end 145 | end 146 | end 147 | --------------------------------------------------------------------------------