Skip to main content
Version: 4.0 preview

The HTTP API, from the inside

Version
4.0 preview
Updated
View markdown

How a request becomes a record. This is the implementation article: the layers, the paths through them, and the classes that do the work. For using the API, read com_configbox_entity_api.md; for the response contract, com_configbox_api_contract.md.

All paths are relative to the component root (components/com_configbox/), except where they are plainly in the outer Joomla install.


1. The whole path, once

A PATCH /en/cb-api/v1/question/26037 with a JSON body, end to end:

Joomla index.php

plg_system_configbox :: parseFrontname() ← outer repo, Joomla-specific
│ sees /cb-api/, sees the v1 segment, and sets:
│ option=com_configbox controller=apiv1 task=dispatch
│ cb_api_path=question/26037 format=raw output_mode=view_only

com_configbox/configbox.php → KenedoController::execute('dispatch')

ConfigboxControllerApiv1::dispatch() ← the EDGE
│ route() split cb_api_path, read the HTTP method
│ authenticate() bearer token + scope → 401 / 403
│ getWriteFields() read php://input, flatten translations

ConfigboxEntityApiHelper::update() ← the SHARED LAYER
│ getModelForEntity() name → model, from the registry → 404
│ assertKnownFields() refuse fields the entity has no → 422
│ encodeRuleFields() authoring form → engine tokens
│ buildDataFromFields() seed request from stored record, read properties
│ runStorePipeline() prepareForStorage → isInsert → validateData → store → purge

KenedoModel (ConfigboxModelQuestions) ← the MODEL
│ property definitions, validation, SQL, afterStore()

ConfigboxApiRecord::project() record → wire shape (flat title_en_GB keys, money objects)
ConfigboxApiResponse::ok() → {data, meta}, status through the platform seam

Every box above is replaceable except the middle one. That is the design: the edge translates, the shared layer does the work.


2. The classes, and what each is for

ClassFileResponsibility
PlgSystemConfigboxplugins/system/configbox/ (outer repo)Claims /cb-api/. Turns a REST path into controller=apiv1, task=dispatch, cb_api_path=…
ConfigboxControllerApiv1controllers/apiv1.phpThe HTTP edge: routing, auth, body reading, status codes
ConfigboxApiTokenHelperhelpers/apitokens.phpMint, verify, revoke bearer tokens
ConfigboxEntityApiHelperhelpers/entityapi.phpThe shared layer. Registry + CRUD + the write pipeline
ConfigboxRuleAuthoringHelperhelpers/ruleauthoring.phpRule ⇄ engine token stream
ConfigboxApiRecordclasses/ConfigboxApiRecord.phpRecord ⇄ wire shape (flat underscore translation keys)
ConfigboxApiResponseclasses/ConfigboxApiResponse.phpThe envelope and RFC 9457 problem bodies
ConfigboxOpenApiHelperhelpers/openapi.phpThe OpenAPI 3.1 document
ConfigboxRuntimeApiCataloghelpers/runtimeapicatalog.phpThe hand-written table of configurator/cart endpoints
ConfigboxPostmanHelperhelpers/postman.phpOpenAPI → Postman Collection v2.1
ConfigboxApiExampleHelperhelpers/apiexamples.phpSchema → example value, shared by the collection and the HTML
ConfigboxApiHtmlHelperhelpers/apihtml.phpOpenAPI → the HTML reference
ConfigboxTypeGeneratorhelpers/typegen/The registry's other consumer: record stubs and JSON Schemas

3. Routing: why one task and one parser

KenedoController::execute() dispatches a task straight to a method of that name:

if (method_exists($this, $task) == false) { throw}
$this->$task();

So if entity names were task names, one caller-supplied string per registered entity — dozens of them — would compete with this class's real methods, and any name a caller invented would reach method_exists(). Instead the plugin sets one controller and one task, and hands the rest over as a single request variable:

const API_VERSION = 'v1';
const API_CONTROLLER = 'apiv1';
const API_TASK = 'dispatch';
$uri->setVar('cb_api_path', implode('/', $rest));

ConfigboxControllerApiv1::route() is then the only parser. One entry point, one place to audit.

Only the site's own SEF suffix is stripped on that branch, not any extension — the generic stripping would eat the .json of /cb-api/v1/schemas/read/question.json, whose path deliberately matches the schema's own $id.

The version is in the path from the first release, even though the answer is expected to be v1 for a long time. Retrofitting a version onto a live API means every client moves at once, or the server guesses from a header.

The multilingual redirect

On a site with the language filter on, /cb-api/… answers 301 to /en/cb-api/…. That is pre-existing for every /cb-api/ route, not this API's doing — but it matters more here, and not for the method you would expect.

Joomla's language filter exempts POST explicitly (plugins/system/languagefilter: getMethod() === 'POST' || count($input->post) > 0 || … || nolangfilter=1 short-circuits the language detection). It does not exempt anything else. Measured against a live multilingual install:

MethodUnprefixed /cb-api/v1/product/25
GET301/en/…
POSTreaches the API (no redirect)
PATCH301/en/…
PUT301/en/…
DELETE301/en/…

So the dangerous case is PATCH and DELETE, not POST. RFC 9110 still permits a client to re-issue a redirected request as GET (the historical 301 behaviour, and what curl's -L does without --post301), which turns an update into a read: the caller gets 200 and a record, the record is unchanged, and nothing reports a failure. A write that silently does nothing while answering 200 is the worst shape a bug can take.

A writing client must address the prefixed URL directly. The OpenAPI server variable puts the language segment in the default base URL, and the Postman collection's baseUrl says so in its description, for exactly this reason. (nolangfilter=1 also suppresses the redirect, but it forces the default language, so it is a worse answer than getting the URL right.)


4. Authentication

KenedoController::isAuthorized() returns true for any controller whose name does not start with admin. apiv1 does not. So the framework grants this controller nothing, and it must do all of its own gating — which is why isAuthorized() is overridden to return true with a comment saying so, rather than left to be read as an oversight.

Authorization: Bearer cbx_7_YvH_W6rwY11RumAsUWlq8Hd7tCpuEt1ARPIsFlXZyBs
│ │ │
│ │ └── 43 chars, base64url of 32 CSPRNG bytes. Never stored.
│ └──── the row id: verification is an indexed read, not a table scan
└─────── a prefix a secret scanner can recognise in a leaked file

verify() looks the row up by id, compares with hash_equals() against the stored SHA-256, then checks revoked and expires. Every failure — malformed, unknown, wrong, revoked, expired — returns the same 401 body: distinguishing them tells a prober which half of a guess was right.

Scope maps to method: read for GET, write for POST/PATCH/DELETE. Insufficient scope is 403, not 401 — the caller is authenticated, and retrying the same credentials will never help. That distinction is what a client's retry logic reads.

Headers on refusals go through the platform seam. A 401 without WWW-Authenticate and a 405 without Allow are non-conforming, not merely unhelpful, and a bare header() does not reliably survive the host — the same reason setResponseStatus() exists. Hence InterfaceKenedoPlatform::setResponseHeader().

The Apache trap. Apache does not pass the Authorization header to PHP unless CGIPassAuth is on or a rewrite copies it — and where a rewrite does it, the value arrives as REDIRECT_HTTP_AUTHORIZATION. getBearerToken() reads both names and falls back to apache_request_headers(). A surface that reads only the first name works on nginx and mysteriously refuses every token on a stock Apache.


5. Reading the request

KRequest cannot be used for a write body, and that is not a bug in KRequest. Its $from='METHOD' default reads $_GET or $_POST off REQUEST_METHOD, and PHP populates $_POST for neither PATCH nor DELETE, nor for a POST whose content type is application/json.

So getWriteFields() reads php://input and decides by content type, not by whether the body happens to parse:

Content-TypeRead as
application/json, or absent with a JSON bodyjson_decode, must be an object
application/x-www-form-urlencoded, multipart/form-dataform fields
anything else with a non-JSON body422, naming the two that work

Form bodies work on every method, not just POST: PHP fills $_POST for POST alone, so the others are parsed out of the raw body with parse_str(). Without that, a form-encoded PATCH reads as "no fields sent" and answers 200 for a change that did not happen.

getFormFields() strips the router's own variables (option, task, cb_api_path, …) — but keeps any of those names that is genuinely a field of the entity being written. Nothing collides today across every committed write schema; that was measured, and is exactly when to make a silent drop impossible rather than unlikely.


6. The shared layer

ConfigboxEntityApiHelper holds three things, and it is worth naming them separately because they fail differently.

The registry

getEntityModels() — every model that returns a name from getEntityName(). ConfigboxTypeGenerator walks the same answer when it writes generated/records/read/*.php and generated/schemas/**, so the entity list, the shipped schemas and the record stubs cannot disagree. A caller-supplied name is matched against this registry, never turned into a class name, so no caller input reaches an autoloader.

ConfigboxMcpHelper asks this class for the list and adds only policy on top (which entities it may write). Two discoveries would eventually disagree, and the disagreement surfaces as an entity the tools list and the schemas do not describe.

The write pipeline

public static function runStorePipeline($model, $data) {
$model->prepareForStorage($data);
$wasInsert = (bool) $model->isInsert($data); // ← between, not before, not after
$model->validateData($data);
$model->store($data);
ConfigboxCacheHelper::purgeCache();
return array('id' =>, 'wasInsert' => $wasInsert);
}

The middle line is the subtle one. isInsert() must be asked after prepareForStorage(), because a property may fill the table key in there, and before store(), because store() sets it on the way out. That window is the only place the answer is right; get it wrong and a create reports itself as an update — a 201 silently becoming a 200.

Four callers reach this method: the REST controller, the MCP tools, anything in-process, and — through KenedoController::store() — the admin UI and configbox:run-task. The only honest difference between them is how a $data object is obtained; everything below that line is identical by construction rather than by agreement.

The wire shape

Reads and writes return records already projected: flat, underscore-separated per-language keys (title_en_GB), the money objects, and nothing that needs unpacking — the same keys the model hydrates and the admin forms post. ConfigboxApiRecord does the work; this class decides that it happens. (Translations were briefly nested under their field; flat won — see §5 of the API contract.)

That placement is load-bearing: the projection lives in the shared layer, not the HTTP controller, so a single-record read, a collection row, an MCP tool result and a CLI read all carry the same shape. One field never has two shapes decided by which call you happened to make.

The rule this restores is the one at the top of this article: an edge may translate a status, a message or an argument, and may refuse. It may not disagree about the shape of a record.

Four methods that look odd and are not

Each of these cost a debugging session, so the comments on them are load-bearing:

  • buildDataFromFields() injects the caller's fields into the request and lets the properties read them, because Kenedo has no "make me a record from an array" seam — every property reads itself off the request, which is what knows that a translatable is one key per language. A create goes through initData() (which applies declared defaults); an update through getDataFromRequest() seeded from the stored record, which is what makes PATCH partial.
  • decodeRuleFields() clones before decoding, because getRecord() hands back a memoised object — decoding in place rewrote the model's own cached row, and a read followed by an update in the same process then wrote NULL into a NOT NULL column.
  • dropUnacceptableDefaults() clears a seeded default the property itself would reject. initData() applies declared defaults without checking them, and some declarations disagree with themselves (default => 'accordeon' against a choices list containing only 'tabs').
  • nullBaseTableValuesToEmptyStrings() turns a seeded NULL into '' on NOT NULL columns — mirroring what a browser posts, since an untouched input sends "" and initData() is the only thing producing NULL. Nullability is asked of the schema (SHOW COLUMNS, memoised), not inferred: product.taxclass_id is a genuinely nullable foreign key, and blanking it produced a 0 no tax class has. How far this has been exercised beyond the catalog entities is recorded honestly in com_configbox_entity_api.md §8.

Errors are typed, and that is the whole interface

KenedoValidationException → 422 + validationIssues the caller can fix it
KenedoNotFoundException → 404 no such entity or record
KenedoSystemException → 500 + a log identifier something broke; detail stays in the log

The shared layer lets these out. The MCP helper flattens them to prose at its own boundary (flattenForTool()), because a JSON-RPC tool result is text with no status code and no field slot. Doing that flattening in the shared layer — as the code did before it was extracted — throws away the per-field issues that an HTTP 422 needs in order to say where.

dispatch() catches all three, plus Throwable for anything untyped (including an Error, which is not an Exception). A stack trace never reaches the response: it goes to the log, and the caller gets the identifier.


7. No authorization in the shared layer — on purpose

Not an oversight, and not a thing to fix by adding a check.

The stdio MCP server deliberately has no permission model: its trust boundary is shell access to the site, the same as every configbox:* command. A gate inside ConfigboxEntityApiHelper would break that caller while adding nothing to the HTTP one, which authenticates before it ever gets there.

Authorization belongs to the surface. The REST controller does bearer tokens; the admin controller does core.manage; MCP does none, deliberately.


8. The documentation artifacts

All three are built from the OpenAPI document, which is built from the registry and the committed schemas. Nothing is written twice.

models (getPropertyDefinitions)

├─ ConfigboxTypeGenerator ──→ generated/records/read/*.php (PHP IDE stubs)
│ └──→ generated/schemas/{read,write}/*.json

└─ ConfigboxEntityApiHelper::getEntityModels() (the registry)

ConfigboxOpenApiHelper::buildDocument() ← inlines those schemas

┌─────────┼─────────────────────┐
│ │ │
openapi.json configbox.postman_ api-reference.html
collection.json

configbox:api:export writes all three. Under CLI the base URL cannot be detected, so pass --live-site=https://your-store.example (or set a real $live_site in configuration.php) — otherwise every URL in the artifacts carries Joomla's joomla.invalid placeholder host: wrong, but visibly wrong, on purpose. Every schema is inlined into components/schemas, not $ref-ed to the served URLs: those URLs need a bearer token, and no importer sends one while dereferencing. That single decision is the difference between a document Postman can read and one it silently imports with empty bodies.

The runtime endpoints (configurator, cart, auth) cannot be generated — they are controller methods, and what previewSelection returns is a shape assembled in PHP, not a table. ConfigboxRuntimeApiCatalog is that table, written by hand, and it says at the top that adding a task means adding a row in the same commit. A missing entry is invisible; a wrong one is worse than absent.

What a consumer actually sees

A schema is not documentation until something renders it. Each artifact carries the same four things, materialised by ConfigboxApiExampleHelper (helpers/apiexamples.php) so a sample in the HTML cannot disagree with the one in Postman:

Fields and typesPer-field proseSample requestSample response
openapi.jsoncomponents/schemasdescription per propertycomposed by the viewer from the schemacomposed from the response schema
Postman collectionfull body + markdown field tablein the tablethe body itselfsaved response[] examples
api-reference.htmlfield tablesin the table<details> sample block<details> sample block

Two conventions worth knowing before reading a generated body:

  • Every writable field is present, not just the required ones. The body is where a reader looks to find out what a write accepts, so a body carrying four of seventy fields is not a convenience — it documents a four-field entity. Read-only fields are omitted entirely, since sending one is an error rather than a choice, and the field table names what is notWritable so its absence reads as a property of the entity rather than a gap in the docs.
  • Required fields carry a usable value; optional ones carry a typed placeholder<string>, <integer>, and a closed set spelled out as <none|composite|shapediver>. Delete the placeholders you do not want and the request runs. This is deliberately unlike collections that placeholder everything: this API refuses unknown fields and validates types, so an all-placeholder body would 422 on the first Send.

Saved response examples exist for the success status and for 422, because the failure shape — code, validationIssues — is the half a client author has to branch on, and it is the half a collection normally leaves undocumented.

Naming operations

A summary is a name, not a sentence: it becomes the Postman request title, the HTML heading and the method name in a generated client. The runtime endpoints were originally named conversationally ("What would happen if I chose this?"), which read well in a list and badly everywhere a name is required. They are now imperative and formal (Preview a selection), and the conversational framing that explained why you would call it moved into the description, where it belongs. The description spec asserts no summary is a question.


9. Where to make a change

You want to…Change
Add or alter a fieldThe model's getPropertyDefinitions(), then configbox:generate-types
Change how a field is describedThe property's label / tooltip — both schemas render them
Add an entity to the APIReturn a name from that model's getEntityName(). Nothing else
Put an entity in a doc groupConfigboxOpenApiHelper::getEntityGroups()
Change storage behaviourConfigboxEntityApiHelper — every surface gets it
Add a runtime endpointThe controller, and ConfigboxRuntimeApiCatalog
Change a status codeConfigboxControllerApiv1 — translation is the edge's job
Let MCP write a new entityThe writable flag in ConfigboxMcpHelper::getEntities()

If you find yourself changing the same thing twice, one of the two places is wrong.


10. See also

  • com_configbox_entity_api.md — using the API
  • com_configbox_api_contract.md — the response contract, and why there is no success field
  • com_configbox_mcp_server_internals.md — the same treatment for MCP
  • com_configbox_type_generation.md — where the schemas and stubs come from
  • com_configbox_cli_commands.md §1.9, §1.11 — the token and export commands