Packages

Send push notifications through a Faro server: per-user and tag audiences, delivery counts, no retries.

Current section

Files

Jump to
faro_ex README.md
Raw

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/`.