Skip to content

Commit 9bfc284

Browse files
committed
Ensure path parameters are declared in each Operation
`Paths.from_router` now returns an ok/error tuple. The error case will occurr if any operation fails to declare a path parameter that is present in the phoenix router.
1 parent 00bbf35 commit 9bfc284

File tree

11 files changed

+125
-48
lines changed

11 files changed

+125
-48
lines changed

CHANGELOG.md

+6
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,9 @@
1+
# 4.0.0 (in development)
2+
3+
- `Paths.from_router/1` now returns an `:ok/:error` tuple and validates that
4+
all path parameters are declared in the OpenAPI `Operation`.
5+
Use `Paths.from_router!` for a version that will raise immediately on any missing parameters.
6+
17
# 3.2.1
28

39
Patch release for documentation updates and improved error rendering Plug when using `CastAndValidate`.

README.md

+1-1
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ defmodule MyApp.ApiSpec do
4444
version: "1.0"
4545
},
4646
# populate the paths from a phoenix router
47-
paths: Paths.from_router(MyAppWeb.Router)
47+
paths: Paths.from_router!(MyAppWeb.Router)
4848
}
4949
|> OpenApiSpex.resolve_schema_modules() # discover request/response schemas from path specs
5050
end

examples/phoenix_app/lib/phoenix_app_web/api_spec.ex

+2-2
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,8 @@ defmodule PhoenixAppWeb.ApiSpec do
77
title: "Phoenix App",
88
version: "1.0"
99
},
10-
paths: Paths.from_router(PhoenixAppWeb.Router)
10+
paths: Paths.from_router!(PhoenixAppWeb.Router)
1111
}
1212
|> OpenApiSpex.resolve_schema_modules()
1313
end
14-
end
14+
end

lib/open_api_spex/operation.ex

+31-3
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,39 @@ 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, route.opts)
64+
check_all_path_params_declared(operation, route.path)
65+
end
66+
67+
defp check_all_path_params_declared(operation, route_path) do
68+
pattern = ~r/{(.*?)}/
69+
expected_path_params =
70+
pattern
71+
|> Regex.scan(route_path)
72+
|> Enum.map(fn [_match, param] -> param end)
73+
74+
actual_path_params =
75+
operation.parameters
76+
|> Enum.filter(& &1.in == :path)
77+
|> Enum.map(& &1.name |> to_string)
78+
79+
missing_params = expected_path_params -- actual_path_params
80+
case missing_params do
81+
[] -> {:ok, operation}
82+
_ ->
83+
message =
84+
"Operation for route: #{inspect(route_path)} " <>
85+
"did not declare path parameters: #{inspect(missing_params)}"
86+
{:error, message}
87+
end
6088
end
6189

6290
@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+
verb: atom,
54+
path: String.t(),
55+
plug: atom,
56+
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

+21-9
Original file line numberDiff line numberDiff line change
@@ -16,20 +16,32 @@ defmodule OpenApiSpex.Paths do
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.map(fn route -> %{route | path: open_api_path(route.path)} end)
2223
|> 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()
24+
|> Enum.map(fn {path, routes} -> {path, PathItem.from_routes(routes)} end)
25+
|> Enum.reduce_while(%{}, fn
26+
{_path, {:error, reason}}, _acc -> {:halt, {:error, reason}}
27+
{_path, {:ok, nil}}, acc -> {:cont, acc}
28+
{path, {:ok, path_item}}, acc -> {:cont, Map.put(acc, path, path_item)}
29+
end)
30+
|> case do
31+
{:error, reason} -> {:error, reason}
32+
paths -> {:ok, paths}
33+
end
34+
end
35+
36+
@spec from_router!(module) :: t
37+
def from_router!(router) do
38+
{:ok, paths} = from_router(router)
39+
paths
2640
end
2741

2842
@spec open_api_path(String.t) :: String.t
2943
defp open_api_path(path) do
30-
path
31-
|> String.split("/")
32-
|> Enum.map(fn ":"<>segment -> "{#{segment}}"; segment -> segment end)
33-
|> Enum.join("/")
44+
pattern = ~r{:([^/]+)}
45+
Regex.replace(pattern, path, "{\\1}")
3446
end
35-
end
47+
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

+2-2
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@ defmodule OpenApiSpex.PathsTest do
55

66
describe "Paths" do
77
test "from_router" do
8-
paths = Paths.from_router(Router)
8+
paths = Paths.from_router!(Router)
99
assert %{
1010
"/api/users" => %PathItem{},
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" => %{

test/support/api_spec.ex

+1-1
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ defmodule OpenApiSpexTest.ApiSpec do
2727
{schema.title, schema}
2828
end
2929
},
30-
paths: Paths.from_router(Router)
30+
paths: Paths.from_router!(Router)
3131
}
3232
|> OpenApiSpex.resolve_schema_modules()
3333
end

0 commit comments

Comments
 (0)