GunSpec
Practice

Find the right records

Most "how do I query X" questions are one call with the right parameters. This is the mapping, and the two rules that keep it cheap.

Every row here is one request: searching by caliber, filtering by maker or country, browsing a category. The version people write first, listing everything and filtering in their own code, is the same answer for a hundred times the quota.

What you wantThe callNotes
9mm pistols made in Austria, still in productionGET /v1/firearms?caliber=&category=&country_of_origin=&status=Filters combine and are applied in the database. Sort with sort and order.
Everything chambered in one cartridgeGET /v1/calibers/{id}/firearmsThe cartridge-first view, already paged.
Everything one maker buildsGET /v1/manufacturers/{id}/firearmsSame shape for a brand page.
Everything in a categoryGET /v1/categories/{slug}/firearmsCategories holding no firearm are not listed at all.
Records carrying a feature (threaded barrel, M-LOK)GET /v1/firearms/by-feature?feature=Feature slugs come from filter-options; by-action, by-material, by-designer and by-conflict work the same way.
What one country fields, by operator typeGET /v1/countries/{code}/arsenalGrouped by military, law enforcement and so on.
A ranked page for a human to choose fromGET /v1/firearms/search?q=Full-text over names and descriptions. A ranking, not a decision.
Which record a typed name isGET /v1/firearms/resolve?q=One id and a status. This is the one to call before fetching a record.
The variants of a modelGET /v1/firearms/{id}/variantsDirect children. The family tree endpoint walks further.
"You may also like"GET /v1/firearms/{id}/similarTen records scored on shared calibers, maker, action, size and year; images inlined.
Leaders on one measurable statGET /v1/firearms/top?stat=Records missing the measurement are excluded rather than ranked as zero.
What people are actually looking atGET /v1/popular/firearms?days=View activity over a trailing window, for a homepage rail.

Every parameter on the list endpoint narrows the query before the page is built, so a narrow filter costs one request and returns one page. The full parameter list is on that page; the point here is that they combine.

filters.sh
bash
# Filters combine, and they are applied in the database.# 9mm semi-automatic pistols still in production, made in Austria,# newest first, a hundred to a page.curl -sS -H "X-API-Key: $GUNSPEC_API_KEY" \  "https://api.gunspec.io/v1/firearms?caliber=9x19mm-parabellum&category=pistol\&country_of_origin=AT&status=in_production&sort=year&order=desc&per_page=100" # Only records that have a photograph or a render, for a page that# is mostly pictures.curl -sS -H "X-API-Key: $GUNSPEC_API_KEY" \  "https://api.gunspec.io/v1/firearms?category=rifle&has_image=true&per_page=24"
  • Prefer one filtered request to a walk plus a loop. Paging the whole catalog to find eleven rifles is the most expensive way to ask a cheap question, and the plan is where it shows up.
  • Ranges are pairs: weight_min / weight_max, year_introduced_min / year_introduced_max, in the units the field names carry.
  • fields trims the response to what you render. A list page that shows a name, a year and a picture does not need seventy fields per row.
  • Sort deliberately. sort=created_at&order=asc is the stable order for a walk; name is the one that shifts under you when a record is added.

One call returns every value that currently has records behind it, which is what a filter UI should offer. Hardcoding the lists is how a dropdown ends up with dead options and misses the category added last month.

filter-options.ts
javascript
// Build the filter UI from the API, not from a constant.// Categories, manufacturers and calibers nothing uses are left out,// so an option in this list always returns results.const { data } = await gunspec.firearms.filterOptions() // data.manufacturers, data.categories, data.calibers,// data.actionTypes, data.features. Countries come from// /v1/countries, which carries the names and codes.//// Hardcoding these is how a UI ends up offering "Flintlock" with// nothing behind it, and missing the category added last month.

Search ranks while the user types; resolve decides once they commit. Using search for both is what puts the wrong firearm on the page, as resolve before you read sets out.

autocomplete.ts
javascript
// An autocomplete has two jobs and they are two endpoints.// While the user types: rank plausible records for them to pick.const { data: suggestions } = await gunspec.firearms.search({ q: typed, per_page: 8 }) // Once they pick, or paste a name and hit enter, decide which// record it is and act on the status rather than the ranking.const { data } = await gunspec.firearms.resolve(typed)if (data.status === 'resolved') go(data.firearmId)
  • Debounce the keystrokes. One request per character is a rate limit you asked for.
  • Cache the suggestion list by query string for a few minutes: people retype the same prefixes all day.
  • Search needs Builder or above. On Explorer, drive the box from filter-options and the list filters instead.

The reference lists are their own endpoints, each paged the same way, and each cheap enough to fetch once and cache for a day.

  • GET /v1/calibers, /v1/manufacturers, /v1/categories, /v1/countries and /v1/ammunition are the catalogs behind the filters.
  • Raise per_page rather than walking: these lists are hundreds of rows, not thousands, so one or two requests is the whole set. Pagination has the ceilings.
  • Cache them for a day. A caliber list changes when the catalog gains a cartridge, which is not per request and not per user.
  • For a filter UI, prefer /v1/firearms/filter-options: it returns only the values that currently have records behind them.

Several questions that look like joins are endpoints of their own, and each returns the records already assembled.

  • /v1/firearms/{id}/family-tree walks lineage in both directions; /variants is just the children.
  • /v1/firearms/{id}/users is who fields it, and /adoption-map the same thing as geography.
  • /v1/firearms/{id}/attachments is what fits, and it has rules of its own. See what fits what.
  • /v1/firearms/compare takes up to five ids and returns them aligned, which is one request where five get calls would be five.