Current section
Files
Jump to
Current section
Files
exact_online
README.md
README.md
# exact_online
[](https://hex.pm/packages/exact_online)
[](https://hexdocs.pm/exact_online)
An Elixir client for the [Exact Online](https://www.exact.com) REST API, built
on [Req](https://hex.pm/packages/req).
It handles the parts of the API that are easy to get wrong: OAuth2 with a
rotating refresh token, the OData `d` envelope, cursor pagination, and the
rate limits Exact Online reports per division.
## Installation
```elixir
def deps do
[{:exact_online, "~> 0.1"}]
end
```
## Quickstart
Register an app in the [Exact App Center](https://apps.exactonline.com) to get
a client id, a client secret and a redirect URI.
```elixir
credentials = [
client_id: System.fetch_env!("EXACT_CLIENT_ID"),
client_secret: System.fetch_env!("EXACT_CLIENT_SECRET"),
redirect_uri: "https://example.com/oauth/callback",
region: :nl
]
# 1. Send the user to this URL.
Exact.OAuth.authorize_url(credentials)
# 2. Exchange the `code` from the callback and store the token.
{:ok, token} = Exact.OAuth.exchange_code(code, credentials)
:ok = Exact.TokenStore.ETS.put(user_id, token)
# 3. Build a client that refreshes and re-stores the token for you.
client =
Exact.new(
token_store: Exact.TokenStore.ETS,
token_key: user_id,
credentials: credentials
)
# 4. Every other endpoint needs a division.
{:ok, division} = Exact.System.Me.division(client)
client = Exact.Client.put_division(client, division)
{:ok, page} = Exact.CRM.Account.list(client, select: ["ID", "Name"], top: 25)
page.results
```
There is a runnable walkthrough in
[`notebooks/exact_online.livemd`](notebooks/exact_online.livemd).
## OAuth2
Exact Online access tokens are valid for ten minutes and refresh tokens for
thirty days. The refresh token **rotates on every refresh**: each refresh
returns a new one and invalidates the old one, so it has to be persisted or the
grant is lost and the user has to authorize again.
That is what `Exact.TokenStore` is for. `Exact.TokenStore.ETS` keeps tokens in
memory, which is fine for scripts, Livebook and tests. In an application,
implement the behaviour against your database:
```elixir
defmodule MyApp.ExactTokens do
@behaviour Exact.TokenStore
@impl true
def fetch(user_id) do
case MyApp.Repo.get(MyApp.ExactToken, user_id) do
nil -> :error
record -> {:ok, MyApp.ExactToken.to_token(record)}
end
end
@impl true
def put(user_id, token) do
MyApp.ExactToken.upsert!(user_id, token)
:ok
end
end
```
Given a store, the client refreshes the token when it is within a minute of
expiring, and once more if the API answers with a 401. Concurrent requests
that find an expired token take a lock, so the grant is not spent twice.
You can also skip all of this and pass `access_token:` directly, in which case
nothing is refreshed.
## Regions
Exact Online runs one installation per country. An account created in the Dutch
installation is not reachable through the German host.
| Region | Host |
| --- | --- |
| `:nl` (default) | `start.exactonline.nl` |
| `:be` | `start.exactonline.be` |
| `:de` | `start.exactonline.de` |
| `:uk` | `start.exactonline.co.uk` |
| `:fr` | `start.exactonline.fr` |
| `:es` | `start.exactonline.es` |
| `:us` | `start.exactonline.com` |
Pass `base_url:` for a host that is not listed.
## Divisions
A division is one administration inside an account, and every endpoint except
`Exact.System.Me` is scoped to one. Set it once with `division:` on
`Exact.new/1`, or rescope an existing client with
`Exact.Client.put_division/2`. Relative paths get the division prefix; a path
starting with `/api` is used as-is.
## Querying
Query options map onto the OData parameters:
```elixir
Exact.CRM.Account.list(client,
select: ["ID", "Name", "Email"],
filter: "Status eq " <> Exact.Query.string("C"),
orderby: ["Name asc"],
top: 50,
inlinecount: "allpages"
)
```
`$filter` is picky about literals, so use `Exact.Query.string/1`,
`Exact.Query.guid/1` and `Exact.Query.datetime/1` rather than interpolating.
## Pagination
Exact Online returns 60 records per page (1000 for the bulk and sync
endpoints) and pages with a cursor, not an offset. `list/2` gives you one page
and its `next` cursor; `stream/2` follows the cursor lazily:
```elixir
client
|> Exact.CRM.Account.stream(select: ["ID", "Name"])
|> Stream.map(& &1["Name"])
|> Enum.take(500)
```
Every page is a request, so narrow the stream with `:select` and `:filter`.
`stream/2` raises `Exact.Error` on failure, because a stream has nowhere to put
an error tuple.
## Errors
Everything returns `{:ok, result}` or `{:error, %Exact.Error{}}`; the bang
variants raise. Match on `:reason` rather than `:status`, so transport
failures are covered too:
```elixir
case Exact.CRM.Account.get(client, id) do
{:ok, account} -> account
{:error, %Exact.Error{reason: :not_found}} -> nil
{:error, %Exact.Error{reason: :rate_limited, retry_after: seconds}} -> {:retry_in, seconds}
{:error, error} -> {:error, error}
end
```
Transient failures (429 and 5xx on safe methods) are retried up to three times,
honoring `Retry-After`. Tune it through `req_options: [retry: ..., max_retries: ...]`.
## Rate limits
Exact Online enforces a daily and a minutely limit per division and reports the
state on every response. The ceilings depend on your agreement, so the client
reports what the API says instead of assuming a number.
```elixir
{:ok, page} = Exact.CRM.Account.list(client, top: 1)
page.rate_limit.minutely_remaining
```
`Exact.Error` carries the same struct, so a rate limited caller can back off.
## Resources
Exact Online exposes around 600 resources. This library ships modules for the
ones most integrations start with:
`Exact.System.Me`, `Exact.System.Division`, `Exact.CRM.Account`,
`Exact.CRM.Contact`, `Exact.Sales.SalesInvoice`, `Exact.Financial.GLAccount`.
For the rest, either call the path directly:
```elixir
Exact.Client.list(client, "logistics/Items", select: ["ID", "Code"])
Exact.Client.stream(client, "bulk/CRM/Accounts")
```
Or generate a module, which is what the shipped ones do:
```elixir
defmodule MyApp.Exact.Item do
@moduledoc "See [Items](https://start.exactonline.nl/docs/HlpRestAPIResourcesDetails.aspx?name=LogisticsItems)."
use Exact.Resource, service: "logistics", resource: "Items"
end
MyApp.Exact.Item.list(client, select: ["ID", "Code", "Description"], top: 25)
```
That gives you `list/2`, `stream/2`, `get/3`, `create/2`, `update/3` and
`delete/2`. See `Exact.Resource` for the options, including read-only resources
and non-GUID primary keys.
Records are plain maps keyed by the field names Exact Online uses. Nothing is
renamed, so the
[reference documentation](https://start.exactonline.nl/docs/HlpRestAPIResources.aspx)
applies as written.
## Not covered
The XML API and webhook signature verification are not wrapped. The bulk and
sync endpoints are reachable as ordinary paths but have no dedicated helpers.
## Testing your own code
The client takes `req_options:`, so point it at a
[`Req.Test`](https://hexdocs.pm/req/Req.Test.html) stub:
```elixir
client = Exact.new(division: 123_456, access_token: "test", req_options: [plug: {Req.Test, MyApp.Exact}])
Req.Test.stub(MyApp.Exact, fn conn ->
Req.Test.json(conn, %{"d" => %{"results" => [%{"ID" => "abc", "Name" => "Paradiso"}]}})
end)
```
## Development
```
mix deps.get
mix test
mix check # format, credo, test, dialyzer
mix docs
```
## License
MIT. See the `LICENSE` file.