diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..64104578 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,39 @@ +## 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) +- [ ] UI Change Only +- [ ] Chore +- [ ] 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..f78b5713 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: ["28"] + elixir: ["1.19"] 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 @@ -31,10 +31,12 @@ jobs: path: | priv/plts - run: mix deps.get + - run: mix deps.compile - 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 +44,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: 28 + elixir-version: 1.19 + - uses: actions/cache@v4 with: key: | ${{ runner.os }}-${{ steps.beam.outputs.elixir-version }}-${{ steps.beam.outputs.otp-version }}-plug-build @@ -61,24 +64,30 @@ 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: ["26", "27", "28"] + elixir: ["1.16", "1.17", "1.18", "1.19"] + # 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'} + # Elixir 1.16 supports OTP 24–26 only + - { otp: "27", elixir: "1.16" } + - { otp: "28", elixir: "1.16" } + # Elixir 1.17 supports OTP 26–27 only + - { otp: "28", elixir: "1.17" } + # Elixir 1.18 supports OTP 25–27 only + - { otp: "28", elixir: "1.18" } 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..b7d37589 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,103 @@ 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.22.3 - 2026-05-05 + +* chore: removed unused require(s) by @David-Klemenc in https://github.com/open-api-spex/open_api_spex/pull/700 +* Relax decimal requirement by @josevalim in https://github.com/open-api-spex/open_api_spex/pull/702 + +## v3.22.2 - 2026-01-08 + +* fix: type warnings Elixir 1.19 by @davydog187 in https://github.com/open-api-spex/open_api_spex/pull/693 + +## v3.22.1 - 2025-11-21 + +* Fix elixir 1.19 support by @adamcstephens in https://github.com/open-api-spex/open_api_spex/pull/685 + +## v3.22.0 - 2025-08-05 + +* Support multiple apps in Plug.SwaggerUI by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/676 +* Validate keys given to operation/2 macro by @xxdavid in https://github.com/open-api-spex/open_api_spex/pull/675 + +## v3.21.5 - 2025-07-08 + +* Fix assert_operation_response/2 references by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/673 + +## v3.21.4 - 2025-07-01 + +* Fix OTP-28 support by @bopm in https://github.com/open-api-spex/open_api_spex/pull/672 + +## v3.21.3 - 2025-06-25 + +* Fix cast x-validate when decoded schema by @GPrimola in https://github.com/open-api-spex/open_api_spex/pull/647 +* Add examples property to Schema by @madjar in https://github.com/open-api-spex/open_api_spex/pull/654 +* Document schema resolver duplicate titles behaviour by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/656 +* Fix 1.18 compilation warnings by @zorbash in https://github.com/open-api-spex/open_api_spex/pull/665 + +## 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..a08a37c0 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,93 @@ 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_response = + case response do + %OpenApiSpex.Reference{} = ref -> + OpenApiSpex.Reference.resolve_response(ref, spec.components.responses) + + _ -> + response + end + + resolved_schema = + resolved_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..774877c7 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.22.3" 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, @@ -63,14 +63,15 @@ defmodule OpenApiSpex.Mixfile do defp deps do [ - {:credo, "~> 1.0", only: [:dev], runtime: false}, + {:credo, "~> 1.7", only: [:dev], runtime: false}, {: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 or ~> 3.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..8e993fe8 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,13 @@ %{ - "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"}, + "bunt": {:hex, :bunt, "1.0.0", "081c2c665f086849e6d57900292b3a161727ab40431219529f13c4ddcf3e7a44", [:mix], [], "hexpm", "dc5f86aa08a5f6fa6b8096f0735c4e76d54ae5c9fa2c143e5a1fc7c1cd9bb6b5"}, + "credo": {:hex, :credo, "1.7.18", "5c5596bf7aedf9c8c227f13272ac499fe8eae6237bd326f2f07dfc173786f042", [: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", "a189d164685fd945809e862fe76a7420c4398fa288d76257662aecb909d6b3e5"}, + "decimal": {:hex, :decimal, "2.4.1", "6c0fbede12fb122ba685e9ab41c6a40c129e322b3aa192f9e072e61f3a6ffaf2", [:mix], [], "hexpm", "7e618897933a8455f19a727d7c5e50a2c071a544b700e5e724298ecb4340187f"}, "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"}, + "file_system": {:hex, :file_system, "1.1.1", "31864f4685b0148f25bd3fbef2b1228457c0c89024ad67f7a81a3ffbc0bbad3a", [:mix], [], "hexpm", "7a15ff97dfe526aeefb090a7a9d3d03aa907e100e262a0f8f7746b78f8f87a5d"}, + "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"}, "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"}, 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/string_test.exs b/test/cast/string_test.exs index 427c616a..eabb5703 100644 --- a/test/cast/string_test.exs +++ b/test/cast/string_test.exs @@ -27,7 +27,7 @@ defmodule OpenApiSpex.CastStringTest do assert {:error, [error]} = cast(value: "hello", schema: schema) assert error.reason == :invalid_format assert error.value == "hello" - assert error.format == ~r/\d-\d/ + assert error.format.source == "\\d-\\d" end test "string with format (date time)" do 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/controller_specs_test.exs b/test/controller_specs_test.exs index 7ef0f786..eff3b1de 100644 --- a/test/controller_specs_test.exs +++ b/test/controller_specs_test.exs @@ -7,7 +7,7 @@ defmodule OpenApiSpex.ControllerSpecsTest do alias OpenApiSpexTest.DslController alias OpenApiSpexTest.DslControllerOperationStructs - describe "operation/1" do + describe "operation/2" do test "supports :parameters" do assert %OpenApiSpex.Operation{ responses: %{}, @@ -157,5 +157,39 @@ defmodule OpenApiSpex.ControllerSpecsTest do assert %OpenApiSpex.Operation{extensions: %{"x-foo" => "bar"}} = DslController.open_api_operation(:index) end + + test "raises when unknown key is provided" do + msg = + "Unknown keys given to operation/2: [:unknown]. Allowed keys are: " <> + "[:callbacks, :description, :deprecated, :external_docs, :operation_id, :parameters, " <> + ":request_body, :responses, :security, :summary, :tags], and keys starting with 'x-'." + + assert_raise ArgumentError, msg, fn -> + Code.eval_string(""" + defmodule TestController do + use OpenApiSpex.ControllerSpecs + + operation :index, + summary: "Users index", + parameters: [ + username: [ + in: :query, + description: "Filter by username", + type: :string + ] + ], + responses: [ + ok: {"Users index response", "application/json", UsersIndexResponse} + ], + unknown: "value", + "x-foo": "bar" + + def index(conn, _) do + json(conn, []) + end + end + """) + end + end end end 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/api_spec.ex b/test/support/api_spec.ex index 5c2629af..ec59dda1 100644 --- a/test/support/api_spec.ex +++ b/test/support/api_spec.ex @@ -76,7 +76,7 @@ defmodule OpenApiSpexTest.ApiSpec do } }, responses: %{ - unprocessable_entity: %Response{ + "unprocessable_entity" => %Response{ description: "Unprocessable Entity", content: %{"application/json" => %MediaType{schema: %Schema{type: :object}}} } 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..8ae0474a 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,21 @@ 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.Reference{ + "$ref": "#/components/responses/unprocessable_entity" + } + ], + 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/schemas.ex b/test/support/schemas.ex index 65f687fd..0f93748f 100644 --- a/test/support/schemas.ex +++ b/test/support/schemas.ex @@ -186,7 +186,7 @@ defmodule OpenApiSpexTest.Schemas do type: :object, properties: %{ id: %Schema{type: :integer, description: "User ID"}, - name: %Schema{type: :string, description: "User name", pattern: ~r/[a-zA-Z][a-zA-Z0-9_]+/}, + name: %Schema{type: :string, description: "User name", pattern: "[a-zA-Z][a-zA-Z0-9_]+"}, email: %Schema{type: :string, description: "Email address", format: :email}, password: %Schema{type: :string, description: "Login password", writeOnly: true}, age: %Schema{type: :integer, description: "Age"}, 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