From 2ed65d2abae5236433eee34ed377549717bbffd9 Mon Sep 17 00:00:00 2001 From: Matthew Sinclair Date: Mon, 2 Jun 2025 11:08:05 +0100 Subject: [PATCH] feat: Add OTP 28+ compatibility with version-aware regex pattern handling - Implement OTP version detection in schema macro - Add runtime schema compilation for OTP 28+ when regex patterns are present - Maintain module attribute optimization for non-regex schemas on all OTP versions - Add deprecation warning for regex patterns on OTP 28+ with migration guidance - Fix cast_parameters.ex to avoid regex in module attributes - Update string test to check pattern source instead of regex struct equality This ensures backward compatibility while providing a clear migration path for OTP 28+ users to move from regex patterns to string patterns. --- lib/open_api_spex.ex | 80 ++++++++++++++++++++++------ lib/open_api_spex/cast_parameters.ex | 10 ++-- lib/open_api_spex/schema.ex | 20 +++++++ test/cast/string_test.exs | 5 +- test/controller_specs_test.exs | 2 +- test/controller_test.exs | 2 +- 6 files changed, 96 insertions(+), 23 deletions(-) diff --git a/lib/open_api_spex.ex b/lib/open_api_spex.ex index 9db3db9d..4f8c3ebe 100644 --- a/lib/open_api_spex.ex +++ b/lib/open_api_spex.ex @@ -253,32 +253,59 @@ defmodule OpenApiSpex do prevent "... protocol has already been consolidated ..." compiler warnings. """ + def should_use_runtime_compilation?(body) do + with true <- System.otp_release() >= "28", + true <- Schema.has_regex_pattern?(body) do + true + else + _ -> false + end + end + defmacro schema(body, opts \\ []) do quote do @compile {:report_warnings, false} @behaviour OpenApiSpex.Schema - @schema OpenApiSpex.build_schema( - unquote(body), - Keyword.merge([module: __MODULE__], unquote(opts)) - ) + + schema = OpenApiSpex.build_schema(unquote(body), Keyword.merge([module: __MODULE__], unquote(opts))) + + case OpenApiSpex.should_use_runtime_compilation?(unquote(body)) do + true -> + IO.warn(""" + [OpenApiSpex] Regex patterns in schema definitions are deprecated in OTP 28+. + Consider using string patterns: pattern: "\\\\d-\\\\d" instead of pattern: ~r/\\\\d-\\\\d/ + """, Macro.Env.stacktrace(__ENV__)) + + def schema do + OpenApiSpex.build_schema_without_validation(unquote(body), Keyword.merge([module: __MODULE__], unquote(opts))) + end + + false -> + @schema schema + def schema, do: @schema + end unless Module.get_attribute(__MODULE__, :moduledoc) do - @moduledoc [@schema.title, @schema.description] + @moduledoc [schema.title, schema.description] |> Enum.reject(&is_nil/1) |> Enum.join("\n\n") end - def schema, do: @schema - - if Map.get(@schema, :"x-struct") == __MODULE__ do - if Keyword.get(unquote(opts), :derive?, true) do - @derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1) - end - - if Keyword.get(unquote(opts), :struct?, true) do - defstruct Schema.properties(@schema) - @type t :: %__MODULE__{} - end + case Map.get(schema, :"x-struct") == __MODULE__ do + true -> + case Keyword.get(unquote(opts), :derive?, true) do + true -> @derive Enum.filter([Poison.Encoder, Jason.Encoder], &Code.ensure_loaded?/1) + false -> nil + end + + case Keyword.get(unquote(opts), :struct?, true) do + true -> + defstruct Schema.properties(schema) + @type t :: %__MODULE__{} + false -> nil + end + + false -> nil end end end @@ -328,6 +355,27 @@ defmodule OpenApiSpex do schema end + @doc false + def build_schema_without_validation(body, opts \\ []) do + module = opts[:module] || body[:"x-struct"] + + attrs = + body + |> Map.delete(:__struct__) + |> update_in([:"x-struct"], fn struct_module -> + if Keyword.get(opts, :struct?, true) do + struct_module || module + else + struct_module + end + end) + |> update_in([:title], fn title -> + title || title_from_module(module) + end) + + struct(OpenApiSpex.Schema, attrs) + end + def title_from_module(nil), do: nil def title_from_module(module) do diff --git a/lib/open_api_spex/cast_parameters.ex b/lib/open_api_spex/cast_parameters.ex index 1155cb79..7d568aaf 100644 --- a/lib/open_api_spex/cast_parameters.ex +++ b/lib/open_api_spex/cast_parameters.ex @@ -4,7 +4,11 @@ defmodule OpenApiSpex.CastParameters do alias OpenApiSpex.Cast.Error alias Plug.Conn - @default_parsers %{~r/^application\/.*json.*$/ => OpenApi.json_encoder()} + @doc false + @spec default_content_parsers() :: %{Regex.t() => module() | function()} + defp default_content_parsers do + %{~r/^application\/.*json.*$/ => OpenApi.json_encoder()} + end @spec cast(Plug.Conn.t(), Operation.t(), OpenApi.t(), opts :: [OpenApiSpex.cast_opt()]) :: {:error, [Error.t()]} | {:ok, Conn.t()} @@ -119,8 +123,8 @@ defmodule OpenApiSpex.CastParameters do conn, opts ) do - parsers = Map.get(ext || %{}, "x-parameter-content-parsers", %{}) - parsers = Map.merge(@default_parsers, parsers) + custom_parsers = Map.get(ext || %{}, "x-parameter-content-parsers", %{}) + parsers = Map.merge(default_content_parsers(), custom_parsers) conn |> get_params_by_location( diff --git a/lib/open_api_spex/schema.ex b/lib/open_api_spex/schema.ex index 5c585e13..5cf00d79 100644 --- a/lib/open_api_spex/schema.ex +++ b/lib/open_api_spex/schema.ex @@ -530,4 +530,24 @@ defmodule OpenApiSpex.Schema do defp default(value) do raise "Expected %Schema{}, schema module, or %Reference{}. Got: #{inspect(value)}" end + + @doc false + def has_regex_pattern?(%Schema{pattern: %Regex{}}), do: true + + def has_regex_pattern?(%Schema{} = schema) do + schema + |> Map.from_struct() + |> has_regex_pattern?() + end + + def has_regex_pattern?(enumerable) + when not is_struct(enumerable) and (is_list(enumerable) or is_map(enumerable)) do + Enum.any?(enumerable, fn + {_, value} -> has_regex_pattern?(value) + %Schema{} = schema -> has_regex_pattern?(schema) + _ -> false + end) + end + + def has_regex_pattern?(_), do: false end diff --git a/test/cast/string_test.exs b/test/cast/string_test.exs index 427c616a..e28dfb96 100644 --- a/test/cast/string_test.exs +++ b/test/cast/string_test.exs @@ -22,12 +22,13 @@ defmodule OpenApiSpex.CastStringTest do end test "string with pattern" do - schema = %Schema{type: :string, pattern: ~r/\d-\d/} + pattern = ~r/\d-\d/ + schema = %Schema{type: :string, pattern: pattern} assert cast(value: "1-2", schema: schema) == {:ok, "1-2"} 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/controller_specs_test.exs b/test/controller_specs_test.exs index 7ef0f786..c240fb35 100644 --- a/test/controller_specs_test.exs +++ b/test/controller_specs_test.exs @@ -1,5 +1,5 @@ defmodule OpenApiSpex.ControllerSpecsTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false import ExUnit.CaptureIO diff --git a/test/controller_test.exs b/test/controller_test.exs index d5c8bab2..863b4301 100644 --- a/test/controller_test.exs +++ b/test/controller_test.exs @@ -1,5 +1,5 @@ defmodule OpenApiSpex.ControllerTest do - use ExUnit.Case, async: true + use ExUnit.Case, async: false alias OpenApiSpex.Reference alias OpenApiSpex.Controller, as: Subject