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#
- Každý parametr z
getRequestParameters()přečti přesRequestParamShape, ne(int)aniboolval(). - Při
nullzavolejsendInvalidRequestParameter('<jméno>')a skonči — ještě před RBAC a cache. - Filtry od volajícího ověř
FilterBuilder::isBuildable()(viz Filtry). - Velikost výpisu omez
externalListLimit(). Bez stropu by vnějšíget-allvrá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
- V Api presenteru napiš
internalAction<Akce>()bezaction*()(zvenčí 404), guard na prvním řádku a odpověď s jedinou hodnotou typu bool, žádná data o záznamu. - 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ů vtests/Unit/Security/FrontApiSurfacePolicyTest.php. - Ve front repository čti přes
failSoftRead()a chybějící klíč odpovědi vrať jakonull. - Ve validaci porovnávej
=== false, nikdy!$existsani?? 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í#
- Api presenter dostane
internalAction<Akce>()s guardem oprávnění na prvním řádku (viz RBAC). - Klíč oprávnění do databáze — bez něj je to fail-closed DENY.
- Front/Admin repository dostane metodu, která zavolá
bridge->call()nadapiEndpoint. - Mapper odpověď zhydratuje; service a manager ji jen propustí.
- 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
- Před založením notifikace se zeptej
$this->shouldProduceNotifications(). - O oprávnění nerozhoduj podle zdroje — je to značka větve, práva řeší RBAC.
- 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