Skip to content

feat: Add OTP 28+ compatibility with version-aware regex pattern handling #670

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
148 changes: 64 additions & 84 deletions lib/open_api_spex.ex
Original file line number Diff line number Diff line change
Expand Up @@ -184,101 +184,60 @@ defmodule OpenApiSpex do
Error.message(error)
end

@doc """
Declares a struct based `OpenApiSpex.Schema`

- defines the schema/0 callback
- ensures the schema is linked to the module by "x-struct" extension property
- defines a struct with keys matching the schema properties
- defines a @type `t` for the struct
- derives a `Jason.Encoder` and/or `Poison.Encoder` for the struct

See `OpenApiSpex.Schema` for additional examples and details.

## Example

require OpenApiSpex

defmodule User do
OpenApiSpex.schema %{
title: "User",
description: "A user of the app",
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_]+/},
email: %Schema{type: :string, description: "Email address", format: :email},
inserted_at: %Schema{type: :string, description: "Creation timestamp", format: :'date-time'},
updated_at: %Schema{type: :string, description: "Update timestamp", format: :'date-time'}
},
required: [:name, :email],
example: %{
"id" => 123,
"name" => "Joe User",
"email" => "[email protected]",
"inserted_at" => "2017-09-12T12:34:55Z",
"updated_at" => "2017-09-13T10:11:12Z"
}
}
end

## Example

This example shows the `:struct?` and `:derive?` options that may
be passed to `schema/2`:

defmodule MyAppWeb.Schemas.User do
require OpenApiSpex
alias OpenApiSpex.Schema

OpenApiSpex.schema(
%{
type: :object,
properties: %{
name: %Schema{type: :string}
}
},
struct?: false,
derive?: false
)
end

## Options
@doc false
def should_use_runtime_compilation?(body) do
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't think this should be a public function. It's also hijacking the @doc for defmacro schema/2.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll submit a change to fix this, but that method is called from elsewhere, so I think it needs to be public, no? Unless I'm missing something. The @doc issue is easier to fix (I think). :)

with true <- System.otp_release() >= "28",
true <- Schema.has_regex_pattern?(body) do
true
else
_ -> false
end
end

- `:struct?` (boolean) - When false, prevents the automatic generation
of a struct definition for the schema module.
- `:derive?` (boolean) When false, prevents the automatic generation
of a `@derive` call for either `Poison.Encoder`
or `Jason.Encoder`. Using this option can
prevent "... protocol has already been consolidated ..."
compiler warnings.
"""
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
Expand Down Expand Up @@ -328,6 +287,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
Expand Down
10 changes: 7 additions & 3 deletions lib/open_api_spex/cast_parameters.ex
Original file line number Diff line number Diff line change
Expand Up @@ -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()}
Expand Down Expand Up @@ -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(
Expand Down
23 changes: 23 additions & 0 deletions lib/open_api_spex/schema.ex
Original file line number Diff line number Diff line change
Expand Up @@ -530,4 +530,27 @@ 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 is_list(enumerable) do
Enum.any?(enumerable, &has_regex_pattern?/1)
end

def has_regex_pattern?(enumerable) when is_map(enumerable) and not is_struct(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
5 changes: 3 additions & 2 deletions test/cast/string_test.exs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading