The HTTP API, from the inside
- Version
- 4.0 preview
- Updated
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
| Class | File | Responsibility |
|---|---|---|
PlgSystemConfigbox | plugins/system/configbox/ (outer repo) | Claims /cb-api/. Turns a REST path into controller=apiv1, task=dispatch, cb_api_path=… |
ConfigboxControllerApiv1 | controllers/apiv1.php | The HTTP edge: routing, auth, body reading, status codes |
ConfigboxApiTokenHelper | helpers/apitokens.php | Mint, verify, revoke bearer tokens |
ConfigboxEntityApiHelper | helpers/entityapi.php | The shared layer. Registry + CRUD + the write pipeline |
ConfigboxRuleAuthoringHelper | helpers/ruleauthoring.php | Rule ⇄ engine token stream |
ConfigboxApiRecord | classes/ConfigboxApiRecord.php | Record ⇄ wire shape (flat underscore translation keys) |
ConfigboxApiResponse | classes/ConfigboxApiResponse.php | The envelope and RFC 9457 problem bodies |
ConfigboxOpenApiHelper | helpers/openapi.php | The OpenAPI 3.1 document |
ConfigboxRuntimeApiCatalog | helpers/runtimeapicatalog.php | The hand-written table of configurator/cart endpoints |
ConfigboxPostmanHelper | helpers/postman.php | OpenAPI → Postman Collection v2.1 |
ConfigboxApiExampleHelper | helpers/apiexamples.php | Schema → example value, shared by the collection and the HTML |
ConfigboxApiHtmlHelper | helpers/apihtml.php | OpenAPI → the HTML reference |
ConfigboxTypeGenerator | helpers/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:
| Method | Unprefixed /cb-api/v1/product/25 |
|---|---|
GET | 301 → /en/… |
POST | reaches the API (no redirect) |
PATCH | 301 → /en/… |
PUT | 301 → /en/… |
DELETE | 301 → /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-Type | Read as |
|---|---|
application/json, or absent with a JSON body | json_decode, must be an object |
application/x-www-form-urlencoded, multipart/form-data | form fields |
| anything else with a non-JSON body | 422, 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 throughinitData()(which applies declared defaults); an update throughgetDataFromRequest()seeded from the stored record, which is what makesPATCHpartial.decodeRuleFields()clones before decoding, becausegetRecord()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 wroteNULLinto aNOT NULLcolumn.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 achoiceslist containing only'tabs').nullBaseTableValuesToEmptyStrings()turns a seeded NULL into''on NOT NULL columns — mirroring what a browser posts, since an untouched input sends""andinitData()is the only thing producing NULL. Nullability is asked of the schema (SHOW COLUMNS, memoised), not inferred:product.taxclass_idis a genuinely nullable foreign key, and blanking it produced a0no tax class has. How far this has been exercised beyond the catalog entities is recorded honestly incom_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 types | Per-field prose | Sample request | Sample response | |
|---|---|---|---|---|
openapi.json | components/schemas | description per property | composed by the viewer from the schema | composed from the response schema |
| Postman collection | full body + markdown field table | in the table | the body itself | saved response[] examples |
api-reference.html | field tables | in 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
notWritableso 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 would422on 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 field | The model's getPropertyDefinitions(), then configbox:generate-types |
| Change how a field is described | The property's label / tooltip — both schemas render them |
| Add an entity to the API | Return a name from that model's getEntityName(). Nothing else |
| Put an entity in a doc group | ConfigboxOpenApiHelper::getEntityGroups() |
| Change storage behaviour | ConfigboxEntityApiHelper — every surface gets it |
| Add a runtime endpoint | The controller, and ConfigboxRuntimeApiCatalog |
| Change a status code | ConfigboxControllerApiv1 — translation is the edge's job |
| Let MCP write a new entity | The 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 APIcom_configbox_api_contract.md— the response contract, and why there is nosuccessfieldcom_configbox_mcp_server_internals.md— the same treatment for MCPcom_configbox_type_generation.md— where the schemas and stubs come fromcom_configbox_cli_commands.md§1.9, §1.11 — the token and export commands