Skip to content
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

[WIP] Introduce mutate_endpoint/3 macro to ease building changes that match on endpoint #12

Open
wants to merge 1 commit 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
184 changes: 154 additions & 30 deletions lib/multiverse/change.ex
Original file line number Diff line number Diff line change
@@ -1,40 +1,35 @@
defmodule Multiverse.Change do
@moduledoc """
Provides behaviour for Multiverse API Changes.

## Examples

defmodule ChangeAccountType do
@behaviour Multiverse.Change

def handle_request(%Plug.Conn{} = conn) do
# Mutate your request here
IO.inspect "GateName.mutate_request applied to request"
conn
end

def handle_response(%Plug.Conn{} = conn) do
# Mutate your response here
IO.inspect "GateName.mutate_response applied to response"
conn
end
end

Provides behaviour and macro to build Multiverse API Changes.
"""

@doc """
Macros that can be used if you want to omit either `handle_request/1`
or `handle_response/1` callback in your change module.
"""
defmacro __unsing__(_opts) do
@doc false
defmacro __using__(opts) do
quote do
import Multiverse.Change#, only: [mutate_endpoint: 3]
require Multiverse.Change
@behaviour Multiverse.Change

def handle_request(conn), do: conn
@multiverse_change_opts unquote(opts)

def handle_response(conn), do: conn
def handle_request(conn), do: do_handle_request(conn)
def handle_response(conn), do: do_handle_response(conn)

defoverridable handle_request: 1, handle_response: 1
Module.register_attribute(__MODULE__, :mutations, accumulate: true)
@before_compile Multiverse.Change
end
end

@doc false
defmacro __before_compile__(env) do
mutations = Module.get_attribute(env.module, :mutations)
change_opts = Module.get_attribute(env.module, :multiverse_change_opts)

{conn, request_mutations_body, response_mutations_body} = Multiverse.Change.compile(env, mutations, change_opts)
Copy link
Member Author

Choose a reason for hiding this comment

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

Nested modules could be aliased at the top of the invoking module.


quote do
def do_handle_request(unquote(conn)), do: unquote(request_mutations_body)
def do_handle_response(unquote(conn)), do: unquote(response_mutations_body)
end
end

Expand All @@ -50,14 +45,143 @@ defmodule Multiverse.Change do
@doc """
Defines a request mutator.

This function will be called whenever Cowboy receives request.
Request mutation is applied when when Multiverse plug is called.
"""
@callback handle_request(conn :: Plug.Conn.t()) :: Plug.Conn.t()

@doc """
Defines a response mutator.

This function will be called before Cowboy is dispatched response to a consumer.
Response mutation is applied via `Plug.Conn.register_before_send/2`.
"""
@callback handle_response(conn :: Plug.Conn.t()) :: Plug.Conn.t()

@doc """
Applies change only if the request method and path are matched.

## Example

defmodule ChangeAccountType do
use Multiverse.Change

mutate_endpoint :get, "templates/:id" do
:request, conn ->
# Mutate your request here
IO.inspect "ChangeAccountType request mutated"
conn

:response, conn ->
# Mutate your response here
IO.inspect "ChangeAccountType response mutated"
conn
end
end
"""
defmacro mutate_endpoint(method_or_methods, expr, do: body) do
methods = List.wrap(method_or_methods)
{path, guards} = extract_path_and_guards(expr)
changes = exctract_changes(body, __CALLER__)
request_changes_body = List.keyfind(changes, :request, 0)
response_changes_body = List.keyfind(changes, :response, 0)

quote bind_quoted: [
methods: methods,
path: path,
guards: Macro.escape(guards, unquote: true),
request_changes_body: Macro.escape(request_changes_body, unquote: true),
response_changes_body: Macro.escape(response_changes_body, unquote: true),
] do
for method <- methods do
@mutations {method, path, guards, request_changes_body, response_changes_body}
end
end
end

@doc false
def compile(env, mutations, change_opts) do
IO.inspect {env, mutations, change_opts}
Copy link
Member Author

Choose a reason for hiding this comment

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

There should be no calls to IO.inspect/1.

{nil, nil, nil}
# quote bind_quoted: [
# method: method,
# path: path,
# guards: Macro.escape(guards, unquote: true),
# request_changes_body: Macro.escape(request_changes_body, unquote: true),
# response_changes_body: Macro.escape(response_changes_body, unquote: true),
# ] do
# route = Plug.Router.__route__(method, path, guards, [])
# {conn, method, match, params_match, host, guards, _private, _assigns} = route
# IO.inspect route

# if request_changes_body do
# {:request, conn_binding, change_body} = request_changes_body
# defp do_handle_request(unquote(conn_binding)), do: unquote(change_body)
# end

# if response_changes_body do
# {:response, conn_binding, change_body} = response_changes_body
# defp do_handle_response(unquote(conn_binding)), do: unquote(change_body)
# end

# # defp do_handle_request(unquote(conn), unquote(method), unquote(match), unquote(host))
# # when unquote(guards) do

# # merge_params = fn
# # %Plug.Conn.Unfetched{} -> unquote({:%{}, [], params})
# # fetched -> Map.merge(fetched, unquote({:%{}, [], params}))
# # end

# # conn = update_in(unquote(conn).params, merge_params)
# # conn = update_in(conn.path_params, merge_params)

# # Plug.Router.__put_route__(conn, unquote(path), fn var!(conn) -> unquote(body) end)
# # end
# end
end

defp exctract_changes(body, caller, acc \\ [])

defp exctract_changes([], _caller, acc) do
acc
end

defp exctract_changes([{:->, ctx, [[kind, conn_binding], change_body]} | t], caller, acc)
when kind in [:request, :response] do
if List.keymember?(acc, kind, 0) do
raise SyntaxError,
file: caller.file,
line: ctx[:line],
description: "mutation for #{kind} is already defined"
end

exctract_changes(t, caller, acc ++ [{kind, conn_binding, change_body}])
end

defp exctract_changes([{:->, ctx, [[kind, _conn_binding], _change_body]} | _], caller, _acc) do
raise SyntaxError,
file: caller.file,
line: ctx[:line],
description: "unknown mutation kind #{kind}, supported kinds: :request, :response"
end

defp exctract_changes({:__block__, _ctx, []}, caller, _acc) do
raise SyntaxError,
file: caller.file,
line: caller.line,
description: "mutation can not be empty"
end

defp exctract_changes(_ast, caller, _acc) do
description = "invalid syntax for endpoint mutation, for a valid syntax see Multiverse.Change.mutate_endpoint/3 doc"
raise SyntaxError,
file: caller.file,
line: caller.line,
description: description
end

# Extract the path and guards from the path.
defp extract_path_and_guards({:when, _, [path, guards]}), do: {extract_path(path), guards}
defp extract_path_and_guards(path), do: {extract_path(path), true}

defp extract_path({:_, _, var}) when is_atom(var), do: "/*_path"
defp extract_path(path), do: path
end
81 changes: 81 additions & 0 deletions test/change_test.exs
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
defmodule Multiverse.MyChange do
use Multiverse.Change

# def handle_request(conn), do: do_handle_request(conn)
# def handle_response(conn), do: do_handle_response(conn)

mutate_endpoint :get, "templates/:id" do
:request, conn ->
applied_changes = Map.get(conn.assigns, :applied_changes, []) ++ [:"#{__MODULE__}.handle_request1"]
%{conn | assigns: Map.put(conn.assigns, :applied_changes, applied_changes)}

:response, conn ->
applied_changes = Map.get(conn.assigns, :applied_changes, []) ++ [:"#{__MODULE__}.handle_response1"]
%{conn | assigns: Map.put(conn.assigns, :applied_changes, applied_changes)}
end

mutate_endpoint :get, "templates/:id" do
:request, conn ->
applied_changes = Map.get(conn.assigns, :applied_changes, []) ++ [:"#{__MODULE__}.handle_request2"]
%{conn | assigns: Map.put(conn.assigns, :applied_changes, applied_changes)}

:response, conn ->
applied_changes = Map.get(conn.assigns, :applied_changes, []) ++ [:"#{__MODULE__}.handle_response2"]
%{conn | assigns: Map.put(conn.assigns, :applied_changes, applied_changes)}
end
end

defmodule Multiverse.ChangeTest do
use ExUnit.Case, async: true
use Plug.Test
import Multiverse.Change
doctest Multiverse.Change

setup do
%{conn: conn(:get, "/foo")}
end

describe "mutate_endpoint/3" do
# test "applies changes", %{conn: conn} do
# config =
# Multiverse.init(
# default_version: :latest,
# gates: [
# {~D[2002-03-01], [Multiverse.MyChange]},
# ]
# )

# conn = %{conn | req_headers: [{"x-api-version", "2001-01-01"}]}

# conn =
# conn
# |> Multiverse.call(config)
# |> send_resp(204, "")

# assert length(conn.private.multiverse_version_schema.changes) == 2
# assert active?(conn, Multiverse.MyChange)

# assert conn.assigns.applied_changes ==
# [
# :"Elixir.Multiverse.MyChange.handle_request1",
# :"Elixir.Multiverse.MyChange.handle_request2",
# :"Elixir.Multiverse.MyChange.handle_response2",
# :"Elixir.Multiverse.MyChange.handle_response1"
# ]

# assert length(conn.before_send) == 2
# end

test "raises on duplicate mutation" do

end

test "raises on invalid mutation kind" do

end

test "raises on invalid mutation arithy" do

end
end
end