Skip to content

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 austauschbarnikic/fast-route sitzt hinter Contract\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://input gelesen; 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

bash
composer require jardiscore/app

GitHub: jardisCore/app

Abhängigkeiten:

PackageZweck
jardiscore/kernelDer Koffer (DomainKernel) + ENV-Packer, auf dem die Bootstrap-Brücke aufsetzt
jardissupport/contractsDomainResponseInterface, ResponseStatus, der Response-Envelope-Contract
nikic/fast-route, nyholm/psr7, nyholm/psr7-serverRouting + PSR-7/PSR-17-Implementierung

Die Bausteine

KlasseVerantwortung
RoutesRegistrierungs-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.
AppDer Orchestrator. handle(ServerRequestInterface): ResponseInterface (pur, kein geteilter Zustand) · run(): void (baut Request aus den SAPI-Globals, handle()t, emittiert).
Config\AppConfigfinal readonly__construct(bool $debug = false). Wird vom Bootstrap injiziert, liest nie selbst ENV.
Handler\Response\MapDomainResponseDer kanonische Envelope-Mapper: DomainResponseInterface → PSR-7.
Handler\Response\BuildErrorResponseDer eine Ort, der {status, data, errors, meta} zusammensetzt.
Handler\Error\HandleThrowableDie äußerste try/catch-Grenze.
Handler\Request\ParseJsonBodyDer eine JSON-Body-Parser (Raw-Body-Invariante).

Routen registrieren

php
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 Domain

Jeder 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 automatisch HEAD registriert (außer eine explizite HEAD-Route existiert bereits); der Body wird beim Emittieren unterdrückt, Content-Length bleibt.
  • OPTIONS wird nie automatisch ergänzt: unregistriert → 404 (kein Pfad) bzw. 405 (Pfad mit anderen Methoden).

Der Envelope — {status, data, errors, meta}

Referenz: jardissupport/contractsdocs/response-envelope.md. ResponseStatus (elf Fälle, JardisSupport\Contract\Kernel\ResponseStatus) mappt 1:1 auf den HTTP-Code:

CaseHTTPCaseHTTP
Success200Forbidden403
Created201NotFound404
NoContent204MethodNotAllowed405
ValidationError400Conflict409
Unauthorized401RuleViolation422
InternalError500
  • 204 (NoContent) ist ein Sonderfall: kein Body, kein Content-Type, eine nackte leere Antwort.
  • Leere data/errors/meta werden zu einem JSON-Objekt ({}) koerziert, nie zu [].
  • 405 trägt einen RFC-7231-Allow-Header: der einzige Fall, der den allowedMethods-Parameter füllt.
  • 422 (RuleViolation)getData() wird unverändert unter data serialisiert; 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 denn AppConfig::$debug === true. Die vollständige Exception geht immer an den injizierten PSR-3-Logger (LogThrowable fällt auf error_log() zurück, wenn der Logger null ist 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 → Apprun():

php
<?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:

bash
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):

php
$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.