REST API that matches chainmail ring specs across the US (SWG/AWG + inch mandrel) and EU (metric) catalogs. Conversion is bidirectional:
- a US input returns the closest EU matches;
- a metric input returns the closest US matches.
Suppliers are bucketed by measurement system: EU (metric, includes UK)
and US (AWG + inch, includes Canada).
On the US market, rings are advertised with:
- Wire Diameter in SWG (sometimes AWG)
- Inner Diameter in inches
Gotcha: the advertised Inner Diameter is the diameter of the mandrel the wire was wound around. After unwinding, the wire springs back and the real ID is slightly larger. The API applies unit conversions and an empirical spring-back table (source: TheQueenring, Netherlands) to recover the real dimensions.
make runIn another terminal:
curl -s localhost:8080/healthz
curl -sX POST localhost:8080/api/v1/convert \
-H 'content-type: application/json' \
-d '{"gauge_system":"SWG","gauge_number":18,"mandrel_inches":0.25}'
curl -sX POST localhost:8080/api/v1/match \
-H 'content-type: application/json' \
-d '{"input":{"gauge_system":"SWG","gauge_number":18,"mandrel_inches":0.25},"top_n":3}'
curl -sX POST localhost:8080/api/v1/match/eu-to-us \
-H 'content-type: application/json' \
-d '{"input":{"wire_diameter_mm":1.2,"inner_diameter_mm":5.0},"top_n":3}'
curl -s localhost:8080/api/v1/gauges/swg
curl -s localhost:8080/api/v1/gauges/awg
curl -s localhost:8080/api/v1/suppliersPublic (browser + API):
| Method | Path | Description |
|---|---|---|
| GET | / |
HTML landing page (pitch + curl examples) |
| GET | /docs |
Swagger UI served from embedded assets (no CDN) |
| GET | /ui |
Interactive in-browser conversion tool |
| GET | /openapi.yaml |
OpenAPI 3.1 spec (served with open CORS) |
| GET | /assets/swagger-ui/{file} |
Bundled Swagger UI JS/CSS (allow-listed filenames only) |
| GET | /healthz |
Liveness check |
Public API (/api/v1/*):
| Method | Path | Description |
|---|---|---|
| POST | /api/v1/convert |
US spec → EU mm spec with a conversion trace |
| POST | /api/v1/match |
US input → top-N EU catalog rings ranked by proximity |
| POST | /api/v1/match/eu-to-us |
EU metric input → top-N US catalog rings |
| POST | /api/v1/match/any |
US/EU input OR product URL → top-N across BOTH catalogs |
| POST | /api/v1/match/bulk |
Up to 64 matches in one request, results in input order |
| GET | /api/v1/rings/{ring_id}/equivalents |
Same-geometry matches for a catalog ring id |
| GET | /api/v1/rings/search?q= |
Full-text search (FTS5) on material / supplier / notes |
| GET | /api/v1/stats |
Catalog snapshot: totals + breakdowns (cached 5 min) |
| GET | /api/v1/suppliers |
Full supplier list (both regions, with region field) |
| GET | /api/v1/gauges/{system} |
Full AWG or SWG table |
| GET | /api/v1/catalog.csv |
CSV dump of the whole catalog (cached 5 min) |
| GET | /api/v1/click/{ring_id} |
Click tracker: logs event, 302 redirects to product page |
| POST | /api/v1/submissions |
Propose a catalog entry (physics-validated, pending) |
| POST | /api/v1/api-keys |
Self-service mint of a public-tier key (raw shown once) |
Admin (require X-Admin-Key header matching ADMIN_API_KEY):
| Method | Path | Description |
|---|---|---|
| GET | /metrics |
Prometheus 0.0.4 metrics (request_total etc.) |
| GET | /api/v1/admin/submissions?status=... |
List submissions, optional status filter |
| POST | /api/v1/admin/submissions/{id}/approve |
Approve; materialises the ring into the catalog |
| POST | /api/v1/admin/submissions/{id}/reject |
Reject with an optional note |
| POST | /api/v1/admin/api-keys |
Issue a client API key (raw value returned once) |
| GET | /api/v1/admin/api-keys |
List issued keys (raw value never replayed) |
| POST | /api/v1/admin/api-keys/{id}/revoke |
Revoke a key (idempotent) |
| GET | /api/v1/admin/click-stats |
Aggregated click counters per supplier (audit) |
| GET | /api/v1/admin/dashboard |
Server-Sent Events: live ops snapshot every 5 s |
Errors are returned as JSON {"code": <http_status>, "message": "..."}.
Clean architecture — dependencies always point inward, toward the domain. The infrastructure ring implements the service interfaces it never imports from the other direction.
flowchart LR
subgraph entry["cmd/ — entrypoint"]
main["main.go<br/>(6-phase boot)"]
cfg["config/Environment"]
end
subgraph wiring["infrastructure/di — wiring"]
container["Container<br/>NewContainer(baseCtx, cfg)"]
end
subgraph http["presentation/http"]
handlers["handler/"]
middleware["middleware/"]
httpx["httpx/"]
end
subgraph core["usecase + service + domain"]
usecase["usecase/<br/>Execute(ctx, …)"]
service["service/<br/>interfaces only"]
domain["domain/<br/>types + sentinels"]
end
subgraph infra["infrastructure/<capability>/"]
impls["sqlitestore · ringcataloglister<br/>ringsearcher · apikeystore<br/>clickstore · submissionrepository · …"]
end
main --> container
container --> handlers
container --> impls
handlers --> middleware
handlers --> usecase
handlers --> httpx
usecase --> service
usecase --> domain
impls -. satisfies .-> service
impls --> domain
pkg/domain/— pure business types and sentinel errorspkg/service/— capability interfaces, one per filepkg/usecase/— business orchestration,Execute()methodpkg/presentation/http/handler/— HTTP handlers (one per endpoint)pkg/presentation/http/httpx/— JSON helpers (WriteJSON,WriteJSONError)pkg/infrastructure/<capability>/— concrete implementationspkg/infrastructure/sqlitestore/— shared SQLite connection + schema + seedingpkg/infrastructure/submissionrepository/— user-submission storagepkg/infrastructure/seeds/— bootstrap JSON files embedded viago:embedpkg/presentation/http/middleware/— Recoverer, RequestID, AccessLog, ContentType, AdminAuthpkg/infrastructure/di/— DI container with baseCtx + Environmentcmd/— entrypoint + config loading (6 phases)
See CLAUDE.md for the full architecture diagram,
conventions, and clean-architecture rules that AI agents must follow
when editing this repo.
Environment variables (all optional, sensible defaults):
| Variable | Default | Description |
|---|---|---|
PORT |
8080 |
HTTP listen port |
LOG_LEVEL |
info |
Log level: debug, info, warn, error |
ENVIRONMENT |
development |
Tag added to every log line as environment |
DATABASE_PATH |
chainmail.db |
SQLite file location (relative paths resolve against CWD) |
ADMIN_API_KEY |
(empty) | Shared secret for admin endpoints. Empty = admin disabled (503). |
API_BASE_URL |
http://localhost:8080 |
Public root URL, used to build click_url on match responses |
CORS_ALLOWED_ORIGINS |
* |
Comma-separated origins (or *). Empty disables CORS headers. |
TRUSTED_PROXY_CIDRS |
(empty) | CIDRs whose X-Forwarded-For is trusted for rate-limit / click |
attribution. Empty = never trust XFF (use RemoteAddr direct). |
HTTP timeouts are private constants in
pkg/infrastructure/di/http_server.go (read/write 15s, idle 60s).
The graceful shutdown timeout is a constant in cmd/main.go (30s).
On first boot the SQLite DB is seeded from the JSON files under
pkg/infrastructure/seeds/; subsequent boots reuse the existing file
and layer user submissions on top. A schema migration adds the
suppliers.region and rings.material_detail columns idempotently on
legacy databases (duplicate-column errors are swallowed). The OpenAPI
3.1 spec lives at
pkg/presentation/http/openapi.yaml.
The API is free for every caller. The project earns its money by
referring buyers to partner suppliers via affiliate networks (Impact,
Awin, ShareASale, CJ). When a deal is signed, the supplier's tracking
URL goes into Supplier.AffiliateURLTemplate (seeded JSON or updated
at runtime via migration). The click tracker rewrites the outbound
302 with the network's click URL — the network handles attribution and
credits commissions on completed purchases.
The API does not expose any paid plan. Every call is gratis.
disclosure is stamped on every /match* response so end-users see
that some links carry affiliate params (FTC/DGCCRF compliance).
Public endpoints are open to anonymous traffic, rate-limited to 60 req/min per IP. Callers can optionally register an API key to get a different quota. Tiers reflect usage, not price:
| Tier | Quota | When to hand it out |
|---|---|---|
public |
60 req/min | Default; same as anonymous. |
partner |
600 req/min | B2B partner shipping an app that uses our API. |
internal |
6000 req/min | Our own tooling (scrapers, dashboards). |
Keys are minted via POST /api/v1/admin/api-keys (admin-gated). The
raw secret is returned once — if a client loses it, issue a new
one and revoke the old. Callers pass it as X-API-Key; missing
header stays anonymous, an unknown value returns 401. The server
stores SHA-256 hashes only, so a DB leak can't replay live keys.
The rate-limit middleware exposes X-RateLimit-Limit and
X-RateLimit-Remaining on every response, and Retry-After on 429s.
GET /api/v1/admin/click-stats (admin-gated) returns per-supplier
click counters over a from/to window:
curl -s "localhost:8080/api/v1/admin/click-stats?from=2026-01-01&to=2026-02-01" \
-H 'X-Admin-Key: $ADMIN_API_KEY' | jqUse it to build a proof pack when negotiating with a new supplier, or
to sanity-check a monthly report from an affiliate network against
what our server actually observed. Clicks are persisted in the
clicks SQLite table and live alongside the catalog — no extra
infrastructure.
Every match response includes a click_url on each ring:
https://<API_BASE_URL>/api/v1/click/<ring_id>
Frontends should link their "Buy" button to that URL rather than to
the raw product_url. Hitting it does three things:
- Generates a unique
click_id. - Emits a structured JSON log event (
event_name=click_redirect) that carriesclick_id,ring_id,supplier_id,supplier_region,destination_url, andaffiliate_rewritten. This feed is what you use to prove impressions/clicks to a supplier when negotiating a commission, and later to attribute conversions. - Issues a 302 to the destination URL. If the supplier has an
affiliate_url_templateconfigured (e.g.{url}?ref=chainmail-api&subid={click_id}), that template is applied before the redirect — the three supported tokens are{url},{click_id}and{ring_id}.
The X-Click-ID response header is echoed so clients can later join
impressions and conversions.
Summary of what the server does to stay safe in the wild:
- Admin auth —
X-Admin-Keycompared constant-time; both sides SHA-256-hashed first so an attacker can't learn the secret length from timing. EmptyADMIN_API_KEY→ 503. - Public API keys — stored as SHA-256 hashes only; raw key is returned once on create. Unknown key → 401. No key required for public endpoints.
- Rate limiting — in-memory token buckets keyed by API key ID or (trusted) IP. A background sweeper evicts idle buckets so the map never grows unboundedly. Per-minute quotas configurable.
- Trusted proxies —
X-Forwarded-Foris only honoured when the direct peer is listed inTRUSTED_PROXY_CIDRS. Without config the server always usesr.RemoteAddr, which prevents anonymous callers from spoofing their IP to bypass per-IP limits. - CORS — public endpoints advertise
Content-Type,X-API-KeyandX-Request-ID.X-Admin-Keyis not advertised, so browsers can't submit it cross-origin (admin remains server-to-server only). Origin comparison is byte-exact, no case-folding. - Body limits — every POST goes through
MaxBytesReadercapped at 64 KiB; oversize payloads return 413. - Redirect safety — the click tracker rejects destination URLs
that aren't
http(s)://. Submissions enforce the same rule at ingest. - Error bodies — 4xx responses carry a sanitised human message;
the
scality/go-errorsstack annotation (file, line) is stripped before reaching the wire. 5xx responses return a fixed generic message only. - Log redaction — every
error_messagelog field goes throughpkg/logx.ErrMessage(), which strips the sameat=[…]source annotation before logs leave the process. Logs shipped to a third-party system (Datadog, Loki, shared Grafana) do not leak internal file paths or line numbers. - GDPR / IP hash —
caller_ip_hashin theclickstable isSHA-256(dailySalt || ipv4_slash24)truncated to 8 bytes. The daily salt is a fresh 16-byte random value, held only in memory, so clicks cannot be joined across UTC days. - Clicks retention — a background sweeper deletes click rows
older than 90 days every 24 h (
clickstore.StartRetentionSweeper, lifecycle bound to the container's base context). Runs an immediate sweep at boot to catch up after long downtime. - Async click persist — the click redirect handler enqueues
events through
clickstore.Async(buffered channel, single worker). Record is non-blocking: a full buffer is warn-logged and dropped rather than delaying the 302. Analytics, not money.
Logs are emitted as JSON on stdout via log/slog with a slog.JSONHandler.
Every line carries application_name, application_version, and
environment. Handlers add handler_name and per-request http_method,
http_path, remote_address. Use-cases add usecase_name. Infra
implementations add component_name and implementation_name. Errors
always go under the error_message key.
{"time":"2026-04-20T09:00:00Z","level":"INFO","msg":"converted us ring","application_name":"chainmail-api","application_version":"dev","environment":"development","usecase_name":"convert_us_ring","gauge_system":"SWG","gauge_number":18,"mandrel_inches":0.25,"wire_diameter_mm":1.2192,"inner_diameter_mm":6.544,"aspect_ratio":5.367}Two paths:
- Bootstrap seed (no running server): drop a JSON file in
pkg/infrastructure/seeds/rings_<supplier>.json+ an entry insuppliers.json(with an explicitregionfield:EUorUS). The seed is applied on the next boot when the DB is empty. - User submission (against a running server):
POST /api/v1/submissionswith either an existingsupplier_idor a fullnew_supplier(includingregion). After admin approval the ring (and, if any, the new supplier) lands in the catalog.
The seeded catalog lists 29 suppliers split between EU (metric, DE,
AT, NL, FR, IT, ES, CZ, UK) and US (AWG, USA and Canada). Ring
specifications come from two sources: curated JSON seeds for the first
3 EU suppliers (queenring, artofsilver, ctdesign), and the
chainmail-scrapers tool
which produces the remaining seed files from supplier catalogs
(Playwright for JS-heavy stores, httpx + regex for Shopify
/products.json, OpenCart HTML, and BigCommerce slug-based URLs).
As of today the catalog contains ~1960 rings across 19 active
scraper-backed suppliers (theringlord, urbanmaille, tomsringshop,
metaldesignz, westcoastchainmail, chainreaction, dragonchains,
thatbeadlady, americanchainmail, perlesmania, aussiemaille,
chainmailjoe, midwestmaille, weavegotmaille, hilmaspyssel, cimoon,
schmuckzubehoeroutlet, kfactory, gluecksfieber plus the 3 curated
EU seeds); AU, Nordic EU and JP coverage live. The ring count is
smaller than the raw scrape output because cosmetic variants
(colour / finish / pack-size duplicates of the same geometry) are
collapsed at scrape time — one representative ring per supplier ×
(wire, inner, material).
# scrapers repo
vim src/scrapers/<supplier>.py # ~60 lines, reuse src/units helpers
make run SUP=<supplier> LIMIT=5 # dry-run to tune regex
make run SUP=<supplier> # full crawl
make integrate # cp output/rings_*.json → api seeds
# api repo
make reseed && make test # wipe db + verify tests greensrc/units.py (awg_to_mm, fraction_to_mm, slug, first_mm, strip_html)
is shared by every scraper; touch it there, benefit everywhere.
The interfaces in pkg/service/ let you replace any implementation. To
switch to Postgres: create
pkg/infrastructure/ringcataloglister/postgres.go (and friends), add a DI
getter, wire it in cmd/main.go. No changes needed outside the
infrastructure layer.
make test # standard tests
make test-race # with the race detector
make cover # HTML report in coverage.html
make lint # go vet + gofumpt + golangci-lintThe GitHub Actions CI gates every PR on:
gofumpt,go vet,golangci-lintwith a strict profile (cyclop max 10, funlen 50/25, gocognit 10,ireturn,wrapcheck,err113,mnd,containedctx,contextcheck,tagliatelle,wsl_v5; see.golangci.yaml)- tests with
-raceand coverage - coverage threshold ≥ 80 %
Formatter is gofumpt, stricter than
gofmt. Local install: go install mvdan.cc/gofumpt@latest.
- Go 1.26+ (
net/httppattern routing + envconfig) github.com/scality/go-errors— error wrapping with identifiers, details, and propertiesgithub.com/sethvargo/go-envconfig— config loading via struct tagsmodernc.org/sqlite— pure-Go SQLite driver (no CGO)- BDD tests: Ginkgo / Gomega