Current section
Files
Jump to
Current section
Files
README.md
# Picnic
[](https://hex.pm/packages/ex_picnic)
[](https://hexdocs.pm/ex_picnic)
[](https://github.com/emischorr/ex_picnic/actions/workflows/ci.yml)
[](LICENSE)
An Elixir client for Picnic's supermarket API.
> **This is an unofficial client for an undocumented API.** Picnic does not
> publish, support, or version this API for third parties, and changes it
> without notice — so this library *will* break from time to time. Using it may
> also violate Picnic's terms of service. Be a good citizen: don't hammer the
> endpoints, and don't build anything you can't afford to see stop working.
Because breakage is the expected case rather than an exception, the whole
library is built around it: nothing hard-fails on an unfamiliar payload, every
endpoint is reachable even if this library doesn't model it, and the original
response is always kept within reach.
## Installation
Add `ex_picnic` to your dependencies:
```elixir
def deps do
[
{:ex_picnic, "~> 0.1"}
]
end
```
The package is `ex_picnic`; the modules are `Picnic.*`.
## Quick start
```elixir
client = Picnic.Client.new(country: :nl)
{:ok, client} = Picnic.login(client, "user@example.com", "hunter2")
{:ok, results} = Picnic.search(client, "melk")
{:ok, cart} = Picnic.add_to_cart(client, "s1019822", count: 2)
{:ok, deliveries} = Picnic.deliveries(client)
```
The client is a plain struct — no process, no global state, nothing to
supervise. Every function takes it as the first argument; the ones that change
it (login) hand you a new one back.
Every function returns `{:ok, result}` or `{:error, %Picnic.Error{}}`. Bang
variants (`Picnic.search!/3`, `Picnic.get_cart!/2`, …) raise instead, for
scripts and pipelines where crashing is fine.
## Authentication
Picnic expects an MD5 hash of the password. That is *their* scheme, not a
choice made here — `Picnic.login/3` hashes for you, and
`Picnic.Auth.hash_password/1` is public if you'd rather store the hash than the
password.
The token comes back in the `x-picnic-auth` response header and is stored on the
client struct as plain data. Persist it and skip the login next time:
```elixir
token = client.auth_token
client = Picnic.Client.new(auth_token: token)
```
### Two-factor authentication
2FA is modelled explicitly rather than handled invisibly. A login that needs it
returns `{:error, %Picnic.Error{category: :auth, reason: :two_factor_required}}`
— an error, not a client. That error carries the provisional client, so feed the
login result straight into the handshake:
```elixir
client = Picnic.Client.new(country: :de)
result = Picnic.login(client, "jane@example.com", "secret")
# {:error, %Picnic.Error{reason: :two_factor_required}}
:ok = Picnic.Auth.generate_2fa(result) # sends the code
{:ok, client} = Picnic.Auth.verify_2fa(result, "123456")
```
`send_2fa: true` folds the code request into the login, if you'd rather not make
the call yourself:
```elixir
result = Picnic.login(client, "jane@example.com", "secret", send_2fa: true)
{:ok, client} = Picnic.Auth.verify_2fa(result, "123456")
```
It is opt-in because sending a code is a side effect: the default keeps one login
to one request, and leaves `:channel` (default `"SMS"`) your choice. Both 2FA
functions also take a plain client, and pass any other error through untouched,
so a failed login propagates instead of being masked. To carry the handshake
across processes rather than piping the result, the provisional token is on the
error as `error.raw.auth_token`.
## When the API drifts
This is the part worth reading twice.
### The escape hatch
`Picnic.request/4` reaches **any** endpoint — including ones this library
hasn't modelled and ones Picnic adds tomorrow. It is a first-class part of the
public API, not a debug affordance, so you never have to wait for a release:
```elixir
Picnic.request(client, :get, "/user")
Picnic.request(client, :post, "/cart/add_product", json: %{product_id: id, count: 2})
```
The named functions are conveniences layered on top of it. They are never the
only way in.
### Plain maps by default, structs on request
Responses are decoded to plain maps, which always works. Pass `as: :struct` for
a loose typed struct from `Picnic.Schema.*`:
```elixir
{:ok, cart} = Picnic.get_cart(client, as: :struct)
cart.items # a modelled field
cart.raw # the complete, untouched payload
```
Struct decoding is deliberately forgiving: unknown fields are ignored, missing
fields become `nil`, and a field with a surprising type is kept verbatim rather
than rejected. When that happens you get the partial struct **plus** a
`Logger.warning` and a `[:picnic, :schema, :drift]` telemetry event — a signal,
not a failure. Anything the structs don't model is still in `:raw`.
Only a body that can't be parsed at all (an HTML error page, say) becomes
`{:error, %Picnic.Error{category: :decode}}`.
## Errors
`Picnic.Error` is categorised so you can match on classes of failure instead of
picking apart status codes:
| Category | Meaning | Retried |
| ------------- | ----------------------------------------------------- | -------------- |
| `:network` | transport failure — timeout, DNS, refused connection | yes |
| `:auth` | `:unauthorized`, `:forbidden`, `:two_factor_required` | no |
| `:rate_limit` | HTTP 429 | with backoff |
| `:http` | any other non-success status | 5xx only |
| `:decode` | the body couldn't be parsed at all | no |
| `:schema` | parsed, but the overall shape was unusable | no |
Transient failures (network errors, 5xx, 429) are retried automatically with
backoff. `:auth` and other 4xx responses never are.
The `:raw` field carries the response body or exception behind the error — the
first place to look when something unexpected happens.
## Configuration
Everything Picnic might change is data, so an API bump is a configuration
change rather than a code change:
```elixir
client =
Picnic.Client.new(
country: :nl,
api_version: "16",
headers: %{"x-picnic-agent" => "..."},
req_options: [retry: :safe_transient]
)
```
See `Picnic.Client.new/1` for the full option list. Application environment
under `:ex_picnic` supplies *defaults* only — the core never reads global config
at request time, so a client always behaves the way it was built.
## Optional: managed sessions
The functional core is complete on its own. If you want a long-lived handle
that re-authenticates for you, `Picnic.Session` is a GenServer layered on top:
```elixir
{:ok, session} =
Picnic.Session.start_link(country: :nl, email: "user@example.com", password: "hunter2")
{:ok, cart} = Picnic.Session.get_cart(session)
{:ok, hits} = Picnic.Session.run(session, &Picnic.search(&1, "melk"))
```
On an `:auth` error it re-logs-in **once** and retries. Login is lazy, so
starting a session never blocks on the network. Tokens can survive restarts
through a `Picnic.Session.TokenStore`; an in-memory store ships as the default.
None of this leaks into the core — the session is a thin, replaceable
convenience.
## Telemetry
```
[:picnic, :request, :start | :stop | :exception]
[:picnic, :schema, :drift]
```
Attach to the drift event in production. It is how you find out the API changed
before your users do.
## Testing
The test suite never touches the real API. Requests are stubbed with `Req.Test`
against sanitised fixtures, which exercises real request building — paths,
headers, password hashing, JSON bodies — as well as decoding.
```sh
mix test
```
## License
MIT. See [LICENSE](LICENSE).
This project is not affiliated with, endorsed by, or connected to Picnic.