Přeskočit obsah
V
Pro vývojáře
Architektura, konvence, jádro systému a bezpečnost
Jádro / Volání API

Volání API#

Front ani administrace nesahají do databáze. Data si vyžádají od Api vrstvy přes most app/Core/Bridge/ApiBridge.php — a je jedno, jestli Api běží v témž procesu, nebo jako samostatná služba.

Celý tok#

Front / Admin presenter nebo komponenta
        │  $manager->findAll(…)->execute()
        ▼
Manager → Service → Mapper → Repository
        │  $this->bridge->call($apiEndpoint . '/get-all', $params, null, 'POST')
        ▼
ApiBridge::call()
        ├── internal ──► sestaví Api presenter a zavolá internalAction*(), BEZ HTTP
        └── external ──► HTTP požadavek na /api/<sekce>/<presenter>/<akce>
                                  ▼
                        Api presenter → pole → (JSON)
        ▼
Mapper → EntityHydrator → Front / Admin entita

Hydrataci odpovědi popisují Hydrátory, vrstvy Pětivrstvý model.

Konvence endpointů#

Repository nese apiEndpoint (api/blog/news) a metody se na sub-akce mapují pevně:

Metoda repository 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
přesun ve stromu <endpoint>/move-subtree, /delete-node

Admin repository volá /get-all, ne /find-all

Je to častý překlep při psaní nového modulu — a projeví se prázdným výpisem, ne chybou.

Interní versus externí volání#

Interní (výchozí) Externí
Jak sestaví se presenter a zavolá internalAction*() HTTP požadavek
Rychlost bez serializace a bez sítě s režií
Kdy admin, front, CLI Api jako samostatná služba, cizí klient
Co běží createPresenter() → internalAction*() run() → startup() → action*() → internalAction*()

Výchozí typ je internal (config/Shared/api.neon → apiBridge.defaultCallType).

Na interní cestě NEBĚŽÍ startup()

A s ním ani nic, co je v něm — gate na externí cestě, CORS, kontrola JWT. Proto je autorizační guard v internalAction*(), kudy prochází obojí. Kontrola napsaná do startup() nebo do action*() pro admin a front neplatí. Podrobně RBAC v API.

Tělo každé internalAction*() je zabalené v operation() základního Api presenteru: return $this->operation(__FUNCTION__, function () { … });, guard je první řádek uvnitř. Obě cesty tak procházejí jedním místem, kde operace (jedno volání Api) začíná a končí. Nová akce dodrží stejný tvar.

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; }
        // …
    });
}

Interní volání dnes NEobchází autorizaci

Bývalo to tak. Dnes běží guard i na interní cestě (apiRbac.internalMode: enforce), takže admin a front dostanou Permission denied úplně stejně jako cizí klient — jen ho dostanou jako Result, ne jako HTTP 403.

Parametry externí akce: RequestParamShape#

Na externí cestě přijdou parametry jako řetězce nebo pole a holé přetypování v PHP jim tiše změní význam:

Vstup Přetypování RequestParamShape
['999'] (int) → 1 positiveInt() → null
'false' (bool) → true bool() → false
'1e2' (int) → 100 positiveInt() → null
'99999999999999999999' (int) → PHP_INT_MAX positiveInt() → null
skalár do helperu s typem array TypeError (HTTP 500) arrayOrNull() → null

Každá metoda vrací pro hodnotu špatného tvaru null a akce odpoví 400 (sendInvalidRequestParameter($jmeno), kód VALIDATION_FAILED), místo aby pracovala s jinou hodnotou. Interní volání předává nativní typy rovnou do internalAction*() a tudy nejde.

Metoda Projde
positiveInt(), nonNegativeInt() nativní int, nebo řetězec jen z číslic bez přetečení; nonNegativeInt() bere i 0 (kořen stromu, „první pozice“)
positiveIntList() neprázdné pole, kde je každá položka positiveInt()
bool() nativní bool, 1/0, 1\|0\|true\|false\|on\|off (bez ohledu na velikost); 'yes', '', null ne
stringOrList() neprázdný řetězec, nebo seznam neprázdných řetězců (includes)
arrayOrNull(), isArrayOrAbsent() pole; u volitelného parametru i chybějící
externalListLimit() limit vnějšího get-all: chybějící nebo nesmyslný = 50, víc než 500 se ořízne

Postup: přidávám externí akci s parametry#

  1. Každý parametr z getRequestParameters() přečti přes RequestParamShape, ne (int) ani boolval().
  2. Při null zavolej sendInvalidRequestParameter('<jméno>') a skonči — ještě před RBAC a cache.
  3. Filtry od volajícího ověř FilterBuilder::isBuildable() (viz Filtry).
  4. Velikost výpisu omez externalListLimit(). Bez stropu by vnější get-all vrátil celou tabulku; interní volání a exporty zůstávají bez omezení.

(int) na parametru z drátu = akce nad jiným záznamem

?id[]=999 z (int) udělá 1, takže akce místo odmítnutí sáhne na záznam s id 1. Nic nespadne a v odpovědi to vypadá jako úspěch.

Selhaná odpověď: kde se z ní stane „data nejsou“#

Tohle je nejdůležitější věc na celém mostu. Most obsluhuje dva neslučitelné kontrakty:

Kontrakt Volající Co znamená obálka selhání
„dej mi data“ find(), findBy(), findAll() vada → musí se z ní stát prázdno
„proveď a řekni, jestli to prošlo“ save(), delete(), updateColumns(), strom smysl volání — 83 míst v app/ čte ['success']

Proto most obálku nepřepisuje, jen ji přestane tajit: zapíše API_FAILSOFT source=bridge … do auth kanálu. Rozhodnutí „tohle měla být data“ dělá až FailSoftReadTrait ve čtecích metodách repository — a druhou pojistkou je hydrátor, který selhanou obálku odmítne prohlásit za entitu.

Bez toho by z obálky selhání vznikla nesmyslná entita

V hydrátoru TypeError (klíč code je číslo, setter čeká ?string), mimo něj tichá prázdná stránka bez jediného řádku v logu. Proto ta obrana ve dvou vrstvách.

Když stránka ukazuje prázdno, hledej API_FAILSOFT v auth logu

Řádek nese endpoint, cestu (internal/external) i volajícího. Je to rozdíl mezi „data opravdu nejsou“ a „nedostal jsi je“.

Kontrola existence: „prázdno“ neznamená „není“#

Z fail-soft čtení nejde poznat, jestli záznam neexistuje, nebo jen nepřišel (guard, výpadek). Kontrola postavená na findBy(...) === null je proto fail-open: zamítnuté čtení vypadá jako „volné“ nebo „není na seznamu“ a kontrola tiše přestane fungovat.

Front takové otázky klade vyhrazeným interním akcím, které vrací jen ANO/NE, a výsledek nese jako ?bool:

Otázka Front metoda Api akce Odpověď
je přezdívka obsazená UserManager::usernameExists($username, $excludeId) :Api:System:User:usernameExists {exists: bool}
je e-mail obsazený UserManager::emailExists($email, $excludeId) :Api:System:User:emailExists {exists: bool}
je e-mail, telefon nebo IP na blacklistu BlacklistManager::findBlacklistedTypes(?$email, ?$phone, ?$ip) → ?list<BlacklistType> :Api:System:Blacklist:isBlacklisted (internalActionIsBlacklisted()) {blacklisted: bool, types: int[]}

null znamená „nedalo se určit“ a volající ho vyhodnotí jako neúspěšnou validaci (registrace: isFree() vrací $exists === false; blacklist vrací null a registrace pak neprojde s obecnou chybou, fail-closed). Uživatel uvidí „už existuje“ a zkusí to znovu, místo aby prošel na duplicitu.

Postup: nová kontrola existence z frontu

  1. V Api presenteru napiš internalAction<Akce>() bez action*() (zvenčí 404), guard na prvním řádku a odpověď s jedinou hodnotou typu bool, žádná data o záznamu.
  2. Založ klíč akce s grantem pro role, které formulář používají (registrace = guest), a zapiš ho s důvodem do registru neadministrátorských grantů v tests/Unit/Security/FrontApiSurfacePolicyTest.php.
  3. Ve front repository čti přes failSoftRead() a chybějící klíč odpovědi vrať jako null.
  4. Ve validaci porovnávej === false, nikdy !$exists ani ?? false.

null nikdy nepřeklápěj na false

Tím se vrátí fail-open: při zamítnutém grantu nebo výpadku Api projde registrace na obsazené jméno (zastaví ji až unikátní index v DB chybou) a blacklist se tiše vypne. Generické getBy na entitu User nebo Blacklist k tomu nepoužívej: je jen pro administrátora a grant hostovi by vydal celou entitu, resp. celý blacklist.

Postup: přidávám nové čtení#

  1. Api presenter dostane internalAction<Akce>() s guardem oprávnění na prvním řádku (viz RBAC).
  2. Klíč oprávnění do databáze — bez něj je to fail-closed DENY.
  3. Front/Admin repository dostane metodu, která zavolá bridge->call() nad apiEndpoint.
  4. Mapper odpověď zhydratuje; service a manager ji jen propustí.
  5. Ověř obě cesty — přes stránku (interní) i přímým HTTP voláním, pokud endpoint má být veřejný.

Api manager volaný napřímo obchází zpracování v presenteru

Ořez polí podle oprávnění, doplnění vlastníka i normalizace payloadu jsou v internalAction*() / actionSave(), ne v manageru. Volání manageru napřímo je proto v pořádku jen tam, kde to je záměr (cron), a nikdy jako náhrada mostu z frontu — projeví se to tím, že se prázdná asociace tiše neuloží.

Cache#

Volání se dá jednorázově vyjmout z cache:

$payload = $this->bridge->call('api/blog/article/get-all', [
    'filters' => $filters,
    ApiBridge::PARAM_USE_CACHE => false,
], null, 'POST');

Řídicí klíč se z parametrů odstraní na OBOU cestách

Kdyby zůstal, externě by se odeslal jako běžný parametr požadavku — a stal by se z něj vypínač cache ovladatelný zvenčí. Do Api metody se proto nedostane a nesmí být v její signatuře, jinak skončí Unknown named parameter.

Podrobně Cache.

Odkud volání přišlo: PARAM_SOURCE#

Druhý řídicí klíč payloadu, ApiBridge::PARAM_SOURCE (_callSource), říká, ze které větve zápis jde: front, nebo admin. Dosazuje ho sdílený repository SaveTrait do každého save() podle getApiCallSource() základního ApiRepository Frontu a Adminu. Most ho z argumentů vyjme stejně jako PARAM_USE_CACHE a Api presenteru ho předá přes setApiCallSource().

Hodnota Kdo ji pošle shouldProduceNotifications()
admin admin ApiRepository false — administrace notifikace do zvonečku nezakládá
front front ApiRepository true
null externí HTTP, cron, repository mimo základní ApiRepository, neznámá hodnota true — zapomenutá cesta nesmí tiše mlčet

Postup: producent notifikací v Api presenteru

  1. Před založením notifikace se zeptej $this->shouldProduceNotifications().
  2. O oprávnění nerozhoduj podle zdroje — je to značka větve, práva řeší RBAC.
  3. Ověř obě větve: uložení z frontu notifikaci založí, z administrace ne.

Žádný internalAction*() nesmí mít parametr se jménem řídicího klíče

call() klíč z argumentů vyjme vždy, takže stejnojmenný parametr Api metody by z mostu nikdy nedostal hodnotu. Proto má klíč jméno s podtržítkem, které s obchodním parametrem (třeba source u skladových dokladů) nekoliduje.

Zvenčí se podstrčit nedá

proxy() oba řídicí klíče z HTTP parametrů zahodí a externí actionSave() jde do internalAction*() bez mostu. _callSource=admin z cizího požadavku tedy notifikace neumlčí.

Je to interní volání?#

Kód, který se má chovat jinak pro serverový kód a jinak pro externího klienta (typicky přijetí dočasného souboru {name, path} z formuláře), se ptá App\Core\Bridge\InternalCallContext::isActive(). Značku zvyšuje jedině ApiBridge::internalCall() (a ve finally ji zase sníží); je to počítadlo hloubky, takže vnořené volání po sobě vrátí stav vnějšího.

FieldAudienceContext::isInternal() na tohle nepoužívej

Plní ho RBAC guard, a nouzový přepínač apiRbac.internalMode: off guard vypne — s ním by zmizela i informace „volání je interní“ a třeba nahrané soubory by se tiše neuložily. Nouzový režim má všechno pustit, ne ubrat.

CLI a harnessy#

Skript přes most nemá session, takže běží jako guest

Ve vynucujícím režimu nedostane data — a nevrátí chybu autorizace, vrátí prázdno, protože „Permission denied“ zůstane uvnitř mostu. Test nad tím projde zeleně a nic netestuje. Řešení: ApiBridge::setInternalAuthToken($jwtManager->generateToken(…)).

Cron tím dotčený není

Injektuje Api managery přímo, ne přes most.

Kam sáhnout#

Chci Kde
most a jeho konfiguraci app/Core/Bridge/ApiBridge.php, config/Shared/api.neon
mapování metod na endpointy app/Core/Base/Front/Models/Repositories/ApiRepository.php
zápisové metody repository app/Core/Traits/Shared/Models/Repositories/{Save,Delete,Tree}Trait.php
převod selhání na prázdno app/Core/Traits/Shared/Models/Repositories/FailSoftReadTrait.php
rozpoznání obálky selhání app/Core/Utils/Results/Result.php → isFailureEnvelope()
tvar parametrů externí akce app/Core/Utils/Helpers/RequestParamShape.php, app/Core/Traits/Api/Presenters/RequestShapeGuardTrait.php
značka interního volání app/Core/Bridge/InternalCallContext.php
zdroj volání a notifikace ApiBridge::PARAM_SOURCE, app/Core/Base/Api/BasePresenter.php → shouldProduceNotifications()

Navazující kapitoly: Pětivrstvý model · Hydrátory · RBAC v API · Cache

Nastavení cookies