Skip to content

Commit

Permalink
refactor: update request data structure & add peer (#58)
Browse files Browse the repository at this point in the history
closes #57

Co-authored-by: Javier Garea <[email protected]>
  • Loading branch information
LoisSotoLopez and javiergarea authored Nov 30, 2023
1 parent b7d7381 commit 213e2f0
Show file tree
Hide file tree
Showing 16 changed files with 304 additions and 97 deletions.
3 changes: 1 addition & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ name: erf ci

on:
push:
branches: [main]
branches: [main, develop]
pull_request:
branches: [main]

env:
OTP-VERSION: 25.2.3
Expand Down
43 changes: 23 additions & 20 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ Design-first is an approach to API development that prioritises the design of th

## Quickstart

1. Design your API using OpenAPI 3.0. For example: [users.json](examples/users/users.json).
1. Design your API using OpenAPI 3.0. For example: [users.openapi.json](examples/users/priv/users.openapi.json).

2. Add `erf` as a dependency in your `rebar3` project.
```erl
Expand All @@ -25,27 +25,27 @@ Design-first is an approach to API development that prioritises the design of th
]}.
```

3. Implement a callback module for your API. A hypothetical example for [users.json](examples/users/users.json) would be [users_callback.erl](examples/users/users_callback.erl).
3. Implement a callback module for your API. A hypothetical example for [users.openapi.json](examples/users/priv/users.openapi.json) would be [users_callback.erl](examples/users/src/users_callback.erl).
```erl
%% An <code>erf</code> callback for the users REST API.
-module(users_callback).

%%% EXTERNAL EXPORTS
-export([
create_user/4,
get_user/4,
delete_user/4
create_user/1,
get_user/1,
delete_user/1
]).

%%%-------------------------------------------------------
%%% EXTERNAL EXPORTS
%%%-------------------------------------------------------
create_user(_PathParameters, _QueryParameters, _Headers, Body) ->
create_user(#{body := Body} = _Request) ->
Id = base64:encode(crypto:strong_rand_bytes(16)),
ets:insert(users, {Id, Body#{<<"id">> => Id}}),
{201, [], Body#{<<"id">> => Id}}.

get_user(PathParameters, _QueryParameters, _Headers, _Body) ->
get_user(#{path_parameters := PathParameters} = _Request) ->
Id = proplists:get_value(<<"userId">>, PathParameters),
case ets:lookup(users, Id) of
[] ->
Expand All @@ -57,7 +57,7 @@ get_user(PathParameters, _QueryParameters, _Headers, _Body) ->
{200, [], User}
end.

delete_user(PathParameters, _QueryParameters, _Headers, _Body) ->
delete_user(#{path_parameters := PathParameters} = _Request) ->
Id = proplists:get_value(<<"userId">>, PathParameters),
case ets:lookup(users, Id) of
[] ->
Expand Down Expand Up @@ -97,8 +97,10 @@ init([]) ->
% Users storage
ets:new(users, [public, named_table]),
UsersAPIConf = #{
spec_path => <<"doc/openapi/users.openapi.json">>,
spec_path => <<"priv/users.openapi.json">>,
callback => users_callback,
preprocess_middlewares => [users_preprocess],
postprocess_middlewares => [users_postprocess],
port => 8080
},
UsersChildSpec = {
Expand All @@ -111,10 +113,11 @@ init([]) ->
},
{ok, {{one_for_one, 5, 10}, [UsersChildSpec]}}.
```
Notice the configured preprocess and postprocess middlewares. They implement a basic authorization mechanism, short-circuiting the request and returning a 403 HTTP error code if the `X-API-KEY: api-key` header is missing, and they print in console the time in microseconds that authorized requests take to complete.

5. Start requesting your service.
```sh
$ curl -vvv 'localhost:8080/users' -H 'Content-Type: application/json' -d '{"username": "foo", "password": "foobar"}'
$ curl -vvv 'localhost:8080/users' -H 'Content-Type: application/json' -H 'X-API-KEY: api-key' -d '{"username": "foo", "password": "foobar"}'
* Trying 127.0.0.1:8080...
* Connected to localhost (127.0.0.1) port 8080 (#0)
> POST /users HTTP/1.1
Expand Down Expand Up @@ -182,20 +185,20 @@ A detailed description of each parameter can be found in the following list:
- `header_timeout`: Timeout in ms for receiving more packets when waiting for the headers. Defaults to `10000`.
- `body_timeout`: Timeout in ms for receiving more packets when waiting for the body. Defaults to `30000`.
- `max_body_size`: Maximum size in bytes for the body of allowed received messages. Defaults to `1024000`.
- `log_level`: Severity asociated to logged messages. Defaults to `error`.
- `log_level`: Severity associated to logged messages. Defaults to `error`.
## Callback modules
## Callback modules & middlewares
`erf` dynamically generates a router that type check the received requests against the API specification. If the request passes the validation, it is deconstructed and passed to the callback module. But, how does the callback module must look like?
`erf` dynamically generates a router that type check the received requests against the API specification. If the request passes the validation, it is deconstructed and passed to the middleware and callback modules. But, how do those middleware and callback modules must look like?
The router expects your callback module to export one function per operation defined in your API specification. It also expects each operation to include an `operationId` that, after being transformed to _snake_case_, will identify the function that is going to be called. Regarding the expected arguments in those functions, `erf` will provide 4 arguments that include the variable data of the request (i.e., that data that cannot be inferred just from the `operationId`):
- `PathParameters :: [{Name :: binary(), Value :: binary()}]`
- `QueryParameters :: [{Name :: binary(), Value :: binary()}]`
- `Headers :: [{Name :: binary(), Value :: binary()}]`
- `Body :: njson:t()`
> [`njson`](https://github.com/nomasystems/njson) is the library used in `erf` to deserialize JSON values to Erlang terms.
- **Preprocess middlewares** receive a request, do something with it (such as adding an entry to an access log) and return it for the next middleware or callback module to process it. This allows each preprocess middleware to modify the content of the request, updating any of its fields such as the `context` field, specifically dedicated to store contextual information middlewares might want to provide. Preprocess middlewares can short-circuit the processing flow, returning `{stop, Response}` or `{stop, Response, Request}` instead of just `Request`. The first of those alternatives prevents the following preprocess middlewares to execute, as well as the callback module, skipping directly to the postprocess middlewares. The second alternative response format does the same but allows to modify the request information.
An example of an API specification and a supported callback can be seen in [Quickstart](#quickstart).
- **Callback module**.
The router expects your callback module to export one function per operation defined in your API specification. It also expects each operation to include an `operationId` that, after being transformed to _snake_case_, will identify the function that is going to be called. Such function receives an `erf:request()` and must return an `erf:response()`.
- **Postprocess middlewares** can also update the request, like the preprocess middlewares, by returning a `{erf:response(), erf:request()}` tuple or just return a `erf:response()` and leave the received request intact. This middlewares cannot short-circuit the processing flow.
An example of an API specification and a supported callback can be seen in [Quickstart](#quickstart). Files `users_preprocess.erl` and `users_postprocess.erl` under `examples/users` exemplify how to use `erf` middlewares. Try out the example by running `rebar3 as examples shell` from the root of this project.
## Hot-configuration reloading
Expand Down
File renamed without changes.
8 changes: 8 additions & 0 deletions examples/users/src/users.app.src
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
{application, users, [
{description, "A tiny app exemplifying erf usage"},
{vsn, "0.1.0"},
{registered, []},
{applications, [kernel, stdlib, erf]},
{mod, {users, []}},
{env, []}
]}.
11 changes: 11 additions & 0 deletions examples/users/src/users.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
-module(users).

-behaviour(application).

-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
users_sup:start_link().

stop(_State) ->
ok.
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,20 @@

%%% EXTERNAL EXPORTS
-export([
create_user/4,
get_user/4,
delete_user/4
create_user/1,
get_user/1,
delete_user/1
]).

%%%-------------------------------------------------------
%%% EXTERNAL EXPORTS
%%%-------------------------------------------------------
create_user(_PathParameters, _QueryParameters, _Headers, Body) ->
create_user(#{body := Body} = _Request) ->
Id = base64:encode(crypto:strong_rand_bytes(16)),
ets:insert(users, {Id, Body#{<<"id">> => Id}}),
{201, [], Body#{<<"id">> => Id}}.

get_user(PathParameters, _QueryParameters, _Headers, _Body) ->
get_user(#{path_parameters := PathParameters} = _Request) ->
Id = proplists:get_value(<<"userId">>, PathParameters),
case ets:lookup(users, Id) of
[] ->
Expand All @@ -28,7 +28,7 @@ get_user(PathParameters, _QueryParameters, _Headers, _Body) ->
{200, [], User}
end.

delete_user(PathParameters, _QueryParameters, _Headers, _Body) ->
delete_user(#{path_parameters := PathParameters} = _Request) ->
Id = proplists:get_value(<<"userId">>, PathParameters),
case ets:lookup(users, Id) of
[] ->
Expand Down
22 changes: 22 additions & 0 deletions examples/users/src/users_postprocess.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
%% An <code>erf</code> postprocess middleware for the users REST API.
-module(users_postprocess).

-behaviour(erf_postprocess_middleware).

%%% EXTERNAL EXPORTS
-export([
postprocess/2
]).

%%%-------------------------------------------------------
%%% EXTERNAL EXPORTS
%%%-------------------------------------------------------
% Here we exemplify how information previously inserted on the request context
% can be used to condition the request processing flow.
postprocess(#{method := post, context := #{post_init := PostInitT}} = _Request, Response) ->
PostEndT = erlang:timestamp(),
Diff = timer:now_diff(PostEndT, PostInitT),
io:format("Post time diff : ~p~n", [Diff]),
Response;
postprocess(_Request, Response) ->
Response.
37 changes: 37 additions & 0 deletions examples/users/src/users_preprocess.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
%% An <code>erf</code> preprocess middleware for the users REST API.
-module(users_preprocess).

-behaviour(erf_preprocess_middleware).

%%% EXTERNAL EXPORTS
-export([
preprocess/1
]).

%%%-------------------------------------------------------
%%% EXTERNAL EXPORTS
%%%-------------------------------------------------------
preprocess(#{headers := Headers} = Request) ->
Authorization = proplists:get_value(<<"x-api-key">>, Headers, undefined),
case is_authorized(Authorization) of
false ->
% For delete operations, if delete is disabled,
% we skip to the post-process middlewares.
{stop, {403, [], <<"Missing valid basic authorization header">>}};
true ->
PostInitT = erlang:timestamp(),
Context = maps:get(context, Request, #{}),
% We store the current timestamp on the the request context
% for latter use.
Request#{context => Context#{post_init => PostInitT}}
end.

%%%-------------------------------------------------------
%%% INTERNAL FUNCTIONS
%%%-------------------------------------------------------
is_authorized(undefined) ->
false;
is_authorized(<<"api-key">>) ->
true;
is_authorized(_) ->
false.
39 changes: 39 additions & 0 deletions examples/users/src/users_sup.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
-module(users_sup).

%%% BEHAVIOURS
-behaviour(supervisor).

%%% START/STOP EXPORTS
-export([start_link/0]).

%%% INTERNAL EXPORTS
-export([init/1]).

%%%-------------------------------------------------------
%%% START/STOP EXPORTS
%%%-------------------------------------------------------
start_link() ->
supervisor:start_link({local, ?MODULE}, ?MODULE, []).

%%%-------------------------------------------------------
%%% INTERNAL EXPORTS
%%%-------------------------------------------------------
init([]) ->
% Users storage
ets:new(users, [public, named_table]),
UsersAPIConf = #{
spec_path => filename:join(code:priv_dir(users), <<"users.openapi.json">>),
callback => users_callback,
preprocess_middlewares => [users_preprocess],
postprocess_middlewares => [users_postprocess],
port => 8080
},
UsersChildSpec = {
public_api_server,
{erf, start_link, [UsersAPIConf]},
permanent,
5000,
worker,
[erf]
},
{ok, {{one_for_one, 5, 10}, [UsersChildSpec]}}.
5 changes: 4 additions & 1 deletion rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,10 @@

{profiles, [
{examples, [
{extra_src_dirs, ["examples"]}
{project_app_dirs, ["examples/users", "."]},
{shell, [
{apps, [users]}
]}
]},
{test, [
{erl_opts, [nowarn_export_all]},
Expand Down
15 changes: 9 additions & 6 deletions src/erf.erl
Original file line number Diff line number Diff line change
Expand Up @@ -76,12 +76,15 @@
| connect.
-type path_parameter() :: {binary(), binary()}.
-type query_parameter() :: {binary(), binary()}.
-type request() :: {
Path :: [binary()],
Method :: method(),
QueryParameters :: [query_parameter()],
Headers :: [header()],
Body :: body()
-type request() :: #{
path := [binary()],
path_parameters => [path_parameter()],
method := method(),
query_parameters := [query_parameter()],
headers := [header()],
body := body(),
peer := undefined | binary(),
context => any()
}.
-type response() :: {
StatusCode :: pos_integer(),
Expand Down
4 changes: 3 additions & 1 deletion src/erf_postprocess_middleware.erl
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,6 @@
-callback postprocess(Request, Response) -> Result when
Request :: erf:request(),
Response :: erf:response(),
Result :: erf:response().
Result ::
erf:response()
| {erf:request(), erf:response()}.
7 changes: 6 additions & 1 deletion src/erf_preprocess_middleware.erl
Original file line number Diff line number Diff line change
Expand Up @@ -29,4 +29,9 @@
%%%-----------------------------------------------------------------------------
-callback preprocess(Request) -> Result when
Request :: erf:request(),
Result :: erf:request() | {stop, erf:response()}.
Result ::
{stop, Response}
| {stop, Response, NewRequest}
| NewRequest,
NewRequest :: erf:request(),
Response :: erf:response().
Loading

0 comments on commit 213e2f0

Please sign in to comment.