# The HTTP API, from the inside

> 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, …

Source: CBX documentation, version 4.0 preview (unreleased). Canonical page: https://docs.configbox.at/docs/4.0-preview/technical/http_api. Last updated 2026-08-02.

---
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**:

```php
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:

```php
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

```php
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 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 `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 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 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
