Skip to content

Commit

Permalink
Merge pull request #67 from nomasystems/build/0-1-2
Browse files Browse the repository at this point in the history
build: 0.1.2
  • Loading branch information
javiergarea authored May 15, 2024
2 parents 494a47b + 2f6a911 commit 0dac504
Show file tree
Hide file tree
Showing 25 changed files with 1,505 additions and 904 deletions.
7 changes: 1 addition & 6 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,9 @@
name: erf ci
name: CI

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

env:
OTP-VERSION: 25.2.3
Expand All @@ -17,10 +16,6 @@ jobs:
steps:
- uses: actions/checkout@v3

- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- uses: erlef/setup-beam@v1
with:
otp-version: ${{ env.OTP-VERSION }}
Expand Down
4 changes: 0 additions & 4 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,6 @@ jobs:
steps:
- uses: actions/checkout@v3

- uses: webfactory/[email protected]
with:
ssh-private-key: ${{ secrets.SSH_PRIVATE_KEY }}

- uses: erlef/setup-beam@v1
with:
otp-version: ${{ env.OTP-VERSION }}
Expand Down
45 changes: 24 additions & 21 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 @@ -168,7 +171,7 @@ A detailed description of each parameter can be found in the following list:
- `callback`: Name of the callback module.
- `port`: Port the server will listen to. Defaults to `8080`.
- `name`: Name under which the server is registered. Defaults to `erf`.
- `spec_parser`: Name of the specification parser module. Defaults to `erf_oas_3_0`.
- `spec_parser`: Name of the specification parser module. Defaults to `erf_parser_oas_3_0`.
- `preprocess_middlewares`: List of names of middlewares to be invoked before the request is forwarded to the callback. Defaults to `[]`.
- `postprocess_middlewares`: List of names of middlewares to be invoked after the response is returned by the callback. Defaults to `[]`.
- `ssl`: Boolean flag that enables/disables SSL. Defaults to `false`.
Expand All @@ -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]}}.
25 changes: 15 additions & 10 deletions rebar.config
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,13 @@
]}.

{deps, [
{elli, {git, "git@github.com:elli-lib/elli.git", {branch, "main"}}},
{ndto, {git, "git@github.com:nomasystems/ndto.git", {branch, "main"}}},
{njson, {git, "git@github.com:nomasystems/njson.git", {branch, "main"}}}
{elli, {git, "https://github.com/elli-lib/elli.git", {branch, "main"}}},
{ndto, {git, "https://github.com/nomasystems/ndto.git", {tag, "0.3.1"}}},
{njson, {git, "https://github.com/nomasystems/njson.git", {tag, "2.0.0"}}}
]}.

{plugins, [
{rebar3_ndto, {git, "git@github.com:nomasystems/rebar3_ndto.git", {tag, "0.1.0"}}}
{rebar3_ndto, {git, "https://github.com/nomasystems/rebar3_ndto.git", {tag, "0.3.1"}}}
]}.
{ndto, [
{specs, [
Expand All @@ -26,21 +26,24 @@
]}.

{project_plugins, [
{erlfmt, {git, "[email protected]:WhatsApp/erlfmt.git", {branch, "main"}}},
{gradualizer, {git, "git@github.com:josefs/Gradualizer.git", {branch, "master"}}},
erlfmt,
{gradualizer, {git, "https://github.com/josefs/Gradualizer.git", {tag, "0.3.0"}}},
rebar3_ex_doc
]}.
{erlfmt, [write]}.

{profiles, [
{examples, [
{extra_src_dirs, ["examples"]}
{project_app_dirs, ["examples/users", "."]},
{shell, [
{apps, [users]}
]}
]},
{test, [
{erl_opts, [nowarn_export_all]},
{deps, [
{meck, {git, "git@github.com:eproxus/meck.git", {branch, "master"}}},
{nct_util, {git, "git@github.com:nomasystems/nct_util.git", {branch, "main"}}}
{meck, {git, "https://github.com/eproxus/meck.git", {branch, "master"}}},
{nct_util, {git, "https://github.com/nomasystems/nct_util.git", {branch, "main"}}}
]}
]}
]}.
Expand Down Expand Up @@ -88,5 +91,7 @@

{gradualizer_opts, [
%% TODO: address
{exclude, ["src/erf_oas_3_0.erl", "src/erf_router.erl"]}
{exclude, [
"src/erf_router.erl"
]}
]}.
14 changes: 7 additions & 7 deletions rebar.lock
Original file line number Diff line number Diff line change
@@ -1,16 +1,16 @@
[{<<"elli">>,
{git,"git@github.com:elli-lib/elli.git",
{git,"https://github.com/elli-lib/elli.git",
{ref,"3ec352293ef493c142767127f4113c85541c32cc"}},
0},
{<<"ncalendar">>,
{git,"git@github.com:nomasystems/ncalendar.git",
{ref,"0237766de898145bbd55bb26eef8917535f341ca"}},
{git,"https://github.com/nomasystems/ncalendar.git",
{ref,"aa5615f6723585e45e82fa5524cb976cdfe3d7f7"}},
1},
{<<"ndto">>,
{git,"git@github.com:nomasystems/ndto.git",
{ref,"491a2441e43afa2fb037c6e7e826c45a383e3bd9"}},
{git,"https://github.com/nomasystems/ndto.git",
{ref,"295281b72ea4ac85e7c4d5ca42337c68b1aac137"}},
0},
{<<"njson">>,
{git,"git@github.com:nomasystems/njson.git",
{ref,"76ab40033ee977f876e7b3addca5de981ff4a9ef"}},
{git,"https://github.com/nomasystems/njson.git",
{ref,"94c586b92a7e24c403089cdbe2994b7e7c87b9cc"}},
0}].
2 changes: 1 addition & 1 deletion src/erf.app.src
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{application, erf, [
{description, "A design-first Erlang REST Framework"},
{vsn, "0.1.0"},
{vsn, "0.1.2"},
{registered, []},
{applications, [kernel, stdlib, compiler, syntax_tools, elli, ndto, njson]},
{env, []}
Expand Down
Loading

0 comments on commit 0dac504

Please sign in to comment.