Packages
Send push notifications through a Faro server: per-user and tag audiences, delivery counts, no retries.
Current section
Files
Jump to
Current section
Files
README.md
# faro_ex
Send push notifications through a [Faro](../README.md) server from Elixir.
Faro is a push-only notification service: you manage apps, devices and tags there, and this
library covers the part a backend needs — **sending** a notification and **reading back what
was sent**. Device registration, tags from the phone and open reporting belong to the mobile
SDK, [`@faro/react-native`](../sdk/README.md).
- Plain maps in and out: the wire shape is the shape, no structs to learn.
- `FaroEx.Audience` helpers make tag and per-user targeting readable.
- Failures are **returned**, never raised: `{:error, %FaroEx.Error{status: 403, code: "forbidden"}}`.
- **No retries**, deliberately — see [No retries](#no-retries).
## Install
```elixir
# mix.exs
def deps do
[{:faro_ex, "~> 0.1.0"}]
end
```
Until `0.1.0` is on hex.pm, point at the checkout instead:
```elixir
{:faro_ex, path: "../faro/sdk_ex"}
```
Requires Elixir `~> 1.15`.
## Setup
**1. Mint a least-privilege key.** In the Faro dashboard, open the app → *API keys* → create a
key with exactly `notifications:read` and `notifications:write`. A key holding `apps:write` is
a superuser for the app, and this library never needs one.
The token looks like `<appId>.<secret>`. Keep it out of the repo — `config/runtime.exs` reading
`System.fetch_env!/1` is the usual place:
```elixir
# config/runtime.exs
config :my_app, :faro,
base_url: System.get_env("FARO_BASE_URL", "https://push.example.com"),
api_key: System.fetch_env!("FARO_KEY")
```
**2. Build the client where you have config** — a client is stateless data, so there is
nothing to supervise, and reading the key at runtime keeps it out of the compile artifacts:
```elixir
# lib/my_app/push.ex
defmodule MyApp.Push do
@moduledoc false
def client do
config = Application.fetch_env!(:my_app, :faro)
FaroEx.client(base_url: config[:base_url], api_key: config[:api_key])
end
end
```
There is no application-environment default inside the library on purpose: an explicit client
is the thing that still works the day a second app or tenant appears.
**3. The app id comes from the token.** `FaroEx.client/1` reads it out of `<appId>.<secret>`,
so a key and an app id can never disagree. It raises `ArgumentError` for anything that is not
an app key — an operator token, or a public client key (`yk_…`) — so a bad paste fails at boot
instead of as a 403 at 3am.
**4. The server must support `externalUserIds`.** Per-user sends use the audience selector
field added in Faro's task #1871. Against an older Faro, `FaroEx.Audience.users/1` comes back
as a `validation_error`.
## Sending
```elixir
alias FaroEx.Audience
FaroEx.send(MyApp.Push.client(),
title: "Your order shipped",
body: "Tracking is available now",
data: %{"screen" => "orders", "orderId" => "A-1042"},
audience: %{"include" => [Audience.users(["42"])]}
)
#=> {:ok, %{
# notification: %{"id" => "ntf_…", "status" => "completed", …},
# delivery: %{"total" => 1, "pending" => 0, "sent" => 1, "failed" => 0, …}
# }}
```
Options: `:title`, `:subtitle`, `:body`, `:data`, `:priority` (`"low" | "normal" | "high"`),
`:ttl`, `:url`, `:icon`, `:image`, `:launch_url`, `:channel_id`, `:collapse_id`,
`:send_after_seconds`, `:audience`. Anything you leave out is not sent. An option the library
does not know raises rather than being dropped, because a silently ignored `:titel` looks
exactly like a notification with no title.
### Audiences
Include selectors are unioned; `exclude` selectors are subtracted; an empty `include` means
every subscribed device.
```elixir
audience: %{
"include" => [
Audience.users(["42", "43"]), # %{"externalUserIds" => [...]}
Audience.tag("plan", "pro"), # plan equals pro
Audience.tag("country", :in, ["es", "mx"])
],
"exclude" => [Audience.tag("plan", :not_in, ["trial"])]
}
```
Operators: `:equals` (default), `:not_equals`, `:contains`, `:starts_with`, `:in`, `:not_in`,
`:exists` — the camelCase wire spelling is accepted too. Helpers return plain maps and
**validate nothing**: the server's schemas are the contract, and a second validator would drift.
Inside one selector, `deviceIds` and `externalUserIds` *name* devices and are OR-ed together;
`platforms` and `filters` narrow that set with AND. So a selector can be
`%{"externalUserIds" => ["42"], "platforms" => ["ios"]}` — "user 42, on iOS only".
An external user id with no device matches nothing, and the send is still `{:ok, _}` with
`"total" => 0`. That is not an error: people uninstall apps.
## Reading back
```elixir
{:ok, %{notification: notification, deliveries: deliveries}} =
FaroEx.get_notification(client, notification["id"])
{:ok, failed} = FaroEx.deliveries(client, notification["id"], status: "failed", limit: 50)
```
`send/2` returns a snapshot: provider retries and delayed sends keep running on the server.
Poll `get_notification/2` when you need the final tally.
## Errors
```elixir
case FaroEx.send(client, body: "hi", audience: audience) do
{:ok, %{delivery: %{"sent" => sent}}} -> sent
{:error, %FaroEx.Error{status: 403}} -> :not_allowed
{:error, %FaroEx.Error{code: "credentials_not_configured"}} -> :configure_apns_first
{:error, %FaroEx.Error{status: nil, code: "transport_error"} = error} -> Logger.warning(Exception.message(error))
end
```
`code` is the server's own string, passed through verbatim: `validation_error`, `unauthorized`,
`forbidden`, `not_found`, `conflict`, `credentials_not_configured`, `bad_request`,
`internal_error`. A transport failure (refused connection, timeout) arrives with `status: nil`
and `code: "transport_error"`. Codes are never turned into atoms.
## No retries
Faro's send endpoint has **no idempotency key**, so a retry after a timeout can push the same
notification to real phones twice. This library therefore makes exactly one request and returns
what happened; `retry: false` is set on the underlying HTTP client so nothing creeps in
underneath. Retry in your own code only if a duplicate is acceptable.
## It blocks, so supervise it
`send/2` waits for the server's inline drain: one provider request per device, so a ~1000-user
audience takes roughly 10–20 seconds. Do not run it inside a bare Phoenix request; start it
under a `Task.Supervisor` and broadcast the result over PubSub.
```elixir
Task.Supervisor.start_child(MyApp.TaskSupervisor, fn ->
result = FaroEx.send(MyApp.Push.client(), title: "…", body: "…", audience: audience)
Phoenix.PubSub.broadcast(MyApp.PubSub, "notifications", {:sent, result})
end)
```
Timeouts default to `connect_timeout: 5_000` and `receive_timeout: 120_000` (milliseconds) and
are options on `FaroEx.client/1`.
## Telemetry
One event triplet per request:
```elixir
:telemetry.attach_many(
"faro-ex-logger",
[[:faro_ex, :request, :start], [:faro_ex, :request, :stop], [:faro_ex, :request, :exception]],
&__MODULE__.handle_faro_event/4,
nil
)
```
`stop` carries `%{duration: native_time}` with metadata `%{method, path, status}`; `exception`
carries `%{kind, reason, stacktrace}`. `status` is `nil` for a transport failure.
## Development
```sh
mix deps.get
mix test # 25 tests, Bypass-backed: a real local HTTP server
mix format --check-formatted
mix compile --warnings-as-errors
```
Tests never touch a Faro instance. `Bypass` stands up a real Cowboy server on a random port, so
requests exercise real `Req`, real encoding and real decoding — there is no test-only transport
seam in `lib/`.