├── .formatter.exs ├── .github └── workflows │ └── on-push-pr.yaml ├── .gitignore ├── CHANGELOG.md ├── LICENSE ├── README.md ├── guides └── Apical for testing.md ├── lib ├── apical.ex └── apical │ ├── _exceptions │ ├── content_length_error.ex │ ├── content_type_error.ex │ ├── parameter_error.ex │ └── request_body_too_large_error.ex │ ├── adapters │ ├── phoenix.ex │ └── plug.ex │ ├── conn.ex │ ├── parser │ ├── marshal.ex │ ├── query.ex │ └── style.ex │ ├── path.ex │ ├── plug │ ├── controller.ex │ └── router.ex │ ├── plugs │ ├── _parameter.ex │ ├── cookie.ex │ ├── header.ex │ ├── path.ex │ ├── query.ex │ ├── request_body.ex │ ├── request_body │ │ ├── _source.ex │ │ ├── default.ex │ │ ├── form_encoded.ex │ │ └── json.ex │ ├── set_operation_id.ex │ └── set_version.ex │ ├── router.ex │ ├── schema.ex │ ├── testing.ex │ ├── tools.ex │ └── validators.ex ├── mix.exs ├── mix.lock └── test ├── _support ├── endpoint_case.ex ├── error.ex ├── error_view.ex ├── extra_plug.ex ├── from_file_test.yaml └── test_test_router.ex ├── compile_error ├── duplicate_operation_id_test.exs ├── duplicate_parameter_test.exs ├── form_encoded_non_object_test.exs ├── form_exploded_object_test.exs ├── invalid_openapi_test.exs ├── invalid_parameter_location_test.exs ├── missing_info_test.exs ├── missing_openapi_test.exs ├── missing_operation_id_test.exs ├── missing_parameter_in_test.exs ├── missing_parameter_name_test.exs ├── missing_paths_test.exs ├── missing_version_test.exs ├── nonexistent_path_parameter_test.exs └── unrequired_path_parameter_test.exs ├── controllers ├── aliased_function_test.exs ├── by_group_test.exs ├── by_operation_id_test.exs └── by_tag_test.exs ├── extra_plug ├── by_operation_id_test.exs ├── by_tag_test.exs └── global_test.exs ├── from_file_test.exs ├── parameters ├── cookie_test.exs ├── header_test.exs ├── path_test.exs └── query_test.exs ├── parser └── query_parser_test.exs ├── plug ├── aliased_test.exs ├── get_path_test.exs ├── get_test.exs └── operation_mf_test.exs ├── refs ├── parameter_object_test.exs ├── path_item_object_test.exs ├── request_body_object_test.exs └── schema_object_test.exs ├── regression └── ref_not_found_error_test.exs ├── request_body ├── form_encoded_test.exs ├── json_test.exs └── other_test.exs ├── test_helper.exs ├── test_test.exs ├── verbs ├── delete_test.exs ├── get_test.exs ├── head_test.exs ├── options_test.exs ├── patch_test.exs ├── post_test.exs ├── put_test.exs └── trace_test.exs └── versioning ├── by_assigns_test.exs ├── by_controller_test.exs └── by_different_routers_test.exs /.formatter.exs: -------------------------------------------------------------------------------- 1 | # Used by "mix format" 2 | [ 3 | inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"] 4 | ] 5 | -------------------------------------------------------------------------------- /.github/workflows/on-push-pr.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | name: Build and Test OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}} 12 | runs-on: ubuntu-latest 13 | strategy: 14 | matrix: 15 | otp: [23, 24, 25] 16 | elixir: [1.14] 17 | 18 | env: 19 | MIX_ENV: test 20 | ImageOS: ubuntu20 # equivalent to runs-on ubuntu-20.04 21 | 22 | steps: 23 | - uses: actions/checkout@v3 24 | 25 | - name: Set up Elixir 26 | uses: erlef/setup-beam@v1 27 | with: 28 | elixir-version: ${{matrix.elixir}} # Define the elixir version [required] 29 | otp-version: ${{matrix.otp}} # Define the OTP version [required] 30 | 31 | - uses: actions/cache@v1 32 | id: deps-cache 33 | with: 34 | path: deps 35 | key: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 36 | 37 | - uses: actions/cache@v1 38 | id: build-cache 39 | with: 40 | path: _build 41 | key: ${{ runner.os }}-build-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles(format('{0}{1}', github.workspace, '/mix.lock')) }} 42 | 43 | - name: Install dependencies 44 | run: mix deps.get 45 | 46 | - name: Run tests 47 | run: mix test 48 | -------------------------------------------------------------------------------- /.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 | apical-*.tar 24 | 25 | # Temporary files, for example, from tests. 26 | /tmp/ 27 | 28 | # elixir-ls files 29 | /.elixir_ls/ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Apical Changelog 2 | 3 | ## 0.1.0 4 | 5 | ### initial release 6 | 7 | - supports from file or from string 8 | - supports injecting extra plugs 9 | - supports versioning 10 | - by matching on assigns `api_version` field 11 | - by targeting controllers 12 | - by chaining routers 13 | - parameter marshalling support 14 | - path 15 | - query 16 | - headers 17 | - cookies 18 | - validation of parameters using Exonerate (https://github.com/E-xyza/Exonerate) 19 | - generates routes into phoenix header 20 | - inbound request body payloads into params 21 | - application/json 22 | - application/form-encoded 23 | - options targettable by tag or operation_id 24 | - supports internal $refs 25 | 26 | ## 0.2.0 27 | 28 | - support for grouping operationId by "group" 29 | - support for using Plug only, with no Phoenix dependency 30 | - support for aliasing operationId to functions 31 | - support for custom parameter marshalling functions 32 | - support for disabling marshalling or validation 33 | - support for using Apical to test requests against OpenAPI schemas 34 | 35 | ## 0.2.1 36 | - fixes issue with nested refs 37 | 38 | ## Future planned support 39 | 40 | ### These features are in approximate order of priority 41 | 42 | - more sophisticated parsing and marshalling in request_body 43 | - support for json libraries besides Jason 44 | - data egress checking (conditional, compile-time) 45 | - support for auto-rejecting based on accept: information 46 | - multipart/form-data support 47 | - authorization schema support (use `extra_plugs:` for now) 48 | - remote $ref support 49 | - $id-based $ref support 50 | - Openapi 3.0 support 51 | - Openapi 4.0 support -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2017 Isaac Yonemoto and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Apical 2 | 3 | ![test status](https://github.com/e-xyza/apical/actions/workflows/on-push-pr.yaml/badge.svg) 4 | 5 | Elixir Routers from OpenAPI schemas 6 | 7 | ## Installation 8 | 9 | This package can be installed by adding `apical` to your list of dependencies in `mix.exs`: 10 | 11 | Exonerate is a compile-time dependency. If you don't include this in your Mix.exs, there will 12 | be unwanted compiler warnings. 13 | 14 | ```elixir 15 | def deps do 16 | [ 17 | {:apical, "~> 0.2.1"}, 18 | {:exonerate, "~> 1.1.2", runtime: Mix.env() != :prod} 19 | ] 20 | end 21 | ``` 22 | 23 | If you think you might need to recompile your router in production, remove the runtime option 24 | on Exonerate. 25 | 26 | ## Basic use 27 | 28 | For the following router module: 29 | 30 | ```elixir 31 | defmodule MyProjectWeb.ApiRouter do 32 | require Apical 33 | 34 | Apical.router_from_string( 35 | """ 36 | openapi: 3.1.0 37 | info: 38 | title: My API 39 | version: 1.0.0 40 | paths: 41 | "/": 42 | get: 43 | operationId: getOperation 44 | responses: 45 | "200": 46 | description: OK 47 | """, 48 | controller: MyProjectWeb.ApiController, 49 | encoding: "application/yaml" 50 | ) 51 | end 52 | ``` 53 | 54 | You would connect this to your endpoint as follows: 55 | 56 | ```elixir 57 | defmodule MyProjectWeb.ApiEndpoint do 58 | use Phoenix.Endpoint, otp_app: :my_project 59 | 60 | plug(MyProjectWeb.ApiRouter) 61 | end 62 | ``` 63 | 64 | And compose a controller as follows: 65 | 66 | ```elixir 67 | defmodule MyProjectWeb.ApiController do 68 | use Phoenix.Controller 69 | 70 | alias Plug.Conn 71 | 72 | # NOTE THE CASING BELOW: 73 | def getOperation(conn, _params) do 74 | Conn.send_resp(conn, 200, "OK") 75 | end 76 | end 77 | ``` 78 | 79 | ## From file: 80 | 81 | You may also generate a router from a file: 82 | 83 | ```elixir 84 | defmodule MyProjectWeb.ApiRouter do 85 | require Apical 86 | 87 | Apical.router_from_file("priv/assets/api/openapi.v1.yaml", 88 | controller: MyProjectWeb.ApiController 89 | ) 90 | end 91 | ``` 92 | 93 | ### Embedding inside of an existing router 94 | 95 | You may also embed apical inside of an existing phoenix router: 96 | 97 | ```elixir 98 | scope "/api", MyProjectWeb.Api do 99 | require Apical 100 | 101 | Apical.router_from_file("priv/assets/api/openapi.v1.yaml", 102 | controller: MyProjectWeb.ApiController) 103 | end 104 | ``` 105 | 106 | If you are embedding Apical in an existing router, be sure to delete 107 | the following lines from the phoenix *endpoint*: 108 | 109 | ```elixir 110 | plug Plug.Parsers, 111 | parsers: [:urlencoded, :multipart, :json], 112 | pass: ["*/*"], 113 | json_decoder: Phoenix.json_library() 114 | ``` 115 | 116 | As Apical will do its own parsing. 117 | 118 | ## Using it in tests: 119 | 120 | If you're using OpenAPI as a client, you can use Apical to test that your 121 | request wrapper conforms to the client schema: 122 | 123 | https://hexdocs.pm/apical/apical-for-testing.html 124 | 125 | ## Advanced usage 126 | 127 | For more advanced usage, consult the tests in the test directory. 128 | Guides will be provided in the next version of apical 129 | 130 | ## Documentation 131 | 132 | Documentation can be found at . 133 | 134 | -------------------------------------------------------------------------------- /guides/Apical for testing.md: -------------------------------------------------------------------------------- 1 | # Using Apical for Testing OpenAPI requests 2 | 3 | You may use Apical in your test environment to make sure that client requests 4 | you perform against a 3rd party OpenAPI server are well-formed. 5 | 6 | Often times, tests for API compliance are not performed because they can look 7 | like your tests are merely duplicative of the code that you already have. 8 | Moreover, if the API changes, then you will have to rewrite all of the tests 9 | to remain in compliance with API. 10 | 11 | With Apical, you have an easy way of testing that the parameter requirements 12 | are fulfilled and that they should not cause 400 or 404 errors when you 13 | make the request. You can also use this to test that the parameters you're 14 | assigning are correctly bound into the OpenAPI parameters. 15 | 16 | ## Prerequisites 17 | 18 | In "test" mode, Apical expects that you are using the following two Elixir 19 | libraries: 20 | 21 | - `Mox` to mock out the API controllers. 22 | - `Bypass` to stand up transient http servers. 23 | 24 | ### Installation 25 | 26 | 1. Add dependenciecs: 27 | 28 | In your `mix.exs` file, add the following dependencies: 29 | 30 | ```elixir 31 | defp deps do 32 | [ 33 | ... 34 | {:apical, "~> 0.2", only: :test}, 35 | {:mox, "~> 1.0", only: :test}, 36 | {:bypass, "~> 2.1", only: :test}, 37 | ] 38 | end 39 | ``` 40 | 41 | 2. If you haven't already, set up your elixir compilers to compile to a support directory: 42 | 43 | In `mix.exs`, `project` function 44 | 45 | ```elixir 46 | def project do 47 | [ 48 | ... 49 | elixirc_paths: elixirc_paths(Mix.env()), 50 | ] 51 | end 52 | ``` 53 | 54 | In `mix.exs` module top level: 55 | 56 | ```elixir 57 | def elixirc_paths(:test), do: ["lib", "test/support"] 58 | def elixirc_paths(_), do: ["lib"] 59 | ``` 60 | 61 | 3. Make sure `mox` and `bypass` are running when tests are running: 62 | 63 | In `test/test_helper.exs`: 64 | 65 | ```elixir 66 | Application.ensure_all_started(:bypass) 67 | Application.ensure_all_started(:mox) 68 | ``` 69 | 70 | ## Router setup 71 | 72 | Create a router in your `test/support` directory. 73 | 74 | For example: 75 | 76 | ```elixir 77 | defmodule MyAppTest.SomeSAAS do 78 | use Phoenix.Router 79 | 80 | require Apical 81 | 82 | Apical.router_from_file("path/to/some_saas.yaml", encoding: "application/yaml", testing: :auto) 83 | end 84 | ``` 85 | 86 | Note that this macro creates `MyAppTest.SomeSAAS.Mock` which is the mock for controller serviced 87 | by the `some_saas` OpenAPI schema, as well as the `bypass/1,2` function which configures bypass 88 | to use the router. 89 | 90 | For details on how to set up more fine-grained testing settings, see documentation for `Apical` module. 91 | 92 | ## Testing module setup 93 | 94 | In your test module, start with the following code: 95 | 96 | ```elixir 97 | defmodule MyAppTest.SaasRequestTest do 98 | # tests using Apical in "test" mode where it creates a bypass server. 99 | 100 | use ExUnit.Case, async: true 101 | 102 | alias MyAppTest.SomeSAAS 103 | alias MyAppTest.SomeSAAS.Mock 104 | 105 | alias MyApp.ClientModule 106 | 107 | setup do 108 | bypass = Bypass.open() 109 | SomeSAAS.bypass(bypass) 110 | {:ok, bypass: bypass} 111 | end 112 | ``` 113 | 114 | This sets up bypass to serve an http server on its own port for each test 115 | run in the test module. Since it's async, the `Mox` expectations are set 116 | up to work with the bypass server. 117 | 118 | ## Testing your API consumer 119 | 120 | > ### Required for your API consumer {: .warning } 121 | > 122 | > In order to use this feature, your API consumer functions MUST be able to 123 | > use a host other than the API's "normal" host. 124 | 125 | we'll assume that some `ClientModule` has 126 | 127 | 1. Testing to see that the issued request is compliant (no 400/404 errors) 128 | 129 | In this case, we have function `some_operation` is compliant and doesn't 130 | issue a request to an incorrect path or present invalid parameters. 131 | 132 | ```elixir 133 | test "someOperation" %{bypass: bypass} do 134 | Mox.expect(Mock, :someOperation, fn conn, _params -> 135 | send_resp(conn, 200, @dummy_result) 136 | end) 137 | 138 | ClientModule.some_operation(host: "localhost:#{bypass.port}") 139 | end 140 | ``` 141 | 142 | 2. Testing to see that parameters are serialized as expected 143 | 144 | This test is an example verification that content issued through a client 145 | module into a OpenAPI operation is serialized as expected. 146 | 147 | > ### Scope of parameters {: .info } 148 | > 149 | > Keep in mind that parameters can be in cookies, headers, query string, path, 150 | > or content serialized from the body of the http request 151 | > parameters taken from the body have lower precedence than taken from the 152 | > request, if you could potentially have a collision in keys, use the 153 | > `nest_all_json` option in your Apical router configuration. 154 | 155 | ```elixir 156 | @test_parameter 47 157 | 158 | test "someOperation" %{bypass: bypass} do 159 | Mox.expect(Mock, :someOperation, fn conn, %{"parameter-name" => parameter} -> 160 | assert parameter == @test_parameter 161 | send_resp(conn, 201, @dummy_result) 162 | end) 163 | 164 | ClientModule.some_operation(@test_parameter, host: "localhost:#{bypass.port}") 165 | end 166 | ``` 167 | 168 | > ### Json Encoding {: .warning } 169 | > 170 | > note that your client function input parameter might have atom keys (or might 171 | > be a struct), in which case, strict equality might not be the correct test 172 | > inside your mox expectation, as Apical will typically render it as a JSON with 173 | > string keys. -------------------------------------------------------------------------------- /lib/apical/_exceptions/content_length_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Exceptions.MissingContentLengthError do 2 | @moduledoc """ 3 | Error raised when the `content-length` header is missing from the request. 4 | 5 | This error should result in a http 411 status code. 6 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/411 7 | """ 8 | 9 | defexception plug_status: 411 10 | 11 | def message(_), do: "missing content-length header" 12 | end 13 | 14 | defmodule Apical.Exceptions.MultipleContentLengthError do 15 | @moduledoc """ 16 | Error raised multiple `content-length` headers are provided by the request. 17 | 18 | This error should result in a http 411 status code. 19 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/411 20 | """ 21 | 22 | defexception plug_status: 411 23 | 24 | def message(_), do: "multiple content-length headers found" 25 | end 26 | 27 | defmodule Apical.Exceptions.InvalidContentLengthError do 28 | @moduledoc """ 29 | Error raised when the `content-length` header does not parse to a valid 30 | integer. 31 | 32 | This error should result in a http 411 status code. 33 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/411 34 | """ 35 | 36 | defexception [:invalid_string, plug_status: 411] 37 | 38 | def message(error), do: "invalid content-length header provided: #{error.invalid_string}" 39 | end 40 | -------------------------------------------------------------------------------- /lib/apical/_exceptions/content_type_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Exceptions.MissingContentTypeError do 2 | @moduledoc """ 3 | Error raised when the `content-type` header is missing from the request. 4 | 5 | This error should result in a http 400 status code. 6 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 7 | """ 8 | 9 | defexception plug_status: 400 10 | 11 | def message(_), do: "missing content-type header" 12 | end 13 | 14 | defmodule Apical.Exceptions.MultipleContentTypeError do 15 | @moduledoc """ 16 | Error raised multiple `content-type` headers are provided by the request. 17 | 18 | This error should result in a http 400 status code. 19 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 20 | """ 21 | 22 | defexception plug_status: 400 23 | 24 | def message(_), do: "multiple content-type headers found" 25 | end 26 | 27 | defmodule Apical.Exceptions.InvalidContentTypeError do 28 | @moduledoc """ 29 | Error raised when the `content-type` header does not parse to a valid 30 | mimetype string. 31 | 32 | This error should result in a http 400 status code. 33 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 34 | """ 35 | 36 | defexception [:invalid_string, plug_status: 400] 37 | 38 | def message(error), do: "invalid content-type header provided: #{error.invalid_string}" 39 | end 40 | -------------------------------------------------------------------------------- /lib/apical/_exceptions/parameter_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Exceptions.ParameterError do 2 | @moduledoc """ 3 | Error raised when parameters are invalid. Note that many of the fields 4 | correspond to error parameters returned by `Exonerate` validators. 5 | 6 | This error should result in a http 400 status code. 7 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/400 8 | """ 9 | 10 | @optional_keys ~w(operation_id 11 | in 12 | misparsed 13 | absolute_keyword_location 14 | instance_location 15 | errors 16 | error_value 17 | matches 18 | reason 19 | required 20 | ref_trace)a 21 | 22 | defexception @optional_keys ++ [plug_status: 400] 23 | 24 | @struct_keys @optional_keys ++ [:plug_status] 25 | 26 | def message(exception = %{reason: reason}) when is_binary(reason) do 27 | "Parameter Error in operation #{exception.operation_id} (in #{exception.in}): #{reason}" 28 | end 29 | 30 | def message(exception = %{misparsed: nil}) do 31 | "Parameter Error in operation #{exception.operation_id} (in #{exception.in}): #{describe_exonerate(exception)}" 32 | end 33 | 34 | def message(exception) do 35 | "Parameter Error in operation #{exception.operation_id} (in #{exception.in}): invalid character #{exception.misparsed}" 36 | end 37 | 38 | def custom_fields_from(operation_id, where, style_name, property, message) 39 | when is_binary(message) do 40 | custom_fields_from(operation_id, where, style_name, property, message: message) 41 | end 42 | 43 | def custom_fields_from(operation_id, where, style_name, property, contents) 44 | when is_list(contents) do 45 | tail = 46 | if message = Keyword.get(contents, :message) do 47 | ": #{message}" 48 | else 49 | "" 50 | end 51 | 52 | contents 53 | |> Keyword.take(@struct_keys) 54 | |> Keyword.merge(operation_id: operation_id, in: where) 55 | |> Keyword.put_new( 56 | :reason, 57 | "custom parser for style `#{style_name}` in property `#{property}` failed#{tail}" 58 | ) 59 | end 60 | 61 | # TODO: improve this error by turning 62 | def describe_exonerate(exception) do 63 | etc = 64 | exception 65 | |> Map.take([:errors, :matches, :reason, :required, :ref_trace]) 66 | |> Enum.flat_map(fn {key, value} -> 67 | List.wrap(if value, do: "#{key}: #{inspect(value)}") 68 | end) 69 | |> Enum.join(";\n") 70 | |> case do 71 | "" -> "" 72 | string -> ".\n#{string}" 73 | end 74 | 75 | json_value = Jason.encode!(exception.error_value) 76 | 77 | "value `#{json_value}` at `#{exception.instance_location}` fails schema criterion at `#{exception.absolute_keyword_location}`#{etc}" 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/apical/_exceptions/request_body_too_large_error.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Exceptions.RequestBodyTooLargeError do 2 | @moduledoc """ 3 | Error raised when the request body is too large. This could be because the 4 | payload is larger than the maximum allowed size as specified in configuration 5 | or if the request body size doesn't match the `content-length` header. 6 | 7 | This error should result in a http 413 status code. 8 | see: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/413 9 | """ 10 | 11 | defexception [:max_length, :content_length, plug_status: 413] 12 | 13 | def message(_), do: "missing content-length header" 14 | end 15 | -------------------------------------------------------------------------------- /lib/apical/adapters/phoenix.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Adapters.Phoenix do 2 | @moduledoc false 3 | def build_path(path) do 4 | operation_pipeline = :"#{path.version}-#{path.operation_id}" 5 | 6 | quote do 7 | unquote(path.parameter_validators) 8 | unquote(path.body_validators) 9 | 10 | pipeline unquote(operation_pipeline) do 11 | unquote(path.extra_plugs) 12 | 13 | plug(Apical.Plugs.SetVersion, unquote(path.version)) 14 | plug(Apical.Plugs.SetOperationId, unquote(path.operation_id)) 15 | unquote(path.parameter_plugs) 16 | 17 | unquote(path.body_plugs) 18 | end 19 | 20 | scope unquote(path.root) do 21 | pipe_through(unquote(operation_pipeline)) 22 | 23 | unquote(path.verb)( 24 | unquote(path.canonical_path), 25 | unquote(path.controller), 26 | unquote(path.function) 27 | ) 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/apical/adapters/plug.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Adapters.Plug do 2 | @methods Map.new( 3 | ~w(get post put patch delete head options trace)a, 4 | &{&1, String.upcase(to_string(&1))} 5 | ) 6 | 7 | @moduledoc false 8 | def build_path(path) do 9 | # to get proper plug segregation, we have to create a module for each 10 | # operation. In order to name these plugs, we'll use a hash of the 11 | # version and the operation id. 12 | 13 | module_name = 14 | :sha256 |> :crypto.hash("#{path.version}-#{path.operation_id}") |> Base.encode16() 15 | 16 | module_alias = {:__aliases__, [alias: false], [String.to_atom(module_name)]} 17 | 18 | match_parts = 19 | path.root 20 | |> Path.join(path.canonical_path) 21 | |> String.split("/") 22 | |> Enum.map(&split_on_matches/1) 23 | |> Enum.reject(&(&1 == "")) 24 | |> Macro.escape() 25 | 26 | method = Map.fetch!(@methods, path.verb) 27 | 28 | quote do 29 | previous = Module.get_attribute(__MODULE__, :operations, []) 30 | 31 | Module.put_attribute(__MODULE__, :operations, [ 32 | {:operation, unquote(module_alias)} | previous 33 | ]) 34 | 35 | defmodule unquote(module_alias) do 36 | use Plug.Builder 37 | 38 | unquote(path.parameter_validators) 39 | unquote(path.body_validators) 40 | 41 | unquote(path.extra_plugs) 42 | 43 | plug(Apical.Plugs.SetVersion, unquote(path.version)) 44 | plug(Apical.Plugs.SetOperationId, unquote(path.operation_id)) 45 | unquote(path.parameter_plugs) 46 | 47 | unquote(path.body_plugs) 48 | 49 | plug(unquote(path.controller), unquote(path.function)) 50 | 51 | @impl Plug 52 | def init(_), do: [] 53 | 54 | @impl Plug 55 | def call(conn = %{method: unquote(method)}, opts) do 56 | if matched_conn = Apical.Adapters.Plug._path_match(conn, unquote(match_parts)) do 57 | super(matched_conn, opts) 58 | else 59 | conn 60 | end 61 | end 62 | 63 | def call(conn, _opts), do: conn 64 | end 65 | end 66 | end 67 | 68 | def _path_match(conn, match_parts) do 69 | find_match(conn, conn.path_info, match_parts) 70 | end 71 | 72 | defp split_on_matches(string) do 73 | case String.split(string, ":") do 74 | [no_colon] -> 75 | no_colon 76 | 77 | [_, ""] -> 78 | raise "invalid path: `#{string}`" 79 | 80 | [match_str, key] -> 81 | {match_str, key, byte_size(match_str)} 82 | 83 | _ -> 84 | raise "invalid path: `#{string}`" 85 | end 86 | end 87 | 88 | defp find_match(conn, [], []), do: conn 89 | 90 | defp find_match(conn, [path_part | path_rest], [path_part | match_rest]) do 91 | find_match(conn, path_rest, match_rest) 92 | end 93 | 94 | defp find_match(conn, [path_part | path_rest], [{"", match_var, _} | match_rest]) do 95 | # optimization of next clause 96 | conn 97 | |> put_path_param(match_var, path_part) 98 | |> find_match(path_rest, match_rest) 99 | end 100 | 101 | defp find_match(conn, [path_part | path_rest], [{match_str, key, match_len} | match_rest]) do 102 | case :binary.part(path_part, 0, match_len) do 103 | ^match_str -> 104 | value = :binary.part(path_part, match_len, byte_size(path_part) - match_len) 105 | 106 | conn 107 | |> put_path_param(key, value) 108 | |> find_match(path_rest, match_rest) 109 | 110 | _ -> 111 | nil 112 | end 113 | end 114 | 115 | defp find_match(_, _, _), do: nil 116 | 117 | defp put_path_param(conn = %{params: %Plug.Conn.Unfetched{}}, key, value) do 118 | %{conn | params: %{key => value}} 119 | end 120 | 121 | defp put_path_param(conn = %{params: params}, key, value) do 122 | %{conn | params: Map.put(params, key, value)} 123 | end 124 | end 125 | -------------------------------------------------------------------------------- /lib/apical/conn.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Conn do 2 | @moduledoc false 3 | 4 | # module that contains specialized function that act on the Plug.Conn struct 5 | # these operations are specialized for Apical. 6 | 7 | alias Apical.Exceptions.ParameterError 8 | alias Apical.Parser.Marshal 9 | alias Apical.Parser.Query 10 | alias Apical.Parser.Style 11 | alias Plug.Conn 12 | 13 | defp style_description(:form), do: "comma delimited" 14 | defp style_description(:space_delimited), do: "space delimited" 15 | defp style_description(:pipe_delimited), do: "pipe delimited" 16 | 17 | def fetch_query_params!(conn, settings) do 18 | case Query.parse(conn.query_string, settings) do 19 | {:ok, parse_result, warnings} -> 20 | new_conn = 21 | conn 22 | |> Map.put(:query_params, parse_result) 23 | |> Map.update!(:params, &Map.merge(&1, parse_result)) 24 | 25 | Enum.reduce(warnings, new_conn, fn warning, so_far -> 26 | Conn.put_resp_header(so_far, "warning", warning) 27 | end) 28 | 29 | {:ok, parse_result} -> 30 | conn 31 | |> Map.put(:query_params, parse_result) 32 | |> Map.update!(:params, &Map.merge(&1, parse_result)) 33 | 34 | {:error, :odd_object, key, value} -> 35 | style = 36 | settings 37 | |> Map.fetch!(key) 38 | |> Map.get(:style, :form) 39 | |> style_description 40 | 41 | raise ParameterError, 42 | operation_id: conn.private.operation_id, 43 | in: :query, 44 | reason: 45 | "#{style} object parameter `#{value}` for parameter `#{key}` has an odd number of entries" 46 | 47 | {:error, :custom, property, payload} -> 48 | style_name = 49 | settings 50 | |> Map.fetch!(property) 51 | |> Map.fetch!(:style_name) 52 | 53 | raise ParameterError, 54 | ParameterError.custom_fields_from( 55 | conn.private.operation_id, 56 | :query, 57 | style_name, 58 | property, 59 | payload 60 | ) 61 | 62 | {:error, :misparse, misparse_location} -> 63 | raise ParameterError, 64 | operation_id: conn.private.operation_id, 65 | in: :query, 66 | misparsed: misparse_location 67 | end 68 | end 69 | 70 | def fetch_path_params!(conn, settings) do 71 | Map.new(conn.path_params, &fetch_kv(&1, conn.private.operation_id, :path, settings)) 72 | end 73 | 74 | # TODO: make this recursive 75 | 76 | def fetch_header_params!(conn, settings) do 77 | conn.req_headers 78 | |> Enum.filter(&is_map_key(settings, elem(&1, 0))) 79 | |> Map.new(&fetch_kv(&1, conn.private.operation_id, :header, settings)) 80 | end 81 | 82 | defp fetch_kv({key, value}, operation_id, where, settings) do 83 | key_settings = Map.fetch!(settings, key) 84 | 85 | style = Map.get(key_settings, :style, :simple) 86 | 87 | type = 88 | key_settings 89 | |> Map.get(:type) 90 | |> List.wrap() 91 | 92 | explode = Map.get(key_settings, :explode) 93 | 94 | with {:ok, parsed} <- Style.parse(value, key, style, type, explode), 95 | {:ok, marshalled} <- Marshal.marshal(parsed, key_settings, type) do 96 | {key, marshalled} 97 | else 98 | {:error, msg} -> 99 | raise ParameterError, 100 | operation_id: operation_id, 101 | in: where, 102 | reason: msg 103 | 104 | {:error, :custom, property, payload} -> 105 | style_name = 106 | settings 107 | |> Map.fetch!(key) 108 | |> Map.fetch!(:style_name) 109 | 110 | raise ParameterError, 111 | ParameterError.custom_fields_from( 112 | operation_id, 113 | where, 114 | style_name, 115 | property, 116 | payload 117 | ) 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /lib/apical/parser/marshal.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Parser.Marshal do 2 | @moduledoc false 3 | 4 | # this module contains functions for marshalling values based on settings 5 | # passed from `schema` fields of parameters that are textual. The 6 | # marshalling functions will respect JsonSchema to the point where it can. 7 | # This supports peeking into textual content and attempting to marshal 8 | # numbers, integers, booleans, and nulls. These actions will tunnel into 9 | # nested arrays and objects but not will not traverse JSON Schema's `oneOf`, 10 | # `anyOf`, `allOf`, `not` or conditional keywords. You may use these 11 | # keywords if your contents are string contents at the top level. 12 | 13 | def marshal(value, settings, _type) when is_list(value) do 14 | {:ok, array(value, settings)} 15 | end 16 | 17 | def marshal(value, settings, _type) when is_map(value) do 18 | {:ok, object(value, settings)} 19 | end 20 | 21 | def marshal(value, _, type) do 22 | {:ok, as_type(value, type)} 23 | end 24 | 25 | def array([""], _), do: [] 26 | 27 | def array(array, %{elements: {prefix_type, tail_type}}) do 28 | array_marshal(array, prefix_type, tail_type, []) 29 | end 30 | 31 | def array(array, _), do: array 32 | 33 | # fastlane this 34 | defp array_marshal(array, [], [:string], []), do: array 35 | 36 | defp array_marshal([], _, _, so_far), do: Enum.reverse(so_far) 37 | 38 | defp array_marshal([first | rest], [], tail_type, so_far) do 39 | array_marshal(rest, [], tail_type, [as_type(first, tail_type) | so_far]) 40 | end 41 | 42 | defp array_marshal([first | rest], [first_type | rest_type], tail_type, so_far) do 43 | array_marshal(rest, rest_type, tail_type, [as_type(first, first_type) | so_far]) 44 | end 45 | 46 | def object(object, %{properties: {property_types, pattern_types, additional_type}}) do 47 | object_marshal(object, {property_types, pattern_types, additional_type}) 48 | end 49 | 50 | def object(object, _), do: Map.new(object) 51 | 52 | # fastlane these 53 | defp object_marshal(object, {empty, empty, [:string]}) when empty === %{}, do: Map.new(object) 54 | 55 | defp object_marshal(object, {empty, empty, types}) when empty === %{} do 56 | Map.new(object, fn {k, v} -> {k, as_type(v, types)} end) 57 | end 58 | 59 | defp object_marshal(object, {property_types, pattern_types, additional_type}) do 60 | Map.new(object, fn {key, value} -> 61 | cond do 62 | types = Map.get(property_types, key) -> 63 | {key, as_type(value, types)} 64 | 65 | types = Enum.find_value(pattern_types, &(Regex.match?(elem(&1, 0), key) and elem(&1, 1))) -> 66 | {key, as_type(value, types)} 67 | 68 | true -> 69 | {key, as_type(value, additional_type)} 70 | end 71 | end) 72 | end 73 | 74 | def as_type("", [:null | _]), do: nil 75 | def as_type("null", [:null | _rest]), do: nil 76 | def as_type(string, [:null | rest]), do: as_type(string, rest) 77 | def as_type("true", [:boolean | _]), do: true 78 | def as_type("false", [:boolean | _]), do: false 79 | def as_type(string, [:boolean | rest]), do: as_type(string, rest) 80 | 81 | def as_type(string, [:integer, :number | rest]), 82 | do: as_type(string, [:number | rest]) 83 | 84 | def as_type(string, [:integer | rest]) do 85 | case Integer.parse(string) do 86 | {value, ""} -> value 87 | _ -> as_type(string, rest) 88 | end 89 | end 90 | 91 | def as_type(string, [:number | rest]) do 92 | case Integer.parse(string) do 93 | {value, ""} -> 94 | value 95 | 96 | _ -> 97 | case Float.parse(string) do 98 | {value, ""} -> value 99 | _ -> as_type(string, rest) 100 | end 101 | end 102 | end 103 | 104 | def as_type(string, _), do: string 105 | end 106 | -------------------------------------------------------------------------------- /lib/apical/parser/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Parser.Query do 2 | @moduledoc false 3 | 4 | # module which contains a comprehensive parser for the query string. The 5 | # parser built by this module is more powerful than what is provided with 6 | # `Plug.Conn.parse_query/1` and adheres to the query parsing demanded by 7 | # the OpenAPI spec. 8 | # 9 | # this module is dual purporsed, it is also used by the parser for cookie 10 | # parsing, as the cookie format is equivalent to the query format. 11 | 12 | require Pegasus 13 | import NimbleParsec 14 | 15 | alias Apical.Parser.Marshal 16 | 17 | Pegasus.parser_from_string( 18 | """ 19 | # guards 20 | empty <- "" 21 | ARRAY_GUARD <- empty 22 | FORM_GUARD <- empty 23 | SPACE_GUARD <- empty 24 | PIPE_GUARD <- empty 25 | OBJECT_GUARD <- empty 26 | RESERVED_GUARD <- empty 27 | 28 | # characters 29 | ALPHA <- [A-Za-z] 30 | DIGIT <- [0-9] 31 | HEXDIG <- [0-9A-Fa-f] 32 | sub_delims <- "!" / "$" / "'" / "(" / ")" / "*" / "+" / ";" 33 | equals <- "=" 34 | ampersand <- "&" 35 | comma <- "," 36 | space <- "%20" 37 | pipe <- "%7C" / "%7c" 38 | pct_encoded <- "%" HEXDIG HEXDIG 39 | open_br <- "[" 40 | close_br <- "]" 41 | 42 | # characters, from the spec 43 | reserved <- "/" / "?" / "[" / "]" / "!" / "$" / "'" / "(" / ")" / "*" / "+" / "," / ";" / "=" 44 | 45 | iunreserved <- ALPHA / DIGIT / "-" / "." / "_" / "~" / ucschar 46 | ipchar <- iunreserved / pct_encoded / sub_delims / ":" / "@" 47 | ipchar_ns <- !"%20" ipchar 48 | ipchar_np <- !("%7C" / "%7c") ipchar 49 | ipchar_rs <- ipchar / reserved 50 | 51 | # specialized array parsing 52 | form_array <- FORM_GUARD value? (comma value)* 53 | space_array <- SPACE_GUARD value_ns? (space value_ns)* 54 | pipe_array <- PIPE_GUARD value_np? (pipe value_np)* 55 | array_value <- ARRAY_GUARD (form_array / space_array / pipe_array) 56 | 57 | # specialized object parsing 58 | form_object <- FORM_GUARD ("null" / ((value comma value)? (comma value comma value)* (comma value odd_object_fail)?)) 59 | space_object <- SPACE_GUARD (value_ns space value_ns)? (comma value_ns space value_ns)* (space value_ns odd_object_fail)? 60 | pipe_object <- PIPE_GUARD (value_np pipe value_np)? (comma value_np pipe value_np)* (pipe value_np odd_object_fail)? 61 | 62 | object_value <- OBJECT_GUARD (form_object / space_object / pipe_object) 63 | 64 | value <- ipchar+ 65 | value_ns <- ipchar_ns+ 66 | value_np <- ipchar_np+ 67 | value_rs <- ipchar_rs+ 68 | 69 | key_part <- ipchar+ 70 | key_deep <- key_part open_br key_part close_br 71 | value_part <- equals (object_value / array_value / value_rs_part / value / "") 72 | value_rs_part <- RESERVED_GUARD value_rs+ 73 | 74 | basic_query <- key_part !"[" value_part? 75 | deep_object <- key_deep value_part 76 | 77 | query_part <- basic_query / deep_object 78 | query <- query_part? (ampersand query_part?)* 79 | """, 80 | # guards 81 | empty: [ignore: true], 82 | ARRAY_GUARD: [post_traverse: :array_guard], 83 | OBJECT_GUARD: [post_traverse: :object_guard], 84 | FORM_GUARD: [post_traverse: {:style_guard, [:form]}], 85 | SPACE_GUARD: [post_traverse: {:style_guard, [:space_delimited]}], 86 | PIPE_GUARD: [post_traverse: {:style_guard, [:pipe_delimited]}], 87 | RESERVED_GUARD: [post_traverse: :reserved_guard], 88 | # small characters 89 | equals: [ignore: true], 90 | ampersand: [ignore: true], 91 | comma: [ignore: true], 92 | space: [ignore: true], 93 | pipe: [ignore: true], 94 | open_br: [ignore: true], 95 | close_br: [ignore: true], 96 | pct_encoded: [post_traverse: :percent_decode], 97 | # array parsing 98 | form_array: [tag: :array, post_traverse: :finalize_array], 99 | space_array: [tag: :array, post_traverse: :finalize_array], 100 | pipe_array: [tag: :array, post_traverse: :finalize_array], 101 | # object parsing 102 | form_object: [tag: :object, post_traverse: :finalize_object], 103 | space_object: [tag: :object, post_traverse: :finalize_object], 104 | pipe_object: [tag: :object, post_traverse: :finalize_object], 105 | # general parsing stuff 106 | key_part: [collect: true, post_traverse: :store_key], 107 | value: [collect: true], 108 | value_ns: [collect: true], 109 | value_np: [collect: true], 110 | value_rs: [collect: true], 111 | # query collection 112 | basic_query: [post_traverse: :handle_query_part, tag: true], 113 | deep_object: [post_traverse: :handle_deep_object] 114 | ) 115 | 116 | # fastlane combinators. Note this supplants the bytewise combinator supplied in the RFC. 117 | defcombinatorp(:ucschar, utf8_char(not: 0..255)) 118 | defcombinatorp(:odd_object_fail, eventually(eos()) |> post_traverse(:odd_object_fail)) 119 | 120 | defparsecp(:parse_query, parsec(:query) |> eos()) 121 | 122 | def parse(string, context \\ %{}) 123 | 124 | def parse(string, context) do 125 | case parse_query(string, context: context) do 126 | {:ok, _, _, result = %{odd_object: true}, _, _} -> 127 | {:error, :odd_object, result.key, result.this} 128 | 129 | {:ok, result, "", context, _, _} -> 130 | query_parameters = 131 | result 132 | |> Map.new() 133 | |> merge_deep_objects(context) 134 | |> merge_exploded_arrays(context) 135 | 136 | case context do 137 | %{warnings: warnings} -> 138 | {:ok, query_parameters, warnings} 139 | 140 | _ -> 141 | {:ok, query_parameters} 142 | end 143 | 144 | {:error, "expected end of string", char, _, _, _} -> 145 | {:error, :misparse, char} 146 | end 147 | catch 148 | # TODO: assess if this can be done without a catch 149 | custom_error = {:error, :custom, _, _} -> custom_error 150 | end 151 | 152 | defp merge_deep_objects(collected_parameters, context = %{deep_objects: objects}) do 153 | Enum.reduce(objects, collected_parameters, fn {key, value}, acc -> 154 | object = Marshal.object(value, Map.get(context, key)) 155 | Map.put(acc, key, object) 156 | end) 157 | end 158 | 159 | defp merge_deep_objects(parameters, _), do: parameters 160 | 161 | defp merge_exploded_arrays(collected_parameters, context = %{exploded_arrays: arrays}) do 162 | Enum.reduce(arrays, collected_parameters, fn {key, value}, acc -> 163 | array = 164 | value 165 | |> Enum.reverse() 166 | |> Marshal.array(Map.get(context, key)) 167 | 168 | Map.put(acc, key, array) 169 | end) 170 | end 171 | 172 | defp merge_exploded_arrays(parameters, _), do: parameters 173 | 174 | # UTILITY GUARDS 175 | 176 | defguardp context_key_type(context, key) 177 | when :erlang.map_get(:type, :erlang.map_get(key, context)) 178 | 179 | defguardp context_key_style(context) 180 | when :erlang.map_get(:style, :erlang.map_get(:erlang.map_get(:key, context), context)) 181 | 182 | defguardp is_context_value_reserved(context) 183 | when :erlang.map_get( 184 | :allow_reserved, 185 | :erlang.map_get(:erlang.map_get(:key, context), context) 186 | ) === true 187 | 188 | defp handle_query_part(rest_str, [{:basic_query, [key]} | rest], context, _line, _offset) 189 | when is_map_key(context, key) do 190 | value = 191 | context 192 | |> Map.get(key) 193 | |> parse_key_only 194 | 195 | {rest_str, [{key, value} | rest], context} 196 | end 197 | 198 | defp handle_query_part(rest_str, [{:basic_query, [key, value]} | rest], context, _line, _offset) 199 | when is_map_key(context, key) do 200 | case context do 201 | %{exploded_array_keys: exploded} -> 202 | if key in exploded do 203 | new_list = 204 | context 205 | |> get_in([:exploded_arrays, key]) 206 | |> List.wrap() 207 | |> List.insert_at(0, parse_kv(value, key, Map.get(context, key))) 208 | 209 | new_exploded_arrays = 210 | context 211 | |> Map.get(:exploded_arrays, %{}) 212 | |> Map.put(key, new_list) 213 | 214 | {rest_str, rest, Map.put(context, :exploded_arrays, new_exploded_arrays)} 215 | else 216 | {rest_str, [{key, parse_kv(value, key, Map.get(context, key))} | rest], context} 217 | end 218 | 219 | _ -> 220 | {rest_str, [{key, parse_kv(value, key, Map.get(context, key))} | rest], context} 221 | end 222 | end 223 | 224 | defp handle_query_part(rest_str, [{:basic_query, [key | _]} | rest], context, _line, _offset) do 225 | # ignore it if it's not specified, but do post a warning. 226 | message = "299 - the key `#{key}` is not specified in the schema" 227 | {rest_str, rest, Map.update(context, :warnings, [message], &[message | &1])} 228 | end 229 | 230 | defp handle_deep_object(rest_str, [value, value_key, key | rest], context, _line, _offset) do 231 | if key in context.deep_object_keys do 232 | {rest_str, rest, 233 | Map.update( 234 | context, 235 | :deep_objects, 236 | %{key => %{value_key => value}}, 237 | &put_in(&1, [key, value_key], value) 238 | )} 239 | else 240 | {:error, []} 241 | end 242 | end 243 | 244 | defp parse_key_only(kv_spec) do 245 | case kv_spec do 246 | %{type: [:null | _]} -> nil 247 | %{type: [:boolean | _]} -> true 248 | _ -> "" 249 | end 250 | end 251 | 252 | defp parse_kv(string, property, kv_spec) do 253 | case kv_spec do 254 | %{style: {module, fun, args}} -> 255 | case apply(module, fun, [string | args]) do 256 | {:ok, result} -> 257 | result 258 | 259 | {:error, message} -> 260 | throw({:error, :custom, property, message}) 261 | end 262 | 263 | %{type: types} -> 264 | Marshal.as_type(string, types) 265 | 266 | _ -> 267 | string 268 | end 269 | end 270 | 271 | defp percent_decode(rest_str, [a, b, "%" | rest], context, _line, _offset) do 272 | {rest_str, [List.to_integer([b, a], 16) | rest], context} 273 | end 274 | 275 | defp store_key(rest_str, [key | rest], context, _line, _offset) do 276 | {rest_str, [key | rest], Map.put(context, :key, key)} 277 | end 278 | 279 | for type <- [:object, :array] do 280 | defp unquote(:"#{type}_guard")(rest_str, list, context = %{key: key}, _, _) 281 | when is_map_key(context, key) and 282 | context_key_type(context, key) in [[unquote(type)], [:null, unquote(type)]] do 283 | value = rest_str |> String.split("&", parts: 2) |> List.first() 284 | {rest_str, list, Map.put(context, :this, value)} 285 | end 286 | 287 | defp unquote(:"#{type}_guard")(_, _, _, _, _) do 288 | {:error, []} 289 | end 290 | end 291 | 292 | defp style_guard(rest_str, list, context, _, _, style) 293 | when context_key_style(context) == style do 294 | {rest_str, list, context} 295 | end 296 | 297 | defp style_guard(_rest_str, _list, _context, _, _, _), do: {:error, []} 298 | 299 | defp reserved_guard(rest_str, list, context, _, _) when is_context_value_reserved(context) do 300 | {rest_str, list, context} 301 | end 302 | 303 | defp reserved_guard(_rest_str, _list, _context, _, _) do 304 | {:error, []} 305 | end 306 | 307 | defp finalize_array(rest_str, [{:array, list} | rest], context = %{key: key}, _, _) do 308 | {rest_str, [Marshal.array(list, Map.get(context, key)) | rest], 309 | Map.drop(context, [:key, :this])} 310 | end 311 | 312 | defp finalize_object(_, _, context = %{odd_object: true}, _, _) do 313 | {"", [], context} 314 | end 315 | 316 | defp finalize_object(rest_str, [{:object, object_list} | rest], context = %{key: key}, _, _) do 317 | settings = Map.fetch!(context, key) 318 | 319 | case {object_list, settings} do 320 | {["null"], %{type: [:null, :object], style: :form}} -> 321 | {rest_str, [nil | rest], Map.drop(context, [:key, :this])} 322 | 323 | _ -> 324 | marshalled_object = 325 | object_list 326 | |> to_pairs 327 | |> Marshal.object(settings) 328 | 329 | {rest_str, [marshalled_object | rest], Map.drop(context, [:key, :this])} 330 | end 331 | end 332 | 333 | defp to_pairs(object_list, so_far \\ []) 334 | 335 | defp to_pairs([a, b | rest], so_far) do 336 | to_pairs(rest, [{a, b} | so_far]) 337 | end 338 | 339 | defp to_pairs([], so_far), do: so_far 340 | 341 | defp odd_object_fail(_, _, context, _, _) do 342 | {[], [], Map.put(context, :odd_object, true)} 343 | end 344 | end 345 | -------------------------------------------------------------------------------- /lib/apical/parser/style.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Parser.Style do 2 | @moduledoc false 3 | 4 | def parse(value, key, :simple, type, explode) do 5 | cond do 6 | :array in type -> 7 | has_null? = :null in type 8 | 9 | case value do 10 | "" -> {:ok, []} 11 | "null" when has_null? -> {:ok, nil} 12 | _ -> {:ok, String.split(value, ",")} 13 | end 14 | 15 | :object in type -> 16 | has_null? = :null in type 17 | 18 | case value do 19 | "" -> 20 | {:ok, %{}} 21 | 22 | "null" when has_null? -> 23 | {:ok, nil} 24 | 25 | _ -> 26 | value 27 | |> String.split(",") 28 | |> collect(explode) 29 | |> case do 30 | ok = {:ok, _} -> 31 | ok 32 | 33 | {:error, :odd} -> 34 | {:error, 35 | "comma delimited object parameter `#{value}` for parameter `#{key}` has an odd number of entries"} 36 | 37 | {:error, item} when is_binary(item) -> 38 | {:error, 39 | "comma delimited object parameter `#{value}` for parameter `#{key}` has a malformed entry: `#{item}`"} 40 | end 41 | end 42 | 43 | true -> 44 | {:ok, value} 45 | end 46 | end 47 | 48 | def parse("." <> value, key, :label, type, explode) do 49 | parsed = 50 | cond do 51 | :array in type -> 52 | {:ok, String.split(value, ".")} 53 | 54 | explode && :object in type -> 55 | value 56 | |> String.split(".") 57 | |> collect(explode) 58 | 59 | :object in type -> 60 | value 61 | |> String.split(".") 62 | |> into_map(%{}) 63 | 64 | String.contains?(value, ".") -> 65 | {:error, 66 | "label object parameter `.#{value}` for parameter `#{key}` has multiple entries for a primitive type"} 67 | 68 | true -> 69 | {:ok, value} 70 | end 71 | 72 | case parsed do 73 | ok = {:ok, _} -> 74 | ok 75 | 76 | {:error, :odd} -> 77 | {:error, 78 | "label object parameter `.#{value}` for parameter `#{key}` has an odd number of entries"} 79 | 80 | {:error, item} when is_binary(item) -> 81 | {:error, 82 | "label object parameter `.#{value}` for parameter `#{key}` has a malformed entry: `#{item}`"} 83 | end 84 | end 85 | 86 | def parse(value, key, :label, _, _) do 87 | {:error, 88 | "label style `#{value}` for parameter `#{key}` is missing a leading dot, use format: `.value1.value2.value3...`"} 89 | end 90 | 91 | def parse(";" <> value, key, :matrix, type, explode) do 92 | split = 93 | value 94 | |> String.split(";") 95 | |> Enum.map(fn 96 | part -> 97 | case String.split(part, "=") do 98 | [subkey] -> 99 | {subkey, []} 100 | 101 | [subkey, subvalue] -> 102 | {subkey, String.split(subvalue, ",")} 103 | # TODO: error when something strange happens 104 | end 105 | end) 106 | 107 | cond do 108 | :null in type and value === key -> 109 | {:ok, nil} 110 | 111 | :array in type -> 112 | if match?([{_, [""]}], split) do 113 | {:ok, []} 114 | else 115 | matrix_array_parse(split, key, explode) 116 | end 117 | 118 | :object in type -> 119 | if match?([{_, [""]}], split) do 120 | {:ok, %{}} 121 | else 122 | case matrix_object_parse(split, key, explode) do 123 | ok = {:ok, _} -> 124 | ok 125 | 126 | {:error, :odd} -> 127 | {:error, 128 | "matrix object parameter `;#{value}` for parameter `#{key}` has an odd number of entries"} 129 | 130 | error = {:error, _} -> 131 | error 132 | end 133 | end 134 | 135 | value === key -> 136 | {:ok, if(:boolean in type, do: "true", else: "")} 137 | 138 | String.contains?(value, ";") -> 139 | {:error, 140 | "matrix object parameter `.#{value}` for parameter `#{key}` has multiple entries for a primitive type"} 141 | 142 | String.starts_with?(value, "#{key}=") -> 143 | {:ok, String.replace_leading(value, "#{key}=", "")} 144 | 145 | true -> 146 | {:error, 147 | "matrix style `#{value}` for parameter `#{key}` is malformed, use format: `;#{key}=...`"} 148 | end 149 | end 150 | 151 | def parse(value, key, :matrix, _, _explode) do 152 | {:error, 153 | "matrix style `#{value}` for parameter `#{key}` is missing a leading semicolon, use format: `;#{key}=...`"} 154 | end 155 | 156 | def parse(value, key, {m, f, a}, _, _explode) do 157 | result = apply(m, f, [value | a]) 158 | 159 | case result do 160 | ok = {:ok, _} -> 161 | ok 162 | 163 | {:error, msg} -> 164 | {:error, :custom, key, msg} 165 | end 166 | end 167 | 168 | def parse(value, _, _, _, _?), do: {:ok, value} 169 | 170 | defp matrix_array_parse(split, key, explode) do 171 | if explode do 172 | split 173 | |> Enum.reduce_while({:ok, []}, fn 174 | {^key, [v]}, {:ok, acc} -> 175 | {:cont, {:ok, [v | acc]}} 176 | 177 | {other, _}, _ -> 178 | {:halt, 179 | {:error, 180 | "matrix key `#{other}` provided for array named `#{key}`, use format: `;#{key}=...;#{key}=...`"}} 181 | end) 182 | |> case do 183 | {:ok, arr} -> {:ok, Enum.reverse(arr)} 184 | error -> error 185 | end 186 | else 187 | case split do 188 | [{^key, v}] -> 189 | {:ok, v} 190 | 191 | [{other, _}] -> 192 | {:error, 193 | "matrix key `#{other}` provided for array named `#{key}`, use format: `;#{key}=...`"} 194 | end 195 | end 196 | end 197 | 198 | defp matrix_object_parse(parsed, key, explode) do 199 | if explode do 200 | {:ok, Map.new(parsed, fn {key, vals} -> {key, Enum.join(vals, ",")} end)} 201 | else 202 | case parsed do 203 | [{^key, values}] -> 204 | into_map(values, %{}) 205 | 206 | [{other, _}] -> 207 | {:error, 208 | "matrix key `#{other}` provided for array named `#{key}`, use format: `;#{key}=...`"} 209 | end 210 | end 211 | end 212 | 213 | defp collect(parts, true) do 214 | Enum.reduce_while(parts, {:ok, %{}}, fn 215 | part, {:ok, so_far} -> 216 | case String.split(part, "=") do 217 | [key, val] -> 218 | {:cont, {:ok, Map.put(so_far, key, val)}} 219 | 220 | [key] -> 221 | {:cont, {:ok, Map.put(so_far, key, "")}} 222 | 223 | _ -> 224 | {:halt, {:error, part}} 225 | end 226 | end) 227 | end 228 | 229 | defp collect(parts, _explode?), do: into_map(parts, %{}) 230 | 231 | defp into_map([k, v | rest], so_far), do: into_map(rest, Map.put(so_far, k, v)) 232 | defp into_map([], so_far), do: {:ok, so_far} 233 | defp into_map(_, _), do: {:error, :odd} 234 | end 235 | -------------------------------------------------------------------------------- /lib/apical/path.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Path do 2 | @moduledoc false 3 | 4 | # module which handles the `pathsObject` and `pathItemObject` from the 5 | # OpenAPI spec. 6 | # 7 | # note: only the `pathsObject` may be substituted with a `$ref` pointer. 8 | # 9 | # see: https://spec.openapis.org/oas/v3.1.0#paths-object 10 | 11 | alias Apical.Plugs.Parameter 12 | alias Apical.Plugs.RequestBody 13 | alias Apical.Tools 14 | 15 | @routes {[], MapSet.new()} 16 | 17 | # to make passing into the adapter easier we're going to make the parameters 18 | # a struct. 19 | @enforce_keys ~w(parameter_validators body_validators extra_plugs version 20 | operation_id parameter_plugs body_plugs root verb canonical_path controller 21 | function)a 22 | defstruct @enforce_keys 23 | 24 | def to_plug_routes(_pointer, path, %{"$ref" => ref}, schema, opts) do 25 | # for now, don't handle the remote ref scenario, or the id scenario. 26 | new_pointer = JsonPtr.from_uri(ref) 27 | subschema = JsonPtr.resolve_json!(schema, new_pointer) 28 | to_plug_routes(new_pointer, path, subschema, schema, opts) 29 | end 30 | 31 | def to_plug_routes(pointer, path, _subschema, schema, opts) do 32 | # each route contains a map of verbs to operations. 33 | # map over that content to generate routes. 34 | JsonPtr.reduce(pointer, schema, @routes, &to_plug_route(&1, &2, &3, &4, schema, path, opts)) 35 | end 36 | 37 | @verb_mapping Map.new(~w(get put post delete options head patch trace)a, &{"#{&1}", &1}) 38 | @verbs Map.keys(@verb_mapping) 39 | 40 | defp to_plug_route( 41 | pointer, 42 | verb, 43 | operation, 44 | {routes_so_far, operation_ids_so_far}, 45 | schema, 46 | path, 47 | opts 48 | ) 49 | when verb in @verbs do 50 | Tools.assert( 51 | Map.has_key?(operation, "operationId"), 52 | "that all operations have an operationId: (missing for operation at `#{JsonPtr.to_path(pointer)}`)" 53 | ) 54 | 55 | operation_id = Map.fetch!(operation, "operationId") 56 | 57 | {canonical_path, path_parameters} = 58 | case parse_path(path, context: %{path_parameters: []}) do 59 | {:ok, canonical, "", context, _, _} -> 60 | {"#{canonical}", context.path_parameters} 61 | 62 | _ -> 63 | raise CompileError, description: "path #{path} is not a valid path template" 64 | end 65 | 66 | verb = Map.fetch!(@verb_mapping, verb) 67 | 68 | tags = Map.get(operation, "tags", []) 69 | 70 | opts = 71 | opts 72 | |> fold_tag_opts(tags) 73 | |> fold_group_opts(operation_id) 74 | |> fold_operation_id_opts(operation_id) 75 | 76 | version = Keyword.fetch!(opts, :version) 77 | root = Keyword.fetch!(opts, :root) 78 | 79 | plug_opts = 80 | opts 81 | |> Keyword.take( 82 | ~w(styles parameters nest_all_json content_sources version resource dump dump_validator)a 83 | ) 84 | |> Keyword.merge(path_parameters: path_parameters, path: path) 85 | 86 | {controller, function} = 87 | case Keyword.fetch(opts, :controller) do 88 | {:ok, controller} when is_atom(controller) -> 89 | case Keyword.get(opts, :alias) do 90 | nil -> 91 | case String.split(operation_id, ".", trim: true) do 92 | [operation_id] -> {controller, String.to_atom(operation_id)} 93 | parts -> 94 | {alias_fun, module_parts} = List.pop_at(parts, -1) 95 | 96 | {Module.concat([controller] ++ module_parts), String.to_atom(alias_fun)} 97 | end 98 | 99 | alias_fun -> 100 | {controller, alias_fun} 101 | end 102 | 103 | {:ok, controller} -> 104 | raise CompileError, 105 | description: 106 | "invalid controller for operation #{operation_id}, got #{inspect(controller)} (expected a module atom)" 107 | 108 | :error -> 109 | raise CompileError, description: "can't find controller for operation #{operation_id}" 110 | end 111 | 112 | extra_plugs = 113 | opts 114 | |> Keyword.get(:extra_plugs, []) 115 | |> Enum.map(fn 116 | {plug, plug_opts} -> 117 | quote do 118 | plug(unquote(plug), unquote(plug_opts)) 119 | end 120 | 121 | plug -> 122 | quote do 123 | plug(unquote(plug)) 124 | end 125 | end) 126 | 127 | {parameter_plugs, parameter_validators} = 128 | Parameter.make(pointer, schema, operation_id, plug_opts) 129 | 130 | {body_plugs, body_validators} = RequestBody.make(pointer, schema, operation_id, plug_opts) 131 | 132 | framework = opts |> Keyword.get(:for, Phoenix) |> Macro.expand(%Macro.Env{}) 133 | adapter = Module.concat(Apical.Adapters, framework) 134 | 135 | route = 136 | __MODULE__ 137 | |> struct(Keyword.take(binding(), @enforce_keys)) 138 | |> adapter.build_path() 139 | 140 | {[route | routes_so_far], MapSet.put(operation_ids_so_far, operation_id)} 141 | end 142 | 143 | @folded_opts ~w(controller styles parameters extra_plugs nest_all_json content_sources dump dump_validator)a 144 | 145 | defp fold_tag_opts(opts, tags) do 146 | # NB it's totallgroup_opts(operation_id)y okay if this process is unoptimized since it 147 | # should be running at compile time. 148 | tags 149 | |> Enum.reverse() 150 | |> Enum.reduce(opts, &merge_opts(&2, &1, :tags)) 151 | end 152 | 153 | defp fold_group_opts(opts, operation_id) do 154 | group_opts = 155 | opts 156 | |> Keyword.get(:groups, []) 157 | |> Enum.find_value(fn group_spec -> 158 | {ids, opts} = Enum.split_while(group_spec, &is_atom/1) 159 | 160 | if Enum.any?(ids, &(Atom.to_string(&1) == operation_id)) do 161 | opts 162 | end 163 | end) 164 | |> List.wrap() 165 | 166 | Tools.deepmerge(opts, group_opts) 167 | end 168 | 169 | defp fold_operation_id_opts(opts, operation_id), 170 | do: merge_opts(opts, operation_id, :operation_ids) 171 | 172 | defp merge_opts(opts, key, class) do 173 | opts_to_fold = 174 | case class do 175 | :operation_ids -> [:alias | @folded_opts] 176 | _ -> @folded_opts 177 | end 178 | 179 | merge_opts = 180 | opts 181 | |> Keyword.get(class, []) 182 | |> Enum.find_value(&if Atom.to_string(elem(&1, 0)) == key, do: elem(&1, 1)) 183 | |> List.wrap() 184 | |> Keyword.take(opts_to_fold) 185 | 186 | Tools.deepmerge(opts, merge_opts) 187 | end 188 | 189 | require Pegasus 190 | import NimbleParsec 191 | 192 | Pegasus.parser_from_string( 193 | """ 194 | # see https://datatracker.ietf.org/doc/html/rfc6570#section-2.1 195 | 196 | ALPHA <- [A-Za-z] 197 | DIGIT <- [0-9] 198 | HEXDIG <- DIGIT / [A-Fa-f] 199 | pct_encoded <- '%' HEXDIG HEXDIG 200 | 201 | # see https://datatracker.ietf.org/doc/html/rfc6570#section-2.1 202 | 203 | literal <- (ascii_literals / ucschar / pct_encoded)+ 204 | 205 | # expressions must be identifiers 206 | identifiers <- [A-Za-z_] [A-Za-z0-9_]* 207 | 208 | LBRACKET <- "{" 209 | RBRACKET <- "}" 210 | expression <- LBRACKET identifiers+ RBRACKET 211 | 212 | parse_path <- (expression / literal)+ eof 213 | eof <- !. 214 | """, 215 | LBRACKET: [ignore: true], 216 | RBRACKET: [ignore: true], 217 | expression: [post_traverse: :to_colon_form], 218 | parse_path: [parser: true] 219 | ) 220 | 221 | defcombinatorp(:unreserved_extra, ascii_char(~C'-._~')) 222 | 223 | defcombinatorp( 224 | :ucschar, 225 | utf8_char([ 226 | 0xA0..0xD7FF, 227 | 0xF900..0xFDCF, 228 | 0xFDF0..0xFFEF, 229 | 0x10000..0x1FFFD, 230 | 0x20000..0x2FFFD, 231 | 0x30000..0x3FFFD, 232 | 0x40000..0x4FFFD, 233 | 0x50000..0x5FFFD, 234 | 0x60000..0x6FFFD, 235 | 0x70000..0x7FFFD, 236 | 0x80000..0x8FFFD, 237 | 0x90000..0x9FFFD, 238 | 0xA0000..0xAFFFD, 239 | 0xB0000..0xBFFFD, 240 | 0xC0000..0xCFFFD, 241 | 0xD0000..0xDFFFD, 242 | 0xE1000..0xEFFFD 243 | ]) 244 | ) 245 | 246 | defcombinatorp( 247 | :ascii_literals, 248 | ascii_char([ 249 | 0x21, 250 | 0x23, 251 | 0x24, 252 | 0x26, 253 | 0x28..0x3B, 254 | 0x3D, 255 | 0x3F..0x5B, 256 | 0x5D, 257 | 0x5F, 258 | 0x61..0x7A, 259 | 0x7E 260 | ]) 261 | ) 262 | 263 | defp to_colon_form(rest, var, context, _line, _offset) do 264 | parameter = 265 | var 266 | |> Enum.reverse() 267 | |> IO.iodata_to_binary() 268 | 269 | {rest, var ++ ~C':', Map.update!(context, :path_parameters, &[parameter | &1])} 270 | end 271 | end 272 | -------------------------------------------------------------------------------- /lib/apical/plug/controller.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plug.Controller do 2 | defmacro __using__(_) do 3 | quote do 4 | @behaviour Plug 5 | 6 | @impl Plug 7 | def init(function), do: [function] 8 | 9 | @impl Plug 10 | def call(conn, [function]) do 11 | __MODULE__ 12 | |> apply(function, [conn, conn.params]) 13 | |> Plug.Conn.halt() 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/apical/plug/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plug.Router do 2 | @moduledoc """ 3 | boilerplate code setting up a router using the `Plug` 4 | framework *without* using `Phoenix`. Note that although 5 | this reduces the needed dependencies, this doesn't provide 6 | you with some Phoenix affordances such as route helpers. 7 | """ 8 | 9 | defmacro __using__(opts) do 10 | quote do 11 | import Apical.Plug.Router, only: [match: 2] 12 | 13 | @before_compile unquote(__MODULE__) 14 | 15 | # This MUST come after @before_compile, or else the list of 16 | # operations won't be done. 17 | use Plug.Builder, unquote(opts) 18 | end 19 | end 20 | 21 | @doc """ 22 | returns a 404 error since none of the routes have matched 23 | """ 24 | def match(conn, _opts) do 25 | Plug.Conn.send_resp(conn, 404, "not found") 26 | end 27 | 28 | defmacro __before_compile__(env) do 29 | env.module 30 | |> Module.get_attribute(:operations) 31 | |> List.insert_at(0, :match) 32 | |> Enum.reverse() 33 | |> Enum.map(fn 34 | {:operation, operation_module} -> 35 | quote do 36 | plug(Module.concat(__MODULE__, unquote(operation_module))) 37 | end 38 | 39 | plug -> 40 | quote do 41 | plug(unquote(plug)) 42 | end 43 | end) 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/apical/plugs/cookie.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.Cookie do 2 | @moduledoc """ 3 | `Plug` module for parsing cookie parameters and placing them into params. 4 | 5 | ### init options 6 | 7 | the plug initialization options are as follows: 8 | 9 | `[router_module, operation_id, parameters, plug_opts]` 10 | 11 | The router module is passed itself, the operation_id (as an atom), 12 | a list of parameters maps from the OpenAPI schema, one for each cookie 13 | parameter, and the plug_opts keyword list as elucidated by the router 14 | compiler. Initialization will compile an optimized `operations` object 15 | which is used to parse cookie parameters from the request. 16 | 17 | ### conn output 18 | 19 | The `conn` struct after calling this plug will have cookie parameters 20 | declared in the OpenAPI schema placed into the `params` map. Cookie 21 | parameters not declared in the OpenAPI schema are allowed. 22 | """ 23 | 24 | alias Apical.Parser.Query 25 | alias Apical.Plugs.Parameter 26 | alias Apical.Exceptions.ParameterError 27 | 28 | @behaviour Parameter 29 | @behaviour Plug 30 | 31 | @impl Plug 32 | def init(opts) do 33 | Parameter.init([__MODULE__ | opts]) 34 | end 35 | 36 | @impl Plug 37 | def call(conn, operations = %{parser_context: parser_context}) do 38 | params = 39 | with {_, value} <- List.keyfind(conn.req_headers, "cookie", 0) do 40 | case Query.parse(value, parser_context) do 41 | {:ok, result} -> 42 | result 43 | 44 | {:ok, result, _} -> 45 | result 46 | 47 | {:error, :odd_object, key, value} -> 48 | raise ParameterError, 49 | operation_id: conn.private.operation_id, 50 | in: :cookie, 51 | reason: 52 | "form object parameter `#{value}` for parameter `#{key}` has an odd number of entries" 53 | 54 | {:error, :custom, key, payload} -> 55 | style_name = 56 | parser_context 57 | |> Map.fetch!(key) 58 | |> Map.fetch!(:style_name) 59 | 60 | raise ParameterError, 61 | ParameterError.custom_fields_from( 62 | conn.private.operation_id, 63 | :cookie, 64 | style_name, 65 | key, 66 | payload 67 | ) 68 | end 69 | else 70 | nil -> %{} 71 | end 72 | 73 | # TODO: make this recursive 74 | 75 | conn 76 | |> Parameter.check_required(params, :cookie, operations) 77 | |> Map.update!(:params, &Map.merge(&1, params)) 78 | |> Parameter.warn_deprecated(:cookie, operations) 79 | |> Parameter.custom_marshal(:cookie, operations) 80 | |> Parameter.validate(:cookie, operations) 81 | end 82 | 83 | @impl Apical.Plugs.Parameter 84 | def name, do: :cookie 85 | 86 | @impl Apical.Plugs.Parameter 87 | def default_style, do: "form" 88 | 89 | @impl Apical.Plugs.Parameter 90 | def style_allowed?(style), do: style === "form" 91 | end 92 | -------------------------------------------------------------------------------- /lib/apical/plugs/header.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.Header do 2 | @moduledoc """ 3 | `Plug` module for parsing header parameters and placing them into params. 4 | 5 | ### init options 6 | 7 | the plug initialization options are as follows: 8 | 9 | `[router_module, operation_id, parameters, plug_opts]` 10 | 11 | The router module is passed itself, the operation_id (as an atom), 12 | a list of parameters maps from the OpenAPI schema, one for each cookie 13 | parameter, and the plug_opts keyword list as elucidated by the router 14 | compiler. Initialization will compile an optimized `operations` object 15 | which is used to parse header parameters from the request. 16 | 17 | ### conn output 18 | 19 | The `conn` struct after calling this plug will have header parameters 20 | declared in the OpenAPI schema placed into the `params` map. Header 21 | parameters not declared in the OpenAPI schema are allowed. 22 | """ 23 | 24 | alias Apical.Plugs.Parameter 25 | 26 | @behaviour Parameter 27 | @behaviour Plug 28 | 29 | @impl Plug 30 | def init(opts) do 31 | Parameter.init([__MODULE__ | opts]) 32 | end 33 | 34 | @impl Plug 35 | def call(conn, operations) do 36 | params = Apical.Conn.fetch_header_params!(conn, operations.parser_context) 37 | 38 | conn 39 | |> Parameter.check_required(params, :header, operations) 40 | |> Map.update!(:params, &Map.merge(&1, params)) 41 | |> Parameter.warn_deprecated(:header, operations) 42 | |> Parameter.custom_marshal(:header, operations) 43 | |> Parameter.validate(:header, operations) 44 | end 45 | 46 | @impl Apical.Plugs.Parameter 47 | def name, do: :header 48 | 49 | @impl Apical.Plugs.Parameter 50 | def default_style, do: "simple" 51 | 52 | @impl Apical.Plugs.Parameter 53 | def style_allowed?(style), do: style === "simple" 54 | end 55 | -------------------------------------------------------------------------------- /lib/apical/plugs/path.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.Path do 2 | @moduledoc """ 3 | `Plug` module for parsing path parameters and placing them into params. 4 | 5 | ### init options 6 | 7 | the plug initialization options are as follows: 8 | 9 | `[router_module, operation_id, parameters, plug_opts]` 10 | 11 | The router module is passed itself, the operation_id (as an atom), 12 | a list of parameters maps from the OpenAPI schema, one for each cookie 13 | parameter, and the plug_opts keyword list as elucidated by the router 14 | compiler. Initialization will compile an optimized `operations` object 15 | which is used to parse path parameters from the request. 16 | 17 | ### conn output 18 | 19 | The `conn` struct after calling this plug will have path parameters 20 | declared in the OpenAPI schema placed into the `params` map. 21 | 22 | > ### Important {: .warning} 23 | > 24 | > As part of the OpenAPI spec, path parameters must be declared in the 25 | > path key under the `paths` field of the schema. 26 | """ 27 | 28 | alias Apical.Tools 29 | alias Apical.Plugs.Parameter 30 | 31 | @behaviour Plug 32 | @behaviour Parameter 33 | 34 | @impl Plug 35 | def init(opts = [_module, operation_id, parameters, plug_opts]) do 36 | Enum.each(parameters, fn parameter = %{"name" => name} -> 37 | Tools.assert( 38 | parameter["required"], 39 | "for parameter `#{name}` in operation `#{operation_id}`: path parameters must be `required: true`" 40 | ) 41 | 42 | path_parameters = Keyword.get(plug_opts, :path_parameters, []) 43 | path = Keyword.fetch!(plug_opts, :path) 44 | 45 | Tools.assert( 46 | name in path_parameters, 47 | "that the parameter `#{name}` in operation `#{operation_id}` exists as a match in its path definition: (got: `#{path}`)" 48 | ) 49 | end) 50 | 51 | Parameter.init([__MODULE__ | opts]) 52 | end 53 | 54 | @impl Plug 55 | def call(conn, operations) do 56 | params = Apical.Conn.fetch_path_params!(conn, operations.parser_context) 57 | 58 | conn 59 | |> Map.update!(:params, &Map.merge(&1, params)) 60 | |> Parameter.warn_deprecated(:path, operations) 61 | |> Parameter.custom_marshal(:path, operations) 62 | |> Parameter.validate(:path, operations) 63 | end 64 | 65 | @impl Apical.Plugs.Parameter 66 | def name, do: :path 67 | 68 | @impl Apical.Plugs.Parameter 69 | def default_style, do: "simple" 70 | 71 | @impl Apical.Plugs.Parameter 72 | def style_allowed?(style), do: style in ~w(matrix label simple) 73 | end 74 | -------------------------------------------------------------------------------- /lib/apical/plugs/query.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.Query do 2 | @moduledoc """ 3 | `Plug` module for parsing query parameters and placing them into params. 4 | 5 | ### init options 6 | 7 | the plug initialization options are as follows: 8 | 9 | `[router_module, operation_id, parameters, plug_opts]` 10 | 11 | The router module is passed itself, the operation_id (as an atom), 12 | a list of parameters maps from the OpenAPI schema, one for each cookie 13 | parameter, and the plug_opts keyword list as elucidated by the router 14 | compiler. Initialization will compile an optimized `operations` object 15 | which is used to parse query parameters from the request. 16 | 17 | ### conn output 18 | 19 | The `conn` struct after calling this plug will have query parameters 20 | declared in the OpenAPI schema placed into the `params` map. 21 | 22 | > ### Important {: .warning} 23 | > 24 | > If the client produces a query parameter that is not a part of the 25 | > OpenAPI schema, the request will fail with a 400 error. 26 | """ 27 | 28 | alias Apical.Plugs.Parameter 29 | 30 | @behaviour Parameter 31 | @behaviour Plug 32 | 33 | @impl Plug 34 | def init(opts) do 35 | Parameter.init([__MODULE__ | opts]) 36 | end 37 | 38 | @impl Plug 39 | def call(conn, operations) do 40 | conn = Apical.Conn.fetch_query_params!(conn, operations.parser_context) 41 | 42 | conn 43 | |> Parameter.check_required(conn.query_params, :query, operations) 44 | |> Map.update!(:params, &Map.merge(&1, conn.query_params)) 45 | |> Parameter.warn_deprecated(:query, operations) 46 | |> Parameter.custom_marshal(:query, operations) 47 | |> Parameter.validate(:query, operations) 48 | end 49 | 50 | @impl Apical.Plugs.Parameter 51 | def name, do: :query 52 | 53 | @impl Apical.Plugs.Parameter 54 | def default_style, do: "form" 55 | 56 | @impl Apical.Plugs.Parameter 57 | def style_allowed?(style), do: style in ~w(form spaceDelimited pipeDelimited deepObject) 58 | end 59 | -------------------------------------------------------------------------------- /lib/apical/plugs/request_body/_source.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.RequestBody.Source do 2 | @moduledoc """ 3 | Behaviour for adapters that process request bodies and alter the conn. 4 | """ 5 | 6 | alias Plug.Conn 7 | 8 | @typedoc """ 9 | Type for a function that encapsulates the logic for validating a request body. 10 | 11 | The function should return `:ok` if the body is valid, or `{:error, keyword}` 12 | 13 | In the generic case, keyword should contain the key `:message` which determines 14 | what the request body error message will be. 15 | 16 | For more specific cases, see the documentation for `Exonerate` which describes 17 | the fields available. 18 | 19 | The default validator (if no validation is to be performed) will return `:ok` 20 | on any input. 21 | """ 22 | @type validator :: nil | {module, atom} | {module, atom, keyword} 23 | 24 | @doc """ 25 | """ 26 | @callback fetch(Conn.t(), validator, opts :: keyword) :: {:ok, Conn.t()} | {:error, keyword} 27 | 28 | @doc """ 29 | Compile-time check to see if the validator is valid for the given requestBody 30 | subschema. 31 | 32 | This may reject for any reason and should raise a CompileError if the validator 33 | cannot be used for that subschema. 34 | """ 35 | @callback validate!(subschema :: map, operation_id :: String.t()) :: :ok 36 | 37 | @spec fetch_body(Conn.t(), keyword) :: {:ok, body :: iodata, Conn.t()} | {:error, any} 38 | @doc """ 39 | Utility function that grabs request bodies. 40 | 41 | `Apical.Plugs.RequestBody.Source` modules are expected to use this function 42 | if they need the request body, since it conforms to the options keyword that 43 | plug uses natively. This function will exhaust the ability of the `conn` to 44 | have its body fetched. Thus, the use of this function is *not* required 45 | 46 | > ### Streaming request bodies {: .warning } 47 | > 48 | > If the request body source plugin processes data in a streaming fashion, this 49 | > function should not be used, instead manually call `Plug.Conn.read_body/2` 50 | > in your plugin's `c:fetch/3` function 51 | 52 | ### options 53 | 54 | `:length` (integer, default `8_000_000`) - total maximum length of the request body. 55 | `:read_length` (integer, default `1_000_000`) - maximum length of each chunk. 56 | `:string` (boolean, default `false`) - if true, the result will be a single binary, 57 | if false, the result *may* be an improper iolist. 58 | """ 59 | def fetch_body(conn, opts) do 60 | content_length = conn.private.content_length 61 | max_length = Keyword.get(opts, :length, 8_000_000) 62 | string? = Keyword.get(opts, :string, true) 63 | 64 | if content_length > max_length do 65 | raise Apical.Exceptions.RequestBodyTooLargeError, 66 | max_length: max_length, 67 | content_length: content_length 68 | end 69 | 70 | chunk_opts = [length: Keyword.get(opts, :read_length, 1_000_000)] 71 | fetch_body(conn, [], content_length, string?, chunk_opts) 72 | end 73 | 74 | defp fetch_body(conn, so_far, length, string?, chunk_opts) do 75 | case Conn.read_body(conn, chunk_opts) do 76 | {:ok, last, conn} when :erlang.byte_size(last) == length -> 77 | full_iodata = [so_far | last] 78 | 79 | if string? do 80 | {:ok, IO.iodata_to_binary(full_iodata), conn} 81 | else 82 | {:ok, full_iodata, conn} 83 | end 84 | 85 | {:ok, _, _} -> 86 | {:error, :body_length} 87 | 88 | {:more, chunk, conn} -> 89 | new_size = length - :erlang.byte_size(chunk) 90 | fetch_body(conn, [so_far | chunk], new_size, string?, chunk_opts) 91 | 92 | error = {:error, _} -> 93 | error 94 | end 95 | end 96 | 97 | @doc false 98 | # private utility function to consistently apply validators to request body 99 | # fetch results. 100 | def apply_validator(_content, nil), do: :ok 101 | 102 | def apply_validator(content, {module, fun}) do 103 | apply(module, fun, [content]) 104 | end 105 | 106 | def apply_validator(content, {module, fun, args}) do 107 | apply(module, fun, [content | args]) 108 | end 109 | end 110 | -------------------------------------------------------------------------------- /lib/apical/plugs/request_body/default.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.RequestBody.Default do 2 | @moduledoc """ 3 | """ 4 | 5 | @behaviour Apical.Plugs.RequestBody.Source 6 | 7 | @impl true 8 | def fetch(conn, _validator, _opts), do: {:ok, conn} 9 | 10 | @impl true 11 | def validate!(_, _), do: :ok 12 | end 13 | -------------------------------------------------------------------------------- /lib/apical/plugs/request_body/form_encoded.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.RequestBody.FormEncoded do 2 | @moduledoc """ 3 | """ 4 | 5 | alias Apical.Plugs.RequestBody.Source 6 | @behaviour Source 7 | 8 | @impl true 9 | def fetch(conn, validator, _opts) do 10 | with {:ok, str, conn} <- Source.fetch_body(conn, string: true), 11 | params = Plug.Conn.Query.decode(str, %{}, true), 12 | :ok <- Source.apply_validator(params, validator) do 13 | {:ok, %{conn | params: Map.merge(params, conn.params)}} 14 | else 15 | kw_error = {:error, kw} when is_list(kw) -> 16 | kw_error 17 | 18 | {:error, other} -> 19 | {:error, message: "fetching json body failed: #{other}"} 20 | end 21 | end 22 | 23 | @formencoded_types ["object", ["object"]] 24 | 25 | @impl true 26 | def validate!(%{"schema" => %{"type" => type}}, operation_id) 27 | when type not in @formencoded_types do 28 | type_json = Jason.encode!(type) 29 | 30 | raise CompileError, 31 | description: 32 | "media type `application/x-www-form-urlencoded` does not support types other than object, found `#{type_json}` in operation `#{operation_id}`" 33 | end 34 | 35 | def validate!(_, _), do: :ok 36 | end 37 | -------------------------------------------------------------------------------- /lib/apical/plugs/request_body/json.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.RequestBody.Json do 2 | @moduledoc """ 3 | """ 4 | 5 | alias Apical.Plugs.RequestBody.Source 6 | @behaviour Source 7 | 8 | @impl true 9 | def fetch(conn, validator, opts) do 10 | with {:ok, str, conn} <- Source.fetch_body(conn, []), 11 | {:ok, json} <- Jason.decode(str), 12 | :ok <- Source.apply_validator(json, validator) do 13 | {:ok, add_into_params(conn, json, opts)} 14 | else 15 | kw_error = {:error, kw} when is_list(kw) -> 16 | kw_error 17 | 18 | {:error, exception} when is_exception(exception) -> 19 | {:error, message: " (#{Exception.message(exception)})"} 20 | 21 | {:error, other} -> 22 | {:error, message: "fetching json body failed: #{other}"} 23 | end 24 | end 25 | 26 | defp add_into_params(conn, json, opts) when is_map(json) do 27 | if Keyword.get(opts, :nest_all_json, false) do 28 | %{conn | params: Map.put(conn.params, "_json", json)} 29 | else 30 | # note that in this case we are merging the params into the json. 31 | # this is so that someone can't override a declared parameter by 32 | # supplying a json key unspecified in the schema, that happens to 33 | # collide with one of the schema-parsed parameters 34 | %{conn | params: Map.merge(json, conn.params)} 35 | end 36 | end 37 | 38 | defp add_into_params(conn, json, _) do 39 | # when it's not an object, put in under the _json key, this follows the 40 | # convention set out in phoenix. 41 | %{conn | params: Map.put(conn.params, "_json", json)} 42 | end 43 | 44 | @impl true 45 | def validate!(_, _), do: :ok 46 | end 47 | -------------------------------------------------------------------------------- /lib/apical/plugs/set_operation_id.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.SetOperationId do 2 | @moduledoc """ 3 | `Plug` module which sets the private `:operation_id` key on the `conn` struct 4 | to the operationId (as an atom) that was declared in the schema. 5 | """ 6 | 7 | @behaviour Plug 8 | 9 | alias Plug.Conn 10 | 11 | @doc false 12 | def init(operation_id), do: operation_id 13 | 14 | @doc false 15 | def call(conn, operation_id) do 16 | Conn.put_private(conn, :operation_id, operation_id) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/apical/plugs/set_version.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Plugs.SetVersion do 2 | @moduledoc """ 3 | `Plug` module which sets the `:api_version` key on the `conn` struct's 4 | `assigns` to the version string that was declared in the schema. 5 | """ 6 | 7 | @behaviour Plug 8 | 9 | alias Plug.Conn 10 | 11 | @doc false 12 | def init(version), do: version 13 | 14 | @doc false 15 | def call(conn, version) do 16 | Conn.assign(conn, :api_version, version) 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /lib/apical/router.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Router do 2 | @moduledoc false 3 | 4 | alias Apical.Path 5 | alias Apical.Schema 6 | alias Apical.Testing 7 | alias Apical.Tools 8 | 9 | def build(schema, schema_string, opts) do 10 | %{"info" => %{"version" => version}} = Schema.verify_router!(schema) 11 | 12 | resource = Keyword.get_lazy(opts, :resource, fn -> hash(schema) end) 13 | encode_opts = Keyword.take(opts, ~w(encoding mimetype_mapping)a) 14 | 15 | route_opts = 16 | opts 17 | |> Keyword.merge( 18 | resource: resource, 19 | root: resolve_root(version, opts), 20 | version: version 21 | ) 22 | |> Testing.set_controller() 23 | 24 | routes = 25 | "/paths" 26 | |> JsonPtr.from_path() 27 | |> JsonPtr.map(schema, &Path.to_plug_routes(&1, &2, &3, schema, route_opts)) 28 | |> Enum.unzip() 29 | |> paths_to_route 30 | 31 | tests = Testing.build_tests(schema, opts) 32 | 33 | quote do 34 | require Exonerate 35 | Exonerate.register_resource(unquote(schema_string), unquote(resource), unquote(encode_opts)) 36 | 37 | unquote(external_resource(opts)) 38 | 39 | unquote(routes) 40 | 41 | unquote(tests) 42 | end 43 | end 44 | 45 | defp external_resource(opts) do 46 | List.wrap( 47 | if file = Keyword.get(opts, :file) do 48 | quote do 49 | @external_resource unquote(file) 50 | end 51 | end 52 | ) 53 | end 54 | 55 | defp hash(openapi) do 56 | :sha256 57 | |> :crypto.hash(:erlang.term_to_binary(openapi)) 58 | |> Base.encode16() 59 | end 60 | 61 | defp resolve_root(version, opts) do 62 | case Keyword.fetch(opts, :root) do 63 | {:ok, root} -> root 64 | :error -> resolve_version(version) 65 | end 66 | end 67 | 68 | defp resolve_version(version) do 69 | case String.split(version, ".") do 70 | [a, _ | _rest] -> 71 | "/v#{a}" 72 | 73 | _ -> 74 | raise CompileError, 75 | description: """ 76 | unable to parse supplied version string `#{version}` into a default root path. 77 | 78 | Suggested resolutions: 79 | - supply root path using `root: ` option 80 | - use semver version in `info` -> `version` in schema. 81 | """ 82 | end 83 | end 84 | 85 | defp paths_to_route({routes, operation_ids}) do 86 | validate_no_duplicate_operation_ids!(operation_ids, MapSet.new()) 87 | 88 | Enum.flat_map(routes, &Enum.reverse/1) 89 | end 90 | 91 | defp validate_no_duplicate_operation_ids!([], _so_far), do: :ok 92 | 93 | defp validate_no_duplicate_operation_ids!([set | rest], so_far) do 94 | intersection = MapSet.intersection(set, so_far) 95 | 96 | Tools.assert( 97 | intersection == MapSet.new(), 98 | "that operationIds are unique: (got more than one `#{Enum.at(intersection, 0)}`)" 99 | ) 100 | 101 | validate_no_duplicate_operation_ids!(rest, MapSet.union(set, so_far)) 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /lib/apical/schema.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Schema do 2 | @moduledoc false 3 | 4 | alias Apical.Tools 5 | 6 | @openapi_versions ["3.1.0"] 7 | 8 | def verify_router!(schema) do 9 | Tools.assert(is_map_key(schema, "paths"), "that the schema has a `paths` key") 10 | 11 | Tools.assert(is_map_key(schema, "openapi"), "that the schema has an `openapi` key") 12 | 13 | openapi = Map.fetch!(schema, "openapi") 14 | 15 | Tools.assert( 16 | openapi in @openapi_versions, 17 | "that the schema has a supported `openapi` version (got `#{openapi}`)", 18 | apical: true 19 | ) 20 | 21 | Tools.assert(is_map_key(schema, "info"), "that the schema has an `info` key") 22 | 23 | Tools.assert( 24 | is_map_key(schema["info"], "version"), 25 | "that the schema `info` field has a `version` key" 26 | ) 27 | 28 | schema 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/apical/testing.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Testing do 2 | @moduledoc false 3 | 4 | def set_controller(opts) do 5 | case Keyword.fetch(opts, :testing) do 6 | {:ok, :auto} -> 7 | Keyword.put(opts, :controller, resolve_controller(opts, [])) 8 | 9 | {:ok, testing_opts} -> 10 | Keyword.put(opts, :controller, resolve_controller(opts, testing_opts)) 11 | 12 | :error -> 13 | opts 14 | end 15 | end 16 | 17 | defp resolve_controller(opts, testing_opts) do 18 | default_controller = 19 | opts 20 | |> Keyword.fetch!(:router) 21 | |> Module.concat(Controller) 22 | 23 | Keyword.get(testing_opts, :controller, default_controller) 24 | end 25 | 26 | defmacro build(options) do 27 | router = __CALLER__.module 28 | operation_ids = Keyword.fetch!(options, :operation_ids) 29 | behaviour = Keyword.get(options, :behaviour, Module.concat(router, Api)) 30 | controller = Keyword.get(options, :controller, Module.concat(router, Controller)) 31 | mock = Keyword.get(options, :mock, Module.concat(router, Mock)) 32 | 33 | behaviour_code = build_behaviour(behaviour, operation_ids) 34 | mock_code = build_mock(mock, behaviour) 35 | controller_code = build_controller(behaviour, mock, controller, operation_ids) 36 | bypass_code = if Keyword.get(options, :bypass, false), do: build_bypass() 37 | 38 | quote do 39 | @router unquote(router) 40 | @mock unquote(mock) 41 | unquote(behaviour_code) 42 | unquote(mock_code) 43 | unquote(controller_code) 44 | unquote(bypass_code) 45 | end 46 | end 47 | 48 | def build_tests(%{"paths" => paths}, options) do 49 | case Keyword.fetch(options, :testing) do 50 | {:ok, :auto} -> 51 | do_build_tests(paths, bypass: true) 52 | 53 | {:ok, opts} -> 54 | do_build_tests(paths, opts) 55 | 56 | :error -> 57 | quote do 58 | end 59 | end 60 | end 61 | 62 | defp do_build_tests(paths, opts) do 63 | operation_ids = 64 | Enum.flat_map(paths, fn {_path, verbs} -> 65 | Enum.map(verbs, fn {_verb, %{"operationId" => operation_id}} -> 66 | String.to_atom(operation_id) 67 | end) 68 | end) 69 | 70 | opts = Keyword.put(opts, :operation_ids, operation_ids) 71 | 72 | quote do 73 | require Apical.Testing 74 | Apical.Testing.build(unquote(opts)) 75 | end 76 | end 77 | 78 | defp build_behaviour(behaviour, operation_ids) do 79 | quote bind_quoted: binding() do 80 | defmodule behaviour do 81 | for operation <- operation_ids do 82 | @callback unquote(operation)(Plug.Conn.t(), term) :: Plug.Conn.t() 83 | end 84 | end 85 | end 86 | end 87 | 88 | defp build_controller(behaviour, mock, controller, operation_ids) do 89 | quote bind_quoted: binding() do 90 | defmodule controller do 91 | @behaviour behaviour 92 | use Apical.Plug.Controller 93 | 94 | for operation <- operation_ids do 95 | @impl true 96 | defdelegate unquote(operation)(conn, params), to: mock 97 | end 98 | end 99 | end 100 | end 101 | 102 | defp build_mock(mock, behaviour) do 103 | quote do 104 | require Mox 105 | Mox.defmock(unquote(mock), for: unquote(behaviour)) 106 | end 107 | end 108 | 109 | defp build_bypass do 110 | quote do 111 | defp respond(conn, exception) do 112 | status = Map.get(exception, :plug_status, 500) 113 | Plug.Conn.send_resp(conn, status, Exception.message(exception)) 114 | end 115 | 116 | def bypass(bypass, opts \\ []) do 117 | this = self() 118 | 119 | Bypass.expect(bypass, fn conn -> 120 | Mox.allow(@mock, this, self()) 121 | 122 | try do 123 | @router.call(conn, @router.init(opts)) 124 | rescue 125 | e in Plug.Conn.WrapperError -> 126 | respond(conn, e.reason) 127 | 128 | e -> 129 | respond(conn, e) 130 | end 131 | end) 132 | end 133 | end 134 | end 135 | end 136 | -------------------------------------------------------------------------------- /lib/apical/tools.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Tools do 2 | @moduledoc false 3 | 4 | # private tools shared across multiple modules in the Apical library 5 | 6 | @default_content_mapping [{"application/yaml", YamlElixir}, {"application/json", Jason}] 7 | def decode(string, opts) do 8 | encoding = Keyword.fetch!(opts, :encoding) 9 | 10 | opts 11 | |> Keyword.get(:decoders, []) 12 | |> List.keyfind(encoding, 0, List.keyfind(@default_content_mapping, encoding, 0)) 13 | |> case do 14 | {_, YamlElixir} -> YamlElixir.read_from_string!(string) 15 | {_, Jason} -> Jason.decode!(string) 16 | {_, {module, function}} -> apply(module, function, [string]) 17 | nil -> raise "decoder for #{encoding} not found" 18 | end 19 | end 20 | 21 | @spec maybe_dump(Macro.t(), keyword) :: Macro.t() 22 | def maybe_dump(quoted, opts) do 23 | if Keyword.get(opts, :dump, false) do 24 | quoted 25 | |> Macro.to_string() 26 | |> IO.puts() 27 | 28 | quoted 29 | else 30 | quoted 31 | end 32 | end 33 | 34 | @terminating ~w(extra_plugs)a 35 | 36 | def deepmerge(into_list, src_list) when is_list(into_list) do 37 | Enum.reduce(src_list, into_list, fn 38 | {key, src_value}, so_far when key in @terminating -> 39 | if List.keyfind(into_list, key, 0) do 40 | List.keyreplace(so_far, key, 0, {key, src_value}) 41 | else 42 | [{key, src_value} | so_far] 43 | end 44 | 45 | {key, src_value}, so_far -> 46 | if kv = List.keyfind(into_list, key, 0) do 47 | {_k, v} = kv 48 | List.keyreplace(so_far, key, 0, {key, deepmerge(v, src_value)}) 49 | else 50 | [{key, src_value} | so_far] 51 | end 52 | end) 53 | end 54 | 55 | def deepmerge(_, src), do: src 56 | 57 | def assert(condition, message, opts \\ []) do 58 | # todo: consider adding jsonschema path information here. 59 | unless condition do 60 | explained = 61 | if opts[:apical] do 62 | "Your schema violates the Apical requirement #{message}" 63 | else 64 | "Your schema violates the OpenAPI requirement #{message}" 65 | end 66 | 67 | raise CompileError, description: explained 68 | end 69 | end 70 | end 71 | -------------------------------------------------------------------------------- /lib/apical/validators.ex: -------------------------------------------------------------------------------- 1 | defmodule Apical.Validators do 2 | @moduledoc false 3 | 4 | # module for creating exonerate-based validators. Generates the macro as AST 5 | # so these validators can be built at compile-time. 6 | 7 | @exonerate_opts ~w(metadata format decoders draft)a 8 | 9 | @spec make_quoted(map, JsonPtr.t(), atom, keyword()) :: [Macro.t()] 10 | def make_quoted(subschema, pointer, fn_name, opts) do 11 | resource = Keyword.fetch!(opts, :resource) 12 | 13 | List.wrap( 14 | if Map.get(subschema, "schema") do 15 | schema_pointer = 16 | pointer 17 | |> JsonPtr.join("schema") 18 | |> JsonPtr.to_uri() 19 | |> to_string 20 | |> String.trim_leading("#") 21 | 22 | should_dump = 23 | List.wrap( 24 | if Keyword.get(opts, :dump) === :all or Keyword.get(opts, :dump_validator) do 25 | {:dump, true} 26 | end 27 | ) 28 | 29 | opts = 30 | opts 31 | |> Keyword.take(@exonerate_opts) 32 | |> Keyword.put(:entrypoint, schema_pointer) 33 | |> Keyword.merge(should_dump) 34 | 35 | quote do 36 | Exonerate.function_from_resource( 37 | :def, 38 | unquote(fn_name), 39 | unquote(resource), 40 | unquote(opts) 41 | ) 42 | end 43 | end 44 | ) 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /mix.exs: -------------------------------------------------------------------------------- 1 | defmodule Apical.MixProject do 2 | use Mix.Project 3 | 4 | def project do 5 | [ 6 | app: :apical, 7 | version: "0.2.1", 8 | elixir: "~> 1.14", 9 | start_permanent: Mix.env() == :prod, 10 | elixirc_paths: elixirc_paths(Mix.env()), 11 | package: [ 12 | description: "OpenAPI 3.1.0 router generator for Elixir", 13 | licenses: ["MIT"], 14 | files: ~w(lib mix.exs README* LICENSE* CHANGELOG*), 15 | links: %{"GitHub" => "https://github.com/E-xyza/Apical"} 16 | ], 17 | source_url: "https://github.com/E-xyza/Apical/", 18 | deps: deps(), 19 | docs: docs() 20 | ] 21 | end 22 | 23 | def application do 24 | [ 25 | extra_applications: [:logger] 26 | ] 27 | end 28 | 29 | def elixirc_paths(:test), do: ["lib", "test/_support"] 30 | def elixirc_paths(_), do: ["lib"] 31 | 32 | defp deps do 33 | [ 34 | {:pegasus, "~> 0.2.4", runtime: false}, 35 | {:exonerate, "~> 1.1.2", runtime: false}, 36 | {:bandit, ">= 0.7.6", only: :test}, 37 | # note that phoenix is an optional dependency. 38 | {:phoenix, "~> 1.7.2", only: [:test, :dev], optional: true}, 39 | {:phoenix_html, "~> 3.3.1", only: :test, optional: true}, 40 | {:req, "~> 0.3.10", only: :test}, 41 | {:yaml_elixir, "~> 2.7", optional: true}, 42 | {:jason, "~> 1.4", optional: true}, 43 | {:mox, "~> 1.0", optional: true}, 44 | {:bypass, "~> 2.1", optional: true}, 45 | {:plug, "~> 1.14"}, 46 | {:json_ptr, "~> 1.2"}, 47 | {:ex_doc, "~> 0.27", only: :dev, runtime: false} 48 | ] 49 | end 50 | 51 | defp docs do 52 | [ 53 | main: "Apical", 54 | source_ref: "main", 55 | extra_section: "GUIDES", 56 | extras: ["guides/Apical for testing.md"], 57 | groups_for_modules: [ 58 | Behaviours: [ 59 | Apical.Plugs.RequestBody.Source 60 | ], 61 | Plugs: [ 62 | Apical.Plugs.Cookie, 63 | Apical.Plugs.Header, 64 | Apical.Plugs.Path, 65 | Apical.Plugs.Query, 66 | Apical.Plugs.RequestBody, 67 | Apical.Plugs.SetOperationId, 68 | Apical.Plugs.SetVersion 69 | ], 70 | "RequestBody Source Plugins": [ 71 | Apical.Plugs.RequestBody.Default, 72 | Apical.Plugs.RequestBody.Json, 73 | Apical.Plugs.RequestBody.FormEncoded 74 | ] 75 | ] 76 | ] 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /mix.lock: -------------------------------------------------------------------------------- 1 | %{ 2 | "bandit": {:hex, :bandit, "0.7.7", "48456d09022607a312cf723a91992236aeaffe4af50615e6e2d2e383fb6bef10", [:mix], [{:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:thousand_island, "~> 0.6.7", [hex: :thousand_island, repo: "hexpm", optional: false]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "772f0a32632c2ce41026d85e24b13a469151bb8cea1891e597fb38fde103640a"}, 3 | "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"}, 4 | "castore": {:hex, :castore, "1.0.3", "7130ba6d24c8424014194676d608cb989f62ef8039efd50ff4b3f33286d06db8", [:mix], [], "hexpm", "680ab01ef5d15b161ed6a95449fac5c6b8f60055677a8e79acf01b27baa4390b"}, 5 | "cowboy": {:hex, :cowboy, "2.10.0", "ff9ffeff91dae4ae270dd975642997afe2a1179d94b1887863e43f681a203e26", [:make, :rebar3], [{:cowlib, "2.12.1", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "3afdccb7183cc6f143cb14d3cf51fa00e53db9ec80cdcd525482f5e99bc41d6b"}, 6 | "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, 7 | "cowlib": {:hex, :cowlib, "2.12.1", "a9fa9a625f1d2025fe6b462cb865881329b5caff8f1854d1cbc9f9533f00e1e1", [:make, :rebar3], [], "hexpm", "163b73f6367a7341b33c794c4e88e7dbfe6498ac42dcd69ef44c5bc5507c8db0"}, 8 | "earmark_parser": {:hex, :earmark_parser, "1.4.32", "fa739a0ecfa34493de19426681b23f6814573faee95dfd4b4aafe15a7b5b32c6", [:mix], [], "hexpm", "b8b0dd77d60373e77a3d7e8afa598f325e49e8663a51bcc2b88ef41838cca755"}, 9 | "ex_doc": {:hex, :ex_doc, "0.29.4", "6257ecbb20c7396b1fe5accd55b7b0d23f44b6aa18017b415cb4c2b91d997729", [:mix], [{:earmark_parser, "~> 1.4.31", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "2c6699a737ae46cb61e4ed012af931b57b699643b24dabe2400a8168414bc4f5"}, 10 | "exonerate": {:hex, :exonerate, "1.1.2", "a320e3dc82ad2386891cfa86f61f1be754a937d2d798bb0b48246d166f111749", [:mix], [{:finch, "~> 0.15", [hex: :finch, repo: "hexpm", optional: true]}, {:idna, "~> 6.1.1", [hex: :idna, repo: "hexpm", optional: true]}, {:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}, {:json_ptr, "~> 1.0", [hex: :json_ptr, repo: "hexpm", optional: false]}, {:match_spec, "~> 0.3.1", [hex: :match_spec, repo: "hexpm", optional: false]}, {:pegasus, "~> 0.2.2", [hex: :pegasus, repo: "hexpm", optional: true]}, {:req, "~> 0.3", [hex: :req, repo: "hexpm", optional: true]}, {:yaml_elixir, "~> 2.7", [hex: :yaml_elixir, repo: "hexpm", optional: true]}], "hexpm", "35f5efda235b6e790be99ad234da254f79440cc658a765bc19c0de4d74549711"}, 11 | "finch": {:hex, :finch, "0.16.0", "40733f02c89f94a112518071c0a91fe86069560f5dbdb39f9150042f44dcfb1a", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:mint, "~> 1.3", [hex: :mint, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_pool, "~> 0.2.6 or ~> 1.0", [hex: :nimble_pool, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "f660174c4d519e5fec629016054d60edd822cdfe2b7270836739ac2f97735ec5"}, 12 | "hpax": {:hex, :hpax, "0.1.2", "09a75600d9d8bbd064cdd741f21fc06fc1f4cf3d0fcc335e5aa19be1a7235c84", [:mix], [], "hexpm", "2c87843d5a23f5f16748ebe77969880e29809580efdaccd615cd3bed628a8c13"}, 13 | "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, 14 | "json_ptr": {:hex, :json_ptr, "1.2.0", "07a757d1b0a86b7fd73f5a5b26d4d41c5bc7d5be4a3e3511d7458293ce70b8bb", [:mix], [{:jason, "~> 1.4.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "e58704ac304cbf3832c0ac161e76479e7b05f75427991ddd57e19b307ae4aa05"}, 15 | "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, 16 | "makeup_elixir": {:hex, :makeup_elixir, "0.16.1", "cc9e3ca312f1cfeccc572b37a09980287e243648108384b97ff2b76e505c3555", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "e127a341ad1b209bd80f7bd1620a15693a9908ed780c3b763bccf7d200c767c6"}, 17 | "makeup_erlang": {:hex, :makeup_erlang, "0.1.2", "ad87296a092a46e03b7e9b0be7631ddcf64c790fa68a9ef5323b6cbb36affc72", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "f3f5a1ca93ce6e092d92b6d9c049bcda58a3b617a8d888f8e7231c85630e8108"}, 18 | "match_spec": {:hex, :match_spec, "0.3.1", "9fb2b9313dbd2c5aa6210a8c11eea94e0531a0982e463d9467445e8615081af0", [:mix], [], "hexpm", "225e90aa02c8023b12cf47e33c8a7307ac88b31f461b43d93e040f4641704557"}, 19 | "mime": {:hex, :mime, "2.0.5", "dc34c8efd439abe6ae0343edbb8556f4d63f178594894720607772a041b04b02", [:mix], [], "hexpm", "da0d64a365c45bc9935cc5c8a7fc5e49a0e0f9932a761c55d6c52b142780a05c"}, 20 | "mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"}, 21 | "mox": {:hex, :mox, "1.0.2", "dc2057289ac478b35760ba74165b4b3f402f68803dd5aecd3bfd19c183815d64", [:mix], [], "hexpm", "f9864921b3aaf763c8741b5b8e6f908f44566f1e427b2630e89e9a73b981fef2"}, 22 | "nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"}, 23 | "nimble_parsec": {:hex, :nimble_parsec, "1.3.1", "2c54013ecf170e249e9291ed0a62e5832f70a476c61da16f6aac6dca0189f2af", [:mix], [], "hexpm", "2682e3c0b2eb58d90c6375fc0cc30bc7be06f365bf72608804fb9cffa5e1b167"}, 24 | "nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"}, 25 | "pegasus": {:hex, :pegasus, "0.2.4", "3d8d5a2c89552face9c7ca14f959cc6c6d2cd645db1df85940db4c77c3b21a24", [:mix], [{:nimble_parsec, "~> 1.2", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "2d21e2b6b946fe3cd441544bf9856e7772f29050332f0255e166a13cdbe65bb4"}, 26 | "phoenix": {:hex, :phoenix, "1.7.6", "61f0625af7c1d1923d582470446de29b008c0e07ae33d7a3859ede247ddaf59a", [:mix], [{:castore, ">= 0.0.0", [hex: :castore, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.1", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_template, "~> 1.0", [hex: :phoenix_template, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 2.0", [hex: :phoenix_view, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:plug_crypto, "~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}, {:websock_adapter, "~> 0.5.3", [hex: :websock_adapter, repo: "hexpm", optional: false]}], "hexpm", "f6b4be7780402bb060cbc6e83f1b6d3f5673b674ba73cc4a7dd47db0322dfb88"}, 27 | "phoenix_html": {:hex, :phoenix_html, "3.3.1", "4788757e804a30baac6b3fc9695bf5562465dd3f1da8eb8460ad5b404d9a2178", [:mix], [{:plug, "~> 1.5", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "bed1906edd4906a15fd7b412b85b05e521e1f67c9a85418c55999277e553d0d3"}, 28 | "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.1.3", "3168d78ba41835aecad272d5e8cd51aa87a7ac9eb836eabc42f6e57538e3731d", [:mix], [], "hexpm", "bba06bc1dcfd8cb086759f0edc94a8ba2bc8896d5331a1e2c2902bf8e36ee502"}, 29 | "phoenix_template": {:hex, :phoenix_template, "1.0.1", "85f79e3ad1b0180abb43f9725973e3b8c2c3354a87245f91431eec60553ed3ef", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "157dc078f6226334c91cb32c1865bf3911686f8bcd6bcff86736f6253e6993ee"}, 30 | "plug": {:hex, :plug, "1.14.2", "cff7d4ec45b4ae176a227acd94a7ab536d9b37b942c8e8fa6dfc0fff98ff4d80", [:mix], [{:mime, "~> 1.0 or ~> 2.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.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "842fc50187e13cf4ac3b253d47d9474ed6c296a8732752835ce4a86acdf68d13"}, 31 | "plug_cowboy": {:hex, :plug_cowboy, "2.6.1", "9a3bbfceeb65eff5f39dab529e5cd79137ac36e913c02067dba3963a26efe9b2", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "de36e1a21f451a18b790f37765db198075c25875c64834bcc82d90b309eb6613"}, 32 | "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, 33 | "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, 34 | "req": {:hex, :req, "0.3.11", "462315e50db6c6e1f61c45e8c0b267b0d22b6bd1f28444c136908dfdca8d515a", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.9", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 1.6 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0e4b331627fedcf90b29aa8064cd5a95619ef6134d5ab13919b6e1c4d7cccd4b"}, 35 | "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, 36 | "thousand_island": {:hex, :thousand_island, "0.6.7", "3a91a7e362ca407036c6691e8a4f6e01ac8e901db3598875863a149279ac8571", [:mix], [{:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "541a5cb26b88adf8d8180b6b96a90f09566b4aad7a6b3608dcac969648cf6765"}, 37 | "websock": {:hex, :websock, "0.5.2", "b3c08511d8d79ed2c2f589ff430bd1fe799bb389686dafce86d28801783d8351", [:mix], [], "hexpm", "925f5de22fca6813dfa980fb62fd542ec43a2d1a1f83d2caec907483fe66ff05"}, 38 | "websock_adapter": {:hex, :websock_adapter, "0.5.3", "4908718e42e4a548fc20e00e70848620a92f11f7a6add8cf0886c4232267498d", [:mix], [{:bandit, ">= 0.6.0", [hex: :bandit, repo: "hexpm", optional: true]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.6", [hex: :plug_cowboy, repo: "hexpm", optional: true]}, {:websock, "~> 0.5", [hex: :websock, repo: "hexpm", optional: false]}], "hexpm", "cbe5b814c1f86b6ea002b52dd99f345aeecf1a1a6964e209d208fb404d930d3d"}, 39 | "yamerl": {:hex, :yamerl, "0.10.0", "4ff81fee2f1f6a46f1700c0d880b24d193ddb74bd14ef42cb0bcf46e81ef2f8e", [:rebar3], [], "hexpm", "346adb2963f1051dc837a2364e4acf6eb7d80097c0f53cbdc3046ec8ec4b4e6e"}, 40 | "yaml_elixir": {:hex, :yaml_elixir, "2.9.0", "9a256da867b37b8d2c1ffd5d9de373a4fda77a32a45b452f1708508ba7bbcb53", [:mix], [{:yamerl, "~> 0.10", [hex: :yamerl, repo: "hexpm", optional: false]}], "hexpm", "0cb0e7d4c56f5e99a6253ed1a670ed0e39c13fc45a6da054033928607ac08dfc"}, 41 | } 42 | -------------------------------------------------------------------------------- /test/_support/endpoint_case.ex: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.EndpointCase do 2 | use ExUnit.CaseTemplate 3 | 4 | using opts do 5 | opts 6 | |> Keyword.get(:with, Phoenix) 7 | |> Macro.expand(__ENV__) 8 | |> case do 9 | Plug -> 10 | port = Enum.random(2000..3000) 11 | plug_endpoint(port) 12 | 13 | Phoenix -> 14 | phoenix_endpoint() 15 | end 16 | end 17 | 18 | defp plug_endpoint(port) do 19 | quote do 20 | @port unquote(port) 21 | 22 | setup_all do 23 | start_supervised({Bandit, plug: __MODULE__.Router, port: @port}) 24 | :ok 25 | end 26 | end 27 | end 28 | 29 | defp phoenix_endpoint do 30 | quote do 31 | # Use the endpoint module as the endpoint 32 | @endpoint __MODULE__.Endpoint 33 | @after_compile __MODULE__ 34 | use Phoenix.Controller 35 | 36 | # Import conveniences for testing with connections 37 | import Phoenix.ConnTest 38 | 39 | Application.put_env(:apical, @endpoint, adapter: Bandit.PhoenixAdapter) 40 | 41 | setup_all do 42 | __MODULE__.Endpoint.start_link() 43 | :ok 44 | end 45 | 46 | def __after_compile__(_, _) do 47 | router = Module.concat(__MODULE__, Router) 48 | endpoint = Module.concat(__MODULE__, Endpoint) 49 | 50 | Code.eval_quoted( 51 | quote do 52 | defmodule unquote(endpoint) do 53 | use Phoenix.Endpoint, otp_app: :apical 54 | 55 | plug(unquote(router)) 56 | end 57 | end 58 | ) 59 | end 60 | end 61 | end 62 | 63 | setup _tags do 64 | %{conn: Phoenix.ConnTest.build_conn()} 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /test/_support/error.ex: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Support.Error do 2 | # before version 1.15.0 the error messages as found by assert_raise strings 3 | # contain a space. This ensures passing on 1.15.0 and above. 4 | 5 | changeover = Version.parse!("1.15.0") 6 | 7 | version_compare = 8 | System.version() 9 | |> Version.parse!() 10 | |> Version.compare(changeover) 11 | 12 | if version_compare == :lt do 13 | def error_message(string) do 14 | " " <> string 15 | end 16 | else 17 | def error_message(string), do: string 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /test/_support/error_view.ex: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.ErrorView do 2 | def render(_, assigns) do 3 | "error #{assigns.status}" 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /test/_support/extra_plug.ex: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.ExtraPlug do 2 | @behaviour Plug 3 | 4 | alias Plug.Conn 5 | 6 | def init(opts) do 7 | opts 8 | end 9 | 10 | def call(conn, []), do: Conn.put_private(conn, :extra_module_plug, "no options") 11 | def call(conn, [option]), do: Conn.put_private(conn, :extra_module_plug_option, option) 12 | end 13 | -------------------------------------------------------------------------------- /test/_support/from_file_test.yaml: -------------------------------------------------------------------------------- 1 | openapi: 3.1.0 2 | info: 3 | title: FromFileTest 4 | version: 1.0.0 5 | paths: 6 | "/{number}": 7 | get: 8 | operationId: route 9 | parameters: 10 | - name: number 11 | in: path 12 | required: true 13 | schema: 14 | type: integer 15 | minimum: 0 16 | responses: 17 | "200": 18 | description: OK -------------------------------------------------------------------------------- /test/_support/test_test_router.ex: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.TestTest.Router do 2 | use Phoenix.Router 3 | 4 | require Apical 5 | 6 | Apical.router_from_string( 7 | """ 8 | openapi: 3.1.0 9 | info: 10 | title: TestGet 11 | version: 1.0.0 12 | paths: 13 | "/": 14 | get: 15 | operationId: testGet 16 | parameters: 17 | - in: query 18 | name: foo 19 | schema: 20 | type: string 21 | enum: 22 | - bar 23 | responses: 24 | "200": 25 | description: OK 26 | """, 27 | root: "/", 28 | encoding: "application/yaml", 29 | testing: [ 30 | behaviour: ApicalTest.TestTest.Api, 31 | controller: ApicalTest.TestTest.Controller, 32 | mock: ApicalTest.TestTest.Mock, 33 | bypass: true 34 | ] 35 | ) 36 | end 37 | -------------------------------------------------------------------------------- /test/compile_error/duplicate_operation_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.DuplicateOperationIdTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule DuplicateOperationId do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: DuplicateOperationIdTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | in: query 25 | responses: 26 | "200": 27 | description: OK 28 | "/other": 29 | get: 30 | operationId: fails 31 | parameters: 32 | - name: parameter 33 | in: query 34 | responses: 35 | "200": 36 | description: OK 37 | """, 38 | controller: Undefined, 39 | encoding: "application/yaml" 40 | ) 41 | end 42 | end 43 | 44 | @attempt_compile fails 45 | 46 | test "nonunique operation ids compile error" do 47 | assert_raise CompileError, 48 | error_message( 49 | "Your schema violates the OpenAPI requirement that operationIds are unique: (got more than one `fails`)" 50 | ), 51 | fn -> 52 | Code.eval_quoted(@attempt_compile) 53 | end 54 | end 55 | end 56 | -------------------------------------------------------------------------------- /test/compile_error/duplicate_parameter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.DuplicateParameterTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule DuplicateParameter do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: DuplicateParameterTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | in: query 25 | - name: parameter 26 | in: header 27 | responses: 28 | "200": 29 | description: OK 30 | """, 31 | controller: Undefined, 32 | encoding: "application/yaml" 33 | ) 34 | end 35 | end 36 | 37 | @attempt_compile fails 38 | 39 | test "duplicate parameter raises compile error" do 40 | assert_raise CompileError, 41 | error_message( 42 | "Your schema violates the OpenAPI requirement for unique parameters: the parameter `parameter` is not unique (in operation `fails`)" 43 | ), 44 | fn -> 45 | Code.eval_quoted(@attempt_compile) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/compile_error/form_encoded_non_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.FormEncodedNonObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule FormEncodedNonObject do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: FormEncodedNonObjectTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | requestBody: 23 | content: 24 | "application/x-www-form-urlencoded": 25 | schema: 26 | type: integer 27 | responses: 28 | "200": 29 | description: OK 30 | """, 31 | controller: Undefined, 32 | encoding: "application/yaml" 33 | ) 34 | end 35 | end 36 | 37 | @attempt_compile fails 38 | 39 | test "request body fails when it's form-encoded and the type is not \"object\"" do 40 | assert_raise CompileError, 41 | error_message( 42 | "media type `application/x-www-form-urlencoded` does not support types other than object, found `\"integer\"` in operation `fails`" 43 | ), 44 | fn -> 45 | Code.eval_quoted(@attempt_compile) 46 | end 47 | end 48 | end 49 | -------------------------------------------------------------------------------- /test/compile_error/form_exploded_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.FormExplodedObjectTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule FormExplodedObjectFails do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: FormExplodedObjectTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | required: true 25 | in: query 26 | style: form 27 | explode: true 28 | schema: 29 | type: object 30 | responses: 31 | "200": 32 | description: OK 33 | """, 34 | controller: Undefined, 35 | encoding: "application/yaml" 36 | ) 37 | end 38 | end 39 | 40 | @attempt_compile fails 41 | 42 | test "form exploded parameters raises compile error" do 43 | assert_raise CompileError, 44 | error_message( 45 | "Your schema violates the Apical requirement for parameter `parameter` in operation `fails`: form exploded parameters may not be objects" 46 | ), 47 | fn -> 48 | Code.eval_quoted(@attempt_compile) 49 | end 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /test/compile_error/invalid_openapi_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.InvalidOpenApiTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule InvalidOpenApi do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: foo 15 | info: 16 | title: InvalidOpenApiTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | in: query 25 | responses: 26 | "200": 27 | description: OK 28 | """, 29 | controller: Undefined, 30 | encoding: "application/yaml" 31 | ) 32 | end 33 | end 34 | 35 | @attempt_compile fails 36 | 37 | test "an invalid openapi section triggers compile failure" do 38 | assert_raise CompileError, 39 | error_message( 40 | "Your schema violates the Apical requirement that the schema has a supported `openapi` version (got `foo`)" 41 | ), 42 | fn -> 43 | Code.eval_quoted(@attempt_compile) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/compile_error/invalid_parameter_location_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.InvalidParameterLocationTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule InvalidParameterLocation do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: InvalidParameterLocationTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | in: not-a-location 25 | responses: 26 | "200": 27 | description: OK 28 | """, 29 | controller: Undefined, 30 | encoding: "application/yaml" 31 | ) 32 | end 33 | end 34 | 35 | @attempt_compile fails 36 | 37 | test "invalid parameter location raises compile error" do 38 | assert_raise CompileError, 39 | error_message( 40 | "Your schema violates the OpenAPI requirement for parameters, invalid parameter location: `not-a-location` (in operation `fails`, parameter 0)" 41 | ), 42 | fn -> 43 | Code.eval_quoted(@attempt_compile) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/compile_error/missing_info_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingInfoTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingInfo do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | paths: 16 | "/": 17 | get: 18 | operationId: fails 19 | parameters: 20 | - name: parameter 21 | in: query 22 | responses: 23 | "200": 24 | description: OK 25 | """, 26 | controller: Undefined, 27 | encoding: "application/yaml" 28 | ) 29 | end 30 | end 31 | 32 | @attempt_compile fails 33 | 34 | test "missing the openapi section triggers compile failure" do 35 | assert_raise CompileError, 36 | error_message( 37 | "Your schema violates the OpenAPI requirement that the schema has an `info` key" 38 | ), 39 | fn -> 40 | Code.eval_quoted(@attempt_compile) 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/compile_error/missing_openapi_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingOpenApiTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingOpenApi do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | info: 15 | title: MissingOpenApiTest 16 | version: 1.0.0 17 | paths: 18 | "/": 19 | get: 20 | operationId: fails 21 | parameters: 22 | - name: parameter 23 | in: query 24 | responses: 25 | "200": 26 | description: OK 27 | """, 28 | controller: Undefined, 29 | encoding: "application/yaml" 30 | ) 31 | end 32 | end 33 | 34 | @attempt_compile fails 35 | 36 | test "missing the openapi section triggers compile failure" do 37 | assert_raise CompileError, 38 | error_message( 39 | "Your schema violates the OpenAPI requirement that the schema has an `openapi` key" 40 | ), 41 | fn -> 42 | Code.eval_quoted(@attempt_compile) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/compile_error/missing_operation_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingOperationIdTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingOperationIdFailsTest do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: MissingOperationIdTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | parameters: 22 | - name: parameter 23 | in: query 24 | responses: 25 | "200": 26 | description: OK 27 | """, 28 | controller: Undefined, 29 | encoding: "application/yaml" 30 | ) 31 | end 32 | end 33 | 34 | @attempt_compile fails 35 | 36 | test "nonexistent path parameter raises compile error" do 37 | assert_raise CompileError, 38 | error_message( 39 | "Your schema violates the OpenAPI requirement that all operations have an operationId: (missing for operation at `/paths/~1/get`)" 40 | ), 41 | fn -> 42 | Code.eval_quoted(@attempt_compile) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/compile_error/missing_parameter_in_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingParameterInTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingParameterIn do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: MissingParameterInTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | responses: 25 | "200": 26 | description: OK 27 | """, 28 | controller: Undefined, 29 | encoding: "application/yaml" 30 | ) 31 | end 32 | end 33 | 34 | @attempt_compile fails 35 | 36 | test "invalid parameter location raises compile error" do 37 | assert_raise CompileError, 38 | error_message( 39 | "Your schema violates the OpenAPI requirement for parameters, field `in` is required (in operation `fails`, parameter 0)" 40 | ), 41 | fn -> 42 | Code.eval_quoted(@attempt_compile) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/compile_error/missing_parameter_name_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingParameterNameTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingParameterName do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: MissingParameterNameTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - in: query 24 | responses: 25 | "200": 26 | description: OK 27 | """, 28 | controller: Undefined, 29 | encoding: "application/yaml" 30 | ) 31 | end 32 | end 33 | 34 | @attempt_compile fails 35 | 36 | test "missing parameter name raises compile error" do 37 | assert_raise CompileError, 38 | error_message( 39 | "Your schema violates the OpenAPI requirement for parameters, field `name` is required (in operation `fails`, parameter 0)" 40 | ), 41 | fn -> 42 | Code.eval_quoted(@attempt_compile) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/compile_error/missing_paths_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingPathsTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingPaths do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: MissingPathsTest 17 | version: 1.0.0 18 | """, 19 | controller: Undefined, 20 | encoding: "application/yaml" 21 | ) 22 | end 23 | end 24 | 25 | @attempt_compile fails 26 | 27 | test "missing the paths section triggers compile failure" do 28 | assert_raise CompileError, 29 | error_message( 30 | "Your schema violates the OpenAPI requirement that the schema has a `paths` key" 31 | ), 32 | fn -> 33 | Code.eval_quoted(@attempt_compile) 34 | end 35 | end 36 | end 37 | -------------------------------------------------------------------------------- /test/compile_error/missing_version_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.MissingVersionTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule MissingVersion do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: MissingVersionTest 17 | paths: 18 | "/": 19 | get: 20 | operationId: fails 21 | parameters: 22 | - name: parameter 23 | in: query 24 | responses: 25 | "200": 26 | description: OK 27 | """, 28 | controller: Undefined, 29 | encoding: "application/yaml" 30 | ) 31 | end 32 | end 33 | 34 | @attempt_compile fails 35 | 36 | test "missing the openapi section triggers compile failure" do 37 | assert_raise CompileError, 38 | error_message( 39 | "Your schema violates the OpenAPI requirement that the schema `info` field has a `version` key" 40 | ), 41 | fn -> 42 | Code.eval_quoted(@attempt_compile) 43 | end 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /test/compile_error/nonexistent_path_parameter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.NonexistentPathParameterTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule NonexistentPathParameterFails do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: NonexistentPathParameterTest 17 | version: 1.0.0 18 | paths: 19 | "/": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | required: true 25 | in: path 26 | responses: 27 | "200": 28 | description: OK 29 | """, 30 | controller: Undefined, 31 | encoding: "application/yaml" 32 | ) 33 | end 34 | end 35 | 36 | @attempt_compile fails 37 | 38 | test "nonexistent path parameter raises compile error" do 39 | assert_raise CompileError, 40 | error_message( 41 | "Your schema violates the OpenAPI requirement that the parameter `parameter` in operation `fails` exists as a match in its path definition: (got: `/`)" 42 | ), 43 | fn -> 44 | Code.eval_quoted(@attempt_compile) 45 | end 46 | end 47 | end 48 | -------------------------------------------------------------------------------- /test/compile_error/unrequired_path_parameter_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.CompileError.UnrequiredPathParameterTest do 2 | use ExUnit.Case, async: true 3 | 4 | import ApicalTest.Support.Error 5 | 6 | fails = 7 | quote do 8 | defmodule UnrequiredPathParameterFails do 9 | require Apical 10 | use Phoenix.Router 11 | 12 | Apical.router_from_string( 13 | """ 14 | openapi: 3.1.0 15 | info: 16 | title: UnrequiredPathParameterTest 17 | version: 1.0.0 18 | paths: 19 | "/{parameter}": 20 | get: 21 | operationId: fails 22 | parameters: 23 | - name: parameter 24 | in: path 25 | responses: 26 | "200": 27 | description: OK 28 | """, 29 | controller: Undefined, 30 | encoding: "application/yaml" 31 | ) 32 | end 33 | end 34 | 35 | @attempt_compile fails 36 | 37 | test "unrequired path parameter raises compile error" do 38 | assert_raise CompileError, 39 | error_message( 40 | "Your schema violates the OpenAPI requirement for parameter `parameter` in operation `fails`: path parameters must be `required: true`" 41 | ), 42 | fn -> 43 | Code.eval_quoted(@attempt_compile) 44 | end 45 | end 46 | end 47 | -------------------------------------------------------------------------------- /test/controllers/aliased_function_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Controllers.AliasedFunctionTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestGet 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | get: 16 | operationId: testGet 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Controllers.AliasedFunctionTest, 23 | operation_ids: [testGet: [alias: :aliased]], 24 | encoding: "application/yaml" 25 | ) 26 | end 27 | 28 | use ApicalTest.EndpointCase 29 | alias Plug.Conn 30 | 31 | def aliased(conn, _params) do 32 | Conn.send_resp(conn, 200, "OK") 33 | end 34 | 35 | test "GET /", %{conn: conn} do 36 | assert %{ 37 | resp_body: "OK", 38 | status: 200 39 | } = get(conn, "/") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/controllers/by_group_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Parameters.ByGroupTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestGet 12 | version: 1.0.0 13 | paths: 14 | "/default": 15 | get: 16 | operationId: default 17 | responses: 18 | "200": 19 | description: OK 20 | "/grouped": 21 | get: 22 | operationId: grouped 23 | responses: 24 | "200": 25 | description: OK 26 | "/tagged": 27 | get: 28 | operationId: tagged 29 | tags: [tag] 30 | responses: 31 | "200": 32 | description: OK 33 | "/untagged": 34 | get: 35 | operationId: untagged 36 | responses: 37 | "200": 38 | description: OK 39 | """, 40 | root: "/", 41 | controller: ApicalTest.Parameters.ByGroupTest, 42 | operation_ids: [ 43 | tagged: [controller: ApicalTest.Parameters.ByGroupTest.OperationId], 44 | untagged: [controller: ApicalTest.Parameters.ByGroupTest.OperationId] 45 | ], 46 | groups: [ 47 | [:grouped, controller: ApicalTest.Parameters.ByGroupTest.OperationId] 48 | ], 49 | tags: [ 50 | tag: [controller: ApicalTest.Parameters.ByGroupTest.Unimplemented] 51 | ], 52 | encoding: "application/yaml" 53 | ) 54 | end 55 | 56 | defmodule OperationId do 57 | use Phoenix.Controller 58 | alias Plug.Conn 59 | 60 | for operation <- ~w(tagged untagged grouped) do 61 | def unquote(:"#{operation}")(conn, _param) do 62 | Conn.resp(conn, 200, unquote(operation)) 63 | end 64 | end 65 | end 66 | 67 | use ApicalTest.EndpointCase 68 | alias Plug.Conn 69 | 70 | def default(conn, _param) do 71 | Conn.resp(conn, 200, "default") 72 | end 73 | 74 | describe "routing forwards to correct modules" do 75 | test "in the default case", %{conn: conn} do 76 | assert %{ 77 | resp_body: "default", 78 | status: 200 79 | } = get(conn, "/default") 80 | end 81 | 82 | test "when the operationId is tagged operationId takes precedence", %{conn: conn} do 83 | assert %{ 84 | resp_body: "tagged", 85 | status: 200 86 | } = get(conn, "/tagged") 87 | end 88 | 89 | test "when the operationId is grouped grouped takes precedence over global", %{conn: conn} do 90 | assert %{ 91 | resp_body: "grouped", 92 | status: 200 93 | } = get(conn, "/grouped") 94 | end 95 | 96 | test "when the operationId is untagged", %{conn: conn} do 97 | assert %{ 98 | resp_body: "untagged", 99 | status: 200 100 | } = get(conn, "/untagged") 101 | end 102 | end 103 | end 104 | -------------------------------------------------------------------------------- /test/controllers/by_operation_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Parameters.ByOperationIdTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestGet 12 | version: 1.0.0 13 | paths: 14 | "/default": 15 | get: 16 | operationId: default 17 | responses: 18 | "200": 19 | description: OK 20 | "/tagged": 21 | get: 22 | operationId: tagged 23 | tags: [tag] 24 | responses: 25 | "200": 26 | description: OK 27 | "/untagged": 28 | get: 29 | operationId: untagged 30 | responses: 31 | "200": 32 | description: OK 33 | """, 34 | root: "/", 35 | controller: ApicalTest.Parameters.ByOperationIdTest, 36 | operation_ids: [ 37 | tagged: [controller: ApicalTest.Parameters.ByOperationIdTest.OperationId], 38 | untagged: [controller: ApicalTest.Parameters.ByOperationIdTest.OperationId] 39 | ], 40 | tags: [ 41 | tag: [controller: ApicalTest.Parameters.ByOperationIdTest.Unimplemented] 42 | ], 43 | encoding: "application/yaml" 44 | ) 45 | end 46 | 47 | defmodule OperationId do 48 | use Phoenix.Controller 49 | alias Plug.Conn 50 | 51 | for operation <- ~w(tagged untagged) do 52 | def unquote(:"#{operation}")(conn, _param) do 53 | Conn.resp(conn, 200, unquote(operation)) 54 | end 55 | end 56 | end 57 | 58 | use ApicalTest.EndpointCase 59 | alias Plug.Conn 60 | 61 | def default(conn, _param) do 62 | Conn.resp(conn, 200, "default") 63 | end 64 | 65 | describe "routing forwards to correct modules" do 66 | test "in the default case", %{conn: conn} do 67 | assert %{ 68 | resp_body: "default", 69 | status: 200 70 | } = get(conn, "/default") 71 | end 72 | 73 | test "when the operationId is tagged operationId takes precedence", %{conn: conn} do 74 | assert %{ 75 | resp_body: "tagged", 76 | status: 200 77 | } = get(conn, "/tagged") 78 | end 79 | 80 | test "when the operationId is untagged", %{conn: conn} do 81 | assert %{ 82 | resp_body: "untagged", 83 | status: 200 84 | } = get(conn, "/untagged") 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/controllers/by_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Parameters.ByTagTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestGet 12 | version: 1.0.0 13 | paths: 14 | "/default": 15 | get: 16 | operationId: default 17 | responses: 18 | "200": 19 | description: OK 20 | "/tagged": 21 | get: 22 | operationId: tagged 23 | tags: [tag] 24 | responses: 25 | "200": 26 | description: OK 27 | "/emptyFirst": 28 | get: 29 | operationId: emptyFirst 30 | tags: [empty, tag] 31 | responses: 32 | "200": 33 | description: OK 34 | "/tagFirst": 35 | get: 36 | operationId: tagFirst 37 | tags: [tag, empty] 38 | responses: 39 | "200": 40 | description: OK 41 | "/prioritized": 42 | get: 43 | operationId: prioritized 44 | tags: [tag, other] 45 | responses: 46 | "200": 47 | description: OK 48 | """, 49 | root: "/", 50 | controller: ApicalTest.Parameters.ByTagTest, 51 | tags: [ 52 | tag: [controller: ApicalTest.Parameters.ByTagTest.Tagged], 53 | other: [controller: ApicalTest.Parameters.ByTagTest.Unimplemented] 54 | ], 55 | encoding: "application/yaml" 56 | ) 57 | end 58 | 59 | defmodule Tagged do 60 | use Phoenix.Controller 61 | alias Plug.Conn 62 | 63 | for operation <- ~w(tagged emptyFirst tagFirst prioritized) do 64 | def unquote(:"#{operation}")(conn, _param) do 65 | Conn.resp(conn, 200, unquote(operation)) 66 | end 67 | end 68 | end 69 | 70 | use ApicalTest.EndpointCase 71 | alias Plug.Conn 72 | 73 | def default(conn, _param) do 74 | Conn.resp(conn, 200, "default") 75 | end 76 | 77 | describe "routing forwards to correct modules" do 78 | for operation <- ~w(default tagged emptyFirst tagFirst prioritized) do 79 | test "in the #{operation} case", %{conn: conn} do 80 | assert %{ 81 | resp_body: unquote(operation), 82 | status: 200 83 | } = get(conn, "/#{unquote(operation)}") 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/extra_plug/by_operation_id_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.ExtraPlug.ByOperationIdTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | alias Plug.Conn 8 | 9 | Apical.router_from_string( 10 | """ 11 | openapi: 3.1.0 12 | info: 13 | title: ByOperationIdTest 14 | version: 1.0.0 15 | paths: 16 | "/global": 17 | get: 18 | operationId: global 19 | responses: 20 | "200": 21 | description: OK 22 | "/tagged": 23 | get: 24 | operationId: tagged 25 | tags: [tag] 26 | responses: 27 | "200": 28 | description: OK 29 | "/by-operation-id": 30 | get: 31 | operationId: operation_id 32 | tags: [tag] 33 | responses: 34 | "200": 35 | description: OK 36 | """, 37 | root: "/", 38 | controller: ApicalTest.ExtraPlug.ByOperationIdTest, 39 | operation_ids: [ 40 | operation_id: [ 41 | extra_plugs: [ 42 | {:local_plug, ["local override operation_id"]} 43 | ] 44 | ] 45 | ], 46 | tags: [ 47 | tag: [ 48 | extra_plugs: [ 49 | {:local_plug, ["local override tag"]}, 50 | {ApicalTest.ExtraPlug, ["module override"]}, 51 | :tag_only_plug 52 | ] 53 | ] 54 | ], 55 | extra_plugs: [ 56 | :local_plug, 57 | {:local_plug, ["local option"]}, 58 | ApicalTest.ExtraPlug, 59 | {ApicalTest.ExtraPlug, ["module option"]} 60 | ], 61 | encoding: "application/yaml" 62 | ) 63 | 64 | def local_plug(conn, []) do 65 | Conn.put_private(conn, :extra_local_plug, "no options") 66 | end 67 | 68 | def local_plug(conn, [option]) do 69 | Conn.put_private(conn, :extra_local_plug_option, option) 70 | end 71 | 72 | def tag_only_plug(conn, []) do 73 | Conn.put_private(conn, :extra_tag_only_plug, "no options") 74 | end 75 | 76 | def operation_id_only_plug(conn, []) do 77 | Conn.put_private(conn, :extra_operation_id_only_plug, "no options") 78 | end 79 | end 80 | 81 | use ApicalTest.EndpointCase 82 | 83 | alias Plug.Conn 84 | 85 | for operation <- ~w(global tagged operation_id)a do 86 | def unquote(operation)(conn, _) do 87 | resp = 88 | conn.private 89 | |> Map.take([ 90 | :extra_module_plug, 91 | :extra_module_plug_option, 92 | :extra_local_plug, 93 | :extra_local_plug_option, 94 | :extra_tag_only_plug, 95 | :extra_operation_id_only_plug 96 | ]) 97 | |> Jason.encode!() 98 | 99 | conn 100 | |> Conn.put_resp_header("content-type", "application/json") 101 | |> Conn.resp(200, resp) 102 | end 103 | end 104 | 105 | describe "routing" do 106 | test "globally adds extra plugs", %{conn: conn} do 107 | assert %{ 108 | "extra_module_plug" => "no options", 109 | "extra_module_plug_option" => "module option", 110 | "extra_local_plug" => "no options", 111 | "extra_local_plug_option" => "local option" 112 | } == 113 | conn 114 | |> get("/global") 115 | |> json_response(200) 116 | end 117 | 118 | test "scoped to tag adds overrides plugs", %{conn: conn} do 119 | assert %{ 120 | "extra_tag_only_plug" => "no options", 121 | "extra_module_plug_option" => "module override", 122 | "extra_local_plug_option" => "local override tag" 123 | } == 124 | conn 125 | |> get("/tagged") 126 | |> json_response(200) 127 | end 128 | 129 | test "scoped to operation_id overrides global and tag extra plugs", %{conn: conn} do 130 | assert %{ 131 | "extra_local_plug_option" => "local override operation_id" 132 | } == 133 | conn 134 | |> get("/by-operation-id") 135 | |> json_response(200) 136 | end 137 | end 138 | end 139 | -------------------------------------------------------------------------------- /test/extra_plug/by_tag_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.ExtraPlug.ByTagTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | alias Plug.Conn 8 | 9 | Apical.router_from_string( 10 | """ 11 | openapi: 3.1.0 12 | info: 13 | title: ByTagTest 14 | version: 1.0.0 15 | paths: 16 | "/global": 17 | get: 18 | operationId: global 19 | responses: 20 | "200": 21 | description: OK 22 | "/tagged": 23 | get: 24 | operationId: tagged 25 | tags: [tag] 26 | responses: 27 | "200": 28 | description: OK 29 | """, 30 | root: "/", 31 | controller: ApicalTest.ExtraPlug.ByTagTest, 32 | tags: [ 33 | tag: [ 34 | extra_plugs: [ 35 | {:local_plug, ["local override"]}, 36 | {ApicalTest.ExtraPlug, ["module override"]}, 37 | :tag_only_plug 38 | ] 39 | ] 40 | ], 41 | extra_plugs: [ 42 | :local_plug, 43 | {:local_plug, ["local option"]}, 44 | ApicalTest.ExtraPlug, 45 | {ApicalTest.ExtraPlug, ["module option"]} 46 | ], 47 | encoding: "application/yaml" 48 | ) 49 | 50 | def local_plug(conn, []) do 51 | Conn.put_private(conn, :extra_local_plug, "no options") 52 | end 53 | 54 | def local_plug(conn, [option]) do 55 | Conn.put_private(conn, :extra_local_plug_option, option) 56 | end 57 | 58 | def tag_only_plug(conn, []) do 59 | Conn.put_private(conn, :extra_tag_only_plug, "no options") 60 | end 61 | end 62 | 63 | use ApicalTest.EndpointCase 64 | 65 | alias Plug.Conn 66 | 67 | for operation <- ~w(global tagged)a do 68 | def unquote(operation)(conn, _) do 69 | resp = 70 | conn.private 71 | |> Map.take([ 72 | :extra_module_plug, 73 | :extra_module_plug_option, 74 | :extra_local_plug, 75 | :extra_local_plug_option, 76 | :extra_tag_only_plug 77 | ]) 78 | |> Jason.encode!() 79 | 80 | conn 81 | |> Conn.put_resp_header("content-type", "application/json") 82 | |> Conn.resp(200, resp) 83 | end 84 | end 85 | 86 | describe "routing" do 87 | test "globally adds extra plugs", %{conn: conn} do 88 | assert %{ 89 | "extra_module_plug" => "no options", 90 | "extra_module_plug_option" => "module option", 91 | "extra_local_plug" => "no options", 92 | "extra_local_plug_option" => "local option" 93 | } == 94 | conn 95 | |> get("/global") 96 | |> json_response(200) 97 | end 98 | 99 | test "scoped to tag overrides extra plugs", %{conn: conn} do 100 | assert %{ 101 | "extra_tag_only_plug" => "no options", 102 | "extra_module_plug_option" => "module override", 103 | "extra_local_plug_option" => "local override" 104 | } == 105 | conn 106 | |> get("/tagged") 107 | |> json_response(200) 108 | end 109 | end 110 | end 111 | -------------------------------------------------------------------------------- /test/extra_plug/global_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.ExtraPlug.GlobalTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | alias Plug.Conn 8 | 9 | Apical.router_from_string( 10 | """ 11 | openapi: 3.1.0 12 | info: 13 | title: GlobalTest 14 | version: 1.0.0 15 | paths: 16 | "/global": 17 | get: 18 | operationId: global 19 | responses: 20 | "200": 21 | description: OK 22 | """, 23 | root: "/", 24 | controller: ApicalTest.ExtraPlug.GlobalTest, 25 | extra_plugs: [ 26 | :local_plug, 27 | {:local_plug, ["local option"]}, 28 | ApicalTest.ExtraPlug, 29 | {ApicalTest.ExtraPlug, ["module option"]} 30 | ], 31 | encoding: "application/yaml" 32 | ) 33 | 34 | def local_plug(conn, []) do 35 | Conn.put_private(conn, :extra_local_plug, "no options") 36 | end 37 | 38 | def local_plug(conn, [option]) do 39 | Conn.put_private(conn, :extra_local_plug_option, option) 40 | end 41 | end 42 | 43 | use ApicalTest.EndpointCase 44 | 45 | alias Plug.Conn 46 | 47 | def global(conn, _) do 48 | resp = 49 | conn.private 50 | |> Map.take([ 51 | :extra_module_plug, 52 | :extra_module_plug_option, 53 | :extra_local_plug, 54 | :extra_local_plug_option 55 | ]) 56 | |> Jason.encode!() 57 | 58 | conn 59 | |> Conn.put_resp_header("content-type", "application/json") 60 | |> Conn.resp(200, resp) 61 | end 62 | 63 | describe "routing" do 64 | test "adds extra plugs", %{conn: conn} do 65 | assert %{ 66 | "extra_module_plug" => "no options", 67 | "extra_module_plug_option" => "module option", 68 | "extra_local_plug" => "no options", 69 | "extra_local_plug_option" => "local option" 70 | } == 71 | conn 72 | |> get("/global") 73 | |> json_response(200) 74 | end 75 | end 76 | end 77 | -------------------------------------------------------------------------------- /test/from_file_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.FromFileTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_file( 8 | "test/_support/from_file_test.yaml", 9 | root: "/", 10 | controller: ApicalTest.FromFileTest 11 | ) 12 | end 13 | 14 | use ApicalTest.EndpointCase 15 | alias Plug.Conn 16 | 17 | def route(conn, params) do 18 | conn 19 | |> Conn.put_resp_header("content-type", "application/json") 20 | |> Conn.send_resp(200, Jason.encode!(params)) 21 | end 22 | 23 | test "GET /", %{conn: conn} do 24 | assert %{"number" => 47} = 25 | conn 26 | |> get("/47") 27 | |> json_response(200) 28 | end 29 | 30 | test "GET errors", %{conn: conn} do 31 | assert_raise Apical.Exceptions.ParameterError, 32 | "Parameter Error in operation route (in path): value `-42` at `/` fails schema criterion at `#/paths/~1%7Bnumber%7D/get/parameters/0/schema/minimum`", 33 | fn -> 34 | get(conn, "/-42") 35 | end 36 | end 37 | end 38 | -------------------------------------------------------------------------------- /test/parser/query_parser_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Parser.QueryParserTest do 2 | use ExUnit.Case, async: true 3 | 4 | alias Apical.Parser.Query 5 | 6 | describe "for the query parser - basics" do 7 | test "it works with empty string" do 8 | assert {:ok, %{}} = Query.parse("") 9 | end 10 | 11 | test "it works with basic one key parameter" do 12 | assert {:ok, %{"foo" => "bar"}} = Query.parse("foo=bar", %{"foo" => %{}}) 13 | end 14 | 15 | test "it works with basic multi key thing" do 16 | assert {:ok, %{"foo" => "bar"}} = 17 | Query.parse("foo=bar&baz=quux", %{"foo" => %{}, "baz" => %{}}) 18 | end 19 | 20 | test "percent encoding works" do 21 | assert {:ok, %{"foo" => "bar baz"}} = Query.parse("foo=bar%20baz", %{"foo" => %{}}) 22 | end 23 | end 24 | 25 | describe "exceptions with strange value strings" do 26 | test "value with no key defaults to empty string" do 27 | assert {:ok, %{"foo" => ""}} = Query.parse("foo=", %{"foo" => %{}}) 28 | end 29 | 30 | test "standalone value with no key defaults to empty string" do 31 | assert {:ok, %{"foo" => ""}} = Query.parse("foo", %{"foo" => %{}}) 32 | end 33 | end 34 | 35 | describe "array encoding" do 36 | test "with form encoding" do 37 | assert {:ok, %{"foo" => ["bar", "baz"]}} = 38 | Query.parse("foo=bar,baz", %{"foo" => %{type: [:array], style: :form}}) 39 | end 40 | 41 | test "with space delimited encoding" do 42 | assert {:ok, %{"foo" => ["bar", "baz"]}} = 43 | Query.parse("foo=bar%20baz", %{"foo" => %{type: [:array], style: :space_delimited}}) 44 | end 45 | 46 | test "with pipe delimited encoding" do 47 | assert {:ok, %{"foo" => ["bar", "baz"]}} = 48 | Query.parse("foo=bar%7Cbaz", %{"foo" => %{type: [:array], style: :pipe_delimited}}) 49 | 50 | assert {:ok, %{"foo" => ["bar", "baz"]}} = 51 | Query.parse("foo=bar%7cbaz", %{"foo" => %{type: [:array], style: :pipe_delimited}}) 52 | end 53 | end 54 | 55 | describe "object encoding" do 56 | test "with form encoding" do 57 | assert {:ok, %{"foo" => %{"bar" => "baz"}}} = 58 | Query.parse("foo=bar,baz", %{"foo" => %{type: [:object], style: :form}}) 59 | 60 | assert {:ok, %{"foo" => %{"bar" => "baz", "quux" => "mlem"}}} = 61 | Query.parse("foo=bar,baz,quux,mlem", %{ 62 | "foo" => %{type: [:object], style: :form} 63 | }) 64 | end 65 | 66 | test "with space delimited encoding" do 67 | assert {:ok, %{"foo" => %{"bar" => "baz"}}} = 68 | Query.parse("foo=bar%20baz", %{ 69 | "foo" => %{type: [:object], style: :space_delimited} 70 | }) 71 | end 72 | 73 | test "with pipe delimited encoding" do 74 | assert {:ok, %{"foo" => %{"bar" => "baz"}}} = 75 | Query.parse("foo=bar%7Cbaz", %{"foo" => %{type: [:object], style: :pipe_delimited}}) 76 | 77 | assert {:ok, %{"foo" => %{"bar" => "baz"}}} = 78 | Query.parse("foo=bar%7cbaz", %{"foo" => %{type: [:object], style: :pipe_delimited}}) 79 | end 80 | end 81 | 82 | describe "deep object encoding" do 83 | test "works" do 84 | assert {:ok, %{"foo" => %{"bar" => "baz"}}} = 85 | Query.parse("foo[bar]=baz", %{deep_object_keys: ["foo"]}) 86 | 87 | assert {:ok, %{"foo" => %{"bar" => "baz", "quux" => "mlem"}}} = 88 | Query.parse("foo[bar]=baz&foo[quux]=mlem", %{deep_object_keys: ["foo"]}) 89 | end 90 | end 91 | 92 | describe "deep array type marshalling" do 93 | test "works with a generic" do 94 | assert {:ok, %{"foo" => [1, 2, 3]}} = 95 | Query.parse("foo=1,2,3", %{ 96 | "foo" => %{type: [:array], style: :form, elements: {[], [:integer]}} 97 | }) 98 | end 99 | 100 | test "works with a tuple" do 101 | assert {:ok, %{"foo" => [1, true, "3"]}} = 102 | Query.parse("foo=1,true,3", %{ 103 | "foo" => %{ 104 | type: [:array], 105 | style: :form, 106 | elements: {[[:integer], [:boolean]], [:string]} 107 | } 108 | }) 109 | end 110 | 111 | test "works with a tuple and a generic" do 112 | assert {:ok, %{"foo" => [1, true, 3]}} = 113 | Query.parse("foo=1,true,3", %{ 114 | "foo" => %{ 115 | type: [:array], 116 | style: :form, 117 | elements: {[[:integer], [:boolean]], [:integer]} 118 | } 119 | }) 120 | end 121 | end 122 | 123 | describe "deep object type marshalling" do 124 | test "works with a parameter mapping" do 125 | assert {:ok, %{"foo" => %{"bar" => 1}}} = 126 | Query.parse("foo=bar,1", %{ 127 | "foo" => %{ 128 | type: [:object], 129 | style: :form, 130 | properties: {%{"bar" => [:integer]}, %{}, [:string]} 131 | } 132 | }) 133 | end 134 | 135 | test "works with a regex mapping" do 136 | assert {:ok, %{"foo" => %{"bar" => 1}}} = 137 | Query.parse("foo=bar,1", %{ 138 | "foo" => %{ 139 | type: [:object], 140 | style: :form, 141 | properties: {%{}, %{~r/b.*/ => [:integer]}, [:string]} 142 | } 143 | }) 144 | end 145 | 146 | test "works with a default mapping" do 147 | assert {:ok, %{"foo" => %{"bar" => 1}}} = 148 | Query.parse("foo=bar,1", %{ 149 | "foo" => %{ 150 | type: [:object], 151 | style: :form, 152 | properties: {%{}, %{}, [:integer]} 153 | } 154 | }) 155 | end 156 | end 157 | 158 | describe "exploded types" do 159 | test "works for array" do 160 | assert {:ok, %{"foo" => ["bar", "baz"]}} = 161 | Query.parse("foo=bar&foo=baz", %{ 162 | "foo" => %{type: [:array]}, 163 | exploded_array_keys: ["foo"] 164 | }) 165 | end 166 | 167 | test "works for object" do 168 | assert {:ok, %{"foo" => %{"bar" => "baz", "quux" => "mlem"}}} = 169 | Query.parse("foo[bar]=baz&foo[quux]=mlem", %{ 170 | "foo" => %{type: [:object]}, 171 | deep_object_keys: ["foo"] 172 | }) 173 | end 174 | 175 | test "works for type marshalling array" do 176 | assert {:ok, %{"foo" => [1, true, 2]}} = 177 | Query.parse("foo=1&foo=true&foo=2", %{ 178 | "foo" => %{type: [:array], elements: {[[:integer], [:boolean]], [:integer]}}, 179 | exploded_array_keys: ["foo"] 180 | }) 181 | end 182 | 183 | test "works for type marshalling object" do 184 | assert {:ok, %{"foo" => %{"foo" => 1, "bar" => true, "quux" => 3}}} = 185 | Query.parse("foo[foo]=1&foo[bar]=true&foo[quux]=3", %{ 186 | "foo" => %{ 187 | type: [:object], 188 | properties: {%{"foo" => [:integer]}, %{~r/b.*/ => [:boolean]}, [:integer]} 189 | }, 190 | deep_object_keys: ["foo"] 191 | }) 192 | end 193 | end 194 | end 195 | -------------------------------------------------------------------------------- /test/plug/aliased_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Plug.AliasedTest do 2 | use ApicalTest.EndpointCase, with: Plug 3 | alias Plug.Conn 4 | 5 | use Apical.Plug.Controller 6 | 7 | def aliased(conn, _params) do 8 | Conn.send_resp(conn, 200, "OK") 9 | end 10 | 11 | test "GET /" do 12 | assert %{ 13 | status: 200, 14 | body: "OK" 15 | } = Req.get!("http://localhost:#{@port}/") 16 | end 17 | end 18 | 19 | defmodule ApicalTest.Plug.AliasedTest.Router do 20 | use Apical.Plug.Router 21 | 22 | require Apical 23 | 24 | Apical.router_from_string( 25 | """ 26 | openapi: 3.1.0 27 | info: 28 | title: TestGet 29 | version: 1.0.0 30 | paths: 31 | "/": 32 | get: 33 | operationId: testGet 34 | responses: 35 | "200": 36 | description: OK 37 | """, 38 | for: Plug, 39 | root: "/", 40 | operation_ids: [testGet: [alias: :aliased]], 41 | controller: ApicalTest.Plug.AliasedTest, 42 | encoding: "application/yaml" 43 | ) 44 | end 45 | -------------------------------------------------------------------------------- /test/plug/get_path_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Plug.GetPathTest do 2 | use ApicalTest.EndpointCase, with: Plug 3 | alias Plug.Conn 4 | 5 | use Apical.Plug.Controller 6 | 7 | def testGet(conn, params) do 8 | conn 9 | |> Conn.put_resp_header("content-type", "application/json") 10 | |> Conn.send_resp(200, Jason.encode!(params)) 11 | end 12 | 13 | test "GET /one/two2" do 14 | assert %{ 15 | status: 200, 16 | body: %{ 17 | "one" => "one", 18 | "two" => "2" 19 | } 20 | } = Req.get!("http://localhost:#{@port}/one/two2") 21 | end 22 | end 23 | 24 | defmodule ApicalTest.Plug.GetPathTest.Router do 25 | use Apical.Plug.Router 26 | 27 | require Apical 28 | 29 | Apical.router_from_string( 30 | """ 31 | openapi: 3.1.0 32 | info: 33 | title: TestGet 34 | version: 1.0.0 35 | paths: 36 | "/{one}/two{two}": 37 | get: 38 | operationId: testGet 39 | responses: 40 | "200": 41 | description: OK 42 | """, 43 | for: Plug, 44 | root: "/", 45 | controller: ApicalTest.Plug.GetPathTest, 46 | encoding: "application/yaml" 47 | ) 48 | end 49 | -------------------------------------------------------------------------------- /test/plug/get_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Plug.GetTest do 2 | use ApicalTest.EndpointCase, with: Plug 3 | alias Plug.Conn 4 | 5 | use Apical.Plug.Controller 6 | 7 | def testGet(conn, _params) do 8 | Conn.send_resp(conn, 200, "OK") 9 | end 10 | 11 | test "GET /" do 12 | assert %{ 13 | status: 200, 14 | body: "OK" 15 | } = Req.get!("http://localhost:#{@port}/") 16 | end 17 | end 18 | 19 | defmodule ApicalTest.Plug.GetTest.Router do 20 | use Apical.Plug.Router 21 | 22 | require Apical 23 | 24 | Apical.router_from_string( 25 | """ 26 | openapi: 3.1.0 27 | info: 28 | title: TestGet 29 | version: 1.0.0 30 | paths: 31 | "/": 32 | get: 33 | operationId: testGet 34 | responses: 35 | "200": 36 | description: OK 37 | """, 38 | for: Plug, 39 | root: "/", 40 | controller: ApicalTest.Plug.GetTest, 41 | encoding: "application/yaml" 42 | ) 43 | end 44 | -------------------------------------------------------------------------------- /test/plug/operation_mf_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Plug.Operation.Module do 2 | use ApicalTest.EndpointCase, with: Plug 3 | alias Plug.Conn 4 | 5 | use Apical.Plug.Controller 6 | 7 | def fun(conn, _params) do 8 | Conn.send_resp(conn, 200, "OK") 9 | end 10 | 11 | test "GET /" do 12 | assert %{ 13 | status: 200, 14 | body: "OK" 15 | } = Req.get!("http://localhost:#{@port}/") 16 | end 17 | end 18 | 19 | defmodule ApicalTest.Plug.Operation.Module.Router do 20 | use Apical.Plug.Router 21 | 22 | require Apical 23 | 24 | Apical.router_from_string( 25 | """ 26 | openapi: 3.1.0 27 | info: 28 | title: TestGet 29 | version: 1.0.0 30 | paths: 31 | "/": 32 | get: 33 | operationId: Module.fun 34 | responses: 35 | "200": 36 | description: OK 37 | """, 38 | for: Plug, 39 | root: "/", 40 | controller: ApicalTest.Plug.Operation, 41 | encoding: "application/yaml" 42 | ) 43 | end 44 | -------------------------------------------------------------------------------- /test/refs/parameter_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Refs.ParameterObjectTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: ParameterObjectTest 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | get: 16 | operationId: parameterGet 17 | parameters: 18 | - $ref: "#/components/parameters/ParameterObjectTest" 19 | post: 20 | operationId: parameterPost 21 | parameters: 22 | - $ref: "#/components/parameters/ParameterObjectTest" 23 | components: 24 | parameters: 25 | ParameterObjectTest: 26 | name: required 27 | in: query 28 | required: true 29 | schema: 30 | type: integer 31 | """, 32 | root: "/", 33 | controller: ApicalTest.Refs.ParameterObjectTest, 34 | encoding: "application/yaml" 35 | ) 36 | end 37 | 38 | use ApicalTest.EndpointCase 39 | 40 | alias Apical.Exceptions.ParameterError 41 | alias Plug.Conn 42 | 43 | for operation <- ~w(parameterGet parameterPost)a do 44 | def unquote(operation)(conn, params) do 45 | conn 46 | |> Conn.put_resp_content_type("application/json") 47 | |> Conn.send_resp(200, Jason.encode!(params)) 48 | end 49 | end 50 | 51 | describe "for get route with shared parameter" do 52 | test "fails when missing parameter", %{conn: conn} do 53 | assert_raise ParameterError, 54 | "Parameter Error in operation parameterGet (in query): required parameter `required` not present", 55 | fn -> 56 | get(conn, "/") 57 | end 58 | end 59 | 60 | test "fails when not marshallable", %{conn: conn} do 61 | assert_raise ParameterError, 62 | "Parameter Error in operation parameterGet (in query): value `\"foo\"` at `/` fails schema criterion at `#/components/parameters/ParameterObjectTest/schema/type`", 63 | fn -> 64 | get(conn, "/?required=foo") 65 | end 66 | end 67 | 68 | test "works when marshallable", %{conn: conn} do 69 | assert %{"required" => 47} = 70 | conn 71 | |> get("/?required=47") 72 | |> json_response(200) 73 | end 74 | end 75 | 76 | describe "for post route with shared parameter" do 77 | test "fails when missing parameter", %{conn: conn} do 78 | assert_raise ParameterError, 79 | "Parameter Error in operation parameterPost (in query): required parameter `required` not present", 80 | fn -> 81 | post(conn, "/") 82 | end 83 | end 84 | 85 | test "fails when not marshallable", %{conn: conn} do 86 | assert_raise ParameterError, 87 | "Parameter Error in operation parameterPost (in query): value `\"foo\"` at `/` fails schema criterion at `#/components/parameters/ParameterObjectTest/schema/type`", 88 | fn -> 89 | post(conn, "/?required=foo") 90 | end 91 | end 92 | 93 | test "works when marshallable", %{conn: conn} do 94 | assert %{"required" => 47} = 95 | conn 96 | |> post("/?required=47") 97 | |> json_response(200) 98 | end 99 | end 100 | end 101 | -------------------------------------------------------------------------------- /test/refs/path_item_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Refs.PathItemObjectTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: PathItemObjectTest 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | "$ref": "#/components/pathItems/PathItemObjectTest" 16 | components: 17 | pathItems: 18 | PathItemObjectTest: 19 | get: 20 | operationId: testGet 21 | responses: 22 | "200": 23 | description: OK 24 | """, 25 | root: "/", 26 | controller: ApicalTest.Refs.PathItemObjectTest, 27 | encoding: "application/yaml" 28 | ) 29 | end 30 | 31 | use ApicalTest.EndpointCase 32 | alias Plug.Conn 33 | 34 | def testGet(conn, _params) do 35 | Conn.send_resp(conn, 200, "OK") 36 | end 37 | 38 | test "GET /", %{conn: conn} do 39 | assert %{ 40 | resp_body: "OK", 41 | status: 200 42 | } = get(conn, "/") 43 | end 44 | end 45 | -------------------------------------------------------------------------------- /test/refs/request_body_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Refs.RequestBodyObjectTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: RequestBodyObjectTest 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | post: 16 | operationId: testPost 17 | requestBody: 18 | "$ref": "#/components/requestBodies/RequestBodyObjectTest" 19 | components: 20 | requestBodies: 21 | RequestBodyObjectTest: 22 | content: 23 | "application/json": 24 | schema: 25 | type: array 26 | """, 27 | root: "/", 28 | controller: ApicalTest.Refs.RequestBodyObjectTest, 29 | encoding: "application/yaml" 30 | ) 31 | end 32 | 33 | use ApicalTest.EndpointCase 34 | 35 | alias Apical.Exceptions.ParameterError 36 | alias Plug.Conn 37 | 38 | def testPost(conn, params) do 39 | conn 40 | |> Conn.put_resp_content_type("application/json") 41 | |> Conn.send_resp(200, Jason.encode!(params)) 42 | end 43 | 44 | @array ["foo", "bar"] 45 | 46 | def do_post(conn, content) do 47 | encoded = Jason.encode!(content) 48 | length = byte_size(encoded) 49 | 50 | conn 51 | |> Conn.put_req_header("content-type", "application/json") 52 | |> Conn.put_req_header("content-length", "#{length}") 53 | |> post("/", Jason.encode!(content)) 54 | |> json_response(200) 55 | end 56 | 57 | test "POST /", %{conn: conn} do 58 | assert %{"_json" => @array} = do_post(conn, @array) 59 | end 60 | 61 | test "failure", %{conn: conn} do 62 | assert_raise ParameterError, 63 | "Parameter Error in operation testPost (in body): value `{\"foo\":\"bar\"}` at `/` fails schema criterion at `#/components/requestBodies/RequestBodyObjectTest/content/application~1json/schema/type`", 64 | fn -> 65 | do_post(conn, %{"foo" => "bar"}) 66 | end 67 | end 68 | end 69 | -------------------------------------------------------------------------------- /test/refs/schema_object_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Refs.SchemaObjectTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: ParameterObjectTest 12 | version: 1.0.0 13 | paths: 14 | "/via-parameter": 15 | get: 16 | operationId: parameterGet 17 | parameters: 18 | - $ref: "#/components/parameters/ParameterObjectTest" 19 | "/direct": 20 | get: 21 | operationId: schemaGet 22 | parameters: 23 | - name: required 24 | in: query 25 | required: true 26 | schema: 27 | $ref: "#/components/schemas/ParameterSchemaTest" 28 | components: 29 | parameters: 30 | ParameterObjectTest: 31 | name: required 32 | in: query 33 | required: true 34 | schema: 35 | $ref: "#/components/schemas/ParameterSchemaTest" 36 | schemas: 37 | ParameterSchemaTest: 38 | type: integer 39 | """, 40 | root: "/", 41 | controller: ApicalTest.Refs.SchemaObjectTest, 42 | encoding: "application/yaml" 43 | ) 44 | end 45 | 46 | use ApicalTest.EndpointCase 47 | 48 | alias Apical.Exceptions.ParameterError 49 | alias Plug.Conn 50 | 51 | for operation <- ~w(parameterGet schemaGet)a do 52 | def unquote(operation)(conn, params) do 53 | conn 54 | |> Conn.put_resp_content_type("application/json") 55 | |> Conn.send_resp(200, Jason.encode!(params)) 56 | end 57 | end 58 | 59 | describe "getting direct via parameter" do 60 | test "fails when missing parameter", %{conn: conn} do 61 | assert_raise ParameterError, 62 | "Parameter Error in operation schemaGet (in query): required parameter `required` not present", 63 | fn -> 64 | get(conn, "/direct") 65 | end 66 | end 67 | 68 | test "fails when not marshallable", %{conn: conn} do 69 | assert_raise ParameterError, 70 | "Parameter Error in operation schemaGet (in query): value `\"foo\"` at `/` fails schema criterion at `#/components/schemas/ParameterSchemaTest/type`.\nref_trace: [\"/paths/~1direct/get/parameters/0/schema/$ref\"]", 71 | fn -> 72 | get(conn, "/direct/?required=foo") 73 | end 74 | end 75 | 76 | test "works when marshallable", %{conn: conn} do 77 | assert %{"required" => 47} = 78 | conn 79 | |> get("/direct/?required=47") 80 | |> json_response(200) 81 | end 82 | end 83 | 84 | describe "getting indirect via parameter ref" do 85 | test "fails when missing parameter", %{conn: conn} do 86 | assert_raise ParameterError, 87 | "Parameter Error in operation parameterGet (in query): required parameter `required` not present", 88 | fn -> 89 | get(conn, "/via-parameter") 90 | end 91 | end 92 | 93 | test "fails when not marshallable", %{conn: conn} do 94 | assert_raise ParameterError, 95 | "Parameter Error in operation parameterGet (in query): value `\"foo\"` at `/` fails schema criterion at `#/components/schemas/ParameterSchemaTest/type`.\nref_trace: [\"/components/parameters/ParameterObjectTest/schema/$ref\"]", 96 | fn -> 97 | get(conn, "/via-parameter/?required=foo") 98 | end 99 | end 100 | 101 | test "works when marshallable", %{conn: conn} do 102 | assert %{"required" => 47} = 103 | conn 104 | |> get("/via-parameter/?required=47") 105 | |> json_response(200) 106 | end 107 | end 108 | end 109 | -------------------------------------------------------------------------------- /test/regression/ref_not_found_error_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Regression.RefNotFoundErrorTest do 2 | use ExUnit.Case, async: true 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: This API Fails 12 | version: '1.0' 13 | paths: 14 | "/foo": 15 | post: 16 | operationId: foo 17 | requestBody: 18 | required: true 19 | content: 20 | application/json: 21 | schema: 22 | $ref: '#/components/schemas/Root' 23 | components: 24 | schemas: 25 | Root: 26 | type: object 27 | properties: 28 | abc: 29 | $ref: '#/components/schemas/Leaf' 30 | Leaf: 31 | type: string 32 | """, 33 | encoding: "application/yaml", 34 | controller: __MODULE__ 35 | ) 36 | 37 | def init(_), do: raise "not called" 38 | 39 | # this schema used to trigger a compilation error 40 | test "works", do: :ok 41 | end 42 | -------------------------------------------------------------------------------- /test/request_body/form_encoded_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.RequestBody.FormEncodedTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: RequestBodyFormEncodedTest 12 | version: 1.0.0 13 | paths: 14 | "/object": 15 | post: 16 | operationId: requestBodyFormEncodedObject 17 | requestBody: 18 | content: 19 | "application/x-www-form-urlencoded": 20 | schema: 21 | type: object 22 | """, 23 | root: "/", 24 | controller: ApicalTest.RequestBody.FormEncodedTest, 25 | encoding: "application/yaml" 26 | ) 27 | end 28 | 29 | use ApicalTest.EndpointCase 30 | 31 | alias ApicalTest.RequestBody.FormEncodedTest.Endpoint 32 | alias Plug.Parsers.UnsupportedMediaTypeError 33 | alias Plug.Conn 34 | 35 | for operation <- 36 | ~w(requestBodyFormEncodedObject requestBodyFormEncodedArray requestBodyGeneric nest_all_json)a do 37 | def unquote(operation)(conn, params) do 38 | conn 39 | |> Conn.put_resp_content_type("application/json") 40 | |> Conn.send_resp(200, Jason.encode!(params)) 41 | end 42 | end 43 | 44 | defp do_post(conn, route, payload, content_type \\ "application/x-www-form-urlencoded") do 45 | content_length = byte_size(payload) 46 | 47 | conn 48 | |> Conn.put_req_header("content-length", "#{content_length}") 49 | |> Conn.put_req_header("content-type", content_type) 50 | |> post(route, payload) 51 | end 52 | 53 | describe "for posted object data" do 54 | test "it incorporates into params", %{conn: conn} do 55 | assert %{"foo" => "bar"} = 56 | conn 57 | |> do_post("/object", "foo=bar") 58 | |> json_response(200) 59 | 60 | assert %{"foo" => "bar", "baz" => "quux"} = 61 | conn 62 | |> do_post("/object", "foo=bar&baz=quux") 63 | |> json_response(200) 64 | end 65 | end 66 | 67 | describe "generic errors when posting" do 68 | # TODO: create a more meaningful apical error for this 69 | test "passing data with the wrong content-type", %{conn: conn} do 70 | assert_raise UnsupportedMediaTypeError, 71 | "unsupported media type text/csv", 72 | fn -> 73 | do_post(conn, "/object", "", "text/csv") 74 | end 75 | end 76 | 77 | test "passing data with no content-type", %{conn: conn} do 78 | assert %{plug_status: 400} = %Apical.Exceptions.MissingContentTypeError{} 79 | 80 | assert_raise Apical.Exceptions.MissingContentTypeError, "missing content-type header", fn -> 81 | conn 82 | |> Plug.Adapters.Test.Conn.conn(:post, "/object", "{}") 83 | |> Endpoint.call(Endpoint.init([])) 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /test/request_body/json_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.RequestBody.JsonTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: RequestBodyJsonTest 12 | version: 1.0.0 13 | paths: 14 | "/array": 15 | post: 16 | operationId: requestBodyJsonArray 17 | requestBody: 18 | content: 19 | "application/json": 20 | schema: 21 | type: array 22 | "/object": 23 | post: 24 | operationId: requestBodyJsonObject 25 | requestBody: 26 | content: 27 | "application/json": 28 | schema: 29 | type: object 30 | "/generic": 31 | post: 32 | operationId: requestBodyGeneric 33 | requestBody: 34 | content: 35 | "application/json": {} 36 | "/nest_all_json": 37 | post: 38 | operationId: nest_all_json 39 | requestBody: 40 | content: 41 | "application/json": 42 | schema: 43 | type: object 44 | """, 45 | root: "/", 46 | controller: ApicalTest.RequestBody.JsonTest, 47 | encoding: "application/yaml", 48 | operation_ids: [ 49 | nest_all_json: [nest_all_json: true] 50 | ] 51 | ) 52 | end 53 | 54 | use ApicalTest.EndpointCase 55 | 56 | alias Plug.Parsers.UnsupportedMediaTypeError 57 | alias Plug.Conn 58 | alias Apical.Exceptions.ParameterError 59 | alias ApicalTest.RequestBody.JsonTest.Endpoint 60 | 61 | for operation <- 62 | ~w(requestBodyJsonObject requestBodyJsonArray requestBodyGeneric nest_all_json)a do 63 | def unquote(operation)(conn, params) do 64 | conn 65 | |> Conn.put_resp_content_type("application/json") 66 | |> Conn.send_resp(200, Jason.encode!(params)) 67 | end 68 | end 69 | 70 | defp do_post(conn, route, payload, content_type \\ "application/json") do 71 | payload_binary = Jason.encode!(payload) 72 | content_length = byte_size(payload_binary) 73 | 74 | conn 75 | |> Conn.put_req_header("content-type", content_type) 76 | |> Conn.put_req_header("content-length", "#{content_length}") 77 | |> post(route, payload_binary) 78 | end 79 | 80 | describe "for posted array data" do 81 | test "it is nested params under json", %{conn: conn} do 82 | assert %{"_json" => ["foo", "bar"]} = 83 | conn 84 | |> do_post("/array", ["foo", "bar"]) 85 | |> json_response(200) 86 | end 87 | 88 | test "passing wrong data", %{conn: conn} do 89 | assert_raise ParameterError, 90 | "Parameter Error in operation requestBodyJsonArray (in body): value `{\"foo\":\"bar\"}` at `/` fails schema criterion at `#/paths/~1array/post/requestBody/content/application~1json/schema/type`", 91 | fn -> 92 | do_post(conn, "/array", %{"foo" => "bar"}) 93 | end 94 | end 95 | end 96 | 97 | describe "for posted object data" do 98 | test "it incorporates into params", %{conn: conn} do 99 | assert %{"foo" => "bar"} = 100 | conn 101 | |> do_post("/object", %{"foo" => "bar"}) 102 | |> json_response(200) 103 | end 104 | 105 | test "passing wrong data", %{conn: conn} do 106 | assert_raise ParameterError, 107 | "Parameter Error in operation requestBodyJsonObject (in body): value `[\"foo\",\"bar\"]` at `/` fails schema criterion at `#/paths/~1object/post/requestBody/content/application~1json/schema/type`", 108 | fn -> 109 | do_post(conn, "/object", ["foo", "bar"]) 110 | end 111 | end 112 | end 113 | 114 | describe "for posted scalar data" do 115 | test "null is nested params under json", %{conn: conn} do 116 | assert %{"_json" => nil} = 117 | conn 118 | |> do_post("/generic", nil) 119 | |> json_response(200) 120 | end 121 | 122 | test "boolean is nested params under json", %{conn: conn} do 123 | assert %{"_json" => true} = 124 | conn 125 | |> do_post("/generic", true) 126 | |> json_response(200) 127 | end 128 | 129 | test "number is nested params under json", %{conn: conn} do 130 | assert %{"_json" => 4.7} = 131 | conn 132 | |> do_post("/generic", 4.7) 133 | |> json_response(200) 134 | end 135 | 136 | test "string is nested params under json", %{conn: conn} do 137 | assert %{"_json" => "string"} = 138 | conn 139 | |> do_post("/generic", "string") 140 | |> json_response(200) 141 | end 142 | 143 | test "array is nested params under json", %{conn: conn} do 144 | assert %{"_json" => [1, 2]} = 145 | conn 146 | |> do_post("/generic", [1, 2]) 147 | |> json_response(200) 148 | end 149 | 150 | test "object is not nested", %{conn: conn} do 151 | assert %{"foo" => "bar"} = 152 | conn 153 | |> do_post("/generic", %{"foo" => "bar"}) 154 | |> json_response(200) 155 | end 156 | end 157 | 158 | describe "generic errors when posting" do 159 | # TODO: create a more meaningful apical error for this 160 | test "passing data with the wrong content-type", %{conn: conn} do 161 | assert_raise UnsupportedMediaTypeError, 162 | "unsupported media type text/csv", 163 | fn -> 164 | do_post(conn, "/object", %{}, "text/csv") 165 | end 166 | end 167 | 168 | test "passing data with no content-type", %{conn: conn} do 169 | assert %{plug_status: 400} = %Apical.Exceptions.MissingContentTypeError{} 170 | 171 | assert_raise Apical.Exceptions.MissingContentTypeError, "missing content-type header", fn -> 172 | conn 173 | |> Plug.Adapters.Test.Conn.conn(:post, "/object", "{}") 174 | |> Endpoint.call(Endpoint.init([])) 175 | end 176 | end 177 | end 178 | 179 | describe "object with nest_all_json option" do 180 | test "nests the json in the _json field", %{conn: conn} do 181 | assert %{"_json" => %{"foo" => "bar"}} = 182 | conn 183 | |> do_post("/nest_all_json", %{"foo" => "bar"}) 184 | |> json_response(200) 185 | end 186 | end 187 | 188 | test "unparsable not-json-content", %{conn: conn} do 189 | payload_binary = "{" 190 | content_length = byte_size(payload_binary) 191 | 192 | assert_raise ParameterError, 193 | "Parameter Error in operation requestBodyGeneric (in body): error fetching request body (unexpected end of input at position 1)", 194 | fn -> 195 | conn 196 | |> Conn.put_req_header("content-type", "application/json") 197 | |> Conn.put_req_header("content-length", "#{content_length}") 198 | |> post("/generic", payload_binary) 199 | end 200 | end 201 | end 202 | -------------------------------------------------------------------------------- /test/request_body/other_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.RequestBody.OtherTest do 2 | defmodule GenericSource do 3 | @behaviour Apical.Plugs.RequestBody.Source 4 | alias Plug.Conn 5 | 6 | @impl true 7 | def fetch(conn, _, opts) do 8 | response = Keyword.get(opts, :tag, "generic") 9 | 10 | {:ok, Conn.put_private(conn, :response, response)} 11 | end 12 | 13 | @impl true 14 | def validate!(_, _), do: :ok 15 | end 16 | 17 | defmodule Router do 18 | use Phoenix.Router 19 | 20 | require Apical 21 | alias ApicalTest.RequestBody.OtherTest.GenericSource 22 | 23 | Apical.router_from_string( 24 | """ 25 | openapi: 3.1.0 26 | info: 27 | title: RequestBodyOtherTest 28 | version: 1.0.0 29 | paths: 30 | "/no-parser": 31 | post: 32 | operationId: requestBodyNoParser 33 | requestBody: 34 | content: 35 | "text/csv": {} 36 | "/multi-parser": 37 | post: 38 | operationId: requestBodyMultiParser 39 | requestBody: 40 | content: 41 | "*/*": {} 42 | "application/*": {} 43 | "application/x-foo": {} 44 | "application/x-foo; charset=utf-8": {} 45 | "/tag-parser": 46 | post: 47 | operationId: requestBodyTagParser 48 | tags: [tag] 49 | requestBody: 50 | content: 51 | "application/*": {} 52 | "application/x-foo": {} 53 | "/operation-parser": 54 | post: 55 | operationId: requestBodyOperationParser 56 | tags: [tag] 57 | requestBody: 58 | content: 59 | "application/*": {} 60 | "application/x-foo": {} 61 | """, 62 | root: "/", 63 | controller: ApicalTest.RequestBody.OtherTest, 64 | encoding: "application/yaml", 65 | content_sources: [ 66 | {"*/*", GenericSource}, 67 | {"application/*", {GenericSource, tag: "application generic"}}, 68 | {"application/x-foo", {GenericSource, tag: "application specific"}}, 69 | {"application/x-foo; charset=utf-8", {GenericSource, tag: "application option"}} 70 | ], 71 | tags: [ 72 | tag: [ 73 | content_sources: [{"application/*", {GenericSource, tag: "application tagged"}}] 74 | ] 75 | ], 76 | operation_ids: [ 77 | requestBodyOperationParser: [ 78 | content_sources: [{"application/*", {GenericSource, tag: "application operation_id"}}] 79 | ] 80 | ] 81 | ) 82 | end 83 | 84 | use ApicalTest.EndpointCase 85 | 86 | alias Apical.Exceptions.InvalidContentTypeError 87 | alias Plug.Conn 88 | 89 | def requestBodyNoParser(conn, _params) do 90 | [content_type] = Conn.get_req_header(conn, "content-type") 91 | 92 | {:ok, body, conn} = Conn.read_body(conn) 93 | 94 | conn 95 | |> Conn.put_resp_content_type(content_type) 96 | |> Conn.send_resp(200, body) 97 | end 98 | 99 | for operation <- ~w(requestBodyMultiParser requestBodyTagParser requestBodyOperationParser)a do 100 | def unquote(operation)(conn, _params) do 101 | conn 102 | |> Conn.put_resp_content_type("text/plain") 103 | |> Conn.send_resp(200, conn.private.response) 104 | 105 | # note that conn.private.response comes from GenericSource 106 | end 107 | end 108 | 109 | defp do_post(conn, route, payload, content_type) do 110 | conn 111 | |> Conn.put_req_header("content-type", content_type) 112 | |> Conn.put_req_header("content-length", "#{byte_size(payload)}") 113 | |> post(route, payload) 114 | |> Map.get(:resp_body) 115 | end 116 | 117 | describe "for posted object data" do 118 | test "it incorporates into params", %{conn: conn} do 119 | assert "foo" = do_post(conn, "/no-parser", "foo", "text/csv") 120 | end 121 | end 122 | 123 | describe "when multiple content-types are declared" do 124 | test "the fully generic content-type is accepted when it doesn't match", %{conn: conn} do 125 | assert "generic" = do_post(conn, "/multi-parser", "foo", "text/csv") 126 | end 127 | 128 | test "the content-supertype can be selected when the subtype doesn't match", %{conn: conn} do 129 | assert "application generic" = do_post(conn, "/multi-parser", "foo", "application/x-bar") 130 | end 131 | 132 | test "the content-subtype overrides generic matches", %{conn: conn} do 133 | assert "application specific" = do_post(conn, "/multi-parser", "foo", "application/x-foo") 134 | end 135 | 136 | test "the content-subtype with option overrides generic matches", %{conn: conn} do 137 | assert "application option" = 138 | do_post(conn, "/multi-parser", "foo", "application/x-foo; charset=utf-8") 139 | end 140 | end 141 | 142 | describe "when content-type is declared by tag" do 143 | test "the fully generic content-type is accepted when it doesn't match", %{conn: conn} do 144 | assert "application specific" = do_post(conn, "/tag-parser", "foo", "application/x-foo") 145 | end 146 | 147 | test "the content-subtype with option overrides generic matches", %{conn: conn} do 148 | assert "application tagged" = do_post(conn, "/tag-parser", "foo", "application/x-bar") 149 | end 150 | end 151 | 152 | describe "when content-type is declared by operation_id" do 153 | test "the fully generic content-type is accepted when it doesn't match", %{conn: conn} do 154 | assert "application specific" = 155 | do_post(conn, "/operation-parser", "foo", "application/x-foo") 156 | end 157 | 158 | test "the content-subtype with option overrides generic matches", %{conn: conn} do 159 | assert "application operation_id" = 160 | do_post(conn, "/operation-parser", "foo", "application/x-bar") 161 | end 162 | end 163 | 164 | @payload "foo,bar" 165 | @payload_length byte_size(@payload) 166 | 167 | alias Apical.Exceptions.MissingContentTypeError 168 | alias Apical.Exceptions.MultipleContentTypeError 169 | alias Apical.Exceptions.InvalidContentTypeError 170 | 171 | alias Apical.Exceptions.MissingContentLengthError 172 | alias Apical.Exceptions.MultipleContentLengthError 173 | alias Apical.Exceptions.InvalidContentLengthError 174 | 175 | defp manual_call(conn) do 176 | # we have to do this manually since dispatch/5 doesn't let us have fun 177 | conn 178 | |> Plug.Adapters.Test.Conn.conn(:post, "/no-parser", @payload) 179 | |> @endpoint.call(@endpoint.init([])) 180 | end 181 | 182 | test "generic missing content-type error", %{conn: conn} do 183 | assert_raise MissingContentTypeError, fn -> 184 | conn 185 | |> Conn.put_req_header("content-length", "#{@payload_length}") 186 | |> manual_call 187 | end 188 | end 189 | 190 | test "duplicate content-type error", %{conn: conn} do 191 | assert_raise MultipleContentTypeError, fn -> 192 | # we have to do this manually since dispatch/5 doesn't let us have fun 193 | conn 194 | |> Map.update!( 195 | :req_headers, 196 | &[{"content-type", "text/csv"}, {"content-type", "text/csv"} | &1] 197 | ) 198 | |> Conn.put_req_header("content-length", "#{@payload_length}") 199 | |> manual_call 200 | end 201 | end 202 | 203 | test "invalid content-type error", %{conn: conn} do 204 | assert_raise InvalidContentTypeError, fn -> 205 | do_post(conn, "/no-parser", @payload, "this-is-not-a-content-type") 206 | end 207 | end 208 | 209 | test "generic missing content-length error", %{conn: conn} do 210 | assert_raise MissingContentLengthError, fn -> 211 | # we have to do this manually since dispatch/5 doesn't let us have fun 212 | conn 213 | |> Conn.put_req_header("content-type", "text/csv") 214 | |> manual_call 215 | end 216 | end 217 | 218 | test "generic multiple content-length error", %{conn: conn} do 219 | assert_raise MultipleContentLengthError, fn -> 220 | # we have to do this manually since dispatch/5 doesn't let us have fun 221 | conn 222 | |> Conn.put_req_header("content-type", "text/csv") 223 | |> Map.update!( 224 | :req_headers, 225 | &[{"content-length", "#{@payload_length}"}, {"content-length", "#{@payload_length}"} | &1] 226 | ) 227 | |> manual_call 228 | end 229 | end 230 | 231 | test "generic invalid content-length error", %{conn: conn} do 232 | assert_raise InvalidContentLengthError, 233 | "invalid content-length header provided: not-a-number", 234 | fn -> 235 | # we have to do this manually since dispatch/5 doesn't let us have fun 236 | conn 237 | |> Conn.put_req_header("content-type", "text/csv") 238 | |> Conn.put_req_header("content-length", "not-a-number") 239 | |> manual_call 240 | end 241 | end 242 | end 243 | -------------------------------------------------------------------------------- /test/test_helper.exs: -------------------------------------------------------------------------------- 1 | Logger.configure(level: :info) 2 | 3 | :application.ensure_all_started(:bypass) 4 | :application.ensure_all_started(:mox) 5 | 6 | ExUnit.start() 7 | -------------------------------------------------------------------------------- /test/test_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.TestTest do 2 | # tests using Apical in "test" mode where it creates a bypass server. 3 | 4 | use ExUnit.Case, async: true 5 | 6 | alias ApicalTest.TestTest.Router 7 | alias ApicalTest.TestTest.Mock 8 | 9 | setup do 10 | bypass = Bypass.open() 11 | Router.bypass(bypass) 12 | {:ok, bypass: bypass} 13 | end 14 | 15 | test "content can be served", %{bypass: bypass} do 16 | Mox.expect(Mock, :testGet, fn conn, _params -> 17 | Plug.Conn.send_resp(conn, 200, "OK") 18 | end) 19 | 20 | assert %{status: 200} = Req.get!("http://localhost:#{bypass.port}/?foo=bar") 21 | end 22 | 23 | test "content can be rejected", %{bypass: bypass} do 24 | # this one should never reach because it's filtered out as a 400 before it gets 25 | # to the controller. 26 | 27 | assert %{status: 400} = Req.get!("http://localhost:#{bypass.port}/?foo=baz") 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /test/verbs/delete_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.DeleteTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestDelete 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | delete: 16 | operationId: testDelete 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.DeleteTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testDelete(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "DELETE /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = delete(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/verbs/get_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.GetTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestGet 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | get: 16 | operationId: testGet 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.GetTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testGet(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "GET /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = get(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/verbs/head_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.HeadTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestHead 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | head: 16 | operationId: testHead 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.HeadTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testHead(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "HEAD /", %{conn: conn} do 35 | # NB: HEAD requests should not have a response body 36 | assert %{ 37 | resp_body: "", 38 | status: 200 39 | } = head(conn, "/") 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /test/verbs/options_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.OptionsTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestOptions 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | options: 16 | operationId: testOptions 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.OptionsTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testOptions(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "OPTIONS /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = options(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/verbs/patch_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.PatchTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestPatch 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | patch: 16 | operationId: testPatch 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.PatchTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testPatch(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "PATCH /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = patch(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/verbs/post_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.PostTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestPost 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | post: 16 | operationId: testPost 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.PostTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testPost(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "POST /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = post(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/verbs/put_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.PutTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestPut 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | put: 16 | operationId: testPut 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.PutTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testPut(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "PUT /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = put(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/verbs/trace_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Verbs.TraceTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: TestTrace 12 | version: 1.0.0 13 | paths: 14 | "/": 15 | trace: 16 | operationId: testTrace 17 | responses: 18 | "200": 19 | description: OK 20 | """, 21 | root: "/", 22 | controller: ApicalTest.Verbs.TraceTest, 23 | encoding: "application/yaml" 24 | ) 25 | end 26 | 27 | use ApicalTest.EndpointCase 28 | alias Plug.Conn 29 | 30 | def testTrace(conn, _params) do 31 | Conn.send_resp(conn, 200, "OK") 32 | end 33 | 34 | test "TRACE /", %{conn: conn} do 35 | assert %{ 36 | resp_body: "OK", 37 | status: 200 38 | } = trace(conn, "/") 39 | end 40 | end 41 | -------------------------------------------------------------------------------- /test/versioning/by_assigns_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Versioning.ByAssignsTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: VersioningByAssignsTest 12 | version: 1.0.0 13 | paths: 14 | "/shared": 15 | get: 16 | operationId: sharedOp 17 | responses: 18 | "200": 19 | description: OK 20 | "/forked": 21 | get: 22 | operationId: forkedOp 23 | responses: 24 | "200": 25 | description: OK 26 | """, 27 | controller: ApicalTest.Versioning.ByAssignsTest, 28 | encoding: "application/yaml" 29 | ) 30 | 31 | Apical.router_from_string( 32 | """ 33 | openapi: 3.1.0 34 | info: 35 | title: VersioningByAssignsTest 36 | version: 2.0.0 37 | paths: 38 | "/shared": 39 | get: 40 | operationId: sharedOp 41 | responses: 42 | "200": 43 | description: OK 44 | "/forked": 45 | get: 46 | operationId: forkedOp 47 | responses: 48 | "200": 49 | description: OK 50 | """, 51 | controller: ApicalTest.Versioning.ByAssignsTest, 52 | encoding: "application/yaml" 53 | ) 54 | end 55 | 56 | use ApicalTest.EndpointCase 57 | alias Plug.Conn 58 | 59 | def sharedOp(conn, _param) do 60 | Conn.resp(conn, 200, "shared") 61 | end 62 | 63 | def forkedOp(conn = %{assigns: %{api_version: "1.0.0"}}, _param) do 64 | Conn.resp(conn, 200, "v1") 65 | end 66 | 67 | def forkedOp(conn = %{assigns: %{api_version: "2.0.0"}}, _param) do 68 | Conn.resp(conn, 200, "v2") 69 | end 70 | 71 | describe "for shared routes" do 72 | test "v1 routes to shared", %{conn: conn} do 73 | assert %{ 74 | resp_body: "shared", 75 | status: 200 76 | } = get(conn, "/v1/shared") 77 | end 78 | 79 | test "v2 routes to shared", %{conn: conn} do 80 | assert %{ 81 | resp_body: "shared", 82 | status: 200 83 | } = get(conn, "/v2/shared") 84 | end 85 | end 86 | 87 | describe "for forked routes" do 88 | test "v1 routes to forked", %{conn: conn} do 89 | assert %{ 90 | resp_body: "v1", 91 | status: 200 92 | } = get(conn, "/v1/forked") 93 | end 94 | 95 | test "v2 routes to shared", %{conn: conn} do 96 | assert %{ 97 | resp_body: "v2", 98 | status: 200 99 | } = get(conn, "/v2/forked") 100 | end 101 | end 102 | end 103 | -------------------------------------------------------------------------------- /test/versioning/by_controller_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Versioning.ByControllerTest do 2 | defmodule Router do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: VersioningByControllerTest 12 | version: 1.0.0 13 | paths: 14 | "/shared": 15 | get: 16 | operationId: sharedOp 17 | responses: 18 | "200": 19 | description: OK 20 | "/forked": 21 | get: 22 | operationId: forkedOp 23 | responses: 24 | "200": 25 | description: OK 26 | """, 27 | operation_ids: [ 28 | sharedOp: [controller: ApicalTest.Versioning.ByControllerTest.SharedController], 29 | forkedOp: [controller: ApicalTest.Versioning.ByControllerTest.V1.Controller] 30 | ], 31 | encoding: "application/yaml" 32 | ) 33 | 34 | Apical.router_from_string( 35 | """ 36 | openapi: 3.1.0 37 | info: 38 | title: VersioningByControllerTest 39 | version: 2.0.0 40 | paths: 41 | "/shared": 42 | get: 43 | operationId: sharedOp 44 | responses: 45 | "200": 46 | description: OK 47 | "/forked": 48 | get: 49 | operationId: forkedOp 50 | responses: 51 | "200": 52 | description: OK 53 | """, 54 | operation_ids: [ 55 | sharedOp: [controller: ApicalTest.Versioning.ByControllerTest.SharedController], 56 | forkedOp: [controller: ApicalTest.Versioning.ByControllerTest.V2.Controller] 57 | ], 58 | encoding: "application/yaml" 59 | ) 60 | end 61 | 62 | defmodule SharedController do 63 | use Phoenix.Controller 64 | alias Plug.Conn 65 | 66 | def sharedOp(conn, _param) do 67 | Conn.resp(conn, 200, "shared") 68 | end 69 | end 70 | 71 | defmodule V1.Controller do 72 | use Phoenix.Controller 73 | alias Plug.Conn 74 | 75 | def forkedOp(conn, _param) do 76 | Conn.resp(conn, 200, "v1") 77 | end 78 | end 79 | 80 | defmodule V2.Controller do 81 | use Phoenix.Controller 82 | alias Plug.Conn 83 | 84 | def forkedOp(conn, _param) do 85 | Conn.resp(conn, 200, "v2") 86 | end 87 | end 88 | 89 | use ApicalTest.EndpointCase 90 | 91 | describe "for shared routes" do 92 | test "v1 routes to shared", %{conn: conn} do 93 | assert %{ 94 | resp_body: "shared", 95 | status: 200 96 | } = get(conn, "/v1/shared") 97 | end 98 | 99 | test "v2 routes to shared", %{conn: conn} do 100 | assert %{ 101 | resp_body: "shared", 102 | status: 200 103 | } = get(conn, "/v2/shared") 104 | end 105 | end 106 | 107 | describe "for forked routes" do 108 | test "v1 routes to forked", %{conn: conn} do 109 | assert %{ 110 | resp_body: "v1", 111 | status: 200 112 | } = get(conn, "/v1/forked") 113 | end 114 | 115 | test "v2 routes to shared", %{conn: conn} do 116 | assert %{ 117 | resp_body: "v2", 118 | status: 200 119 | } = get(conn, "/v2/forked") 120 | end 121 | end 122 | end 123 | -------------------------------------------------------------------------------- /test/versioning/by_different_routers_test.exs: -------------------------------------------------------------------------------- 1 | defmodule ApicalTest.Versioning.ByDifferentRoutersTest do 2 | defmodule Router1 do 3 | use Phoenix.Router 4 | 5 | require Apical 6 | 7 | Apical.router_from_string( 8 | """ 9 | openapi: 3.1.0 10 | info: 11 | title: VersioningByDifferentRoutersTest 12 | version: 1.0.0 13 | paths: 14 | "/shared": 15 | get: 16 | operationId: sharedOp 17 | responses: 18 | "200": 19 | description: OK 20 | "/forked": 21 | get: 22 | operationId: forkedOp 23 | responses: 24 | "200": 25 | description: OK 26 | """, 27 | controller: ApicalTest.Versioning.ByDifferentRoutersTest, 28 | encoding: "application/yaml", 29 | root: "/" 30 | ) 31 | end 32 | 33 | defmodule Router2 do 34 | use Phoenix.Router 35 | 36 | require Apical 37 | 38 | Apical.router_from_string( 39 | """ 40 | openapi: 3.1.0 41 | info: 42 | title: VersioningByDifferentRoutersTest 43 | version: 2.0.0 44 | paths: 45 | "/shared": 46 | get: 47 | operationId: sharedOp 48 | responses: 49 | "200": 50 | description: OK 51 | "/forked": 52 | get: 53 | operationId: forkedOp 54 | responses: 55 | "200": 56 | description: OK 57 | """, 58 | controller: ApicalTest.Versioning.ByDifferentRoutersTest, 59 | encoding: "application/yaml", 60 | root: "/" 61 | ) 62 | end 63 | 64 | defmodule Router do 65 | use Phoenix.Router 66 | 67 | scope "/" do 68 | forward("/v1", Router1) 69 | end 70 | 71 | scope "/" do 72 | forward("/v2", Router2) 73 | end 74 | end 75 | 76 | use ApicalTest.EndpointCase 77 | alias Plug.Conn 78 | 79 | def sharedOp(conn, _param) do 80 | Conn.resp(conn, 200, "shared") 81 | end 82 | 83 | def forkedOp(conn = %{assigns: %{api_version: "1.0.0"}}, _param) do 84 | Conn.resp(conn, 200, "v1") 85 | end 86 | 87 | def forkedOp(conn = %{assigns: %{api_version: "2.0.0"}}, _param) do 88 | Conn.resp(conn, 200, "v2") 89 | end 90 | 91 | describe "for shared routes" do 92 | test "v1 routes to shared", %{conn: conn} do 93 | assert %{ 94 | resp_body: "shared", 95 | status: 200 96 | } = get(conn, "/v1/shared") 97 | end 98 | 99 | test "v2 routes to shared", %{conn: conn} do 100 | assert %{ 101 | resp_body: "shared", 102 | status: 200 103 | } = get(conn, "/v2/shared") 104 | end 105 | end 106 | 107 | describe "for forked routes" do 108 | test "v1 routes to forked", %{conn: conn} do 109 | assert %{ 110 | resp_body: "v1", 111 | status: 200 112 | } = get(conn, "/v1/forked") 113 | end 114 | 115 | test "v2 routes to shared", %{conn: conn} do 116 | assert %{ 117 | resp_body: "v2", 118 | status: 200 119 | } = get(conn, "/v2/forked") 120 | end 121 | end 122 | end 123 | --------------------------------------------------------------------------------