diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..b24eaf93 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,37 @@ +## Description of the change + +> Description here + +## Type of change +- [ ] Bug fix (non-breaking change that fixes an issue) +- [ ] New feature (non-breaking change that adds functionality) +- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected) +- [ ] Configuration change +- [ ] Technical Debt +- [ ] Documentation + +## Related tickets + +> Link to Shortcut/Jira Ticket Goes Here + +## Checklists + +### Development and Testing + +- [ ] Lint rules pass locally. +- [ ] The code changed/added as part of this pull request has been covered with tests, or this PR does not alter production code. +- [ ] All tests related to the changed code pass in development, or tests are not applicable. + +### Code Review + +- [ ] This pull request has a descriptive title and information useful to a reviewer. There may be a screenshot or screencast attached. +- [ ] At least two engineers have been added as "Reviewers" on the pull request. +- [ ] Changes have been reviewed by at least two other engineers who did not write the code. +- [ ] This branch has been rebased off master to be current. + +### Tracking +- [ ] Issue from Shortcut/Jira has a link to this pull request. +- [ ] This PR has a link to the issue in Shortcut. + +### QA +- [ ] This branch has been deployed to staging and tested. diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index e2cf4200..f8dee5a7 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -12,17 +12,17 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - otp: ['25'] - elixir: ['1.13'] + otp: ["26"] + elixir: ["1.16"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 id: beam with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - name: PLT cache - uses: actions/cache@v2 + uses: actions/cache@v4 with: key: | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plt @@ -34,7 +34,8 @@ jobs: - run: mix compile --warnings-as-errors - run: mix format --check-formatted - run: mix credo --strict --all - - run: mix dialyzer + - run: mix dialyzer --format github + - run: mix docs --warnings-as-errors test_examples: runs-on: ubuntu-latest @@ -42,12 +43,13 @@ jobs: env: MIX_ENV: test steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 + id: beam with: - otp-version: 24 - elixir-version: 1.13 - - uses: actions/cache@v2 + otp-version: 26 + elixir-version: 1.16 + - uses: actions/cache@v4 with: key: | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plug-build @@ -61,24 +63,28 @@ jobs: run: mix do deps.get, test test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 name: Test (OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}) strategy: matrix: - otp: ['22', '23', '24', '25'] - elixir: ['1.10', '1.11', '1.12', '1.13'] + otp: ["24", "25", "26"] + elixir: ["1.14", "1.15", "1.16", "1.18"] + # Test each elixir version with lowest and highest compatible OTP version, exclude others + # See https://hexdocs.pm/elixir/compatibility-and-deprecations.html#between-elixir-and-erlang-otp exclude: - - {otp: '24', elixir: '1.10'} - - {otp: '25', elixir: '1.10'} - - {otp: '25', elixir: '1.11'} - - {otp: '25', elixir: '1.12'} + - { otp: "24", elixir: "1.14" } + - { otp: "24", elixir: "1.18" } + - { otp: "25", elixir: "1.14" } + - { otp: "25", elixir: "1.15" } + - { otp: "25", elixir: "1.16" } steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - uses: erlef/setup-beam@v1 + id: beam with: otp-version: ${{matrix.otp}} elixir-version: ${{matrix.elixir}} - - uses: actions/cache@v2 + - uses: actions/cache@v4 with: key: | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-build diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 43bd864f..2745d1a2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -16,7 +16,7 @@ jobs: id: beam with: otp-version: 25 - elixir-version: 1.13 + elixir-version: 1.14 - id: deps name: Fetch and compile dependencies diff --git a/CHANGELOG.md b/CHANGELOG.md index 96af969f..a10fe5cf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,71 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## v3.21.2 - 2024-10-02 + +* Use latest version of SwaggerUI by default, but allow it to be configured by @jarmo in https://github.com/open-api-spex/open_api_spex/pull/628 +* Exporting to YAML preserves nil values in examples by @zorbash in f3cd32bee2a + +## v3.21.1 - 2024-09-17 + +* Fix schema inspection argument error by @zorbash. https://github.com/open-api-spex/open_api_spex/issues/636 + +## v3.21.0 - 2024-09-12 + +* Update dev dependencies and example apps by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/624 +* Support casting decimals by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/634 +* Support decoding operations with :servers. by @loguntsov in https://github.com/open-api-spex/open_api_spex/pull/635 + +## v3.20.1 - 2024-07-31 + +* Support custom error messages in custom validators by @GregorGrasselli in https://github.com/open-api-spex/open_api_spex/pull/621 +* Update Schema.example/2 typespec to allow references by @zorbash in 5ec452f + +## v3.20.0 - 2024-07-10 + +* Respect minLength when generating string examples by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/608 +* Accept read_write_scope from opts when calling cast functions directly by @albertored in https://github.com/open-api-spex/open_api_spex/pull/572 +* Allow Poison v6 to be used by @hkrutzer in https://github.com/open-api-spex/open_api_spex/pull/616 +* chore: Drop build matrix support for elixir 1.11, 1.12, 1.13 and OTP 22 by @mbuhot in https://github.com/open-api-spex/open_api_spex/pull/619 +* improvement: use struct spec to avoid double `%` in struct inspect by @zachdaniel in https://github.com/open-api-spex/open_api_spex/pull/613 +* Feat: add `--check` option in Mix tasks to compare the generated spec with a previously generated file by @davidebriani in https://github.com/open-api-spex/open_api_spex/pull/618 +* fix: cast numbers as floats by @David-Klemenc in https://github.com/open-api-spex/open_api_spex/pull/611 + +## v3.19.1 - 2024-05-17 + +* Add notice that body params are not merged into Conn.params whne using cast and validate plug by @hamir-suspect in #589 +* Set nonces on ` - - + + <%= if script_src_nonce do %> + - + """ + @doc """ + Initializes the plug. + + ## Options + + * `:csp_nonce_assign_key` - Optional. An assign key to find the CSP nonce value used + for assets. Supports either `atom()` or a map of type `%{optional(:script) => atom()}`. + + ## Example + + get "/oauth2-redirect.html", + OpenApiSpex.Plug.SwaggerUIOAuth2Redirect, + csp_nonce_assign_key: %{script: :script_src_nonce} + """ @impl Plug - def init(_opts), do: [] + def init(opts) when is_list(opts) do + Map.new(opts) + end @impl Plug - def call(conn, _opts) do - html = render() + def call(conn, config) do + html = render(SwaggerUI.get_nonce(conn, config, :script)) conn |> put_resp_content_type("text/html") @@ -94,5 +114,5 @@ defmodule OpenApiSpex.Plug.SwaggerUIOAuth2Redirect do end require EEx - EEx.function_from_string(:defp, :render, @html, []) + EEx.function_from_string(:defp, :render, @html, [:script_src_nonce]) end diff --git a/lib/open_api_spex/schema.ex b/lib/open_api_spex/schema.ex index a608a85f..5c585e13 100644 --- a/lib/open_api_spex/schema.ex +++ b/lib/open_api_spex/schema.ex @@ -183,6 +183,7 @@ defmodule OpenApiSpex.Schema do :xml, :externalDocs, :example, + :examples, :deprecated, :"x-struct", :"x-validate", @@ -250,6 +251,7 @@ defmodule OpenApiSpex.Schema do xml: Xml.t() | nil, externalDocs: ExternalDocumentation.t() | nil, example: any, + examples: [any] | nil, deprecated: boolean | nil, "x-struct": module | nil, "x-validate": module | nil, @@ -363,11 +365,16 @@ defmodule OpenApiSpex.Schema do assert ... end """ - @spec example(schema :: Schema.t() | module) :: map | String.t() | number | boolean + @spec example(schema :: Schema.t() | module | Reference.t()) :: + map | String.t() | number | boolean def example(%Schema{example: example} = schema) when not is_nil(example) do schema.example end + def example(%Schema{examples: [example | _]}) when not is_nil(example) do + example + end + def example(%Schema{enum: [example | _]}) do example end @@ -402,6 +409,21 @@ defmodule OpenApiSpex.Schema do def example(%Schema{type: :string, format: :"date-time"}), do: "2020-04-20T16:20:00Z" def example(%Schema{type: :string, format: :uuid}), do: "02ef9c5f-29e6-48fc-9ec3-7ed57ed351f6" + def example(%Schema{type: :string, minLength: 1}), do: "a" + def example(%Schema{type: :string, minLength: 2}), do: "ab" + def example(%Schema{type: :string, minLength: 3}), do: "abc" + def example(%Schema{type: :string, minLength: 4}), do: "abcd" + def example(%Schema{type: :string, minLength: 5}), do: "abcde" + def example(%Schema{type: :string, minLength: 6}), do: "abcdef" + + def example(%Schema{type: :string, minLength: min_length}) + when is_integer(min_length) and min_length > 0, + do: + ~c"example" + |> Stream.cycle() + |> Enum.take(min_length) + |> to_string + def example(%Schema{type: :string}), do: "" def example(%Schema{type: :integer} = s), do: example_for(s, :integer) def example(%Schema{type: :number} = s), do: example_for(s, :number) diff --git a/lib/open_api_spex/schema_resolver.ex b/lib/open_api_spex/schema_resolver.ex index ea2bd0b2..24e8b06d 100644 --- a/lib/open_api_spex/schema_resolver.ex +++ b/lib/open_api_spex/schema_resolver.ex @@ -2,6 +2,7 @@ defmodule OpenApiSpex.SchemaResolver do @moduledoc """ Internal module used to resolve `OpenApiSpex.Schema` structs from atoms. """ + alias OpenApiSpex.Discriminator alias OpenApiSpex.{ @@ -29,7 +30,17 @@ defmodule OpenApiSpex.SchemaResolver do Then the `UserResponse.schema()` function will be called to load the schema, and a `Reference` to the loaded schema will be used in the operation response. - See `OpenApiSpex.schema` macro for a convenient syntax for defining schema modules. + See `OpenApiSpex.schema/2` macro for a convenient syntax for defining schema modules. + + > #### Known Issues {: .info} + > + > Resolving schemas expects the schema title to be unique for the generated references to be unique. + > + > For schemas defined with the `OpenApiSpex.schema/2` macro, the title is automatically set + > to the last part of module name. For example `MyAppWeb.Schemas.User` will have the title `"User"`, + > and `MyAppWeb.OtherSchemas.User` **will also** have the title `"User"` which can lead to conflicts. + > + > The recommendation is to set the title explicitly in the schema definition. """ @spec resolve_schema_modules(OpenApi.t()) :: OpenApi.t() def resolve_schema_modules(spec = %OpenApi{}) do @@ -197,14 +208,15 @@ defmodule OpenApiSpex.SchemaResolver do Enum.map_reduce(schema_list, schemas, &resolve_schema_modules_from_schema/2) end - defp resolve_schema_modules_from_schema(schema, schemas) when is_atom(schema) do - title = schema.schema().title + defp resolve_schema_modules_from_schema(schema_module, schemas) when is_atom(schema_module) do + schema = schema_module.schema() + title = schema.title new_schemas = if Map.has_key?(schemas, title) do schemas else - {new_schema, schemas} = resolve_schema_modules_from_schema(schema.schema(), schemas) + {new_schema, schemas} = resolve_schema_modules_from_schema(schema, schemas) Map.put(schemas, title, new_schema) end diff --git a/lib/open_api_spex/test/test_assertions.ex b/lib/open_api_spex/test/test_assertions.ex index ef35bd4a..809044d6 100644 --- a/lib/open_api_spex/test/test_assertions.ex +++ b/lib/open_api_spex/test/test_assertions.ex @@ -3,11 +3,15 @@ defmodule OpenApiSpex.TestAssertions do Defines helpers for testing API responses and examples against API spec schemas. """ import ExUnit.Assertions - alias OpenApiSpex.Cast.Error - alias OpenApiSpex.{Cast, OpenApi} + alias OpenApiSpex.Reference + alias OpenApiSpex.Cast.{Error, Utils} + alias OpenApiSpex.{Cast, Components, OpenApi, Operation, Schema} + alias OpenApiSpex.Plug.PutApiSpec @dialyzer {:no_match, assert_schema: 3} + @json_content_regex ~r/^application\/.*json.*$/ + @doc """ Asserts that `value` conforms to the schema with title `schema_title` in `api_spec`. """ @@ -30,6 +34,45 @@ defmodule OpenApiSpex.TestAssertions do assert_schema(cast_context) end + @doc """ + Asserts that `value` conforms to the schema or reference definition. + """ + @spec assert_raw_schema(term, Schema.t() | Reference.t(), OpenApi.t() | %{}) :: term | no_return + def assert_raw_schema(value, schema, spec \\ %{}) + + def assert_raw_schema(value, schema = %Schema{}, spec) do + schemas = get_or_default_schemas(spec) + + cast_context = %Cast{ + value: value, + schema: schema, + schemas: schemas + } + + assert_schema(cast_context) + end + + def assert_raw_schema(value, schema = %Reference{}, spec) do + schemas = get_or_default_schemas(spec) + resolved_schema = OpenApiSpex.resolve_schema(schema, schemas) + + if is_nil(resolved_schema) do + flunk("Schema: #{inspect(schema)} not found in #{inspect(spec)}") + end + + cast_context = %Cast{ + value: value, + schema: resolved_schema, + schemas: schemas + } + + assert_schema(cast_context) + end + + @spec get_or_default_schemas(OpenApi.t() | %{}) :: Components.schemas_map() | %{} + defp get_or_default_schemas(api_spec = %OpenApi{}), do: api_spec.components.schemas || %{} + defp get_or_default_schemas(input), do: input + @doc """ Asserts that `value` conforms to the schema in the given `%Cast{}` context. """ @@ -75,4 +118,84 @@ defmodule OpenApiSpex.TestAssertions do def assert_request_schema(value, schema_title, api_spec = %OpenApi{}) do assert_schema(value, schema_title, api_spec, :write) end + + @doc """ + Asserts that the response body conforms to the response schema for the operation with id `operation_id`. + """ + @spec assert_operation_response(Plug.Conn.t(), String.t() | nil) :: Plug.Conn.t() + def assert_operation_response(conn, operation_id \\ nil) + + # No need to check for a schema if the response is empty + def assert_operation_response(conn, _operation_id) when conn.status == 204, do: conn + + def assert_operation_response(conn, operation_id) do + {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) + + operation_id = operation_id || conn.private.open_api_spex.operation_id + + case operation_lookup[operation_id] do + nil -> + flunk( + "Failed to resolve a response schema for operation_id: #{operation_id} for status code: #{conn.status}" + ) + + operation -> + validate_operation_response(conn, operation, spec) + end + + conn + end + + if OpenApiSpex.OpenApi.json_encoder() do + @spec validate_operation_response( + Plug.Conn.t(), + Operation.t(), + OpenApi.t() + ) :: + term | no_return + defp validate_operation_response(conn, %Operation{operationId: operation_id} = operation, spec) do + content_type = Utils.content_type_from_header(conn, :response) + + responses = Map.get(operation, :responses, %{}) + code_range = String.first(to_string(conn.status)) <> "XX" + + response = + Map.get(responses, conn.status) || + Map.get(responses, "#{conn.status}") || + Map.get(responses, :"#{conn.status}") || + Map.get(responses, code_range) || + Map.get(responses, :"#{code_range}", %{}) + + resolved_schema = + response + |> Map.get(:content, %{}) + |> Map.get(content_type, %{}) + |> Map.get(:schema) + + if is_nil(resolved_schema) do + flunk( + "Failed to resolve a response schema for operation_id: #{operation_id} for status code: #{conn.status} and content type: #{content_type}" + ) + end + + body = + if String.match?(content_type, @json_content_regex) do + OpenApiSpex.OpenApi.json_encoder().decode!(conn.resp_body) + else + conn.resp_body + end + + assert_raw_schema( + body, + resolved_schema, + spec + ) + end + else + defp validate_operation_response(_conn, _operation, _spec) do + flunk( + "Unable to use assert_operation_response unless a json encoder is configured. Please add :jason or :poison in your mix dependencies." + ) + end + end end diff --git a/mix.exs b/mix.exs index bea70d3e..e554052e 100644 --- a/mix.exs +++ b/mix.exs @@ -2,13 +2,13 @@ defmodule OpenApiSpex.Mixfile do use Mix.Project @source_url "https://github.com/open-api-spex/open_api_spex" - @version "3.17.3" + @version "3.21.2" def project do [ app: :open_api_spex, version: @version, - elixir: "~> 1.10", + elixir: "~> 1.14", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :test, @@ -67,10 +67,11 @@ defmodule OpenApiSpex.Mixfile do {:dialyxir, "~> 1.0", only: [:dev], runtime: false}, {:ex_doc, ">= 0.0.0", only: :dev, runtime: false}, {:jason, "~> 1.0", optional: true}, + {:decimal, "~> 1.0 or ~> 2.0", optional: true}, {:phoenix, "~> 1.3", only: [:dev, :test]}, {:plug, "~> 1.7"}, - {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0", optional: true}, - {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0", optional: true} + {:poison, "~> 3.0 or ~> 4.0 or ~> 5.0 or ~> 6.0", optional: true}, + {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", optional: true} ] end diff --git a/mix.lock b/mix.lock index 7cde5f4a..ea52314c 100644 --- a/mix.lock +++ b/mix.lock @@ -1,23 +1,24 @@ %{ - "bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [:mix], [], "hexpm", "7af5c7e09fe1d40f76c8e4f9dd2be7cebd83909f31fee7cd0e9eadc567da8353"}, - "credo": {:hex, :credo, "1.6.4", "ddd474afb6e8c240313f3a7b0d025cc3213f0d171879429bf8535d7021d9ad78", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "c28f910b61e1ff829bffa056ef7293a8db50e87f2c57a9b5c3f57eee124536b7"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, - "earmark_parser": {:hex, :earmark_parser, "1.4.25", "2024618731c55ebfcc5439d756852ec4e85978a39d0d58593763924d9a15916f", [:mix], [], "hexpm", "56749c5e1c59447f7b7a23ddb235e4b3defe276afc220a6227237f3efe83f51e"}, - "erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"}, - "ex_doc": {:hex, :ex_doc, "0.28.3", "6eea2f69995f5fba94cd6dd398df369fe4e777a47cd887714a0976930615c9e6", [:mix], [{:earmark_parser, "~> 1.4.19", [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", "05387a6a2655b5f9820f3f627450ed20b4325c25977b2ee69bed90af6688e718"}, - "file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"}, - "jason": {:hex, :jason, "1.3.0", "fa6b82a934feb176263ad2df0dbd91bf633d4a46ebfdffea0c8ae82953714946", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "53fc1f51255390e0ec7e50f9cb41e751c260d065dcba2bf0d08dc51a4002c2ac"}, - "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"}, - "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, - "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, - "mime": {:hex, :mime, "2.0.2", "0b9e1a4c840eafb68d820b0e2158ef5c49385d17fb36855ac6e7e087d4b1dcc5", [:mix], [], "hexpm", "e6a3f76b4c277739e36c2e21a2c640778ba4c3846189d5ab19f97f126df5f9b7"}, - "nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.12", "9e3c20463de4b5f3f23721527fcaf16722ec815e70ff6c60b86412c695d426c1", [:mix], [{:bunt, "~> 0.2.1 or ~> 1.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2 or ~> 1.0", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "8493d45c656c5427d9c729235b99d498bd133421f3e0a683e5c1b561471291e5"}, + "decimal": {:hex, :decimal, "2.3.0", "3ad6255aa77b4a3c4f818171b12d237500e63525c2fd056699967a3e7ea20f62", [:mix], [], "hexpm", "a4d66355cb29cb47c3cf30e71329e58361cfcb37c34235ef3bf1d7bf3773aeac"}, + "dialyxir": {:hex, :dialyxir, "1.4.5", "ca1571ac18e0f88d4ab245f0b60fa31ff1b12cbae2b11bd25d207f865e8ae78a", [:mix], [{:erlex, ">= 0.2.7", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b0fb08bb8107c750db5c0b324fa2df5ceaa0f9307690ee3c1f6ba5b9eb5d35c3"}, + "earmark_parser": {:hex, :earmark_parser, "1.4.43", "34b2f401fe473080e39ff2b90feb8ddfeef7639f8ee0bbf71bb41911831d77c5", [:mix], [], "hexpm", "970a3cd19503f5e8e527a190662be2cee5d98eed1ff72ed9b3d1a3d466692de8"}, + "erlex": {:hex, :erlex, "0.2.7", "810e8725f96ab74d17aac676e748627a07bc87eb950d2b83acd29dc047a30595", [:mix], [], "hexpm", "3ed95f79d1a844c3f6bf0cea61e0d5612a42ce56da9c03f01df538685365efb0"}, + "ex_doc": {:hex, :ex_doc, "0.37.3", "f7816881a443cd77872b7d6118e8a55f547f49903aef8747dbcb345a75b462f9", [:mix], [{:earmark_parser, "~> 1.4.42", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "e6aebca7156e7c29b5da4daa17f6361205b2ae5f26e5c7d8ca0d3f7e18972233"}, + "file_system": {:hex, :file_system, "1.1.0", "08d232062284546c6c34426997dd7ef6ec9f8bbd090eb91780283c9016840e8f", [:mix], [], "hexpm", "bfcf81244f416871f2a2e15c1b515287faa5db9c6bcf290222206d120b3d43f6"}, + "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"}, + "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, + "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [: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", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, + "makeup_erlang": {:hex, :makeup_erlang, "1.0.2", "03e1804074b3aa64d5fad7aa64601ed0fb395337b982d9bcf04029d68d51b6a7", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "af33ff7ef368d5893e4a267933e7744e46ce3cf1f61e2dccf53a111ed3aa3727"}, + "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "phoenix": {:hex, :phoenix, "1.6.6", "281c8ce8dccc9f60607346b72cdfc597c3dde134dd9df28dff08282f0b751754", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:phoenix_pubsub, "~> 2.0", [hex: :phoenix_pubsub, repo: "hexpm", optional: false]}, {:phoenix_view, "~> 1.0", [hex: :phoenix_view, repo: "hexpm", optional: false]}, {:plug, "~> 1.10", [hex: :plug, repo: "hexpm", optional: false]}, {:plug_cowboy, "~> 2.2", [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]}], "hexpm", "807bd646e64cd9dc83db016199715faba72758e6db1de0707eef0a2da4924364"}, "phoenix_pubsub": {:hex, :phoenix_pubsub, "2.0.0", "a1ae76717bb168cdeb10ec9d92d1480fec99e3080f011402c0a2d68d47395ffb", [:mix], [], "hexpm", "c52d948c4f261577b9c6fa804be91884b381a7f8f18450c5045975435350f771"}, "phoenix_view": {:hex, :phoenix_view, "1.1.2", "1b82764a065fb41051637872c7bd07ed2fdb6f5c3bd89684d4dca6e10115c95a", [:mix], [{:phoenix_html, "~> 2.14.2 or ~> 3.0", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "7ae90ad27b09091266f6adbb61e1d2516a7c3d7062c6789d46a7554ec40f3a56"}, - "plug": {:hex, :plug, "1.13.4", "addb6e125347226e3b11489e23d22a60f7ab74786befb86c14f94fb5f23ca9a4", [: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", "06114c1f2a334212fe3ae567dbb3b1d29fd492c1a09783d52f3d489c1a6f4cf2"}, - "plug_crypto": {:hex, :plug_crypto, "1.2.2", "05654514ac717ff3a1843204b424477d9e60c143406aa94daf2274fdd280794d", [:mix], [], "hexpm", "87631c7ad914a5a445f0a3809f99b079113ae4ed4b867348dd9eec288cecb6db"}, - "poison": {:hex, :poison, "5.0.0", "d2b54589ab4157bbb82ec2050757779bfed724463a544b6e20d79855a9e43b24", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "11dc6117c501b80c62a7594f941d043982a1bd05a1184280c0d9166eb4d8d3fc"}, - "telemetry": {:hex, :telemetry, "1.0.0", "0f453a102cdf13d506b7c0ab158324c337c41f1cc7548f0bc0e130bbf0ae9452", [:rebar3], [], "hexpm", "73bc09fa59b4a0284efb4624335583c528e07ec9ae76aca96ea0673850aec57a"}, + "plug": {:hex, :plug, "1.16.1", "40c74619c12f82736d2214557dedec2e9762029b2438d6d175c5074c933edc9d", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a13ff6b9006b03d7e33874945b2755253841b238c34071ed85b0e86057f8cddc"}, + "plug_crypto": {:hex, :plug_crypto, "1.2.5", "918772575e48e81e455818229bf719d4ab4181fcbf7f85b68a35620f78d89ced", [:mix], [], "hexpm", "26549a1d6345e2172eb1c233866756ae44a9609bd33ee6f99147ab3fd87fd842"}, + "poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"}, + "telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"}, "ymlr": {:hex, :ymlr, "4.1.0", "9d3f6765b0f6f9ebc0f1c7a61385c85178e36529066280d3d427fa8bbc0b175c", [:mix], [], "hexpm", "49dd3fc88f136f4c71a3e812f89e04570cc92be94b1bf57ede13cc4caaa92332"}, } diff --git a/test/cast/all_of_test.exs b/test/cast/all_of_test.exs index a491dc25..9f2fbabe 100644 --- a/test/cast/all_of_test.exs +++ b/test/cast/all_of_test.exs @@ -355,6 +355,7 @@ defmodule OpenApiSpex.CastAllOfTest do test "with schema having x-type" do value = %{fur: true, meow: true} - assert {:ok, _} = cast(value: value, schema: CatSchema.schema()) + + assert {:ok, %CatSchema{fur: true, meow: true}} = cast(value: value, schema: CatSchema.schema()) end end diff --git a/test/cast/discriminator_test.exs b/test/cast/discriminator_test.exs index 210a067d..eb3fa76f 100644 --- a/test/cast/discriminator_test.exs +++ b/test/cast/discriminator_test.exs @@ -143,6 +143,29 @@ defmodule OpenApiSpex.CastDiscriminatorTest do assert cast(value: input_value, schema: discriminator_schema) == expected end + test "without title", %{schemas: %{dog: dog, cat: cat}} do + dog = Map.put(dog, :title, nil) + cat = Map.put(cat, :title, nil) + + schemas = %{"Dog" => dog, "Cat" => cat} + + discriminator_schema = %OpenApiSpex.Schema{ + anyOf: [ + %OpenApiSpex.Reference{"$ref": "#/components/schemas/Dog"}, + %OpenApiSpex.Reference{"$ref": "#/components/schemas/Cat"} + ], + discriminator: %{ + mapping: %{"dog" => "#/components/schemas/Dog", "cat" => "#/components/schemas/Cat"}, + propertyName: "animal_type" + }, + type: :object + } + + input_value = %{@discriminator => "dog", "breed" => "Corgi", "age" => 1} + expected = {:ok, %{age: 1, breed: "Corgi", animal_type: "dog"}} + assert cast(value: input_value, schema: discriminator_schema, schemas: schemas) == expected + end + test "valid discriminator mapping but schema does not match", %{ schemas: %{dog: dog, wolf: wolf, cat: cat} } do @@ -168,6 +191,18 @@ defmodule OpenApiSpex.CastDiscriminatorTest do ]} = cast(value: input_value, schema: discriminator_schema) end + test "value is not an object", %{schemas: %{dog: dog, wolf: wolf, cat: cat}} do + input_value = "this is a string but discriminator only works on objects" + + discriminator_schema = + build_discriminator_schema([dog, wolf, cat], :anyOf, String.to_atom(@discriminator), nil) + + assert {:error, [error]} = cast(value: input_value, schema: discriminator_schema) + assert error.reason == :invalid_type + assert error.type == :object + assert error.value == input_value + end + test "invalid property on discriminator schema", %{ schemas: %{dog: dog, wolf: wolf} } do diff --git a/test/cast/integer_test.exs b/test/cast/integer_test.exs index 55cab47f..f4ca5e01 100644 --- a/test/cast/integer_test.exs +++ b/test/cast/integer_test.exs @@ -18,6 +18,17 @@ defmodule OpenApiSpex.CastIntegerTest do assert %Error{reason: :invalid_type, value: "other"} = error end + test "with a Decimal" do + schema = %Schema{type: :integer} + + assert {:ok, 1} = cast(value: Decimal.new(1), schema: schema) + + number = Decimal.new("1.2") + + assert {:error, [%{reason: :invalid_type, value: ^number}]} = + cast(value: number, schema: schema) + end + test "with multiple of" do schema = %Schema{type: :integer, multipleOf: 2} assert cast(value: 2, schema: schema) == {:ok, 2} diff --git a/test/cast/number_test.exs b/test/cast/number_test.exs index 398008d6..bb28edb5 100644 --- a/test/cast/number_test.exs +++ b/test/cast/number_test.exs @@ -8,7 +8,7 @@ defmodule OpenApiSpex.CastNumberTest do describe "cast/1" do test "basics" do schema = %Schema{type: :number} - assert cast(value: 1, schema: schema) === {:ok, 1} + assert cast(value: 1, schema: schema) === {:ok, 1.0} assert cast(value: 1.5, schema: schema) === {:ok, 1.5} assert cast(value: "1", schema: schema) === {:ok, 1.0} assert cast(value: "1.5", schema: schema) === {:ok, 1.5} @@ -17,13 +17,26 @@ defmodule OpenApiSpex.CastNumberTest do assert error.value == "other" end + test "with a Decimal" do + schema = %Schema{type: :number} + + assert cast(value: Decimal.new("1.2345"), schema: schema) === {:ok, 1.2345} + + schema = %Schema{type: :number, minimum: 2} + assert cast(value: Decimal.new("3"), schema: schema) === {:ok, 3.0} + assert cast(value: Decimal.new("2"), schema: schema) === {:ok, 2.0} + + assert {:error, [%{reason: :minimum, value: 1.0}]} = + cast(value: Decimal.new("1"), schema: schema) + end + test "with minimum" do schema = %Schema{type: :number, minimum: 2} - assert cast(value: 3, schema: schema) === {:ok, 3} - assert cast(value: 2, schema: schema) === {:ok, 2} + assert cast(value: 3, schema: schema) === {:ok, 3.0} + assert cast(value: 2, schema: schema) === {:ok, 2.0} assert {:error, [error]} = cast(value: 1, schema: schema) assert error.reason == :minimum - assert error.value === 1 + assert error.value === 1.0 # error.length is the minimum assert error.length === 2 assert Error.message(error) =~ "smaller than inclusive minimum" @@ -31,11 +44,11 @@ defmodule OpenApiSpex.CastNumberTest do test "with maximum" do schema = %Schema{type: :number, maximum: 2} - assert cast(value: 1, schema: schema) === {:ok, 1} - assert cast(value: 2, schema: schema) === {:ok, 2} + assert cast(value: 1, schema: schema) === {:ok, 1.0} + assert cast(value: 2, schema: schema) === {:ok, 2.0} assert {:error, [error]} = cast(value: 3, schema: schema) assert error.reason === :maximum - assert error.value === 3 + assert error.value === 3.0 # error.length is the maximum assert error.length === 2 assert Error.message(error) =~ "larger than inclusive maximum" @@ -43,10 +56,10 @@ defmodule OpenApiSpex.CastNumberTest do test "with minimum w/ exclusiveMinimum" do schema = %Schema{type: :number, minimum: 2, exclusiveMinimum: true} - assert cast(value: 3, schema: schema) == {:ok, 3} + assert cast(value: 3, schema: schema) == {:ok, 3.0} assert {:error, [error]} = cast(value: 2, schema: schema) assert error.reason == :exclusive_min - assert error.value == 2 + assert error.value == 2.0 # error.length is the minimum assert error.length == 2 assert Error.message(error) =~ "smaller than exclusive minimum" @@ -54,10 +67,10 @@ defmodule OpenApiSpex.CastNumberTest do test "with maximum w/ exclusiveMaximum" do schema = %Schema{type: :number, maximum: 2, exclusiveMaximum: true} - assert cast(value: 1, schema: schema) == {:ok, 1} + assert cast(value: 1, schema: schema) == {:ok, 1.0} assert {:error, [error]} = cast(value: 2, schema: schema) assert error.reason == :exclusive_max - assert error.value == 2 + assert error.value == 2.0 # error.length is the maximum assert error.length == 2 assert Error.message(error) =~ "larger than exclusive maximum" diff --git a/test/cast/object_test.exs b/test/cast/object_test.exs index add91d24..84261c18 100644 --- a/test/cast/object_test.exs +++ b/test/cast/object_test.exs @@ -415,7 +415,11 @@ defmodule OpenApiSpex.ObjectTest do } } - assert {:error, [error1, error2]} = cast(value: %{"age" => 0, "name" => "N"}, schema: schema) + assert {:error, [_error1, _error2] = errors} = + cast(value: %{"age" => 0, "name" => "N"}, schema: schema) + + error1 = Enum.find(errors, &(&1.path == [:age])) + error2 = Enum.find(errors, &(&1.path == [:name])) assert %Error{} = error1 assert error1.reason == :minimum diff --git a/test/cast_test.exs b/test/cast_test.exs index f5d4ef17..f0d71273 100644 --- a/test/cast_test.exs +++ b/test/cast_test.exs @@ -7,6 +7,23 @@ defmodule OpenApiSpec.CastTest do def cast(ctx), do: Cast.cast(ctx) describe "cast/1" do + defmodule CustomValidator.EvenInt do + require OpenApiSpex + + alias OpenApiSpex.Cast + + OpenApiSpex.schema(%{ + description: "An even integer", + type: :integer, + "x-validate": __MODULE__ + }) + + def cast(context = %Cast{value: value}) when is_integer(value) and rem(value, 2) == 0, + do: Cast.ok(context) + + def cast(context), do: Cast.error(context, {:custom, "Must be an even integer"}) + end + test "unknown schema type" do assert {:error, [error]} = cast(value: "string", schema: %Schema{type: :nope}) assert error.reason == :invalid_schema_type @@ -222,6 +239,32 @@ defmodule OpenApiSpec.CastTest do assert Error.message_with_path(error) == "#/age: Invalid strict_integer. Got: string" end + test "cast custom error with custom validator" do + schema = %Schema{type: :object, properties: %{even_number: CustomValidator.EvenInt.schema()}} + + assert {:error, errors} = cast(value: %{"even_number" => 1}, schema: schema) + assert [error] = errors + assert %Error{} = error + assert error.reason == :custom + assert error.path == [:even_number] + assert Error.message_with_path(error) == "#/even_number: Must be an even integer" + end + + test "cast with custom validator from decoded schema" do + spec = + "./test/support/encoded_schema.json" + |> File.read!() + |> Jason.decode!() + |> OpenApiSpex.OpenApi.Decode.decode() + + %{ + components: %{schemas: %{"CustomValidationDecoded" => custom_validation_schema}} + } = spec + + assert {:ok, %{even_num: 2}} = + cast(value: %{"even_num" => 2}, schema: custom_validation_schema) + end + test "nil value with xxxOf" do schema = %Schema{anyOf: [%Schema{nullable: true, type: :string}]} assert {:ok, nil} = cast(value: nil, schema: schema) @@ -253,6 +296,26 @@ defmodule OpenApiSpec.CastTest do end end + describe "opts" do + test "read_write_scope" do + schema = %Schema{ + type: :object, + properties: %{ + id: %Schema{type: :string, readOnly: true}, + name: %Reference{"$ref": "#/components/schemas/Name"}, + age: %Schema{type: :integer} + }, + required: [:id, :name, :age] + } + + schemas = %{"Name" => %Schema{type: :string, readOnly: true}} + + value = %{"age" => 30} + assert {:error, _} = Cast.cast(schema, value, schemas, []) + assert {:ok, %{age: 30}} == Cast.cast(schema, value, schemas, read_write_scope: :write) + end + end + describe "ok/1" do test "basics" do assert {:ok, 1} = Cast.ok(%Cast{value: 1}) diff --git a/test/inspect/for_schema_test.exs b/test/inspect/for_schema_test.exs index c8d21301..a2584a21 100644 --- a/test/inspect/for_schema_test.exs +++ b/test/inspect/for_schema_test.exs @@ -5,6 +5,6 @@ defmodule OpenApiSpex.Inspect.ForSchemaTest do test "inspect schema" do schema = %Schema{title: "Hello"} output = inspect(schema) - assert output == "%OpenApiSpex.Schema%{title: \"Hello\"}" + assert output == "%OpenApiSpex.Schema{title: \"Hello\"}" end end diff --git a/test/mix/tasks/openapi.spec.json_test.exs b/test/mix/tasks/openapi.spec.json_test.exs index d011dec5..8b941c90 100644 --- a/test/mix/tasks/openapi.spec.json_test.exs +++ b/test/mix/tasks/openapi.spec.json_test.exs @@ -18,4 +18,15 @@ defmodule Mix.Tasks.Openapi.Spec.JsonTest do assert File.read!(actual_schema_path) == File.read!(expected_schema_path) end + + test "generates openapi.json quietly" do + Mix.Tasks.Openapi.Spec.Json.run(~w( + --quiet=true + --spec OpenApiSpexTest.Tasks.SpecModule + tmp/openapi.json + )) + + refute_received {:mix_shell, :info, ["* creating tmp"]} + refute_received {:mix_shell, :info, ["* creating tmp/openapi.json"]} + end end diff --git a/test/open_api/decode_test.exs b/test/open_api/decode_test.exs index 41a1f85d..bcef54d2 100644 --- a/test/open_api/decode_test.exs +++ b/test/open_api/decode_test.exs @@ -1,6 +1,8 @@ defmodule OpenApiSpex.OpenApi.DecodeTest do use ExUnit.Case - use Plug.Test + + import Plug.Test + import Plug.Conn alias OpenApiSpex.OpenApi diff --git a/test/operation_test.exs b/test/operation_test.exs index b33bd09e..2a016ac6 100644 --- a/test/operation_test.exs +++ b/test/operation_test.exs @@ -4,6 +4,8 @@ defmodule OpenApiSpex.OperationTest do alias OpenApiSpex.Operation alias OpenApiSpexTest.UserController + doctest Operation + describe "Operation" do test "from_route %Phoenix.Router.Route{}" do plug = UserController diff --git a/test/paths_test.exs b/test/paths_test.exs index 300e9497..4bec9a9f 100644 --- a/test/paths_test.exs +++ b/test/paths_test.exs @@ -12,8 +12,13 @@ defmodule OpenApiSpex.PathsTest do "/api/pets/{id}" => pets_path_item } = paths - assert pets_path_item.patch.operationId == "OpenApiSpexTest.PetController.update" - assert pets_path_item.put.operationId == "OpenApiSpexTest.PetController.update (2)" + refute Map.has_key?(paths, "/api/noapi") + refute Map.has_key?(paths, "/api/noapi_with_struct") + + operation_ids = [pets_path_item.put.operationId, pets_path_item.patch.operationId] + + assert "OpenApiSpexTest.PetController.update" in operation_ids + assert "OpenApiSpexTest.PetController.update (2)" in operation_ids end end end diff --git a/test/plug/none_cache_test.exs b/test/plug/none_cache_test.exs new file mode 100644 index 00000000..50c69d02 --- /dev/null +++ b/test/plug/none_cache_test.exs @@ -0,0 +1,28 @@ +defmodule OpenApiSpex.Plug.NoneCacheTest do + use ExUnit.Case, async: true + + alias OpenApiSpex.Plug.NoneCache + alias OpenApiSpexTest.ApiSpec + + setup do + [spec: ApiSpec.spec()] + end + + describe "get/1" do + test "returns nil", %{spec: spec} do + assert is_nil(NoneCache.get(spec)) + end + end + + describe "put/2" do + test "returns :ok", %{spec: spec} do + assert :ok = NoneCache.put(spec, %{}) + end + end + + describe "erase/1" do + test "returns :ok", %{spec: spec} do + assert :ok = NoneCache.erase(spec) + end + end +end diff --git a/test/plug/swagger_ui_test.exs b/test/plug/swagger_ui_test.exs index e53b21d6..4506e7bc 100644 --- a/test/plug/swagger_ui_test.exs +++ b/test/plug/swagger_ui_test.exs @@ -13,4 +13,37 @@ defmodule OpenApiSpec.Plug.SwaggerUITest do assert conn.resp_body =~ ~r[pathname.+?/ui] assert String.contains?(conn.resp_body, token) end + + describe "nonces" do + test "omits nonces if not configured" do + conn = Plug.Test.conn(:get, "/ui") |> SwaggerUI.call(@opts) + refute String.contains?(conn.resp_body, "nonce") + end + + test "renders with single key" do + conn = + Plug.Test.conn(:get, "/ui") + |> Plug.Conn.assign(:nonce, "my_nonce") + |> SwaggerUI.call(Map.put(@opts, :csp_nonce_assign_key, :nonce)) + + assert String.match?(conn.resp_body, ~r/ Plug.Conn.assign(:style_src_nonce, "my_style_nonce") + |> Plug.Conn.assign(:script_src_nonce, "my_script_nonce") + |> SwaggerUI.call( + Map.put(@opts, :csp_nonce_assign_key, %{ + script: :script_src_nonce, + style: :style_src_nonce + }) + ) + + assert String.match?(conn.resp_body, ~r/ - SchemaWithoutStructDef.__struct__() - end + refute function_exported?(SchemaWithoutStructDef, :__struct__, 0) end defmodule SchemaWithoutDerive do diff --git a/test/schema_resolver_test.exs b/test/schema_resolver_test.exs index 23f13b9c..18fc8aeb 100644 --- a/test/schema_resolver_test.exs +++ b/test/schema_resolver_test.exs @@ -14,206 +14,208 @@ defmodule OpenApiSpex.SchemaResolverTest do SchemaResolver } - test "Resolves schemas in OpenApi spec" do - spec = %OpenApi{ - info: %Info{ - title: "Test", - version: "1.0.0" - }, - paths: %{ - "/api/users" => %PathItem{ - get: %Operation{ - responses: %{ - 200 => %Response{ - description: "Success", - content: %{ - "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.UsersResponse + describe "resolve_schema_modules/1" do + test "resolves schemas in OpenApi spec" do + spec = %OpenApi{ + info: %Info{ + title: "Test", + version: "1.0.0" + }, + paths: %{ + "/api/users" => %PathItem{ + get: %Operation{ + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.UsersResponse + } } } } - } - }, - post: %Operation{ - description: "Create a user", - operationId: "UserController.create", - requestBody: %RequestBody{ - content: %{ - "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.UserRequest - } - } }, - responses: %{ - 201 => %Response{ - description: "Created", + post: %Operation{ + description: "Create a user", + operationId: "UserController.create", + requestBody: %RequestBody{ content: %{ "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.UserResponse + schema: OpenApiSpexTest.Schemas.UserRequest + } + } + }, + responses: %{ + 201 => %Response{ + description: "Created", + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.UserResponse + } } } } } - } - }, - "/api/users/{id}/payment_details" => %PathItem{ - get: %Operation{ - responses: %{ - 200 => %Response{ - description: "Success", - content: %{ - "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.PaymentDetails + }, + "/api/users/{id}/payment_details" => %PathItem{ + get: %Operation{ + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.PaymentDetails + } } } } } - } - }, - "/api/users/{id}/friends" => %PathItem{ - get: %Operation{ - parameters: [ - %OpenApiSpex.Parameter{ - name: :id, - in: :path, - schema: %Schema{type: :integer} - } - ], - responses: %{ - 200 => %Response{ - description: "Success", - content: %{ - "application/json" => %MediaType{ - schema: %Schema{ - type: :array, - items: OpenApiSpexTest.Schemas.User + }, + "/api/users/{id}/friends" => %PathItem{ + get: %Operation{ + parameters: [ + %OpenApiSpex.Parameter{ + name: :id, + in: :path, + schema: %Schema{type: :integer} + } + ], + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: %Schema{ + type: :array, + items: OpenApiSpexTest.Schemas.User + } } } } } } - } - }, - "/api/users/subscribe" => %PathItem{ - post: %Operation{ - description: "Subscribe to user updates", - operationId: "UserController.subscribe", - requestBody: %RequestBody{ - required: true, - content: %{ - "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.UserSubscribeRequest + }, + "/api/users/subscribe" => %PathItem{ + post: %Operation{ + description: "Subscribe to user updates", + operationId: "UserController.subscribe", + requestBody: %RequestBody{ + required: true, + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.UserSubscribeRequest + } } - } - }, - callbacks: %{ - "user_updated" => %{ - "{$request.body#/callback_url}" => %PathItem{ - post: %Operation{ - description: "Provided endpoint for sending updates", - requestBody: %RequestBody{ - required: true, - content: %{ - "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.UserResponse + }, + callbacks: %{ + "user_updated" => %{ + "{$request.body#/callback_url}" => %PathItem{ + post: %Operation{ + description: "Provided endpoint for sending updates", + requestBody: %RequestBody{ + required: true, + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.UserResponse + } + } + }, + responses: %{ + 200 => %Response{ + description: "Your server returns this code if it accepts the callback" } - } - }, - responses: %{ - 200 => %Response{ - description: "Your server returns this code if it accepts the callback" } } } } - } - }, - responses: %{ - 201 => %Response{ - description: "Webhook created" + }, + responses: %{ + 201 => %Response{ + description: "Webhook created" + } } } - } - }, - "/api/appointsments" => %PathItem{ - post: %Operation{ - description: "Create a new pet appointment", - operationId: "PetAppointmentController.create", - requestBody: %RequestBody{ - required: true, - content: %{ - "application/json" => %MediaType{ - schema: OpenApiSpexTest.Schemas.PetAppointmentRequest + }, + "/api/appointsments" => %PathItem{ + post: %Operation{ + description: "Create a new pet appointment", + operationId: "PetAppointmentController.create", + requestBody: %RequestBody{ + required: true, + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.PetAppointmentRequest + } + } + }, + responses: %{ + 201 => %Response{ + description: "Appointment created" } - } - }, - responses: %{ - 201 => %Response{ - description: "Appointment created" } } } } } - } - resolved = OpenApiSpex.resolve_schema_modules(spec) + resolved = OpenApiSpex.resolve_schema_modules(spec) - assert %Reference{"$ref": "#/components/schemas/UsersResponse"} = - resolved.paths["/api/users"].get.responses[200].content["application/json"].schema + assert %Reference{"$ref": "#/components/schemas/UsersResponse"} = + resolved.paths["/api/users"].get.responses[200].content["application/json"].schema - assert %Reference{"$ref": "#/components/schemas/UserResponse"} = - resolved.paths["/api/users"].post.responses[201].content["application/json"].schema + assert %Reference{"$ref": "#/components/schemas/UserResponse"} = + resolved.paths["/api/users"].post.responses[201].content["application/json"].schema - assert %Reference{"$ref": "#/components/schemas/UserRequest"} = - resolved.paths["/api/users"].post.requestBody.content["application/json"].schema + assert %Reference{"$ref": "#/components/schemas/UserRequest"} = + resolved.paths["/api/users"].post.requestBody.content["application/json"].schema - assert "#/components/schemas/TrainingAppointment" = - resolved.components.schemas["PetAppointmentRequest"].discriminator.mapping[ - "training" - ] + assert "#/components/schemas/TrainingAppointment" = + resolved.components.schemas["PetAppointmentRequest"].discriminator.mapping[ + "training" + ] - assert "#/components/schemas/GroomingAppointment" = - resolved.components.schemas["PetAppointmentRequest"].discriminator.mapping[ - "grooming" - ] + assert "#/components/schemas/GroomingAppointment" = + resolved.components.schemas["PetAppointmentRequest"].discriminator.mapping[ + "grooming" + ] - assert %{ - "UserRequest" => %Schema{}, - "UserResponse" => %Schema{}, - "User" => %Schema{}, - "UserSubscribeRequest" => %Schema{}, - "PaymentDetails" => %Schema{}, - "CreditCardPaymentDetails" => %Schema{}, - "DirectDebitPaymentDetails" => %Schema{}, - "PetAppointmentRequest" => %Schema{}, - "TrainingAppointment" => %Schema{}, - "GroomingAppointment" => %Schema{} - } = resolved.components.schemas + assert %{ + "UserRequest" => %Schema{}, + "UserResponse" => %Schema{}, + "User" => %Schema{}, + "UserSubscribeRequest" => %Schema{}, + "PaymentDetails" => %Schema{}, + "CreditCardPaymentDetails" => %Schema{}, + "DirectDebitPaymentDetails" => %Schema{}, + "PetAppointmentRequest" => %Schema{}, + "TrainingAppointment" => %Schema{}, + "GroomingAppointment" => %Schema{} + } = resolved.components.schemas - get_friends = resolved.paths["/api/users/{id}/friends"].get + get_friends = resolved.paths["/api/users/{id}/friends"].get - assert %Reference{"$ref": "#/components/schemas/User"} = - get_friends.responses[200].content["application/json"].schema.items - end + assert %Reference{"$ref": "#/components/schemas/User"} = + get_friends.responses[200].content["application/json"].schema.items + end - test "raises informative error when schema :properties is not a map" do - spec = %OpenApi{ - info: %Info{ - title: "Test", - version: "1.0.0" - }, - paths: %{ - "/api/users" => %PathItem{ - get: %Operation{ - responses: %{ - 200 => %Response{ - description: "Success", - content: %{ - "application/json" => %MediaType{ - schema: %Schema{ - type: :object, - properties: Invalid + test "raises informative error when schema :properties is not a map" do + spec = %OpenApi{ + info: %Info{ + title: "Test", + version: "1.0.0" + }, + paths: %{ + "/api/users" => %PathItem{ + get: %Operation{ + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: %Schema{ + type: :object, + properties: Invalid + } } } } @@ -222,28 +224,28 @@ defmodule OpenApiSpex.SchemaResolverTest do } } } - } - assert_raise RuntimeError, "Expected :properties to be a map. Got: Invalid", fn -> - OpenApiSpex.resolve_schema_modules(spec) + assert_raise RuntimeError, "Expected :properties to be a map. Got: Invalid", fn -> + OpenApiSpex.resolve_schema_modules(spec) + end end - end - test "raises error when schema cannot be resolved" do - spec = %OpenApi{ - info: %Info{ - title: "Test", - version: "1.0.0" - }, - paths: %{ - "/api/users" => %PathItem{ - get: %Operation{ - responses: %{ - 200 => %Response{ - description: "Success", - content: %{ - "application/json" => %MediaType{ - schema: %{} + test "raises error when schema cannot be resolved" do + spec = %OpenApi{ + info: %Info{ + title: "Test", + version: "1.0.0" + }, + paths: %{ + "/api/users" => %PathItem{ + get: %Operation{ + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: %{} + } } } } @@ -251,22 +253,86 @@ defmodule OpenApiSpex.SchemaResolverTest do } } } - } - error_message = """ - Cannot resolve schema %{}. + error_message = """ + Cannot resolve schema %{}. - Must be one of: + Must be one of: - - schema module, or schema struct - - list of schema modules, or schema structs - - boolean - - nil - - reference - """ + - schema module, or schema struct + - list of schema modules, or schema structs + - boolean + - nil + - reference + """ + + assert_raise RuntimeError, error_message, fn -> + OpenApiSpex.resolve_schema_modules(spec) + end + end + + defmodule Duplicates.UsersResponse do + require OpenApiSpex + + OpenApiSpex.schema(%{ + description: "A duplicate", + type: :object, + properties: %{ + unexpected: %Schema{type: :string} + } + }) + end + + test "resolution ignores schemas with duplicate title" do + spec = %OpenApi{ + info: %Info{ + title: "Test", + version: "1.0.0" + }, + paths: %{ + "/api/users" => %PathItem{ + get: %Operation{ + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: OpenApiSpexTest.Schemas.UsersResponse + } + } + } + } + } + }, + "/api/clones" => %PathItem{ + get: %Operation{ + responses: %{ + 200 => %Response{ + description: "Success", + content: %{ + "application/json" => %MediaType{ + schema: Duplicates.UsersResponse + } + } + } + } + } + } + } + } + + assert %{ + components: %{ + schemas: %{ + "UsersResponse" => %OpenApiSpex.Schema{ + description: "A duplicate", + "x-struct": OpenApiSpex.SchemaResolverTest.Duplicates.UsersResponse + } + } + } + } = resolved = OpenApiSpex.resolve_schema_modules(spec) - assert_raise RuntimeError, error_message, fn -> - OpenApiSpex.resolve_schema_modules(spec) + assert Map.keys(resolved.components.schemas) == ["UsersResponse"] end end diff --git a/test/schema_test.exs b/test/schema_test.exs index 7e67e8cb..43b70154 100644 --- a/test/schema_test.exs +++ b/test/schema_test.exs @@ -103,10 +103,25 @@ defmodule OpenApiSpex.SchemaTest do assert Schema.example(%Schema{type: :string, example: "foo"}) == "foo" end + test "uses the first value in `examples` property when not nil" do + assert Schema.example(%Schema{type: :string, examples: ["foo", "bar"]}) == "foo" + end + test "defaults to type-appropriate value for :string" do assert Schema.example(%Schema{type: :string}) == "" end + test "defaults to type-appropriate value for :string with a minLength" do + assert Schema.example(%Schema{type: :string, minLength: 1}) == "a" + assert Schema.example(%Schema{type: :string, minLength: 2}) == "ab" + assert Schema.example(%Schema{type: :string, minLength: 3}) == "abc" + assert Schema.example(%Schema{type: :string, minLength: 4}) == "abcd" + assert Schema.example(%Schema{type: :string, minLength: 5}) == "abcde" + assert Schema.example(%Schema{type: :string, minLength: 6}) == "abcdef" + assert Schema.example(%Schema{type: :string, minLength: 7}) == "example" + assert Schema.example(%Schema{type: :string, minLength: 9}) == "exampleex" + end + test "defaults to type-appropriate value for :integer, :number" do assert Schema.example(%Schema{type: :integer}) === 0 assert Schema.example(%Schema{type: :number}) === 0 diff --git a/test/support/encoded_schema.json b/test/support/encoded_schema.json index 239f0aea..a7992696 100644 --- a/test/support/encoded_schema.json +++ b/test/support/encoded_schema.json @@ -280,6 +280,15 @@ "not": { "type": "string" } + }, + "CustomValidationDecoded": { + "type": "object", + "properties": { + "even_num": { + "type": "integer", + "x-validate": "OpenApiSpec.CastTest.CustomValidator.EvenInt" + } + } } }, "links": { @@ -403,6 +412,16 @@ "x-custom-info": { "codeowners": "team-rocker" }, + "servers": [ + { + "description": "production", + "url": "https://example.com/examples" + }, + { + "description": "staging", + "url": "https://staging.example.com/examples" + } + ], "operationId": "example-post-test", "callbacks": { "operationCallback": { diff --git a/test/support/pet_controller.ex b/test/support/pet_controller.ex index e0a1530a..9b5e7150 100644 --- a/test/support/pet_controller.ex +++ b/test/support/pet_controller.ex @@ -23,7 +23,8 @@ defmodule OpenApiSpexTest.PetController do ], responses: [ ok: {"Pet", "application/json", Schemas.PetResponse} - ] + ], + operation_id: "showPetById" def show(conn, %{id: _id}) do json(conn, %Schemas.PetResponse{ data: %Schemas.Dog{ @@ -36,7 +37,19 @@ defmodule OpenApiSpexTest.PetController do @doc """ Get a list of pets. """ - @doc responses: [ok: {"Pet list", "application/json", Schemas.PetsResponse}] + @doc parameters: [ + age: [ + in: :query, + type: %Schema{type: :integer, minimum: 1}, + description: "Age of the pet", + example: 1 + ] + ], + responses: [ + ok: {"Pet list", "application/json", Schemas.PetsResponse}, + unprocessable_entity: OpenApiSpex.JsonErrorResponse.response() + ], + operation_id: "listPets" def index(conn, _params) do json(conn, %Schemas.PetsResponse{ data: [ diff --git a/test/support/response_code_ranges_controller.ex b/test/support/response_code_ranges_controller.ex new file mode 100644 index 00000000..49fd8b82 --- /dev/null +++ b/test/support/response_code_ranges_controller.ex @@ -0,0 +1,58 @@ +defmodule OpenApiSpexTest.ResponseCodeRangesController do + use Phoenix.Controller + use OpenApiSpex.ControllerSpecs + + alias OpenApiSpex.Operation + + defmodule GenericResponse do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + type: %Schema{ + type: :string, + enum: ["generic"] + } + } + }) + end + + defmodule CreatedResponse do + alias OpenApiSpex.Schema + require OpenApiSpex + + OpenApiSpex.schema(%{ + type: :object, + properties: %{ + type: %Schema{ + type: :string, + enum: ["created"] + } + } + }) + end + + operation :index, + operation_id: "response_code_ranges", + summary: "String response codes index", + responses: [ + created: + Operation.response( + "Created response", + "application/json", + CreatedResponse + ), + "2XX": + Operation.response( + "Generic response", + "application/json", + GenericResponse + ) + ] + + def index(conn, _) do + json(conn, %{type: "generic"}) + end +end diff --git a/test/support/router.ex b/test/support/router.ex index 217f9e70..89cc5b59 100644 --- a/test/support/router.ex +++ b/test/support/router.ex @@ -15,7 +15,9 @@ defmodule OpenApiSpexTest.Router do get "/noapi", OpenApiSpexTest.NoApiController, :noapi get "/noapi_with_struct", OpenApiSpexTest.NoApiControllerWithStructSpecs, :noapi - resources "/users_no_replace", OpenApiSpexTest.UserNoRepalceController, only: [:create, :index] + get "/response_code_ranges", OpenApiSpexTest.ResponseCodeRangesController, :index + + resources "/users_no_replace", OpenApiSpexTest.UserNoReplaceController, only: [:create, :index] # Used by ParamsTest resources "/custom_error_users", OpenApiSpexTest.CustomErrorUserController, only: [:index] diff --git a/test/support/tasks/openapi.json b/test/support/tasks/openapi.json index 73f6fdb3..479f2e1c 100644 --- a/test/support/tasks/openapi.json +++ b/test/support/tasks/openapi.json @@ -1,4 +1,33 @@ { + "components": { + "schemas": { + "Person": { + "example": { + "first_name": "John", + "last_name": "Doe", + "nickname": null + }, + "properties": { + "first_name": { + "type": "string" + }, + "last_name": { + "type": "string" + }, + "nickname": { + "nullable": true, + "type": "string" + } + }, + "required": [ + "first_name", + "last_name", + "nickname" + ], + "type": "object" + } + } + }, "info": { "title": "Test spec", "version": "1.0" diff --git a/test/support/tasks/openapi.yaml b/test/support/tasks/openapi.yaml index 90107112..9c3e3db8 100644 --- a/test/support/tasks/openapi.yaml +++ b/test/support/tasks/openapi.yaml @@ -1,4 +1,24 @@ --- +components: + schemas: + Person: + example: + first_name: John + last_name: Doe + nickname: + properties: + first_name: + type: string + last_name: + type: string + nickname: + nullable: true + type: string + required: + - first_name + - last_name + - nickname + type: object info: title: Test spec version: '1.0' diff --git a/test/support/tasks/spec_module.ex b/test/support/tasks/spec_module.ex index bb657ff6..107952f5 100644 --- a/test/support/tasks/spec_module.ex +++ b/test/support/tasks/spec_module.ex @@ -18,7 +18,25 @@ defmodule OpenApiSpexTest.Tasks.SpecModule do }, servers: [ %Server{url: "http://localhost:4000"} - ] + ], + components: %{ + schemas: %{ + "Person" => %OpenApiSpex.Schema{ + type: :object, + properties: %{ + first_name: %OpenApiSpex.Schema{type: :string}, + last_name: %OpenApiSpex.Schema{type: :string}, + nickname: %OpenApiSpex.Schema{type: :string, nullable: true} + }, + required: [:first_name, :last_name, :nickname], + example: %{ + first_name: "John", + last_name: "Doe", + nickname: nil + } + } + } + } } end end diff --git a/test/support/user_no_replace_controller.ex b/test/support/user_no_replace_controller.ex index b74822fe..f3d10b5a 100644 --- a/test/support/user_no_replace_controller.ex +++ b/test/support/user_no_replace_controller.ex @@ -1,4 +1,4 @@ -defmodule OpenApiSpexTest.UserNoRepalceController do +defmodule OpenApiSpexTest.UserNoReplaceController do @moduledoc tags: ["users_no_replace"] use Phoenix.Controller diff --git a/test/test_assertions_test.exs b/test/test_assertions_test.exs index aa85755f..78a2e3db 100644 --- a/test/test_assertions_test.exs +++ b/test/test_assertions_test.exs @@ -64,4 +64,133 @@ defmodule OpenApiSpex.TestAssertionsTest do TestAssertions.assert_request_schema(value, schema.title, api_spec) end end + + describe "assert_raw_schema/3" do + test "success" do + schema = %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string} + } + } + + TestAssertions.assert_raw_schema(%{name: "valid"}, schema, %{}) + end + + test "failure" do + schema = %Schema{ + type: :object, + properties: %{ + name: %Schema{type: :string} + } + } + + try do + TestAssertions.assert_raw_schema(%{name: 1234}, schema, %{}) + raise RuntimeError, "Should flunk" + rescue + e in ExUnit.AssertionError -> + assert e.message =~ "Value does not conform to schema" + end + end + end + + describe "assert_operation_response/2" do + test "success with a manually specified operationId" do + conn = + :get + |> Plug.Test.conn("/api/pets") + |> Plug.Conn.put_req_header("content-type", "application/json") + + conn = OpenApiSpexTest.Router.call(conn, []) + + assert conn.status == 200 + TestAssertions.assert_operation_response(conn, "listPets") + end + + test "success with only conn" do + conn = + :get + |> Plug.Test.conn("/api/pets") + |> Plug.Conn.put_req_header("content-type", "application/json") + + conn = OpenApiSpexTest.Router.call(conn, []) + + assert conn.status == 200 + TestAssertions.assert_operation_response(conn) + end + + test "is able to find the operationId via conn when there is an error" do + conn = + :get + |> Plug.Test.conn("/api/pets?age=notanumber") + |> OpenApiSpexTest.Router.call([]) + + assert conn.status == 422 + TestAssertions.assert_operation_response(conn) + end + + test "success with a response code range" do + conn = + :get + |> Plug.Test.conn("/api/response_code_ranges") + |> Plug.Conn.put_req_header("content-type", "application/json") + + conn = OpenApiSpexTest.Router.call(conn, []) + + assert conn.status == 200 + TestAssertions.assert_operation_response(conn, "response_code_ranges") + end + + test "missing operation id" do + conn = + :get + |> Plug.Test.conn("/api/openapi") + |> Plug.Conn.put_req_header("content-type", "application/json") + + conn = OpenApiSpexTest.Router.call(conn, []) + assert conn.status == 200 + + assert_raise( + ExUnit.AssertionError, + ~r/Failed to resolve a response schema for operation_id: not_a_real_operation_id for status code: 200/, + fn -> TestAssertions.assert_operation_response(conn, "not_a_real_operation_id") end + ) + end + + test "invalid schema" do + conn = + :get + |> Plug.Test.conn("/api/pets") + |> Plug.Conn.put_req_header("content-type", "application/json") + + conn = OpenApiSpexTest.Router.call(conn, []) + + assert conn.status == 200 + + assert_raise( + ExUnit.AssertionError, + ~r/Value does not conform to schema PetResponse: Failed to cast value to one of: no schemas validate at/, + fn -> TestAssertions.assert_operation_response(conn, "showPetById") end + ) + end + + test "returns an error when the response content-type does not match the schema" do + conn = + :get + |> Plug.Test.conn("/api/pets") + |> Plug.Conn.put_req_header("content-type", "application/json") + |> Plug.Conn.put_resp_header("content-type", "unexpected-content-type") + + conn = OpenApiSpexTest.Router.call(conn, []) + + assert conn.status == 200 + + assert_raise( + ExUnit.AssertionError, + ~r/Failed to resolve a response schema for operation_id: showPetById for status code: 200 and content type: unexpected-content-type/, + fn -> TestAssertions.assert_operation_response(conn, "showPetById") end + ) + end + end end