diff --git a/docs/Gemfile b/docs/Gemfile new file mode 100644 index 0000000..5b1a4fc --- /dev/null +++ b/docs/Gemfile @@ -0,0 +1,5 @@ +source "https://rubygems.org" + +gem "jekyll", "~> 4.3" +gem "just-the-docs" +gem "webrick" # Required for Ruby 3+ diff --git a/docs/Gemfile.lock b/docs/Gemfile.lock new file mode 100644 index 0000000..d8b61a5 --- /dev/null +++ b/docs/Gemfile.lock @@ -0,0 +1,178 @@ +GEM + remote: https://rubygems.org/ + specs: + addressable (2.8.8) + public_suffix (>= 2.0.2, < 8.0) + base64 (0.3.0) + bigdecimal (4.0.1) + colorator (1.1.0) + concurrent-ruby (1.3.6) + csv (3.3.5) + em-websocket (0.5.3) + eventmachine (>= 0.12.9) + http_parser.rb (~> 0) + eventmachine (1.2.7) + ffi (1.17.3) + ffi (1.17.3-arm-linux-gnu) + ffi (1.17.3-arm-linux-musl) + ffi (1.17.3-arm64-darwin) + forwardable-extended (2.6.0) + google-protobuf (4.33.5) + bigdecimal + rake (>= 13) + http_parser.rb (0.8.1) + i18n (1.14.8) + concurrent-ruby (~> 1.0) + jekyll (4.4.1) + addressable (~> 2.4) + base64 (~> 0.2) + colorator (~> 1.0) + csv (~> 3.0) + em-websocket (~> 0.5) + i18n (~> 1.0) + jekyll-sass-converter (>= 2.0, < 4.0) + jekyll-watch (~> 2.0) + json (~> 2.6) + kramdown (~> 2.3, >= 2.3.1) + kramdown-parser-gfm (~> 1.0) + liquid (~> 4.0) + mercenary (~> 0.3, >= 0.3.6) + pathutil (~> 0.9) + rouge (>= 3.0, < 5.0) + safe_yaml (~> 1.0) + terminal-table (>= 1.8, < 4.0) + webrick (~> 1.7) + jekyll-include-cache (0.2.1) + jekyll (>= 3.7, < 5.0) + jekyll-sass-converter (3.1.0) + sass-embedded (~> 1.75) + jekyll-seo-tag (2.8.0) + jekyll (>= 3.8, < 5.0) + jekyll-watch (2.2.1) + listen (~> 3.0) + json (2.18.1) + just-the-docs (0.12.0) + jekyll (>= 3.8.5) + jekyll-include-cache + jekyll-seo-tag (>= 2.0) + rake (>= 12.3.1) + kramdown (2.5.2) + rexml (>= 3.4.4) + kramdown-parser-gfm (1.1.0) + kramdown (~> 2.0) + liquid (4.0.4) + listen (3.10.0) + logger + rb-fsevent (~> 0.10, >= 0.10.3) + rb-inotify (~> 0.9, >= 0.9.10) + logger (1.7.0) + mercenary (0.4.0) + pathutil (0.16.2) + forwardable-extended (~> 2.6) + public_suffix (7.0.2) + rake (13.3.1) + rb-fsevent (0.11.2) + rb-inotify (0.11.1) + ffi (~> 1.0) + rexml (3.4.4) + rouge (4.7.0) + safe_yaml (1.0.5) + sass-embedded (1.97.3) + google-protobuf (~> 4.31) + rake (>= 13) + sass-embedded (1.97.3-aarch64-linux-android) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-arm-linux-androideabi) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-arm-linux-gnueabihf) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-arm-linux-musleabihf) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-arm64-darwin) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-riscv64-linux-android) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-riscv64-linux-gnu) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-riscv64-linux-musl) + google-protobuf (~> 4.31) + sass-embedded (1.97.3-x86_64-linux-android) + google-protobuf (~> 4.31) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + unicode-display_width (2.6.0) + webrick (1.9.2) + +PLATFORMS + aarch64-linux-android + arm-linux-androideabi + arm-linux-gnu + arm-linux-gnueabihf + arm-linux-musl + arm-linux-musleabihf + arm64-darwin-24 + riscv64-linux-android + riscv64-linux-gnu + riscv64-linux-musl + ruby + x86_64-linux-android + +DEPENDENCIES + jekyll (~> 4.3) + just-the-docs + webrick + +CHECKSUMS + addressable (2.8.8) sha256=7c13b8f9536cf6364c03b9d417c19986019e28f7c00ac8132da4eb0fe393b057 + base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b + bigdecimal (4.0.1) sha256=8b07d3d065a9f921c80ceaea7c9d4ae596697295b584c296fe599dd0ad01c4a7 + colorator (1.1.0) sha256=e2f85daf57af47d740db2a32191d1bdfb0f6503a0dfbc8327d0c9154d5ddfc38 + concurrent-ruby (1.3.6) sha256=6b56837e1e7e5292f9864f34b69c5a2cbc75c0cf5338f1ce9903d10fa762d5ab + csv (3.3.5) sha256=6e5134ac3383ef728b7f02725d9872934f523cb40b961479f69cf3afa6c8e73f + em-websocket (0.5.3) sha256=f56a92bde4e6cb879256d58ee31f124181f68f8887bd14d53d5d9a292758c6a8 + eventmachine (1.2.7) sha256=994016e42aa041477ba9cff45cbe50de2047f25dd418eba003e84f0d16560972 + ffi (1.17.3) sha256=0e9f39f7bb3934f77ad6feab49662be77e87eedcdeb2a3f5c0234c2938563d4c + ffi (1.17.3-arm-linux-gnu) sha256=5bd4cea83b68b5ec0037f99c57d5ce2dd5aa438f35decc5ef68a7d085c785668 + ffi (1.17.3-arm-linux-musl) sha256=0d7626bb96265f9af78afa33e267d71cfef9d9a8eb8f5525344f8da6c7d76053 + ffi (1.17.3-arm64-darwin) sha256=0c690555d4cee17a7f07c04d59df39b2fba74ec440b19da1f685c6579bb0717f + forwardable-extended (2.6.0) sha256=1bec948c469bbddfadeb3bd90eb8c85f6e627a412a3e852acfd7eaedbac3ec97 + google-protobuf (4.33.5) sha256=1b64fb774c101b23ac3f6923eca24be04fd971635d235c4cd4cfe0d752620da0 + http_parser.rb (0.8.1) sha256=9ae8df145b39aa5398b2f90090d651c67bd8e2ebfe4507c966579f641e11097a + i18n (1.14.8) sha256=285778639134865c5e0f6269e0b818256017e8cde89993fdfcbfb64d088824a5 + jekyll (4.4.1) sha256=4c1144d857a5b2b80d45b8cf5138289579a9f8136aadfa6dd684b31fe2bc18c1 + jekyll-include-cache (0.2.1) sha256=c7d4b9e551732a27442cb2ce853ba36a2f69c66603694b8c1184c99ab1a1a205 + jekyll-sass-converter (3.1.0) sha256=83925d84f1d134410c11d0c6643b0093e82e3a3cf127e90757a85294a3862443 + jekyll-seo-tag (2.8.0) sha256=3f2ed1916d56f14ebfa38e24acde9b7c946df70cb183af2cb5f0598f21ae6818 + jekyll-watch (2.2.1) sha256=bc44ed43f5e0a552836245a54dbff3ea7421ecc2856707e8a1ee203a8387a7e1 + json (2.18.1) sha256=fe112755501b8d0466b5ada6cf50c8c3f41e897fa128ac5d263ec09eedc9f986 + just-the-docs (0.12.0) sha256=15f2839ac9082898d60f33b978aa6f8e46fc50ba8fac20ae7a7f0e1fb295523e + kramdown (2.5.2) sha256=1ba542204c66b6f9111ff00dcc26075b95b220b07f2905d8261740c82f7f02fa + kramdown-parser-gfm (1.1.0) sha256=fb39745516427d2988543bf01fc4cf0ab1149476382393e0e9c48592f6581729 + liquid (4.0.4) sha256=4fcfebb1a045e47918388dbb7a0925e7c3893e58d2bd6c3b3c73ec17a2d8fdb3 + listen (3.10.0) sha256=c6e182db62143aeccc2e1960033bebe7445309c7272061979bb098d03760c9d2 + logger (1.7.0) sha256=196edec7cc44b66cfb40f9755ce11b392f21f7967696af15d274dde7edff0203 + mercenary (0.4.0) sha256=b25a1e4a59adca88665e08e24acf0af30da5b5d859f7d8f38fba52c28f405138 + pathutil (0.16.2) sha256=e43b74365631cab4f6d5e4228f812927efc9cb2c71e62976edcb252ee948d589 + public_suffix (7.0.2) sha256=9114090c8e4e7135c1fd0e7acfea33afaab38101884320c65aaa0ffb8e26a857 + rake (13.3.1) sha256=8c9e89d09f66a26a01264e7e3480ec0607f0c497a861ef16063604b1b08eb19c + rb-fsevent (0.11.2) sha256=43900b972e7301d6570f64b850a5aa67833ee7d87b458ee92805d56b7318aefe + rb-inotify (0.11.1) sha256=a0a700441239b0ff18eb65e3866236cd78613d6b9f78fea1f9ac47a85e47be6e + rexml (3.4.4) sha256=19e0a2c3425dfbf2d4fc1189747bdb2f849b6c5e74180401b15734bc97b5d142 + rouge (4.7.0) sha256=dba5896715c0325c362e895460a6d350803dbf6427454f49a47500f3193ea739 + safe_yaml (1.0.5) sha256=a6ac2d64b7eb027bdeeca1851fe7e7af0d668e133e8a88066a0c6f7087d9f848 + sass-embedded (1.97.3) sha256=c4136da69ae3acfa7b0809f4ec10891125edf57df214a2d1ab570f721f96f7a6 + sass-embedded (1.97.3-aarch64-linux-android) sha256=623b2f52fed6e3696c6445406e4efaef57b54442cc35604bfffbb82aef7d5c45 + sass-embedded (1.97.3-arm-linux-androideabi) sha256=e2ef33b187066e09374023e58e72cf3b5baabe6b77ecd74356fe9b4892a1c6e1 + sass-embedded (1.97.3-arm-linux-gnueabihf) sha256=ce443b57f3d7f03740267cf0f2cdff13e8055dd5938488967746f29f230222da + sass-embedded (1.97.3-arm-linux-musleabihf) sha256=be3972424616f916ce1f4f41228266d57339e490dfd7ca0cea5588579564d4c0 + sass-embedded (1.97.3-arm64-darwin) sha256=8897d3503efe75c30584070a7104095545f5157665029aeb9bff3fa533a73861 + sass-embedded (1.97.3-riscv64-linux-android) sha256=201426b3e58611aa8cf34a7574df51905ec42fefb5a69982cc8497ac7fb26a6b + sass-embedded (1.97.3-riscv64-linux-gnu) sha256=d7bac32f4de55c589a036da13ac4482bf5b7dfac980b4c0203d31a1bd9f07622 + sass-embedded (1.97.3-riscv64-linux-musl) sha256=621d981d700e2b8d0459b5ea696fff746dfa07d6b6bbc70cd982905214b07888 + sass-embedded (1.97.3-x86_64-linux-android) sha256=8f5e179bee8610be432499f228ea4e53ab362b1db0da1ae3cd3e76b114712372 + terminal-table (3.0.2) sha256=f951b6af5f3e00203fb290a669e0a85c5dd5b051b3b023392ccfd67ba5abae91 + unicode-display_width (2.6.0) sha256=12279874bba6d5e4d2728cef814b19197dbb10d7a7837a869bab65da943b7f5a + webrick (1.9.2) sha256=beb4a15fc474defed24a3bda4ffd88a490d517c9e4e6118c3edce59e45864131 + +BUNDLED WITH + 4.0.3 diff --git a/docs/_config.yml b/docs/_config.yml new file mode 100644 index 0000000..3b2509a --- /dev/null +++ b/docs/_config.yml @@ -0,0 +1,28 @@ +title: Cubex Framework +description: Documentation for the Cubex PHP 8.2+ web application framework +theme: just-the-docs + +url: https://cubex.github.io +baseurl: /framework + +aux_links: + "GitHub": + - "https://github.com/cubex/framework" + "Packagist": + - "https://packagist.org/packages/cubex/framework" + +nav_sort: order + +mermaid: + version: "11.4.1" + +callouts: + note: + title: Note + color: blue + warning: + title: Warning + color: red + tip: + title: Tip + color: green diff --git a/docs/_includes/head_custom.html b/docs/_includes/head_custom.html new file mode 100644 index 0000000..e69de29 diff --git a/docs/condition-processor.md b/docs/condition-processor.md new file mode 100644 index 0000000..ddaf0cd --- /dev/null +++ b/docs/condition-processor.md @@ -0,0 +1,212 @@ +--- +title: Condition Processor +layout: default +nav_order: 10 +--- + +# Condition Processor + +The Condition Processor provides declarative authorization and validation for controller methods using PHP 8 attributes. It integrates with the DI container's `resolveMethod()` to evaluate conditions before a method executes. + +## Overview + +```mermaid +flowchart TD + Resolve["resolveMethod()"] --> Scan["Scan attributes"] + Scan --> Collect["Collect PreConditions
remove SkipConditions"] + Collect --> Check{"Conditions
remaining?"} + Check -->|No| Execute["Execute method"] + Check -->|Yes| Eval{"process(ctx)
returns null?"} + Eval -->|All pass| Execute + Eval -->|Non-null| Interrupt["Return interrupt value"] +``` + +## Attributes + +### #[PreCondition] + +Marks a method (or class) with a condition that must pass before execution: + +```php +use Cubex\Attributes\PreCondition; + +class AdminController extends Controller +{ + #[PreCondition(RequiresAuth::class)] + #[PreCondition(RequiresRole::class, ['admin'])] + public function getUsers(): Response + { + return new JsonResponse($this->listUsers()); + } +} +``` + +The attribute accepts: +- **`$class`** — The fully qualified class name implementing `ConditionResult` +- **`$args`** (optional) — Constructor arguments passed when instantiating the condition + +### #[SkipCondition] + +Exempts a method from a specific `PreCondition`. This is useful when a class-level condition should not apply to certain methods: + +```php +#[PreCondition(RequiresAuth::class)] +class DashboardController extends Controller +{ + public function getIndex(): Response + { + // RequiresAuth IS evaluated + return new TextResponse('Dashboard'); + } + + #[SkipCondition(RequiresAuth::class)] + public function getHealthCheck(): Response + { + // RequiresAuth is SKIPPED + return new TextResponse('OK'); + } +} +``` + +Both attributes are repeatable (`IS_REPEATABLE`) and can target any element (`TARGET_ALL`). + +## Implementing ConditionResult + +Create a condition by implementing the `ConditionResult` interface: + +```php +use Cubex\Attributes\ConditionResult; +use Packaged\Context\Context; + +class RequiresAuth implements ConditionResult +{ + public function process(Context $ctx): mixed + { + if ($ctx->request()->headers->has('Authorization')) + { + return null; // Allow execution to proceed + } + + // Interrupt — this value becomes the method's return + return new Response('Unauthorized', 401); + } +} +``` + +The `process()` method: +- Returns **`null`** to allow execution to continue +- Returns **any non-null value** to interrupt execution — the returned value replaces the method's normal return value + +### Conditions with Arguments + +Pass constructor arguments via the attribute: + +```php +class RequiresRole implements ConditionResult +{ + public function __construct(private string $role) + { + } + + public function process(Context $ctx): mixed + { + $user = $ctx->meta()->get('user'); + if ($user && $user->hasRole($this->role)) + { + return null; + } + return new Response('Forbidden', 403); + } +} +``` + +```php +#[PreCondition(RequiresRole::class, ['admin'])] +public function getAdminPanel(): Response +{ + // Only accessible to users with the 'admin' role +} +``` + +## How It Works Internally + +The `ConditionProcessor` class integrates with `packaged/di-container`'s reflection system: + +1. **`ConditionProcessor`** extends `AttributeWatcher` and implements `ReflectionInterrupt` +2. When `resolveMethod()` is called on a controller method, the DI container's reflection observers are notified +3. `ConditionProcessor` scans the method (and class) for `#[PreCondition]` and `#[SkipCondition]` attributes +4. Skip conditions are collected first — any `PreCondition` whose class appears in the skip list is removed +5. Each remaining condition is instantiated (via DI if available, for constructor injection) and `process($context)` is called +6. If any condition returns non-null, `shouldInterruptMethod()` returns `true` and the interrupt value is used as the method's return value + +```php +// This happens automatically inside Controller::_prepareHandler() +// when Cubex DI is available: + +$conditionProcessor = new ConditionProcessor($cubex); +$result = $di->resolveMethod($controller, $methodName, [], [$conditionProcessor]); +``` + +## Execution Flow + +```mermaid +sequenceDiagram + participant DI as DI Container + participant CP as ConditionProcessor + participant Cond as ConditionResult + participant Method as Controller Method + + Note over CP: Collect attributes,
remove skipped conditions + + loop Each condition + CP->>Cond: process(context) + alt pass (null) + Note over CP: Continue + else fail (non-null) + CP-->>DI: interrupt with result + Note over Method: Method NOT called + end + end + + Note over CP: All conditions passed + DI->>Method: invoke with injected params + Method-->>DI: return value +``` + +## Multiple Conditions + +When multiple `#[PreCondition]` attributes are present, they are evaluated in order. The first condition to return a non-null value stops evaluation: + +```php +#[PreCondition(RequiresAuth::class)] // Checked first +#[PreCondition(RequiresRole::class, ['editor'])] // Checked second +#[PreCondition(RateLimiter::class)] // Checked third +public function postArticle(): Response +{ + // All three conditions must pass (return null) +} +``` + +## Class-Level vs Method-Level + +Attributes on the class apply to all methods. Method-level attributes are additive. Use `#[SkipCondition]` to exempt specific methods: + +```php +#[PreCondition(RequiresAuth::class)] +class SecureController extends Controller +{ + // RequiresAuth applies to all methods + + #[PreCondition(RequiresRole::class, ['admin'])] + public function getAdmin(): Response + { + // Both RequiresAuth AND RequiresRole apply + } + + #[SkipCondition(RequiresAuth::class)] + public function getPublic(): Response + { + // RequiresAuth is skipped, no conditions apply + } +} +``` diff --git a/docs/console.md b/docs/console.md new file mode 100644 index 0000000..b8f78e1 --- /dev/null +++ b/docs/console.md @@ -0,0 +1,271 @@ +--- +title: Console +layout: default +nav_order: 9 +--- + +# Console + +Cubex integrates with Symfony Console to provide CLI command support. It adds auto-configuration from INI files, DocBlock-driven argument/option definition, and DI integration. + +## CLI Entry Point + +```php +cli()); +``` + +```mermaid +sequenceDiagram + participant Entry as cubex + participant Cubex + participant Console + participant Config as INI Config + participant Command + + rect rgb(240, 240, 255) + Note right of Entry: Setup + Entry->>Cubex: cli() + Cubex->>Console: getConsole() + Console->>Config: read [console] section + Config-->>Console: commands & patterns + Note over Console: Register commands + BuiltInWebServer + end + + rect rgb(240, 255, 240) + Note right of Entry: Execute + Cubex->>Console: run(input, output) + Console->>Command: find + execute() + Command-->>Console: exit code + Console-->>Cubex: exit code + end +``` + +## Configuration + +Commands are configured via INI files in your `conf/` directory under the `[console]` section: + +```ini +; conf/defaults.ini +[console] +commands[migrate] = "App\Commands\MigrateCommand" +commands[seed] = "App\Commands\SeedCommand" + +patterns[] = "App\Commands\%s" +patterns[] = "App\Console\%s" +``` + +### Commands + +The `commands` map registers named commands. Keys are command names, values are fully qualified class names: + +```ini +commands[db:migrate] = "App\Commands\Database\MigrateCommand" +commands[cache:clear] = "App\Commands\Cache\ClearCommand" +``` + +### Patterns + +Patterns enable auto-discovery of commands by name. When a command is not found by its registered name, the console tries each pattern with the class name derived from the command name. + +For example, with pattern `App\Commands\%s`, running `my.command` will try to resolve `App\Commands\My\Command`. + +## ConsoleCommand + +`ConsoleCommand` extends Symfony's `Command` with automatic argument/option configuration from method signatures and DocBlock annotations. + +### Defining Commands + +Create a command by extending `ConsoleCommand` and implementing one of three method signatures: + +```php +use Cubex\Console\ConsoleCommand; + +/** + * @name greet + * @description Greet a user by name + */ +class GreetCommand extends ConsoleCommand +{ + /** + * Short description for --uppercase option + * @short u + * @flag + */ + public bool $uppercase = false; + + public function executeCommand( + InputInterface $input, + OutputInterface $output, + string $name, + string $greeting = 'Hello' + ): int + { + $message = "{$greeting}, {$name}!"; + + if ($this->uppercase) + { + $message = strtoupper($message); + } + + $output->writeln($message); + return 0; + } +} +``` + +```bash +php cubex greet Alice +# Hello, Alice! + +php cubex greet Alice --uppercase -greeting "Good morning" +# GOOD MORNING, ALICE! +``` + +### Method Signatures + +`ConsoleCommand` dispatches to the first available method: + +| Method | Signature | Notes | +|--------|-----------|-------| +| `executeCommand` | `(InputInterface, OutputInterface, ...args)` | Full access to I/O; extra params become arguments | +| `process` | `(...args)` | Simplified; params become arguments | +| `_execute` | `(InputInterface, OutputInterface)` | Fallback; manually read input | + +Parameters after the first two in `executeCommand` (or all parameters in `process`) are automatically registered as console arguments. + +### Auto-Configured Arguments + +Method parameters are converted to console arguments: + +```php +public function process(string $name, int $count = 1): int +{ + // $name → required argument + // $count → optional argument (default: 1) + return 0; +} +``` + +### Auto-Configured Options from Properties + +Public properties are converted to console options. DocBlock annotations control behavior: + +| Annotation | Effect | Example | +|------------|--------|---------| +| `@short` | Single-letter shortcut | `@short v` → `-v` | +| `@description` | Option description in help | `@description Enable verbose` | +| `@flag` | Boolean flag (no value) | `@flag` → `--verbose` | +| `@valuerequired` | Value is required | `@valuerequired` | + +```php +/** + * The output format + * @short f + * @valuerequired + */ +public string $format = 'json'; + +/** + * Run in dry-run mode + * @short n + * @flag + */ +public bool $dryRun = false; +``` + +### DocBlock Command Metadata + +Use DocBlock annotations on the class to set the command name and description: + +```php +/** + * @name cache:clear + * @description Clear the application cache + */ +class ClearCacheCommand extends ConsoleCommand +{ + // ... +} +``` + +If `@name` is not specified, the command name defaults to the lowercase class basename. + +### Context and DI Access + +`ConsoleCommand` implements both `ContextAware` and `CubexAware`, so you have full access to the framework: + +```php +class MigrateCommand extends ConsoleCommand +{ + public function process(): int + { + $db = $this->getCubex()->retrieve(DbConnection::class); + $config = $this->getContext()->config(); + // ... + return 0; + } +} +``` + +## Built-In Web Server + +Cubex includes a `serve` command that starts PHP's built-in web server: + +```bash +php cubex serve +# Starts server at 127.0.0.1:8888 + +php cubex serve -p 3000 +# Custom port + +php cubex serve --host 0.0.0.0 +# Bind to all interfaces + +php cubex serve -d +# Enable xdebug + +php cubex serve --useNextAvailablePort +# Auto-increment port if 8888 is in use +``` + +### serve Options + +| Option | Short | Description | +|--------|-------|-------------| +| `--host` | | Server hostname (default: `127.0.0.1`) | +| `--port` | `-p` | Server port (default: `8888`) | +| `--router` | | PHP router script (default: `public/index.php`) | +| `--workers` | `-w` | Number of PHP CLI server workers (default: `5`) | +| `--debug` | `-d` | Enable xdebug | +| `--debugIdeKey` | `-idekey` | IDE key for xdebug (default: `PHPSTORM`) | +| `--showCommand` | | Display the raw command being executed | +| `--showfig` | | Show ASCII figlet banner (default: `true`) | +| `--useNextAvailablePort` | | Auto-find next available port | +| `--cubexLocalSubDomain` | `-c` | Use `{value}.cubex-local.com` as host | +| `--comment` | | Extra output text | + +### Server Configuration via INI + +Host and port can also be configured in INI files: + +```ini +; conf/defaults.ini +[serve] +host = "0.0.0.0" +port = 9000 +``` + +## Registering Commands Programmatically + +Use the `ConsoleCreateEvent` to add commands at runtime: + +```php +use Cubex\Console\Events\ConsoleCreateEvent; + +$cubex->listen(ConsoleCreateEvent::class, function (ConsoleCreateEvent $e) { + $e->getConsole()->add(new CustomCommand()); +}); +``` diff --git a/docs/controllers.md b/docs/controllers.md new file mode 100644 index 0000000..c77a7ec --- /dev/null +++ b/docs/controllers.md @@ -0,0 +1,175 @@ +--- +title: Controllers +layout: default +nav_order: 4 +--- + +# Controllers + +Controllers extend `RouteProcessor` and add HTTP verb-based method resolution, automatic response preparation, and integration with the DI container. + +## Controller Method Resolution + +When a route returns a string (e.g., `"login"`), the `Controller` resolves it to a method on the controller class by trying HTTP verb-prefixed method names: + +```mermaid +flowchart LR + Route["Route string
e.g. 'login'"] --> IsXHR{"AJAX?"} + + IsXHR -->|Yes| Ajax["ajaxLogin()"] + Ajax -->|Not found| AjaxVerb["ajaxGetLogin()"] + AjaxVerb -->|Not found| Verb + + IsXHR -->|No| Verb["getLogin() /
postLogin()"] + Verb -->|Not found| Process["processLogin()"] + Process -->|Not found| Error["No match"] + + Ajax -->|Found| Call["_prepareResponse()"] + AjaxVerb -->|Found| Call + Verb -->|Found| Call + Process -->|Found| Call +``` + +The resolution order (from `_getRouteMethods()`) for a route string `"login"` is: + +| Priority | XHR Request | Regular GET | Regular POST | +|----------|-------------|-------------|--------------| +| 1 | `ajaxLogin()` | `getLogin()` | `postLogin()` | +| 2 | `ajaxGetLogin()` | `processLogin()` | `processLogin()` | +| 3 | `getLogin()` / `postLogin()` | | | +| 4 | `processLogin()` | | | + +The `process` prefix acts as a catch-all that matches any HTTP verb. + +## Basic Controller + +```php +use Cubex\Controller\Controller; +use Packaged\Http\Response\TextResponse; + +class UserController extends Controller +{ + protected function _generateRoutes(): Generator + { + yield self::_route('/profile', 'profile'); + yield self::_route('/settings', 'settings'); + return 'index'; + } + + public function getIndex(): TextResponse + { + return new TextResponse('User index'); + } + + public function getProfile(): TextResponse + { + $userId = $this->routeData()->get('id'); + return new TextResponse("Profile for {$userId}"); + } + + public function getSettings(): string + { + return 'Settings page'; + } + + public function postSettings(): TextResponse + { + // Handle settings form submission + return new TextResponse('Settings saved'); + } +} +``` + +## Response Preparation + +`Controller::_prepareResponse()` automatically converts return values into `Response` objects: + +| Return Type | Conversion | +|-------------|------------| +| `Response` | Returned as-is | +| `ViewModel` | Creates a `View` via `createView()`, renders it | +| `Renderable` | Calls `render()` to get string content | +| `ISafeHtmlProducer` | Produces safe HTML content | +| `string` / stringable | Wrapped in a `CubexResponse` | +| `null` | Falls back to output buffer content | + +If the return value is `ContextAware` or `CubexAware`, the context and Cubex instance are set on it before processing. + +## Convenience Methods + +Controllers provide shortcuts for common context operations: + +```php +// Access the current request +$request = $this->request(); + +// Access route data (captured path variables) +$id = $this->routeData()->get('id'); +``` + +## DI-Aware Method Resolution + +When a `Cubex` instance is available, controller methods are resolved through the DI container using `resolveMethod()`. This enables: + +- **Constructor injection** of method parameters +- **Attribute-based conditions** via `ConditionProcessor` (see [Condition Processor]({% link condition-processor.md %})) + +```php +class ApiController extends Controller +{ + #[PreCondition(RequiresAuth::class)] + public function getSecure(UserService $users): Response + { + // $users is resolved via DI + // RequiresAuth condition is checked first + return new TextResponse('Secure content'); + } +} +``` + +## SingleRouteController + +For controllers that handle a single endpoint with verb-based dispatch only (no sub-routes): + +```php +use Cubex\Controller\SingleRouteController; + +class HealthCheckController extends SingleRouteController +{ + public function get(): TextResponse + { + return new TextResponse('OK'); + } + + public function post(): TextResponse + { + return new TextResponse('Received'); + } +} +``` + +`SingleRouteController` returns an empty string from `_generateRoutes()`, which the controller resolves to `get()`, `post()`, `ajax()`, or `process()` depending on the HTTP verb. + +## AuthedController (Deprecated) + +{: .warning } +`AuthedController` is deprecated. Use [Middleware]({% link middleware.md %}) or [Condition Processor]({% link condition-processor.md %}) attributes instead. + +`AuthedController` calls `canProcess()` before routing. If it returns `false`, the request is rejected with a 403 response: + +```php +use Cubex\Controller\AuthedController; + +class AdminController extends AuthedController +{ + public function canProcess(&$response): bool + { + return $this->getContext()->request()->has('admin_token'); + } + + public function getIndex(): string + { + return 'Admin panel'; + } +} +``` diff --git a/docs/dependency-injection.md b/docs/dependency-injection.md new file mode 100644 index 0000000..d8e73cb --- /dev/null +++ b/docs/dependency-injection.md @@ -0,0 +1,185 @@ +--- +title: Dependency Injection +layout: default +nav_order: 5 +--- + +# Dependency Injection + +The `Cubex` class is the DI container for the framework. It extends `DependencyInjector` from `packaged/di-container` and provides sharing, factories, auto-resolution, and method injection. + +## Container Basics + +The Cubex instance itself is the DI container — there is no separate container object: + +```php +$cubex = new Cubex(__DIR__, $loader); + +// Share a singleton +$cubex->share(LoggerInterface::class, new FileLogger('/var/log/app.log')); + +// Register a factory +$cubex->factory( + DbConnection::class, + fn() => new DbConnection($cubex->retrieve(Config::class)) +); + +// Retrieve an instance +$logger = $cubex->retrieve(LoggerInterface::class); +``` + +## Core Methods + +### share($abstract, $concrete) + +Registers a singleton binding. Subsequent calls to `retrieve()` return the same instance: + +```php +$cubex->share(CacheInterface::class, new RedisCache($config)); + +// Both calls return the same RedisCache instance +$cache1 = $cubex->retrieve(CacheInterface::class); +$cache2 = $cubex->retrieve(CacheInterface::class); +// $cache1 === $cache2 +``` + +### factory($abstract, callable $factory) + +Registers a factory that is called each time `retrieve()` is invoked: + +```php +$cubex->factory( + RequestContext::class, + fn() => new RequestContext(time()) +); + +// Each call creates a new instance +$ctx1 = $cubex->retrieve(RequestContext::class); +$ctx2 = $cubex->retrieve(RequestContext::class); +// $ctx1 !== $ctx2 +``` + +### retrieve($abstract, $parameters = [], $shared = true, $attemptNewAbstract = true) + +Resolves an abstract to a concrete instance. Resolution order: + +1. Check for a shared singleton +2. Check for a registered factory +3. If `$attemptNewAbstract` is true, attempt to build a new instance via constructor injection + +```php +// Resolves registered binding +$logger = $cubex->retrieve(LoggerInterface::class); + +// Auto-instantiation: if UserService has no binding, +// Cubex tries to construct it, injecting dependencies +$users = $cubex->retrieve(UserService::class); +``` + +### resolve($class, $parameters = []) + +Resolves a class with constructor injection. Dependencies are recursively resolved from the container: + +```php +class UserService +{ + public function __construct( + private DbConnection $db, + private CacheInterface $cache + ) {} +} + +// Both DbConnection and CacheInterface are resolved from the container +$service = $cubex->resolve(UserService::class); +``` + +### resolveMethod($object, $method, $parameters = []) + +Invokes a method on an object, injecting parameters from the container. This is used internally by `Controller` for DI-aware method dispatch: + +```php +class OrderController extends Controller +{ + public function getOrder(OrderService $orders): Response + { + // $orders is injected by the DI container + $id = $this->routeData()->get('id'); + return new JsonResponse($orders->find($id)); + } +} +``` + +## The CubexAware Pattern + +Components that need access to the DI container implement `CubexAware`: + +```php +use Cubex\CubexAware; +use Cubex\CubexAwareTrait; + +class MyService implements CubexAware +{ + use CubexAwareTrait; + + public function doWork(): void + { + $db = $this->getCubex()->retrieve(DbConnection::class); + // ... + } +} +``` + +The framework automatically sets the Cubex instance on `CubexAware` objects during handler preparation and response processing. + +### Interface Methods + +| Method | Description | +|--------|-------------| +| `setCubex(Cubex $cubex)` | Set the Cubex container reference | +| `getCubex(): ?Cubex` | Get the Cubex container | +| `hasCubex(): bool` | Check if a Cubex reference is available | + +## Auto-Wired Route Handlers + +When a route returns a class name string (e.g., `DashboardController::class`), the `RouteProcessor` resolves it through the DI container: + +```php +protected function _generateRoutes(): Generator +{ + // DashboardController is resolved via $cubex->retrieve() + // Its constructor dependencies are auto-injected + yield self::_route('/dashboard', DashboardController::class); +} +``` + +If the context does not have a Cubex reference, the class is instantiated directly with `new`. + +## Built-In Bindings + +Cubex automatically registers these bindings during construction: + +| Abstract | Concrete | Type | +|----------|----------|------| +| `ClassLoader` | Composer autoloader | Shared | +| `DependencyInjector` | The Cubex instance itself | Shared | +| `Context` | Factory from `Request::createFromGlobals()` | Factory | + +## Accessing the Container + +From any `CubexAware` component: + +```php +$cubex = $this->getCubex(); +``` + +From a `Context` (if it is a `CubexAware` Cubex context): + +```php +$cubex = Cubex::fromContext($context); +``` + +Or use the global singleton (if available): + +```php +$cubex = Cubex::instance(); +``` diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..784a81e --- /dev/null +++ b/docs/events.md @@ -0,0 +1,230 @@ +--- +title: Events +layout: default +nav_order: 8 +--- + +# Events + +Cubex uses an event system based on `Channel` from `packaged/event`. Events are fired at key points in the HTTP and CLI lifecycles, allowing you to hook into framework behavior without modifying core code. + +## Event Channels + +There are two event channels: + +| Channel | Accessed Via | Purpose | +|---------|-------------|---------| +| Cubex channel | `$cubex->listen()` | Framework-level lifecycle events | +| Context channel | `$context->events()` | Context-specific events | + +## Listening for Events + +### On the Cubex Channel + +```php +use Cubex\Events\Handle\ResponsePrepareEvent; + +$cubex->listen(ResponsePrepareEvent::class, function (ResponsePrepareEvent $event) { + $response = $event->getResponse(); + $response->headers->set('X-Powered-By', 'Cubex'); +}); +``` + +### On the Context Channel + +```php +use Cubex\Context\Events\ConsoleLaunchedEvent; + +$context->events()->listen( + ConsoleLaunchedEvent::class, + function (ConsoleLaunchedEvent $event) { + // Console is starting up + } +); +``` + +## Event Hierarchy + +```mermaid +classDiagram + class AbstractEvent { + +getType() string + } + class ShutdownEvent + class ContextEvent { + <> + +getContext() Context + } + class PreExecuteEvent { + +getHandler() + } + class HandlerEvent { + <> + +getHandler() Handler + } + class ResponseEvent { + <> + +getResponse() Response + } + class ResponsePrepareEvent + class ResponsePreparedEvent + class ResponsePreSendHeadersEvent + class ResponsePreSendContentEvent + class HandleCompleteEvent + class ConsoleEvent { + <> + +getConsole() Console + } + class ConsoleCreateEvent + class ConsolePrepareEvent { + +getInput() InputInterface + +getOutput() OutputInterface + } + + AbstractEvent <|-- ShutdownEvent + AbstractEvent <|-- ContextEvent + ContextEvent <|-- PreExecuteEvent + ContextEvent <|-- HandlerEvent + ContextEvent <|-- ConsoleEvent + HandlerEvent <|-- ResponseEvent + ResponseEvent <|-- ResponsePrepareEvent + ResponseEvent <|-- ResponsePreparedEvent + ResponseEvent <|-- ResponsePreSendHeadersEvent + ResponseEvent <|-- ResponsePreSendContentEvent + ResponseEvent <|-- HandleCompleteEvent + ConsoleEvent <|-- ConsoleCreateEvent + ConsoleEvent <|-- ConsolePrepareEvent +``` + +## HTTP Lifecycle Events + +These events fire on the **Cubex channel** during `Cubex::handle()`: + +| Event | When It Fires | Available Data | +|-------|--------------|----------------| +| `PreExecuteEvent` | Before the handler's `handle()` is called | Context, Handler | +| `ResponsePrepareEvent` | After handler returns, before cookies/prepare | Context, Handler, Response | +| `ResponsePreparedEvent` | After `$response->prepare()` is called | Context, Handler, Response | +| `ResponsePreSendHeadersEvent` | Before `$response->sendHeaders()` | Context, Handler, Response | +| `ResponsePreSendContentEvent` | Before `$response->sendContent()` | Context, Handler, Response | +| `HandleCompleteEvent` | After the full response has been sent | Context, Handler, Response | +| `ShutdownEvent` | During `$cubex->shutdown()` | — | + +### Event Flow + +```mermaid +sequenceDiagram + participant Cubex + participant Channel as Cubex Channel + participant Handler + + rect rgb(240, 240, 255) + Cubex->>Channel: PreExecuteEvent + Cubex->>Handler: handle(context) + Handler-->>Cubex: Response + end + + rect rgb(240, 255, 240) + Cubex->>Channel: ResponsePrepareEvent + Note over Cubex: Apply cookies, prepare + Cubex->>Channel: ResponsePreparedEvent + end + + rect rgb(255, 240, 240) + Cubex->>Channel: PreSendHeadersEvent + Cubex->>Channel: PreSendContentEvent + Cubex->>Channel: HandleCompleteEvent + end + + Cubex->>Channel: ShutdownEvent +``` + +{: .note } +`PreExecuteEvent` is also fired by `RouteProcessor::_processHandler()` when a sub-handler is executed during route resolution. + +## CLI Lifecycle Events + +CLI events fire on both channels: + +| Event | Channel | When It Fires | Available Data | +|-------|---------|--------------|----------------| +| `ConsoleLaunchedEvent` | Context | At the start of `Cubex::cli()` | Input, Output | +| `ConsoleCreatedEvent` | Context | When the Console object is first created | Console | +| `ConsoleCreateEvent` | Cubex | Same time as `ConsoleCreatedEvent` | Context, Console | +| `ConsolePrepareEvent` | Cubex | Just before `console->run()` | Context, Console, Input, Output | + +## Event Data Access + +All context events provide access to the context: + +```php +$cubex->listen(PreExecuteEvent::class, function (PreExecuteEvent $e) { + $context = $e->getContext(); + $handler = $e->getHandler(); +}); +``` + +Response events add access to the handler and response: + +```php +$cubex->listen(ResponsePrepareEvent::class, function (ResponsePrepareEvent $e) { + $context = $e->getContext(); + $handler = $e->getHandler(); + $response = $e->getResponse(); +}); +``` + +Console events provide access to the console application: + +```php +$cubex->listen(ConsolePrepareEvent::class, function (ConsolePrepareEvent $e) { + $console = $e->getConsole(); + $input = $e->getInput(); + $output = $e->getOutput(); +}); +``` + +## Common Use Cases + +### Adding Response Headers + +```php +$cubex->listen(ResponsePreparedEvent::class, function (ResponsePreparedEvent $e) { + $e->getResponse()->headers->set('X-Request-Id', uniqid()); +}); +``` + +### Logging Request Duration + +```php +$cubex->listen(PreExecuteEvent::class, function (PreExecuteEvent $e) { + $e->getContext()->meta()->set('request_start', microtime(true)); +}); + +$cubex->listen(HandleCompleteEvent::class, function (HandleCompleteEvent $e) { + $start = $e->getContext()->meta()->get('request_start'); + $duration = microtime(true) - $start; + Cubex::log()->info('Request completed', ['duration_ms' => $duration * 1000]); +}); +``` + +### Registering Console Commands Dynamically + +```php +$cubex->listen(ConsoleCreateEvent::class, function (ConsoleCreateEvent $e) { + $e->getConsole()->add(new MigrateCommand()); + $e->getConsole()->add(new SeedCommand()); +}); +``` + +## Server Timing + +The `Cubex\Context\Context` class automatically adds `Server-Timing` headers to responses via a listener on `ResponsePreSendHeadersEvent`. Use the context's timer API: + +```php +$timer = $context->newTimer('db-query', 'Database query'); +// ... perform query ... +$timer->stop(); + +// The Server-Timing header is added automatically before headers are sent +``` diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..5f4ada6 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,102 @@ +--- +title: Cubex Framework +layout: default +nav_order: 1 +--- + +# Cubex Framework + +Cubex is a PHP 8.2+ web application framework that provides routing, dependency injection, middleware, console commands, and a ViewModel layer. It is built on top of `packaged/*` libraries and Symfony components. + +## Requirements + +- PHP 8.2 or later +- Composer + +## Installation + +```bash +composer require cubex/framework +``` + +## Quick Start + +Create a basic HTTP application with a router: + +```php +onPath('/', new FuncHandler(fn() => new TextResponse('Hello, Cubex!'))) + ->onPath('/about', new FuncHandler(fn() => new TextResponse('About page'))); + +$response = $cubex->handle($router); +$cubex->shutdown(); +``` + +## Configuration + +Cubex loads INI configuration files from a `conf/` directory relative to your project root. Files are loaded in cascade order: + +1. `conf/defaults.ini` +2. `conf/defaults/config.ini` +3. `conf/{environment}.ini` +4. `conf/{environment}/config.ini` + +The environment is set via the `CUBEX_ENV` environment variable. + +## Core Concepts + +| Topic | Description | +|-------|-------------| +| [Request Lifecycle]({% link request-lifecycle.md %}) | How HTTP requests and CLI commands flow through the framework | +| [Routing]({% link routing.md %}) | Generator-based route matching with the Router fluent API | +| [Controllers]({% link controllers.md %}) | HTTP verb method resolution and response preparation | +| [Dependency Injection]({% link dependency-injection.md %}) | The DI container, sharing, factories, and auto-resolution | +| [Middleware]({% link middleware.md %}) | Onion-layer middleware chain for request/response processing | +| [ViewModels]({% link viewmodels.md %}) | ViewModel/View separation, templating, and JSON rendering | +| [Events]({% link events.md %}) | Framework lifecycle events and the Channel dispatcher | +| [Console]({% link console.md %}) | Symfony Console integration with auto-configured commands | +| [Condition Processor]({% link condition-processor.md %}) | PHP 8 attribute-based pre-conditions and skip-conditions | + +## Architecture Overview + +```mermaid +flowchart TD + subgraph HTTP["HTTP Request"] + direction TB + Entry["public/index.php"] --> CubexBoot["Bootstrap"] + CubexBoot --> CtxInit["Context Init"] + CtxInit --> Handle["Cubex::handle()"] + Handle --> Router["Router / Controller"] + Router --> MW["Middleware"] + MW --> Handler["Handler"] + Handler --> Response["Response"] + end + + subgraph CLI["CLI Command"] + direction TB + CLIEntry["cubex"] --> CLIBoot["Bootstrap"] + CLIBoot --> CLICtx["Context Init"] + CLICtx --> CLICli["Cubex::cli()"] + CLICli --> Console["Console"] + Console --> Command["ConsoleCommand"] + end + + subgraph Core["Core Services"] + DI["DI Container"] ~~~ Config["INI Config"] + Context["Context"] ~~~ Events["Event Channel"] + end + + HTTP -.-> Core + CLI -.-> Core +``` diff --git a/docs/middleware.md b/docs/middleware.md new file mode 100644 index 0000000..86a1ec4 --- /dev/null +++ b/docs/middleware.md @@ -0,0 +1,209 @@ +--- +title: Middleware +layout: default +nav_order: 6 +--- + +# Middleware + +Cubex provides an onion-layer middleware system that wraps handlers. Each middleware can process the request before and after the inner handler executes. + +## Middleware Chain + +```mermaid +sequenceDiagram + participant Client + participant MH as MiddlewareHandler + participant MW1 as Middleware 1 + participant MW2 as Middleware 2 + participant Handler as Inner Handler + + rect rgb(240, 240, 255) + Note right of Client: Request + Client->>MH: handle(context) + MH->>MW1: handle(context) + MW1->>MW2: next(context) + MW2->>Handler: next(context) + end + + rect rgb(240, 255, 240) + Note right of Client: Response + Handler-->>MW2: Response + MW2-->>MW1: Response + MW1-->>MH: Response + MH-->>Client: Response + end +``` + +Middleware executes in an onion pattern: +1. The outermost middleware receives the request first +2. Each middleware can run logic before calling `next()` to pass to the next layer +3. The innermost layer is the actual handler +4. Responses bubble back through each middleware in reverse order + +## Implementing Middleware + +Extend the abstract `Middleware` base class: + +```php +use Cubex\Middleware\Middleware; +use Packaged\Context\Context; +use Symfony\Component\HttpFoundation\Response; + +class TimingMiddleware extends Middleware +{ + public function handle(Context $c): Response + { + $start = microtime(true); + + // Call the next handler in the chain + $response = $this->next($c); + + $duration = microtime(true) - $start; + $response->headers->set('X-Response-Time', round($duration * 1000) . 'ms'); + + return $response; + } +} +``` + +The key parts: +- Extend `Middleware` (or implement `MiddlewareInterface` directly) +- Call `$this->next($c)` to pass control to the next middleware or the inner handler +- You can modify the request (context) before calling `next()` and modify the response after + +## MiddlewareInterface + +For full control, implement the interface directly: + +```php +use Cubex\Middleware\MiddlewareInterface; +use Packaged\Context\Context; +use Packaged\Routing\Handler\Handler; +use Symfony\Component\HttpFoundation\Response; + +class AuthMiddleware implements MiddlewareInterface +{ + private Handler $_next; + + public function setNext(Handler $handler): Handler + { + $this->_next = $handler; + return $this; + } + + public function handle(Context $c): Response + { + if (!$c->request()->headers->has('Authorization')) + { + return new Response('Unauthorized', 401); + } + + return $this->_next->handle($c); + } +} +``` + +## Using MiddlewareHandler + +`MiddlewareHandler` wraps a handler with a chain of middleware: + +```php +use Cubex\Middleware\MiddlewareHandler; + +$handler = new MiddlewareHandler($router); +$handler->append(new TimingMiddleware()); +$handler->append(new AuthMiddleware()); +$handler->append(new CorsMiddleware()); + +$response = $cubex->handle($handler); +``` + +### MiddlewareHandler Methods + +| Method | Description | +|--------|-------------| +| `__construct(Handler $handler)` | Create a middleware handler wrapping an inner handler | +| `append(MiddlewareInterface $mw)` | Add middleware to the end of the chain (outermost) | +| `prepend(MiddlewareInterface $mw)` | Add middleware to the front of the chain (innermost) | +| `add(MiddlewareInterface $mw, ?int $mode)` | Add with explicit mode (`PREPEND` or `APPEND`) | +| `remove(MiddlewareInterface\|string $mw)` | Remove the first middleware matching the instance or class name | +| `replace(MiddlewareInterface\|string $old, MiddlewareInterface $new)` | Replace the first matching middleware | + +### Execution Order + +Middleware added with `append()` wraps further out, while `prepend()` wraps closer to the inner handler: + +```php +$handler = new MiddlewareHandler($router); +$handler->append(new A()); // Outermost +$handler->append(new B()); // Even more outer +$handler->prepend(new C()); // Innermost (closest to router) + +// Execution order: B → A → C → Router → C → A → B +``` + +## Common Middleware Patterns + +### Short-Circuit Middleware + +Return a response directly without calling `next()` to stop the chain: + +```php +class MaintenanceMiddleware extends Middleware +{ + public function handle(Context $c): Response + { + if ($this->isMaintenanceMode()) + { + return new Response('Service unavailable', 503); + } + + return $this->next($c); + } +} +``` + +### Request Modification + +Modify the context before passing it along: + +```php +class JsonBodyMiddleware extends Middleware +{ + public function handle(Context $c): Response + { + $request = $c->request(); + if ($request->getContentTypeFormat() === 'json') + { + $data = json_decode($request->getContent(), true); + $request->request->replace($data ?? []); + } + + return $this->next($c); + } +} +``` + +### Response Modification + +Transform the response on the way back: + +```php +class CompressionMiddleware extends Middleware +{ + public function handle(Context $c): Response + { + $response = $this->next($c); + + if (str_contains($c->request()->headers->get('Accept-Encoding', ''), 'gzip')) + { + $compressed = gzencode($response->getContent()); + $response->setContent($compressed); + $response->headers->set('Content-Encoding', 'gzip'); + } + + return $response; + } +} +``` diff --git a/docs/request-lifecycle.md b/docs/request-lifecycle.md new file mode 100644 index 0000000..c3684a8 --- /dev/null +++ b/docs/request-lifecycle.md @@ -0,0 +1,157 @@ +--- +title: Request Lifecycle +layout: default +nav_order: 2 +--- + +# Request Lifecycle + +Cubex handles both HTTP requests and CLI commands through distinct but related lifecycles. Both begin with bootstrapping a `Cubex` instance and creating a `Context`. + +## HTTP Lifecycle + +The HTTP lifecycle is driven by `Cubex::handle(Handler $handler)`. Here is the full flow: + +```mermaid +sequenceDiagram + participant Entry as index.php + participant Cubex + participant Ctx as Context + participant Channel as Event Channel + participant Handler + participant Response + + rect rgb(240, 240, 255) + Note right of Entry: Setup + Entry->>Cubex: new Cubex($root, $loader) + Entry->>Cubex: handle($handler) + Cubex->>Ctx: getContext() + Cubex->>Ctx: initialize() + Cubex->>Handler: setContext($ctx) + end + + rect rgb(240, 255, 240) + Note right of Entry: Execute + Cubex->>Channel: PreExecuteEvent + Cubex->>Handler: handle($ctx) + Handler-->>Cubex: Response + end + + rect rgb(255, 240, 240) + Note right of Entry: Send Response + Cubex->>Channel: ResponsePrepareEvent + Cubex->>Response: apply cookies, prepare() + Cubex->>Channel: ResponsePreparedEvent + Cubex->>Channel: PreSendHeadersEvent + Cubex->>Response: sendHeaders() + Cubex->>Channel: PreSendContentEvent + Cubex->>Response: sendContent() + Cubex->>Channel: HandleCompleteEvent + end + + Entry->>Cubex: shutdown() + Cubex->>Channel: ShutdownEvent +``` + +### Bootstrap + +```php +$loader = require __DIR__ . '/../vendor/autoload.php'; +$cubex = new Cubex(__DIR__ . '/..', $loader); +``` + +The constructor: +1. Sets the project root path +2. Creates an event `Channel` named `'cubex'` +3. Shares the `ClassLoader` and `DependencyInjector` (itself) in the DI container +4. Registers a `Context` factory that creates contexts from `Request::createFromGlobals()` + +### Context Preparation + +When `handle()` is called, it retrieves the shared `Context` from the DI container. The context is prepared with: +- **Environment** from the `CUBEX_ENV` environment variable +- **Project root** path +- **Configuration** loaded from INI files in cascade order +- **Cubex reference** set on the context (if `CubexAware`) + +### Handler Execution + +The handler (typically a `Router`, `Application`, or `Controller`) receives the context and produces a `Response`. If the handler is `ContextAware`, its context is set before execution. + +### Response Processing + +After the handler returns a response, Cubex: +1. Fires `ResponsePrepareEvent` (listeners can modify the response) +2. Applies cookies from the context's cookie jar +3. Calls `$response->prepare($request)` to finalize headers +4. Fires `ResponsePreparedEvent` +5. Sends headers, flushes, then sends content +6. Calls `fastcgi_finish_request()` if running under PHP-FPM +7. Fires `HandleCompleteEvent` + +### Exception Handling + +By default, Cubex catches exceptions in production environments and re-throws them in `local` and `dev` environments. This behavior is controlled by `setThrowEnvironments()`: + +```php +$cubex->setThrowEnvironments([Context::ENV_LOCAL, Context::ENV_DEV]); +``` + +## CLI Lifecycle + +The CLI lifecycle is driven by `Cubex::cli()`: + +```mermaid +sequenceDiagram + participant Entry as cubex + participant Cubex + participant Ctx as Context + participant Channel as Event Channel + participant Console + participant Command + + rect rgb(240, 240, 255) + Note right of Entry: Bootstrap + Entry->>Cubex: new Cubex($root, $loader) + Entry->>Cubex: cli($input, $output) + Cubex->>Ctx: getContext() + end + + rect rgb(240, 255, 240) + Note right of Entry: Console Setup + Cubex->>Ctx: trigger(ConsoleLaunchedEvent) + Cubex->>Console: create Console + Cubex->>Ctx: trigger(ConsoleCreatedEvent) + Cubex->>Channel: ConsoleCreateEvent + Note over Console: Registers commands from config + end + + rect rgb(255, 240, 240) + Note right of Entry: Execute + Cubex->>Channel: ConsolePrepareEvent + Cubex->>Console: run($input, $output) + Console->>Command: execute() + Command-->>Console: exit code + Console-->>Cubex: exit code + end +``` + +### CLI Bootstrap + +```php +$loader = require __DIR__ . '/../vendor/autoload.php'; +$cubex = new Cubex(__DIR__ . '/..', $loader); +exit($cubex->cli()); +``` + +`Cubex::cli()` creates default `ArgvInput` and `ConsoleOutput` if none are provided, then: +1. Fires `ConsoleLaunchedEvent` on the context event channel +2. Creates the `Console` application (lazy, cached) +3. Fires `ConsoleCreatedEvent` (context channel) and `ConsoleCreateEvent` (cubex channel) +4. Fires `ConsolePrepareEvent` on the cubex channel +5. Runs the console application +6. Returns the exit code (capped at 255) + +### Shutdown + +Call `$cubex->shutdown()` after handling completes. This fires the `ShutdownEvent` exactly once (guarded against double-shutdown). If shutdown is not called explicitly, the destructor will attempt it and log a warning. diff --git a/docs/routing.md b/docs/routing.md new file mode 100644 index 0000000..e287a24 --- /dev/null +++ b/docs/routing.md @@ -0,0 +1,145 @@ +--- +title: Routing +layout: default +nav_order: 3 +--- + +# Routing + +Cubex uses a generator-based routing system built on `packaged/routing`. Routes are defined by yielding conditions from `_generateRoutes()`, and the framework traverses them to find a matching handler. + +## How Route Resolution Works + +```mermaid +flowchart TD + Start["handle()"] --> GetHandler["_getHandler(context)"] + GetHandler --> GenRoutes["_generateRoutes()"] + GenRoutes --> Check{"Next condition?"} + Check -->|Yes| Eval{"Matches?"} + Check -->|No| Default["Default handler"] + Eval -->|No| Check + Eval -->|Yes| IsRoute{"Traversable?"} + IsRoute -->|Yes| Traverse["Traverse sub-handler"] --> Check + IsRoute -->|No| Handler["Matched handler"] + Handler --> Prepare["_prepareHandler()"] + Default --> Prepare + Prepare --> Process["_processHandler()"] + Process --> PrepResp["_prepareResponse()"] +``` + +The `RouteSelector` base class (from `packaged/routing`) calls `_generateRoutes()` and iterates through the yielded `ConditionHandler` pairs. For each: + +1. The condition is evaluated against the current context +2. If the handler is a `Route` (traversable), it recurses into the sub-handler +3. Otherwise, the matched handler is returned + +The result passes through `_prepareHandler()` (string-to-class resolution, redirect handling) and then `_processHandler()` (execution). + +## The Router Class + +`Router` provides a fluent API for defining routes without subclassing: + +```php +use Cubex\Routing\Router; +use Packaged\Http\Response\TextResponse; +use Packaged\Routing\Handler\FuncHandler; + +$router = Router::i() + ->onPath('/hello', new FuncHandler( + fn() => new TextResponse('Hello, World!') + )) + ->onPathFunc('/greet/{name}', function ($ctx) { + $name = $ctx->routeData()->get('name'); + return new TextResponse("Hello, {$name}!"); + }) + ->setDefaultHandler(new FuncHandler( + fn() => new TextResponse('Not Found', 404) + )); +``` + +### Router Methods + +| Method | Description | +|--------|-------------| +| `Router::i()` | Static factory for a new Router instance | +| `onPath($path, $handler)` | Add a route matching the given path pattern | +| `onPathFunc($path, callable $func)` | Add a route with a callable (wrapped in `FuncHandler`) | +| `setDefaultHandler(Handler $handler)` | Set the fallback handler when no route matches | +| `addCondition(ConditionHandler $cond)` | Add a custom condition/handler pair | +| `getHandler(Context $ctx)` | Resolve the matching handler for a context | + +### Path Patterns + +Path matching uses `RequestCondition` from `packaged/routing`. Patterns support: + +| Syntax | Description | Example | +|--------|-------------|---------| +| `/literal` | Exact path segment | `/users` | +| `/{name}` | Named path variable | `/users/{id}` | +| `/{name@constraint}` | Constrained variable | `/{id@num}` | +| Prefix matching | Routes match path prefixes by default | `/api` matches `/api/users` | + +Route data (captured variables) is available via `$context->routeData()`. + +## Generator-Based Routes + +For custom route processors, override `_generateRoutes()` to yield route conditions: + +```php +use Cubex\Routing\RouteProcessor; +use Packaged\Routing\ConditionHandler; +use Packaged\Routing\Handler\FuncHandler; +use Packaged\Routing\RequestCondition; + +class MyRouter extends RouteProcessor +{ + protected function _generateRoutes(): Generator + { + yield self::_route('/dashboard', DashboardController::class); + yield self::_route('/api', ApiRouter::class); + yield self::_route('/health', new FuncHandler( + fn() => new TextResponse('OK') + )); + // Default handler returned (not yielded) + return new FuncHandler(fn() => new TextResponse('Not Found', 404)); + } +} +``` + +The `_route()` helper (from `RouteSelector`) creates `ConditionHandler` pairs from a path and handler. + +## Redirect Shorthand + +String handlers beginning with `@` are treated as redirects: + +``` +@301!/new-url → 301 redirect to /new-url +@302!/other → 302 redirect to /other +``` + +This is handled in `RouteProcessor::_prepareHandler()`. + +## Handler Resolution Chain + +When `_prepareHandler()` processes a handler, it follows this chain: + +1. **String containing `\`**: Treated as a class name — resolved via DI container (if Cubex is available) or instantiated directly +2. **String starting with `@`**: Parsed as a redirect (`@{code}!{url}`) +3. **Callable**: Invoked directly, result processed recursively +4. **Handler instance**: `handle()` is called with the context +5. **String (in Controller)**: Resolved to controller methods via HTTP verb prefixes (see [Controllers]({% link controllers.md %})) + +## Nested Routing + +Routes can be nested by returning other `RouteProcessor` instances (routers, applications, or controllers) as handlers: + +```php +protected function _generateRoutes(): Generator +{ + // ApiRouter is itself a RouteProcessor with its own _generateRoutes() + yield self::_route('/api', ApiRouter::class); + yield self::_route('/admin', AdminController::class); +} +``` + +The path is consumed segment by segment as the route traverses into nested handlers. diff --git a/docs/viewmodels.md b/docs/viewmodels.md new file mode 100644 index 0000000..13420d3 --- /dev/null +++ b/docs/viewmodels.md @@ -0,0 +1,268 @@ +--- +title: ViewModels +layout: default +nav_order: 7 +--- + +# ViewModels + +Cubex separates data (Models/ViewModels) from presentation (Views). This pattern keeps business logic out of templates and makes models independently testable and JSON-serializable. + +## Class Hierarchy + +```mermaid +classDiagram + class Model { + <> + +jsonSerialize() mixed + } + class View { + <> + +setModel(Model data) + +render() string + } + class ViewModel { + +setView(string viewClass) self + +createView() View + +lock() self + } + class AbstractView { + <> + +setModel(Model data) + +render() string + } + class TemplatedViewModel { + +render() string + +addVariant(string variant) self + } + class JsonView { + +setFlags(int flags) self + +render() string + } + class ArrayModel { + +set(array data) self + +addItem(value, key) self + } + + Model <|.. ViewModel + View <|.. AbstractView + ViewModel <|-- TemplatedViewModel + View <|.. TemplatedViewModel + AbstractView <|-- JsonView + ViewModel <|-- ArrayModel +``` + +## ViewModel + +The primary data container. Holds data as public properties, supports JSON serialization, and can create a corresponding View: + +```php +use Cubex\ViewModel\ViewModel; + +class UserProfileModel extends ViewModel +{ + protected string $_defaultView = UserProfileView::class; + + public string $name; + public string $email; + public int $age; +} +``` + +### Creating and Using + +```php +$model = new UserProfileModel(); +$model->name = 'Alice'; +$model->email = 'alice@example.com'; +$model->age = 30; + +// Create the associated view and render +$view = $model->createView(); +$html = $view->render(); + +// Or serialize to JSON +$json = json_encode($model); +// {"name":"Alice","email":"alice@example.com","age":30} +``` + +### Locking (Immutability) + +Call `lock()` to freeze the model's state. After locking, property values are captured and the model becomes read-only: + +```php +$model->lock(); + +// Properties are still readable +echo $model->name; // "Alice" + +// But setting throws an exception +$model->name = 'Bob'; // Throws: "Cannot set property 'name' ... is immutable" +``` + +Locked models serialize from their frozen snapshot rather than live properties. + +### Custom View Override + +Override the view class at creation time: + +```php +$view = $model->createView(MobileUserProfileView::class); +``` + +## View + +Views receive a model and render it to a string. Implement the `View` interface: + +```php +use Cubex\ViewModel\View; +use Cubex\ViewModel\Model; + +class UserProfileView implements View +{ + private Model $_model; + + public function setModel(Model $data): void + { + $this->_model = $data; + } + + public function render(): string + { + return "
" + . "

{$this->_model->name}

" + . "

{$this->_model->email}

" + . "
"; + } +} +``` + +### AbstractView + +A convenient base class that stores the model and provides a `_render()` hook: + +```php +use Cubex\ViewModel\AbstractView; + +class UserCardView extends AbstractView +{ + protected function _render(): ?ISafeHtmlProducer + { + // Access the model via $this->_model + return new SafeHtml("
{$this->_model->name}
"); + } +} +``` + +## TemplatedViewModel + +Combines ViewModel and View into a single class. Renders using `.phtml` template files located alongside the class file: + +```php +use Cubex\ViewModel\TemplatedViewModel; + +class DashboardPage extends TemplatedViewModel +{ + public int $userCount; + public int $orderCount; + public array $recentOrders; +} +``` + +With a template at `DashboardPage.phtml` in the same directory: + +```php + +
+

Dashboard

+

Users: userCount ?>

+

Orders: orderCount ?>

+
    + recentOrders as $order): ?> +
  • :
  • + +
+
+``` + +### Template Variants + +Add variant templates that take priority over the default. Useful for device-specific or A/B test rendering: + +```php +$page = new DashboardPage(); +$page->addVariant('mobile'); +// Looks for DashboardPage.mobile.phtml first, +// falls back to DashboardPage.phtml +echo $page->render(); +``` + +### Self-Rendering + +`TemplatedViewModel` acts as its own view. Calling `createView()` without an override returns `$this`: + +```php +$page = new DashboardPage(); +$view = $page->createView(); // Returns $page itself +echo $view->render(); // Renders the template +``` + +## JsonView + +Renders any `Model` as JSON: + +```php +use Cubex\ViewModel\JsonView; + +$model = new UserProfileModel(); +$model->name = 'Alice'; +$model->email = 'alice@example.com'; + +$view = new JsonView(); +$view->setModel($model); +$view->setFlags(JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES); +echo $view->render(); +``` + +## ArrayModel + +A ViewModel backed by a simple array instead of typed properties: + +```php +use Cubex\ViewModel\ArrayModel; + +$model = new ArrayModel(); +$model->addItem('Alice', 'name'); +$model->addItem('alice@example.com', 'email'); +$model->set(['tags' => ['admin', 'user']]); + +echo json_encode($model); +// {"name":"Alice","email":"alice@example.com","tags":["admin","user"]} +``` + +## ViewModel Flow in Controllers + +When a controller method returns a `ViewModel`, the framework handles view creation and rendering automatically: + +```mermaid +flowchart LR + Controller["Controller returns
ViewModel"] --> SetCtx["Set context/cubex"] + SetCtx --> CreateView["createView()"] + CreateView --> SetModel["setModel()"] + SetModel --> Render["render()"] + Render --> Response["CubexResponse"] +``` + +```php +class ProfileController extends Controller +{ + public function getIndex(): UserProfileModel + { + $model = new UserProfileModel(); + $model->name = 'Alice'; + $model->email = 'alice@example.com'; + // Controller::_prepareResponse() handles the rest + return $model; + } +} +```