App
Die HTTP-Delivery für generierte Jardis-Domains: Router, Middleware-Pipeline und der eine kanonische Envelope-Mapper. Mehr nicht.
Einführung
Zwischen „ein HTTP-Request kommt an" und „$domain->…->process(…) wird aufgerufen" gab es keine Jardis-Antwort: eine Jardis-gestützte HTTP-API zu bauen hieß, Laravel oder Symfony allein fürs Routing hereinzuholen. jardiscore/app schließt diese Lücke mit einem bewusst kleinen Kern:
- FastRoute, aber austauschbar —
nikic/fast-routesitzt hinterContract\RouterInterface. Ein Implementierungsdetail, keine Festlegung. - PSR-15-Pipeline — globale und Route-Middleware, Chain-of-Responsibility-Reihenfolge (global umschließt Route). Jedes PSR-15-konforme Ökosystem-Package (Auth, CORS, Request-ID, …) läuft ohne Adapter.
- Ein kanonischer Mapper — jede
DomainResponseInterface, ob Erfolg oder Fehler, wird derselbe{status, data, errors, meta}-JSON-Envelope. Niemand baut diese Übersetzung von Hand nach. - Ein definierter Boundary-Fehler-Contract — 404/405 (mit korrektem
Allow-Header)/500 antworten im selben Envelope wie ein Domain-Fehler. - Eine Raw-Body-Invariante — der Request-Body wird genau einmal aus
php://inputgelesen; JSON-Parsing ist lazy und ersetzt ihn nie (der Webhook-HMAC-Fall).
Wall Freedom
Keine generierte Domain importiert jemals jardiscore/app: eine mechanisch prüfbare strukturelle Eigenschaft, kein Versprechen. Ein Fremd-Framework (siehe examples/symfony-demo/) kann denselben Envelope-Contract erfüllen, ohne dieses Package zu kennen. Bleiben ist der Default, Gehen die garantierte Freiheit.
Installation
composer require jardiscore/appGitHub: jardisCore/app
Abhängigkeiten:
| Package | Zweck |
|---|---|
jardiscore/kernel | Der Koffer (DomainKernel) + ENV-Packer, auf dem die Bootstrap-Brücke aufsetzt |
jardissupport/contracts | DomainResponseInterface, ResponseStatus, der Response-Envelope-Contract |
nikic/fast-route, nyholm/psr7, nyholm/psr7-server | Routing + PSR-7/PSR-17-Implementierung |
Die Bausteine
| Klasse | Verantwortung |
|---|---|
Routes | Registrierungs-Collector — sammelt Route-VOs + globale Middleware. get/post/put/patch/delete/middleware/health. |
Router (implements Contract\RouterInterface) | dispatch(ServerRequestInterface): RouteMatch. Umschließt FastRoute; der Dispatcher wird lazy beim ersten Aufruf gebaut. |
App | Der Orchestrator. handle(ServerRequestInterface): ResponseInterface (pur, kein geteilter Zustand) · run(): void (baut Request aus den SAPI-Globals, handle()t, emittiert). |
Config\AppConfig | final readonly — __construct(bool $debug = false). Wird vom Bootstrap injiziert, liest nie selbst ENV. |
Handler\Response\MapDomainResponse | Der kanonische Envelope-Mapper: DomainResponseInterface → PSR-7. |
Handler\Response\BuildErrorResponse | Der eine Ort, der {status, data, errors, meta} zusammensetzt. |
Handler\Error\HandleThrowable | Die äußerste try/catch-Grenze. |
Handler\Request\ParseJsonBody | Der eine JSON-Body-Parser (Raw-Body-Invariante). |
Routen registrieren
use JardisCore\App\Routes;
use Nyholm\Psr7\Factory\Psr17Factory;
$routes = new Routes(new Psr17Factory());
$routes->get('/orders/{id}', $handler, ...$middleware); // MiddlewareInterface ...$middleware, variadic
$routes->post('/orders', $handler);
$routes->put('/orders/{id}', $handler);
$routes->patch('/orders/{id}', $handler);
$routes->delete('/orders/{id}', $handler);
$routes->middleware($globalMiddleware); // gilt für jede Route (außen), Registrierungsreihenfolge
$routes->health('/health'); // GET, immer 200 {"status":200} — berührt nie eine DomainJeder Verb-Aufruf akzeptiert callable|RequestHandlerInterface; ein Callable wird bei der Registrierung sofort zur Closure. Ein Route-Handler muss DomainResponseInterface oder ein PSR-7-ResponseInterface zurückgeben. Alles andere wirft UnresolvableHandlerResult und propagiert zur generischen 500-Grenze.
- Auto-HEAD — für jede
GET-Route wird automatischHEADregistriert (außer eine expliziteHEAD-Route existiert bereits); der Body wird beim Emittieren unterdrückt,Content-Lengthbleibt. - OPTIONS wird nie automatisch ergänzt: unregistriert → 404 (kein Pfad) bzw. 405 (Pfad mit anderen Methoden).
Der Envelope — {status, data, errors, meta}
Referenz: jardissupport/contracts → docs/response-envelope.md. ResponseStatus (elf Fälle, JardisSupport\Contract\Kernel\ResponseStatus) mappt 1:1 auf den HTTP-Code:
| Case | HTTP | Case | HTTP |
|---|---|---|---|
Success | 200 | Forbidden | 403 |
Created | 201 | NotFound | 404 |
NoContent | 204 | MethodNotAllowed | 405 |
ValidationError | 400 | Conflict | 409 |
Unauthorized | 401 | RuleViolation | 422 |
InternalError | 500 |
- 204 (
NoContent) ist ein Sonderfall: kein Body, keinContent-Type, eine nackte leere Antwort. - Leere
data/errors/metawerden zu einem JSON-Objekt ({}) koerziert, nie zu[]. - 405 trägt einen RFC-7231-
Allow-Header: der einzige Fall, der denallowedMethods-Parameter füllt. - 422 (
RuleViolation) —getData()wird unverändert unterdataserialisiert; bei einem Rules-Layer-Verstoß typisch{rule, messageKey, context}. getEvents()ist nicht Teil des Client-Envelopes: es erscheinen nur die vier Top-Level-Keys.
Fehler-Contract
InvalidJsonBody→ 400,errors.message= die (nicht sensible) Meldung.- Jedes andere
Throwable→ 500, generischer Body (keine Meldung/Klasse/Trace), es sei dennAppConfig::$debug === true. Die vollständige Exception geht immer an den injizierten PSR-3-Logger (LogThrowablefällt auferror_log()zurück, wenn der Loggernullist oder selbst wirft). Die 500-Antwort bleibt davon unberührt.
Bootstrap-Rezept — public/index.php
BuildDomainKernelFromEnv (Packer) → DomainKernel (Koffer) → generierte Domain (new {Domain}($kernel)) → Routes + Handler → App → run():
<?php
declare(strict_types=1);
require __DIR__ . '/../vendor/autoload.php';
use JardisCore\App\App;
use JardisCore\App\Config\AppConfig;
use JardisCore\App\Routes;
use JardisCore\Kernel\Bootstrap\BuildDomainKernelFromEnv;
use Nyholm\Psr7\Factory\Psr17Factory;
use Psr\Http\Message\ServerRequestInterface;
// 1. Koffer aus der .env-Kaskade bauen (ENV-Packer, jardiscore/kernel)
$kernel = (new BuildDomainKernelFromEnv())(__DIR__ . '/..');
// 2. Domain-Komposition (Builder-generiert — nicht Teil dieses Packages):
// require __DIR__ . '/../App/bootstrap.php';
// $sales = new \Ecommerce\Sales($kernel);
// 3. AppConfig — Werte kommen aus dem Kernel-ENV, nie vom VO selbst gelesen.
$config = new AppConfig(debug: (bool) $kernel->env('app_debug'));
// 4. Routen: Health-Endpoint + eigene Routen. Ein Handler gibt eine
// DomainResponse (aus der BC-Read-Fassade/process()) ODER eine PSR-7-Response
// zurück — beides wird automatisch gemappt.
$routes = new Routes(new Psr17Factory());
$routes->health('/health');
$routes->get('/orders/{id}', static function (ServerRequestInterface $request) {
$id = (string) $request->getAttribute('id');
// return $sales->order()->getOrderById($id); // BC-Read-Fassade
$factory = new Psr17Factory();
$response = $factory->createResponse(200)->withHeader('Content-Type', 'application/json');
$response->getBody()->write(json_encode(['id' => $id], JSON_THROW_ON_ERROR));
return $response;
});
// 5. App + run(): Request aus SAPI-Globals bauen, durch die Pipeline schicken, emittieren.
$app = new App($routes, $kernel, $config);
$app->run();Starten:
php -d display_errors=Off -S 127.0.0.1:8080 -t public public/index.php
curl -s http://127.0.0.1:8080/health # {"status":200}
curl -s http://127.0.0.1:8080/orders/42 # {"id":"42"}display_errors=Off ist bewusst: ein produktionsnaher Lauf leckt nie einen Stacktrace an den Client. Exception-Details laufen ausschließlich über AppConfig::$debug (APP_DEBUG).
API-Versionierung
v1 schreibt keinen Mechanismus vor. version ist ein Domain-Parameter, den der Handler aus einer beliebigen Quelle setzt (URL-Präfix oder Header):
$routes->get('/v2/orders/{id}', static function (ServerRequestInterface $request) use ($sales) {
$id = (string) $request->getAttribute('id');
return $sales->context(GetOrderById::class, ['id' => $id], version: 'v2');
});Bewusst nicht gelöst (N1 — Infrastruktur-Verantwortung)
Request-Body-Größenlimits, Trusted-Proxy/X-Forwarded-* und display_errors=Off sind nicht Aufgabe dieses Packages: sie bleiben Sache von Webserver/FPM/Proxy. Typisierte Koerzierung von Pfad-/Query-/Body-Parametern ist in v1 Job des Handlers; für mehr als triviale Casts: Validation.