API¶
Product Bundles 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 Bundles 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¶
Two read resources and no writes: line, what a sold bundle actually contained,
frozen as sold, and bundle, the bundle a line refers to as it stands.
bundle¶
One bundle: its carrier product, the stores that sell it and what is in it today.
index.php?route=extension/product_bundles/api/v1/bundle
| One record | &bundle_id= |
| The collection | the same route without &bundle_id, ordered bundle_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
bundle_id |
integer | This extension's id for the bundle, which is what a line refers to. |
opencart_product_id |
integer | null | The carrier: the core product a shopper sees and buys, and what core's own order line names. Null where that product no longer exists — deleted outside this extension's own screens — rather than an id that points at nothing; the next save of the bundle creates a new carrier. |
name |
string | The bundle's name in the store's default language. |
model |
string | |
status |
boolean | Whether the bundle is on sale. Core's own status on the carrier follows it. |
stores |
array of store |
The stores that sell it. |
components |
array of component |
What is in the bundle now, in its sort order. What a past order sold is on that order's line, frozen; this is today's. |
date_added |
string, RFC 3339 | null | Set by every save on the bundle screen; null only on a row written around it. |
date_modified |
string, RFC 3339 | null | Moves on every change this resource answers: an edit, a store being removed from it, and its carrier being created again. Null only on a row written around the bundle screen. |
The collection¶
The order is the contract, not a default. It is bundle_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_opencart_product_id |
integer | The bundle a carrier stands in for: the read an ERP holding a core order line makes. |
filter_component_product_id |
integer | Every bundle containing one product. |
filter_date_modified_from |
string, RFC 3339 | Inclusive. Every bundle changed since a moment. A bundle deleted since is absent rather than returned; reconcile by a full walk. |
line¶
One sold bundle: core's order line for the carrier, and every component and option that went with it.
index.php?route=extension/product_bundles/api/v1/line
| One record | &opencart_order_product_id= |
| The collection | the same route without &opencart_order_product_id, ordered opencart_order_product_id DESC |
| Verbs | GET |
Fields¶
In the order they are emitted in.
| Field | Type | Notes |
|---|---|---|
opencart_order_product_id |
integer | Core's order line for the carrier, and the line's identity. Core gives it a new id when the order is edited, and this extension rebuilds its rows under the new one. |
opencart_order_id |
integer | |
store_id |
integer | The order's storefront, read off the order. |
bundle_id |
integer | The bundle that was sold. It may since have been deleted, and the line still says what was in it. |
quantity |
integer | How many bundles the line sold, off core's own order line. Stock moved by each component's quantity_per_bundle times this. |
components |
array of sold_component |
What was in it, frozen as sold. |
The collection¶
The order is the contract, not a default. It is opencart_order_product_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_opencart_order_id |
integer | One order's bundle lines: the read an ERP makes beside the order it already holds. |
filter_bundle_id |
integer |
Embedded shapes¶
What a field of type array or object holds. Each is declared once and
referred to from wherever it is embedded, so two copies cannot come to
disagree.
store¶
One store a bundle is sold in.
| Field | Type | Notes |
|---|---|---|
store_id |
integer | 0 is the default store. |
component¶
One product in a bundle as it stands.
| Field | Type | Notes |
|---|---|---|
opencart_product_id |
integer | |
quantity_per_bundle |
integer | How many of this product one bundle holds. Spelled so it cannot be read as a line total. |
sold_component¶
One product in a sold bundle, frozen at checkout.
| Field | Type | Notes |
|---|---|---|
opencart_product_id |
integer | |
name |
string | As it was called when sold, in the order's language. |
model |
string | As it was when sold. |
quantity_per_bundle |
integer | |
options |
array of sold_option |
The options the shopper chose on this component, which core records nowhere. |
sold_option¶
One option chosen on a sold component.
| Field | Type | Notes |
|---|---|---|
opencart_product_option_id |
integer | |
opencart_product_option_value_id |
integer | null | Null for an option with no value list — a text box, a date. |
name |
string | |
value |
string | What was chosen, or what the shopper typed. Free text from a shopper — an engraving, a gift message — so treat it as untrusted wherever it is displayed. |
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 line is written once and never changed, so it syncs by insert order. It
is created at checkout and never updated; new lines carry higher ids than old
ones. Walk the collection once, then read from the newest
opencart_order_product_idyou hold. What a line does not do is stay: editing the order deletes it and writes it again under core's new order line id, deleting the order deletes it, and so does erasing the customer's personal data. Reconcile by absence with a periodic full walk. - A bundle's
date_modifiedmoves on every change it answers, from this version on. An edit, a store being removed from the bundle and the carrier being created again all stamp it. A carrier deleted outside this extension's screens is answered as null at once and stamps nothing until the bundle is next saved. - There is no money here, and none is faked. A line's price is core's, on core's own order line. A bundle's price is the carrier's, on core's own product. A bundle's discount is a percentage in one of its two pricing modes, so it is not an amount.
- Option values are shopper text. A component's
valueholds whatever was typed into a text option — an engraving, a message — returned as stored. - 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.