Skip to content

Commit d61d03f

Browse files
committed
Ensure path parameters are declared in each Operation
Paths.from_router will raise an exception if an operation fails to declare a path parameter that exists in the route
1 parent 716f453 commit d61d03f

7 files changed

+114
-46
lines changed

lib/open_api_spex/operation.ex

+35-3
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,43 @@ defmodule OpenApiSpex.Operation do
5252
}
5353

5454
@doc """
55-
Constructs an Operation struct from the plug and opts specified in the given route
55+
Constructs an Operation struct from the plug and opts specified in the given route.
56+
57+
If any path parameters declared in the `route.path` do not have corresponding
58+
parameters defined in the `Operation`, the result is an error tuple with a message
59+
descring which parameters are missing.
5660
"""
57-
@spec from_route(PathItem.route) :: t
61+
@spec from_route(PathItem.route) :: {:ok, t} | {:error, String.t()}
5862
def from_route(route) do
59-
from_plug(route.plug, route.opts)
63+
operation = from_plug(route.plug, Map.get(route, :opts, []))
64+
65+
case route do
66+
%{path: path} -> check_all_path_params_declared(operation, path)
67+
_ -> {:ok, operation}
68+
end
69+
end
70+
71+
defp check_all_path_params_declared(operation, route_path) do
72+
{expected_path_params, _} = Plug.Router.Utils.build_path_match(route_path)
73+
74+
actual_path_params =
75+
operation.parameters
76+
|> Enum.filter(&(&1.in == :path))
77+
|> Enum.map(& &1.name)
78+
79+
missing_params = expected_path_params -- actual_path_params
80+
81+
case missing_params do
82+
[] ->
83+
{:ok, operation}
84+
85+
_ ->
86+
message =
87+
"Operation for route: #{inspect(route_path)} " <>
88+
"did not declare path parameters: #{inspect(missing_params)}"
89+
90+
{:error, message}
91+
end
6092
end
6193

6294
@doc """

lib/open_api_spex/path_item.ex

+38-19
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ defmodule OpenApiSpex.PathItem do
44
"""
55

66
alias OpenApiSpex.{Operation, Server, Parameter, PathItem, Reference}
7+
78
defstruct [
89
:"$ref",
910
:summary,
@@ -29,31 +30,36 @@ defmodule OpenApiSpex.PathItem do
2930
but they will not know which operations and parameters are available.
3031
"""
3132
@type t :: %__MODULE__{
32-
"$ref": String.t | nil,
33-
summary: String.t | nil,
34-
description: String.t | nil,
35-
get: Operation.t | nil,
36-
put: Operation.t | nil,
37-
post: Operation.t | nil,
38-
delete: Operation.t | nil,
39-
options: Operation.t | nil,
40-
head: Operation.t | nil,
41-
patch: Operation.t | nil,
42-
trace: Operation.t | nil,
43-
servers: [Server.t] | nil,
44-
parameters: [Parameter.t | Reference.t] | nil
45-
}
33+
"$ref": String.t() | nil,
34+
summary: String.t() | nil,
35+
description: String.t() | nil,
36+
get: Operation.t() | nil,
37+
put: Operation.t() | nil,
38+
post: Operation.t() | nil,
39+
delete: Operation.t() | nil,
40+
options: Operation.t() | nil,
41+
head: Operation.t() | nil,
42+
patch: Operation.t() | nil,
43+
trace: Operation.t() | nil,
44+
servers: [Server.t()] | nil,
45+
parameters: [Parameter.t() | Reference.t()] | nil
46+
}
4647

4748
@typedoc """
4849
Represents a route from in a Plug/Phoenix application.
4950
Eg from the generated `__routes__` function in a Phoenix.Router module.
5051
"""
51-
@type route :: %{verb: atom, plug: atom, opts: any}
52+
@type route :: %{
53+
required(:verb) => atom,
54+
required(:plug) => atom,
55+
optional(:path) => String.t(),
56+
optional(:opts) => any
57+
}
5258

5359
@doc """
5460
Builds a PathItem struct from a list of routes that share a path.
5561
"""
56-
@spec from_routes([route]) :: nil | t
62+
@spec from_routes([route]) :: {:ok, nil | t} | {:error, Sting.t()}
5763
def from_routes(routes) do
5864
Enum.each(routes, fn route ->
5965
Code.ensure_loaded(route.plug)
@@ -64,9 +70,22 @@ defmodule OpenApiSpex.PathItem do
6470
|> from_valid_routes()
6571
end
6672

67-
@spec from_valid_routes([route]) :: nil | t
68-
defp from_valid_routes([]), do: nil
73+
@spec from_valid_routes([route]) :: {:ok, nil | t} | {:error, String.t()}
74+
defp from_valid_routes([]), do: {:ok, nil}
75+
6976
defp from_valid_routes(routes) do
70-
struct(PathItem, Enum.map(routes, &{&1.verb, Operation.from_route(&1)}))
77+
routes
78+
|> Enum.map(fn route -> {route.verb, Operation.from_route(route)} end)
79+
|> Enum.reduce_while(
80+
%PathItem{},
81+
fn
82+
{verb, {:ok, operation}}, acc -> {:cont, Map.put(acc, verb, operation)}
83+
{_verb, {:error, reason}}, _acc -> {:halt, {:error, reason}}
84+
end
85+
)
86+
|> case do
87+
{:error, reason} -> {:error, reason}
88+
path_item = %PathItem{} -> {:ok, path_item}
89+
end
7190
end
7291
end

lib/open_api_spex/paths.ex

+17-12
Original file line numberDiff line numberDiff line change
@@ -11,25 +11,30 @@ defmodule OpenApiSpex.Paths do
1111
The path is appended to the URL from the Server Object in order to construct the full URL.
1212
The Paths MAY be empty, due to ACL constraints.
1313
"""
14-
@type t :: %{String.t => PathItem.t}
14+
@type t :: %{String.t() => PathItem.t()}
1515

1616
@doc """
1717
Create a Paths map from the routes in the given router module.
1818
"""
19-
@spec from_router(module) :: t
19+
@spec from_router(module) :: {:ok, t} | {:error, String.t()}
2020
def from_router(router) do
2121
router.__routes__()
22-
|> Enum.group_by(fn route -> route.path end)
23-
|> Enum.map(fn {k, v} -> {open_api_path(k), PathItem.from_routes(v)} end)
24-
|> Enum.filter(fn {_k, v} -> !is_nil(v) end)
25-
|> Map.new()
22+
|> Enum.group_by(fn route -> open_api_path(route.path) end)
23+
|> Enum.map(fn {path, routes} -> {path, PathItem.from_routes(routes)} end)
24+
|> Enum.reduce_while(%{}, fn
25+
{_path, {:error, reason}}, _acc -> {:halt, {:error, reason}}
26+
{_path, {:ok, nil}}, acc -> {:cont, acc}
27+
{path, {:ok, path_item}}, acc -> {:cont, Map.put(acc, path, path_item)}
28+
end)
29+
|> case do
30+
{:error, reason} -> raise reason
31+
paths -> paths
32+
end
2633
end
2734

28-
@spec open_api_path(String.t) :: String.t
35+
@spec open_api_path(String.t()) :: String.t()
2936
defp open_api_path(path) do
30-
path
31-
|> String.split("/")
32-
|> Enum.map(fn ":"<>segment -> "{#{segment}}"; segment -> segment end)
33-
|> Enum.join("/")
37+
pattern = ~r{:([^/]+)}
38+
Regex.replace(pattern, path, "{\\1}")
3439
end
35-
end
40+
end

test/operation_test.exs

+21-6
Original file line numberDiff line numberDiff line change
@@ -3,14 +3,29 @@ defmodule OpenApiSpex.OperationTest do
33
alias OpenApiSpex.Operation
44
alias OpenApiSpexTest.UserController
55

6-
describe "Operation" do
7-
test "from_route" do
8-
route = %{plug: UserController, opts: :show}
9-
assert Operation.from_route(route) == UserController.show_operation()
6+
describe "Operation.from_route" do
7+
test "succeeds when all path params present" do
8+
route = %{plug: UserController, opts: :show, path: "/users/:id"}
9+
assert Operation.from_route(route) == {:ok, UserController.show_operation()}
1010
end
1111

12-
test "from_plug" do
12+
test "Fails on missing path params" do
13+
path = "/teams/:missing_team_id_param/users/:id"
14+
route = %{plug: UserController, opts: :show, path: path}
15+
assert {:error, message} = Operation.from_route(route)
16+
assert message =~ "missing_team_id_param"
17+
end
18+
19+
test "Allows additional path params not known to phoenix" do
20+
path = "/no/path/params"
21+
route = %{plug: UserController, opts: :show, path: path}
22+
assert {:ok, _operation} = Operation.from_route(route)
23+
end
24+
end
25+
26+
describe "Operation.from_plug" do
27+
test "invokes the plug to get the Operation" do
1328
assert Operation.from_plug(UserController, :show) == UserController.show_operation()
1429
end
1530
end
16-
end
31+
end

test/path_item_test.exs

+2-2
Original file line numberDiff line numberDiff line change
@@ -10,11 +10,11 @@ defmodule OpenApiSpex.PathItemTest do
1010
route.path == "/api/users",
1111
do: route
1212

13-
path_item = PathItem.from_routes(routes)
13+
{:ok, path_item} = PathItem.from_routes(routes)
1414
assert path_item == %PathItem{
1515
get: UserController.index_operation(),
1616
post: UserController.create_operation()
1717
}
1818
end
1919
end
20-
end
20+
end

test/paths_test.exs

+1-1
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ defmodule OpenApiSpex.PathsTest do
1111
} = paths
1212
end
1313
end
14-
end
14+
end

test/plug/cast_and_validate_test.exs

-3
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ defmodule OpenApiSpex.Plug.CastAndValidateTest do
5555
end
5656

5757
describe "body params" do
58-
# TODO Fix this test. The datetime should be parsed, but it isn't.
59-
# https://github.com/open-api-spex/open_api_spex/issues/90
60-
@tag :skip
6158
test "Valid Request" do
6259
request_body = %{
6360
"user" => %{

0 commit comments

Comments
 (0)