Packages

Client for OpenFeed (Australian CDR banking and energy data). Handles the FAPI 2.0 Security Profile — PAR, PKCE, DPoP — and key management.

Current section

Files

Jump to
openfeed README.md
Raw

README.md

# OpenFeed
An Elixir client for [OpenFeed](https://openfeed.au) — Australian Consumer Data
Right (CDR) banking and energy data.
OpenFeed implements the [FAPI 2.0 Security
Profile](https://openid.bitbucket.io/fapi/fapi-security-profile-2_0.html). This
library's job is to make that a detail you don't think about:
- **PAR** — pushed authorization requests, so the front channel only ever
carries a single-use `request_uri`.
- **PKCE S256** on every code exchange.
- **DPoP** (RFC 9449, `PS256`) — sender-constrained tokens, with a freshly
signed proof on every call.
- **`private_key_jwt`** client authentication, and the key management that
implies.
> Using [Ash](https://ash-hq.org)? You probably want `ash_openfeed`, which
> builds on this package and adds grant persistence, token refresh, and an
> installer.
## Requirements
- **Elixir 1.17 or later**
- **OTP 27 or later** — this is a hard floor, inherited from
[`oidcc`](https://hex.pm/packages/oidcc), whose Erlang source does not compile
on OTP 26. Both packages check for it and fail with a clear message rather than
letting you hit an unexplained dependency compile error.
Tested in CI against Elixir 1.17, 1.18, 1.19 and 1.20.
## Installation
```elixir
def deps do
[{:openfeed, "~> 0.1"}]
end
```
## Getting set up
**1. Generate a keypair.** OpenFeed's Recommended profile authenticates you with
a key, not a secret. The same key signs DPoP proofs.
```sh
mix openfeed.gen.key --path priv/openfeed.jwk
```
**2. Register the public half.** Print the JWKS and add it to your app
registration at <https://app.openfeed.au/registered-apps>:
```sh
mix openfeed.jwks --path priv/openfeed.jwk
```
**3. Build a config.**
```elixir
config =
OpenFeed.Config.new!(
client_id: System.fetch_env!("OPENFEED_CLIENT_ID"),
redirect_uri: "https://my.app/openfeed/callback",
key_store: {OpenFeed.KeyStore.File, path: "priv/openfeed.jwk"},
scopes: [:banking, :energy]
)
```
**4. Start the provider metadata worker** in your supervision tree, so OIDC
discovery and the JWKS are fetched once and cached rather than on every call:
```elixir
children = [
{OpenFeed.ProviderConfiguration, config}
# ...
]
```
## Collecting data
Every read goes through `OpenFeed.Sharing`, which has one function per endpoint.
Each takes the config and an access token.
### How the data is shaped
Both domains are trees, and the nesting is the actual work:
```
banking/accounts ─┬─ accounts/{id}/balance on demand, slow
└─ accounts/{id}/transactions paginated, date-filterable
energy/accounts ─┬─ accounts/{id}/balance on demand, slow
├─ accounts/{id}/invoices
├─ accounts/{id}/billing
└─ accounts/{id}/meters ─┬─ .../usage paginated, date-filterable
└─ .../der
```
### A complete read
```elixir
{:ok, accounts} = OpenFeed.Sharing.banking_accounts(config, tokens)
for account <- accounts do
id = account["accountId"]
{:ok, balance} = OpenFeed.Sharing.banking_balance(config, tokens, id)
{:ok, transactions} =
OpenFeed.Sharing.banking_transactions(config, tokens, id,
oldest_date: Date.add(Date.utc_today(), -365)
)
IO.puts("#{account["displayName"]}: #{length(transactions)} transactions")
IO.puts(" balance #{OpenFeed.Amount.to_decimal(balance["currentBalance"])} #{balance["currency"]}")
end
```
Energy is one level deeper:
```elixir
{:ok, accounts} = OpenFeed.Sharing.energy_accounts(config, tokens)
for account <- accounts, id = account["accountId"] do
{:ok, meters} = OpenFeed.Sharing.energy_meters(config, tokens, id)
for meter <- meters do
{:ok, days} =
OpenFeed.Sharing.energy_usage(config, tokens, id, meter["meterId"],
oldest_date: Date.add(Date.utc_today(), -90)
)
total = days |> Enum.map(&OpenFeed.Energy.net_usage/1) |> Enum.reduce(&Decimal.add/2)
IO.puts("#{meter["nationalMeteringId"]}: #{total} kWh net over #{length(days)} days")
end
end
```
Pass the `OpenFeed.Tokens` struct, not a bare access-token string — it carries
the `token_type` OpenFeed issued, which is the only reliable source of the
authorization scheme. See `OpenFeed.Client.token_type/2`.
### Two traps, both handled for you
**Money has two wire formats.** Banking amounts are ISO 20022 strings
(`"-52.00"`); energy amounts are JSON numbers (`193.8`). `Decimal.new/1` works
on one and raises on the other, so whichever domain you test second breaks. Use
`OpenFeed.Amount.to_decimal/1`, which takes either — and never hold money in a
float.
**Usage reads carry a discriminator.** `reads[].readUType` selects
`basicRead` (a single total, from a daily-read meter) or `intervalRead` (an
aggregate plus per-interval values). Reading only the interval variant works
against an interval meter and silently reports **zero** for every daily meter.
Use `OpenFeed.Energy.net_usage/1`.
`net_usage/1` returns `0` when OpenFeed returns no reads at all, which keeps
sums free of `nil` guards but collapses "no data" into "zero usage". Use
`OpenFeed.Energy.has_reads?/1` when the difference matters — a gap in a chart is
more honest than a plotted zero.
### Large collections
Collection functions return every page. One real account produced **3,947
transactions** for a single year, so for anything unbounded stream instead:
```elixir
OpenFeed.Client.stream(config, tokens, "/v1/banking/accounts/#{id}/transactions")
|> Stream.each(&store!/1)
|> Stream.run()
```
`stream/4` fetches a page at a time and raises `OpenFeed.Error` on failure,
since a lazy stream has nowhere to put an error tuple.
### When a call fails
Branch on `OpenFeed.Error`'s `:kind`, never on the HTTP status — OpenFeed
returns 403 both for a withdrawn consent and for a subject mismatch, and
conflating them marks healthy grants dead.
```elixir
case OpenFeed.Sharing.banking_accounts(config, tokens) do
{:ok, accounts} ->
accounts
{:error, %OpenFeed.Error{kind: :grant_revoked}} ->
:stop_syncing_and_ask_them_to_reconnect
{:error, %OpenFeed.Error{kind: :credit_exhausted}} ->
:your_openfeed_credit_ran_out
{:error, %OpenFeed.Error{} = error} ->
if OpenFeed.Error.retryable?(error), do: :try_later, else: :give_up
end
```
In a sync that touches many endpoints, collect errors and keep going rather than
abandoning the run — one unavailable balance should not cost you a year of
transactions. See the [example app](https://github.com/benkolera/ash-openfeed/tree/main/examples/openfeed_demo) for that shape.
### Field reference
This library returns decoded maps with the keys OpenFeed sends, and does not
restate the schema — that would only drift. The authoritative reference is
public:
- **API reference**<https://openfeed.au/developers/api>
- **OpenAPI spec**<https://openfeed.au/openapi.yaml>
- **Quickstart**<https://openfeed.au/resources/quickstart>
- **For coding agents**<https://openfeed.au/llms.txt>
There is no `rawJson` passthrough: responses carry explicitly typed,
allow-listed fields only.
### What this library does not do
It does not mirror CDR data into a schema for you. How much history to keep, how
to model it and how often to refresh are application decisions, and OpenFeed's
own refresh cadence (banking ~4h, energy ~6h) is the practical ceiling on how
often re-reading is worthwhile. See the
[Collecting data guide](documentation/tutorials/collecting-data.md) and
[Cost and cadence](documentation/topics/cost-and-cadence.md), and
`ash_openfeed` if you are on Ash.
## Configuration is injected, not read
Nothing in this package reads `Application.get_env/2`. Every entry point takes
an `OpenFeed.Config`.
That is deliberate. It means the FAPI 2.0 surface is testable against a stub
with no application environment, and an application can hold more than one
OpenFeed registration — one per tenant, say — without this library knowing
anything about it. Where the values come from is your decision.
## Key management
The one thing this library refuses to do is quietly generate a key for you.
A store that lazily generates on first read works beautifully on one machine
and breaks on two: each node mints a *different* key, signs proofs with it, and
OpenFeed rejects them because that key was never registered. The failure is
intermittent and depends on which node the load balancer picked, which makes it
genuinely painful to diagnose.
So key creation is always explicit, and you pick where keys live:
| Store | Multi-node safe | Use when |
|---|---|---|
| `OpenFeed.KeyStore.Env` | yes | Releases, 12-factor deploys. Key supplied out of band. |
| `AshOpenFeed.KeyStore.Ash` | yes | You want it in your database, optionally encrypted. |
| `OpenFeed.KeyStore.File` | **no** | Local development, or genuinely shared storage. |
Bring your own by implementing the `OpenFeed.KeyStore` behaviour.
## License
Apache-2.0