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.
Add ex_picnic to your dependencies:
def deps do
[
{:ex_picnic, "~> 0.1"}
]
endThe package is ex_picnic; the modules are Picnic.*.
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.
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:
token = client.auth_token
client = Picnic.Client.new(auth_token: token)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:
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:
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.
This is the part worth reading twice.
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:
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.
Responses are decoded to plain maps, which always works. Pass as: :struct for
a loose typed struct from Picnic.Schema.*:
{:ok, cart} = Picnic.get_cart(client, as: :struct)
cart.items # a modelled field
cart.raw # the complete, untouched payloadStruct 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}}.
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.
Everything Picnic might change is data, so an API bump is a configuration change rather than a code change:
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.
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:
{: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.
[: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.
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.
mix testMIT. See LICENSE.
This project is not affiliated with, endorsed by, or connected to Picnic.