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 get*Data(), 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 get*Data() 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() → get*Data() |
run() → startup() → action*() → get*Data() |
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 get*Data(), 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.
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.
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".
Procedure: adding a new read#
- The Api presenter gets a
get<Action>Data()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 get*Data() / 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.
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() |
Follow-up chapters: Five-layer model · Hydrators · RBAC in the API · Cache