Calling the API#
Neither the front end nor the administration touches the database. They request data from
the Api layer through the bridge app/Core/Bridge/ApiBridge.php — and it makes no
difference whether the Api runs in the same process or as a separate service.
The whole flow#
Front / Admin presenter or component
│ $manager->findAll(…)->execute()
▼
Manager → Service → Mapper → Repository
│ $this->bridge->call($apiEndpoint . '/get-all', $params, null, 'POST')
▼
ApiBridge::call()
├── internal ──► builds the Api presenter and calls internalAction*(), NO HTTP
└── external ──► an HTTP request to /api/<section>/<presenter>/<action>
▼
Api presenter → array → (JSON)
▼
Mapper → EntityHydrator → Front / Admin entity
Hydrating the response is covered by Hydrators, the layers by the Five-layer model.
Endpoint conventions#
The repository carries an apiEndpoint (api/blog/news) and the methods map onto
sub-actions in a fixed way:
| Repository method | Endpoint |
|---|---|
find($id) |
<endpoint>/get |
findBy($filters) |
<endpoint>/get-by |
findAll($filters, …) |
<endpoint>/get-all |
save($data) |
<endpoint>/save |
updateColumns($ids, $values) |
<endpoint>/update-columns |
delete($id) / deleteAll($filters) |
<endpoint>/delete / /delete-all |
| moving in a tree | <endpoint>/move-subtree, /delete-node |
The Admin repository calls /get-all, not /find-all
It is a common typo when writing a new module — and it surfaces as an empty listing, not as an error.
Internal versus external calls#
| Internal (the default) | External | |
|---|---|---|
| How | the presenter is built and internalAction*() is called |
an HTTP request |
| Speed | no serialisation, no network | with overhead |
| When | administration, front end, CLI | Api as a separate service, a third-party client |
| What runs | createPresenter() → internalAction*() |
run() → startup() → action*() → internalAction*() |
The default type is internal (config/Shared/api.neon → apiBridge.defaultCallType).
startup() does NOT run on the internal path
And neither does anything inside it — the gate on the external path, CORS, the JWT
check. That is why the authorisation guard lives in internalAction*(), which both paths
go through. A check written into startup() or into action*() does not apply to the
administration or the front end. In detail: RBAC in the API.
The body of every internalAction*() is wrapped in operation() of the base Api presenter:
return $this->operation(__FUNCTION__, function () { … });, with the guard as the first line
inside. Both paths thus pass through one place where an operation (one Api call) begins and
ends. A new action keeps the same shape.
public function internalActionIsBlacklisted(string $email = '', string $phone = '', string $ip = ''): array
{
return $this->operation(__FUNCTION__, function () use ($email, $phone, $ip) {
if (($denied = $this->requireApiPermission('isBlacklisted')) !== null) { return $denied; }
// …
});
}
Internal calls do NOT bypass authorisation today
They used to. Today the guard runs on the internal path as well
(apiRbac.internalMode: enforce), so the administration and the front end get
Permission denied exactly like a third-party client — they just get it as a Result
rather than as an HTTP 403.
Parameters of an external action: RequestParamShape#
On the external path parameters arrive as strings or arrays, and a plain PHP cast silently changes their meaning:
| Input | Cast | RequestParamShape |
|---|---|---|
['999'] |
(int) → 1 |
positiveInt() → null |
'false' |
(bool) → true |
bool() → false |
'1e2' |
(int) → 100 |
positiveInt() → null |
'99999999999999999999' |
(int) → PHP_INT_MAX |
positiveInt() → null |
a scalar into an array typed helper |
TypeError (HTTP 500) |
arrayOrNull() → null |
Every method returns null for a value of the wrong shape and the action answers 400
(sendInvalidRequestParameter($name), code VALIDATION_FAILED) instead of acting on a
different value. An internal call passes native types straight to internalAction*() and does not
come through here.
| Method | Accepts |
|---|---|
positiveInt(), nonNegativeInt() |
a native int, or a string of digits only without overflow; nonNegativeInt() also takes 0 (tree root, "first position") |
positiveIntList() |
a non-empty array where every item is a positiveInt() |
bool() |
a native bool, 1/0, 1\|0\|true\|false\|on\|off (case-insensitive); not 'yes', '', null |
stringOrList() |
a non-empty string, or a list of non-empty strings (includes) |
arrayOrNull(), isArrayOrAbsent() |
an array; for an optional parameter also a missing one |
externalListLimit() |
limit of an external get-all: missing or nonsense = 50, more than 500 is clamped |
Procedure: adding an external action with parameters#
- Read every parameter from
getRequestParameters()throughRequestParamShape, not with(int)orboolval(). - On
nullcallsendInvalidRequestParameter('<name>')and stop — before RBAC and cache. - Check filters from the caller with
FilterBuilder::isBuildable()(see Filters). - Cap the listing size with
externalListLimit(). Without a cap an externalget-allwould return the whole table; internal calls and exports stay unlimited.
(int) on a parameter from the wire = an action on another record
?id[]=999 becomes 1 through (int), so instead of rejecting the request the action
touches the record with id 1. Nothing fails and the response looks like a success.
A failed response: where it turns into "there is no data"#
This is the most important thing about the whole bridge. The bridge serves two irreconcilable contracts:
| Contract | Caller | What a failure envelope means |
|---|---|---|
| "give me data" | find(), findBy(), findAll() |
a defect → it has to become emptiness |
| "do it and tell me whether it worked" | save(), delete(), updateColumns(), the tree |
the point of the call — 83 places in app/ read ['success'] |
The bridge therefore does not rewrite the envelope, it only stops hiding it: it writes
API_FAILSOFT source=bridge … into the auth channel. The decision "this was supposed to
be data" is made later by FailSoftReadTrait in the repository's read methods — and the
second safety net is the hydrator, which refuses to declare a failure envelope an entity.
Without that, a failure envelope would become a nonsensical entity
In the hydrator a TypeError (the code key is a number, the setter expects
?string), outside it a silent empty page with not a single line in the log. Hence
the defence in two layers.
When a page shows nothing, look for API_FAILSOFT in the auth log
The line carries the endpoint, the path (internal/external) and the caller. It is
the difference between "there really is no data" and "you were not given it".
Existence check: "empty" does not mean "missing"#
A fail-soft read cannot tell whether a record does not exist or just did not arrive (guard,
outage). A check built on findBy(...) === null is therefore fail-open: a refused read
looks like "free" or "not on the list" and the check silently stops working.
The front end asks such questions through dedicated internal actions that return only yes/no,
and carries the result as ?bool:
| Question | Front method | Api action | Response |
|---|---|---|---|
| is the username taken | UserManager::usernameExists($username, $excludeId) |
:Api:System:User:usernameExists |
{exists: bool} |
| is the e-mail taken | UserManager::emailExists($email, $excludeId) |
:Api:System:User:emailExists |
{exists: bool} |
| is the e-mail, phone or IP blacklisted | BlacklistManager::findBlacklistedTypes(?$email, ?$phone, ?$ip) → ?list<BlacklistType> |
:Api:System:Blacklist:isBlacklisted (internalActionIsBlacklisted()) |
{blacklisted: bool, types: int[]} |
null means "could not be determined" and the caller evaluates it as a failed validation
(registration: isFree() returns $exists === false; the blacklist returns null and registration then fails with a generic error, fail-closed). The user sees
"already exists" and tries again instead of passing on to a duplicate.
Procedure: a new existence check from the front end
- In the Api presenter write
internalAction<Action>()withoutaction*()(404 from outside), the guard on the first line and a response with a single bool value, no data about the record. - Create the action key with a grant for the roles that use the form (registration =
guest) and record it with a reason in the register of non-admin grants intests/Unit/Security/FrontApiSurfacePolicyTest.php. - In the front repository read through
failSoftRead()and return a missing response key asnull. - In the validation compare
=== false, never!$existsor?? false.
Never turn null into false
That brings fail-open back: with a refused grant or an Api outage, registration passes on a
taken name (only the unique index in the database stops it, with an error) and the blacklist
silently switches off. Do not use the generic getBy on the User or Blacklist entity for
it: it is admin-only and a guest grant would hand out the whole entity, or the whole blacklist.
Procedure: adding a new read#
- The Api presenter gets a
internalAction<Action>()with the permission guard on its first line (see RBAC). - The permission key goes into the database — without it, it is a fail-closed DENY.
- The Front/Admin repository gets a method calling
bridge->call()over itsapiEndpoint. - The mapper hydrates the response; the service and the manager only pass it through.
- Verify both paths — through a page (internal) and by a direct HTTP call if the endpoint is meant to be public.
An Api manager called directly bypasses the presenter's processing
Stripping fields by permission, filling in the owner and normalising the payload all
live in internalAction*() / actionSave(), not in the manager. Calling a manager directly is
therefore fine only where it is intended (cron) and never as a substitute for the bridge
from the front end — it surfaces as an empty association silently not being saved.
Cache#
A call can be taken out of the cache one-off:
$payload = $this->bridge->call('api/blog/article/get-all', [
'filters' => $filters,
ApiBridge::PARAM_USE_CACHE => false,
], null, 'POST');
The control key is stripped from the parameters on BOTH paths
If it stayed, it would be sent externally as an ordinary request parameter — and become
a cache switch controllable from outside. It therefore never reaches the Api method
and must not appear in its signature, or the call ends with Unknown named parameter.
In detail: Cache.
Where the call came from: PARAM_SOURCE#
The second payload control key, ApiBridge::PARAM_SOURCE (_callSource), says which branch
a write comes from: front or admin. The shared repository SaveTrait adds it to every
save() from getApiCallSource() of the base Front and Admin ApiRepository. The bridge
strips it from the arguments just like PARAM_USE_CACHE and hands it to the Api presenter
through setApiCallSource().
| Value | Who sends it | shouldProduceNotifications() |
|---|---|---|
admin |
the admin ApiRepository |
false — the administration creates no bell notifications |
front |
the front ApiRepository |
true |
null |
external HTTP, cron, a repository outside the base ApiRepository, an unknown value |
true — a forgotten path must not stay silent |
Procedure: a notification producer in an Api presenter
- Ask
$this->shouldProduceNotifications()before creating the notification. - Do not decide permissions by the source — it is a branch marker, RBAC handles access.
- Check both branches: a save from the front end creates the notification, one from the administration does not.
No internalAction*() may have a parameter named like a control key
call() always strips the key from the arguments, so an Api method parameter of the same
name would never receive a value from the bridge. That is why the key name starts with an
underscore and does not collide with a business parameter (such as source on stock
documents).
It cannot be injected from outside
proxy() drops both control keys from HTTP parameters and the external actionSave()
reaches internalAction*() without the bridge. _callSource=admin in a foreign request
therefore does not silence notifications.
Is this an internal call?#
Code that must behave differently for server code and for an external client (typically
accepting a temporary {name, path} file from a form) asks
App\Core\Bridge\InternalCallContext::isActive(). Only ApiBridge::internalCall() raises
the flag (and lowers it again in finally); it is a depth counter, so a nested call restores
the outer state.
Do not use FieldAudienceContext::isInternal() for this
The RBAC guard fills it, and the emergency switch apiRbac.internalMode: off turns the
guard off — taking the "call is internal" information with it, so uploaded files, for
example, would silently not be saved. The emergency mode must let everything through,
not take things away.
CLI and harnesses#
A script going through the bridge has no session, so it runs as guest
In enforcing mode it gets no data — and it does not return an authorisation error, it
returns emptiness, because "Permission denied" stays inside the bridge. A test over
that passes green and tests nothing. The fix:
ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)).
Cron is not affected
It injects the Api managers directly, not through the bridge.
Where to look#
| I want | Where |
|---|---|
| the bridge and its configuration | app/Core/Bridge/ApiBridge.php, config/Shared/api.neon |
| the mapping of methods onto endpoints | app/Core/Base/Front/Models/Repositories/ApiRepository.php |
| the repository's write methods | app/Core/Traits/Shared/Models/Repositories/{Save,Delete,Tree}Trait.php |
| turning a failure into emptiness | app/Core/Traits/Shared/Models/Repositories/FailSoftReadTrait.php |
| recognising a failure envelope | app/Core/Utils/Results/Result.php → isFailureEnvelope() |
| the shape of external action parameters | app/Core/Utils/Helpers/RequestParamShape.php, app/Core/Traits/Api/Presenters/RequestShapeGuardTrait.php |
| the internal-call flag | app/Core/Bridge/InternalCallContext.php |
| call source and notifications | ApiBridge::PARAM_SOURCE, app/Core/Base/Api/BasePresenter.php → shouldProduceNotifications() |
Follow-up chapters: Five-layer model · Hydrators · RBAC in the API · Cache