# Francis
[](https://hex.pm/packages/francis)
[](https://github.com/francis-build/francis/blob/main/LICENSE)
[](https://github.com/francis-build/francis/actions/workflows/elixir.yaml)
Simple boilerplate killer using Plug and Bandit inspired by [Sinatra](https://sinatrarb.com) for Ruby.
Focused on reducing time to build as it offers automatic request parsing, automatic response parsing, easy DSL to build quickly new endpoints and websocket listeners.
## Installation
If [available in Hex](https://hex.pm/docs/publish), the package can be installed by adding `francis` to your list of dependencies in `mix.exs`:
```elixir
def deps do
[
{:francis, "~> 0.2.0"}
]
end
```
You can also use the Francis generator to create all the initial project files. You need to install the francis tasks first.
```bash
mix archive.install hex francis
```
Then you can create a new project with:
```bash
mix francis.new my_app
```
You can also create a project with a supervisor structure:
```bash
mix francis.new my_app --sup
mix francis.new my_app --sup MyApp
```
Use `mix help francis.new` to see all the available options.
## Usage
To start the server up you can run `mix francis.server` or if you need a iex console you can run with `iex -S mix francis.server`.
## Deployment
To create the Dockerfile that can be used for deployment you can run:
```bash
mix francis.release
```
## Static Asset Management
Francis provides utilities for managing static assets, including content-based hashing for cache busting.
### Digest Task
The `mix francis.digest` task generates digested versions of static files with content-based hashes in their filenames:
```bash
mix francis.digest
mix francis.digest priv/static
mix francis.digest priv/static --output priv/static
```
Options:
- `--output` - The output path for generated files (defaults to input path)
- `--age` - Cache control max age in seconds (defaults to 31536000, 1 year)
- `--gzip` - Generate gzipped files (defaults to true)
- `--exclude` - File patterns to exclude (e.g., `--exclude '*.txt' --exclude '*.json'`)
### Static Module
The `Francis.Static` module provides functions to work with digested assets:
```elixir
# Get the digested path for an asset
Francis.Static.static_path("app.css")
# => "/app-a1b2c3d4.css"
# Check if an asset exists in the manifest
Francis.Static.exists?("app.css")
# => true
# Get all assets from the manifest
Francis.Static.all()
# => %{"app.css" => %{"digest" => "a1b2c3d4", ...}, ...}
```
## Configuration
You can configure Francis in your `config/config.exs` file. The following options are available:
- `dev` - If set to `true`, it will enable the development mode which will automatically reload the server when you change your code. Defaults to `false`.
- `bandit_opts` - Options to be passed to Bandit
- `static` - Configure Plug.Static to serve static files
- `parser` - Overrides the default configuration for Plug.Parsers
- `error_handler` - Defines a custom error handler for the server
- `log_level` - Sets the log level for Plug.Logger (default is `:info`)
```elixir
import Config
config :francis,
dev: false,
bandit_opts: [port: 4000],
static: [from: "priv/static", at: "/"],
parser: [parsers: [:json, :urlencoded], pass: ["*/*"]],
error_handler: &Example.error/2,
log_level: :info
```
You can also set the values in `use` macro:
```elixir
defmodule Example do
use Francis,
bandit_opts: [port: 4000],
static: [from: "priv/static", at: "/"],
parser: [parsers: [:json, :urlencoded], pass: ["*/*"]],
error_handler: &Example.error/2,
log_level: :info
end
```
Note: The `dev` option can only be set in your `config/config.exs` file, not in the `use` macro.
## Error Handling
By default, Francis will return a 500 error with the message "Internal Server Error" if you return a tuple `{:error, any()}` or an exception is raised during the request handling.
### Unmatched Routes
If a request does not match any defined route, you can use the `unmatched/1` macro to define a custom response:
```elixir
unmatched(fn _conn -> "not found" end)
```
### Custom Error Responses
For more advanced error handling, you can setup a custom error handler by providing the function that will handle the errors of your application:
```elixir
defmodule Example do
use Francis, error_handler: &__MODULE__.error/2
get("/", fn _ -> {:error, :custom_error} end)
def error(conn, {:error, :custom_error}) do
# Return a custom response
Plug.Conn.send_resp(conn, 502, "Custom error response")
end
end
```
If you do not handle errors explicitly, Francis will catch them and return a 500 response.
## Example of a router
```elixir
defmodule Example do
use Francis
get("/", fn _ -> "<html>world</html>" end)
get("/:name", fn %{params: %{"name" => name}} -> "hello #{name}" end)
post("/", fn conn -> conn.body_params end)
ws("/ws", fn {:received, "ping"}, _socket -> {:reply, "pong"} end)
unmatched(fn _ -> "not found" end)
end
```
And in your `mix.exs` file add that this module should be the one used for
startup:
```elixir
def application do
[
extra_applications: [:logger],
mod: {Example, []}
]
end
```
This will ensure that Mix knows what module should be the entrypoint.
## WebSocket Support
Francis provides a simple DSL for WebSocket endpoints using the `ws/2` and `ws/3` macros.
### Basic Usage
```elixir
defmodule Example do
use Francis
# Simple echo server
ws("/echo", fn {:received, message}, _socket ->
{:reply, message}
end)
end
```
### Events
The handler receives different event types that can be pattern matched:
- `:join` - Sent when a client connects
- `{:close, reason}` - Sent when the connection closes
- `{:received, message}` - Regular WebSocket text messages from the client
### Socket State
The socket state map includes:
- `:id` - A unique identifier for the WebSocket connection
- `:transport` - The transport process for sending messages
- `:path` - The actual request path of the WebSocket connection
- `:params` - A map of path parameters extracted from the route
### Full Example with Lifecycle Events
```elixir
defmodule Chat do
use Francis
require Logger
ws("/chat/:room", fn
:join, socket ->
room = socket.params["room"]
{:reply, %{type: "welcome", room: room, id: socket.id}}
{:close, reason}, socket ->
Logger.info("Client #{socket.id} left: #{inspect(reason)}")
:ok
{:received, message}, socket ->
room = socket.params["room"]
{:reply, "[#{room}] #{message}"}
end)
end
```
### Options
- `:timeout` - The timeout for the WebSocket connection in milliseconds (default: 60_000)
- `:heartbeat_interval` - The interval in milliseconds between ping frames (default: 30_000). Set to `nil` to disable.
```elixir
ws("/ws", fn {:received, msg}, _socket -> {:reply, msg} end, heartbeat_interval: 10_000)
```
## Example of a router with Static serving
With the `static` option, you are able to setup the options for `Plug.Static` to serve static assets easily.
```elixir
defmodule Example do
use Francis, static: [from: "priv/static", at: "/"]
end
```
## Response Helpers
Francis provides convenient helper functions for common response types through the `Francis.ResponseHandlers` module, which is automatically imported when you `use Francis`.
### Redirect
```elixir
get("/old", fn conn -> redirect(conn, "/new") end)
get("/old", fn conn -> redirect(conn, 301, "/new") end)
```
### JSON
```elixir
get("/api/data", fn conn -> json(conn, %{message: "success"}) end)
get("/api/data", fn conn -> json(conn, 201, %{id: 123, created: true}) end)
```
### Text
```elixir
get("/text", fn conn -> text(conn, "Hello, World!") end)
get("/text", fn conn -> text(conn, 201, "Resource created") end)
```
### HTML
```elixir
get("/", fn conn -> html(conn, "<h1>Hello, World!</h1>") end)
get("/", fn conn -> html(conn, 201, "<h1>Created</h1>") end)
```
**Warning:** The `html/2` and `html/3` functions do not escape HTML content. Only use with trusted, static HTML content to avoid XSS vulnerabilities.
## Example of a router with Plugs
With the `plugs` option you are able to apply a list of plugs that happen
between before dispatching the request.
In the following example we're adding the `Plug.BasicAuth` plug to setup basic
authentication on all routes
```elixir
defmodule Example do
import Plug.BasicAuth
use Francis
plug(:basic_auth, username: "test", password: "test")
get("/", fn _ -> "<html>world</html>" end)
get("/:name", fn %{params: %{"name" => name}} -> "hello #{name}" end)
ws("/ws", fn {:received, "ping"}, _socket -> {:reply, "pong"} end)
unmatched(fn _ -> "not found" end)
end
```
## Example of multiple routers
You can also define multiple routers in your application by using the `forward/2` function provided by [Plug](https://hexdocs.pm/plug/Plug.Router.html#forward/2) .
For example, you can have an authenticated router and a public router.
```elixir
defmodule Public do
use Francis
get("/", fn _ -> "ok" end)
end
defmodule Private do
use Francis
import Plug.BasicAuth
plug(:basic_auth, username: "test", password: "test")
get("/", fn _ -> "hello" end)
end
defmodule TestApp do
use Francis
forward("/path1", to: Public)
forward("/path2", to: Private)
unmatched(fn _ -> "not found" end)
end
```
Check the folder [examples](https://github.com/francis-build/francis/tree/main/examples) to see examples of how to use Francis.