Skip to content
V
For developers
Architecture, conventions, the core and security
Core / Calling the API

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.neonapiBridge.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#

  1. The Api presenter gets a get<Action>Data() with the permission guard on its first line (see RBAC).
  2. The permission key goes into the database — without it, it is a fail-closed DENY.
  3. The Front/Admin repository gets a method calling bridge->call() over its apiEndpoint.
  4. The mapper hydrates the response; the service and the manager only pass it through.
  5. 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.phpisFailureEnvelope()

Follow-up chapters: Five-layer model · Hydrators · RBAC in the API · Cache