GunSpec
Practice

Resolve before you read

Every integration starts with a name somebody typed and has to end with one catalog id. This is how to get there, and what to do when the answer is uncertain.

Three endpoints take a string and none of them answers the same question. Picking the wrong one is the single most common reason an integration shows the wrong firearm.

What you haveWhat to callWhy
A name a person wrote, and you need the record it meansGET /v1/firearms/resolveAnswers which record a string is, and reports how certain it is. Deterministic, so the same query returns the same id indefinitely.
A search box, and you need a page of plausible resultsGET /v1/firearms/searchRanks records against a query for a human to choose from. It is not a decision, and its first row is not an answer.
A filter, a facet or a browse pageGET /v1/firearmsStructured filters (manufacturer, caliber, category, year, action) that mean the same thing every time. Use these before you reach for text.

The reference for both is on the resolve page and the search page. If a reader is choosing, you want search; if your code is choosing, you want resolve.

Extracting the firearms named in a document, a spreadsheet column or a chat message means dozens of names at once. One request per name turns an inline feature into a background job.

resolve-batch.ts
javascript
// Fifty names, one round trip. Each result echoes its own query,// so nothing has to be matched up by position.const res = await fetch('https://api.gunspec.io/v1/firearms/resolve', {  method: 'POST',  headers: {    'X-API-Key': process.env.GUNSPEC_API_KEY,    'Content-Type': 'application/json',  },  body: JSON.stringify({    queries: ['G19 gen 5 MOS', 'AK-47', 'H&K MP5', 'that one from Die Hard'],  }),}) const { data } = await res.json()for (const result of data.results) {  // An unknown name is a result with a status, not an error: one  // bad name in fifty does not fail the batch.  if (result.status !== 'resolved') {    review.push(result)    continue  }  resolved.set(result.query, result.firearmId)}
  • Up to fifty queries in one call, and each result echoes its own query, so nothing has to be matched up by position.
  • An unknown name comes back as a result with status: "not_found", never as an error. One bad name does not fail the other forty-nine.
  • A batch costs one request against your quota rather than fifty, so it is worth building even where a loop would work.

Each result carries a status and a score, because a name can be unambiguous, plausible or meaningless and those need three different behaviours. Write the rule once and use it everywhere a name arrives.

accept.ts
javascript
// One decision, written once, used everywhere a name arrives.function decide(result) {  switch (result.status) {    case 'resolved':      // Certain enough to act on unattended. Below that, the answer      // is probably right, which is not the same thing.      return result.score >= 0.9        ? { action: 'use', id: result.firearmId }        : { action: 'confirm', id: result.firearmId, from: result.query }     case 'ambiguous':      // Several records fit equally well and the API deliberately      // did not choose. Offer `alternatives`; do not pick [0].      return { action: 'choose', options: result.alternatives }     default:      // Nothing matched. `unresolvedTokens` names the words that      // carried no meaning, which is what to show the user.      return { action: 'ask', unresolved: result.unresolvedTokens }  }}

Do not silently fall back to search when resolution fails. A top hit presented as an answer is how the wrong firearm reaches a customer. An ambiguous result already carries its candidates, so show them.

A catalog id is the stable thing in this system. Resolving the same name on every page view spends a request to re-derive something that has not changed since last month.

  • Resolve once, store the id beside your own record, and call the catalog by id from then on.
  • Store version too: it changes when and only when a field we serve changes, so it tells you whether your copy is stale without another lookup. Caching and freshness covers the rest.
  • Keep the original string as well. When a record is merged or renamed, the query that produced the id is what lets you re-resolve without asking the user again.

Each of these works in a demo and fails in front of a customer.

Instead ofDo this
Searching and taking the first resultResolve, and act on the status. Search is for a human to choose from.
Guessing an id from a name ("glock-19")Ask the API. Slugs follow the catalog, not a formatting rule you can reproduce.
Resolving the same name on every requestResolve once, store the id, and cache the mapping as long as you like.
Dropping the words that did not matchRead unresolvedTokens. A variant we do not hold appears there rather than being quietly ignored.