nomasystems / erf

:pencil: A design-first Erlang REST Framework.
https://nomasystems.github.io/erf/
Apache License 2.0
29 stars 2 forks source link
erlang framework openapi rest

erf - A design-first Erlang REST Framework

erf ci erf docs

erf is a design-first Erlang REST framework. It provides an interface to spawn specification-driven HTTP servers with several automated features that aim to ease the development, operation and maintenance of design-first RESTful services. Its HTTP protocol features are provided as a wrapper of the elli HTTP 1.1 server.

What is design-first?

When following a code-first approach to develop APIs, the interface is produced as a result of the implementation and, therefore, client-side code, integration tests and other parts of the system that depend on the API behaviour, need to wait until the server-side work is done.

Design-first is an approach to API development that prioritises the design of the API before its implementation. The explicit contract produced in this design, which should be the result of an agreement between the stakeholders of the API, aims to reduce bottlenecks in the development process.

How does erf help developing design-first RESTful services?

erf is an HTTP server framework that, taking an API design in the form of an specification file and a callback module as input, starts a server and dynamically generates code to efficiently type-check and route requests to callback functions. Its main goal is to provide a tool to REST API development in Erlang that reduces the development time by automating the implementation of boilerplate code that can be inferred from the API specification.

Quickstart

  1. Design your API using OpenAPI 3.0. For example: users.openapi.json.

  2. Add erf as a dependency in your rebar3 project.

    {deps, [
    {erf, {git, "git@github.com:nomasystems/erf.git", {branch, "main"}}}
    ]}.
  3. Implement a callback module for your API. A hypothetical example for users.openapi.json would be users_callback.erl.

    
    %% An <code>erf</code> callback for the users REST API.
    -module(users_callback).

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

%%%------------------------------------------------------- %%% EXTERNAL EXPORTS %%%------------------------------------------------------- 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(#{path_parameters := PathParameters} = _Request) -> Id = proplists:get_value(<<"userId">>, PathParameters), case ets:lookup(users, Id) of [] -> {404, [], #{ <<"message">> => <<"User ", Id/binary, " not found">> }}; [{Id, User}] -> {200, [], User} end.

delete_user(#{path_parameters := PathParameters} = _Request) -> Id = proplists:get_value(<<"userId">>, PathParameters), case ets:lookup(users, Id) of [] -> {404, [], #{ <<"message">> => <<"User ", Id/binary, " not found">> }}; [_User] -> ets:delete(users, Id), {204, [], #{<<"id">> => Id}} end.


4. Start an `erf` instance using the [`erf:start_link/1`](https://nomasystems.github.io/erf/erf.html#start_link/1) function under the supervisor of your application. For example:
```erl
-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 => <<"priv/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]}}.

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.

  1. Start requesting your service.
    $ 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
    > Host: localhost:8080
    > User-Agent: curl/8.0.1
    > Accept: */*
    > Content-Type: application/json
    > Content-Length: 44
    >
    < HTTP/1.1 201 Created
    < connection: Keep-Alive
    < content-length: 73
    < content-type: application/json
    <
    * Connection #0 to host localhost left intact
    {"id":"b7R7bJSbaTmoiWwecy2IwA==","password":"foobar","username":"foo"}

erf configuration

erf's main entry point (i.e., the start_link/1 function) receives an API specification, a callback module and a set of optional values that enable its configuration.

The configuration is provided as map with the following type spec:

%%% erf.erl
-type conf() :: #{
    spec_path := binary(),
    callback := module(),
    port => inet:port_number(),
    name => atom(),
    spec_parser => module(),
    preprocess_middlewares => [module()],
    postprocess_middlewares => [module()],
    ssl => boolean(),
    certfile => binary(),
    keyfile => binary(),
    static_routes => [static_route()],
    swagger_ui => boolean(),
    min_acceptors => pos_integer(),
    accept_timeout => pos_integer(),
    request_timeout => pos_integer(),
    header_timeout => pos_integer(),
    body_timeout => pos_integer(),
    max_body_size => pos_integer(),
    log_level => logger:level()
}.

A detailed description of each parameter can be found in the following list:

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 middleware and callback modules. But, how do those middleware and callback modules must look like?

An example of an API specification and a supported callback can be seen in 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

The design principles behind erf allow its instances to be reconfigured in runtime with no needed downtime. While not every configuration key is updatable once the server is started (e.g., the port), some interesting features of the framework can be updated on-the-fly.

The following type spec corresponds to the runtime configuration of an erf instance. At the same time, is the type spec of the second argument for the erf:reload/2 function.

%%% erf_conf.erl
-type t() :: #{
    callback => module(),
    log_level => logger:level(),
    preprocess_middlewares => [module()],
    postprocess_middlewares => [module()],
    router => erl_syntax:syntaxTree(), % not manually updatable
    router_mod => module(), % not manually updatable
    spec_path => binary(),
    spec_parser => module(),
    static_routes => [erf:static_route()],
    swagger_ui => boolean()
}.

NOTE: the router and router_mod keys are not updatable as they are automatically computed when new configuration is provided.

Static routes

As shown in erf configuration, the server supports routes that serve static files. The type spec for static routes is the following:

%%% erf.erl
-type static_dir() :: {dir, binary()}.
-type static_file() :: {file, binary()}.
-type static_route() :: {Path :: binary(), Resource :: static_file() | static_dir()}.

This feature enables erf to serve a Swagger UI version with your API specification. Just set the swagger_ui flag to true and open your web browser in the server host under the /swagger path.

Troubleshooting

Diagnosing the cause of a 400 Bad Request error for a specific request can become challenging due to the automated generation of the router's source code. To simplify the process of analyzing this generated code, erf provides the get_router/1 function. This function offers the router's source code in binary form, allowing you to conveniently manipulate it using the most suitable handler for your particular use case, whether it's printing the code to a file or using io operations.

Specification constraints

OAS 3.0

Contributing

We :heart: contributions! Please feel free to submit issues, create pull requests or just spread the word about erf in the open-source community. Don't forget to check out our contribution guidelines to ensure smooth collaboration! :rocket:

Support

If you need help or have any questions, please don't hesitate to open an issue or contact the maintainers directly.

License

erf is released under the Apache 2.0 License. For more information, please see the LICENSE file.

Additional Licenses

This project uses OpenAPI specification (OAS) schemas and examples, which are licensed under the Apache 2.0 license. See the associated LICENSE file for more information.

Additionally, it allows for swagger-ui hosting, which is licensed under the Apache 2.0 license. For more details, please refer to the associated LICENSE file.