Skip to content

About

Free REST API that matches chainmail ring specs bidirectionally between US (SWG/AWG + inch) and EU (metric) catalogs.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Repository files navigation

chainmail-api

License: MIT Go

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).

Business context

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.

Getting started

make run

In 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/suppliers

Endpoints

Public (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": "..."}.

Architecture

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/&lt;capability&gt;/"]
        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
Loading
  • pkg/domain/ — pure business types and sentinel errors
  • pkg/service/ — capability interfaces, one per file
  • pkg/usecase/ — business orchestration, Execute() method
  • pkg/presentation/http/handler/ — HTTP handlers (one per endpoint)
  • pkg/presentation/http/httpx/ — JSON helpers (WriteJSON, WriteJSONError)
  • pkg/infrastructure/<capability>/ — concrete implementations
  • pkg/infrastructure/sqlitestore/ — shared SQLite connection + schema + seeding
  • pkg/infrastructure/submissionrepository/ — user-submission storage
  • pkg/infrastructure/seeds/ — bootstrap JSON files embedded via go:embed
  • pkg/presentation/http/middleware/ — Recoverer, RequestID, AccessLog, ContentType, AdminAuth
  • pkg/infrastructure/di/ — DI container with baseCtx + Environment
  • cmd/ — 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.

Configuration

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.

Business model

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).

API keys and partner quotas

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.

Click audit reporting

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' | jq

Use 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.

Click tracker / affiliate wrapping

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:

  1. Generates a unique click_id.
  2. Emits a structured JSON log event (event_name=click_redirect) that carries click_id, ring_id, supplier_id, supplier_region, destination_url, and affiliate_rewritten. This feed is what you use to prove impressions/clicks to a supplier when negotiating a commission, and later to attribute conversions.
  3. Issues a 302 to the destination URL. If the supplier has an affiliate_url_template configured (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.

Security posture

Summary of what the server does to stay safe in the wild:

  • Admin auth — X-Admin-Key compared constant-time; both sides SHA-256-hashed first so an attacker can't learn the secret length from timing. Empty ADMIN_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-For is only honoured when the direct peer is listed in TRUSTED_PROXY_CIDRS. Without config the server always uses r.RemoteAddr, which prevents anonymous callers from spoofing their IP to bypass per-IP limits.
  • CORS — public endpoints advertise Content-Type, X-API-Key and X-Request-ID. X-Admin-Key is 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 MaxBytesReader capped 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-errors stack annotation (file, line) is stripped before reaching the wire. 5xx responses return a fixed generic message only.
  • Log redaction — every error_message log field goes through pkg/logx.ErrMessage(), which strips the same at=[…] 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_hash in the clicks table is SHA-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.

Logging

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}

Adding a supplier

Two paths:

  • Bootstrap seed (no running server): drop a JSON file in pkg/infrastructure/seeds/rings_<supplier>.json + an entry in suppliers.json (with an explicit region field: EU or US). The seed is applied on the next boot when the DB is empty.
  • User submission (against a running server): POST /api/v1/submissions with either an existing supplier_id or a full new_supplier (including region). 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).

Adding a new supplier (fast path)

# 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 green

src/units.py (awg_to_mm, fraction_to_mm, slug, first_mm, strip_html) is shared by every scraper; touch it there, benefit everywhere.

Swapping storage

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.

Tests and quality

make test        # standard tests
make test-race   # with the race detector
make cover       # HTML report in coverage.html
make lint        # go vet + gofumpt + golangci-lint

The GitHub Actions CI gates every PR on:

  • gofumpt, go vet, golangci-lint with 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 -race and coverage
  • coverage threshold ≥ 80 %

Formatter is gofumpt, stricter than gofmt. Local install: go install mvdan.cc/gofumpt@latest.

Dependencies

License

MIT

About

Free REST API that matches chainmail ring specs bidirectionally between US (SWG/AWG + inch) and EU (metric) catalogs.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages