Aller au contenu principal

Hello Invoice

La première application officielle d'AUSUS 2.0. C'est un petit gestionnaire de factures qui parcourt l'intégralité du pipeline Gen2 — Authoring → Compiler → graphe immuable → Runtime → API HTTP → React Renderer — en n'utilisant que les packages publics. C'est exactement le projet que vous téléchargeriez depuis GitHub : aucun monorepo, aucun path repository, aucun code interne.

1. Introduction

Vous déclarez une entité Invoice sous forme de données. L'Entity Engine compile cette déclaration en un schéma figé, adressé par contenu, puis l'exécute : stockage, autorisation pilotée par la donnée, API HTTP et interface React — le tout dérivé de l'unique déclaration. Comptez environ quinze minutes pour obtenir une application fonctionnelle.

2. Installation

Prérequis : PHP 8.3+ et (pour l'interface) Node 18+. Créez un dossier de projet et installez les packages Composer publics depuis Packagist :

mkdir hello-invoice && cd hello-invoice
composer require ausus/authoring:^2.0 ausus/entity-engine:^2.0 \
ausus/persistence-memory:^2.0 ausus/api-runtime:^2.0

ausus/authoring fournit le DSL, ausus/entity-engine le Compiler et le runtime, ausus/persistence-memory le driver de référence, et ausus/api-runtime la surface HTTP. ausus/kernel est tiré automatiquement.

3. Créer le projet

hello-invoice/
composer.json
entities/
Invoice.php # la déclaration
bin/
demo.php # compile → runtime → API, en un seul script
server.php # contrôleur HTTP pour le renderer React
web/ # l'interface React (étape 8)

4. Déclarer l'entité Invoice

entities/Invoice.php retourne exactement une EntityDefinition immuable. Champs, actions, projections et autorisations sont des données. Les guards utilisent uniquement des opérateurs primitifs (eq / lt / not) : le runtime refuse dès qu'un fait n'est pas résolu.

<?php
use Ausus\Authoring\Dsl\Definition;
use Ausus\Authoring\Dsl\Expr;
use Ausus\Definition\Enum\ActionKind;
use Ausus\Definition\Enum\FieldType;

return Definition::make('invoice', true) // multi-tenant
->field('number', FieldType::String)
->field('customer', FieldType::String)
->field('issueDate', FieldType::Date)
->field('dueDate', FieldType::Date)
->field('status', FieldType::Enum, [
'default' => 'draft',
'writeProtected' => true, // seules les transitions le changent
'typeOptions' => ['values' => ['draft', 'paid', 'cancelled']],
])
->field('total', FieldType::Decimal)

->action('create', ActionKind::Create, [
'inputs' => ['number', 'customer', 'issueDate', 'dueDate', 'total'],
'guard' => Expr::eq(Expr::actor('type'), 'user'),
])
->action('update', ActionKind::Update, [
'inputs' => ['customer', 'dueDate', 'total'],
'guard' => Expr::eq(Expr::actor('type'), 'user'),
])
->action('pay', ActionKind::Transition, [
'guard' => Expr::not(Expr::lt(Expr::subject('total'), 1)), // total >= 1
'transition' => ['field' => 'status', 'from' => 'draft', 'to' => 'paid'],
])
->action('cancel', ActionKind::Transition, [
'guard' => Expr::eq(Expr::actor('type'), 'user'),
'transition' => ['field' => 'status', 'from' => 'draft', 'to' => 'cancelled'],
])

->projection('board', ['fields' => [
['field' => 'number'], ['field' => 'customer'], ['field' => 'status'], ['field' => 'total'],
]])
->projection('detail', ['fields' => [
['field' => 'number'], ['field' => 'customer'], ['field' => 'issueDate'],
['field' => 'dueDate'], ['field' => 'status'], ['field' => 'total'],
]])
->build();

5. Compilation

Le Compiler transforme la déclaration en un EntitySchema adressé par contenu (forme normale canonique + hash SHA-256). Mêmes sémantiques ⇒ même hash ; le runtime ne recompile jamais.

use Ausus\Engine\Compile\Compiler;
use Ausus\Engine\Repository\InMemorySchemaRepository;

$invoice = require __DIR__ . '/../entities/Invoice.php'; // EntityDefinition
$graph = (new Compiler())->compile([$invoice]); // CompiledGraph immuable

$repo = new InMemorySchemaRepository();
foreach ($graph->schemas as $schema) {
$repo->putByHash($schema); // store adressé par contenu
}

6. Lancer le runtime

Liez le schéma à un driver et invoquez des actions. Le runtime ne dépend que du contrat PersistenceDriver ; on utilise ici le driver en mémoire de référence.

use Ausus\Engine\Runtime\DefaultEntityEngine;
use Ausus\Engine\Runtime\DefaultAuthorizationEvaluator;
use Ausus\Persistence\Memory\MemoryDriver;
use Ausus\Api\Runtime\Http\RequestContextFactory;

$engine = new DefaultEntityEngine(new DefaultAuthorizationEvaluator(), $repo);
$driver = new MemoryDriver();
$ctx = (new RequestContextFactory(new DateTimeImmutable()))
->fromHeaders(['X-Tenant-ID' => 'acme', 'X-Actor-Type' => 'user']);

$invoice = $engine->bind($repo->resolve('invoice'), $driver);
$created = $invoice->invoke('create', [
'number' => 'INV-001', 'customer' => 'Globex',
'issueDate' => '2025-01-10', 'dueDate' => '2025-02-10', 'total' => 1500,
], $ctx);
$id = $created->reference->identityHandle;

$invoice->invoke('update', ['id' => $id, 'total' => 1800], $ctx); // mise à jour
$invoice->invoke('pay', ['id' => $id], $ctx); // draft → paid

Un acteur guest appelant create est refusé — le guard actor.type = user échoue en mode fermé. Exécutez l'ensemble avec php bin/demo.php.

7. Démarrer l'API HTTP

L'API Runtime expose le même domaine via un contrat agnostique du framework : dispatch(method, path, headers, body) retourne { status, body }. Routes : GET /api/entities/{entity}, GET …/projections/{projection}, POST …/actions/{action}.

use Ausus\Api\Runtime\Http\RuntimeApi;

$api = new RuntimeApi($repo, $engine, $driver, new RequestContextFactory(new DateTimeImmutable()));
$res = $api->dispatch('GET', '/api/entities/invoice/projections/board',
['X-Tenant-ID' => 'acme', 'X-Actor-Type' => 'user']);
// $res === ['status' => 200, 'body' => ['rows' => [ … ]]]

bin/server.php branche cela dans un contrôleur frontal ; servez-le :

php -S 127.0.0.1:8080 bin/server.php
curl http://127.0.0.1:8080/api/entities/invoice/projections/board

8. Connecter le React Renderer

Le renderer ne parle que le contrat HTTP — donnez-lui une URL de base et il découvre l'entité, les projections et les actions. Installez le package npm public :

cd web
npm install @ausus/react-renderer react react-dom

web/src/App.tsx :

import { RuntimeClient, RendererApp } from '@ausus/react-renderer';

const client = new RuntimeClient({ baseUrl: 'http://127.0.0.1:8080' });

export default function App() {
return <RendererApp client={client} entities={['invoice']} />;
}
npm run dev # ouvrez l'URL affichée, le serveur d'API étant lancé

9. Le résultat obtenu

Vous obtenez un gestionnaire de factures fonctionnel : une liste de factures (projection board), une vue détaillée (detail), des formulaires générés automatiquement pour create / update / pay / cancel, l'isolation par tenant sur chaque lecture et écriture, et l'autorisation appliquée avant toute modification — sans rien écrire à la main. Ajouter un champ ou une action à entities/Invoice.php puis recompiler les fait apparaître dans l'API et l'interface sans autre changement de code.

10. Ce que vous venez d'apprendre

  • Une application est une donnée : une EntityDefinition immuable.
  • Le Compiler la fige en un EntitySchema adressé par contenu.
  • Le Runtime lie ce schéma à un driver et exécute les actions, avec une autorisation pilotée par la donnée et fermée par défaut, et un multi-tenant structurel.
  • L'API Runtime l'expose en { status, body }, et le React Renderer la dessine à partir de ce seul contrat.
  • Le tout fonctionne uniquement avec des packages publics — exactement l'expérience d'un développeur externe.

Limitations

Le driver de référence ausus/persistence-memory vit le temps d'un processus. Sous php -S, chaque requête réexécute le routeur à neuf : les écritures ne persistent donc pas entre les requêtes ; bin/server.php amorce quelques factures au démarrage pour que les lectures affichent toujours des données. Pour un serveur persistant, liez un PersistenceDriver persistant à la place de MemoryDriver — le reste de l'application est inchangé.