API¶
Product Search answers a JSON API over HTTPS, for the system that keeps the merchant's own records rather than for a browser.
This page is generated from the extension's contract declaration — the same data the routes are served from, the committed snapshot is diffed against and the release's smoke pass walks. So it cannot describe a route the version you installed does not answer, and a field that moves moves this page or fails the build.
A caller authenticates as one of OpenCart's own API users, under
System > Users > API, and the API has to be switched on for the
extension. That switch is under Extensions > Extensions > Modules, on
this extension's own settings screen, in its API section. With it off,
every route here answers 404 api_disabled, so an extension with its API off
is shaped like one that never had it.
That switch is independent of the extension's own status.
Product Search can be switched off while its API keeps answering, and that is
deliberate: a merchant who switches the extension off has stopped it doing
its job in the storefront, which is precisely when the system holding their
records still needs to read what is already there. Coupling the two would
break a nightly sync every time the merchant closed a window, with
404 api_disabled as the only clue.
Every answer is the same envelope¶
A success carries data. A collection carries page beside it:
{"data": [], "page": {"limit": 50, "count": 0, "cursor": null}}
A refusal carries error, and no data at all:
{"error": {"code": "not_found", "message": "No such record.", "status": 404}}
Exactly one of the two is present, always — including for a refusal the store made before it read anything of the call.
limit defaults to 50 and is clamped to 200; page.limit is the value
actually applied, so a caller who asked for more can see they were clamped
rather than infer it from a short page. page.cursor is null on the last page,
and it is the only reliable end-of-walk signal: a full page is not evidence of
more rows and a short one is not evidence of none. There is no total.
A money value is an object carrying amount, a string at four decimal places,
and the currency it is in. A day is YYYY-MM-DD; a moment is RFC 3339 with an
offset, and which offset is the last section on this page.
Branch on code. The message is prose for a person reading a log and is not
promised; status in the body is the status on the wire. A refusal that names
the parameters it rejected carries fields, a map of parameter name to the
codes that rejected it.
These codes belong to the envelope and mean the same thing in every one of our extensions that serves an API:
| Code | Status |
|---|---|
api_disabled |
404 |
tls_required |
426 |
unauthorized |
401 |
unknown_version |
404 |
unknown_route |
404 |
method_not_allowed |
405 |
not_found |
404 |
invalid_parameter |
400 |
malformed_json |
400 |
unsupported_media_type |
415 |
payload_too_large |
413 |
internal_error |
500 |
v1¶
One read resource and no writes: term, one normalised search term in one
store, language and category on one day, and how many of those searches found
nothing.
term¶
One search term on one day: how often it was searched, and how often that found nothing.
index.php?route=extension/product_search/api/v1/term
| One record | &term_id= |
| The collection | the same route without &term_id, ordered date DESC, term_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
term_id |
integer | The row's own id. Stable for as long as the row exists; a day the store has stopped keeping is deleted with its ids. |
store_id |
integer | The storefront the searches were typed on. 0 is the default store. |
opencart_language_id |
integer | The language the storefront was in, as core's own language id. |
opencart_category_id |
integer | null | The category the shopper scoped the search to, and null for a search of the whole catalogue. |
keyword |
string | The search, normalised the way the counters file it — lower-cased, trimmed, with runs of space collapsed — and at most 255 characters. Free text a shopper typed: treat it as untrusted wherever it is displayed or opened, a spreadsheet included. |
date |
string, YYYY-MM-DD |
The day the searches happened, in the store's own timezone. The bucket's key rather than a stamp: it does not move when the counts on it do. |
searches |
integer | How many first-page searches for this term there were that day. Later pages of the same search are not counted again. |
zero_results |
integer | How many of them found nothing in the whole of what they searched — read before any filter the shopper narrowed by, because excluding every match is their choice rather than a gap — or were answered by the zero-result rule. A search rescued by a synonym, a corrected spelling or a keyword pin found something, and is not counted here. |
The collection¶
The order is the contract, not a default. It is date DESC, term_id DESC,
it is what the cursor is keyed on, and there is no sort parameter: a second
order is a second permanent promise and a second index to keep.
Walk it with &cursor=, taking the value from the previous page's
page.cursor and changing nothing else. The cursor names a row rather than a
position, so a row inserted mid-walk cannot shift the page under you.
It may be narrowed by these, and by nothing else — any other parameter is
400 invalid_parameter naming itself in fields.
| Parameter | Type | Notes |
|---|---|---|
filter_store_id |
integer | 0 is the default store. Unfiltered is every store, which is what the credential authorises. |
filter_opencart_language_id |
integer | |
filter_opencart_category_id |
integer | 0 is searches of the whole catalogue. |
filter_date_from |
string, YYYY-MM-DD |
Inclusive. Yesterday is the incremental read: every earlier day is closed. |
filter_date_to |
string, YYYY-MM-DD |
Inclusive. |
What a client has to do¶
Three obligations, and a client that meets them keeps working across every non-breaking change this API is allowed to make:
- Ignore a field you do not recognise. A new field is added without notice and without a version segment, so a client that refuses an unexpected key breaks on a change that was promised to be safe.
- Tolerate an unrecognised member of an enum exactly as you tolerate an
unrecognised field. A new state, resolution, verdict or actor is the same
kind of additive change as a new field. Branch on the members you know and
route the rest to a default; never
switchwith an exhaustiveelsethat throws. - Treat a response that does not parse as this envelope as a transport failure, and retry it. A proxy's HTML error page, a truncated body, a maintenance splash: none of those came from this API, and reading them as data is how a client invents a state the store never reported.
Timestamps, and which store answered¶
Every timestamp is RFC 3339 with an offset, and the offset is the one of whichever store the hostname you polled resolved to — not UTC, and not the default store's. A multi-store installation polled on two hostnames answers the same moment with two different offsets. Parse to an instant and compare instants; never compare the strings, and never assume the offset is stable across stores or across a daylight-saving boundary.
What v1 owes you before you build on it¶
Facts about this surface that no field list states, and that a client author would otherwise find out from a support ticket:
- A term is a day's total, not a search. Each row is one normalised term in one store, language and category on one day, with two counts. It is not a log of individual searches and carries nothing about who searched: no IP address, no customer, no session.
- Today's rows are still counting; every earlier day is closed. A search
adds to today's row in place, with nothing stamped, so there is no
modified-since filter and
datemust not be read as one. A day before today never changes again — it can only be deleted — so an incremental read isfilter_date_fromset to yesterday, run daily. Yesterday rather than today, so a read straddling midnight or a change of the store's timezone loses nothing. - Old days are deleted, and only when the search report is opened. The store
keeps as many days as its retention setting says (a year, as shipped, and
0keeps everything), and the trim runs when somebody opens the report screen rather than on a schedule — so a store nobody opens the report on keeps every day, and the day after somebody does, a year's worth may vanish at once. Clearing the report from its screen deletes a whole store and language. Neither leaves a trace: a full walk is the authoritative list, and reconcile by absence. - Only first-page searches are counted, and only where counting is on. A shopper paging through results is one search, not three. A storefront with the counters switched off writes nothing, and the API reads only what was written.
- The keyword is shopper text. It is normalised, not sanitised: it may hold anything a shopper can type into a search box, including text a spreadsheet would execute as a formula. The search report's own CSV guards against that; this JSON does not and cannot, because the guard changes the value.
- Nothing records that you read anything, and the credential cannot be
scoped. An OpenCart API user opens every extension's API and core's own
api/orderbesides, across every store in the installation. There is no read log, no per-credential audit trail and no rate limiting; v1 writes nothing at all.