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

  1. Read every parameter from getRequestParameters() through RequestParamShape, not with (int) or boolval().
  2. On null call sendInvalidRequestParameter('<name>') and stop — before RBAC and cache.
  3. Check filters from the caller with FilterBuilder::isBuildable() (see Filters).
  4. Cap the listing size with externalListLimit(). Without a cap an external get-all would 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

  1. In the Api presenter write internalAction<Action>() without action*() (404 from outside), the guard on the first line and a response with a single bool value, no data about the record.
  2. 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 in tests/Unit/Security/FrontApiSurfacePolicyTest.php.
  3. In the front repository read through failSoftRead() and return a missing response key as null.
  4. In the validation compare === false, never !$exists or ?? 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#

  1. The Api presenter gets a internalAction<Action>() 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 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

  1. Ask $this->shouldProduceNotifications() before creating the notification.
  2. Do not decide permissions by the source — it is a branch marker, RBAC handles access.
  3. 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

Cookie settings