DEV Community

Piece moto occasion
Piece moto occasion

Posted on

One API client, seven registries: what actually broke

We run a small French marketplace for used motorcycle parts. Sellers are scrapyards, and scrapyards have every stock system imaginable: PrestaShop, Odoo, Dolibarr, an Excel file, a nephew with a Python script. So our seller API needed clients in whatever they already use.

Over one week we published the same client on PyPI, npm, JSR, RubyGems, NuGet, Docker Hub and the Snap Store. The code was the easy part. Here is what each registry actually demanded, and what we got wrong.

The API is deliberately tiny

Seven endpoints, one Bearer token, one webhook.

GET    /moi                          account, status, commission
GET    /produits                     your parts
PUT    /produits                     create or update, 200 per call
PATCH  /produits/{ref}/stock         {"stock": 3}
DELETE /produits/{ref}               withdraw (stock 0, never deleted)
GET    /commandes?depuis=YYYY-MM-DD  paid orders with shipping address
POST   /commandes/{id}/expedier      carrier + tracking number
Enter fullscreen mode Exit fullscreen mode

Two design decisions shaped every client:

The seller's own SKU is the identifier. PUT /produits with a known reference updates; an unknown one creates. Clients never need to store our IDs.

Batches of 200, and a refused row does not fail the batch. The response carries produits (accepted) and erreurs (refused, with a reason). A thousand rows is five calls, and one bad price doesn't block 999 good parts.

The webhook is signed: X-Pmo-Signature: sha256=<HMAC-SHA256 of the raw body, key = your token>. Every client ships a verifier, and every verifier compares in constant time.

What each registry wanted

PyPI — publish from CI, no token anywhere

PyPI's trusted publishing is the nicest flow of the seven. You register owner/repo, the workflow filename and an environment name once; after that a tagged release publishes with no secret stored anywhere.

Two traps:

  • The workflow filename is part of the identity. We registered publier-pypi.yml, then renamed the file. Every publish failed with invalid-publisher until the names matched again.
  • The form for a project that doesn't exist yet is the pending publisher form, on your account page — not on the project page, which doesn't exist.

We also learned that pip on macOS 26 is currently broken for some Python builds (platform.mac_ver() returns an empty string and truststore crashes). Build in CI, not on the laptop.

npm — 2FA is mandatory now

npm publish returned 403 until the account had two-factor auth. A passkey works: npm publish then opens a browser to confirm instead of asking for --otp. No token juggling.

We published two packages: the client, and an n8n community node. n8n lists any npm package with the n8n-community-node-package keyword — the cheapest "integration marketplace" listing we found, with no user-count threshold unlike Zapier.

JSR — write TypeScript, or it won't document you

JSR's "slow types" check refuses to generate docs for an untyped JavaScript API. So the JSR package is a separate TypeScript port, with one useful constraint: no Node built-ins. We replaced node:crypto with crypto.subtle, which made the same file run on Deno, Node 18+, Bun, Cloudflare Workers and in a browser.

async signatureValide(corps: string | Uint8Array, entete: string | null): Promise<boolean> {
  if (!entete) return false;
  const octets = typeof corps === "string" ? new TextEncoder().encode(corps) : corps;
  const cle = await crypto.subtle.importKey("raw", new TextEncoder().encode(this.#jeton),
    { name: "HMAC", hash: "SHA-256" }, false, ["sign"]);
  const empreinte = new Uint8Array(await crypto.subtle.sign("HMAC", cle, octets));
  const attendu = "sha256=" + [...empreinte].map(o => o.toString(16).padStart(2, "0")).join("");
  if (attendu.length !== entete.length) return false;
  let difference = 0;
  for (let i = 0; i < attendu.length; i++) difference |= attendu.charCodeAt(i) ^ entete.charCodeAt(i);
  return difference === 0;
}
Enter fullscreen mode Exit fullscreen mode

jsr publish refuses to run from a dirty git tree, even when the dirt is in a parent directory unrelated to the package. --allow-dirty is fine when you know why.

RubyGems — zero dependencies, and one missing function

net/http, json, openssl: the gem has no runtime dependency at all. One surprise: OpenSSL.fixed_length_secure_compare only exists from Ruby 2.7, and OpenSSL.secure_compare isn't everywhere either. Four lines of our own constant-time compare removed the version question.

NuGet — the URI class lies to you

Uri.EscapeDataString("REF /1") correctly gives REF%20%2F1. Then new Uri(...) displays the path with the space decoded again, and our test asserted on ToString(). Panic, then a socket listener to see the real request line:

PATCH /api/v1/produits/REF%20%2F1/stock HTTP/1.1
Enter fullscreen mode Exit fullscreen mode

The wire was right all along; only the string representation lied. Test what leaves the machine, not what a getter returns.

Docker Hub — the only one whose links are followed

We checked the HTML of package pages on each registry. PyPI, RubyGems, WordPress.org, Drupal.org, Packagist and GitHub all mark outbound links rel="nofollow". Docker Hub renders README links with rel="noopener noreferrer" — no nofollow — and serves the README server-side. If you care about that sort of thing, it's the one registry where the description page is worth writing properly.

The image itself is trivial: a two-stage build that installs the PyPI package into a venv, then copies the venv into python:3.12-alpine. 101 MB instead of 225 with -slim, because our only dependency is requests, which needs no compiler — the usual reason to avoid Alpine doesn't apply. Runs as uid 10001, token via environment variable, never on the command line where docker ps would show it.

Snap Store — you cannot build on a Mac

snapcraft installs on macOS but cannot produce a snap there. The path that works: push the snapcraft.yaml to a public git repo (GitLab is fine), import it into Launchpad, create a snap recipe, tick amd64 + arm64, tick "automatically upload to store". Launchpad builds both architectures in about thirty minutes and pushes to the stable channel.

Snapcraft.io also renders followed links, and the store listing lets you attach a verified website. We measured seven followed links from our listing page.

The one thing every client shares

Whatever the language, envoyer() takes any iterable, slices it by 200, and returns accepted and refused rows separately. And every verifier works on the raw request body: re-serializing JSON changes bytes, and the signature with them. Both rules were written once in the API and copied into all seven READMEs, because the first support question would otherwise have been "why does my signature not match".

Where they are

  • Python: pip install piecemotooccasion — includes the pmo CLI
  • JavaScript: npm i piecemotooccasion
  • TypeScript / Deno: jsr add @piecemotooccasion/api
  • Ruby: gem install piecemotooccasion
  • n8n: n8n-nodes-piecemotooccasion
  • Docker: docker run tonydevweb/pmo envoyer catalogue.csv
  • Linux: snap install piecemotooccasion

API reference: https://piecemotooccasion.eu/extensions/api

Top comments (0)