How to use a separate plug for an API

phoenix

#1

I’m trying to use a separate router for an API. I’m forwarding the request from the main router to a plug which in turn forwards the request to the correct version router based on the accepts header.

Everything is works except my controller tests. I’m getting a route not found. Here’s my main router:

defmodule MyAppWeb.Router do
  use MyAppWeb, :router
  forward "/api", MyAppWeb.Plug.API.Version
end

Here’s the plug that forwards the request to the correct router:

defmodule MyAppWeb.Plug.API.Version do
  import Plug.Conn
  alias MyAppWeb.Routers.API

  @versions Application.get_env(:mime, :types)

  def init(opts), do: opts

  def call(conn, opts) do
    [accept] = get_req_header(conn, "accept")
    @versions
    |> Map.fetch!(accept)
    |> call_router(conn, opts)
  end

  defp call_router([:v2], conn, opts) do
    API.V2.Router.call(conn, API.V2.Router.init(opts))
  end
  defp call_router(_, conn, _opts) do
    conn
    |> send_resp(404, "Not Found")
    |> halt()
  end
end

Here’s the api router that eventually receives the request:

defmodule MyAppWeb.Routers.API.V2.Router do
  use MyAppWeb, :router

  pipeline :api do
    plug :accepts, [:v2]
  end

  scope "/", MyAppWeb.API.V2 do
    pipe_through :api

    resources "/stores", StoreController, except: [:delete]
  end  
end

Using post man, everything works as expected. But when I run my tests, I get the following errors:

  3) test index lists all stores (MyAppWeb.API.V2.StoreControllerTest)
     test/my_app_web/controllers/api/v2/store_controller_test.exs:17
     ** (Phoenix.Router.NoRouteError) no route found for GET /stores (MyAppWeb.Router)
     code: conn = get conn, store_path(conn, :index)
     stacktrace:
       (my_app) lib/my_app_web/router.ex:1: MyAppWeb.Router.__match_route__/4
       (my_app) lib/phoenix/router.ex:303: MyAppWeb.Router.call/2
       (my_app) lib/my_app_web/endpoint.ex:1: MyAppWeb.Endpoint.plug_builder_call/2
       (my_app) lib/my_app_web/endpoint.ex:1: MyAppWeb.Endpoint.call/2
       (phoenix) lib/phoenix/test/conn_test.ex:224: Phoenix.ConnTest.dispatch/5
       test/my_app_web/controllers/api/v2/store_controller_test.exs:18: (test)

When I run my routes (mix phx.routes MyAppWeb.Routers.API.V2.Router), I get the following:

store_path  GET    /stores           MyAppWeb.API.V2.StoreController :index

Here’s my controller test:

defmodule MyAppWeb.API.V2.StoreControllerTest do
  use MyAppWeb.ConnCase
  import MyAppWeb.Routers.API.V2.Router.Helpers

  setup %{conn: conn} do
    {:ok, conn: put_req_header("accept", "application/vnd.app.v2+json")}
  end

  describe "index" do
    test "lists all stores", %{conn: conn} do
      conn = get conn, store_path(conn, :index)
      assert json_response(conn, 200)["data"] == []
    end
  end
end

Any ideas where I’m going wrong?


#2

The API router helpers isn’t including the “/api” route prefix.

If you construct the route manually in the test it should be forwarded correctly.

Alternatively, I believe you could preserve the full path by using a wildcard match instead of forward


#3

The API router helpers isn’t including the “/api” route prefix.

That’s what I figured, but I mistakenly thought that calling the route helpers directly made it a none issue. Obviously I was confused.

Alternatively, I believe you could preserve the full path by using a wildcard match 2 instead of forward

I tried with match but had no luck. I wonder if there’s another way to automate this other than using a manual path. I get the feeling I’m complicating things a little. For now I’m doing this:

  conn = get conn, path(store_path(conn, :index))
  defp path(path) do
    "/api" <> path
  end

An aside, is it worth it for forward to have the option of forwarding the path?


#4

I tried with match but had no luck.

Plug.Router is another option for implementing the top level MyAppWeb.Router. It’s a bit simpler, with a match function that can act like forward without stripping the path.

There’s some discussion in Umbrella App routing with Plug.Router app & Phoenix app


#5

You may be able to use as: :api in your router scope to prefix all the path helpers.

  scope "/", MyAppWeb.API.V2, as: :api do
    pipe_through :api

    resources "/stores", StoreController, except: [:delete]
  end

This should result in the path helper:

api_store_path GET /stores MyAppWeb.API.V2.StoreController :index

You could version these as well with:

scope "/", MyAppWeb.API.V2, as: :api_v2 do
  ...
end