Skip to content

Repository files navigation

Picnic

Hex.pm Documentation CI 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:

def deps do
  [
    {:ex_picnic, "~> 0.1"}
  ]
end

The package is ex_picnic; the modules are Picnic.*.

Quick start

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:

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:

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.

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:

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.*:

{: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:

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:

{: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.

mix test

License

MIT. See LICENSE.

This project is not affiliated with, endorsed by, or connected to Picnic.

About

No description, website, or topics provided.

Resources

Stars

Watchers

Forks

Releases

Packages

Contributors

Languages