From 63ee1009685debcfad2e89377610cc9c6e110e81 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibaut=20Barr=C3=A8re?= Date: Tue, 25 Jul 2023 17:32:41 +0200 Subject: [PATCH 01/59] Fix deprecation warning on Elixir 1.15, require Elixir 1.11, adapt CI (#550) * Fix deprecation warning on Elixir 1.15 * Stop testing against Elixir 1.10 * Require Elixir 1.11+ now * Update CHANGELOG.md --- .github/workflows/elixir.yml | 3 +-- CHANGELOG.md | 5 +++++ lib/open_api_spex/cast_parameters.ex | 2 +- mix.exs | 2 +- 4 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index e2cf4200..f83af979 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -66,9 +66,8 @@ jobs: strategy: matrix: otp: ['22', '23', '24', '25'] - elixir: ['1.10', '1.11', '1.12', '1.13'] + elixir: ['1.11', '1.12', '1.13'] exclude: - - {otp: '24', elixir: '1.10'} - {otp: '25', elixir: '1.10'} - {otp: '25', elixir: '1.11'} - {otp: '25', elixir: '1.12'} diff --git a/CHANGELOG.md b/CHANGELOG.md index 96af969f..0ffcc8a6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,11 @@ 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). +## Unreleased + +* Fix deprecation warning on Elixir 1.15 https://github.com/open-api-spex/open_api_spex/pull/550 +* Now require Elixir 1.11+ https://github.com/open-api-spex/open_api_spex/pull/550 + ## v3.17.3 - 2023-05-30 * Raise meaningful error message when `SchemaResolver.resolve_schema_modules_from_schema` failed to pattern match by @yuchunc in https://github.com/open-api-spex/open_api_spex/pull/541 diff --git a/lib/open_api_spex/cast_parameters.ex b/lib/open_api_spex/cast_parameters.ex index 296a04af..1155cb79 100644 --- a/lib/open_api_spex/cast_parameters.ex +++ b/lib/open_api_spex/cast_parameters.ex @@ -152,7 +152,7 @@ defmodule OpenApiSpex.CastParameters do defp pre_parse_parameter(parameter, %{content_type: content_type}, parsers) when is_bitstring(content_type) do Enum.reduce_while(parsers, {:ok, parameter}, fn {match, parser}, acc -> - if Regex.regex?(match) and Regex.match?(match, content_type) do + if is_struct(match, Regex) and Regex.match?(match, content_type) do {:halt, decode_parameter(parameter, content_type, parser)} else {:cont, acc} diff --git a/mix.exs b/mix.exs index bea70d3e..8e545b29 100644 --- a/mix.exs +++ b/mix.exs @@ -8,7 +8,7 @@ defmodule OpenApiSpex.Mixfile do [ app: :open_api_spex, version: @version, - elixir: "~> 1.10", + elixir: "~> 1.11", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, consolidate_protocols: Mix.env() != :test, From 5560d0a29bd14df0b7fc922f2949980c9ffc5ffa Mon Sep 17 00:00:00 2001 From: Brice Thomas Date: Fri, 18 Aug 2023 11:56:42 +0200 Subject: [PATCH 02/59] Add `--quiet` option for spec generation (#557) --- lib/mix/tasks/openapi.spec.json.ex | 1 + lib/mix/tasks/openapi.spec.yaml.ex | 1 + lib/open_api_spex/export_spec.ex | 23 +++++++++++++++-------- test/mix/tasks/openapi.spec.json_test.exs | 11 +++++++++++ 4 files changed, 28 insertions(+), 8 deletions(-) diff --git a/lib/mix/tasks/openapi.spec.json.ex b/lib/mix/tasks/openapi.spec.json.ex index 7ac2d08b..147d505e 100644 --- a/lib/mix/tasks/openapi.spec.json.ex +++ b/lib/mix/tasks/openapi.spec.json.ex @@ -20,6 +20,7 @@ defmodule Mix.Tasks.Openapi.Spec.Json do * `--vendor-extensions` - Whether to include open_api_spex OpenAPI vendor extensions (defaults to true) + * `--quiet` - Whether to disable output printing (defaults to false) """ use Mix.Task require Mix.Generator diff --git a/lib/mix/tasks/openapi.spec.yaml.ex b/lib/mix/tasks/openapi.spec.yaml.ex index 2e9bb70f..0158c522 100644 --- a/lib/mix/tasks/openapi.spec.yaml.ex +++ b/lib/mix/tasks/openapi.spec.yaml.ex @@ -17,6 +17,7 @@ defmodule Mix.Tasks.Openapi.Spec.Yaml do * `--vendor-extensions` - Whether to include open_api_spex OpenAPI vendor extensions (defaults to true) + * `--quiet` - Whether to disable output printing (defaults to false) """ use Mix.Task require Mix.Generator diff --git a/lib/open_api_spex/export_spec.ex b/lib/open_api_spex/export_spec.ex index 5c001115..a6c457b8 100644 --- a/lib/open_api_spex/export_spec.ex +++ b/lib/open_api_spex/export_spec.ex @@ -7,7 +7,7 @@ defmodule OpenApiSpex.ExportSpec do defmodule Options do @moduledoc false - defstruct filename: nil, spec: nil, pretty: false, vendor_extensions: true + defstruct filename: nil, spec: nil, pretty: false, vendor_extensions: true, quiet: false end def call(argv, encode_spec, default_filename) do @@ -16,7 +16,7 @@ defmodule OpenApiSpex.ExportSpec do opts |> generate_spec() |> encode_spec.(opts) - |> write_spec(opts.filename) + |> write_spec(opts) end defp generate_spec(%{spec: spec, vendor_extensions: vendor_extensions}) do @@ -41,7 +41,13 @@ defmodule OpenApiSpex.ExportSpec do defp parse_options(argv, default_filename) do parse_options = [ - strict: [spec: :string, endpoint: :string, pretty: :boolean, vendor_extensions: :boolean] + strict: [ + spec: :string, + endpoint: :string, + pretty: :boolean, + vendor_extensions: :boolean, + quiet: :boolean + ] ] {opts, args, _} = OptionParser.parse(argv, parse_options) @@ -50,17 +56,18 @@ defmodule OpenApiSpex.ExportSpec do filename: args |> List.first() || default_filename, spec: find_spec(opts), pretty: Keyword.get(opts, :pretty, false), - vendor_extensions: Keyword.get(opts, :vendor_extensions, true) + vendor_extensions: Keyword.get(opts, :vendor_extensions, true), + quiet: Keyword.get(opts, :quiet, false) } end - defp write_spec(content, filename) do - case Path.dirname(filename) do + defp write_spec(content, opts) do + case Path.dirname(opts.filename) do "." -> true - dir -> Mix.Generator.create_directory(dir) + dir -> Mix.Generator.create_directory(dir, quiet: opts.quiet) end - Mix.Generator.create_file(filename, content, force: true) + Mix.Generator.create_file(opts.filename, content, force: true, quiet: opts.quiet) end defp find_spec(opts) do diff --git a/test/mix/tasks/openapi.spec.json_test.exs b/test/mix/tasks/openapi.spec.json_test.exs index d011dec5..ef27b464 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 From 3aa4c9da373bed78ee677daa55b4184825269030 Mon Sep 17 00:00:00 2001 From: Gianluca Nitti Date: Fri, 18 Aug 2023 12:03:18 +0200 Subject: [PATCH 03/59] Fix casting non-objects against discriminator #551 (#552) Co-authored-by: Gianluca Nitti --- lib/open_api_spex/cast/discriminator.ex | 6 +++++- test/cast/discriminator_test.exs | 12 ++++++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/lib/open_api_spex/cast/discriminator.ex b/lib/open_api_spex/cast/discriminator.ex index 079def63..cd9ef05e 100644 --- a/lib/open_api_spex/cast/discriminator.ex +++ b/lib/open_api_spex/cast/discriminator.ex @@ -37,7 +37,7 @@ defmodule OpenApiSpex.Cast.Discriminator do end end - defp cast_discriminator(%_{value: value, schema: schema} = ctx) do + defp cast_discriminator(%_{value: %{} = value, schema: schema} = ctx) do {discriminator_property, mappings} = discriminator_details(schema) case value["#{discriminator_property}"] || value[discriminator_property] do @@ -53,6 +53,10 @@ defmodule OpenApiSpex.Cast.Discriminator do end end + defp cast_discriminator(ctx) do + Cast.error(ctx, {:invalid_type, :object}) + end + # When the composite key `allOf` is set we have to cast all the schemas even when the discriminator is set defp cast_composition( %_{schema: %{allOf: [_ | _]}} = composite_ctx, diff --git a/test/cast/discriminator_test.exs b/test/cast/discriminator_test.exs index 210a067d..9cf61344 100644 --- a/test/cast/discriminator_test.exs +++ b/test/cast/discriminator_test.exs @@ -168,6 +168,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 From 37743cfd37641647e40abf6703cc1d67f2332fa0 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Fri, 18 Aug 2023 13:06:13 +0300 Subject: [PATCH 04/59] Bump credo version to 1.7.0 --- mix.lock | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/mix.lock b/mix.lock index 7cde5f4a..de28ced6 100644 --- a/mix.lock +++ b/mix.lock @@ -1,12 +1,12 @@ %{ - "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, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, + "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [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", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, "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"}, + "jason": {:hex, :jason, "1.4.1", "af1504e35f629ddcdd6addb3513c3853991f694921b1b9368b0bd32beb9f1b63", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "fbb01ecdfd565b56261302f7e1fcc27c4fb8f32d56eab74db621fc154604a7a1"}, "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"}, From 6edb86dd3b5a6e7dd0d6a17440367b77a6661ddb Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Fri, 18 Aug 2023 13:10:09 +0300 Subject: [PATCH 05/59] Bump dialyxir to 1.3.0 --- mix.lock | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mix.lock b/mix.lock index de28ced6..402c6fec 100644 --- a/mix.lock +++ b/mix.lock @@ -1,7 +1,7 @@ %{ "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "credo": {:hex, :credo, "1.7.0", "6119bee47272e85995598ee04f2ebbed3e947678dee048d10b5feca139435f75", [:mix], [{:bunt, "~> 0.2.1", [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", "6839fcf63d1f0d1c0f450abc8564a57c43d644077ab96f2934563e68b8a769d7"}, - "dialyxir": {:hex, :dialyxir, "1.1.0", "c5aab0d6e71e5522e77beff7ba9e08f8e02bad90dfbeffae60eaf0cb47e29488", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "07ea8e49c45f15264ebe6d5b93799d4dd56a44036cf42d0ad9c960bc266c0b9a"}, + "dialyxir": {:hex, :dialyxir, "1.3.0", "fd1672f0922b7648ff9ce7b1b26fcf0ef56dda964a459892ad15f6b4410b5284", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "00b2a4bcd6aa8db9dcb0b38c1225b7277dca9bc370b6438715667071a304696f"}, "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"}, From 7a0309ae735180c4e1fa288f4dad4a1a54160764 Mon Sep 17 00:00:00 2001 From: Matt Sutkowski Date: Wed, 23 Aug 2023 03:27:08 -0700 Subject: [PATCH 06/59] feat: add assert_operation_response, assert_raw_schema (#545) * feat: add assert_operation_response, assert_raw_schema * make assert_operation_response pipeable * fix return type * automagically infer operationId in assertion * don't need to resolve a %Schema{} * ignore 204s * use OpenApiSpex.OpenApi.json_encoder() * rename test to match fn * reorganize json_encoder check per feedback * update json_encoder message for :jason or :poison * use a regex to match json content types in validate_operation_response * :nail_care: feedback - types, error message, module attrib for regex * add doc for content_type_from_header * remove no_return from spec --- lib/open_api_spex.ex | 18 +--- lib/open_api_spex/cast/utils.ex | 39 +++++++ lib/open_api_spex/operation2.ex | 16 ++- lib/open_api_spex/plug/cast.ex | 8 +- lib/open_api_spex/plug/render_spec.ex | 2 +- lib/open_api_spex/test/test_assertions.ex | 120 +++++++++++++++++++++- test/support/pet_controller.ex | 6 +- test/test_assertions_test.exs | 95 +++++++++++++++++ 8 files changed, 277 insertions(+), 27 deletions(-) diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex index f805da13..165de826 100644 --- a/lib/open_api_spex.ex +++ b/lib/open_api_spex.ex @@ -15,7 +15,7 @@ defmodule OpenApiSpex do SchemaResolver } - alias OpenApiSpex.Cast.Error + alias OpenApiSpex.Cast.{Error, Utils} @doc """ Adds schemas to the api spec from the modules specified in the Operations. @@ -93,22 +93,10 @@ defmodule OpenApiSpex do content_type \\ nil, opts \\ [] ) do - content_type = content_type || content_type_from_header(conn) + content_type = content_type || Utils.content_type_from_header(conn) Operation2.cast(spec, operation, conn, content_type, opts) end - defp content_type_from_header(conn = %Plug.Conn{}) do - case Plug.Conn.get_req_header(conn, "content-type") do - [header_value | _] -> - header_value - |> String.split(";") - |> List.first() - - _ -> - nil - end - end - @doc """ Cast params to conform to a `OpenApiSpex.Schema`. @@ -406,7 +394,7 @@ defmodule OpenApiSpex do Resolve a schema or reference to a schema. """ @spec resolve_schema(Schema.t() | Reference.t() | module, Components.schemas_map()) :: - Schema.t() + Schema.t() | nil def resolve_schema(%Schema{} = schema, _), do: schema def resolve_schema(%Reference{} = ref, schemas), do: Reference.resolve_schema(ref, schemas) diff --git a/lib/open_api_spex/cast/utils.ex b/lib/open_api_spex/cast/utils.ex index d897e3af..5be0b02a 100644 --- a/lib/open_api_spex/cast/utils.ex +++ b/lib/open_api_spex/cast/utils.ex @@ -46,4 +46,43 @@ defmodule OpenApiSpex.Cast.Utils do end def check_required_fields(_ctx, _acc), do: :ok + + @doc """ + Retrieves the content type from the request header of the given connection. + + ## Parameters: + + - `conn`: The connection from which the content type should be retrieved. Must be an instance of `Plug.Conn`. + + ## Returns: + + - If the content type is found: Returns the main content type as a string. For example, for the header "application/json; charset=utf-8", it would return "application/json". + - If the content type is not found or is not set: Returns `nil`. + + ## Examples: + + iex> content_type_from_header(%Plug.Conn{req_headers: [{"content-type", "application/json; charset=utf-8"}]}) + "application/json" + + iex> content_type_from_header(%Plug.Conn{req_headers: []}) + nil + + ## Notes: + + - The function only retrieves the main content type and does not consider any additional parameters that may be set in the `content-type` header. + - If multiple `content-type` headers are found, the function will only return the value of the first one. + + """ + @spec content_type_from_header(Plug.Conn.t()) :: String.t() | nil + def content_type_from_header(conn = %Plug.Conn{}) do + case Plug.Conn.get_req_header(conn, "content-type") do + [header_value | _] -> + header_value + |> String.split(";") + |> List.first() + + _ -> + nil + end + end end diff --git a/lib/open_api_spex/operation2.ex b/lib/open_api_spex/operation2.ex index d1452d7b..41d0b599 100644 --- a/lib/open_api_spex/operation2.ex +++ b/lib/open_api_spex/operation2.ex @@ -41,12 +41,26 @@ defmodule OpenApiSpex.Operation2 do components, opts ) do - {:ok, conn |> cast_conn(body) |> maybe_replace_body(body, replace_params)} + {:ok, + conn + |> cast_conn(body) + |> maybe_replace_body(body, replace_params) + |> put_operation_id(operation)} end end ## Private functions + defp put_operation_id(conn, operation) do + private_data = + conn + |> Map.get(:private) + |> Map.get(:open_api_spex, %{}) + |> Map.put(:operation_id, operation.operationId) + + Plug.Conn.put_private(conn, :open_api_spex, private_data) + end + defp cast_conn(conn, body) do private_data = conn diff --git a/lib/open_api_spex/plug/cast.ex b/lib/open_api_spex/plug/cast.ex index 16f765c0..9c97c3b9 100644 --- a/lib/open_api_spex/plug/cast.ex +++ b/lib/open_api_spex/plug/cast.ex @@ -44,8 +44,8 @@ defmodule OpenApiSpex.Plug.Cast do @behaviour Plug + alias OpenApiSpex.Cast.Utils alias OpenApiSpex.Plug.PutApiSpec - alias Plug.Conn @impl Plug @deprecated "Use OpenApiSpex.Plug.CastAndValidate instead" @@ -64,11 +64,7 @@ defmodule OpenApiSpex.Plug.Cast do {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) operation = operation_lookup[operation_id] - content_type = - Conn.get_req_header(conn, "content-type") - |> Enum.at(0, "") - |> String.split(";") - |> Enum.at(0) + content_type = Utils.content_type_from_header(conn) # credo:disable-for-next-line case apply(OpenApiSpex, :cast, [spec, operation, conn, content_type]) do diff --git a/lib/open_api_spex/plug/render_spec.ex b/lib/open_api_spex/plug/render_spec.ex index 4cf9bfd1..f07804d3 100644 --- a/lib/open_api_spex/plug/render_spec.ex +++ b/lib/open_api_spex/plug/render_spec.ex @@ -41,7 +41,7 @@ defmodule OpenApiSpex.Plug.RenderSpec do |> Plug.Conn.send_resp(200, @json_encoder.encode!(spec)) end else - IO.warn("No JSON encoder found. Please add :json or :poison in your mix dependencies.") + IO.warn("No JSON encoder found. Please add :jason or :poison in your mix dependencies.") @impl Plug def call(conn, _opts), do: conn diff --git a/lib/open_api_spex/test/test_assertions.ex b/lib/open_api_spex/test/test_assertions.ex index ef35bd4a..42f95b2e 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,77 @@ 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 schema. Unable to find a response for operation_id: #{operation_id} for response 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) + + resolved_schema = + get_in(operation, [ + Access.key!(:responses), + Access.key!(conn.status), + Access.key!(:content), + content_type, + Access.key!(:schema) + ]) + + if is_nil(resolved_schema) do + flunk( + "Failed to resolve schema! Unable to find a response for operation_id: #{operation_id} for response 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/test/support/pet_controller.ex b/test/support/pet_controller.ex index e0a1530a..fc68f3d6 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,8 @@ defmodule OpenApiSpexTest.PetController do @doc """ Get a list of pets. """ - @doc responses: [ok: {"Pet list", "application/json", Schemas.PetsResponse}] + @doc responses: [ok: {"Pet list", "application/json", Schemas.PetsResponse}], + operation_id: "listPets" def index(conn, _params) do json(conn, %Schemas.PetsResponse{ data: [ diff --git a/test/test_assertions_test.exs b/test/test_assertions_test.exs index aa85755f..0959eef5 100644 --- a/test/test_assertions_test.exs +++ b/test/test_assertions_test.exs @@ -64,4 +64,99 @@ 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 "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 + + try do + TestAssertions.assert_operation_response(conn, "not_a_real_operation_id") + raise RuntimeError, "Should flunk" + rescue + e in ExUnit.AssertionError -> + assert e.message =~ + "Failed to resolve schema. Unable to find a response for operation_id: not_a_real_operation_id for response status code: 200" + 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 + + try do + TestAssertions.assert_operation_response(conn, "showPetById") + raise RuntimeError, "Should flunk" + rescue + e in ExUnit.AssertionError -> + assert e.message =~ + "Value does not conform to schema PetResponse: Failed to cast value to one of: no schemas validate at" + end + end + end end From c1dbca17a3d64c107de18d85311a447cd27e96b2 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Wed, 23 Aug 2023 15:18:26 +0300 Subject: [PATCH 07/59] Release version 3.18.0 --- CHANGELOG.md | 9 ++++++--- README.md | 2 +- mix.exs | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0ffcc8a6..8a6d8c7a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,10 +5,13 @@ 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). -## Unreleased +## v3.18.0 - 2023-08-23 -* Fix deprecation warning on Elixir 1.15 https://github.com/open-api-spex/open_api_spex/pull/550 -* Now require Elixir 1.11+ https://github.com/open-api-spex/open_api_spex/pull/550 +* Relax dependency constraint on ymlr to allow version ~> 4.0 by @arcanemachine in https://github.com/open-api-spex/open_api_spex/pull/544 +* Fix deprecation warning on Elixir 1.15, require Elixir 1.11, adapt CI by @thbar in https://github.com/open-api-spex/open_api_spex/pull/550 +* Add `--quiet` option for spec generation by @Cowa in https://github.com/open-api-spex/open_api_spex/pull/557 +* Fix casting non-objects against discriminator #551 by @gianluca-nitti in https://github.com/open-api-spex/open_api_spex/pull/552 +* feat: add assert_operation_response, assert_raw_schema by @msutkowski in https://github.com/open-api-spex/open_api_spex/pull/545 ## v3.17.3 - 2023-05-30 diff --git a/README.md b/README.md index 2bcede5b..4305a673 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The package can be installed by adding `:open_api_spex` to your list of dependen ```elixir def deps do [ - {:open_api_spex, "~> 3.16"} + {:open_api_spex, "~> 3.18"} ] end ``` diff --git a/mix.exs b/mix.exs index 8e545b29..78459d52 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule OpenApiSpex.Mixfile do use Mix.Project @source_url "https://github.com/open-api-spex/open_api_spex" - @version "3.17.3" + @version "3.18.0" def project do [ From f90bc1118d050176b18aebe02f73137e42c37954 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Mon, 4 Sep 2023 19:47:16 +0300 Subject: [PATCH 08/59] Document the spec export task `--filename` option --- lib/mix/tasks/openapi.spec.json.ex | 5 +++-- lib/mix/tasks/openapi.spec.yaml.ex | 4 +++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/lib/mix/tasks/openapi.spec.json.ex b/lib/mix/tasks/openapi.spec.json.ex index 147d505e..fe5d046b 100644 --- a/lib/mix/tasks/openapi.spec.json.ex +++ b/lib/mix/tasks/openapi.spec.json.ex @@ -1,4 +1,5 @@ defmodule Mix.Tasks.Openapi.Spec.Json do + @default_filename "openapi.json" @moduledoc """ Serialize the given OpenApi spec module to a JSON file. @@ -21,12 +22,12 @@ defmodule Mix.Tasks.Openapi.Spec.Json do (defaults to true) * `--quiet` - Whether to disable output printing (defaults to false) + + * `--filename` - The output filename (defaults to "#{@default_filename}") """ use Mix.Task require Mix.Generator - @default_filename "openapi.json" - @impl true def run(argv) do {opts, _, _} = OptionParser.parse(argv, strict: [start_app: :boolean]) diff --git a/lib/mix/tasks/openapi.spec.yaml.ex b/lib/mix/tasks/openapi.spec.yaml.ex index 0158c522..caef6ecd 100644 --- a/lib/mix/tasks/openapi.spec.yaml.ex +++ b/lib/mix/tasks/openapi.spec.yaml.ex @@ -1,4 +1,5 @@ defmodule Mix.Tasks.Openapi.Spec.Yaml do + @default_filename "openapi.yaml" @moduledoc """ Serialize the given OpenApi spec module to a YAML file. @@ -18,11 +19,12 @@ defmodule Mix.Tasks.Openapi.Spec.Yaml do (defaults to true) * `--quiet` - Whether to disable output printing (defaults to false) + + * `--filename` - The output filename (defaults to "#{@default_filename}") """ use Mix.Task require Mix.Generator - @default_filename "openapi.yaml" @dialyzer {:nowarn_function, encoder: 0} @impl Mix.Task From 7c05fa01adb249d4756ec83ef6cd32159087b670 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Wed, 13 Sep 2023 21:10:56 +0300 Subject: [PATCH 09/59] Docstest Operation.parameter/5 --- lib/open_api_spex/operation.ex | 18 +++++++++++++++++- lib/open_api_spex/parameter.ex | 3 ++- test/operation_test.exs | 2 ++ 3 files changed, 21 insertions(+), 2 deletions(-) diff --git a/lib/open_api_spex/operation.ex b/lib/open_api_spex/operation.ex index a2c2caf2..153da17e 100644 --- a/lib/open_api_spex/operation.ex +++ b/lib/open_api_spex/operation.ex @@ -86,11 +86,27 @@ defmodule OpenApiSpex.Operation do @doc """ Shorthand for constructing an `OpenApiSpex.Parameter` given name, location, type, description and optional examples + + ## Examples + + iex> Operation.parameter( + ...> :status, + ...> :query, + ...> %OpenApiSpex.Schema{type: :string, enum: ["pending", "in_progress", "completed"]}, + ...> "The status of an entity" + ...> ) + %OpenApiSpex.Parameter{ + name: :status, + in: :query, + description: "The status of an entity", + required: false, + schema: %OpenApiSpex.Schema{enum: ["pending", "in_progress", "completed"], type: :string} + } """ @spec parameter( name :: atom, location :: Parameter.location(), - type :: Reference.t() | Schema.t() | atom, + type :: Reference.t() | Schema.t() | Parameter.type() | atom(), description :: String.t(), opts :: keyword ) :: Parameter.t() diff --git a/lib/open_api_spex/parameter.ex b/lib/open_api_spex/parameter.ex index c493c060..86329696 100644 --- a/lib/open_api_spex/parameter.ex +++ b/lib/open_api_spex/parameter.ex @@ -73,11 +73,12 @@ defmodule OpenApiSpex.Parameter do } @type parameters :: %{String.t() => t | Reference.t()} | nil + @type type :: :boolean | :integer | :number | :string | :array | :object @doc """ Sets the schema for a parameter from a simple type, reference or Schema """ - @spec put_schema(t, Reference.t() | Schema.t() | atom) :: t + @spec put_schema(t, Reference.t() | Schema.t() | type) :: t def put_schema(parameter = %Parameter{}, type = %Reference{}) do %{parameter | schema: type} end 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 From 531607c43f7b6ce3f10597b155afcb75f4ce2b1c Mon Sep 17 00:00:00 2001 From: Alberto Sartori Date: Wed, 22 Nov 2023 14:55:50 +0100 Subject: [PATCH 10/59] Cast discriminator when no title present (#574) Co-authored-by: Alberto Sartori --- lib/open_api_spex/cast/discriminator.ex | 34 +++++++++++++---------- test/cast/discriminator_test.exs | 23 +++++++++++++++ test/mix/tasks/openapi.spec.json_test.exs | 2 +- 3 files changed, 43 insertions(+), 16 deletions(-) diff --git a/lib/open_api_spex/cast/discriminator.ex b/lib/open_api_spex/cast/discriminator.ex index cd9ef05e..18221f1b 100644 --- a/lib/open_api_spex/cast/discriminator.ex +++ b/lib/open_api_spex/cast/discriminator.ex @@ -37,7 +37,7 @@ defmodule OpenApiSpex.Cast.Discriminator do end end - defp cast_discriminator(%_{value: %{} = value, schema: schema} = ctx) do + defp cast_discriminator(%_{value: %{} = value, schema: schema, schemas: schemas} = ctx) do {discriminator_property, mappings} = discriminator_details(schema) case value["#{discriminator_property}"] || value[discriminator_property] do @@ -49,7 +49,7 @@ defmodule OpenApiSpex.Cast.Discriminator do # or return an error according to the Open API Spec. composite_ctx = %{ctx | schema: %{schema | discriminator: nil}} - cast_composition(composite_ctx, ctx, discriminator_value, mappings) + cast_composition(composite_ctx, ctx, discriminator_value, mappings, schemas) end end @@ -62,7 +62,8 @@ defmodule OpenApiSpex.Cast.Discriminator do %_{schema: %{allOf: [_ | _]}} = composite_ctx, ctx, discriminator_value, - mappings + mappings, + schemas ) do with {composite_schemas, cast_composition_result} <- {locate_composition_schemas(composite_ctx), Cast.cast(composite_ctx)}, @@ -71,7 +72,8 @@ defmodule OpenApiSpex.Cast.Discriminator do find_discriminator_schema( discriminator_value, mappings, - composite_schemas + composite_schemas, + schemas ) do Cast.cast(%{composite_ctx | schema: schema}) else @@ -80,9 +82,10 @@ defmodule OpenApiSpex.Cast.Discriminator do end end - defp cast_composition(composite_ctx, ctx, discriminator_value, mappings) do + defp cast_composition(composite_ctx, ctx, discriminator_value, mappings, schemas) do with composite_schemas <- locate_composition_schemas(composite_ctx), - %{} = schema <- find_discriminator_schema(discriminator_value, mappings, composite_schemas) do + %{} = schema <- + find_discriminator_schema(discriminator_value, mappings, composite_schemas, schemas) do Cast.cast(%{composite_ctx | schema: schema}) else nil -> error(:invalid_discriminator_value, ctx) @@ -90,27 +93,28 @@ defmodule OpenApiSpex.Cast.Discriminator do end end - defp find_discriminator_schema(discriminator, mappings = %{}, schemas) do + defp find_discriminator_schema(discriminator, mappings = %{}, composite_schemas, schemas) do case Map.fetch(mappings, discriminator) do {:ok, "#/components/schemas/" <> name} -> - find_discriminator_schema(name, nil, schemas) + find_discriminator_schema(name, nil, composite_schemas, schemas) || Map.get(schemas, name) {:ok, name} -> - find_discriminator_schema(name, nil, schemas) + find_discriminator_schema(name, nil, composite_schemas, schemas) :error -> - find_discriminator_schema(discriminator, nil, schemas) + find_discriminator_schema(discriminator, nil, composite_schemas, schemas) end end - defp find_discriminator_schema(discriminator, _mappings, schemas) + defp find_discriminator_schema(discriminator, nil, composite_schemas, _schemas) when is_atom(discriminator) and not is_nil(discriminator), - do: Enum.find(schemas, &Kernel.==(&1."x-struct", discriminator)) + do: Enum.find(composite_schemas, &Kernel.==(&1."x-struct", discriminator)) - defp find_discriminator_schema(discriminator, _mappings, schemas) when is_binary(discriminator), - do: Enum.find(schemas, &Kernel.==(&1.title, discriminator)) + defp find_discriminator_schema(discriminator, nil, composite_schemas, _schemas) + when is_binary(discriminator), + do: Enum.find(composite_schemas, &Kernel.==(&1.title, discriminator)) - defp find_discriminator_schema(_discriminator, _mappings, _schemas), do: nil + defp find_discriminator_schema(_discriminator, _mappings, _composite_schemas, _schemas), do: nil defp discriminator_details(%{discriminator: %{propertyName: property_name, mapping: mappings}}), do: {String.to_existing_atom(property_name), mappings} diff --git a/test/cast/discriminator_test.exs b/test/cast/discriminator_test.exs index 9cf61344..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 diff --git a/test/mix/tasks/openapi.spec.json_test.exs b/test/mix/tasks/openapi.spec.json_test.exs index ef27b464..8b941c90 100644 --- a/test/mix/tasks/openapi.spec.json_test.exs +++ b/test/mix/tasks/openapi.spec.json_test.exs @@ -23,7 +23,7 @@ defmodule Mix.Tasks.Openapi.Spec.JsonTest do Mix.Tasks.Openapi.Spec.Json.run(~w( --quiet=true --spec OpenApiSpexTest.Tasks.SpecModule - "tmp/openapi.json" + tmp/openapi.json )) refute_received {:mix_shell, :info, ["* creating tmp"]} From ae71a1c1dbc8a28a3b363c0f8f78a9d67454812d Mon Sep 17 00:00:00 2001 From: Alisina Bahadori Date: Mon, 18 Dec 2023 11:25:15 -0500 Subject: [PATCH 11/59] Exclude empty paths from spec (#583) * Exclude empty paths from spec --- lib/open_api_spex/path_item.ex | 23 ++++++++++++----------- test/paths_test.exs | 3 +++ 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lib/open_api_spex/path_item.ex b/lib/open_api_spex/path_item.ex index 82042707..c7f0c585 100644 --- a/lib/open_api_spex/path_item.ex +++ b/lib/open_api_spex/path_item.ex @@ -73,16 +73,17 @@ defmodule OpenApiSpex.PathItem do defp from_valid_routes([]), do: nil defp from_valid_routes(routes) do - attrs = - routes - |> Enum.map(fn route -> - case Operation.from_route(route) do - nil -> nil - op -> {route.verb, op} - end - end) - |> Enum.filter(& &1) - - struct(PathItem, attrs) + routes + |> Enum.map(fn route -> + case Operation.from_route(route) do + nil -> nil + op -> {route.verb, op} + end + end) + |> Enum.filter(& &1) + |> case do + [] -> nil + attrs -> struct(PathItem, attrs) + end end end diff --git a/test/paths_test.exs b/test/paths_test.exs index 300e9497..3d633395 100644 --- a/test/paths_test.exs +++ b/test/paths_test.exs @@ -12,6 +12,9 @@ defmodule OpenApiSpex.PathsTest do "/api/pets/{id}" => pets_path_item } = paths + refute Map.has_key?(paths, "/api/noapi") + refute Map.has_key?(paths, "/api/noapi_with_struct") + assert pets_path_item.patch.operationId == "OpenApiSpexTest.PetController.update" assert pets_path_item.put.operationId == "OpenApiSpexTest.PetController.update (2)" end From fb02fb78cb4c178aef52f5c1796cf257ce325fb8 Mon Sep 17 00:00:00 2001 From: Matt Sutkowski Date: Mon, 18 Dec 2023 08:25:48 -0800 Subject: [PATCH 12/59] fix: assert_operation_response header lookup (#584) * fix: assert_operation_response header lookup --- lib/open_api_spex/cast/utils.ex | 16 +++++++-- lib/open_api_spex/test/test_assertions.ex | 19 +++++----- test/test_assertions_test.exs | 44 ++++++++++++++--------- 3 files changed, 50 insertions(+), 29 deletions(-) diff --git a/lib/open_api_spex/cast/utils.ex b/lib/open_api_spex/cast/utils.ex index 5be0b02a..c2ba810b 100644 --- a/lib/open_api_spex/cast/utils.ex +++ b/lib/open_api_spex/cast/utils.ex @@ -73,9 +73,19 @@ defmodule OpenApiSpex.Cast.Utils do - If multiple `content-type` headers are found, the function will only return the value of the first one. """ - @spec content_type_from_header(Plug.Conn.t()) :: String.t() | nil - def content_type_from_header(conn = %Plug.Conn{}) do - case Plug.Conn.get_req_header(conn, "content-type") do + @spec content_type_from_header(Plug.Conn.t(), :request | :response) :: + String.t() | nil + def content_type_from_header(conn = %Plug.Conn{}, header_location \\ :request) do + content_type = + case header_location do + :request -> + Plug.Conn.get_req_header(conn, "content-type") + + :response -> + Plug.Conn.get_resp_header(conn, "content-type") + end + + case content_type do [header_value | _] -> header_value |> String.split(";") diff --git a/lib/open_api_spex/test/test_assertions.ex b/lib/open_api_spex/test/test_assertions.ex index 42f95b2e..6d089024 100644 --- a/lib/open_api_spex/test/test_assertions.ex +++ b/lib/open_api_spex/test/test_assertions.ex @@ -136,7 +136,7 @@ defmodule OpenApiSpex.TestAssertions do case operation_lookup[operation_id] do nil -> flunk( - "Failed to resolve schema. Unable to find a response for operation_id: #{operation_id} for response status code: #{conn.status}" + "Failed to resolve a response schema for operation_id: #{operation_id} for status code: #{conn.status}" ) operation -> @@ -154,20 +154,19 @@ defmodule OpenApiSpex.TestAssertions do ) :: term | no_return defp validate_operation_response(conn, %Operation{operationId: operation_id} = operation, spec) do - content_type = Utils.content_type_from_header(conn) + content_type = Utils.content_type_from_header(conn, :response) resolved_schema = - get_in(operation, [ - Access.key!(:responses), - Access.key!(conn.status), - Access.key!(:content), - content_type, - Access.key!(:schema) - ]) + operation + |> Map.get(:responses, %{}) + |> Map.get(conn.status, %{}) + |> Map.get(:content, %{}) + |> Map.get(content_type, %{}) + |> Map.get(:schema) if is_nil(resolved_schema) do flunk( - "Failed to resolve schema! Unable to find a response for operation_id: #{operation_id} for response status code: #{conn.status} and content type #{content_type}" + "Failed to resolve a response schema for operation_id: #{operation_id} for status code: #{conn.status} and content type: #{content_type}" ) end diff --git a/test/test_assertions_test.exs b/test/test_assertions_test.exs index 0959eef5..9174b5e2 100644 --- a/test/test_assertions_test.exs +++ b/test/test_assertions_test.exs @@ -129,14 +129,11 @@ defmodule OpenApiSpex.TestAssertionsTest do conn = OpenApiSpexTest.Router.call(conn, []) assert conn.status == 200 - try do - TestAssertions.assert_operation_response(conn, "not_a_real_operation_id") - raise RuntimeError, "Should flunk" - rescue - e in ExUnit.AssertionError -> - assert e.message =~ - "Failed to resolve schema. Unable to find a response for operation_id: not_a_real_operation_id for response status code: 200" - end + 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 @@ -149,14 +146,29 @@ defmodule OpenApiSpex.TestAssertionsTest do assert conn.status == 200 - try do - TestAssertions.assert_operation_response(conn, "showPetById") - raise RuntimeError, "Should flunk" - rescue - e in ExUnit.AssertionError -> - assert e.message =~ - "Value does not conform to schema PetResponse: Failed to cast value to one of: no schemas validate at" - end + 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 From e4deef525d09110b17d5320f6bd35f9fefca9705 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Tue, 19 Dec 2023 15:52:19 +0200 Subject: [PATCH 13/59] Release version 3.18.1 --- CHANGELOG.md | 8 ++++++++ mix.exs | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a6d8c7a..28057b04 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ 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.18.1 - 2023-12-19 + +* Fix `assert_operation_response/2` header lookup by @msutkowski in https://github.com/open-api-spex/open_api_spex/pull/584 +* Exclude empty paths (`operation false`) from generated spec by @alisinabh in https://github.com/open-api-spex/open_api_spex/pull/583 +* Cast discriminator when no title present (#574) by @albertored in https://github.com/open-api-spex/open_api_spex/pull/574 +* Docstest Operation.parameter/5 by @zorbash +* Document the spec export task `--filename` option by @zorbash + ## v3.18.0 - 2023-08-23 * Relax dependency constraint on ymlr to allow version ~> 4.0 by @arcanemachine in https://github.com/open-api-spex/open_api_spex/pull/544 diff --git a/mix.exs b/mix.exs index 78459d52..e130d38c 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule OpenApiSpex.Mixfile do use Mix.Project @source_url "https://github.com/open-api-spex/open_api_spex" - @version "3.18.0" + @version "3.18.1" def project do [ From 0b1396c2d383383c8ce9afd4f15aa68b95772710 Mon Sep 17 00:00:00 2001 From: Angelika Tyborska Date: Tue, 16 Jan 2024 19:36:53 +0100 Subject: [PATCH 14/59] Fix 'AllOf cast returns a map, but I expected a struct' (#592) * Add failing test * Cast result of AllOf cast into a struct * Shorter module name --- lib/open_api_spex/cast/all_of.ex | 8 ++++---- test/cast/all_of_test.exs | 3 ++- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/lib/open_api_spex/cast/all_of.ex b/lib/open_api_spex/cast/all_of.ex index 372d55b5..e0f49ced 100644 --- a/lib/open_api_spex/cast/all_of.ex +++ b/lib/open_api_spex/cast/all_of.ex @@ -34,14 +34,14 @@ defmodule OpenApiSpex.Cast.AllOf do cast_all_of(%{ctx | schema: %{schema | allOf: [nested_schema | remaining]}}, result) end - defp cast_all_of(%{schema: %{allOf: []}, errors: []} = ctx, acc) do + defp cast_all_of(%{schema: %{allOf: [], "x-struct": module}, errors: []} = ctx, acc) + when not is_nil(module) do with :ok <- Utils.check_required_fields(ctx, acc) do - {:ok, acc} + {:ok, struct(module, acc)} end end - defp cast_all_of(%{schema: %{allOf: [], errors: [], "x-struct": module}} = ctx, acc) - when not is_nil(module) do + defp cast_all_of(%{schema: %{allOf: []}, errors: []} = ctx, acc) do with :ok <- Utils.check_required_fields(ctx, acc) do {:ok, acc} end 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 From d6d3c895a53260b814c84d05d3c1a4171f6e25a1 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Wed, 17 Jan 2024 19:59:44 +0200 Subject: [PATCH 15/59] Add missing NoneCache test --- test/plug/none_cache_test.exs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 test/plug/none_cache_test.exs 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 From d9cfffee04436b8bb88e769c5ba211c69e1a087b Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Fri, 26 Jan 2024 13:30:22 +0200 Subject: [PATCH 16/59] Release version 3.18.2 --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 28057b04..fe283ccf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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.18.2 - 2024-01-26 + +* Fix 'AllOf cast returns a map, but I expected a struct' by @angelikatyborska in https://github.com/open-api-spex/open_api_spex/pull/592 + ## v3.18.1 - 2023-12-19 * Fix `assert_operation_response/2` header lookup by @msutkowski in https://github.com/open-api-spex/open_api_spex/pull/584 diff --git a/mix.exs b/mix.exs index e130d38c..cd988207 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule OpenApiSpex.Mixfile do use Mix.Project @source_url "https://github.com/open-api-spex/open_api_spex" - @version "3.18.1" + @version "3.18.2" def project do [ From 844f24a1f317862110ea2f99288cf7c6430c722e Mon Sep 17 00:00:00 2001 From: Aleksandr Lossenko Date: Tue, 13 Feb 2024 20:04:20 +0100 Subject: [PATCH 17/59] Relax dependency constraint on ymlr to allow version ~> 5.0 (#586) * relax dependency on ymlr, and fix some tests * test with more elixir versions --- .github/workflows/elixir.yml | 9 +++++++-- mix.exs | 2 +- test/cast/object_test.exs | 6 +++++- test/paths_test.exs | 4 ++-- 4 files changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index f83af979..8919ca82 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -65,12 +65,17 @@ jobs: name: Test (OTP ${{matrix.otp}} / Elixir ${{matrix.elixir}}) strategy: matrix: - otp: ['22', '23', '24', '25'] - elixir: ['1.11', '1.12', '1.13'] + otp: ['22', '23', '24', '25', '26'] + elixir: ['1.11', '1.12', '1.13', '1.14', '1.15', '1.16'] exclude: - {otp: '25', elixir: '1.10'} - {otp: '25', elixir: '1.11'} - {otp: '25', elixir: '1.12'} + - {otp: '26', elixir: '1.10'} + - {otp: '26', elixir: '1.11'} + - {otp: '26', elixir: '1.12'} + - {otp: '26', elixir: '1.13'} + - {otp: '26', elixir: '1.14'} steps: - uses: actions/checkout@v2 - uses: erlef/setup-beam@v1 diff --git a/mix.exs b/mix.exs index cd988207..338f08a0 100644 --- a/mix.exs +++ b/mix.exs @@ -70,7 +70,7 @@ defmodule OpenApiSpex.Mixfile do {: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} + {:ymlr, "~> 2.0 or ~> 3.0 or ~> 4.0 or ~> 5.0", optional: true} ] end 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/paths_test.exs b/test/paths_test.exs index 3d633395..cdfa2494 100644 --- a/test/paths_test.exs +++ b/test/paths_test.exs @@ -15,8 +15,8 @@ defmodule OpenApiSpex.PathsTest do refute Map.has_key?(paths, "/api/noapi") refute Map.has_key?(paths, "/api/noapi_with_struct") - assert pets_path_item.patch.operationId == "OpenApiSpexTest.PetController.update" - assert pets_path_item.put.operationId == "OpenApiSpexTest.PetController.update (2)" + assert pets_path_item.put.operationId == "OpenApiSpexTest.PetController.update" + assert pets_path_item.patch.operationId == "OpenApiSpexTest.PetController.update (2)" end end end From 3bfa0492b5d4351520e2d2c3f615387c381fcc85 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Fri, 15 Mar 2024 12:47:34 +0200 Subject: [PATCH 18/59] Update Elixir version test matrix (#602) * Update Elixir version test matrix * Fix map key order dependent test --- .github/workflows/elixir.yml | 14 ++++++++++++-- test/paths_test.exs | 6 ++++-- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/.github/workflows/elixir.yml b/.github/workflows/elixir.yml index 8919ca82..802e4c11 100644 --- a/.github/workflows/elixir.yml +++ b/.github/workflows/elixir.yml @@ -68,10 +68,20 @@ jobs: otp: ['22', '23', '24', '25', '26'] elixir: ['1.11', '1.12', '1.13', '1.14', '1.15', '1.16'] exclude: - - {otp: '25', elixir: '1.10'} + - {otp: '22', elixir: '1.14'} + - {otp: '22', elixir: '1.15'} + - {otp: '22', elixir: '1.16'} + - {otp: '23', elixir: '1.14'} + - {otp: '23', elixir: '1.15'} + - {otp: '23', elixir: '1.16'} + - {otp: '24', elixir: '1.11'} + - {otp: '24', elixir: '1.12'} + - {otp: '24', elixir: '1.13'} + - {otp: '24', elixir: '1.14'} + - {otp: '24', elixir: '1.15'} - {otp: '25', elixir: '1.11'} - {otp: '25', elixir: '1.12'} - - {otp: '26', elixir: '1.10'} + - {otp: '25', elixir: '1.14'} - {otp: '26', elixir: '1.11'} - {otp: '26', elixir: '1.12'} - {otp: '26', elixir: '1.13'} diff --git a/test/paths_test.exs b/test/paths_test.exs index cdfa2494..4bec9a9f 100644 --- a/test/paths_test.exs +++ b/test/paths_test.exs @@ -15,8 +15,10 @@ defmodule OpenApiSpex.PathsTest do refute Map.has_key?(paths, "/api/noapi") refute Map.has_key?(paths, "/api/noapi_with_struct") - assert pets_path_item.put.operationId == "OpenApiSpexTest.PetController.update" - assert pets_path_item.patch.operationId == "OpenApiSpexTest.PetController.update (2)" + 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 From 12dc58f1b70ba6a324298c765ec56d3ded22be34 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Fri, 15 Mar 2024 13:17:46 +0200 Subject: [PATCH 19/59] Release version 3.18.3 --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fe283ccf..80d09f7e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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.18.3 - 2024-03-15 + +* Relax dependency constraint on ymlr to allow version ~> 5.0 by @egze in https://github.com/open-api-spex/open_api_spex/pull/586 + ## v3.18.2 - 2024-01-26 * Fix 'AllOf cast returns a map, but I expected a struct' by @angelikatyborska in https://github.com/open-api-spex/open_api_spex/pull/592 diff --git a/mix.exs b/mix.exs index 338f08a0..2ca3cc28 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule OpenApiSpex.Mixfile do use Mix.Project @source_url "https://github.com/open-api-spex/open_api_spex" - @version "3.18.2" + @version "3.18.3" def project do [ From 8898859da1eb358806d15b3c79c4accf4afcd8ab Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Tue, 30 Apr 2024 17:46:30 +0300 Subject: [PATCH 20/59] Support response code ranges See: https://swagger.io/docs/specification/describing-responses/ --- lib/open_api_spex/operation_builder.ex | 5 ++ lib/open_api_spex/test/test_assertions.ex | 14 ++++- test/support/router.ex | 4 +- .../string_response_codes_controller.ex | 58 +++++++++++++++++++ test/support/user_no_replace_controller.ex | 2 +- test/test_assertions_test.exs | 12 ++++ 6 files changed, 90 insertions(+), 5 deletions(-) create mode 100644 test/support/string_response_codes_controller.ex diff --git a/lib/open_api_spex/operation_builder.ex b/lib/open_api_spex/operation_builder.ex index 6802c286..f9c70d6d 100644 --- a/lib/open_api_spex/operation_builder.ex +++ b/lib/open_api_spex/operation_builder.ex @@ -108,6 +108,11 @@ defmodule OpenApiSpex.OperationBuilder do def build_responses(_), do: [] defp status_to_code(:default), do: :default + + defp status_to_code(status) + when status in [:"1XX", "1XX", :"2XX", "2XX", :"3XX", "3XX", :"4XX", "4XX", :"5XX", "5XX"], + do: status + defp status_to_code(status), do: Status.code(status) def build_request_body(%{body: {description, media_type, schema}}) do diff --git a/lib/open_api_spex/test/test_assertions.ex b/lib/open_api_spex/test/test_assertions.ex index 6d089024..809044d6 100644 --- a/lib/open_api_spex/test/test_assertions.ex +++ b/lib/open_api_spex/test/test_assertions.ex @@ -156,10 +156,18 @@ defmodule OpenApiSpex.TestAssertions do 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 = - operation - |> Map.get(:responses, %{}) - |> Map.get(conn.status, %{}) + response |> Map.get(:content, %{}) |> Map.get(content_type, %{}) |> Map.get(:schema) 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/string_response_codes_controller.ex b/test/support/string_response_codes_controller.ex new file mode 100644 index 00000000..49fd8b82 --- /dev/null +++ b/test/support/string_response_codes_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/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 9174b5e2..db896977 100644 --- a/test/test_assertions_test.exs +++ b/test/test_assertions_test.exs @@ -120,6 +120,18 @@ defmodule OpenApiSpex.TestAssertionsTest do 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 From fd0d8428b977f991ba982898d75a8645f6f8128b Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Tue, 30 Apr 2024 19:11:28 +0300 Subject: [PATCH 21/59] Release version 3.19.0 --- CHANGELOG.md | 4 ++++ mix.exs | 2 +- ...codes_controller.ex => response_code_ranges_controller.ex} | 0 3 files changed, 5 insertions(+), 1 deletion(-) rename test/support/{string_response_codes_controller.ex => response_code_ranges_controller.ex} (100%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 80d09f7e..3b40beec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ 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.19.0 - 2024-04-30 + +* Support response code ranges by @zorbash in 8898859da1 + ## v3.18.3 - 2024-03-15 * Relax dependency constraint on ymlr to allow version ~> 5.0 by @egze in https://github.com/open-api-spex/open_api_spex/pull/586 diff --git a/mix.exs b/mix.exs index 2ca3cc28..49eaee63 100644 --- a/mix.exs +++ b/mix.exs @@ -2,7 +2,7 @@ defmodule OpenApiSpex.Mixfile do use Mix.Project @source_url "https://github.com/open-api-spex/open_api_spex" - @version "3.18.3" + @version "3.19.0" def project do [ diff --git a/test/support/string_response_codes_controller.ex b/test/support/response_code_ranges_controller.ex similarity index 100% rename from test/support/string_response_codes_controller.ex rename to test/support/response_code_ranges_controller.ex From 699f876501941195f97cafadeec659a2828add30 Mon Sep 17 00:00:00 2001 From: Amir Hasanbasic <43892661+hamir-suspect@users.noreply.github.com> Date: Mon, 13 May 2024 01:43:37 +0200 Subject: [PATCH 22/59] Add notice that body params are not merged into Conn.params whne using cast and validate plug (#589) --- lib/open_api_spex/plug/cast_and_validate.ex | 2 ++ 1 file changed, 2 insertions(+) diff --git a/lib/open_api_spex/plug/cast_and_validate.ex b/lib/open_api_spex/plug/cast_and_validate.ex index e8af40fc..d07a9dd2 100644 --- a/lib/open_api_spex/plug/cast_and_validate.ex +++ b/lib/open_api_spex/plug/cast_and_validate.ex @@ -1,6 +1,8 @@ defmodule OpenApiSpex.Plug.CastAndValidate do @moduledoc """ Module plug that will cast and validate the `Conn.params` and `Conn.body_params` according to the schemas defined for the operation. + Note that when using this plug, the body params are no longer merged into `Conn.params` and must be read from `Conn.body_params` + separately. The operation_id can be given at compile time as an argument to `init`: From 78cf5fd1011d0414a6235fce9978f407d8c63d85 Mon Sep 17 00:00:00 2001 From: Nathan Alderson Date: Sun, 12 May 2024 18:50:25 -0500 Subject: [PATCH 23/59] Set nonces on - - + """ + @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(OpenApiSpex.Plug.SwaggerUI.get_nonce(conn, config, :script)) conn |> put_resp_content_type("text/html") @@ -94,5 +112,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/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/ Date: Tue, 14 May 2024 20:07:04 -0700 Subject: [PATCH 24/59] fix: ensure operation_id is always set on conn.private (#606) * fix: ensure operation_id is always set on conn.private when an operation is known --- lib/open_api_spex/operation2.ex | 13 +------------ lib/open_api_spex/plug/cast_and_validate.ex | 11 +++++++++++ .../plug/swagger_ui_oauth2_redirect.ex | 4 +++- test/support/pet_controller.ex | 13 ++++++++++++- test/test_assertions_test.exs | 10 ++++++++++ 5 files changed, 37 insertions(+), 14 deletions(-) diff --git a/lib/open_api_spex/operation2.ex b/lib/open_api_spex/operation2.ex index 41d0b599..4562f6f9 100644 --- a/lib/open_api_spex/operation2.ex +++ b/lib/open_api_spex/operation2.ex @@ -44,23 +44,12 @@ defmodule OpenApiSpex.Operation2 do {:ok, conn |> cast_conn(body) - |> maybe_replace_body(body, replace_params) - |> put_operation_id(operation)} + |> maybe_replace_body(body, replace_params)} end end ## Private functions - defp put_operation_id(conn, operation) do - private_data = - conn - |> Map.get(:private) - |> Map.get(:open_api_spex, %{}) - |> Map.put(:operation_id, operation.operationId) - - Plug.Conn.put_private(conn, :open_api_spex, private_data) - end - defp cast_conn(conn, body) do private_data = conn diff --git a/lib/open_api_spex/plug/cast_and_validate.ex b/lib/open_api_spex/plug/cast_and_validate.ex index d07a9dd2..d32934c4 100644 --- a/lib/open_api_spex/plug/cast_and_validate.ex +++ b/lib/open_api_spex/plug/cast_and_validate.ex @@ -78,6 +78,7 @@ defmodule OpenApiSpex.Plug.CastAndValidate do ) do {spec, operation_lookup} = PutApiSpec.get_spec_and_operation_lookup(conn) operation = operation_lookup[operation_id] + conn = put_operation_id(conn, operation) cast_opts = opts |> Map.take([:replace_params]) |> Map.to_list() @@ -150,4 +151,14 @@ defmodule OpenApiSpex.Plug.CastAndValidate do def call(_conn, _opts) do raise ":open_api_spex was not found under :private. Maybe PutApiSpec was not called before?" end + + defp put_operation_id(conn, operation) do + private_data = + conn + |> Map.get(:private) + |> Map.get(:open_api_spex, %{}) + |> Map.put(:operation_id, operation.operationId) + + Plug.Conn.put_private(conn, :open_api_spex, private_data) + end end diff --git a/lib/open_api_spex/plug/swagger_ui_oauth2_redirect.ex b/lib/open_api_spex/plug/swagger_ui_oauth2_redirect.ex index 6ca03347..7bfe0cd1 100644 --- a/lib/open_api_spex/plug/swagger_ui_oauth2_redirect.ex +++ b/lib/open_api_spex/plug/swagger_ui_oauth2_redirect.ex @@ -6,6 +6,8 @@ defmodule OpenApiSpex.Plug.SwaggerUIOAuth2Redirect do import Plug.Conn + alias OpenApiSpex.Plug.SwaggerUI + @html """ @@ -104,7 +106,7 @@ defmodule OpenApiSpex.Plug.SwaggerUIOAuth2Redirect do @impl Plug def call(conn, config) do - html = render(OpenApiSpex.Plug.SwaggerUI.get_nonce(conn, config, :script)) + html = render(SwaggerUI.get_nonce(conn, config, :script)) conn |> put_resp_content_type("text/html") diff --git a/test/support/pet_controller.ex b/test/support/pet_controller.ex index fc68f3d6..9b5e7150 100644 --- a/test/support/pet_controller.ex +++ b/test/support/pet_controller.ex @@ -37,7 +37,18 @@ 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{ diff --git a/test/test_assertions_test.exs b/test/test_assertions_test.exs index db896977..78a2e3db 100644 --- a/test/test_assertions_test.exs +++ b/test/test_assertions_test.exs @@ -120,6 +120,16 @@ defmodule OpenApiSpex.TestAssertionsTest do 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 From 012c8abb3e05b984a9b97afa50cf49ebb767976c Mon Sep 17 00:00:00 2001 From: "E. Sambo" Date: Fri, 17 May 2024 04:42:35 -0500 Subject: [PATCH 25/59] Fix grammer (#607) --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 4305a673..d3933eff 100644 --- a/README.md +++ b/README.md @@ -282,7 +282,7 @@ mix openapi.spec.yaml --spec MyAppWeb.ApiSpec Invoking this task starts the application by default. This can be disabled with the `--start-app=false` option. -Please make to replace any calls to [OpenApiSpex.Server.from_endpoint](https://hexdocs.pm/open_api_spex/OpenApiSpex.Server.html#from_endpoint/1) with a `%OpenApiSpex.Server{}` struct like below: +Please replace any calls to [OpenApiSpex.Server.from_endpoint](https://hexdocs.pm/open_api_spex/OpenApiSpex.Server.html#from_endpoint/1) with a `%OpenApiSpex.Server{}` struct like below: ```elixir %OpenApi{ From 826ce1d19628e33fcde1e553ef3f3371d790a686 Mon Sep 17 00:00:00 2001 From: Dimitris Zorbas Date: Fri, 17 May 2024 15:29:10 +0300 Subject: [PATCH 26/59] Release version 3.19.1 --- CHANGELOG.md | 6 ++++++ mix.exs | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b40beec..862ba992 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ 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.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 %>