Upload a supplier's product feed (CSV) and get back a Shopify import, ready for Matrixify, plus an audit of everything wrong with the feed. Every run, every finding and both output files are kept in Postgres, so "what did this supplier send us last Tuesday, and what was wrong with it" is one request away.
The feed logic is not in this repository. It is feed-to-matrixify, a standard-library engine with its own tests; this service installs it as a package pinned to a commit and wraps it in an HTTP API, a database and a container. The API's CSV downloads are byte-for-byte what the engine's CLI writes, and a test holds that.
flowchart LR
client[Client: curl, a script, a no-code tool] -->|POST /runs + X-API-Key| api[FastAPI]
api -->|sha256 seen before?| pg[(Postgres 16)]
api -->|read_feed, audit, convert| engine[feed-to-matrixify engine]
engine -->|run, findings, two CSVs| pg
client -->|GET runs, findings, CSVs, stats| api
cp .env.example .env
docker compose up --build --waitPostgres comes up on port 5435, the API on 8000, both bound to 127.0.0.1
only: the demo password and key in .env.example are public. Alembic migrates the schema
before the server takes traffic, and the API container only starts once the
database reports healthy. Interactive docs are at http://localhost:8000/docs.
Upload the synthetic sample feed (a German-style export: semicolons, decimal commas, twenty rows carrying the defects real feeds carry):
curl -s -H "X-API-Key: demo-key-change-me" -F "file=@sample/supplier-feed.csv" http://localhost:8000/runsThe numbers in the answer are the ones the engine's CLI prints for the same file:
{
"rows_read": 20,
"products": 15,
"variants": 7,
"rows_left_out": 1,
"findings_total": 11,
"findings_blocking": 1,
"delimiter": ";",
"encoding": "utf-8-sig",
"decimal": ","
}Upload the same file again and the answer is 200 with the first run and
"already_seen": true instead of 201 and a duplicate.
# what blocked a row from the import
curl -s -H "X-API-Key: demo-key-change-me" "http://localhost:8000/runs/1/findings?level=blocker"
# the Shopify import and the audit report, as files
curl -s -H "X-API-Key: demo-key-change-me" -o matrixify-import.csv http://localhost:8000/runs/1/matrixify.csv
curl -s -H "X-API-Key: demo-key-change-me" -o audit-report.csv http://localhost:8000/runs/1/report.csv
# the problems suppliers send most often, over every run
curl -s -H "X-API-Key: demo-key-change-me" "http://localhost:8000/stats/findings?limit=5"| Method | Path | What it does |
|---|---|---|
| GET | /health |
200 when the API can query Postgres, 503 when it cannot. No key. |
| POST | /runs |
Upload a feed (multipart file). 201 new run, 200 same file seen before, 413 over MAX_UPLOAD_BYTES (5 MB by default), 422 FeedUnreadable when the engine cannot read it. |
| GET | /runs |
Runs in id order, cursor-paginated: ?after=<id>&limit=<1..100>, next_cursor is null on the last page. |
| GET | /runs/{id} |
One run's summary. |
| GET | /runs/{id}/findings |
Its findings, filterable by ?level= (blocker, warning) and ?what=. |
| GET | /runs/{id}/matrixify.csv |
The Matrixify import, exactly as the engine CLI writes it. |
| GET | /runs/{id}/report.csv |
The audit report, exactly as the engine CLI writes it. |
| GET | /stats/findings |
Findings grouped by what and level across all runs, most frequent first. |
Everything except /health, /docs and /openapi.json needs the X-API-Key
header; a missing or wrong key is 401. The key is compared with
hmac.compare_digest, so the time a wrong key takes to reject does not leak how
much of it was right.
An upload is turned away before its body is read. FastAPI parses the whole
multipart body before any dependency runs, so a check in the endpoint alone
would come after a 200 MB file had been received. A small ASGI middleware
answers 401 for a missing or wrong key and 413 for a Content-Length over
the limit without reading the body, and cuts off a body sent without a length
as soon as it passes the limit.
- Synchronous SQLAlchemy. Each request is one short transaction and the heavy part, the engine, is CPU-bound Python that async would not speed up. FastAPI runs sync endpoints in its thread pool, so a slow upload does not block the others. Async would add a second driver and a second way to write every query for no measured gain here.
- Idempotency by the file's sha256, enforced by a
UNIQUEconstraint, not only by a lookup: two uploads of the same file at the same moment both pass the lookup, and the loser gets the winner's run back instead of a 500. - The output CSVs are stored as text in
run_files, so a download a month later is the same bytes the engine produced, even after the engine changes. - Schema by Alembic migrations, never
create_all; the test suite builds its database with the same migration, andalembic checkin CI confirms the models and the migration agree.
All numbers below are printed by a command; none is typed by hand.
python check.py reruns the tests and coverage and compares them with this
page. The load numbers are a saved measurement: check.py confirms this page
matches docs/load-check.txt, and python scripts/load_check.py remeasures.
- 53 pytest tests, all on a real Postgres (a throwaway
feedaudit_testdatabase, built by the Alembic migration), none on SQLite. They include the cases that should fail: an unreadable feed, an empty file, a binary file, a file one byte over the limit, no key and a wrong key on every protected route, the same file twice, a lost upload race, and paging through every page returning every run exactly once. - Line coverage of
app/: 99% (pytest --cov=app). - Single-thread load check,
python scripts/load_check.py: 200 synthetic feeds fromscripts/make_feeds.py(four export formats, fixed seed, byte-identical on every run), uploaded one after another, then their findings read back. The app runs in-process (TestClient) against the Postgres container, on a laptop:
feeds uploaded: 200, findings stored: 3233
POST /runs: p50 55.9 ms, p95 89.8 ms
GET /runs/{id}/findings: p50 5.7 ms, p95 7.9 ms
python -m venv .venv && .venv/bin/pip install -r requirements-dev.txt
docker compose up -d db --wait
.venv/bin/python -m pytest --cov=app
.venv/bin/python check.pyCI (.github/workflows/ci.yml) runs ruff, the migrations and alembic check, the tests against a Postgres service with an 85% coverage floor, the image build, and docker compose up --wait followed by a request to /health. Every action is pinned to a commit
SHA.
- A lost upload race returned 500. Two uploads of the same file both pass
the "seen this sha256?" lookup; the second insert then hits
UNIQUE(sha256). ThetrycaughtIntegrityErroraroundcommit(), but SQLAlchemy raises it atflush(), which ran before thetry. The test that forces the race failed with the rawIntegrityError; the flush moved inside thetry. - Line endings would have changed the input. Git on Windows with
core.autocrlfwarned it would rewrite LF as CRLF in the working copy. That changes the sample feed's bytes, so its sha256 and every byte-for-byte comparison, and breaks shell lines inside the Linux image..gitattributesnow keeps LF and marks*.csvas data that is never normalised. - The release gate failed on itself.
check.pylooks for Cyrillic text with a\u0400-\u04ffrange, and the tool that wrote the file turned those escapes into the literal characters. Its own hygiene check and the privacy scanner both flaggedcheck.py. The two lines are now generated with the escapes kept as escapes, and the gate passes on a file it can read.
Found by two independent reviews before publishing, each fixed with a test that fails without the fix:
- The size limit came after the upload. The endpoint read at most 5 MB of the file, but FastAPI had already spooled the whole multipart body, even for a request with no key. A 200 MB upload without a key was refused only after it had been received in full. The middleware above now refuses it first.
- An id above 2,147,483,647 was a 500. Postgres
INTEGERoverflowed inside the query. Path ids and the paging cursor are now bounded, so it is a422. - Two products could merge into one. The engine grouped rows by Shopify
handle, and SKUs
A/BandA-Bboth makea-b: the second product became a variant of the first and lost its title with no finding. The engine now groups by SKU, suffixes the clashing handle and reports the clash. A field over Python's CSV size limit also crashed the reader; it is a422now.
- The engine commit this pins is not on GitHub yet. Until it is pushed,
pip install -r requirements.txtand a Docker build without.envcannot fetch it, and CI will fail at install. Locally, install the engine from a checkout (pip install -e ../feed-to-matrixify) and setENGINE_CONTEXT=../feed-to-matrixifyin.envfor the image build. - An accepted upload is read into memory; that is why there is a 5 MB limit, enforced before the body is received. A multi-hundred-megabyte feed would need streaming the engine does not do.
- The demo key and database password are defaults for a local run. Exposing
the stack beyond
localhostmeans setting both in.envfirst. - Idempotency is by exact bytes. The same feed re-saved with different line endings or a different column order is a new run.
- One shared API key, no users, no rate limiting, no way to delete a run.
- The load numbers are one thread on one machine with the app in-process. They say a single upload of a typical feed takes tens of milliseconds; they are not a throughput claim.
- Output CSVs live in Postgres. At thousands of large feeds a day they belong in object storage with only the metadata in the database.
MIT