Skip to content

Commit b97765b

Browse files
author
Adrián Quintás
committed
Add cursor pagination plug
1 parent 3d32394 commit b97765b

10 files changed

+289
-33
lines changed

.formatter.exs

+6
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
[
2+
inputs: [
3+
"{lib,config,test}/**/*.{ex,exs}",
4+
"mix.exs"
5+
]
6+
]

README.md

+18-3
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,9 @@ Features:
1313
* Phoenix plug to validate JWT header
1414
* Phoenix plug to parse a pagination request
1515
* Simple integer parsing
16-
* Page query params parser
16+
* Page parsing:
17+
* Cursor pagination
18+
* Standard pagination
1719
* JWT claims parser
1820
* Wrapped Logger with Sentry integration
1921

@@ -23,7 +25,7 @@ Add to dependencies
2325

2426
```elixir
2527
def deps do
26-
[{:server_utils, "~> 0.1.3"}]
28+
[{:server_utils, "~> 0.1.4"}]
2729
end
2830
```
2931

@@ -33,7 +35,9 @@ mix deps.get
3335

3436
## Configuration
3537

36-
Configure default pagination params
38+
Configure default pagination params:
39+
40+
* Standard pagination
3741

3842
```
3943
config :server_utils,
@@ -43,3 +47,14 @@ config :server_utils,
4347
page_size: 10,
4448
page_number: 1
4549
```
50+
51+
* Cursor pagination
52+
53+
```
54+
config :server_utils,
55+
cursor_key: "cursor",
56+
number_of_items_key: "number_of_items",
57+
default_cursor: "",
58+
default_number_of_items: 25,
59+
max_number_of_items: 50
60+
```

config/config.exs

+9
Original file line numberDiff line numberDiff line change
@@ -2,9 +2,18 @@
22
# and its dependencies with the aid of the Mix.Config module.
33
use Mix.Config
44

5+
# Standard pagination
56
config :server_utils,
67
page_size_key: "page_size",
78
page_number_key: "page_number",
89
max_page_size: 25,
910
page_size: 10,
1011
page_number: 1
12+
13+
# Cursor pagination
14+
config :server_utils,
15+
cursor_key: "cursor",
16+
number_of_items_key: "number_of_items",
17+
default_cursor: "",
18+
default_number_of_items: 25,
19+
max_number_of_items: 50

lib/page/cursor_page_request.ex

+17
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
defmodule ServerUtils.Page.CursorPageRequest do
2+
@moduledoc """
3+
Defines a page request for cursor pagination
4+
"""
5+
6+
@typedoc """
7+
Values:
8+
- cursor: The cursor to get items from
9+
- number_of_items: number of items to retrieve
10+
"""
11+
@type t :: %__MODULE__{
12+
cursor: String.t(),
13+
number_of_items: Integer.t()
14+
}
15+
16+
defstruct [:cursor, :number_of_items]
17+
end

lib/parsers/params_parser.ex

+66-8
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
defmodule ParamException do
2-
@type t :: %__MODULE__{message: String.t}
2+
@type t :: %__MODULE__{message: String.t()}
33
defexception [:message]
44
end
55

@@ -14,6 +14,7 @@ defmodule ServerUtils.Parsers.ParamsParser do
1414

1515
alias ServerUtils.Parsers.IntegerParser
1616
alias ServerUtils.Page.PageParams
17+
alias ServerUtils.Page.CursorPageRequest
1718

1819
@page_size_key Application.get_env(:server_utils, :page_size_key) || "page_size"
1920
@page_number_key Application.get_env(:server_utils, :page_number_key) || "page_number"
@@ -22,6 +23,13 @@ defmodule ServerUtils.Parsers.ParamsParser do
2223
@default_page_size 25
2324
@default_max_page_size 100
2425

26+
@cursor_key Application.get_env(:server_utils, :cursor_key) || "cursor"
27+
@page_number_of_items_key Application.get_env(:server_utils, :number_of_items) ||
28+
"number_of_items"
29+
30+
@default_cursor ""
31+
@default_number_of_items 25
32+
2533
@doc """
2634
Parses a page params from a request params map.
2735
@@ -37,24 +45,75 @@ defmodule ServerUtils.Parsers.ParamsParser do
3745
%PageParams{page_number: 5, page_size: 50}
3846
3947
"""
40-
@spec parse_page_params(Map.t) :: PageParams.t
48+
@spec parse_page_params(Map.t()) :: PageParams.t()
4149
def parse_page_params(params_map, opts \\ []) do
42-
default_page_number = opts[:page_number] || Application.get_env(:server_utils, :page_number) || @default_page_number
43-
default_page_size = opts[:page_size] || Application.get_env(:server_utils, :page_size) || @default_page_size
44-
max_page_size = opts[:max_page_size] || Application.get_env(:server_utils, :max_page_size) || @default_max_page_size
50+
default_page_number =
51+
opts[:page_number] || Application.get_env(:server_utils, :page_number) ||
52+
@default_page_number
53+
54+
default_page_size =
55+
opts[:page_size] || Application.get_env(:server_utils, :page_size) || @default_page_size
56+
57+
max_page_size =
58+
opts[:max_page_size] || Application.get_env(:server_utils, :max_page_size) ||
59+
@default_max_page_size
4560

4661
page_number = parse_integer_param(params_map, @page_number_key, default_page_number)
62+
4763
page_size =
4864
case parse_integer_param(params_map, @page_size_key, default_page_size) do
4965
page_size when page_size > max_page_size ->
5066
max_page_size
67+
5168
page_size ->
5269
page_size
5370
end
5471

5572
%PageParams{page_size: page_size, page_number: page_number}
5673
end
5774

75+
@doc """
76+
Parses a page params from a cursor pagination request.
77+
78+
It returns the `CursorPageRequest.t` with the present values and with the default.
79+
80+
## Examples
81+
82+
iex> ServerUtils.Parsers.ParamsParser.parse_cursor_page_request!(%{"page_number": 5, "page_size": 23})
83+
%PageParams{page_number: 5, page_size: 23}
84+
85+
# With a configured max_page_size of 50
86+
iex> ServerUtils.Parsers.ParamsParser.parse_cursor_page_request!(%{"page_number": 5, "page_size": 9000})
87+
%PageParams{page_number: 5, page_size: 50}
88+
89+
"""
90+
@spec parse_cursor_page_request(Map.t()) :: CursorPageRequest.t()
91+
def parse_cursor_page_request(params_map, opts \\ []) do
92+
default_cursor =
93+
opts[:cursor] || Application.get_env(:server_utils, :default_cursor) || @default_cursor
94+
95+
default_number_of_items =
96+
opts[:number_of_items] || Application.get_env(:server_utils, :default_number_of_items) ||
97+
@default_number_of_items
98+
99+
max_number_of_items =
100+
opts[:max_page_size] || Application.get_env(:server_utils, :max_number_of_items) ||
101+
@default_max_page_size
102+
103+
number_of_items =
104+
case parse_integer_param(params_map, @page_number_of_items_key, default_number_of_items) do
105+
number_of_items when number_of_items > max_number_of_items ->
106+
max_number_of_items
107+
108+
number_of_items ->
109+
number_of_items
110+
end
111+
112+
cursor = Map.get(params_map, @cursor_key, default_cursor)
113+
114+
%CursorPageRequest{cursor: cursor, number_of_items: number_of_items}
115+
end
116+
58117
@doc """
59118
Parses a integer value from a request params map.
60119
@@ -69,21 +128,20 @@ defmodule ServerUtils.Parsers.ParamsParser do
69128
10
70129
71130
"""
72-
@spec parse_integer_param(Map.t, String.t, Integer.t) :: Integer.t
131+
@spec parse_integer_param(Map.t(), String.t(), Integer.t()) :: Integer.t()
73132
def parse_integer_param(params_map, attr_name, default) do
74133
case Map.get(params_map, attr_name, default) do
75134
value when is_integer(value) -> value
76135
value -> IntegerParser.parse_integer(value, default)
77136
end
78137
end
79138

80-
@spec parse_integer_param!(Map.t, String.t) :: Integer.t
139+
@spec parse_integer_param!(Map.t(), String.t()) :: Integer.t()
81140
def parse_integer_param!(params_map, attr_name) do
82141
case Map.get(params_map, attr_name) do
83142
nil -> raise ParamException, message: "Param not found"
84143
value when is_integer(value) -> value
85144
value -> IntegerParser.parse_integer!(value)
86145
end
87146
end
88-
89147
end

lib/plugs/cursor_page_request.ex

+30
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
defmodule ServerUtils.Plugs.CursorPageRequest do
2+
@moduledoc """
3+
Plug to build a paginated request.
4+
5+
If there is no pagination params or they not fullfil the configureation a default page value will be used.
6+
"""
7+
8+
import Plug.Conn
9+
10+
alias ServerUtils.Parsers.ParamsParser
11+
alias ServerUtils.Page.CursorPageRequest
12+
13+
@spec init(Keyword.t()) :: Keyword.t()
14+
def init(default), do: default
15+
16+
@spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t()
17+
def call(%Plug.Conn{query_string: query_string} = conn, _default) do
18+
query_string
19+
|> URI.decode_query()
20+
|> ParamsParser.parse_cursor_page_request()
21+
|> set_page_request(conn)
22+
end
23+
24+
def call(conn, _default), do: conn
25+
26+
@spec set_page_request(CursorPageRequest.t(), Plug.Conn.t()) :: Plug.Conn.t()
27+
defp set_page_request(page_request, conn) do
28+
put_private(conn, :page_request, page_request)
29+
end
30+
end

lib/plugs/session_token_validator.ex

+10-7
Original file line numberDiff line numberDiff line change
@@ -3,28 +3,32 @@ defmodule ServerUtils.Plugs.SessionTokenValidator do
33
Plug to intercept request and validate the presence of the JWT header
44
"""
55

6+
@behaviour Plug
67
import Plug.Conn
78

89
alias ServerUtils.Jwt.JwtParser
910

1011
@authorization_header "authorization"
1112

12-
@spec init(Keyword.t) :: Keyword.t
13+
@spec init(Keyword.t()) :: Keyword.t()
1314
def init(default), do: default
1415

15-
@spec call(Plug.Conn.t, Keyword.t) :: Plug.Conn.t
16+
@spec call(Plug.Conn.t(), Keyword.t()) :: Plug.Conn.t()
1617
def call(%Plug.Conn{req_headers: req_headers} = conn, _default) do
17-
if Enum.any?(req_headers, fn {header, _value} -> String.downcase(header) == @authorization_header end) do
18+
if Enum.any?(req_headers, fn {header, _value} ->
19+
String.downcase(header) == @authorization_header
20+
end) do
1821
user_id =
1922
req_headers
2023
|> Enum.filter(fn {header, _value} -> String.downcase(header) == @authorization_header end)
2124
|> List.first()
2225
|> elem(1)
23-
|> JwtParser.get_claim("username", [error_if_blank: true])
26+
|> JwtParser.get_claim("username", error_if_blank: true)
2427

2528
case user_id do
2629
{:ok, user_id} ->
2730
set_decoded_jwt_data(user_id, conn)
31+
2832
{:error, _} ->
2933
send_unauthorized_response(conn)
3034
end
@@ -37,16 +41,15 @@ defmodule ServerUtils.Plugs.SessionTokenValidator do
3741
send_unauthorized_response(conn)
3842
end
3943

40-
@spec send_unauthorized_response(Plug.Conn.t) :: Plug.Conn.t
44+
@spec send_unauthorized_response(Plug.Conn.t()) :: Plug.Conn.t()
4145
defp send_unauthorized_response(conn) do
4246
conn
4347
|> send_resp(:unauthorized, "missing authentication")
4448
|> halt()
4549
end
4650

47-
@spec set_decoded_jwt_data(String.t, Plug.Conn.t) :: Plug.Conn.t
51+
@spec set_decoded_jwt_data(String.t(), Plug.Conn.t()) :: Plug.Conn.t()
4852
defp set_decoded_jwt_data(user_id, conn) do
4953
put_private(conn, :user_id, user_id)
5054
end
51-
5255
end

mix.exs

+17-12
Original file line numberDiff line numberDiff line change
@@ -6,21 +6,26 @@ defmodule ServerUtils.Mixfile do
66
def project do
77
[
88
app: :server_utils,
9-
version: "0.1.3",
9+
version: "0.1.4",
1010
elixir: "~> 1.6",
11-
elixirc_paths: elixirc_paths(Mix.env),
12-
start_permanent: Mix.env == :prod,
11+
elixirc_paths: elixirc_paths(Mix.env()),
12+
start_permanent: Mix.env() == :prod,
1313
deps: deps(),
1414
aliases: aliases(),
1515
package: package(),
1616
description: description(),
1717
test_coverage: [tool: ExCoveralls],
18-
preferred_cli_env: ["coveralls": :test, "coveralls.detail": :test, "coveralls.post": :test, "coveralls.html": :test],
18+
preferred_cli_env: [
19+
coveralls: :test,
20+
"coveralls.detail": :test,
21+
"coveralls.post": :test,
22+
"coveralls.html": :test
23+
]
1924
]
2025
end
2126

2227
defp elixirc_paths(:test), do: ["lib", "test/support"]
23-
defp elixirc_paths(_), do: ["lib"]
28+
defp elixirc_paths(_), do: ["lib"]
2429

2530
# Run "mix help compile.app" to learn about applications.
2631
def application do
@@ -35,7 +40,7 @@ defmodule ServerUtils.Mixfile do
3540
files: ["lib", "mix.exs", "README*", "LICENSE*"],
3641
maintainers: ["Adrián Quintás"],
3742
licenses: ["MIT"],
38-
links: %{"GitHub" => "https://github.com/heyorbit/elixir-server-utils"}
43+
links: %{"GitHub" => "https://github.com/orbitdigital/elixir-server-utils"}
3944
]
4045
end
4146

@@ -48,21 +53,21 @@ defmodule ServerUtils.Mixfile do
4853
[
4954
{:excoveralls, "~> 0.8", only: :test},
5055
{:mock, "~> 0.3", only: :test},
51-
{:credo, "~> 0.8", only: [:dev, :test], runtime: false},
56+
{:dialyxir, "~> 0.5", only: [:dev], runtime: false},
57+
{:credo, "~> 0.9.0-rc7", only: [:dev, :test], runtime: false},
5258
{:plug, "~> 1.4"},
53-
{:dialyxir, "~> 0.5", only: :dev, runtime: false},
5459
{:ex_doc, "~> 0.16", only: :dev, runtime: false},
5560
{:sentry, "~> 6.0.5"},
5661
{:joken, "~> 1.5"},
57-
{:exjsx, "~> 4.0"},
62+
{:exjsx, "~> 4.0"}
5863
]
5964
end
6065

6166
defp aliases do
6267
[
63-
"compile": ["compile --warnings-as-errors"],
64-
"coveralls": ["coveralls.html --umbrella"],
65-
"coveralls.html": ["coveralls.html --umbrella"],
68+
compile: ["compile --warnings-as-errors"],
69+
coveralls: ["coveralls.html --umbrella"],
70+
"coveralls.html": ["coveralls.html --umbrella"]
6671
]
6772
end
6873
end

mix.lock

+5-3
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
%{"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [], [], "hexpm"},
1+
%{
2+
"base64url": {:hex, :base64url, "0.0.1", "36a90125f5948e3afd7be97662a1504b934dd5dac78451ca6e9abf85a10286be", [], [], "hexpm"},
23
"bunt": {:hex, :bunt, "0.2.0", "951c6e801e8b1d2cbe58ebbd3e616a869061ddadcc4863d0a2182541acae9a38", [], [], "hexpm"},
34
"certifi": {:hex, :certifi, "2.0.0", "a0c0e475107135f76b8c1d5bc7efb33cd3815cb3cf3dea7aefdd174dabead064", [], [], "hexpm"},
4-
"credo": {:hex, :credo, "0.8.10", "261862bb7363247762e1063713bb85df2bbd84af8d8610d1272cd9c1943bba63", [], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}], "hexpm"},
5+
"credo": {:hex, :credo, "0.9.0", "5d1b494e4f2dc672b8318e027bd833dda69be71eaac6eedd994678be74ef7cb4", [:mix], [{:bunt, "~> 0.2.0", [hex: :bunt, repo: "hexpm", optional: false]}, {:poison, ">= 0.0.0", [hex: :poison, repo: "hexpm", optional: false]}], "hexpm"},
56
"dialyxir": {:hex, :dialyxir, "0.5.1", "b331b091720fd93e878137add264bac4f644e1ddae07a70bf7062c7862c4b952", [], [], "hexpm"},
67
"earmark": {:hex, :earmark, "1.2.4", "99b637c62a4d65a20a9fb674b8cffb8baa771c04605a80c911c4418c69b75439", [], [], "hexpm"},
78
"ex_doc": {:hex, :ex_doc, "0.18.1", "37c69d2ef62f24928c1f4fdc7c724ea04aecfdf500c4329185f8e3649c915baf", [], [{:earmark, "~> 1.1", [hex: :earmark, repo: "hexpm", optional: false]}], "hexpm"},
@@ -24,4 +25,5 @@
2425
"sentry": {:hex, :sentry, "6.0.5", "5f4e5818e39b2721bb92ee07c164d10c510af9b519dc587727008433e119ad7a", [], [{:hackney, "~> 1.8 or 1.6.5", [hex: :hackney, repo: "hexpm", optional: false]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}, {:poison, "~> 1.5 or ~> 2.0 or ~> 3.0", [hex: :poison, repo: "hexpm", optional: false]}, {:uuid, "~> 1.0", [hex: :uuid, repo: "hexpm", optional: false]}], "hexpm"},
2526
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.1", "28a4d65b7f59893bc2c7de786dec1e1555bd742d336043fe644ae956c3497fbe", [], [], "hexpm"},
2627
"unicode_util_compat": {:hex, :unicode_util_compat, "0.3.1", "a1f612a7b512638634a603c8f401892afbf99b8ce93a45041f8aaca99cadb85e", [], [], "hexpm"},
27-
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [], [], "hexpm"}}
28+
"uuid": {:hex, :uuid, "1.1.8", "e22fc04499de0de3ed1116b770c7737779f226ceefa0badb3592e64d5cfb4eb9", [], [], "hexpm"},
29+
}

0 commit comments

Comments
 (0)