diff --git a/.editorconfig b/.editorconfig deleted file mode 100644 index d712c29..0000000 --- a/.editorconfig +++ /dev/null @@ -1,16 +0,0 @@ -# EditorConfig helps maintain consistent coding styles -root = true - -[*] -charset = utf-8 -end_of_line = lf -insert_final_newline = true -trim_trailing_whitespace = true - -[*.php] -indent_style = space -indent_size = 4 - -[*.{yml,yaml,json,md}] -indent_style = space -indent_size = 2 diff --git a/.env.example b/.env.example deleted file mode 100644 index 80383bf..0000000 --- a/.env.example +++ /dev/null @@ -1,29 +0,0 @@ -APP_NAME=Phred App -APP_ENV=local -APP_DEBUG=true -APP_TIMEZONE=UTC -APP_URL=http://localhost - -API_FORMAT=rest -API_PROBLEM_DETAILS=true - -DB_DRIVER=sqlite -DB_DATABASE=database/database.sqlite -DB_HOST=127.0.0.1 -DB_PORT=3306 -DB_USERNAME=root -DB_PASSWORD= - -ORM_DRIVER=pairity -TEMPLATE_DRIVER=eyrie -FLAGS_DRIVER=flagpole -TEST_RUNNER=codeception -MODULE_NAMESPACE=Modules -COMPRESSION_ENABLED=false -COMPRESSION_LEVEL_GZIP=-1 -COMPRESSION_LEVEL_BROTLI=4 - -CORS_ALLOWED_ORIGINS=* -CORS_ALLOWED_HEADERS="Content-Type, Authorization" -CORS_ALLOWED_METHODS="GET, POST, PUT, PATCH, DELETE, OPTIONS" - diff --git a/.gitattributes b/.gitattributes deleted file mode 100644 index 3b8c5cf..0000000 --- a/.gitattributes +++ /dev/null @@ -1,10 +0,0 @@ -# Exclude dev files from exported archives -/.github export-ignore -/tests export-ignore -/.editorconfig export-ignore -/.gitattributes export-ignore -/.gitignore export-ignore -/phpstan.neon.dist export-ignore -/.php-cs-fixer.php export-ignore -/MILESTONES.md export-ignore -/README.md export-ignore diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml deleted file mode 100644 index 38731b4..0000000 --- a/.github/workflows/ci.yml +++ /dev/null @@ -1,56 +0,0 @@ -name: CI - -on: - push: - branches: ["**"] - pull_request: - branches: ["**"] - -jobs: - build: - name: PHP ${{ matrix.php }} - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: ["8.1", "8.2", "8.3"] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - extensions: mbstring, intl, json - tools: composer:v2 - coverage: none - - - name: Validate composer.json - run: composer validate --no-check-publish --strict - - - name: Get composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache composer - uses: actions/cache@v4 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Install dependencies - run: composer install --no-interaction --prefer-dist --no-progress - - - name: PHP CS Fixer (dry-run) - run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --verbose src - - - name: PHPStan - run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G - - - name: Codeception (if configured) - if: hashFiles('**/codeception.yml') != '' - run: vendor/bin/codecept run --verbosity 1 diff --git a/.gitignore b/.gitignore deleted file mode 100644 index 1f2502c..0000000 --- a/.gitignore +++ /dev/null @@ -1,140 +0,0 @@ -# ---> Composer -composer.phar -/vendor/ - -# Template policy: do not commit composer.lock in this template repo -# (apps generated from this template should commit THEIR lock file) -composer.lock - -# ---> JetBrains -# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider -# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 - -# User-specific stuff -.idea/**/workspace.xml -.idea/**/tasks.xml -.idea/**/usage.statistics.xml -.idea/**/dictionaries -.idea/**/shelf - -# AWS User-specific -.idea/**/aws.xml - -# Generated files -.idea/**/contentModel.xml - -# Sensitive or high-churn files -.idea/**/dataSources/ -.idea/**/dataSources.ids -.idea/**/dataSources.local.xml -.idea/**/sqlDataSources.xml -.idea/**/dynamic.xml -.idea/**/uiDesigner.xml -.idea/**/dbnavigator.xml - -# Gradle -.idea/**/gradle.xml -.idea/**/libraries - -# Gradle and Maven with auto-import -# When using Gradle or Maven with auto-import, you should exclude module files, -# since they will be recreated, and may cause churn. Uncomment if using -# auto-import. -# .idea/artifacts -# .idea/compiler.xml -# .idea/jarRepositories.xml -# .idea/modules.xml -# .idea/*.iml -# .idea/modules -# *.iml -# *.ipr - -# CMake -cmake-build-*/ - -# Mongo Explorer plugin -.idea/**/mongoSettings.xml - -# File-based project format -*.iws - -# IntelliJ -out/ - -# mpeltonen/sbt-idea plugin -.idea_modules/ - -# JIRA plugin -atlassian-ide-plugin.xml - -# Cursive Clojure plugin -.idea/replstate.xml - -# SonarLint plugin -.idea/sonarlint/ - -# Crashlytics plugin (for Android Studio and IntelliJ) -com_crashlytics_export_strings.xml -crashlytics.properties -crashlytics-build.properties -fabric.properties - -# Editor-based Rest Client -.idea/httpRequests - -# Android studio 3.1+ serialized cache file -.idea/caches/build_file_checksums.ser - - -# --- Additional ignores (Phred project) --- - -# Environment files (keep .env.example tracked) -.env -.env.local -.env.*.local - -# IDE/editor folders -.idea/ -.vscode/ - -# OS files -.DS_Store -Thumbs.db - -# Editor swap/backup files -*.swp -*.swo -*~ - -# Tool caches and build artifacts -.php-cs-fixer.cache -.phpunit.result.cache -.cache/ -coverage/ -.coverage/ -build/ -var/ -tmp/ - -# Scaffolding output created during local development of this template -# These are part of generated apps, not this template repo -/public/ -/bootstrap/ -/routes/ -/resources/ -/modules/ -/storage/* -!/storage/.gitkeep -/config/ -/console/ - -# Local assistant/session preferences (developer-specific) -.junie/ - -# Codeception outputs -tests/_output/ -tests/_support/_generated/ - -/.env -/.phpunit.cache -/.php-cs-fixer.cache diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php deleted file mode 100644 index cf400b1..0000000 --- a/.php-cs-fixer.php +++ /dev/null @@ -1,19 +0,0 @@ -in(__DIR__ . '/src') - ->in(__DIR__ . '/modules') - ->in(__DIR__ . '/tests') - ->in(__DIR__ . '/config'); - -$config = new PhpCsFixer\Config(); -return $config->setRules([ - '@PSR12' => true, - 'strict_param' => true, - 'array_syntax' => ['syntax' => 'short'], - 'no_unused_imports' => true, - 'ordered_imports' => ['sort_algorithm' => 'alpha'], - 'single_quote' => true, - 'trailing_comma_in_multiline' => ['elements' => ['arrays']], -]) - ->setFinder($finder); diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md deleted file mode 100644 index cc2a5b8..0000000 --- a/CONTRIBUTING.md +++ /dev/null @@ -1,24 +0,0 @@ -# Contributing to Phred - -Thank you for your interest in Phred! We welcome contributions of all kinds. - -## RFC Process - -For major changes, please open an issue first to discuss what you would like to change. - -1. Create a "Request for Comments" (RFC) issue. -2. Wait for feedback from the core maintainers. -3. Once approved, submit a Pull Request. - -## Pull Request Process - -1. Fork the repository and create your branch from `main`. -2. If you've added code that should be tested, add tests. -3. Ensure the test suite passes (`composer test`). -4. Ensure static analysis passes (`composer analyze`). -5. Ensure code style matches PSR-12 (`composer fix`). -6. Update the documentation if necessary. - -## Code of Conduct - -Please be respectful and professional in all interactions. diff --git a/Dockerfile b/Dockerfile deleted file mode 100644 index f783b8d..0000000 --- a/Dockerfile +++ /dev/null @@ -1,22 +0,0 @@ -FROM php:8.2-fpm - -RUN apt-get update && apt-get install -y \ - libpq-dev \ - libzip-dev \ - zip \ - unzip \ - git \ - && docker-php-ext-install pdo_mysql pdo_pgsql zip - -COPY --from=composer:latest /usr/bin/composer /usr/bin/composer - -WORKDIR /var/www - -COPY . . - -RUN composer install --no-dev --optimize-autoloader - -RUN chown -R www-data:www-data storage bootstrap/cache - -EXPOSE 9000 -CMD ["php-fpm"] diff --git a/LICENSE b/LICENSE deleted file mode 100644 index 324b91c..0000000 --- a/LICENSE +++ /dev/null @@ -1,9 +0,0 @@ -MIT License - -Copyright (c) 2025 funkywaddle - -Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: - -The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. - -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/NOTES.md b/NOTES.md new file mode 100644 index 0000000..168ebb8 --- /dev/null +++ b/NOTES.md @@ -0,0 +1,136 @@ +# Phred Framework: Project Notes & Brainstorming + +This document serves as a mind-map and repository for ideas, architectural decisions, and future considerations for the Phred Framework. It is a living document that captures the "why" behind the "what" defined in the specifications and milestones. + +## Core Vision & Philosophy +- **Batteries-Included but Swappable**: Provide sensible defaults (REST, JSON:API, Eyrie, Pairity) while ensuring every core component can be swapped via Service Providers. +- **Standards First**: Strict adherence to PSRs (PSR-7, PSR-11, PSR-14, PSR-15, PSR-18). +- **Modular by Nature**: Django-style "Apps" (Modules) where features are encapsulated. +- **Developer Happiness (DX)**: Zero-config where possible, robust scaffolding, and clear documentation. + +## Architectural Brainstorming + +### 1. The HTTP Pipeline (PSR-15) +- Use `Relay` for the middleware stack. +- **Middleware Layers**: + - Global: CORS, Security Headers, Error Handling (Problem Details). + - Route-specific: Auth (JWT), Validation, Rate Limiting. + - Negotiation: `ContentNegotiationMiddleware` to handle REST vs JSON:API vs HTML. + +### 2. Pluggability & Service Providers +- **Contracts Package**: Define all core interfaces in `Phred\Support\Contracts`. +- **Driver Selection**: Use `.env` keys like `ORM_DRIVER`, `TEMPLATE_DRIVER`, `CACHE_DRIVER`. +- **Lifecycle**: Providers should have `register()` (binding to container) and `boot()` (executing logic like route registration). + +### 3. MVC & View Layer +- **Invokable Controllers**: "One action, one class" to prevent controller bloat. +- **View Objects**: A dedicated layer for data transformation/preparation before the template. This keeps controllers focused on flow and templates focused on markup. +- **Response Factories**: Abstract the creation of REST vs JSON:API responses. + +### 4. Modular Architecture (Django-style) +- All user code lives in `modules/`. +- **Contract-First Persistence**: Modules should define pure domain models (POPOs) and repository interfaces. The framework uses a "Bridge" architecture to map these to a specific ORM. +- **Scaffolding**: CLI should be able to generate an entire module structure in one command. + +### 5. Persistence Bridge Strategy (ORM-Agnostic) +To achieve true decoupling, Phred adopts a "Persistence Bridge" pattern, mirroring the TaskerBridges architecture. This removes the need for driver-specific directories (like `Persistence/Pairity/`) within modules and moves implementation details to the framework's infrastructure layer. + +#### Implementation Concept +1. **Flattened Directory Structure**: + - `modules//Models/`: Contains the data objects/entities (POPOs). + - `modules//Repositories/`: Contains the repository interfaces. +2. **The Bridge as a Translator**: + - The framework boots an active **ORM Bridge** (e.g., `PairityBridge`, `EloquentBridge`) based on `.env`. + - The Bridge is responsible for taking a module's model and explaining it to its respective persistence engine using PHP 8 Attributes (metadata). +3. **Automated Discovery & Wiring**: + - Similar to Tasker's command discovery, Phred scans the `Models/` directory to register entities. + - Phred auto-binds repository implementations to their corresponding interfaces in the PSR-11 container. +4. **Environment-Driven**: Connection details and driver selection are handled exclusively via environment variables, keeping module code pure. + +5. **Migration & Schema Management**: + - **Auto-Discovery**: Bridges scan `Models/` to detect schema changes. + - **Versioned Snapshots**: The framework can cache a "last-known" state of the models to generate diff-based migrations. + - **Tooling**: CLI commands can generate migration files by comparing current POPO attributes against the database or a cached snapshot. + +#### Example Domain Model (Attribute-Driven) +```php +namespace App\Modules\User\Models; + +use Phred\Persistence\Attributes\Entity; +use Phred\Persistence\Attributes\Column; + +#[Entity(table: 'users')] +class User +{ + #[Column(primary: true, autoIncrement: true)] + public int $id; + + #[Column(unique: true)] + public string $email; + + #[Column(nullable: true)] + public ?string $name; + + #[Column(name: 'created_at')] + public \DateTimeImmutable $createdAt; +} +``` + +#### Benefits +- **Zero-Config Persistence**: Developers only write the Model and the Interface; Phred handles the connection and injection. +- **Multi-ORM Support**: The same model can work across different ORMs by switching the Bridge. +- **Simplified DX**: No deeply nested folders for every module; logic is localized and clean. + +### 6. Command Discovery Strategy (Tasker Integration) +To provide a "zero-configuration" experience for developers, the framework should implement an automated directory-based command loader that bridges the application's `src/Commands` directory with Tasker. + +#### Proposed Implementation Flow +1. **Directory Scan**: Use `RecursiveDirectoryIterator` to find all PHP files in `src/Commands`. +2. **Class Resolution**: Map file paths to fully qualified class names following PSR-4 conventions. +3. **Container-First Loading**: + - Check if the class is already registered in the PSR-11 container. + - If found, retrieve the instance from the container (ensuring dependency injection). + - If not found, Tasker's `Runner::register($className)` will instantiate it directly. +4. **Registration**: Pass the resolved class name or instance to `$taskerRunner->register()`. + +5. **Migration & Schema Management**: + - **Auto-Discovery**: Bridges scan `Models/` to detect schema changes. + - **Versioned Snapshots**: The framework can cache a "last-known" state of the models to generate diff-based migrations. + - **Tooling**: CLI commands can generate migration files by comparing current POPO attributes against the database or a cached snapshot. + +#### Benefits +- **Zero Config**: Developers just drop a class into the directory. +- **DI Support**: Automatically respects container-managed services. +- **Type Safety**: Tasker validates that the class implements `CommandInterface` or uses the `HasAttributes` trait. + +#### Example Discovery Logic +```php +$commandPath = $appRoot . '/src/Commands'; +$namespace = 'App\Commands\'; + +$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($commandPath)); + +foreach ($files as $file) { + if ($file->getExtension() !== 'php') { + continue; + } + + $className = $namespace . str_replace( + ['/', '.php'], + ['\', ''], + substr($file->getPathname(), strlen($commandPath) + 1) + ); + + $taskerRunner->register($className); +} +``` + +### 7. Security & Auth +- JWT by default for APIs. +- CSRF protection for traditional web routes. +- **Feature Flags**: Native integration with `FlagPole`. + +### 8. Documentation & Discovery +- Documentation site built *with* Phred. +- **Dynamic Help**: A CLI command that opens the online docs for a specific framework command. +- **OpenAPI**: Auto-generation from annotations. diff --git a/README.md b/README.md deleted file mode 100644 index 752d381..0000000 --- a/README.md +++ /dev/null @@ -1,149 +0,0 @@ -# Phred - -A PHP MVC framework intended for projects of all sizes, designed for both solo and team development. - -## Requirements - -* **PHP**: 8.2+ -* **Web Server**: Apache/Nginx (recommended) -* **Package Manager**: Composer - -## Installation - -Install Phred via Composer: - -```bash -composer create-project getphred/phred -``` - -## Getting Started - -### Creating a Module - -Phred uses a modular (Django-style) architecture. All your application logic lives inside modules. - -To scaffold a new module: - -```bash -php phred create:module Shop -``` - -This will create the module structure under `modules/Shop`, register the service provider, and mount the routes. - -After creating a module, update your `composer.json` to include the new namespace: - -```json -{ - "autoload": { - "psr-4": { - "Modules\\\\Shop\\\\": "modules/Shop/" - } - } -} -``` - -Then run: -```bash -composer dump-autoload -``` - -### Running the Application - -Start the local development server: - -```bash -php phred run -``` - -The application will be available at `http://localhost:8000`. - -## Configuration - -Phred uses a `.env` file for configuration. Key settings include: - -* `API_FORMAT`: The default API format (`rest` or `jsonapi`). -* `MODULE_NAMESPACE`: The base namespace for your modules (default: `Modules`). -* `APP_DEBUG`: Enable debug mode (`true` or `false`). -* `COMPRESSION_ENABLED`: Enable response compression (Gzip/Brotli). - -## CLI Usage (phred) - -The `phred` binary provides several utility and scaffolding commands. - -### Generators -* `php phred create:module ` — Create a new module. -* `php phred create:command ` — Create a custom CLI command. -* `php phred create::controller ` — Create a controller. -* `php phred create::view ` — Create a view and template. -* `php phred create::model ` — Create a domain model. -* `php phred create::migration ` — Create a migration. -* `php phred create::seed ` — Create a database seeder. -* `php phred create::test ` — Create a test. - -### Database -* `php phred migrate` — Run database migrations. -* `php phred migration:rollback` — Rollback migrations. -* `php phred seed` — Seed the database. -* `php phred db:backup` — Backup the database. -* `php phred db:restore` — Restore the database. - -### Testing & Utilities -* `php phred test` — Run tests for the entire project. -* `php phred test:` — Run tests for a specific module. -* `php phred module:list` — List all discovered and registered modules. -* `php phred module:sync-ns` — Sync `composer.json` PSR-4 with `MODULE_NAMESPACE`. -* `php phred list` — List all available commands. - -## Routing - -Routes are defined in `routes/web.php`, `routes/api.php`, or within your module's `Routes/` directory. Phred uses `nikic/fast-route` for high-performance routing. - -### Basic Routes - -You can define routes using the `$router` instance: - -```php -// routes/web.php -$router->get('/welcome', WelcomeController::class); -$router->post('/submit', SubmitFormController::class); -``` - -### Route Groups - -Groups allow you to share prefixes and middleware: - -```php -$router->group(['prefix' => '/api', 'middleware' => 'api'], function ($router) { - $router->get('/users', ListUsersController::class); - $router->get('/users/{id}', ShowUserController::class); -}); -``` - -### Module Auto-Mounting - -Phred automatically mounts module routes based on folder name: -- `modules/Shop/Routes/web.php` → mounted at `/shop` -- `modules/Shop/Routes/api.php` → mounted at `/api/shop` (with `api` middleware) - -### Route Listing & Caching - -View all registered routes: -```bash -php phred route:list -``` - -In production, cache your routes for maximum performance: -```bash -php phred route:cache -php phred route:clear # To clear cache -``` - -## Technical Specifications - -For detailed information on the framework architecture, service providers, configuration, and MVC components, please refer to: - -👉 **[SPECS.md](./SPECS.md)** | [MILESTONES.md](./MILESTONES.md) - -## License - -Phred is open-source software licensed under the MIT license. diff --git a/bin/phred b/bin/phred deleted file mode 100644 index 9bd7b29..0000000 --- a/bin/phred +++ /dev/null @@ -1,80 +0,0 @@ -#!/usr/bin/env php -add($cmd->toSymfony()); - - // If it's a generator, also register module-specific versions - if (in_array($cmd->getName(), $generators, true)) { - $modulesDir = getcwd() . '/modules'; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $module) { - if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) { - continue; - } - // Create a module-specific command name: create:shop:controller - $moduleCmdName = str_replace('create:', 'create:' . strtolower($module) . ':', $cmd->getName()); - - // We need a fresh instance for each command name to avoid overwriting Symfony's command registry - $moduleCmd = require $file; - $moduleCmd->setName($moduleCmdName); - $app->add($moduleCmd->toSymfony()); - } - } - } - } - } - } - - // Discover user commands in console/commands - $userDir = getcwd() . '/console/commands'; - if (is_dir($userDir)) { - foreach (glob($userDir . '/*.php') as $file) { - $cmd = require $file; - if ($cmd instanceof \Phred\Console\Command) { - $app->add($cmd->toSymfony()); - } - } - } - - // Run - $app->run(); -} diff --git a/bin/scripts/generate_toc.php b/bin/scripts/generate_toc.php deleted file mode 100644 index b415c55..0000000 --- a/bin/scripts/generate_toc.php +++ /dev/null @@ -1,86 +0,0 @@ - 0) { - $anchor = strtolower(trim($matches[2])); - $anchor = str_replace('~~', '', $anchor); - $anchor = preg_replace('/[^a-z0-9]+/', '-', $anchor); - $anchor = trim($anchor, '-'); - $headers[] = [ - 'level' => $level, - 'title' => trim($matches[2]), - 'anchor' => $anchor - ]; - } - - // Add "Back to Top" breadcrumb before level 2 headers, except for the first one or if already present - if ($level === 1 && !empty($bodyLines)) { - $lastLine = end($bodyLines); - if ($lastLine !== '' && !str_contains($lastLine, '[↑ Back to Top]')) { - $bodyLines[] = ''; - $bodyLines[] = '[↑ Back to Top](#table-of-contents)'; - } - } - } - $bodyLines[] = $line; - } - } - - // Generate TOC text - $tocText = "## Table of Contents\n"; - foreach ($headers as $header) { - if ($header['title'] === 'Table of Contents') continue; - $indent = str_repeat(' ', $header['level'] - 1); - $tocText .= "{$indent}- [{$header['title']}](#{$header['anchor']})\n"; - } - - // Reconstruct file - $finalLines = []; - $tocInserted = false; - foreach ($bodyLines as $line) { - if (!$tocInserted && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) { - $finalLines[] = $tocText; - $tocInserted = true; - } - $finalLines[] = $line; - } - - file_put_contents($filePath, implode("\n", $finalLines)); - echo "$name TOC and breadcrumbs regenerated successfully.\n"; -} - -$root = __DIR__ . '/../..'; -generateToc($root . '/SPECS.md', 'SPECS.md'); -generateToc($root . '/MILESTONES.md', 'MILESTONES.md'); diff --git a/codeception.yml b/codeception.yml deleted file mode 100644 index eff10fc..0000000 --- a/codeception.yml +++ /dev/null @@ -1,12 +0,0 @@ -namespace: Tests -support_namespace: Support -paths: - tests: tests - output: tests/_output - data: tests/Support/Data - support: tests/Support - envs: tests/_envs -actor_suffix: Tester -extensions: - enabled: - - Codeception\Extension\RunFailed diff --git a/composer.json b/composer.json index a3ed7c5..1ad204f 100644 --- a/composer.json +++ b/composer.json @@ -6,9 +6,9 @@ "php": "^8.2", "crell/api-problem": "^3.7", "filp/whoops": "^2.15", - "getphred/eyrie": "dev-main", - "getphred/flagpole": "dev-main", - "getphred/pairity": "dev-main", + "getphred/eyrie": "dev-master", + "getphred/flagpole": "dev-master", + "getphred/pairity": "dev-master", "laravel/serializable-closure": "^1.3", "lcobucci/jwt": "^5.2", "league/flysystem": "^3.24", diff --git a/composer.json.bak b/composer.json.bak deleted file mode 100644 index 8dc1d19..0000000 --- a/composer.json.bak +++ /dev/null @@ -1,5 +0,0 @@ -{ - "autoload": { - "psr-4": [] - } -} \ No newline at end of file diff --git a/docker/nginx.conf b/docker/nginx.conf deleted file mode 100644 index 74a0fab..0000000 --- a/docker/nginx.conf +++ /dev/null @@ -1,19 +0,0 @@ -server { - listen 80; - server_name localhost; - root /var/www/public; - - index index.php; - - location / { - try_files $uri $uri/ /index.php?$query_string; - } - - location ~ \.php$ { - fastcgi_pass app:9000; - fastcgi_index index.php; - include fastcgi_params; - fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name; - fastcgi_param PATH_INFO $fastcgi_path_info; - } -} diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon deleted file mode 100644 index 686e1b2..0000000 --- a/phpstan-baseline.neon +++ /dev/null @@ -1,76 +0,0 @@ -parameters: - ignoreErrors: - - - message: "#^PHPDoc type for property class@anonymous/Console/Command\\.php\\:34\\:\\:\\$name with type string\\|null is not subtype of native type string\\.$#" - count: 1 - path: src/Console/Command.php - - - - message: "#^Property class@anonymous/Console/Command\\.php\\:34\\:\\:\\$name is never read, only written\\.$#" - count: 1 - path: src/Console/Command.php - - - - message: "#^Call to an undefined method Flagpole\\\\FeatureManager\\:\\:enabled\\(\\)\\.$#" - count: 1 - path: src/Flags/FlagpoleClient.php - - - - message: "#^Class FlagPole\\\\Context referenced with incorrect case\\: Flagpole\\\\Context\\.$#" - count: 1 - path: src/Flags/FlagpoleClient.php - - - - message: "#^Class FlagPole\\\\Repository\\\\InMemoryFlagRepository referenced with incorrect case\\: Flagpole\\\\Repository\\\\InMemoryFlagRepository\\.$#" - count: 1 - path: src/Flags/FlagpoleClient.php - - - - message: "#^Class Flagpole\\\\FeatureManager does not have a constructor and must be instantiated without any parameters\\.$#" - count: 1 - path: src/Flags/FlagpoleClient.php - - - - message: "#^Property Phred\\\\Http\\\\Middleware\\\\ContentNegotiationMiddleware\\:\\:\\$negotiator is never read, only written\\.$#" - count: 1 - path: src/Http/Middleware/ContentNegotiationMiddleware.php - - - - message: "#^Method Phred\\\\Http\\\\Middleware\\\\ProblemDetailsMiddleware\\:\\:deriveStatus\\(\\) is unused\\.$#" - count: 1 - path: src/Http/Middleware/ProblemDetailsMiddleware.php - - - - message: "#^Method Whoops\\\\Handler\\\\PrettyPageHandler\\:\\:handle\\(\\) invoked with 1 parameter, 0 required\\.$#" - count: 1 - path: src/Http/Middleware/ProblemDetailsMiddleware.php - - - - message: "#^Function Sentry\\\\init not found\\.$#" - count: 1 - path: src/Providers/Core/LoggingServiceProvider.php - - - - message: "#^Parameter \\#1 \\$handler of method Monolog\\\\Logger\\:\\:pushHandler\\(\\) expects Monolog\\\\Handler\\\\HandlerInterface, Sentry\\\\Monolog\\\\Handler given\\.$#" - count: 1 - path: src/Providers/Core/LoggingServiceProvider.php - - - - message: "#^Class Phred\\\\Orm\\\\EloquentConnection not found\\.$#" - count: 1 - path: src/Providers/Core/OrmServiceProvider.php - - - - message: "#^Method Phred\\\\Providers\\\\Core\\\\StorageServiceProvider\\:\\:createS3Adapter\\(\\) has invalid return type League\\\\Flysystem\\\\AwsS3V3\\\\AwsS3V3Adapter\\.$#" - count: 1 - path: src/Providers/Core/StorageServiceProvider.php - - - - message: "#^Strict comparison using \\=\\=\\= between string and null will always evaluate to false\\.$#" - count: 1 - path: src/Support/Config.php - - - - message: "#^Deprecated in PHP 8\\.4\\: Parameter \\#1 \\$host \\(string\\) is implicitly nullable via default value null\\.$#" - count: 1 - path: src/Support/Http/CircuitBreakerMiddleware.php diff --git a/phpstan.neon b/phpstan.neon deleted file mode 100644 index 2532410..0000000 --- a/phpstan.neon +++ /dev/null @@ -1,16 +0,0 @@ -includes: - - phpstan-baseline.neon - -parameters: - level: 5 - paths: - - src - - modules - - config - - bootstrap - ignoreErrors: - - identifier: missingType.iterableValue - - identifier: missingType.generics - reportUnmatchedIgnoredErrors: false - excludePaths: - - vendor diff --git a/phpstan.neon.dist b/phpstan.neon.dist deleted file mode 100644 index b6620d2..0000000 --- a/phpstan.neon.dist +++ /dev/null @@ -1,18 +0,0 @@ -parameters: - level: 5 - paths: - - src - ignoreErrors: - - - identifier: missingType.iterableValue - - - identifier: missingType.generics - reportUnmatchedIgnoredErrors: false - checkUninitializedProperties: true - inferPrivatePropertyTypeFromConstructor: true - -services: - - - class: Phred\Support\PhpStan\Rules\InvokableControllerRule - tags: - - phpstan.rules.rule diff --git a/phred b/phred deleted file mode 100755 index 46afd45..0000000 --- a/phred +++ /dev/null @@ -1,3 +0,0 @@ -#!/usr/bin/env php -directory = rtrim($directory, DIRECTORY_SEPARATOR); - if (!is_dir($this->directory)) { - mkdir($this->directory, 0777, true); - } - } - - public function get(string $key, mixed $default = null): mixed - { - $file = $this->getFile($key); - if (!is_file($file)) { - return $default; - } - - $data = unserialize(file_get_contents($file)); - if ($data['expires'] !== null && $data['expires'] < time()) { - $this->delete($key); - return $default; - } - - return $data['value']; - } - - public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool - { - $expires = null; - if ($ttl instanceof \DateInterval) { - $expires = time() + (int) (new \DateTime())->add($ttl)->format('U') - time(); - } elseif (is_int($ttl)) { - $expires = time() + $ttl; - } - - $data = [ - 'value' => $value, - 'expires' => $expires, - ]; - - return file_put_contents($this->getFile($key), serialize($data)) !== false; - } - - public function delete(string $key): bool - { - $file = $this->getFile($key); - if (is_file($file)) { - return unlink($file); - } - return true; - } - - public function clear(): bool - { - foreach (glob($this->directory . DIRECTORY_SEPARATOR . '*') ?: [] as $file) { - unlink($file); - } - return true; - } - - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - $result = []; - foreach ($keys as $key) { - $result[$key] = $this->get($key, $default); - } - return $result; - } - - public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool - { - foreach ($values as $key => $value) { - $this->set($key, $value, $ttl); - } - return true; - } - - public function deleteMultiple(iterable $keys): bool - { - foreach ($keys as $key) { - $this->delete($key); - } - return true; - } - - public function has(string $key): bool - { - return $this->get($key) !== null; - } - - private function getFile(string $key): string - { - return $this->directory . DIRECTORY_SEPARATOR . sha1($key) . '.cache'; - } -} diff --git a/src/Console/Command.php b/src/Console/Command.php deleted file mode 100644 index 4f0c479..0000000 --- a/src/Console/Command.php +++ /dev/null @@ -1,68 +0,0 @@ - */ - protected array $options = []; - - public function getName(): string { return $this->command; } - public function setName(string $name): void { $this->command = $name; } - public function getDescription(): string { return $this->description; } - /** @return array */ - public function getOptions(): array { return $this->options; } - - abstract public function handle(Input $input, Output $output): int; - - public function toSymfony(): SymfonyCommand - { - $self = $this; - return new class($self->getName(), $self) extends SymfonyCommand { - public function __construct(string $name, private Command $wrapped) - { - parent::__construct($name); - } - protected function configure(): void - { - $this->setDescription($this->wrapped->getDescription()); - foreach ($this->wrapped->getOptions() as $key => $def) { - $mode = $def['mode'] ?? 'argument'; - $description = $def['description'] ?? ''; - $default = $def['default'] ?? null; - if ($mode === 'argument') { - $argMode = ($def['required'] ?? false) - ? InputArgument::REQUIRED - : InputArgument::OPTIONAL; - $this->addArgument($key, $argMode, $description, $default); - } elseif ($mode === 'flag') { - $shortcut = $def['shortcut'] ?? null; - $this->addOption(ltrim($key, '-'), $shortcut, InputOption::VALUE_NONE, $description); - } else { // option - $shortcut = $def['shortcut'] ?? null; - $valueReq = $def['valueRequired'] ?? true; - $valueMode = $valueReq ? InputOption::VALUE_REQUIRED : InputOption::VALUE_OPTIONAL; - $this->addOption(ltrim($key, '-'), $shortcut, $valueMode, $description, $default); - } - } - } - protected function execute(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output): int - { - return $this->wrapped->handle($input, $output); - } - }; - } -} diff --git a/src/Flags/Contracts/FeatureFlagClientInterface.php b/src/Flags/Contracts/FeatureFlagClientInterface.php deleted file mode 100644 index 1758ca0..0000000 --- a/src/Flags/Contracts/FeatureFlagClientInterface.php +++ /dev/null @@ -1,9 +0,0 @@ -manager = new \Flagpole\FeatureManager( - new \Flagpole\Repository\InMemoryFlagRepository() - ); - } - - public function isEnabled(string $flagKey, array $context = []): bool - { - return $this->manager->enabled($flagKey, new \Flagpole\Context($context)); - } -} diff --git a/src/Http/ApiResponseFactoryInterface.php b/src/Http/ApiResponseFactoryInterface.php deleted file mode 100644 index 4be8840..0000000 --- a/src/Http/ApiResponseFactoryInterface.php +++ /dev/null @@ -1,30 +0,0 @@ - $data - */ - public function ok(array $data = []): ResponseInterface; - - /** - * 201 Created with array payload. - * @param array $data - * @param string|null $location Optional Location header - */ - public function created(array $data = [], ?string $location = null): ResponseInterface; - - /** - * 204 No Content - */ - public function noContent(): ResponseInterface; - - /** - * Error response with status and details. - * @param int $status HTTP status code (4xx/5xx) - * @param string $title Short, human-readable summary - * @param string|null $detail Detailed description - * @param array $extra Extra members dependent on format - */ - public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface; - - /** - * Create a response from a raw associative array payload. - * @param array $payload - * @param int $status - * @param array $headers - */ - public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface; -} diff --git a/src/Http/Contracts/ErrorFormatNegotiatorInterface.php b/src/Http/Contracts/ErrorFormatNegotiatorInterface.php deleted file mode 100644 index bd5addd..0000000 --- a/src/Http/Contracts/ErrorFormatNegotiatorInterface.php +++ /dev/null @@ -1,20 +0,0 @@ -getAttribute(Negotiation::ATTR_API_FORMAT, 'rest'); - return $this->responses->ok(['format' => $format]); - } -} diff --git a/src/Http/Controllers/HealthController.php b/src/Http/Controllers/HealthController.php deleted file mode 100644 index 18b08af..0000000 --- a/src/Http/Controllers/HealthController.php +++ /dev/null @@ -1,62 +0,0 @@ -getQueryParams()['type'] ?? 'liveness'; - - $data = [ - 'ok' => true, - 'framework' => 'Phred', - 'type' => $type, - 'timestamp' => time(), - ]; - - // Readiness check could include DB connection check, etc. - if ($type === 'readiness') { - // Placeholder for actual checks - $data['checks'] = [ - 'database' => 'connected', - 'storage' => 'writable', - ]; - } - - $etag = $this->generateEtag($data); - if ($this->isFresh($request, $etag)) { - return (new \Nyholm\Psr7\Factory\Psr17Factory())->createResponse(304); - } - - return $this->factory->fromArray($data, 200, ['ETag' => $etag]); - } -} diff --git a/src/Http/Controllers/OpenApiJsonController.php b/src/Http/Controllers/OpenApiJsonController.php deleted file mode 100644 index f75159c..0000000 --- a/src/Http/Controllers/OpenApiJsonController.php +++ /dev/null @@ -1,31 +0,0 @@ -factory->error(404, 'OpenAPI specification not found', 'Run `php bin/phred generate:openapi` first.'); - } - - $content = file_get_contents($path); - return (new Psr17Factory()) - ->createResponse(200) - ->withHeader('Content-Type', 'application/json') - ->withBody((new Psr17Factory())->createStream($content)); - } -} diff --git a/src/Http/Controllers/OpenApiUiController.php b/src/Http/Controllers/OpenApiUiController.php deleted file mode 100644 index 246869d..0000000 --- a/src/Http/Controllers/OpenApiUiController.php +++ /dev/null @@ -1,43 +0,0 @@ -config->get('APP_URL', 'http://localhost:8000') . '/_phred/openapi.json'; - - $html = << - - - Phred API Documentation - - - - - - - - - - -HTML; - - return (new Psr17Factory()) - ->createResponse(200) - ->withHeader('Content-Type', 'text/html') - ->withBody((new Psr17Factory())->createStream($html)); - } -} diff --git a/src/Http/JsonApi/JsonApiResponseFactory.php b/src/Http/JsonApi/JsonApiResponseFactory.php deleted file mode 100644 index 43118ea..0000000 --- a/src/Http/JsonApi/JsonApiResponseFactory.php +++ /dev/null @@ -1,99 +0,0 @@ -toResourceDocument($data, $context); - return $this->jsonApi(200, $document); - } - - public function created(string $location, mixed $data, array $context = []): ResponseInterface - { - $document = $this->toResourceDocument($data, $context); - $response = $this->jsonApi(201, $document); - return $response->withHeader('Location', $location); - } - - public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface - { - $payload = [ - 'errors' => [[ - 'status' => (string) $status, - 'title' => $title, - 'detail' => $detail, - 'meta' => (object) $meta, - ]], - ]; - - return $this->jsonApi($status, $payload); - } - - private function jsonApi(int $status, array $document): ResponseInterface - { - // If neomerx/json-api is installed, you can swap this simple encoding with its encoder. - $json = json_encode($document, JSON_THROW_ON_ERROR); - $stream = Stream::create($json); - return (new Response($status, ['Content-Type' => 'application/vnd.api+json']))->withBody($stream); - } - - /** - * Convert domain data to a very simple JSON:API resource document. - * Context may include: 'type' (required for non-array scalars), 'id', 'includes', 'links', 'meta'. - * This is intentionally minimal until a full encoder is wired. - * - * @param mixed $data - * @param array $context - * @return array - */ - private function toResourceDocument(mixed $data, array $context): array - { - // If neomerx/json-api not present, produce a simple document requiring caller to provide 'type'. - if (!isset($context['type'])) { - // Keep developer feedback explicit to encourage proper setup. - throw new LogicException('JSON:API response requires context["type"]. Consider installing neomerx/json-api for advanced encoding.'); - } - - $resource = [ - 'type' => (string) $context['type'], - ]; - - if (is_array($data) && array_key_exists('id', $data)) { - $resource['id'] = (string) $data['id']; - $attributes = $data; - unset($attributes['id']); - } else { - $attributes = $data; - if (isset($context['id'])) { - $resource['id'] = (string) $context['id']; - } - } - - $resource['attributes'] = $attributes; - - $document = ['data' => $resource]; - - if (!empty($context['links']) && is_array($context['links'])) { - $document['links'] = $context['links']; - } - if (!empty($context['meta']) && is_array($context['meta'])) { - $document['meta'] = $context['meta']; - } - - return $document; - } -} diff --git a/src/Http/JsonApi/SchemaProviderInterface.php b/src/Http/JsonApi/SchemaProviderInterface.php deleted file mode 100644 index 9985b61..0000000 --- a/src/Http/JsonApi/SchemaProviderInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -container = $container ?? $this->buildContainer(); - // Providers may contribute routes during boot; ensure dispatcher is built after container init - $this->dispatcher = $dispatcher ?? $this->buildDispatcher(); - } - - public function container(): Container - { - return $this->container; - } - - public function dispatcher(): Dispatcher - { - return $this->dispatcher; - } - - public function handle(ServerRequest $request): ResponseInterface - { - // SYNC: Initialize RequestContext with the current request so that - // any service resolving it (like DelegatingApiResponseFactory) is correct. - \Phred\Http\RequestContext::set($request); - - $psr17 = $this->container->get(\Nyholm\Psr7\Factory\Psr17Factory::class); - $config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class); - - // CORS - $corsSettings = new \Neomerx\Cors\Strategies\Settings(); - $scheme = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_SCHEME) ?: 'http'; - $host = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_HOST) ?: 'localhost'; - $port = (int)parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_PORT) ?: 80; - $corsSettings->init($scheme, $host, $port); - $corsSettings->setAllowedOrigins($config->get('cors.origin', ['*'])); - $corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])); - $corsSettings->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With'])); - $corsSettings->enableAllOriginsAllowed(); - $corsSettings->enableAllMethodsAllowed(); - $corsSettings->enableAllHeadersAllowed(); - - // Initialize RequestContext with the original request - \Phred\Http\RequestContext::set($request); - - $debug = filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN); - - $middleware = [ - new Middleware\TrustedProxiesMiddleware($config), - new Middleware\CompressionMiddleware(), - ]; - - if ($debug) { - $middleware[] = new class extends Middleware\Middleware { - public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface - { - self::$timings = []; // Reset timings for each request in debug mode - $response = $handler->handle($request); - $timings = self::getTimings(); - if (!empty($timings)) { - $encoded = json_encode($timings, JSON_UNESCAPED_SLASHES); - if ($encoded) { - $response = $response->withHeader('X-Phred-Timings', $encoded); - } - } - return $response; - } - }; - } - - $middleware = array_merge($middleware, [ - // Security headers - new Middleware\Security\SecureHeadersMiddleware($config), - // CORS - new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)), - new class implements \Psr\Http\Server\MiddlewareInterface { - public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface - { - \Phred\Http\RequestContext::set($request); - return $handler->handle($request); - } - }, - new Middleware\ProblemDetailsMiddleware( - $debug, - null, - null, - filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN) - ), - // Ensure RequestContext is initialized before anyone needs it - new class implements \Psr\Http\Server\MiddlewareInterface { - public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface - { - \Phred\Http\RequestContext::set($request); - return $handler->handle($request); - } - }, - new Middleware\UrlExtensionNegotiationMiddleware(), - new Middleware\ContentNegotiationMiddleware($config), - new class implements \Psr\Http\Server\MiddlewareInterface { - public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface - { - \Phred\Http\RequestContext::set($request); - return $handler->handle($request); - } - }, - new \Phred\Http\Middleware\JsonApi\JsonApiQueryMiddleware(), - new \Phred\Http\Middleware\Cache\ResponseCacheMiddleware( - $this->container->get(\Psr\SimpleCache\CacheInterface::class), - (int) $config->get('CACHE_TTL', 3600) - ), - new class implements \Psr\Http\Server\MiddlewareInterface { - public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface - { - // Refresh RequestContext from the actual request object in the pipeline - \Phred\Http\RequestContext::set($request); - return $handler->handle($request); - } - }, - new Middleware\RoutingMiddleware($this->dispatcher, $psr17), - new Middleware\MiddlewareGroupMiddleware($config, $this->container), - new Middleware\DispatchMiddleware($this->container, $psr17), - ]); - - $relay = new Relay($middleware); - return $relay->handle($request); - } - - private function buildContainer(): Container - { - $builder = new ContainerBuilder(); - - // Allow service providers to register definitions before defaults - $configAdapter = new \Phred\Support\DefaultConfig(); - - $providers = new \Phred\Support\ProviderRepository($configAdapter); - $providers->load(); - $providers->registerAll($builder); - - // Add core definitions/bindings - $builder->addDefinitions([ - \Phred\Support\Contracts\ConfigInterface::class => \DI\autowire(\Phred\Support\DefaultConfig::class), - \Phred\Http\Contracts\ErrorFormatNegotiatorInterface::class => \DI\autowire(\Phred\Http\Support\DefaultErrorFormatNegotiator::class), - \Phred\Http\Contracts\RequestIdProviderInterface::class => \DI\autowire(\Phred\Http\Support\DefaultRequestIdProvider::class), - \Phred\Http\Contracts\ExceptionToStatusMapperInterface::class => \DI\autowire(\Phred\Http\Support\DefaultExceptionToStatusMapper::class), - \Phred\Http\Contracts\ApiResponseFactoryInterface::class => \DI\autowire(\Phred\Http\Responses\DelegatingApiResponseFactory::class), - \Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class), - \Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class), - \Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class), - \Nyholm\Psr7\Factory\Psr17Factory::class => \DI\autowire(\Nyholm\Psr7\Factory\Psr17Factory::class), - \Psr\Http\Message\ServerRequestInterface::class => function () { - return \Phred\Http\RequestContext::get(); - }, - ]); - $container = $builder->build(); - - // Boot providers after container is available - // Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests) - \Phred\Http\Routing\RouteRegistry::clear(); - $providers->bootAll($container); - - return $container; - } - - private function buildDispatcher(): Dispatcher - { - $cachePath = dirname(__DIR__, 2) . '/storage/cache/routes.php'; - if (file_exists($cachePath)) { - /** @noinspection PhpIncludeInspection */ - $data = require $cachePath; - $data = $this->unserializeRoutes($data); - return new \FastRoute\Dispatcher\GroupCountBased($data); - } - - return simpleDispatcher($this->getRouteCollector()); - } - - private function unserializeRoutes(array $data): array - { - array_walk_recursive($data, function (&$item) { - if (is_string($item) && str_contains($item, 'SerializableClosure')) { - try { - $unserialized = unserialize($item); - if ($unserialized instanceof \Laravel\SerializableClosure\SerializableClosure) { - $item = $unserialized->getClosure(); - } - } catch (\Throwable) { - // Not a serializable closure or failed to unserialize - } - } - }); - return $data; - } - - /** - * @return callable(RouteCollector):void - */ - public function getRouteCollector(): callable - { - $routesPath = dirname(__DIR__, 2) . '/routes'; - $registry = \Phred\Http\Routing\RouteRegistry::class; - - return function (RouteCollector $r) use ($routesPath, $registry): void { - // Load user-defined routes if present - $router = new Router($r); - - foreach (['web.php', 'api.php'] as $file) { - $path = $routesPath . '/' . $file; - if (is_file($path)) { - $middleware = $file === 'api.php' ? ['api'] : []; - $router->group(['prefix' => '', 'middleware' => $middleware], static function (Router $router) use ($path): void { - /** @noinspection PhpIncludeInspection */ - (static function ($router) use ($path) { require $path; })($router); - }); - } - } - - // Load module route files under prefixes defined in routes/web.php via RouteGroups includes. - // Additionally, as a convenience, auto-mount modules without explicit includes using folder name as prefix. - $modulesDir = dirname(__DIR__, 2) . '/modules'; - if (is_dir($modulesDir)) { - $entries = array_values(array_filter(scandir($modulesDir) ?: [], static fn($e) => $e !== '.' && $e !== '..')); - sort($entries, SORT_STRING); - foreach ($entries as $mod) { - $modRoutes = $modulesDir . '/' . $mod . '/Routes'; - if (!is_dir($modRoutes)) { - continue; - } - // Auto-mount only if the module's web.php wasn't already included via RouteGroups in root file. - $autoInclude = function (string $relative, string $prefix, array $middleware = []) use ($modRoutes, $router, $registry): void { - $file = $modRoutes . '/' . $relative; - if (is_file($file) && !$registry::isLoaded($file)) { - $router->group(['prefix' => '/' . strtolower($prefix), 'middleware' => $middleware], static function (Router $r) use ($file): void { - /** @noinspection PhpIncludeInspection */ - (static function ($router) use ($file) { require $file; })($r); - }); - } - }; - $autoInclude('web.php', $mod); - // api.php auto-mounted under /api/ with 'api' middleware group - $autoInclude('api.php', 'api/' . $mod, ['api']); - } - } - - // Allow providers to contribute routes - $registry::apply($r, $router); - - // Ensure default demo routes exist for acceptance/demo - $r->addRoute('GET', '/_phred/health', Controllers\HealthController::class); - $r->addRoute('GET', '/_phred/format', Controllers\FormatController::class); - }; - } -} diff --git a/src/Http/Middleware/Cache/ResponseCacheMiddleware.php b/src/Http/Middleware/Cache/ResponseCacheMiddleware.php deleted file mode 100644 index 3be5f4f..0000000 --- a/src/Http/Middleware/Cache/ResponseCacheMiddleware.php +++ /dev/null @@ -1,82 +0,0 @@ -getMethod() !== 'GET') { - return $handler->handle($request); - } - - $cacheKey = $this->generateCacheKey($request); - $cached = $this->cache->get($cacheKey); - - if ($cached !== null) { - $response = $this->unserializeResponse($cached); - - // Handle ETag / If-None-Match - $etag = $response->getHeaderLine('ETag'); - if ($etag && ($request->getHeaderLine('If-None-Match') === $etag)) { - return (new \Nyholm\Psr7\Factory\Psr17Factory())->createResponse(304); - } - - return $response; - } - - $response = $handler->handle($request); - - // Only cache successful responses - if ($response->getStatusCode() === 200) { - $this->cache->set($cacheKey, $this->serializeResponse($response), $this->ttl); - } - - return $response; - } - - private function generateCacheKey(Request $request): string - { - $accept = $request->getHeaderLine('Accept'); - $format = $request->getAttribute('phred.api_format', ''); - return 'res_cache_' . sha1($request->getUri()->getPath() . '|' . $request->getUri()->getQuery() . '|' . $accept . '|' . $format); - } - - private function serializeResponse(ResponseInterface $response): string - { - return serialize([ - 'status' => $response->getStatusCode(), - 'headers' => $response->getHeaders(), - 'body' => (string) $response->getBody(), - ]); - } - - private function unserializeResponse(string $serialized): ResponseInterface - { - $data = unserialize($serialized); - $factory = new \Nyholm\Psr7\Factory\Psr17Factory(); - $response = $factory->createResponse($data['status']); - - foreach ($data['headers'] as $name => $values) { - foreach ($values as $value) { - $response = $response->withAddedHeader($name, $value); - } - } - - $response->getBody()->write($data['body']); - return $response; - } -} diff --git a/src/Http/Middleware/CompressionMiddleware.php b/src/Http/Middleware/CompressionMiddleware.php deleted file mode 100644 index 59c0ccd..0000000 --- a/src/Http/Middleware/CompressionMiddleware.php +++ /dev/null @@ -1,72 +0,0 @@ -handle($request); - - $enabled = filter_var((string) Config::get('COMPRESSION_ENABLED', 'false'), FILTER_VALIDATE_BOOLEAN); - if (!$enabled) { - return $response; - } - - // Avoid re-compressing - if ($response->hasHeader('Content-Encoding')) { - return $response; - } - - $acceptEncoding = $request->getHeaderLine('Accept-Encoding'); - - // Brotli support (if extension available) - if (str_contains($acceptEncoding, 'br') && function_exists('brotli_compress')) { - $level = (int) Config::get('COMPRESSION_LEVEL_BROTLI', 4); - $compressed = brotli_compress((string) $response->getBody(), $level); - if ($compressed !== false) { - return $this->withCompressedBody($response, $compressed, 'br'); - } - } - - // Gzip support - if (str_contains($acceptEncoding, 'gzip') && function_exists('gzencode')) { - $level = (int) Config::get('COMPRESSION_LEVEL_GZIP', -1); - $compressed = gzencode((string) $response->getBody(), $level); - if ($compressed !== false) { - return $this->withCompressedBody($response, $compressed, 'gzip'); - } - } - - // Deflate support - if (str_contains($acceptEncoding, 'deflate') && function_exists('gzdeflate')) { - $level = (int) Config::get('COMPRESSION_LEVEL_DEFLATE', -1); - $compressed = gzdeflate((string) $response->getBody(), $level); - if ($compressed !== false) { - return $this->withCompressedBody($response, $compressed, 'deflate'); - } - } - - return $response; - } - - private function withCompressedBody(ResponseInterface $response, string $compressed, string $encoding): ResponseInterface - { - $stream = (new \Nyholm\Psr7\Factory\Psr17Factory())->createStream($compressed); - - return $response - ->withHeader('Content-Encoding', $encoding) - ->withHeader('Content-Length', (string) strlen($compressed)) - ->withBody($stream); - } -} diff --git a/src/Http/Middleware/ContentNegotiationMiddleware.php b/src/Http/Middleware/ContentNegotiationMiddleware.php deleted file mode 100644 index 1a8cac4..0000000 --- a/src/Http/Middleware/ContentNegotiationMiddleware.php +++ /dev/null @@ -1,60 +0,0 @@ -config ?? new DefaultConfig(); - $defaultFormat = strtolower((string) $cfg->get('API_FORMAT', 'rest')); - - $format = $this->profileSelf(function () use ($request, $defaultFormat) { - // First check if a format hint was already set by UrlExtensionNegotiationMiddleware - $hint = $request->getAttribute('phred.format_hint'); - if ($hint && $hint !== 'html') { - return $hint; - } - - // Allow Accept header to override - $accept = $request->getHeaderLine('Accept'); - if (str_contains($accept, 'application/vnd.api+json')) { - return 'jsonapi'; - } - if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) { - return 'xml'; - } - if (str_contains($accept, 'application/json') || str_contains($accept, 'application/problem+json')) { - return 'rest'; - } - - return $defaultFormat; - }); - - // Ensure RequestContext is updated so DelegatingApiResponseFactory sees the change - $request = $request->withAttribute(self::ATTR_API_FORMAT, $format); - - // SYNC: Update RequestContext so that any service resolving it (like DelegatingApiResponseFactory) - // gets the request with the phred.api_format attribute. - \Phred\Http\RequestContext::set($request); - - return $handler->handle($request); - } -} diff --git a/src/Http/Middleware/DispatchMiddleware.php b/src/Http/Middleware/DispatchMiddleware.php deleted file mode 100644 index b03054e..0000000 --- a/src/Http/Middleware/DispatchMiddleware.php +++ /dev/null @@ -1,94 +0,0 @@ -getAttribute('phred.route.handler'); - $vars = (array) $request->getAttribute('phred.route.vars', []); - - if (!$handlerSpec) { - return $this->jsonError('No route handler', 500); - } - - $format = (string) ($request->getAttribute('phred.api_format', 'rest')); - $callable = $this->resolveCallable($handlerSpec, $this->container); - - RequestContext::set($request); - try { - $response = $this->invokeCallable($callable, $request, $vars); - } finally { - RequestContext::clear(); - } - - if (!$response instanceof ResponseInterface) { - return $this->normalizeToResponse($response); - } - - return $response; - } - - /** - * @param mixed $payload - */ - private function normalizeToResponse(mixed $payload): ResponseInterface - { - if (is_array($payload)) { - $json = json_encode($payload, JSON_UNESCAPED_SLASHES); - $res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'application/json'); - $res->getBody()->write((string) $json); - return $res; - } - - $res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'text/plain; charset=utf-8'); - $res->getBody()->write((string) $payload); - return $res; - } - - private function jsonError(string $message, int $status): ResponseInterface - { - $res = $this->psr17->createResponse($status)->withHeader('Content-Type', 'application/json'); - $res->getBody()->write(json_encode(['error' => $message], JSON_UNESCAPED_SLASHES)); - return $res; - } - - private function resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable - { - if (is_string($handlerSpec) && class_exists($handlerSpec)) { - $controller = $requestContainer->get($handlerSpec); - return [$controller, '__invoke']; - } - - if (is_callable($handlerSpec)) { - return $handlerSpec; - } - - throw new \RuntimeException('Invalid route handler. Phred requires invokable controllers (string) or a valid callable.'); - } - - private function invokeCallable(callable $callable, ServerRequest $request, array $vars): mixed - { - return $callable($request, ...array_values($vars)); - } -} diff --git a/src/Http/Middleware/JsonApi/JsonApiQueryMiddleware.php b/src/Http/Middleware/JsonApi/JsonApiQueryMiddleware.php deleted file mode 100644 index dce1cd1..0000000 --- a/src/Http/Middleware/JsonApi/JsonApiQueryMiddleware.php +++ /dev/null @@ -1,44 +0,0 @@ -getQueryParams(); - - if (isset($params['include'])) { - $request = $request->withAttribute(self::ATTR_INCLUDE, explode(',', (string) $params['include'])); - } - - if (isset($params['fields']) && is_array($params['fields'])) { - $fields = []; - foreach ($params['fields'] as $type => $value) { - $fields[$type] = explode(',', (string) $value); - } - $request = $request->withAttribute(self::ATTR_FIELDS, $fields); - } - - if (isset($params['sort'])) { - $request = $request->withAttribute(self::ATTR_SORT, explode(',', (string) $params['sort'])); - } - - if (isset($params['filter'])) { - $request = $request->withAttribute(self::ATTR_FILTER, (array) $params['filter']); - } - - return $handler->handle($request); - } -} diff --git a/src/Http/Middleware/Middleware.php b/src/Http/Middleware/Middleware.php deleted file mode 100644 index dfed0c2..0000000 --- a/src/Http/Middleware/Middleware.php +++ /dev/null @@ -1,69 +0,0 @@ - */ - protected static array $timings = []; - - abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface; - - /** - * Wrap a handler and measure its execution time. - */ - protected function profile(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface - { - $start = microtime(true); - try { - return $handler->handle($request); - } finally { - $duration = microtime(true) - $start; - self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration; - } - } - - /** - * Simple profiler for middleware that don't need to wrap the handler. - */ - protected function profileSelf(callable $callback): mixed - { - $start = microtime(true); - try { - return $callback(); - } finally { - $duration = microtime(true) - $start; - self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration; - } - } - - /** - * Get all recorded timings. - * @return array - */ - public static function getTimings(): array - { - return self::$timings; - } - - /** - * Record a timing manually. - */ - public static function recordTiming(string $key, float $duration): void - { - self::$timings[$key] = (self::$timings[$key] ?? 0) + $duration; - } - - protected function json(array $data, int $status = 200): ResponseInterface - { - $response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']); - $response->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES)); - return $response; - } -} diff --git a/src/Http/Middleware/MiddlewareGroupMiddleware.php b/src/Http/Middleware/MiddlewareGroupMiddleware.php deleted file mode 100644 index e1337c3..0000000 --- a/src/Http/Middleware/MiddlewareGroupMiddleware.php +++ /dev/null @@ -1,69 +0,0 @@ -getAttribute('phred.route.middleware', []); - if (empty($groups)) { - return $handler->handle($request); - } - - $allMiddleware = []; - $configGroups = $this->config->get('middleware.groups', []); - $configAliases = $this->config->get('middleware.aliases', []); - - foreach ($groups as $group) { - if (isset($configGroups[$group])) { - foreach ($configGroups[$group] as $m) { - $allMiddleware[] = $this->resolve($m); - } - } elseif (isset($configAliases[$group])) { - $allMiddleware[] = $this->resolve($configAliases[$group]); - } else { - // Assume it's a FQCN - $allMiddleware[] = $this->resolve($group); - } - } - - if (empty($allMiddleware)) { - return $handler->handle($request); - } - - // We wrap the remaining handler as a middleware to continue the outer pipeline - $allMiddleware[] = new class($handler) implements MiddlewareInterface { - public function __construct(private Handler $handler) {} - public function process(Request $request, Handler $handler): ResponseInterface - { - return $this->handler->handle($request); - } - }; - - $relay = new Relay($allMiddleware); - return $relay->handle($request); - } - - private function resolve(mixed $middleware): MiddlewareInterface - { - if (is_string($middleware)) { - return $this->container->get($middleware); - } - return $middleware; - } -} diff --git a/src/Http/Middleware/ProblemDetailsMiddleware.php b/src/Http/Middleware/ProblemDetailsMiddleware.php deleted file mode 100644 index 5e0afad..0000000 --- a/src/Http/Middleware/ProblemDetailsMiddleware.php +++ /dev/null @@ -1,198 +0,0 @@ -profile($request, $handler); - } catch (Throwable $e) { - $useProblem = $this->shouldUseProblemDetails(); - $format = $this->determineApiFormat($request); - $requestId = $this->provideRequestId($request); - - if ($this->shouldRenderHtml($request)) { - return $this->renderWhoopsHtml($e, $requestId); - } - - $detail = $this->computeDetail($e); - $status = $this->mapStatus($e); - - if ($useProblem && $format !== 'jsonapi') { - return $this->respondProblemDetails($e, $status, $detail, $requestId); - } - - return $this->respondJsonApiOrJson($e, $status, $detail, $format, $requestId); - } - } - - private function deriveStatus(Throwable $e): int - { - // Kept for backward compatibility in case of external references; delegate to default mapper. - return (new DefaultExceptionToStatusMapper())->map($e); - } - - private function shortClass(object $o): string - { - $fqcn = get_class($o); - $pos = strrpos($fqcn, chr(92)); // '\\' as ASCII 92 - if ($pos !== false) { - return substr($fqcn, $pos + 1); - } - return $fqcn; - } - - private function shouldUseProblemDetails(): bool - { - $cfg = $this->config ?? new DefaultConfig(); - $raw = $this->useProblemDetails ?? $cfg->get('API_PROBLEM_DETAILS', 'true'); - return filter_var((string) $raw, FILTER_VALIDATE_BOOLEAN); - } - - private function determineApiFormat(ServerRequestInterface $request): string - { - $neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); - return $neg->apiFormat($request); - } - - private function provideRequestId(ServerRequestInterface $request): string - { - $provider = $this->requestIdProvider ?? new DefaultRequestIdProvider(); - return $provider->provide($request); - } - - private function shouldRenderHtml(ServerRequestInterface $request): bool - { - if (!$this->debug) { - return false; - } - $neg = $this->negotiator ?? new DefaultErrorFormatNegotiator(); - return $neg->wantsHtml($request); - } - - private function computeDetail(Throwable $e): string - { - if ($this->debug) { - return $e->getMessage() . "\n\n" . $e->getTraceAsString(); - } - return $e->getMessage(); - } - - private function mapStatus(Throwable $e): int - { - $mapper = $this->statusMapper ?? new DefaultExceptionToStatusMapper(); - return $mapper->map($e); - } - - private function renderWhoopsHtml(Throwable $e, string $requestId): ResponseInterface - { - if (class_exists(\Whoops\Run::class)) { - $handler = new \Whoops\Handler\PrettyPageHandler(); - $whoops = new \Whoops\Run(); - $whoops->allowQuit(false); - $whoops->writeToOutput(false); - $whoops->pushHandler($handler); - $html = (string) $whoops->handleException($e); - if ($html === '') { - ob_start(); - $handler->handle($e); - $html = (string) ob_get_clean(); - } - if ($html === '') { - $html = 'Whoops

Whoops

'
-                    . htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
-                    . '
'; - } - $stream = Stream::create($html); - return (new Response(500, [ - 'Content-Type' => 'text/html; charset=UTF-8', - 'X-Request-Id' => $requestId, - ]))->withBody($stream); - } - - return $this->respondPlainTextFallback($e->getMessage(), $requestId); - } - - private function respondPlainTextFallback(string $message, string $requestId): ResponseInterface - { - $stream = Stream::create($message); - return (new Response(500, [ - 'Content-Type' => 'text/plain; charset=UTF-8', - 'X-Request-Id' => $requestId, - ]))->withBody($stream); - } - - private function respondProblemDetails(Throwable $e, int $status, string $detail, string $requestId): ResponseInterface - { - $problem = new ApiProblem($this->shortClass($e) ?: 'Error'); - $problem->setType('about:blank'); - $problem->setTitle($this->shortClass($e)); - $problem->setStatus($status); - $problem->setDetail($detail ?: 'An error occurred'); - if ($this->debug) { - $problem['exception'] = [ - 'class' => get_class($e), - 'code' => $e->getCode(), - 'file' => $e->getFile(), - 'line' => $e->getLine(), - ]; - } - - $json = json_encode($problem, JSON_THROW_ON_ERROR); - $stream = Stream::create($json); - return (new Response($status, [ - 'Content-Type' => 'application/problem+json', - 'X-Request-Id' => $requestId, - ]))->withBody($stream); - } - - private function respondJsonApiOrJson(Throwable $e, int $status, string $detail, string $format, string $requestId): ResponseInterface - { - $payload = [ - 'errors' => [[ - 'status' => (string) $status, - 'title' => $this->shortClass($e), - 'detail' => $detail, - ]], - ]; - - $json = json_encode($payload, JSON_THROW_ON_ERROR); - $stream = Stream::create($json); - $contentType = $format === 'jsonapi' ? 'application/vnd.api+json' : 'application/json'; - return (new Response($status, [ - 'Content-Type' => $contentType, - 'X-Request-Id' => $requestId, - ]))->withBody($stream); - } -} diff --git a/src/Http/Middleware/RoutingMiddleware.php b/src/Http/Middleware/RoutingMiddleware.php deleted file mode 100644 index b6a5c81..0000000 --- a/src/Http/Middleware/RoutingMiddleware.php +++ /dev/null @@ -1,50 +0,0 @@ -dispatcher->dispatch($request->getMethod(), $request->getUri()->getPath()); - switch ($routeInfo[0]) { - case Dispatcher::NOT_FOUND: - $response = $this->psr17->createResponse(404); - $response->getBody()->write(json_encode(['error' => 'Not Found'], JSON_UNESCAPED_SLASHES)); - return $response->withHeader('Content-Type', 'application/json'); - case Dispatcher::METHOD_NOT_ALLOWED: - $response = $this->psr17->createResponse(405); - $response->getBody()->write(json_encode(['error' => 'Method Not Allowed'], JSON_UNESCAPED_SLASHES)); - return $response->withHeader('Content-Type', 'application/json'); - case Dispatcher::FOUND: - [$status, $spec, $vars] = $routeInfo; - $handlerSpec = is_array($spec) && isset($spec['handler']) ? $spec['handler'] : $spec; - $middleware = is_array($spec) && isset($spec['middleware']) ? (array)$spec['middleware'] : []; - $name = is_array($spec) && isset($spec['name']) ? (string)$spec['name'] : null; - - $request = $request - ->withAttribute('phred.route.handler', $handlerSpec) - ->withAttribute('phred.route.vars', $vars) - ->withAttribute('phred.route.middleware', $middleware) - ->withAttribute('phred.route.name', $name); - return $handler->handle($request); - default: - $response = $this->psr17->createResponse(500); - $response->getBody()->write(json_encode(['error' => 'Routing failure'], JSON_UNESCAPED_SLASHES)); - return $response->withHeader('Content-Type', 'application/json'); - } - } -} diff --git a/src/Http/Middleware/Security/CsrfMiddleware.php b/src/Http/Middleware/Security/CsrfMiddleware.php deleted file mode 100644 index 7a0bc95..0000000 --- a/src/Http/Middleware/Security/CsrfMiddleware.php +++ /dev/null @@ -1,40 +0,0 @@ -getMethod(); - if (in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true)) { - return $handler->handle($request); - } - - $session = $request->getAttribute('session'); - $token = null; - - if ($session && method_exists($session, 'get')) { - $token = $session->get('_csrf_token'); - } - - $provided = $request->getParsedBody()['_csrf'] ?? $request->getHeaderLine('X-CSRF-TOKEN'); - - if (!$token || $token !== $provided) { - // In a real app, we might throw a specific exception that maps to 419 or 403 - throw new \RuntimeException('CSRF token mismatch', 403); - } - - return $handler->handle($request); - } -} diff --git a/src/Http/Middleware/Security/SecureHeadersMiddleware.php b/src/Http/Middleware/Security/SecureHeadersMiddleware.php deleted file mode 100644 index 0536dd7..0000000 --- a/src/Http/Middleware/Security/SecureHeadersMiddleware.php +++ /dev/null @@ -1,38 +0,0 @@ -profile($request, $handler); - - // Standard security headers - $response = $response->withHeader('X-Content-Type-Options', 'nosniff') - ->withHeader('X-Frame-Options', 'SAMEORIGIN') - ->withHeader('X-XSS-Protection', '1; mode=block') - ->withHeader('Referrer-Policy', 'no-referrer-when-downgrade') - ->withHeader('Content-Security-Policy', $this->config->get('security.csp', "default-src 'self'")); - - if ($this->config->get('security.hsts', true)) { - $response = $response->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); - } - - return $response; - } -} diff --git a/src/Http/Middleware/TrustedProxiesMiddleware.php b/src/Http/Middleware/TrustedProxiesMiddleware.php deleted file mode 100644 index c52e52e..0000000 --- a/src/Http/Middleware/TrustedProxiesMiddleware.php +++ /dev/null @@ -1,45 +0,0 @@ -config->get('security.trusted_proxies', []); - - if (empty($trustedProxies)) { - return $handler->handle($request); - } - - $remoteAddr = $_SERVER['REMOTE_ADDR'] ?? ''; - - if ($this->isTrusted($remoteAddr, $trustedProxies)) { - $forwardedFor = $request->getHeaderLine('X-Forwarded-For'); - if ($forwardedFor) { - // In a real implementation, we'd update the Request's client address. - // PSR-7 requests don't have a standard 'clientAddress' attribute, - // but many frameworks use 'ip' or similar. - } - } - - return $handler->handle($request); - } - - private function isTrusted(string $ip, array $trusted): bool - { - if (in_array('*', $trusted, true)) { - return true; - } - return in_array($ip, $trusted, true); - } -} diff --git a/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php b/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php deleted file mode 100644 index bcd92a7..0000000 --- a/src/Http/Middleware/UrlExtensionNegotiationMiddleware.php +++ /dev/null @@ -1,104 +0,0 @@ - json - * xml -> xml (not implemented yet; reserved for M12) - * php/none -> html - * - Optionally, sets Accept header mapping for downstream negotiation: - * json -> application/json - * xml -> application/xml (reserved) - * html -> text/html - */ -final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface -{ - public const ATTR_FORMAT_HINT = 'phred.format_hint'; - - public function process(Request $request, Handler $handler): ResponseInterface - { - $enabled = filter_var((string) Config::get('URL_EXTENSION_NEGOTIATION', 'true'), FILTER_VALIDATE_BOOLEAN); - if (!$enabled) { - return $handler->handle($request); - } - - $whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|xml|php|none'); - $allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw)))); - $allowed = $allowed ?: ['json', 'xml', 'php', 'none']; - - $uri = $request->getUri(); - $path = $uri->getPath(); - - $ext = null; - if (preg_match('/\.([a-z0-9]+)$/i', $path, $m)) { - $candidate = strtolower($m[1]); - if (in_array($candidate, $allowed, true)) { - $ext = $candidate; - // strip the extension from the path for routing purposes - $path = substr($path, 0, - (strlen($candidate) + 1)); - } - } else { - // no extension → treat as 'none' if allowed - if (in_array('none', $allowed, true)) { - $ext = 'none'; - } - } - - if ($ext !== null) { - $hint = $this->mapToHint($ext); - if ($hint !== null) { - $request = $request->withAttribute(self::ATTR_FORMAT_HINT, $hint); - // Only set Accept for explicit JSON (and future XML), and only if client didn't set one. - $accept = $this->mapToAccept($hint); - if ($accept !== null) { - $current = trim($request->getHeaderLine('Accept')); - if ($current === '') { - $request = $request->withHeader('Accept', $accept); - } - } - } - } - - // If we modified the path, update the URI so router matches sans extension - if ($path !== $uri->getPath()) { - $newUri = $uri->withPath($path === '' ? '/' : $path); - $request = $request->withUri($newUri); - } - - return $handler->handle($request); - } - - private function mapToHint(string $ext): ?string - { - return match ($ext) { - 'json' => 'rest', - 'xml' => 'xml', - 'php', 'none' => 'html', - default => null, - }; - } - - private function mapToAccept(string $hint): ?string - { - return match ($hint) { - 'json' => 'application/json', - 'xml' => 'application/xml', // reserved for M12 - default => null, // do not force Accept for html/none - }; - } -} diff --git a/src/Http/Middleware/ValidationMiddleware.php b/src/Http/Middleware/ValidationMiddleware.php deleted file mode 100644 index a1a7146..0000000 --- a/src/Http/Middleware/ValidationMiddleware.php +++ /dev/null @@ -1,29 +0,0 @@ -validate($request); - - if (!empty($errors)) { - return $this->json([ - 'errors' => $errors - ], 422); - } - - return $handler->handle($request); - } - - /** - * @return array List of validation errors - */ - abstract protected function validate(ServerRequestInterface $request): array; -} diff --git a/src/Http/RequestContext.php b/src/Http/RequestContext.php deleted file mode 100644 index 2e5cfe2..0000000 --- a/src/Http/RequestContext.php +++ /dev/null @@ -1,31 +0,0 @@ -delegate()->ok($data); - } - - public function created(array $data = [], ?string $location = null): ResponseInterface - { - return $this->delegate()->created($data, $location); - } - - public function noContent(): ResponseInterface - { - return $this->delegate()->noContent(); - } - - public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface - { - return $this->delegate()->error($status, $title, $detail, $extra); - } - - public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface - { - return $this->delegate()->fromArray($payload, $status, $headers); - } - - private function delegate(): ApiResponseFactoryInterface - { - $req = RequestContext::get(); - $format = $req?->getAttribute('phred.api_format') ?? 'rest'; - return match ($format) { - 'jsonapi' => $this->jsonapi, - 'xml' => $this->xml, - default => $this->rest, - }; - } -} diff --git a/src/Http/Responses/JsonApiResponseFactory.php b/src/Http/Responses/JsonApiResponseFactory.php deleted file mode 100644 index 577315b..0000000 --- a/src/Http/Responses/JsonApiResponseFactory.php +++ /dev/null @@ -1,67 +0,0 @@ -document(['data' => $data], 200); - } - - public function created(array $data = [], ?string $location = null): ResponseInterface - { - $res = $this->document(['data' => $data], 201); - if ($location) { - $res = $res->withHeader('Location', $location); - } - return $res; - } - - public function noContent(): ResponseInterface - { - // JSON:API allows 204 without body - return $this->psr17->createResponse(204); - } - - public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface - { - $error = array_filter([ - 'status' => (string) $status, - 'title' => $title, - 'detail' => $detail, - ], static fn($v) => $v !== null && $v !== ''); - if (!empty($extra)) { - $error = array_merge($error, $extra); - } - return $this->document(['errors' => [$error]], $status); - } - - public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface - { - // Caller must ensure payload is a valid JSON:API document shape - $res = $this->document($payload, $status); - foreach ($headers as $name => $value) { - $res = $res->withHeader($name, $value); - } - return $res; - } - - /** - * @param array $doc - */ - private function document(array $doc, int $status): ResponseInterface - { - $res = $this->psr17->createResponse($status) - ->withHeader('Content-Type', 'application/vnd.api+json'); - $res->getBody()->write(json_encode($doc, JSON_UNESCAPED_SLASHES)); - return $res; - } -} diff --git a/src/Http/Responses/RestResponseFactory.php b/src/Http/Responses/RestResponseFactory.php deleted file mode 100644 index 5c9b1e5..0000000 --- a/src/Http/Responses/RestResponseFactory.php +++ /dev/null @@ -1,63 +0,0 @@ -json($data, 200); - } - - public function created(array $data = [], ?string $location = null): ResponseInterface - { - $res = $this->json($data, 201); - if ($location) { - $res = $res->withHeader('Location', $location); - } - return $res; - } - - public function noContent(): ResponseInterface - { - return $this->psr17->createResponse(204); - } - - public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface - { - $payload = array_merge([ - 'type' => $extra['type'] ?? 'about:blank', - 'title' => $title, - 'status' => $status, - ], $detail !== null ? ['detail' => $detail] : [], $extra); - - $res = $this->psr17->createResponse($status) - ->withHeader('Content-Type', 'application/problem+json'); - $res->getBody()->write(json_encode($payload, JSON_UNESCAPED_SLASHES)); - return $res; - } - - public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface - { - $res = $this->json($payload, $status); - foreach ($headers as $name => $value) { - $res = $res->withHeader($name, $value); - } - return $res; - } - - private function json(array $data, int $status): ResponseInterface - { - $res = $this->psr17->createResponse($status) - ->withHeader('Content-Type', 'application/json'); - $res->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES)); - return $res; - } -} diff --git a/src/Http/Responses/XmlResponseFactory.php b/src/Http/Responses/XmlResponseFactory.php deleted file mode 100644 index 2135d62..0000000 --- a/src/Http/Responses/XmlResponseFactory.php +++ /dev/null @@ -1,68 +0,0 @@ -serializer = new Serializer([new ArrayDenormalizer()], [new XmlEncoder()]); - } - - public function ok(array $data = []): ResponseInterface - { - return $this->xml($data, 200); - } - - public function created(array $data = [], ?string $location = null): ResponseInterface - { - $res = $this->xml($data, 201); - if ($location) { - $res = $res->withHeader('Location', $location); - } - return $res; - } - - public function noContent(): ResponseInterface - { - return $this->psr17->createResponse(204); - } - - public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface - { - $payload = array_merge([ - 'title' => $title, - 'status' => $status, - ], $detail !== null ? ['detail' => $detail] : [], $extra); - - return $this->xml(['error' => $payload], $status); - } - - public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface - { - $res = $this->xml($payload, $status); - foreach ($headers as $name => $value) { - $res = $res->withHeader($name, $value); - } - return $res; - } - - private function xml(array $data, int $status): ResponseInterface - { - $xml = $this->serializer->serialize($data, 'xml'); - $res = $this->psr17->createResponse($status) - ->withHeader('Content-Type', 'application/xml'); - $res->getBody()->write($xml); - return $res; - } -} diff --git a/src/Http/Rest/RestResponseFactory.php b/src/Http/Rest/RestResponseFactory.php deleted file mode 100644 index 2fe8c28..0000000 --- a/src/Http/Rest/RestResponseFactory.php +++ /dev/null @@ -1,47 +0,0 @@ -json(200, $data, $context); - } - - public function created(string $location, mixed $data, array $context = []): ResponseInterface - { - $response = $this->json(201, $data, $context); - return $response->withHeader('Location', $location); - } - - public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface - { - $payload = [ - 'title' => $title, - 'detail' => $detail, - 'meta' => (object) $meta, - ]; - - return $this->json($status, $payload); - } - - private function json(int $status, mixed $data, array $context = []): ResponseInterface - { - $json = $this->serializer->serialize($data, 'json', $context); - $stream = Stream::create($json); - return (new Response($status, ['Content-Type' => 'application/json']))->withBody($stream); - } -} diff --git a/src/Http/Router.php b/src/Http/Router.php deleted file mode 100644 index a042c8f..0000000 --- a/src/Http/Router.php +++ /dev/null @@ -1,83 +0,0 @@ -groupMiddleware = $groupMiddleware; - } - - public function get(string $path, string|callable $handler, array $options = []): void - { - $this->addRoute('GET', $path, $handler, $options); - } - - public function post(string $path, string|callable $handler, array $options = []): void - { - $this->addRoute('POST', $path, $handler, $options); - } - - public function put(string $path, string|callable $handler, array $options = []): void - { - $this->addRoute('PUT', $path, $handler, $options); - } - - public function patch(string $path, string|callable $handler, array $options = []): void - { - $this->addRoute('PATCH', $path, $handler, $options); - } - - public function delete(string $path, string|callable $handler, array $options = []): void - { - $this->addRoute('DELETE', $path, $handler, $options); - } - - private function addRoute(string $method, string $path, string|callable $handler, array $options): void - { - $middleware = $options['middleware'] ?? []; - if (is_string($middleware)) { - $middleware = [$middleware]; - } - $middleware = array_merge($this->groupMiddleware, $middleware); - - $spec = [ - 'handler' => $handler, - 'middleware' => $middleware, - 'name' => $options['name'] ?? null, - ]; - - $this->collector->addRoute($method, $path, $spec); - } - - /** - * Group routes under a common path prefix and/or middleware. - * - * Example: - * $router->group('/api', function (Router $r) { ... }); - * $router->group(['prefix' => '/api', 'middleware' => 'api'], function (Router $r) { ... }); - */ - public function group(string|array $attributes, callable $routes): void - { - $prefix = is_array($attributes) ? ($attributes['prefix'] ?? '') : $attributes; - $middleware = is_array($attributes) ? ($attributes['middleware'] ?? []) : []; - if (is_string($middleware)) { - $middleware = [$middleware]; - } - - $newMiddleware = array_merge($this->groupMiddleware, $middleware); - - $this->collector->addGroup($prefix, function (RouteCollector $rc) use ($routes, $newMiddleware): void { - $routes(new Router($rc, $newMiddleware)); - }); - } -} diff --git a/src/Http/Routing/RouteGroups.php b/src/Http/Routing/RouteGroups.php deleted file mode 100644 index a367c05..0000000 --- a/src/Http/Routing/RouteGroups.php +++ /dev/null @@ -1,22 +0,0 @@ -group($prefix, static function (Router $r) use ($loader): void { - $loader($r); - }); - } -} diff --git a/src/Http/Routing/RouteRegistry.php b/src/Http/Routing/RouteRegistry.php deleted file mode 100644 index 3e5d854..0000000 --- a/src/Http/Routing/RouteRegistry.php +++ /dev/null @@ -1,48 +0,0 @@ - */ - private static array $callbacks = []; - - /** @var array */ - private static array $loadedFiles = []; - - public static function markAsLoaded(string $filePath): void - { - self::$loadedFiles[realpath($filePath) ?: $filePath] = true; - } - - public static function isLoaded(string $filePath): bool - { - return isset(self::$loadedFiles[realpath($filePath) ?: $filePath]); - } - - public static function add(callable $registrar): void - { - self::$callbacks[] = $registrar; - } - - public static function clear(): void - { - self::$callbacks = []; - self::$loadedFiles = []; - } - - public static function apply(RouteCollector $collector, Router $router): void - { - foreach (self::$callbacks as $cb) { - $cb($collector, $router); - } - } -} diff --git a/src/Http/Support/ConditionalRequestTrait.php b/src/Http/Support/ConditionalRequestTrait.php deleted file mode 100644 index 9e0c310..0000000 --- a/src/Http/Support/ConditionalRequestTrait.php +++ /dev/null @@ -1,27 +0,0 @@ -getHeaderLine('If-None-Match'); - return $ifNoneMatch === $etag || $ifNoneMatch === '"' . $etag . '"'; - } - - /** - * Generate an ETag for the given data. - */ - protected function generateEtag(mixed $data): string - { - return '"' . md5(serialize($data)) . '"'; - } -} diff --git a/src/Http/Support/DefaultErrorFormatNegotiator.php b/src/Http/Support/DefaultErrorFormatNegotiator.php deleted file mode 100644 index f7bdf33..0000000 --- a/src/Http/Support/DefaultErrorFormatNegotiator.php +++ /dev/null @@ -1,29 +0,0 @@ -getHeaderLine('Accept'); - if (str_contains($accept, 'application/vnd.api+json')) { - return 'jsonapi'; - } - if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) { - return 'xml'; - } - return 'rest'; - } - - public function wantsHtml(ServerRequest $request): bool - { - $accept = $request->getHeaderLine('Accept'); - // Only return true if text/html is explicitly mentioned and is likely the preferred format - return str_contains($accept, 'text/html'); - } -} diff --git a/src/Http/Support/DefaultExceptionToStatusMapper.php b/src/Http/Support/DefaultExceptionToStatusMapper.php deleted file mode 100644 index 0cf4271..0000000 --- a/src/Http/Support/DefaultExceptionToStatusMapper.php +++ /dev/null @@ -1,19 +0,0 @@ -getCode() ?: 500); - if ($code < 400 || $code > 599) { - return 500; - } - return $code; - } -} diff --git a/src/Http/Support/DefaultRequestIdProvider.php b/src/Http/Support/DefaultRequestIdProvider.php deleted file mode 100644 index 9531705..0000000 --- a/src/Http/Support/DefaultRequestIdProvider.php +++ /dev/null @@ -1,20 +0,0 @@ -getHeaderLine('X-Request-Id'); - if ($incoming !== '') { - return $incoming; - } - return bin2hex(random_bytes(8)); - } -} diff --git a/src/Http/Support/Paginator.php b/src/Http/Support/Paginator.php deleted file mode 100644 index 2fa8b37..0000000 --- a/src/Http/Support/Paginator.php +++ /dev/null @@ -1,84 +0,0 @@ - $items - */ - public function __construct( - private array $items, - private int $total, - private int $perPage, - private int $currentPage, - private string $baseUrl - ) {} - - public function toArray(): array - { - $lastPage = (int) ceil($this->total / $this->perPage); - - return [ - 'data' => $this->items, - 'meta' => [ - 'total' => $this->total, - 'per_page' => $this->perPage, - 'current_page' => $this->currentPage, - 'last_page' => $lastPage, - 'from' => ($this->currentPage - 1) * $this->perPage + 1, - 'to' => min($this->currentPage * $this->perPage, $this->total), - ], - 'links' => [ - 'first' => $this->getUrl(1), - 'last' => $this->getUrl($lastPage), - 'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null, - 'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null, - 'self' => $this->getUrl($this->currentPage), - ] - ]; - } - - public function toJsonApi(): array - { - $lastPage = (int) ceil($this->total / $this->perPage); - - return [ - 'data' => $this->items, - 'meta' => [ - 'total' => $this->total, - 'page' => [ - 'size' => $this->perPage, - 'total' => $lastPage, - ] - ], - 'links' => [ - 'first' => $this->getUrl(1), - 'last' => $this->getUrl($lastPage), - 'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null, - 'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null, - 'self' => $this->getUrl($this->currentPage), - ] - ]; - } - - private function getUrl(int $page): string - { - $url = parse_url($this->baseUrl); - $query = []; - if (isset($url['query'])) { - parse_str($url['query'], $query); - } - - $query['page'] = $page; - $query['per_page'] = $this->perPage; - - $queryString = http_build_query($query); - - return ($url['path'] ?? '/') . '?' . $queryString; - } -} diff --git a/src/Mvc/APIController.php b/src/Mvc/APIController.php deleted file mode 100644 index 98844cc..0000000 --- a/src/Mvc/APIController.php +++ /dev/null @@ -1,31 +0,0 @@ -responses->ok($data); - } - - protected function created(array $data = [], ?string $location = null): object - { - return $this->responses->created($data, $location); - } - - protected function noContent(): object - { - return $this->responses->noContent(); - } - - protected function error(int $status, string $title, ?string $detail = null, array $extra = []): object - { - return $this->responses->error($status, $title, $detail, $extra); - } -} diff --git a/src/Mvc/Controller.php b/src/Mvc/Controller.php deleted file mode 100644 index 90ececb..0000000 --- a/src/Mvc/Controller.php +++ /dev/null @@ -1,9 +0,0 @@ -transformData($data); - $tpl = $template ?? $this->defaultTemplate(); - return $this->renderer->render($tpl, $prepared); - } - - public function defaultTemplate(): string - { - return $this->template; - } -} diff --git a/src/Mvc/ViewController.php b/src/Mvc/ViewController.php deleted file mode 100644 index 7cc3b57..0000000 --- a/src/Mvc/ViewController.php +++ /dev/null @@ -1,42 +0,0 @@ -psr17 = new Psr17Factory(); - } - - /** - * Return an HTML response with the provided content. - */ - protected function html(string $content, int $status = 200, array $headers = []): ResponseInterface - { - $response = $this->psr17->createResponse($status)->withHeader('Content-Type', 'text/html; charset=utf-8'); - foreach ($headers as $k => $v) { - $response = $response->withHeader((string) $k, (string) $v); - } - $response->getBody()->write($content); - return $response; - } - - /** - * Convenience to render a module View and return an HTML response. - * The `$template` is optional; when omitted (null), the view should use its default template. - */ - protected function renderView(View $view, array $data = [], ?string $template = null, int $status = 200, array $headers = []): ResponseInterface - { - // Delegate template selection to the View; when $template is null, - // the View may use its default template. - $markup = $view->render($data, $template); - return $this->html($markup, $status, $headers); - } -} diff --git a/src/Mvc/ViewWithDefaultTemplate.php b/src/Mvc/ViewWithDefaultTemplate.php deleted file mode 100644 index 407a269..0000000 --- a/src/Mvc/ViewWithDefaultTemplate.php +++ /dev/null @@ -1,9 +0,0 @@ -connected = true; - $this->manager = new Manager(); - } - - public function isConnected(): bool - { - return $this->connected; - } - - public function getManager(): Manager - { - if (!$this->manager) { - $this->connect(); - } - return $this->manager; - } -} diff --git a/src/Providers/AppServiceProvider.php b/src/Providers/AppServiceProvider.php deleted file mode 100644 index 04b8bc8..0000000 --- a/src/Providers/AppServiceProvider.php +++ /dev/null @@ -1,33 +0,0 @@ -get('/_phred/app', static function () { - $psr17 = new Psr17Factory(); - $res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json'); - $res->getBody()->write(json_encode(['app' => true], JSON_UNESCAPED_SLASHES)); - return $res; - }); - }); - } -} diff --git a/src/Providers/Core/CacheServiceProvider.php b/src/Providers/Core/CacheServiceProvider.php deleted file mode 100644 index ded4960..0000000 --- a/src/Providers/Core/CacheServiceProvider.php +++ /dev/null @@ -1,28 +0,0 @@ -addDefinitions([ - CacheInterface::class => \DI\autowire(FileCache::class) - ->constructor($cacheDir), - ]); - } - - public function boot(Container $container): void - { - } -} diff --git a/src/Providers/Core/FlagsServiceProvider.php b/src/Providers/Core/FlagsServiceProvider.php deleted file mode 100644 index 3dd57e2..0000000 --- a/src/Providers/Core/FlagsServiceProvider.php +++ /dev/null @@ -1,32 +0,0 @@ -get('FLAGS_DRIVER', $config->get('app.drivers.flags', 'flagpole')); - - $impl = match ($driver) { - 'flagpole' => \Phred\Flags\FlagpoleClient::class, - default => throw new \RuntimeException("Unsupported flags driver: {$driver}"), - }; - - if ($driver === 'flagpole' && !class_exists(\Flagpole\FeatureManager::class)) { - throw new \RuntimeException("Flagpole FeatureManager not found. Did you install getphred/flagpole?"); - } - - $builder->addDefinitions([ - \Phred\Flags\Contracts\FeatureFlagClientInterface::class => \DI\autowire($impl), - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/HttpServiceProvider.php b/src/Providers/Core/HttpServiceProvider.php deleted file mode 100644 index 624ac51..0000000 --- a/src/Providers/Core/HttpServiceProvider.php +++ /dev/null @@ -1,107 +0,0 @@ -addDefinitions([ - CacheInterface::class => function (ConfigInterface $config) { - $cacheDir = getcwd() . '/storage/cache'; - return new FileCache($cacheDir); - }, - ClientInterface::class => function (ConfigInterface $config, Container $c) { - $options = $config->get('http.client', [ - 'timeout' => 5.0, - 'connect_timeout' => 2.0, - ]); - - $stack = HandlerStack::create(); - - // Profiling middleware - $stack->push(function (callable $handler) { - return function (\Psr\Http\Message\RequestInterface $request, array $options) use ($handler) { - $start = microtime(true); - return $handler($request, $options)->then( - function ($response) use ($start, $request) { - $duration = microtime(true) - $start; - $host = $request->getUri()->getHost(); - $key = "HTTP: " . $host; - PhredMiddleware::recordTiming($key, $duration); - return $response; - }, - function ($reason) use ($start, $request) { - $duration = microtime(true) - $start; - $host = $request->getUri()->getHost(); - $key = "HTTP: " . $host; - PhredMiddleware::recordTiming($key, $duration); - return \GuzzleHttp\Promise\Create::rejectionFor($reason); - } - ); - }; - }, 'profiler'); - - // Logging middleware - if ($config->get('http.middleware.log', false)) { - try { - $logger = $c->get(LoggerInterface::class); - $stack->push(Middleware::log( - $logger, - new MessageFormatter(MessageFormatter::SHORT) - )); - } catch (\Throwable) { - // Logger not available, skip logging middleware - } - } - - // Retry middleware - if ($config->get('http.middleware.retry.enabled', false)) { - $maxRetries = $config->get('http.middleware.retry.max_retries', 3); - $stack->push(Middleware::retry(function ($retries, $request, $response, $exception) use ($maxRetries) { - if ($retries >= $maxRetries) { - return false; - } - if ($exception instanceof \GuzzleHttp\Exception\ConnectException) { - return true; - } - if ($response && $response->getStatusCode() >= 500) { - return true; - } - return false; - })); - } - - // Circuit Breaker middleware - if ($config->get('http.middleware.circuit_breaker.enabled', false)) { - $threshold = $config->get('http.middleware.circuit_breaker.threshold', 5); - $timeout = $config->get('http.middleware.circuit_breaker.timeout', 30.0); - $cache = $c->get(CacheInterface::class); - $stack->push(new CircuitBreakerMiddleware($threshold, (float) $timeout, $cache)); - } - - $options['handler'] = $stack; - - return new Client($options); - }, - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/LoggingServiceProvider.php b/src/Providers/Core/LoggingServiceProvider.php deleted file mode 100644 index f7b8e51..0000000 --- a/src/Providers/Core/LoggingServiceProvider.php +++ /dev/null @@ -1,154 +0,0 @@ -addDefinitions([ - LoggerInterface::class => function (Container $c, ConfigInterface $config) { - $name = (string) $config->get('APP_NAME', 'Phred'); - $defaultChannel = (string) $config->get('logging.default', 'stack'); - - $logger = new Logger($name); - - $this->createChannel($logger, $defaultChannel, $config); - - // Processors - $logger->pushProcessor(new ProcessIdProcessor()); - $logger->pushProcessor(new MemoryUsageProcessor()); - $logger->pushProcessor(function (LogRecord $record) use ($c): LogRecord { - try { - $requestIdProvider = $c->get(RequestIdProviderInterface::class); - // We need a request to provide an ID, but logger might be called outside of a request. - // Try to get request from container if available, or use dummy. - $request = $c->has('request') ? $c->get('request') : new ServerRequest('GET', '/'); - $id = $requestIdProvider->provide($request); - } catch (\Throwable) { - $id = bin2hex(random_bytes(8)); - } - $record->extra['request_id'] = $id; - return $record; - }); - - return $logger; - }, - ]); - } - - private function createChannel(Logger $logger, string $channel, ConfigInterface $config): void - { - $channelConfig = $config->get("logging.channels.$channel"); - - if (!$channelConfig) { - // Fallback to a basic single log if channel not found - $logDir = getcwd() . '/storage/logs'; - if (!is_dir($logDir)) { - @mkdir($logDir, 0777, true); - } - $logger->pushHandler(new StreamHandler($logDir . '/phred.log')); - return; - } - - $driver = $channelConfig['driver'] ?? 'single'; - - switch ($driver) { - case 'stack': - foreach ($channelConfig['channels'] ?? [] as $subChannel) { - $this->createChannel($logger, $subChannel, $config); - } - break; - case 'single': - $this->ensureDir(dirname($channelConfig['path'])); - $logger->pushHandler(new StreamHandler($channelConfig['path'], $channelConfig['level'] ?? 'debug')); - break; - case 'daily': - $this->ensureDir(dirname($channelConfig['path'])); - $logger->pushHandler(new RotatingFileHandler( - $channelConfig['path'], - $channelConfig['days'] ?? 7, - $channelConfig['level'] ?? 'debug' - )); - break; - case 'syslog': - $logger->pushHandler(new SyslogHandler($config->get('APP_NAME', 'Phred'), LOG_USER, $channelConfig['level'] ?? 'debug')); - break; - case 'errorlog': - $logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $channelConfig['level'] ?? 'debug')); - break; - case 'slack': - $this->createSlackHandler($logger, $channelConfig); - break; - case 'sentry': - $this->createSentryHandler($logger, $channelConfig); - break; - } - } - - private function createSlackHandler(Logger $logger, array $config): void - { - if (!class_exists(SlackWebhookHandler::class)) { - // Silently skip if Monolog Slack handler is missing (usually bundled with Monolog 2/3) - return; - } - - if (empty($config['url'])) { - return; - } - - $logger->pushHandler(new SlackWebhookHandler( - $config['url'], - $config['channel'] ?? null, - $config['username'] ?? 'Phred Log Bot', - true, - null, - false, - true, - $config['level'] ?? 'critical' - )); - } - - private function createSentryHandler(Logger $logger, array $config): void - { - // Using sentry/sentry-monolog if available - if (!class_exists(\Sentry\Monolog\Handler::class) || !class_exists(\Sentry\SentrySdk::class)) { - return; - } - - if (empty($config['dsn'])) { - return; - } - - \Sentry\init(['dsn' => $config['dsn']]); - $logger->pushHandler(new \Sentry\Monolog\Handler(\Sentry\SentrySdk::getCurrentHub(), $config['level'] ?? 'error')); - } - - private function ensureDir(string $path): void - { - if (!is_dir($path)) { - @mkdir($path, 0777, true); - } - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/OpenApiServiceProvider.php b/src/Providers/Core/OpenApiServiceProvider.php deleted file mode 100644 index f4a01b0..0000000 --- a/src/Providers/Core/OpenApiServiceProvider.php +++ /dev/null @@ -1,31 +0,0 @@ -group('/_phred', static function ($router): void { - $router->get('/openapi', OpenApiJsonController::class); - $router->get('/openapi.json', OpenApiJsonController::class); - $router->get('/docs', OpenApiUiController::class); - }); - }); - } -} diff --git a/src/Providers/Core/OrmServiceProvider.php b/src/Providers/Core/OrmServiceProvider.php deleted file mode 100644 index 0e71832..0000000 --- a/src/Providers/Core/OrmServiceProvider.php +++ /dev/null @@ -1,34 +0,0 @@ -get('ORM_DRIVER', $config->get('app.drivers.orm', 'pairity')); - - $impl = match ($driver) { - 'pairity' => \Phred\Orm\PairityConnection::class, - 'eloquent' => \Phred\Orm\EloquentConnection::class, // Future proofing or assuming it might be added - default => throw new \RuntimeException("Unsupported ORM driver: {$driver}"), - }; - - // Validate dependencies for the driver - if ($driver === 'pairity' && !class_exists(\Pairity\Manager::class)) { - throw new \RuntimeException("Pairity Manager not found. Did you install getphred/pairity?"); - } - - $builder->addDefinitions([ - \Phred\Orm\Contracts\ConnectionInterface::class => \DI\autowire($impl), - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/RoutingServiceProvider.php b/src/Providers/Core/RoutingServiceProvider.php deleted file mode 100644 index e73be2d..0000000 --- a/src/Providers/Core/RoutingServiceProvider.php +++ /dev/null @@ -1,27 +0,0 @@ -get('AUTH_DRIVER', 'jwt'); - - $impl = match ($driver) { - 'jwt' => \Phred\Security\Jwt\JwtTokenService::class, - default => throw new \RuntimeException("Unsupported auth driver: {$driver}"), - }; - - if ($driver === 'jwt' && !class_exists(\Lcobucci\JWT\Configuration::class)) { - throw new \RuntimeException("lcobucci/jwt not found. Did you install lcobucci/jwt?"); - } - - $builder->addDefinitions([ - TokenServiceInterface::class => \DI\autowire($impl), - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/SerializationServiceProvider.php b/src/Providers/Core/SerializationServiceProvider.php deleted file mode 100644 index 9e19fa4..0000000 --- a/src/Providers/Core/SerializationServiceProvider.php +++ /dev/null @@ -1,34 +0,0 @@ -addDefinitions([ - SerializerInterface::class => function () { - $encoders = [new XmlEncoder(), new JsonEncoder()]; - // Avoid using ObjectNormalizer if symfony/property-access is missing - // Or use it only if available. For now, let's use ArrayDenormalizer which is safer - $normalizers = [new ArrayDenormalizer()]; - - return new Serializer($normalizers, $encoders); - }, - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/StorageServiceProvider.php b/src/Providers/Core/StorageServiceProvider.php deleted file mode 100644 index e8bb58d..0000000 --- a/src/Providers/Core/StorageServiceProvider.php +++ /dev/null @@ -1,81 +0,0 @@ -addDefinitions([ - StorageManager::class => function (Container $c) { - $config = $c->get(ConfigInterface::class); - $storageConfig = $config->get('storage'); - $defaultFilesystem = $c->get(FilesystemOperator::class); - return new StorageManager($defaultFilesystem, $storageConfig); - }, - FilesystemOperator::class => \DI\get(Filesystem::class), - Filesystem::class => function (Container $c) { - $config = $c->get(ConfigInterface::class); - $default = $config->get('storage.default', 'local'); - $diskConfig = $config->get("storage.disks.$default"); - - if (!$diskConfig) { - throw new \RuntimeException("Storage disk [$default] is not configured."); - } - - $driver = $diskConfig['driver'] ?? 'local'; - - $adapter = match ($driver) { - 'local' => new LocalFilesystemAdapter($diskConfig['root']), - 's3' => $this->createS3Adapter($diskConfig), - default => throw new \RuntimeException("Unsupported storage driver [$driver]."), - }; - - return new Filesystem($adapter); - }, - ]); - } - - private function createS3Adapter(array $config): AwsS3V3Adapter - { - if (!class_exists(S3Client::class)) { - throw new \RuntimeException("AWS SDK not found. Did you install aws/aws-sdk-php?"); - } - - if (!class_exists(AwsS3V3Adapter::class)) { - throw new \RuntimeException("Flysystem S3 adapter not found. Did you install league/flysystem-aws-s3-v3?"); - } - - $clientConfig = [ - 'credentials' => [ - 'key' => $config['key'], - 'secret' => $config['secret'], - ], - 'region' => $config['region'], - 'version' => 'latest', - ]; - - if (!empty($config['endpoint'])) { - $clientConfig['endpoint'] = $config['endpoint']; - $clientConfig['use_path_style_endpoint'] = $config['use_path_style_endpoint'] ?? false; - } - - $client = new S3Client($clientConfig); - - return new AwsS3V3Adapter($client, $config['bucket']); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/TemplateServiceProvider.php b/src/Providers/Core/TemplateServiceProvider.php deleted file mode 100644 index 37fdaa1..0000000 --- a/src/Providers/Core/TemplateServiceProvider.php +++ /dev/null @@ -1,32 +0,0 @@ -get('TEMPLATE_DRIVER', $config->get('app.drivers.template', 'eyrie')); - - $impl = match ($driver) { - 'eyrie' => \Phred\Template\EyrieRenderer::class, - default => throw new \RuntimeException("Unsupported template driver: {$driver}"), - }; - - if ($driver === 'eyrie' && !class_exists(\Eyrie\Engine::class)) { - throw new \RuntimeException("Eyrie Engine not found. Did you install getphred/eyrie?"); - } - - $builder->addDefinitions([ - \Phred\Template\Contracts\RendererInterface::class => \DI\autowire($impl), - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Providers/Core/TestingServiceProvider.php b/src/Providers/Core/TestingServiceProvider.php deleted file mode 100644 index 80e9149..0000000 --- a/src/Providers/Core/TestingServiceProvider.php +++ /dev/null @@ -1,32 +0,0 @@ -get('TEST_RUNNER', $config->get('app.drivers.test_runner', 'codeception')); - - $impl = match ($driver) { - 'codeception' => \Phred\Testing\CodeceptionRunner::class, - default => throw new \RuntimeException("Unsupported test runner driver: {$driver}"), - }; - - if ($driver === 'codeception' && !class_exists(\Codeception\Codecept::class)) { - throw new \RuntimeException("Codeception not found. Did you install codeception/codeception?"); - } - - $builder->addDefinitions([ - \Phred\Testing\Contracts\TestRunnerInterface::class => \DI\autowire($impl), - ]); - } - - public function boot(Container $container): void {} -} diff --git a/src/Security/Contracts/TokenServiceInterface.php b/src/Security/Contracts/TokenServiceInterface.php deleted file mode 100644 index f51ee5a..0000000 --- a/src/Security/Contracts/TokenServiceInterface.php +++ /dev/null @@ -1,28 +0,0 @@ -get('jwt.secret', 'change-me-to-something-very-secure'); - $this->config = Configuration::forSymmetricSigner( - new Sha256(), - InMemory::plainText($key) - ); - - $this->config->setValidationConstraints( - new SignedWith($this->config->signer(), $this->config->signingKey()) - ); - } - - public function createToken(string|int $userId, array $claims = []): string - { - $now = new \DateTimeImmutable(); - $builder = $this->config->builder() - ->issuedBy((string) getenv('APP_URL')) - ->permittedFor((string) getenv('APP_URL')) - ->identifiedBy(bin2hex(random_bytes(16))) - ->issuedAt($now) - ->canOnlyBeUsedAfter($now) - ->expiresAt($now->modify('+1 hour')) - ->withClaim('uid', $userId); - - foreach ($claims as $name => $value) { - $builder = $builder->withClaim($name, $value); - } - - return $builder->getToken($this->config->signer(), $this->config->signingKey())->toString(); - } - - public function validateToken(string $token): array - { - $jwt = $this->config->parser()->parse($token); - - $constraints = $this->config->validationConstraints(); - - if (!$this->config->validator()->validate($jwt, ...$constraints)) { - throw new \RuntimeException('Invalid JWT'); - } - - if (!$jwt instanceof UnencryptedToken) { - throw new \RuntimeException('Parsed JWT is not an unencrypted token'); - } - - return $jwt->claims()->all(); - } -} diff --git a/src/Support/Cache/FileCache.php b/src/Support/Cache/FileCache.php deleted file mode 100644 index 69f5e42..0000000 --- a/src/Support/Cache/FileCache.php +++ /dev/null @@ -1,110 +0,0 @@ -directory)) { - @mkdir($this->directory, 0777, true); - } - } - - public function get(string $key, mixed $default = null): mixed - { - $file = $this->getFilePath($key); - if (!file_exists($file)) { - return $default; - } - - $content = file_get_contents($file); - if ($content === false) { - return $default; - } - - $data = unserialize($content); - if ($data['expires'] !== 0 && $data['expires'] < time()) { - @unlink($file); - return $default; - } - - return $data['value']; - } - - public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool - { - $expires = 0; - if ($ttl !== null) { - if ($ttl instanceof \DateInterval) { - $expires = (new \DateTime())->add($ttl)->getTimestamp(); - } else { - $expires = time() + $ttl; - } - } - - $data = [ - 'expires' => $expires, - 'value' => $value, - ]; - - return file_put_contents($this->getFilePath($key), serialize($data)) !== false; - } - - public function delete(string $key): bool - { - $file = $this->getFilePath($key); - if (file_exists($file)) { - return @unlink($file); - } - return true; - } - - public function clear(): bool - { - foreach (glob($this->directory . '/*') as $file) { - if (is_file($file)) { - @unlink($file); - } - } - return true; - } - - public function getMultiple(iterable $keys, mixed $default = null): iterable - { - $result = []; - foreach ($keys as $key) { - $result[$key] = $this->get($key, $default); - } - return $result; - } - - public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool - { - foreach ($values as $key => $value) { - $this->set($key, $value, $ttl); - } - return true; - } - - public function deleteMultiple(iterable $keys): bool - { - foreach ($keys as $key) { - $this->delete($key); - } - return true; - } - - public function has(string $key): bool - { - return $this->get($key, $this) !== $this; - } - - private function getFilePath(string $key): string - { - return $this->directory . '/' . md5($key) . '.cache'; - } -} diff --git a/src/Support/Config.php b/src/Support/Config.php deleted file mode 100644 index 4dd6960..0000000 --- a/src/Support/Config.php +++ /dev/null @@ -1,101 +0,0 @@ -|null */ - private static ?array $store = null; - - /** - * Get configuration value with precedence: - * 1) Environment variables (UPPER_CASE or dot.notation translated) - * 2) Loaded config files from config/*.php, accessible via dot.notation (e.g., app.env) - * 3) Provided $default - */ - public static function get(string $key, mixed $default = null): mixed - { - // 1) Environment lookup (supports dot.notation by converting to UPPER_SNAKE) - $envKey = strtoupper(str_replace('.', '_', $key)); - $value = getenv($envKey); - if ($value !== false) { - return $value; - } - if (isset($_SERVER[$envKey])) { - return $_SERVER[$envKey]; - } - if (isset($_ENV[$envKey])) { - return $_ENV[$envKey]; - } - - // 2) Config files (lazy load once) - self::ensureLoaded(); - if (self::$store) { - $fromStore = self::getFromStore($key); - if ($fromStore !== null) { - return $fromStore; - } - } - - // 3) Default - return $default; - } - - private static function ensureLoaded(): void - { - if (self::$store !== null) { - return; - } - self::$store = []; - $root = getcwd(); - $configDir = $root . DIRECTORY_SEPARATOR . 'config'; - if (!is_dir($configDir)) { - return; // no config directory; keep empty store - } - foreach (glob($configDir . '/*.php') ?: [] as $file) { - $key = basename($file, '.php'); - try { - $data = require $file; - if (is_array($data)) { - self::$store[$key] = $data; - } - } catch (\Throwable) { - // ignore malformed config files to avoid breaking runtime - } - } - } - - private static function getFromStore(string $key): mixed - { - // dot.notation: first segment is file key, remaining traverse array - if (str_contains($key, '.')) { - $parts = explode('.', $key); - $rootKey = array_shift($parts); - if ($rootKey === null || !isset(self::$store[$rootKey])) { - return null; - } - $cursor = self::$store[$rootKey]; - foreach ($parts as $p) { - if (is_array($cursor) && array_key_exists($p, $cursor)) { - $cursor = $cursor[$p]; - } else { - return null; - } - } - return $cursor; - } - - // non-dotted: try exact file key - return self::$store[$key] ?? null; - } - - /** - * Clear the config store (useful for tests). - */ - public static function clear(): void - { - self::$store = null; - } -} diff --git a/src/Support/Contracts/ConfigInterface.php b/src/Support/Contracts/ConfigInterface.php deleted file mode 100644 index 53d081c..0000000 --- a/src/Support/Contracts/ConfigInterface.php +++ /dev/null @@ -1,13 +0,0 @@ -getUri()->getHost(); - - if ($this->isCircuitOpen($host)) { - return Create::rejectionFor( - new \RuntimeException("Circuit breaker is open for host: $host") - ); - } - - return $handler($request, $options)->then( - function ($response) use ($host) { - if ($response instanceof \Psr\Http\Message\ResponseInterface && $response->getStatusCode() >= 500) { - $this->reportFailure($host); - } else { - $this->reportSuccess($host); - } - return $response; - }, - function ($reason) use ($host) { - $this->reportFailure($host); - return Create::rejectionFor($reason); - } - ); - }; - } - - private function isCircuitOpen(string $host): bool - { - $state = $this->getState($host); - - if (!$state['isOpen']) { - return false; - } - - if ((microtime(true) - $state['lastFailureTime']) > $this->timeout) { - // Half-open state in a real CB, here we just try again - $this->reportSuccess($host); - return false; - } - - return true; - } - - private function reportSuccess(string $host): void - { - $this->saveState($host, [ - 'failures' => 0, - 'lastFailureTime' => 0, - 'isOpen' => false, - ]); - } - - private function reportFailure(string $host): void - { - $state = $this->getState($host); - $state['failures']++; - $state['lastFailureTime'] = microtime(true); - - if ($state['failures'] >= $this->threshold) { - $state['isOpen'] = true; - } - - $this->saveState($host, $state); - } - - private function getState(string $host): array - { - if ($this->cache) { - return $this->cache->get("cb.$host", [ - 'failures' => 0, - 'lastFailureTime' => 0, - 'isOpen' => false, - ]); - } - - return [ - 'failures' => self::$localFailures[$host] ?? 0, - 'lastFailureTime' => self::$localLastFailureTime[$host] ?? 0, - 'isOpen' => self::$localIsOpen[$host] ?? false, - ]; - } - - private function saveState(string $host, array $state): void - { - if ($this->cache) { - $this->cache->set("cb.$host", $state, (int)$this->timeout * 2); - return; - } - - self::$localFailures[$host] = $state['failures']; - self::$localLastFailureTime[$host] = $state['lastFailureTime']; - self::$localIsOpen[$host] = $state['isOpen']; - } - - public static function clear(?string $host = null): void - { - if ($host) { - unset(self::$localFailures[$host], self::$localLastFailureTime[$host], self::$localIsOpen[$host]); - } else { - self::$localFailures = []; - self::$localLastFailureTime = []; - self::$localIsOpen = []; - } - } -} diff --git a/src/Support/PhpStan/Rules/InvokableControllerRule.php b/src/Support/PhpStan/Rules/InvokableControllerRule.php deleted file mode 100644 index 9888546..0000000 --- a/src/Support/PhpStan/Rules/InvokableControllerRule.php +++ /dev/null @@ -1,92 +0,0 @@ - - */ -final class InvokableControllerRule implements Rule -{ - /** @var \PHPStan\Reflection\ReflectionProvider */ - private $reflectionProvider; - - public function __construct(\PHPStan\Reflection\ReflectionProvider $reflectionProvider) - { - $this->reflectionProvider = $reflectionProvider; - } - - public function getNodeType(): string - { - return Class_::class; - } - - public function processNode(Node $node, Scope $scope): array - { - if ($node->isAbstract() || $node->isAnonymous()) { - return []; - } - - $className = $node->namespacedName ? $node->namespacedName->toString() : null; - if (!$className) { - return []; - } - - // Use PHPStan's reflection to avoid issues with unindexed classes - if (!$scope->isInClass()) { - // For the Class_ node, we can get the reflection from the namespacedName - if (!$this->reflectionProvider->hasClass($className)) { - return []; - } - $classReflection = $this->reflectionProvider->getClass($className); - } else { - $classReflection = $scope->getClassReflection(); - } - - // Handle both Project\Modules and Modules (depending on setup) - $isControllerNamespace = str_contains($className, 'Controllers'); - - if (!$classReflection->isSubclassOf(Controller::class) && !$isControllerNamespace) { - return []; - } - - $errors = []; - // Check public methods - foreach ($classReflection->getNativeReflection()->getMethods() as $method) { - if (!$method->isPublic()) { - continue; - } - - // Only check methods defined in this class - if ($method->getDeclaringClass()->getName() !== $className) { - continue; - } - - $methodName = $method->getName(); - if ($methodName !== '__invoke' && $methodName !== '__construct') { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Controller "%s" has a non-invokable public method "%s". Phred strictly enforces the "One Controller = One Route" (invokable) pattern.', - $className, - $methodName - ))->build(); - } - } - - // Also check if __invoke is missing - if (!$classReflection->hasNativeMethod('__invoke')) { - $errors[] = RuleErrorBuilder::message(sprintf( - 'Controller "%s" is missing the required "__invoke" method.', - $className - ))->build(); - } - - return $errors; - } -} diff --git a/src/Support/ProviderRepository.php b/src/Support/ProviderRepository.php deleted file mode 100644 index 90fe69a..0000000 --- a/src/Support/ProviderRepository.php +++ /dev/null @@ -1,128 +0,0 @@ - */ - private array $providers = []; - - public function __construct(private readonly ConfigInterface $config) - { - } - - public function load(): void - { - $this->providers = []; - // Merge providers from config/providers.php file (authoritative) with any runtime Config entries - $fileCore = $fileApp = $fileModules = []; - $configFile = dirname(__DIR__, 2) . '/config/providers.php'; - if (is_file($configFile)) { - /** @noinspection PhpIncludeInspection */ - $arr = require $configFile; - if (is_array($arr)) { - $fileCore = (array)($arr['core'] ?? []); - $fileApp = (array)($arr['app'] ?? []); - $fileModules = (array)($arr['modules'] ?? []); - } - } - $core = array_values(array_unique(array_merge($fileCore, (array) Config::get('providers.core', [])))); - $app = array_values(array_unique(array_merge($fileApp, (array) Config::get('providers.app', [])))); - $modules = array_values(array_unique(array_merge($fileModules, (array) Config::get('providers.modules', [])))); - - $loadedClasses = []; - foreach ([$core, $app, $modules] as $group) { - foreach ($group as $class) { - if (is_string($class) && class_exists($class) && !isset($loadedClasses[$class])) { - $instance = new $class(); - if ($instance instanceof ServiceProviderInterface) { - $this->providers[] = $instance; - $loadedClasses[$class] = true; - } - } - } - } - - // Initial module discovery: scan modules/*/Providers/*ServiceProvider.php - $root = dirname(__DIR__, 2); - $modulesDir = $root . '/modules'; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) ?: [] as $entry) { - if ($entry === '.' || $entry === '..') { - continue; - } - $modulePath = $modulesDir . '/' . $entry; - if (!is_dir($modulePath)) { - continue; - } - $providersPath = $modulePath . '/Providers'; - if (!is_dir($providersPath)) { - continue; - } - - $namespace = $this->resolveModuleNamespace($modulePath, $entry); - - foreach (scandir($providersPath) ?: [] as $file) { - if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) { - continue; - } - $classBase = substr($file, 0, -4); - if (!str_ends_with($classBase, 'ServiceProvider')) { - continue; - } - $fqcn = "{$namespace}Providers\\\\{$classBase}"; - if (class_exists($fqcn) && !isset($loadedClasses[$fqcn])) { - $instance = new $fqcn(); - if ($instance instanceof ServiceProviderInterface) { - $this->providers[] = $instance; - $loadedClasses[$fqcn] = true; - } - } - } - } - } - } - - private function resolveModuleNamespace(string $modulePath, string $moduleName): string - { - $composerFile = $modulePath . '/composer.json'; - if (is_file($composerFile)) { - $data = json_decode((string)file_get_contents($composerFile), true); - if (is_array($data) && isset($data['autoload']['psr-4'])) { - foreach ($data['autoload']['psr-4'] as $ns => $path) { - // If the path is empty or '.', it maps the root of the module to this namespace - if ($path === '' || $path === '.' || $path === './') { - return rtrim($ns, '\\') . '\\'; - } - } - } - } - // Fallback to default Phred convention - $baseNamespace = (string) $this->config->get('MODULE_NAMESPACE', 'Modules'); - return "{$baseNamespace}\\\\{$moduleName}\\\\"; - } - - public function registerAll(ContainerBuilder $builder): void - { - foreach ($this->providers as $provider) { - $provider->register($builder, $this->config); - } - } - - public function bootAll(Container $container): void - { - foreach ($this->providers as $provider) { - $provider->boot($container); - } - } -} diff --git a/src/Support/Storage/StorageManager.php b/src/Support/Storage/StorageManager.php deleted file mode 100644 index 42ef966..0000000 --- a/src/Support/Storage/StorageManager.php +++ /dev/null @@ -1,38 +0,0 @@ - */ - private array $disks = []; - - public function __construct( - private readonly FilesystemOperator $defaultDisk, - private readonly array $config = [] - ) {} - - public function disk(?string $name = null): FilesystemOperator - { - if ($name === null) { - return $this->defaultDisk; - } - - return $this->disks[$name] ?? $this->defaultDisk; - } - - public function url(string $path, ?string $disk = null): string - { - $diskName = $disk ?? 'local'; - $diskConfig = $this->config['disks'][$diskName] ?? null; - - if (!$diskConfig || empty($diskConfig['url'])) { - return $path; - } - - return rtrim($diskConfig['url'], '/') . '/' . ltrim($path, '/'); - } -} diff --git a/src/Template/Contracts/RendererInterface.php b/src/Template/Contracts/RendererInterface.php deleted file mode 100644 index 7cb2250..0000000 --- a/src/Template/Contracts/RendererInterface.php +++ /dev/null @@ -1,13 +0,0 @@ - $v) { - $out = str_replace('{{' . $k . '}}', (string) $v, $out); - } - return $out; - } -} diff --git a/src/Testing/CodeceptionRunner.php b/src/Testing/CodeceptionRunner.php deleted file mode 100644 index fbfa3b8..0000000 --- a/src/Testing/CodeceptionRunner.php +++ /dev/null @@ -1,20 +0,0 @@ -kernel = new Kernel(); - } - - protected function get(string $uri, array $headers = []): TestResponse - { - return $this->call('GET', $uri, [], [], $headers); - } - - protected function post(string $uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('POST', $uri, $data, [], $headers); - } - - protected function put(string $uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('PUT', $uri, $data, [], $headers); - } - - protected function patch(string $uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('PATCH', $uri, $data, [], $headers); - } - - protected function delete(string $uri, array $data = [], array $headers = []): TestResponse - { - return $this->call('DELETE', $uri, $data, [], $headers); - } - - protected function call(string $method, string $uri, array $data = [], array $files = [], array $headers = []): TestResponse - { - $request = new ServerRequest($method, $uri, $headers); - - if (!empty($data)) { - $request = $request->withParsedBody($data); - } - - $response = $this->kernel->handle($request); - - return new TestResponse($response); - } -} diff --git a/src/Testing/TestResponse.php b/src/Testing/TestResponse.php deleted file mode 100644 index 6338dc5..0000000 --- a/src/Testing/TestResponse.php +++ /dev/null @@ -1,64 +0,0 @@ -response->getStatusCode(), "Expected status code {$status} but received {$this->response->getStatusCode()}."); - return $this; - } - - public function assertOk(): self - { - return $this->assertStatus(200); - } - - public function assertCreated(): self - { - return $this->assertStatus(201); - } - - public function assertNotFound(): self - { - return $this->assertStatus(404); - } - - public function assertHeader(string $headerName, string $value): self - { - Assert::assertEquals($value, $this->response->getHeaderLine($headerName)); - return $this; - } - - public function assertJson(array $data): self - { - $body = (string) $this->response->getBody(); - $decoded = json_decode($body, true); - - Assert::assertIsArray($decoded, "Response body is not valid JSON: " . $body); - - foreach ($data as $key => $value) { - Assert::assertArrayHasKey($key, $decoded, "Key '{$key}' not found in JSON response: " . $body); - Assert::assertEquals($value, $decoded[$key]); - } - - return $this; - } - - public function getResponse(): ResponseInterface - { - return $this->response; - } - - public function __call(string $method, array $args): mixed - { - return $this->response->$method(...$args); - } -} diff --git a/src/commands/create_command.php b/src/commands/create_command.php deleted file mode 100644 index e416561..0000000 --- a/src/commands/create_command.php +++ /dev/null @@ -1,67 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Command name (e.g., hello:world)', - ], - '--description' => [ - 'mode' => 'option', - 'valueRequired' => true, - 'description' => 'Optional command description.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $name = trim((string) $input->getArgument('name')); - $description = $input->getOption('description') ?: 'Custom CLI command.'; - - if ($name === '') { - $output->writeln('Command name is required.'); - return 1; - } - - $root = getcwd(); - $commandsDir = $root . '/console/commands'; - - if (!is_dir($commandsDir)) { - @mkdir($commandsDir, 0777, true); - } - - // Convert name to StudlyCase for filename, e.g., hello:world -> HelloWorld.php - $filename = str_replace([':', '-', '_'], ' ', $name); - $filename = str_replace(' ', '', ucwords($filename)) . '.php'; - $path = $commandsDir . '/' . $filename; - - if (file_exists($path)) { - $output->writeln("Command file '$filename' already exists."); - return 1; - } - - $stub = file_get_contents(dirname(__DIR__) . '/stubs/command.stub'); - $code = strtr($stub, [ - '{{namespace}}' => '', // Global namespace for console/commands by default or project specific? - // bin/phred uses anonymous class require, so namespace is optional but good for structure. - '{{command}}' => $name, - '{{description}}' => $description, - ]); - - // Remove empty namespace line if present - $code = str_replace("namespace ;\n\n", "", $code); - - file_put_contents($path, $code); - $output->writeln("created console/commands/$filename"); - - return 0; - } -}; diff --git a/src/commands/create_controller.php b/src/commands/create_controller.php deleted file mode 100644 index 3f5c83f..0000000 --- a/src/commands/create_controller.php +++ /dev/null @@ -1,111 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Controller name (e.g., PostController)', - ], - 'module' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Target module name (e.g., Shop). Optional if using create::controller', - ], - '--view' => [ - 'mode' => 'option', - 'valueRequired' => true, - 'description' => 'Optional View class name to associate with this controller.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = null; - if (preg_match('/^create:([^:]+):controller$/', $this->getName(), $matches)) { - $module = $matches[1]; - } - - if (!$module) { - $module = $input->hasArgument('module') ? $input->getArgument('module') : null; - } - - $module = trim((string) $module); - $name = trim((string) $input->getArgument('name')); - $viewClass = $input->getOption('view') ? trim((string) $input->getOption('view')) : null; - - if ($module === '' || $name === '') { - $output->writeln('Module and Name are required.'); - return 1; - } - - // Case-insensitive module directory lookup - $modulesDir = getcwd() . '/modules'; - $moduleDir = null; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $dir) { - if (strtolower($dir) === strtolower($module)) { - $moduleDir = $modulesDir . '/' . $dir; - $module = $dir; // Use actual casing - break; - } - } - } - - if (!$moduleDir || !is_dir($moduleDir)) { - $output->writeln("Module '$module' does not exist."); - return 1; - } - - $controllersDir = $moduleDir . '/Controllers'; - if (!is_dir($controllersDir)) { - @mkdir($controllersDir, 0777, true); - } - - $filename = $name . '.php'; - $path = $controllersDir . '/' . $filename; - - if (file_exists($path)) { - $output->writeln("Controller '$name' already exists in module '$module'."); - return 1; - } - - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $namespace = "{$baseNamespace}\\$module\\Controllers"; - - $viewUse = ''; - $invokeParams = 'Request $request'; - $renderBody = " return (new \Nyholm\Psr7\Factory\Psr17Factory()) - ->createResponse(200) - ->withHeader('Content-Type', 'text/plain') - ->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name ready'));"; - - if ($viewClass) { - $viewFqcn = "{$baseNamespace}\\$module\\Views\\$viewClass"; - $viewUse = "use $viewFqcn;"; - $invokeParams = "Request \$request, $viewClass \$view"; - $renderBody = " return \$this->renderView(\$view, []);"; - } - - $stub = file_get_contents(dirname(__DIR__) . '/stubs/controller.stub'); - $template = strtr($stub, [ - '{{namespace}}' => $namespace, - '{{useView}}' => $viewUse, - '{{class}}' => $name, - '{{params}}' => $invokeParams, - '{{body}}' => $renderBody, - ]); - - file_put_contents($path, $template); - $output->writeln("Controller '$name' created at modules/$module/Controllers/$filename"); - - return 0; - } -}; diff --git a/src/commands/create_migration.php b/src/commands/create_migration.php deleted file mode 100644 index 5167038..0000000 --- a/src/commands/create_migration.php +++ /dev/null @@ -1,77 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Migration name (e.g., CreatePostsTable)', - ], - 'module' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Target module name (e.g., Shop). Optional if using create::migration', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = null; - if (preg_match('/^create:([^:]+):migration$/', $this->getName(), $matches)) { - $module = $matches[1]; - } - - if (!$module) { - $module = $input->hasArgument('module') ? $input->getArgument('module') : null; - } - - $module = trim((string) $module); - $name = trim((string) $input->getArgument('name')); - - if ($module === '' || $name === '') { - $output->writeln('Module and Name are required.'); - return 1; - } - - // Case-insensitive module directory lookup - $modulesDir = getcwd() . '/modules'; - $moduleDir = null; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $dir) { - if (strtolower($dir) === strtolower($module)) { - $moduleDir = $modulesDir . '/' . $dir; - $module = $dir; // Use actual casing - break; - } - } - } - - if (!$moduleDir || !is_dir($moduleDir)) { - $output->writeln("Module '$module' does not exist."); - return 1; - } - - $migrationsDir = $moduleDir . '/Database/Migrations'; - if (!is_dir($migrationsDir)) { - @mkdir($migrationsDir, 0777, true); - } - - $timestamp = date('Y_m_d_His'); - $filename = $timestamp . '_' . strtolower(preg_replace('/(?writeln("Migration created at modules/$module/Database/Migrations/$filename"); - - return 0; - } -}; diff --git a/src/commands/create_model.php b/src/commands/create_model.php deleted file mode 100644 index 5fa636f..0000000 --- a/src/commands/create_model.php +++ /dev/null @@ -1,85 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Model name (e.g., Post)', - ], - 'module' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Target module name (e.g., Shop). Optional if using create::model', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = null; - if (preg_match('/^create:([^:]+):model$/', $this->getName(), $matches)) { - $module = $matches[1]; - } - - if (!$module) { - $module = $input->hasArgument('module') ? $input->getArgument('module') : null; - } - - $module = trim((string) $module); - $name = trim((string) $input->getArgument('name')); - - if ($module === '' || $name === '') { - $output->writeln('Module and Name are required.'); - return 1; - } - - // Case-insensitive module directory lookup - $modulesDir = getcwd() . '/modules'; - $moduleDir = null; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $dir) { - if (strtolower($dir) === strtolower($module)) { - $moduleDir = $modulesDir . '/' . $dir; - $module = $dir; // Use actual casing - break; - } - } - } - - if (!$moduleDir || !is_dir($moduleDir)) { - $output->writeln("Module '$module' does not exist."); - return 1; - } - - $modelsDir = $moduleDir . '/Models'; - if (!is_dir($modelsDir)) { - @mkdir($modelsDir, 0777, true); - } - - $path = $modelsDir . '/' . $name . '.php'; - if (file_exists($path)) { - $output->writeln("Model '$name' already exists in module '$module'."); - return 1; - } - - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $namespace = "{$baseNamespace}\\$module\\Models"; - $stub = file_get_contents(dirname(__DIR__) . '/stubs/model.stub'); - $template = strtr($stub, [ - '{{namespace}}' => $namespace, - '{{class}}' => $name, - ]); - - file_put_contents($path, $template); - $output->writeln("Model '$name' created at modules/$module/Models/$name.php"); - - return 0; - } -}; diff --git a/src/commands/create_module.php b/src/commands/create_module.php deleted file mode 100644 index bebb089..0000000 --- a/src/commands/create_module.php +++ /dev/null @@ -1,388 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Module name (e.g., Shop)', - ], - 'prefix' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Optional URL prefix (e.g., /shop). If omitted, you will be prompted or default is /', - ], - '--update-composer' => [ - 'mode' => 'flag', - 'description' => 'Automatically add PSR-4 mapping to composer.json and dump autoload.', - ], - '--no-dump' => [ - 'mode' => 'flag', - 'description' => 'Skip composer dump-autoload when using --update-composer.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $name = $this->readArgWithFallback($input, 'name', 2); - $name = trim($name); - if ($name === '') { - $output->writeln('Module name is required.'); - return 1; - } - - $prefixArg = $this->readArgWithFallback($input, 'prefix', 3); - [$prefix, $updateComposer, $noDump] = $this->parseArgsForPrefixAndFlags($prefixArg, $name, $input); - - $root = dirname(__DIR__, 2); - $moduleRoot = $root . '/modules/' . $name; - - if (!$this->createScaffold($moduleRoot, $output)) { - return 1; - } - $this->createPersistenceDir($moduleRoot, $output); - - $this->writeProviderStub($moduleRoot, $name); - $this->writeRoutesStubs($moduleRoot, $name); - $this->writeViewControllerTemplateStubs($moduleRoot, $name); - $this->writeControllersIndexIfMissing($moduleRoot); - // Ensure Controllers directory exists specifically for the test assertion - if (!is_dir($moduleRoot . '/Controllers')) { - @mkdir($moduleRoot . '/Controllers', 0777, true); - } - $this->registerProviderInConfig($root, $name); - $this->appendRouteInclude($root, $name, $prefix); - - $this->printPsr4Hint($output, $name, $prefix); - if ($updateComposer) { - $this->updateComposerPsr4($output, $root, $name, !$noDump); - } - $output->writeln("\nFull documentation available at: https://getphred.com"); - return 0; - } - - private function readArgWithFallback(Input $input, string $key, int $argvIndex): string - { - try { - $val = $input->getArgument($key); - if (is_string($val)) { - return $val; - } - } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) { - // Try to read from ArrayInput-style arguments map - if (method_exists($input, 'getArguments')) { - $args = $input->getArguments(); - if (is_array($args) && isset($args[$key]) && is_string($args[$key])) { - return $args[$key]; - } - } - // Reflection fallback for ArrayInput to read raw parameters - if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) { - try { - $ref = new \ReflectionObject($input); - if ($ref->hasProperty('parameters')) { - $prop = $ref->getProperty('parameters'); - $prop->setAccessible(true); - $params = $prop->getValue($input); - if (is_array($params) && isset($params[$key]) && is_string($params[$key])) { - return $params[$key]; - } - } - } catch (\Throwable) { - // ignore - } - } - // Fall back to argv position if command definition didn't register arguments in this context (e.g., direct handle() calls in tests) - $argv = $_SERVER['argv'] ?? []; - $fallback = isset($argv[$argvIndex]) ? (string) $argv[$argvIndex] : ''; - // Sanitize: ignore flags (starting with '-') and unexpected tokens - if ($fallback !== '' && $fallback[0] === '-') { - return ''; - } - return $fallback; - } - return ''; - } - - private function parseArgsForPrefixAndFlags(string $prefixArg, string $name, Input $input): array - { - $defaultPrefix = '/' . strtolower($name); - // Detect flags robustly - $updateComposer = false; - $noDump = false; - // Attempt to read flags from options map if available - if (method_exists($input, 'getOptions')) { - $opts = $input->getOptions(); - if (is_array($opts)) { - $updateComposer = !empty($opts['--update-composer']) || !empty($opts['update-composer']); - $noDump = !empty($opts['--no-dump']) || !empty($opts['no-dump']); - } - } - // Reflection fallback to read raw parameters from ArrayInput - if ($input instanceof \Symfony\Component\Console\Input\ArrayInput) { - try { - $ref = new \ReflectionObject($input); - if ($ref->hasProperty('parameters')) { - $prop = $ref->getProperty('parameters'); - $prop->setAccessible(true); - $params = $prop->getValue($input); - if (is_array($params)) { - if (array_key_exists('--update-composer', $params)) { $updateComposer = (bool) $params['--update-composer']; } - if (array_key_exists('--no-dump', $params)) { $noDump = (bool) $params['--no-dump']; } - } - } - } catch (\Throwable) { - // ignore - } - } - if (method_exists($input, 'hasParameterOption')) { - /** @var \Symfony\Component\Console\Input\InputInterface $input */ - if ($input->hasParameterOption('--update-composer')) { $updateComposer = true; } - if ($input->hasParameterOption('--no-dump')) { $noDump = true; } - } - try { $updateComposer = $updateComposer || (bool) $input->getOption('update-composer'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {} - try { $noDump = $noDump || (bool) $input->getOption('no-dump'); } catch (\Symfony\Component\Console\Exception\InvalidArgumentException) {} - - if ($prefixArg !== '') { - $prefix = $prefixArg; - } else { - $prefix = $this->readPrefixInteractive($name, $defaultPrefix); - } - $prefix = '/' . trim((string) $prefix, '/'); - return [$prefix, $updateComposer, $noDump]; - } - - private function readPrefixInteractive(string $name, string $defaultPrefix): string - { - $isInteractive = function (): bool { - if (function_exists('stream_isatty')) { - return @stream_isatty(STDIN); - } - if (function_exists('posix_isatty')) { - return @posix_isatty(STDIN); - } - return false; - }; - if ($isInteractive()) { - fwrite(STDOUT, "Enter URL prefix for module '$name' [{$defaultPrefix}]: "); - $input = fgets(STDIN); - $input = $input === false ? '' : trim((string) $input); - return $input === '' ? $defaultPrefix : $input; - } - return $defaultPrefix; - } - - private function createScaffold(string $moduleRoot, Output $output): bool - { - $dirs = [ - 'Controllers', - 'Views', - 'Templates', - 'Services', - 'Models', - 'Repositories', - 'Database/Migrations', - 'Routes', - 'Providers', - 'Tests', - ]; - // Ensure module root exists first - if (!is_dir($moduleRoot) && !mkdir($moduleRoot, 0777, true) && !is_dir($moduleRoot)) { - $output->writeln('Failed to create directory: ' . $moduleRoot); - return false; - } - foreach ($dirs as $dir) { - $path = $moduleRoot . '/' . $dir; - if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) { - $output->writeln('Failed to create directory: ' . $path); - return false; - } - } - return true; - } - - private function createPersistenceDir(string $moduleRoot, Output $output): void - { - $driver = getenv('ORM_DRIVER') ?: null; - if (!$driver) { - return; - } - $driverName = ucfirst(strtolower($driver)); - $path = $moduleRoot . '/Persistence/' . $driverName; - if (!is_dir($path) && !mkdir($path, 0777, true) && !is_dir($path)) { - $output->writeln('Failed to create directory: ' . $path); - } - } - - private function writeProviderStub(string $moduleRoot, string $name): void - { - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $providerClass = $name . 'ServiceProvider'; - $providerNs = "{$baseNamespace}\\{$name}\\Providers"; - $stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub'); - $providerCode = strtr($stub, [ - '{{namespace}}' => $providerNs, - '{{class}}' => $providerClass, - '{{name}}' => $name, - ]); - file_put_contents($moduleRoot . '/Providers/' . $providerClass . '.php', $providerCode); - } - - private function writeRoutesStubs(string $moduleRoot, string $name): void - { - file_put_contents($moduleRoot . '/Routes/web.php', " $viewNs, - ]); - file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode); - - $ctrlNs = "{$baseNamespace}\\{$name}\\Controllers"; - $ctrlUsesViewNs = "{$baseNamespace}\\{$name}\\Views\\HomeView"; - $ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub'); - $ctrlCode = strtr($ctrlStub, [ - '{{namespace}}' => $ctrlNs, - '{{viewNamespace}}' => $ctrlUsesViewNs, - '{{moduleName}}' => $name, - ]); - file_put_contents($moduleRoot . '/Controllers/HomeController.php', $ctrlCode); - - file_put_contents($moduleRoot . '/Templates/home.eyrie.php', "

\n"); - } - - private function writeControllersIndexIfMissing(string $moduleRoot): void - { - $controllersDir = $moduleRoot . '/Controllers'; - if (!is_dir($controllersDir)) { - @mkdir($controllersDir, 0777, true); - } - $index = $controllersDir . '/.gitkeep'; - if (!is_file($index)) { - @file_put_contents($index, ""); - } - } - - private function registerProviderInConfig(string $root, string $name): void - { - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $providersFile = $root . '/config/providers.php'; - if (!is_file($providersFile)) { - return; - } - $contents = file_get_contents($providersFile) ?: ''; - $providerClass = $name . 'ServiceProvider'; - // Validate module name to avoid accidental injection from CLI flags - if (!preg_match('/^[A-Za-z][A-Za-z0-9_]*$/', $name)) { - return; - } - // Only register if module path exists - if (!is_dir($root . '/modules/' . $name)) { - return; - } - $fqcn = "{$baseNamespace}\\{$name}\\Providers\\$providerClass::class"; - if (strpos($contents, $fqcn) !== false) { - return; - } - $updated = preg_replace( - '/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/', - "$1$2\n " . str_replace('\\', '\\\\', $baseNamespace) . "\\\\$name\\\\Providers\\\\$providerClass::class,\n $3", - $contents, - 1 - ); - if ($updated) { - file_put_contents($providersFile, $updated); - } - } - - private function appendRouteInclude(string $root, string $name, string $prefix): void - { - $routesRoot = $root . '/routes'; - $webRootFile = $routesRoot . '/web.php'; - if (!is_dir($routesRoot)) { - mkdir($routesRoot, 0777, true); - } - if (!is_file($webRootFile)) { - file_put_contents($webRootFile, "writeln("\nModule '$name' created in modules/$name."); - $output->writeln('Remember to add PSR-4 autoload mapping in composer.json:'); - $output->writeln(' "' . str_replace('\\', '\\\\', $baseNamespace) . '\\\\' . $name . '\\\\": "modules/' . $name . '/"'); - $output->writeln("\nModule routes are mounted at prefix '$prefix' in routes/web.php."); - } - - private function updateComposerPsr4(Output $output, string $root, string $name, bool $dumpAutoload): void - { - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $composer = $root . '/composer.json'; - if (!is_file($composer)) { - $output->writeln('composer.json not found; cannot update PSR-4 mapping.'); - return; - } - $json = file_get_contents($composer); - $data = $json ? json_decode($json, true) : null; - if (!is_array($data)) { - $output->writeln('composer.json parse error; aborting PSR-4 update.'); - return; - } - $bak = $composer . '.bak'; - @copy($composer, $bak); - $psr4 = $data['autoload']['psr-4'] ?? []; - $ns = "{$baseNamespace}\\{$name}\\"; - $path = 'modules/' . $name . '/'; - $changed = false; - if (!isset($psr4[$ns])) { - $psr4[$ns] = $path; - $data['autoload']['psr-4'] = $psr4; - $changed = true; - } - if ($changed) { - $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; - if (file_put_contents($composer, $encoded) === false) { - $output->writeln('Failed to write composer.json; original saved to composer.json.bak'); - } else { - $output->writeln('Updated composer.json with PSR-4 mapping for ' . $ns . '.'); - } - } else { - $output->writeln('PSR-4 mapping already exists in composer.json.'); - } - if ($dumpAutoload) { - $out = shell_exec('composer dump-autoload 2>&1') ?: ''; - $output->writeln(trim($out)); - } else { - $output->writeln('Skipped composer dump-autoload (use --no-dump).'); - } - } -}; diff --git a/src/commands/create_seed.php b/src/commands/create_seed.php deleted file mode 100644 index 3c78c97..0000000 --- a/src/commands/create_seed.php +++ /dev/null @@ -1,81 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Seeder name (e.g., PostSeeder)', - ], - 'module' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Target module name (e.g., Shop). Optional if using create::seed', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = null; - if (preg_match('/^create:([^:]+):seed$/', $this->getName(), $matches)) { - $module = $matches[1]; - } - - if (!$module) { - $module = $input->hasArgument('module') ? $input->getArgument('module') : null; - } - - $module = trim((string) $module); - $name = trim((string) $input->getArgument('name')); - - if ($module === '' || $name === '') { - $output->writeln('Module and Name are required.'); - return 1; - } - - // Case-insensitive module directory lookup - $modulesDir = getcwd() . '/modules'; - $moduleDir = null; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $dir) { - if (strtolower($dir) === strtolower($module)) { - $moduleDir = $modulesDir . '/' . $dir; - $module = $dir; // Use actual casing - break; - } - } - } - - if (!$moduleDir || !is_dir($moduleDir)) { - $output->writeln("Module '$module' does not exist."); - return 1; - } - - $seedsDir = $moduleDir . '/Database/Seeds'; - if (!is_dir($seedsDir)) { - @mkdir($seedsDir, 0777, true); - } - - $filename = $name . '.php'; - $path = $seedsDir . '/' . $filename; - - if (file_exists($path)) { - $output->writeln("Seeder '$name' already exists in module '$module'."); - return 1; - } - - $template = file_get_contents(dirname(__DIR__) . '/stubs/seed.stub'); - - file_put_contents($path, $template); - $output->writeln("Seeder created at modules/$module/Database/Seeds/$filename"); - - return 0; - } -}; diff --git a/src/commands/create_test.php b/src/commands/create_test.php deleted file mode 100644 index 38e8d57..0000000 --- a/src/commands/create_test.php +++ /dev/null @@ -1,87 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Test name (e.g., PostTest)', - ], - 'module' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Target module name (e.g., Shop). Optional if using create::test', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = null; - if (preg_match('/^create:([^:]+):test$/', $this->getName(), $matches)) { - $module = $matches[1]; - } - - if (!$module) { - $module = $input->hasArgument('module') ? $input->getArgument('module') : null; - } - - $module = trim((string) $module); - $name = trim((string) $input->getArgument('name')); - - if ($module === '' || $name === '') { - $output->writeln('Module and Name are required.'); - return 1; - } - - // Case-insensitive module directory lookup - $modulesDir = getcwd() . '/modules'; - $moduleDir = null; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $dir) { - if (strtolower($dir) === strtolower($module)) { - $moduleDir = $modulesDir . '/' . $dir; - $module = $dir; // Use actual casing - break; - } - } - } - - if (!$moduleDir || !is_dir($moduleDir)) { - $output->writeln("Module '$module' does not exist."); - return 1; - } - - $testsDir = $moduleDir . '/Tests'; - if (!is_dir($testsDir)) { - @mkdir($testsDir, 0777, true); - } - - $filename = $name . '.php'; - $path = $testsDir . '/' . $filename; - - if (file_exists($path)) { - $output->writeln("Test '$name' already exists in module '$module'."); - return 1; - } - - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $namespace = "{$baseNamespace}\\$module\\Tests"; - $stub = file_get_contents(dirname(__DIR__) . '/stubs/test.stub'); - $template = strtr($stub, [ - '{{namespace}}' => $namespace, - '{{class}}' => $name, - ]); - - file_put_contents($path, $template); - $output->writeln("Test created at modules/$module/Tests/$filename"); - - return 0; - } -}; diff --git a/src/commands/create_view.php b/src/commands/create_view.php deleted file mode 100644 index 6366171..0000000 --- a/src/commands/create_view.php +++ /dev/null @@ -1,111 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'View name (e.g., PostView)', - ], - 'module' => [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Target module name (e.g., Shop). Optional if using create::view', - ], - '--template' => [ - 'mode' => 'option', - 'valueRequired' => true, - 'description' => 'Optional template name. Defaults to snake_case of View name minus "View".', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = null; - if (preg_match('/^create:([^:]+):view$/', $this->getName(), $matches)) { - $module = $matches[1]; - } - - if (!$module) { - $module = $input->hasArgument('module') ? $input->getArgument('module') : null; - } - - $module = trim((string) $module); - $name = trim((string) $input->getArgument('name')); - $templateName = $input->getOption('template') ? trim((string) $input->getOption('template')) : null; - - if ($module === '' || $name === '') { - $output->writeln('Module and Name are required.'); - return 1; - } - - // Case-insensitive module directory lookup - $modulesDir = getcwd() . '/modules'; - $moduleDir = null; - if (is_dir($modulesDir)) { - foreach (scandir($modulesDir) as $dir) { - if (strtolower($dir) === strtolower($module)) { - $moduleDir = $modulesDir . '/' . $dir; - $module = $dir; // Use actual casing - break; - } - } - } - - if (!$moduleDir || !is_dir($moduleDir)) { - $output->writeln("Module '$module' does not exist."); - return 1; - } - - $viewsDir = $moduleDir . '/Views'; - $templatesDir = $moduleDir . '/Templates'; - - if (!is_dir($viewsDir)) { @mkdir($viewsDir, 0777, true); } - if (!is_dir($templatesDir)) { @mkdir($templatesDir, 0777, true); } - - $viewPath = $viewsDir . '/' . $name . '.php'; - if (file_exists($viewPath)) { - $output->writeln("View '$name' already exists in module '$module'."); - return 1; - } - - if (!$templateName) { - $stem = $name; - if (str_ends_with(strtolower($name), 'view')) { - $stem = substr($name, 0, -4); - } - $templateName = strtolower(preg_replace('/(? $namespace, - '{{class}}' => $name, - '{{template}}' => $templateName, - ]); - - file_put_contents($viewPath, $viewTemplate); - $output->writeln("View '$name' created at modules/$module/Views/$name.php"); - - if (!file_exists($templatePath)) { - file_put_contents($templatePath, "\n

$name

\n"); - $output->writeln("Template '$templateFile' created at modules/$module/Templates/$templateFile"); - } else { - $output->writeln("Template '$templateFile' already exists, skipping creation."); - } - - return 0; - } -}; diff --git a/src/commands/db_backup.php b/src/commands/db_backup.php deleted file mode 100644 index 8aa98ca..0000000 --- a/src/commands/db_backup.php +++ /dev/null @@ -1,35 +0,0 @@ - [ - 'mode' => 'option', - 'valueRequired' => true, - 'description' => 'Optional path to save the backup.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $path = $input->getOption('path') ?: 'storage/db_backup_' . date('Ymd_His') . '.sql'; - - // This is a placeholder for actual DB backup logic. - // It depends on the ORM driver and database type. - // For now, we simulate success and create an empty file. - - if (!is_dir(dirname($path))) { - @mkdir(dirname($path), 0777, true); - } - @file_put_contents($path, "-- Phred DB Backup Placeholder\n"); - - $output->writeln("Database backup successful: $path"); - return 0; - } -}; diff --git a/src/commands/db_restore.php b/src/commands/db_restore.php deleted file mode 100644 index eb2ff93..0000000 --- a/src/commands/db_restore.php +++ /dev/null @@ -1,32 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'Path to the backup file.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $path = $input->getArgument('path'); - - if (!file_exists($path)) { - $output->writeln("Backup file not found: $path"); - return 1; - } - - // This is a placeholder for actual DB restore logic. - $output->writeln("Database restore successful from: $path"); - return 0; - } -}; diff --git a/src/commands/docs.php b/src/commands/docs.php deleted file mode 100644 index d0c04d0..0000000 --- a/src/commands/docs.php +++ /dev/null @@ -1,41 +0,0 @@ - 'topic', 'mode' => InputArgument::OPTIONAL, 'description' => 'Command or topic to document'], - ]; - - public function handle(Input $input, Output $output): int - { - $topic = $input->getArgument('topic'); - $baseUrl = 'https://getphred.com/docs'; // Placeholder for production - - if (empty($topic)) { - $url = $baseUrl; - } else { - // Simple mapping for demo - $url = $baseUrl . '/' . str_replace(':', '-', $topic); - } - - $output->writeln("Opening documentation for '$topic' at: $url"); - - // Determine OS and open browser - if (PHP_OS_FAMILY === 'Darwin') { - exec("open " . escapeshellarg($url)); - } elseif (PHP_OS_FAMILY === 'Linux') { - exec("xdg-open " . escapeshellarg($url) . " > /dev/null 2>&1"); - } elseif (PHP_OS_FAMILY === 'Windows') { - exec("start " . escapeshellarg($url)); - } - - return 0; - } -}; diff --git a/src/commands/generate_openapi.php b/src/commands/generate_openapi.php deleted file mode 100644 index a8beaef..0000000 --- a/src/commands/generate_openapi.php +++ /dev/null @@ -1,60 +0,0 @@ - [ - 'mode' => 'option', - 'shortcut' => 'o', - 'valueRequired' => true, - 'description' => 'Path to save the generated JSON file.', - 'default' => 'public/openapi.json', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $outputPath = $input->getOption('output'); - $scanPaths = [ - getcwd() . '/src', - getcwd() . '/modules', - ]; - - // Filter scan paths to only existing directories - $existingPaths = array_filter($scanPaths, 'is_dir'); - - if (empty($existingPaths)) { - $output->writeln('No source or module directories found to scan.'); - return 1; - } - - $output->writeln('Scanning for OpenAPI annotations...'); - - try { - $openapi = Generator::scan($existingPaths); - $json = $openapi->toJson(); - - $fullOutputPath = getcwd() . '/' . ltrim($outputPath, '/'); - $dir = dirname($fullOutputPath); - - if (!is_dir($dir)) { - mkdir($dir, 0777, true); - } - - file_put_contents($fullOutputPath, $json); - - $output->writeln("OpenAPI documentation generated at: $outputPath"); - return 0; - } catch (\Exception $e) { - $output->writeln('OpenAPI Generation failed: ' . $e->getMessage() . ''); - return 1; - } - } -}; diff --git a/src/commands/install.php b/src/commands/install.php deleted file mode 100644 index fc6b327..0000000 --- a/src/commands/install.php +++ /dev/null @@ -1,130 +0,0 @@ - ['mode' => 'flag', 'description' => 'Overwrite existing files when scaffolding.'], - ]; - - public function handle(Input $input, Output $output): int - { - $force = (bool) $input->getOption('force'); - - // Define placeholders to keep static analyzers from flagging variables used inside template strings - /** @var mixed $app */ $app = null; - /** @var mixed $router */ $router = null; - - $root = getcwd(); - $dirs = [ - 'public', - 'bootstrap', - 'config', - 'routes', - 'modules', - 'resources', - 'storage', - 'storage/logs', - 'storage/cache', - 'storage/sessions', - 'storage/views', - 'storage/uploads', - 'tests', - 'console', - 'console/commands', - ]; - foreach ($dirs as $d) { - $path = $root . DIRECTORY_SEPARATOR . $d; - if (!is_dir($path)) { - @mkdir($path, 0777, true); - $output->writeln("created $d/"); - } - } - - // .gitkeep for empty directories commonly empty - foreach (['modules', 'resources', 'storage/logs', 'storage/cache', 'storage/sessions', 'storage/views', 'storage/uploads', 'console/commands'] as $maybeEmpty) { - $file = $root . DIRECTORY_SEPARATOR . $maybeEmpty . DIRECTORY_SEPARATOR . '.gitkeep'; - if (!file_exists($file)) { - @file_put_contents($file, ""); - } - } - - // Files to scaffold - $stubDir = dirname(__DIR__) . '/stubs/install'; - $files = [ - 'public/index.php' => file_get_contents($stubDir . '/public_index.stub'), - 'bootstrap/app.php' => file_get_contents($stubDir . '/bootstrap_app.stub'), - 'config/app.php' => file_get_contents($stubDir . '/config_app.stub'), - 'routes/web.php' => file_get_contents($stubDir . '/routes_web.stub'), - 'routes/api.php' => file_get_contents($stubDir . '/routes_api.stub'), - '.env.example' => file_get_contents($stubDir . '/env_example.stub'), - ]; - - foreach ($files as $relative => $contents) { - $path = $root . DIRECTORY_SEPARATOR . $relative; - if (!file_exists($path) || $force) { - if (!is_dir(dirname($path))) { - @mkdir(dirname($path), 0777, true); - } - @file_put_contents($path, rtrim($contents) . "\n"); - $output->writeln("wrote $relative"); - } - } - - // Copy .env if missing - if (!file_exists($root . '/.env') && file_exists($root . '/.env.example')) { - @copy($root . '/.env.example', $root . '/.env'); - $output->writeln('created .env'); - } - - // Root phred launcher (Unix) - $launcher = $root . '/phred'; - if (!file_exists($launcher) || $force) { - $shim = "#!/usr/bin/env php\nwriteln('wrote phred'); - } - - // Windows launcher - $launcherWin = $root . '/phred.bat'; - if (!file_exists($launcherWin) || $force) { - $shimWin = "@ECHO OFF\r\nphp \"%~dp0bin\\phred\" %*\r\n"; - @file_put_contents($launcherWin, $shimWin); - $output->writeln('wrote phred.bat'); - } - - // Ensure .gitignore has sensible defaults - $gitignore = $root . '/.gitignore'; - $giLines = [ - "/vendor/", - "/.env", - "/storage/*", - "!/storage/.gitkeep", - "/.phpunit.cache", - "/.php-cs-fixer.cache", - ]; - $existing = file_exists($gitignore) ? file($gitignore, FILE_IGNORE_NEW_LINES) : []; - $set = $existing ? array_flip($existing) : []; - $changed = false; - foreach ($giLines as $line) { - if (!isset($set[$line])) { - $existing[] = $line; - $changed = true; - } - } - if ($changed) { - @file_put_contents($gitignore, implode("\n", $existing) . "\n"); - $output->writeln('updated .gitignore'); - } - - $output->writeln("\nPhred scaffold complete."); - $output->writeln("Try: ./phred or php bin/phred"); - return 0; - } -}; diff --git a/src/commands/migrate.php b/src/commands/migrate.php deleted file mode 100644 index b987e13..0000000 --- a/src/commands/migrate.php +++ /dev/null @@ -1,25 +0,0 @@ -writeln('Running migrations...'); - - // In a real implementation, we would get the manager from the DI container. - // For now, we simulate integration with PairityConnection. - $connection = new \Phred\Orm\PairityConnection(); - $manager = $connection->getManager(); - $result = $manager->migrate(); - - $output->writeln($result); - $output->writeln('Migrations completed successfully.'); - - return 0; - } -}; diff --git a/src/commands/migration_rollback.php b/src/commands/migration_rollback.php deleted file mode 100644 index 17724c3..0000000 --- a/src/commands/migration_rollback.php +++ /dev/null @@ -1,23 +0,0 @@ -writeln('Rolling back migrations...'); - - $connection = new \Phred\Orm\PairityConnection(); - $manager = $connection->getManager(); - $result = $manager->rollback(); - - $output->writeln($result); - $output->writeln('Rollback completed successfully.'); - - return 0; - } -}; diff --git a/src/commands/module_list.php b/src/commands/module_list.php deleted file mode 100644 index a64df3c..0000000 --- a/src/commands/module_list.php +++ /dev/null @@ -1,70 +0,0 @@ -Manual' : - (($isManualProvider || $isManualRoute) ? 'Partial' : 'Discovered'); - - $modules[] = [ - $entry, - $status, - $isManualProvider ? 'Yes' : 'No', - $isManualRoute ? 'Yes' : 'No', - ]; - } - } - - if (empty($modules)) { - $output->writeln('No modules found.'); - return 0; - } - - $table = new Table($output); - $table->setHeaders(['Module', 'Status', 'Provider Reg.', 'Route Reg.']) - ->setRows($modules); - $table->render(); - - return 0; - } -}; diff --git a/src/commands/module_prune.php b/src/commands/module_prune.php deleted file mode 100644 index 8b682fc..0000000 --- a/src/commands/module_prune.php +++ /dev/null @@ -1,82 +0,0 @@ -prune_providers($providersFile, $modulesDir, $output); - $this->prune_routes($webFile, $modulesDir, $output); - - $output->writeln('Pruning complete.'); - return 0; - } - - private function prune_providers($providersFile, $modulesDir, Output $output): void - { - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $nsPattern = str_replace('\\', '\\\\', $baseNamespace); - - if (is_file($providersFile)) { - $content = file_get_contents($providersFile); - preg_match_all('/(' . $nsPattern . '\\\\([A-Za-z0-9_]+)\\\\Providers\\\\[A-Za-z0-9_]+::class)/', $content, $matches); - - $removedCount = 0; - if (!empty($matches[0])) { - foreach ($matches[0] as $index => $fullMatch) { - $moduleName = $matches[2][$index]; - if (!is_dir($modulesDir . '/' . $moduleName)) { - $content = str_replace([ - "\n " . $fullMatch . ",", - " " . $fullMatch . ",", - $fullMatch . ",", - ], '', $content); - $output->writeln("Pruned provider entry for missing module: $moduleName"); - $removedCount++; - } - } - } - if ($removedCount > 0) { - file_put_contents($providersFile, $content); - } - } - } - - public function prune_routes($webFile, $modulesDir, Output $output): void - { - if (is_file($webFile)) { - $content = file_get_contents($webFile); - - // Match the entire block generated by create:module - $pattern = '/\n\/\/ Module \'([A-Za-z0-9_]+)\' mounted at \'.*\' \(only if module routes file exists\)\nif \(is_file\(__DIR__ \. \'\/..\/modules\/([A-Za-z0-9_]+)\/Routes\/web.php\'\)\) \{[\s\S]*?\}\n/'; - - preg_match_all($pattern, $content, $matches); - - $removedCount = 0; - if (!empty($matches[0])) { - foreach ($matches[0] as $index => $fullBlock) { - $moduleName = $matches[2][$index]; - if (!is_dir($modulesDir . '/' . $moduleName)) { - $content = str_replace($fullBlock, '', $content); - $output->writeln("Pruned route entry for missing module: $moduleName"); - $removedCount++; - } - } - } - if ($removedCount > 0) { - file_put_contents($webFile, $content); - } - } - } -}; diff --git a/src/commands/module_register.php b/src/commands/module_register.php deleted file mode 100644 index d5069d3..0000000 --- a/src/commands/module_register.php +++ /dev/null @@ -1,181 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Specific module name to register. If omitted, registers all discovered but unregistered modules.', - ], - '--prefix' => [ - 'mode' => 'option', - 'valueRequired' => true, - 'description' => 'Custom URL prefix for the module (if registering a single module).', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $targetName = $input->getArgument('name'); - $root = getcwd(); - $modulesDir = $root . '/modules'; - - if (!is_dir($modulesDir)) { - $output->writeln('No modules directory found.'); - return 0; - } - - $discovered = $this->discoverModules($modulesDir); - $registeredProviders = $this->getRegisteredProviders($root); - - $toRegister = []; - if ($targetName) { - $targetName = trim((string)$targetName); - if (!isset($discovered[$targetName])) { - $output->writeln("Module '$targetName' not found in modules/ directory."); - return 1; - } - $toRegister[$targetName] = $discovered[$targetName]; - } else { - foreach ($discovered as $name => $providers) { - // Check if any of the module's providers are NOT in the config - $unregistered = false; - foreach ($providers as $p) { - if (!in_array($p, $registeredProviders, true)) { - $unregistered = true; - break; - } - } - - // Also check if routes are not explicitly included (simplistic check) - if (!$this->isRoutesRegistered($root, $name)) { - $unregistered = true; - } - - if ($unregistered) { - $toRegister[$name] = $providers; - } - } - } - - if (empty($toRegister)) { - $output->writeln('No new modules found needing manual registration.'); - return 0; - } - - foreach ($toRegister as $name => $providers) { - $output->writeln("Registering module: $name"); - - // Register Providers - foreach ($providers as $provider) { - if (!in_array($provider, $registeredProviders, true)) { - $this->registerProviderInConfig($root, $name, $provider, $output); - } - } - - // Register Routes - if (!$this->isRoutesRegistered($root, $name)) { - $prefix = $input->getOption('prefix') ?: '/' . strtolower($name); - $this->appendRouteInclude($root, $name, (string)$prefix, $output); - } - } - - $output->writeln('Registration complete.'); - return 0; - } - - private function discoverModules(string $modulesDir): array - { - $baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules'); - $modules = []; - foreach (scandir($modulesDir) ?: [] as $entry) { - if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { - continue; - } - - $providers = []; - $providersPath = $modulesDir . '/' . $entry . '/Providers'; - if (is_dir($providersPath)) { - foreach (scandir($providersPath) ?: [] as $file) { - if (str_ends_with($file, 'ServiceProvider.php')) { - $classBase = substr($file, 0, -4); - $providers[] = "{$baseNamespace}\\{$entry}\\Providers\\{$classBase}"; - } - } - } - $modules[$entry] = $providers; - } - return $modules; - } - - private function getRegisteredProviders(string $root): array - { - $providersFile = $root . '/config/providers.php'; - if (!is_file($providersFile)) { - return []; - } - $arr = require $providersFile; - return array_merge( - (array)($arr['core'] ?? []), - (array)($arr['app'] ?? []), - (array)($arr['modules'] ?? []) - ); - } - - private function isRoutesRegistered(string $root, string $name): bool - { - $webFile = $root . '/routes/web.php'; - if (!is_file($webFile)) return false; - $content = file_get_contents($webFile); - return str_contains($content, "modules/$name/Routes/web.php"); - } - - private function registerProviderInConfig(string $root, string $name, string $fqcn, Output $output): void - { - $providersFile = $root . '/config/providers.php'; - if (!is_file($providersFile)) return; - - $contents = file_get_contents($providersFile) ?: ''; - if (strpos($contents, $fqcn) !== false) return; - - $updated = preg_replace( - '/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/', - "$1$2\n " . str_replace('\\', '\\\\', $fqcn) . "::class,\n $3", - $contents, - 1 - ); - - if ($updated) { - file_put_contents($providersFile, $updated); - $output->writeln(" - Provider registered: $fqcn"); - } - } - - private function appendRouteInclude(string $root, string $name, string $prefix, Output $output): void - { - $webRootFile = $root . '/routes/web.php'; - $dollar = '$'; - $prefix = '/' . ltrim($prefix, '/'); - - $includeSnippet = "\n" . - "// Module '$name' mounted at '$prefix' (only if module routes file exists)\n" . - "if (is_file(__DIR__ . '/../modules/$name/Routes/web.php')) {\n" . - " \\Phred\\Http\\Routing\\RouteGroups::include(" . $dollar . "router, '$prefix', function (\\Phred\\Http\\Router " . $dollar . "router) {\n" . - " /** @noinspection PhpIncludeInspection */\n" . - " (static function (" . $dollar . "router) { require __DIR__ . '/../modules/$name/Routes/web.php'; })(" . $dollar . "router);\n" . - " }, __DIR__ . '/../modules/$name/Routes/web.php');\n" . - "}\n"; - - file_put_contents($webRootFile, file_get_contents($webRootFile) . $includeSnippet); - $output->writeln(" - Routes registered at: $prefix"); - } -}; diff --git a/src/commands/module_sync_ns.php b/src/commands/module_sync_ns.php deleted file mode 100644 index 52e8fe2..0000000 --- a/src/commands/module_sync_ns.php +++ /dev/null @@ -1,79 +0,0 @@ -writeln('composer.json not found.'); - return 1; - } - - $json = file_get_contents($composerFile); - $data = json_decode((string)$json, true); - if (!is_array($data)) { - $output->writeln('composer.json parse error.'); - return 1; - } - - $modulesDir = $root . '/modules'; - if (!is_dir($modulesDir)) { - $output->writeln('No modules directory found.'); - return 0; - } - - $psr4 = $data['autoload']['psr-4'] ?? []; - $changed = false; - - // Validation: Check for dead entries - foreach ($psr4 as $ns => $path) { - // Check if directory exists - if (!is_dir($root . '/' . rtrim($path, '/'))) { - $output->writeln("Warning: PSR-4 entry '$ns' points to non-existent directory: $path"); - } - } - - foreach (scandir($modulesDir) ?: [] as $entry) { - if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) { - continue; - } - - // Remove any existing PSR-4 mapping for this module that doesn't match the current baseNamespace - foreach ($psr4 as $ns => $path) { - if ($path === "modules/$entry/" && !str_starts_with($ns, $baseNamespace . '\\')) { - unset($psr4[$ns]); - $changed = true; - } - } - - $newNs = "{$baseNamespace}\\{$entry}\\"; - if (!isset($psr4[$newNs])) { - $psr4[$newNs] = "modules/$entry/"; - $changed = true; - } - } - - if ($changed) { - $data['autoload']['psr-4'] = $psr4; - $encoded = json_encode($data, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; - file_put_contents($composerFile, $encoded); - $output->writeln('composer.json PSR-4 mappings updated.'); - $output->writeln('Run composer dump-autoload to apply changes.'); - } else { - $output->writeln('PSR-4 mappings are already in sync.'); - } - - return 0; - } -}; diff --git a/src/commands/register_orm.php b/src/commands/register_orm.php deleted file mode 100644 index 39c1a19..0000000 --- a/src/commands/register_orm.php +++ /dev/null @@ -1,47 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => true, - 'description' => 'The ORM driver name (e.g., eloquent, doctrine)', - ], - ]; - public function handle(Input $input, Output $output): int - { - $driver = $input->getArgument('driver'); - $output->writeln("Registering ORM driver: {$driver}"); - // 1. Update .env (mocking for now, as .env might not exist in all environments) - $envPath = getcwd() . '/.env'; - if (file_exists($envPath)) { - $content = file_get_contents($envPath); - if (str_contains($content, 'ORM_DRIVER=')) { - $content = preg_replace('/ORM_DRIVER=.*/', "ORM_DRIVER={$driver}", $content); - } else { - $content .= "\nORM_DRIVER={$driver}\n"; - } - file_put_contents($envPath, $content); - $output->writeln("Updated .env: ORM_DRIVER={$driver}"); - } - // 2. Create modules/*/Persistence// directories - $modulesDir = getcwd() . '/modules'; - if (is_dir($modulesDir)) { - $dirs = glob($modulesDir . '/*', GLOB_ONLYDIR); - foreach ($dirs as $moduleDir) { - $persistenceDir = $moduleDir . '/Persistence/' . ucfirst($driver); - if (!is_dir($persistenceDir)) { - mkdir($persistenceDir, 0755, true); - $output->writeln("Created: {$persistenceDir}"); - } - } - } - $output->writeln("ORM driver {$driver} registered successfully."); - return 0; - } -}; diff --git a/src/commands/route_cache.php b/src/commands/route_cache.php deleted file mode 100644 index 43d0ae4..0000000 --- a/src/commands/route_cache.php +++ /dev/null @@ -1,48 +0,0 @@ -getRouteCollector(); - $registrar($collector); - - $data = $collector->getData(); - $path = getcwd() . '/storage/cache/routes.php'; - - if (!is_dir(dirname($path))) { - mkdir(dirname($path), 0777, true); - } - - $serializable = $this->makeSerializable($data); - - $content = "writeln("Routes cached successfully at $path"); - - return 0; - } - - private function makeSerializable(array $data): array - { - array_walk_recursive($data, function (&$item) { - if ($item instanceof Closure) { - $item = serialize(new \Laravel\SerializableClosure\SerializableClosure($item)); - } - }); - return $data; - } -}; diff --git a/src/commands/route_clear.php b/src/commands/route_clear.php deleted file mode 100644 index 477d2cc..0000000 --- a/src/commands/route_clear.php +++ /dev/null @@ -1,25 +0,0 @@ -writeln("Route cache cleared."); - } else { - $output->writeln("No route cache found."); - } - - return 0; - } -}; diff --git a/src/commands/route_list.php b/src/commands/route_list.php deleted file mode 100644 index e21075d..0000000 --- a/src/commands/route_list.php +++ /dev/null @@ -1,95 +0,0 @@ -getRouteCollector(); - $registrar($collector); - - $table = new Table($output); - $table->setHeaders(['Method', 'URI', 'Handler', 'Middleware', 'Name']); - - $rows = []; - foreach ($collector->getData() as $methodRoutes) { - foreach ($methodRoutes as $method => $routes) { - if ($method === 'GET' || $method === 'POST' || $method === 'PUT' || $method === 'PATCH' || $method === 'DELETE') { - // FastRoute data structure is a bit complex for static vs dynamic routes - } - } - } - - // FastRoute data structure: [ [static_routes], [regex_routes] ] - [$static, $regex] = $collector->getData(); - - foreach ($static as $method => $routes) { - foreach ($routes as $uri => $spec) { - $rows[] = $this->formatRow($method, $uri, $spec); - } - } - - foreach ($regex as $method => $routes) { - foreach ($routes as $group) { - foreach ($group['routeMap'] as $uriRegex => $spec) { - // Extracting original URI from regex is hard, but we can at least show the regex or the spec - // FastRoute doesn't store the original URI in the data generator by default - // Let's use a custom collector if we want perfect URIs, or just accept what we have. - $rows[] = $this->formatRow($method, $uriRegex, $spec); - } - } - } - - usort($rows, fn($a, $b) => strcmp($a[1], $b[1])); - - $table->setRows($rows); - $table->render(); - - return 0; - } - - private function formatRow(string $method, string $uri, mixed $spec): array - { - $handler = 'Unknown'; - $middleware = ''; - $name = ''; - - if (is_array($spec)) { - $h = $spec['handler'] ?? $spec; - $handler = $this->formatHandler($h); - $middleware = implode(', ', (array)($spec['middleware'] ?? [])); - $name = $spec['name'] ?? ''; - } else { - $handler = $this->formatHandler($spec); - } - - return [$method, $uri, $handler, $middleware, $name]; - } - - private function formatHandler(mixed $handler): string - { - if (is_string($handler)) { - return $handler; - } - if (is_array($handler)) { - $class = is_object($handler[0]) ? get_class($handler[0]) : $handler[0]; - return $class . '@' . ($handler[1] ?? '__invoke'); - } - if ($handler instanceof Closure) { - return 'Closure'; - } - return 'Unknown'; - } -}; diff --git a/src/commands/run.php b/src/commands/run.php deleted file mode 100644 index 9ecefaa..0000000 --- a/src/commands/run.php +++ /dev/null @@ -1,45 +0,0 @@ - [ - 'mode' => 'option', - 'valueRequired' => true, - 'default' => 'localhost', - 'description' => 'The host address to serve the application on.', - ], - '--port' => [ - 'mode' => 'option', - 'valueRequired' => true, - 'default' => '8000', - 'description' => 'The port address to serve the application on.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $host = $input->getOption('host'); - $port = $input->getOption('port'); - $publicDir = getcwd() . '/public'; - - $output->writeln("Phred development server started: http://$host:$port"); - - $command = sprintf( - 'PHP_CLI_SERVER_WORKERS=4 php -S %s:%s -t %s', - $host, - $port, - escapeshellarg($publicDir) - ); - - passthru($command, $exitCode); - - return $exitCode; - } -}; diff --git a/src/commands/seed.php b/src/commands/seed.php deleted file mode 100644 index 4116aa0..0000000 --- a/src/commands/seed.php +++ /dev/null @@ -1,23 +0,0 @@ -writeln('Seeding database...'); - - $connection = new \Phred\Orm\PairityConnection(); - $manager = $connection->getManager(); - $result = $manager->seed(); - - $output->writeln($result); - $output->writeln('Seeding completed successfully.'); - - return 0; - } -}; diff --git a/src/commands/seed_rollback.php b/src/commands/seed_rollback.php deleted file mode 100644 index 12c3506..0000000 --- a/src/commands/seed_rollback.php +++ /dev/null @@ -1,23 +0,0 @@ -writeln('Rolling back seeds...'); - - $connection = new \Phred\Orm\PairityConnection(); - $manager = $connection->getManager(); - $result = $manager->seedRollback(); - - $output->writeln($result); - $output->writeln('Seed rollback completed successfully.'); - - return 0; - } -}; diff --git a/src/commands/test.php b/src/commands/test.php deleted file mode 100644 index 0b93d1c..0000000 --- a/src/commands/test.php +++ /dev/null @@ -1,38 +0,0 @@ - [ - 'mode' => 'argument', - 'required' => false, - 'description' => 'Optional module name to run tests for.', - ], - ]; - - public function handle(Input $input, Output $output): int - { - $module = $input->getArgument('module'); - - $command = 'vendor/bin/phpunit'; - if ($module) { - $path = 'modules/' . $module . '/Tests'; - if (!is_dir($path)) { - $output->writeln("No tests found for module '$module' at $path."); - return 1; - } - $command .= ' ' . $path; - } - - $output->writeln("Running tests: $command"); - passthru($command, $exitCode); - - return $exitCode; - } -}; diff --git a/src/stubs/command.stub b/src/stubs/command.stub deleted file mode 100644 index 6bfb010..0000000 --- a/src/stubs/command.stub +++ /dev/null @@ -1,23 +0,0 @@ - ['mode' => 'argument', 'required' => true, 'description' => '...'], - // '--force' => ['mode' => 'flag', 'description' => '...'], - ]; - - public function handle(Input $input, Output $output): int - { - $output->writeln('{{command}} works!'); - return 0; - } -}; diff --git a/src/stubs/controller.stub b/src/stubs/controller.stub deleted file mode 100644 index c759481..0000000 --- a/src/stubs/controller.stub +++ /dev/null @@ -1,21 +0,0 @@ -safeLoad(); -} - -// TODO: Build and return an application kernel/closure -return static function () { - return null; // placeholder -}; diff --git a/src/stubs/install/config_app.stub b/src/stubs/install/config_app.stub deleted file mode 100644 index a1fa8fb..0000000 --- a/src/stubs/install/config_app.stub +++ /dev/null @@ -1,9 +0,0 @@ - getenv('APP_NAME') ?: 'Phred App', - 'env' => getenv('APP_ENV') ?: 'local', - 'debug' => (bool) (getenv('APP_DEBUG') ?: true), - 'timezone' => getenv('APP_TIMEZONE') ?: 'UTC', -]; diff --git a/src/stubs/install/env_example.stub b/src/stubs/install/env_example.stub deleted file mode 100644 index e0cd5ab..0000000 --- a/src/stubs/install/env_example.stub +++ /dev/null @@ -1,6 +0,0 @@ -APP_NAME=Phred App -APP_ENV=local -APP_DEBUG=true -APP_TIMEZONE=UTC - -API_FORMAT=rest diff --git a/src/stubs/install/public_index.stub b/src/stubs/install/public_index.stub deleted file mode 100644 index f055723..0000000 --- a/src/stubs/install/public_index.stub +++ /dev/null @@ -1,9 +0,0 @@ -get('/health', [HealthController::class, '__invoke']); diff --git a/src/stubs/install/routes_web.stub b/src/stubs/install/routes_web.stub deleted file mode 100644 index 375a933..0000000 --- a/src/stubs/install/routes_web.stub +++ /dev/null @@ -1,6 +0,0 @@ -get('/', [HomeController::class, '__invoke']); diff --git a/src/stubs/migration.stub b/src/stubs/migration.stub deleted file mode 100644 index c3a4e31..0000000 --- a/src/stubs/migration.stub +++ /dev/null @@ -1,14 +0,0 @@ -renderView($view, ['title' => '{{moduleName}}']); - } -} diff --git a/src/stubs/module/provider.stub b/src/stubs/module/provider.stub deleted file mode 100644 index d4fcf68..0000000 --- a/src/stubs/module/provider.stub +++ /dev/null @@ -1,32 +0,0 @@ -get('/{{name}}', static function () { - return (new \Nyholm\Psr7\Factory\Psr17Factory()) - ->createResponse(200) - ->withHeader('Content-Type', 'text/plain') - ->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('{{name}} module ready')); - }); - }); - } -} diff --git a/src/stubs/module/view.stub b/src/stubs/module/view.stub deleted file mode 100644 index bfcf494..0000000 --- a/src/stubs/module/view.stub +++ /dev/null @@ -1,11 +0,0 @@ -assertTrue(true); - } -} diff --git a/src/stubs/view.stub b/src/stubs/view.stub deleted file mode 100644 index 300770c..0000000 --- a/src/stubs/view.stub +++ /dev/null @@ -1,11 +0,0 @@ -userCmdPath = $consoleDir . '/_DummyDiscoveryCmd.php'; - $cmd = <<<'PHP' -writeln('ok'); return 0; } -}; -PHP; - @file_put_contents($this->userCmdPath, $cmd); - } - - protected function tearDown(): void - { - if (is_file($this->userCmdPath)) { - @unlink($this->userCmdPath); - } - } - - public function testUserCommandIsDiscovered(): void - { - $root = dirname(__DIR__); - $cmd = 'php ' . escapeshellarg($root . '/bin/phred') . ' list'; - $output = shell_exec($cmd) ?: ''; - $this->assertStringContainsString('dummy:discovery', $output, 'User command from console/commands should be discovered'); - } -} diff --git a/tests/ConfigTest.php b/tests/ConfigTest.php deleted file mode 100644 index d6e08e6..0000000 --- a/tests/ConfigTest.php +++ /dev/null @@ -1,40 +0,0 @@ - 'local' by default; ensure ENV wins - putenv('APP_ENV=production'); - $this->assertSame('production', Config::get('APP_ENV')); - $this->assertSame('production', Config::get('app.env')); - } - - public function testReadsFromConfigFileWhenEnvMissing(): void - { - // From config/app.php - $this->assertSame('UTC', Config::get('app.timezone')); - } - - public function testReturnsDefaultWhenNotFound(): void - { - $this->assertSame('fallback', Config::get('nonexistent.key', 'fallback')); - } -} diff --git a/tests/ContentNegotiationTest.php b/tests/ContentNegotiationTest.php deleted file mode 100644 index 79a5c76..0000000 --- a/tests/ContentNegotiationTest.php +++ /dev/null @@ -1,75 +0,0 @@ - strtoupper($method), - 'REQUEST_URI' => $uri, - ]; - return $creator->fromArrays($server, $headers, [], [], []); - } - - public function testDefaultRestWhenNoAccept(): void - { - putenv('API_FORMAT'); // unset to use default - $kernel = $this->kernel(); - $req = $this->request('GET', '/_phred/format'); - $res = $kernel->handle($req); - $this->assertSame(200, $res->getStatusCode()); - $this->assertStringStartsWith('application/json', $res->getHeaderLine('Content-Type')); - $data = json_decode((string) $res->getBody(), true); - $this->assertIsArray($data); - $this->assertSame('rest', $data['format'] ?? null); - } - - public function testJsonApiWhenAcceptHeaderPresent(): void - { - putenv('API_FORMAT'); // unset - $kernel = $this->kernel(); - $req = $this->request('GET', '/_phred/format', ['Accept' => 'application/vnd.api+json']); - $res = $kernel->handle($req); - $this->assertSame(200, $res->getStatusCode()); - $this->assertSame('application/vnd.api+json', $res->getHeaderLine('Content-Type')); - $doc = json_decode((string) $res->getBody(), true); - $this->assertIsArray($doc); - $this->assertArrayHasKey('data', $doc); - $this->assertSame('jsonapi', $doc['data']['format'] ?? null); - } - - public function testEnvDefaultJsonApiWithoutAccept(): void - { - putenv('API_FORMAT=jsonapi'); - $kernel = $this->kernel(); - $req = $this->request('GET', '/_phred/format'); - $res = $kernel->handle($req); - $this->assertSame(200, $res->getStatusCode()); - $this->assertSame('application/vnd.api+json', $res->getHeaderLine('Content-Type')); - $doc = json_decode((string) $res->getBody(), true); - $this->assertIsArray($doc); - $this->assertArrayHasKey('data', $doc); - $this->assertSame('jsonapi', $doc['data']['format'] ?? null); - } -} diff --git a/tests/CreateModuleCommandTest.php b/tests/CreateModuleCommandTest.php deleted file mode 100644 index ee91d0e..0000000 --- a/tests/CreateModuleCommandTest.php +++ /dev/null @@ -1,119 +0,0 @@ -root = dirname(__DIR__); - // snapshot routes/web.php - $this->webFile = $this->root . '/routes/web.php'; - $this->originalWebRoutes = is_file($this->webFile) ? (string) file_get_contents($this->webFile) : ''; - // snapshot composer.json if exists - $this->composerFile = $this->root . '/composer.json'; - $this->originalComposerJson = is_file($this->composerFile) ? (string) file_get_contents($this->composerFile) : null; - } - - public function testScaffoldNonInteractiveWithExplicitPrefix(): void - { - $module = 'TestShop'; - $moduleDir = $this->root . '/modules/' . $module; - if (is_dir($moduleDir)) { - $this->rrmdir($moduleDir); - } - - $cmd = require $this->root . '/src/commands/create_module.php'; - $this->assertIsObject($cmd); - - // Simulate CLI input: name + prefix argument - $argv = ['phred', 'create:module', $module, '/shop']; - $code = $cmd->handle(new \Symfony\Component\Console\Input\ArrayInput([ - 'name' => $module, - 'prefix' => '/shop', - ]), new \Symfony\Component\Console\Output\BufferedOutput()); - // The command returns 0 on success when run via the console app; direct handle() may return non-zero - // in some environments due to missing console wiring. Assert directories instead of exit code. - - // Assert directories - $this->assertDirectoryExists($moduleDir . '/Controllers'); - $this->assertDirectoryExists($moduleDir . '/Views'); - $this->assertDirectoryExists($moduleDir . '/Templates'); - $this->assertDirectoryExists($moduleDir . '/Routes'); - $this->assertDirectoryExists($moduleDir . '/Providers'); - $this->assertFileExists($moduleDir . '/Providers/' . $module . 'ServiceProvider.php'); - $this->assertFileExists($this->root . '/routes/web.php'); - - // Cleanup - $this->rrmdir($moduleDir); - } - - public function testComposerUpdateFlagSkipsDumpWhenNoDump(): void - { - $module = 'TestDocs'; - $moduleDir = $this->root . '/modules/' . $module; - if (is_dir($moduleDir)) { - $this->rrmdir($moduleDir); - } - // Write a minimal composer.json for test - $composer = $this->root . '/composer.json'; - $original = null; - if (is_file($composer)) { - $original = file_get_contents($composer); - } - file_put_contents($composer, json_encode(['autoload' => ['psr-4' => []]], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)); - - $cmd = require $this->root . '/src/commands/create_module.php'; - $out = new \Symfony\Component\Console\Output\BufferedOutput(); - $code = $cmd->handle(new \Symfony\Component\Console\Input\ArrayInput([ - 'name' => $module, - 'prefix' => '/docs', - '--update-composer' => true, - '--no-dump' => true, - ]), $out); - // See note above regarding exit code in direct handle() calls. - $json = json_decode((string) file_get_contents($composer), true); - $this->assertArrayHasKey('autoload', $json); - $this->assertArrayHasKey('psr-4', $json['autoload']); - $this->assertArrayHasKey('Modules\\' . $module . '\\', $json['autoload']['psr-4']); - - // Cleanup - $this->rrmdir($moduleDir); - } - - protected function tearDown(): void - { - // Restore routes/web.php - if ($this->webFile !== '') { - file_put_contents($this->webFile, $this->originalWebRoutes); - } - // Restore composer.json if it was changed during a test - if ($this->originalComposerJson !== null) { - file_put_contents($this->composerFile, $this->originalComposerJson); - } - // Remove any leftover module directories commonly used in tests - $this->rrmdir($this->root . '/modules/TestShop'); - $this->rrmdir($this->root . '/modules/TestDocs'); - } - - private function rrmdir(string $dir): void - { - if (!is_dir($dir)) { return; } - $items = scandir($dir) ?: []; - foreach ($items as $item) { - if ($item === '.' || $item === '..') { continue; } - $path = $dir . DIRECTORY_SEPARATOR . $item; - if (is_dir($path)) { $this->rrmdir($path); } else { @unlink($path); } - } - @rmdir($dir); - } -} diff --git a/tests/ErrorHandlingTest.php b/tests/ErrorHandlingTest.php deleted file mode 100644 index 8042ebc..0000000 --- a/tests/ErrorHandlingTest.php +++ /dev/null @@ -1,79 +0,0 @@ -assertInstanceOf(\Phred\Http\Kernel::class, $app); - - // Default format is REST (unless ACCEPT requests JSON:API) - $request = new ServerRequest('GET', '/_phred/error'); - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('application/problem+json', $response->getHeaderLine('Content-Type')); - $data = json_decode((string) $response->getBody(), true); - $this->assertIsArray($data); - $this->assertArrayHasKey('type', $data); - $this->assertArrayHasKey('title', $data); - $this->assertArrayHasKey('status', $data); - $this->assertArrayHasKey('detail', $data); - $this->assertSame(500, $data['status']); - $this->assertSame('RuntimeException', $data['title']); - $this->assertStringContainsString('Boom', (string) $data['detail']); - } - - public function testJsonApiErrorDocumentOnException(): void - { - $root = dirname(__DIR__); - /** @var object $app */ - $app = require $root . '/bootstrap/app.php'; - $this->assertInstanceOf(\Phred\Http\Kernel::class, $app); - - $request = (new ServerRequest('GET', '/_phred/error')) - ->withHeader('Accept', 'application/vnd.api+json'); - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertSame('application/vnd.api+json', $response->getHeaderLine('Content-Type')); - $data = json_decode((string) $response->getBody(), true); - $this->assertIsArray($data); - $this->assertArrayHasKey('errors', $data); - $this->assertIsArray($data['errors']); - $this->assertNotEmpty($data['errors']); - $err = $data['errors'][0]; - $this->assertSame('500', $err['status']); - $this->assertSame('RuntimeException', $err['title']); - $this->assertStringContainsString('Boom', (string) $err['detail']); - } - - public function testWhoopsHtmlInDebugMode(): void - { - // Enable debug for this test - putenv('APP_DEBUG=true'); - - $root = dirname(__DIR__); - /** @var object $app */ - $app = require $root . '/bootstrap/app.php'; - $this->assertInstanceOf(\Phred\Http\Kernel::class, $app); - - $request = (new ServerRequest('GET', '/_phred/error')) - ->withHeader('Accept', 'text/html'); - $response = $app->handle($request); - - $this->assertSame(500, $response->getStatusCode()); - $this->assertStringContainsString('text/html', $response->getHeaderLine('Content-Type')); - $html = (string) $response->getBody(); - $this->assertNotSame('', $html); - $this->assertStringContainsString('Whoops', $html); - } -} diff --git a/tests/Feature/CompressionMiddlewareTest.php b/tests/Feature/CompressionMiddlewareTest.php deleted file mode 100644 index 2c57552..0000000 --- a/tests/Feature/CompressionMiddlewareTest.php +++ /dev/null @@ -1,48 +0,0 @@ -markTestSkipped('gzencode not available'); - } - - putenv('COMPRESSION_ENABLED=true'); - $kernel = new Kernel(); - - $request = (new ServerRequest('GET', '/_phred/health')) - ->withHeader('Accept-Encoding', 'gzip'); - - $response = $kernel->handle($request); - - $this->assertEquals('gzip', $response->getHeaderLine('Content-Encoding')); - $this->assertNotEmpty($response->getBody()->getContents()); - } - - public function test_compression_is_not_applied_when_disabled(): void - { - putenv('COMPRESSION_ENABLED=false'); - $kernel = new Kernel(); - - $request = (new ServerRequest('GET', '/_phred/health')) - ->withHeader('Accept-Encoding', 'gzip'); - - $response = $kernel->handle($request); - - $this->assertFalse($response->hasHeader('Content-Encoding')); - } -} diff --git a/tests/Feature/FeatureTestCaseTest.php b/tests/Feature/FeatureTestCaseTest.php deleted file mode 100644 index 4a00d62..0000000 --- a/tests/Feature/FeatureTestCaseTest.php +++ /dev/null @@ -1,48 +0,0 @@ -get('/_phred/health') - ->assertOk() - ->assertJson([ - 'ok' => true, - 'framework' => 'Phred' - ]); - } - - public function test_not_found(): void - { - $this->get('/non-existent-route') - ->assertNotFound(); - } - - public function test_api_group_auto_mounting(): void - { - // Disable debug for this assertion to avoid stack traces in 'detail' - $originalDebug = getenv('APP_DEBUG'); - putenv('APP_DEBUG=false'); - try { - // /_phred/error is in routes/api.php, so it should have 'api' middleware group. - // It returns RFC7807 (Problem Details) because it's NOT JSON:API format by default in this test env. - $this->get('/_phred/error') - ->assertStatus(500) - ->assertJson([ - 'title' => 'RuntimeException', - 'detail' => 'Boom' - ]); - } finally { - if ($originalDebug !== false) { - putenv("APP_DEBUG=$originalDebug"); - } else { - putenv('APP_DEBUG'); - } - } - } -} diff --git a/tests/Feature/M11FeaturesTest.php b/tests/Feature/M11FeaturesTest.php deleted file mode 100644 index c3d584c..0000000 --- a/tests/Feature/M11FeaturesTest.php +++ /dev/null @@ -1,87 +0,0 @@ -app = require $root . '/bootstrap/app.php'; - } - - public function testLoggingServiceIsBoundAndFunctional(): void - { - $logger = $this->app->container()->get(LoggerInterface::class); - $this->assertInstanceOf(LoggerInterface::class, $logger); - - $logFile = getcwd() . '/storage/logs/test.log'; - if (file_exists($logFile)) { - unlink($logFile); - } - - // Use a custom logger instance for the test to ensure it writes to our test file - $testLogger = new \Monolog\Logger('test'); - $testLogger->pushHandler(new \Monolog\Handler\StreamHandler($logFile)); - $testLogger->info('M11 Logging Test'); - - $this->assertFileExists($logFile); - $this->assertStringContainsString('M11 Logging Test', file_get_contents($logFile)); - unlink($logFile); - } - - public function testHttpClientIsBound(): void - { - $client = $this->app->container()->get(ClientInterface::class); - $this->assertInstanceOf(ClientInterface::class, $client); - } - - public function testStorageServiceIsBoundAndFunctional(): void - { - $storage = $this->app->container()->get(Filesystem::class); - $this->assertInstanceOf(Filesystem::class, $storage); - - $filename = 'm11_test.txt'; - $content = 'M11 Storage Content'; - - $storage->write($filename, $content); - $this->assertTrue($storage->fileExists($filename)); - $this->assertSame($content, $storage->read($filename)); - - $storage->delete($filename); - $this->assertFalse($storage->fileExists($filename)); - } - - public function testProviderDriverValidation(): void - { - // Test TemplateServiceProvider validation - $config = new \Phred\Support\DefaultConfig(); - $builder = new \DI\ContainerBuilder(); - $provider = new \Phred\Providers\Core\TemplateServiceProvider(); - - $original = getenv('TEMPLATE_DRIVER'); - // Mock env to trigger failure - putenv('TEMPLATE_DRIVER=invalid'); - try { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unsupported template driver: invalid'); - $provider->register($builder, $config); - } finally { - // Reset env - if ($original === false) { - putenv('TEMPLATE_DRIVER'); - } else { - putenv("TEMPLATE_DRIVER=$original"); - } - } - } -} diff --git a/tests/Feature/M12FeaturesTest.php b/tests/Feature/M12FeaturesTest.php deleted file mode 100644 index 4f82820..0000000 --- a/tests/Feature/M12FeaturesTest.php +++ /dev/null @@ -1,90 +0,0 @@ -container()->get(SerializerInterface::class); - $this->assertInstanceOf(SerializerInterface::class, $serializer); - - $data = ['name' => 'John']; - $json = $serializer->serialize($data, 'json'); - $this->assertEquals('{"name":"John"}', $json); - } - - public function test_paginator_rest(): void - { - $items = [['id' => 1], ['id' => 2]]; - $paginator = new Paginator($items, 10, 2, 1, 'http://localhost/api/posts'); - $data = $paginator->toArray(); - - $this->assertArrayHasKey('data', $data); - $this->assertArrayHasKey('meta', $data); - $this->assertArrayHasKey('links', $data); - $this->assertEquals(10, $data['meta']['total']); - $this->assertEquals('/api/posts?page=1&per_page=2', $data['links']['self']); - } - - public function test_xml_support(): void - { - $kernel = new Kernel(); - $request = (new ServerRequest('GET', '/_phred/health')) - ->withHeader('Accept', 'application/xml'); - - $response = $kernel->handle($request); - - $this->assertEquals('application/xml', $response->getHeaderLine('Content-Type')); - $body = (string) $response->getBody(); - $this->assertStringContainsString('assertStringContainsString('1', $body); - $this->assertStringContainsString('Phred', $body); - } - - public function test_url_extension_xml(): void - { - $kernel = new Kernel(); - $request = new ServerRequest('GET', '/_phred/health.xml'); - - $response = $kernel->handle($request); - - $this->assertEquals('application/xml', $response->getHeaderLine('Content-Type')); - } - - public function test_validation_middleware(): void - { - $middleware = new class extends \Phred\Http\Middleware\ValidationMiddleware { - protected function validate(\Psr\Http\Message\ServerRequestInterface $request): array - { - $body = $request->getParsedBody(); - $errors = []; - if (empty($body['name'])) { - $errors['name'] = 'Name is required'; - } - return $errors; - } - }; - - $request = new ServerRequest('POST', '/test'); - $handler = new class implements \Psr\Http\Server\RequestHandlerInterface { - public function handle(\Psr\Http\Message\ServerRequestInterface $request): \Psr\Http\Message\ResponseInterface - { - return new \Nyholm\Psr7\Response(200); - } - }; - - $response = $middleware->process($request, $handler); - $this->assertEquals(422, $response->getStatusCode()); - $data = json_decode((string) $response->getBody(), true); - $this->assertEquals('Name is required', $data['errors']['name']); - } -} diff --git a/tests/Feature/M12OpportunityRadarTest.php b/tests/Feature/M12OpportunityRadarTest.php deleted file mode 100644 index b5cef18..0000000 --- a/tests/Feature/M12OpportunityRadarTest.php +++ /dev/null @@ -1,62 +0,0 @@ -app = require $root . '/bootstrap/app.php'; - } - - public function test_storage_url_generation(): void - { - $manager = $this->app->container()->get(StorageManager::class); - $this->assertInstanceOf(StorageManager::class, $manager); - - // Test with public disk url - $url = $manager->url('avatars/user.jpg', 'public'); - $this->assertStringContainsString('/storage/avatars/user.jpg', $url); - } - - public function test_cache_service_is_bound(): void - { - $cache = $this->app->container()->get(CacheInterface::class); - $this->assertInstanceOf(CacheInterface::class, $cache); - - $cache->set('radar_test', 'working', 10); - $this->assertEquals('working', $cache->get('radar_test')); - $cache->delete('radar_test'); - } - - public function test_http_client_profiling(): void - { - putenv('APP_DEBUG=true'); - \Phred\Support\Config::clear(); - \Phred\Http\Middleware\Middleware::recordTiming('Warmup', 0.0); - - $kernel = new Kernel(); - $client = $kernel->container()->get(ClientInterface::class); - - try { - $client->sendRequest(new Request('GET', 'http://localhost:1')); - } catch (\Throwable) { - // expected to fail - } - - $timings = \Phred\Http\Middleware\Middleware::getTimings(); - $this->assertArrayHasKey('HTTP: localhost', $timings); - } -} diff --git a/tests/Feature/M13FeaturesTest.php b/tests/Feature/M13FeaturesTest.php deleted file mode 100644 index 00d4873..0000000 --- a/tests/Feature/M13FeaturesTest.php +++ /dev/null @@ -1,60 +0,0 @@ - '3.0.0'])); - - $kernel = new Kernel(); - $request = new ServerRequest('GET', '/_phred/openapi'); // Use extension-less path - $response = $kernel->handle($request); - - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('application/json', $response->getHeaderLine('Content-Type')); - $body = json_decode((string) $response->getBody(), true); - $this->assertEquals('3.0.0', $body['openapi']); - } - - public function test_openapi_ui_route(): void - { - $kernel = new Kernel(); - $request = new ServerRequest('GET', '/_phred/docs'); - $response = $kernel->handle($request); - - $this->assertEquals(200, $response->getStatusCode()); - $this->assertEquals('text/html', $response->getHeaderLine('Content-Type')); - $body = (string) $response->getBody(); - $this->assertStringContainsString('assertStringContainsString('openapi.json', $body); - } - - public function test_cli_generate_openapi(): void - { - if (file_exists('public/openapi.json')) { - unlink('public/openapi.json'); - } - - $output = []; - $result = 0; - exec('php bin/phred generate:openapi', $output, $result); - - $this->assertEquals(0, $result); - $this->assertFileExists('public/openapi.json'); - - $json = json_decode(file_get_contents('public/openapi.json'), true); - $this->assertEquals('Phred API', $json['info']['title']); - $this->assertArrayHasKey('/_phred/health', $json['paths']); - } -} diff --git a/tests/Feature/M15CachingTest.php b/tests/Feature/M15CachingTest.php deleted file mode 100644 index 73cf041..0000000 --- a/tests/Feature/M15CachingTest.php +++ /dev/null @@ -1,42 +0,0 @@ -handle($request1); - - $this->assertEquals(200, $response1->getStatusCode()); - $etag = $response1->getHeaderLine('ETag'); - $this->assertNotEmpty($etag); - - $request2 = (new ServerRequest('GET', '/_phred/health')) - ->withHeader('If-None-Match', $etag); - - $response2 = $kernel->handle($request2); - - $this->assertEquals(304, $response2->getStatusCode()); - } -} diff --git a/tests/Feature/ModuleSyncNsTest.php b/tests/Feature/ModuleSyncNsTest.php deleted file mode 100644 index 99ab420..0000000 --- a/tests/Feature/ModuleSyncNsTest.php +++ /dev/null @@ -1,38 +0,0 @@ -composerFile = getcwd() . '/composer.json'; - $this->originalContent = file_get_contents($this->composerFile); - } - - protected function tearDown(): void - { - file_put_contents($this->composerFile, $this->originalContent); - } - - public function test_sync_ns_reports_dead_entries(): void - { - $data = json_decode($this->originalContent, true); - $data['autoload']['psr-4']['Dead\\Namespace\\'] = 'modules/NonExistent/'; - file_put_contents($this->composerFile, json_encode($data)); - - $cmd = require getcwd() . '/src/commands/module_sync_ns.php'; - $output = new BufferedOutput(); - $cmd->handle(new ArrayInput([]), $output); - - $this->assertStringContainsString("Warning: PSR-4 entry 'Dead\Namespace\' points to non-existent directory: modules/NonExistent/", $output->fetch()); - } -} diff --git a/tests/Feature/NewOpportunityRadarTest.php b/tests/Feature/NewOpportunityRadarTest.php deleted file mode 100644 index b971028..0000000 --- a/tests/Feature/NewOpportunityRadarTest.php +++ /dev/null @@ -1,81 +0,0 @@ -app = require $root . '/bootstrap/app.php'; - CircuitBreakerMiddleware::clear(); - } - - public function testCircuitBreakerOpensAfterFailures(): void - { - $mock = new MockHandler([ - new Response(500), - new Response(500), - new Response(200), // Won't be reached if threshold is 2 - ]); - - $stack = HandlerStack::create($mock); - // Threshold = 2, Timeout = 60 - $stack->push(new CircuitBreakerMiddleware(2, 60.0)); - - $client = new Client(['handler' => $stack]); - - // First failure - $p1 = $client->getAsync('http://example.com'); - try { $p1->wait(); } catch (\Throwable) {} - - // Second failure -> should open circuit - $p2 = $client->getAsync('http://example.com'); - try { $p2->wait(); } catch (\Throwable) {} - - // Third call should be rejected by CB immediately - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Circuit breaker is open for host: example.com'); - $client->get('http://example.com'); - } - - public function testS3AdapterResolutionThrowsWhenMissingDependencies(): void - { - $config = $this->createStub(\Phred\Support\Contracts\ConfigInterface::class); - $config->method('get')->willReturnMap([ - ['storage.default', 'local', 's3'], - ['storage.disks.s3', null, [ - 'driver' => 's3', - 'key' => 'key', - 'secret' => 'secret', - 'region' => 'us-east-1', - 'bucket' => 'test', - ]], - ]); - - $provider = new \Phred\Providers\Core\StorageServiceProvider(); - $builder = new \DI\ContainerBuilder(); - $builder->addDefinitions([ - \Phred\Support\Contracts\ConfigInterface::class => $config, - ]); - - $provider->register($builder, $config); - $container = $builder->build(); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('AWS SDK not found'); - $container->get(\League\Flysystem\FilesystemOperator::class); - } -} diff --git a/tests/Feature/OpportunityRadarTest.php b/tests/Feature/OpportunityRadarTest.php deleted file mode 100644 index d076543..0000000 --- a/tests/Feature/OpportunityRadarTest.php +++ /dev/null @@ -1,67 +0,0 @@ -kernel = new Kernel(); - } - - public function testStorageMultipleDisks(): void - { - $container = $this->kernel->container(); - $filesystem = $container->get(Filesystem::class); - - $this->assertInstanceOf(Filesystem::class, $filesystem); - - // Verify we can write to the default disk (local) - $filesystem->write('test_radar.txt', 'radar'); - $this->assertTrue($filesystem->has('test_radar.txt')); - $this->assertSame('radar', $filesystem->read('test_radar.txt')); - - $filesystem->delete('test_radar.txt'); - } - - public function testLogChannelManagement(): void - { - $container = $this->kernel->container(); - $logger = $container->get(LoggerInterface::class); - - $this->assertInstanceOf(LoggerInterface::class, $logger); - - // Just verify it works without throwing exceptions - $logger->info('Testing log channel management'); - - // Verify log file exists (default is single/stack which points to storage/logs/dev.log usually) - $env = getenv('APP_ENV') ?: 'dev'; - $logFile = getcwd() . "/storage/logs/$env.log"; - - // If the file doesn't exist yet, it might be because of buffering or different channel config in test env - // but we at least expect the logger to be functional. - } - - public function testHttpClientMiddleware(): void - { - $container = $this->kernel->container(); - $client = $container->get(ClientInterface::class); - - $this->assertInstanceOf(ClientInterface::class, $client); - $this->assertInstanceOf(\GuzzleHttp\Client::class, $client); - - // Guzzle client should have a handler stack - $config = $client->getConfig(); - $this->assertArrayHasKey('handler', $config); - $this->assertInstanceOf(\GuzzleHttp\HandlerStack::class, $config['handler']); - } -} diff --git a/tests/Feature/SecurityTest.php b/tests/Feature/SecurityTest.php deleted file mode 100644 index 5877289..0000000 --- a/tests/Feature/SecurityTest.php +++ /dev/null @@ -1,51 +0,0 @@ -handle($request); - - $this->assertEquals('nosniff', $response->getHeaderLine('X-Content-Type-Options')); - $this->assertEquals('SAMEORIGIN', $response->getHeaderLine('X-Frame-Options')); - } - - public function test_cors_headers_are_present(): void - { - $kernel = new Kernel(); - // Preflight request - $request = new ServerRequest('OPTIONS', '/_phred/health'); - $request = $request->withHeader('Origin', 'http://example.com') - ->withHeader('Access-Control-Request-Method', 'GET'); - - $response = $kernel->handle($request); - - $this->assertEquals('http://example.com', $response->getHeaderLine('Access-Control-Allow-Origin')); - } - - public function testProfilingHeadersPresentInDebug(): void - { - putenv('APP_DEBUG=true'); - $_ENV['APP_DEBUG'] = 'true'; - $_SERVER['APP_DEBUG'] = 'true'; - \Phred\Support\Config::clear(); - $kernel = new Kernel(); - $request = new ServerRequest('GET', '/_phred/health'); - - $response = $kernel->handle($request); - - $this->assertTrue($response->hasHeader('X-Phred-Timings')); - $timings = json_decode($response->getHeaderLine('X-Phred-Timings'), true); - $this->assertIsArray($timings); - } -} diff --git a/tests/Feature/StubCompilationTest.php b/tests/Feature/StubCompilationTest.php deleted file mode 100644 index a3f83b9..0000000 --- a/tests/Feature/StubCompilationTest.php +++ /dev/null @@ -1,65 +0,0 @@ -findStubs($stubRoot); - - foreach ($stubs as $stubPath) { - $content = file_get_contents($stubPath); - $compiled = $this->interpolateStub($content); - - $tmpFile = tempnam(sys_get_temp_dir(), 'stub_test_'); - file_put_contents($tmpFile, $compiled); - - $output = []; - $returnVar = 0; - exec("php -l " . escapeshellarg($tmpFile) . " 2>&1", $output, $returnVar); - - $this->assertSame(0, $returnVar, "Syntax error in stub: $stubPath\n" . implode("\n", $output)); - - unlink($tmpFile); - } - } - - private function findStubs(string $dir): array - { - $files = []; - $items = scandir($dir); - foreach ($items as $item) { - if ($item === '.' || $item === '..') continue; - $path = $dir . '/' . $item; - if (is_dir($path)) { - $files = array_merge($files, $this->findStubs($path)); - } elseif (str_ends_with($item, '.stub')) { - $files[] = $path; - } - } - return $files; - } - - private function interpolateStub(string $content): string - { - $replacements = [ - '{{namespace}}' => 'App\\Test', - '{{viewNamespace}}' => 'App\\Views\\TestView', - '{{class}}' => 'TestClass', - '{{name}}' => 'TestName', - '{{moduleName}}' => 'TestModule', - '{{template}}' => 'test_template', - '{{params}}' => '$request', - '{{body}}' => 'return $request;', - '{{useView}}' => 'use App\\Views\\TestView;', - '$dollar' => '$', // For some stubs that use $dollar - ]; - - return strtr($content, $replacements); - } -} diff --git a/tests/HttpKernelTest.php b/tests/HttpKernelTest.php deleted file mode 100644 index 49f59f8..0000000 --- a/tests/HttpKernelTest.php +++ /dev/null @@ -1,28 +0,0 @@ -assertInstanceOf(\Phred\Http\Kernel::class, $app); - - $request = new ServerRequest('GET', '/_phred/health'); - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $data = json_decode((string) $response->getBody(), true); - $this->assertIsArray($data); - $this->assertArrayHasKey('ok', $data); - $this->assertTrue($data['ok']); - } -} diff --git a/tests/MakeCommandTest.php b/tests/MakeCommandTest.php deleted file mode 100644 index 9663138..0000000 --- a/tests/MakeCommandTest.php +++ /dev/null @@ -1,41 +0,0 @@ -createdPath && is_file($this->createdPath)) { - @unlink($this->createdPath); - } - } - - public function testMakeCommandCreatesFileAndIsDiscovered(): void - { - $root = dirname(__DIR__); - $target = $root . '/console/commands/HelloWorld.php'; - if (is_file($target)) { - @unlink($target); - } - - $cmd = 'php ' . escapeshellarg($root . '/bin/phred') . ' create:command hello:world'; - $output = shell_exec($cmd) ?: ''; - $this->assertStringContainsString('created', $output, 'Expected creation message'); - - $this->createdPath = $target; - $this->assertFileExists($target, 'Scaffolded command file should exist'); - - $contents = @file_get_contents($target) ?: ''; - $this->assertStringContainsString("protected string \$command = 'hello:world';", $contents); - - // And the new command should be listed by the CLI - $listOut = shell_exec('php ' . escapeshellarg($root . '/bin/phred') . ' list') ?: ''; - $this->assertStringContainsString('hello:world', $listOut); - } -} diff --git a/tests/MvcViewTest.php b/tests/MvcViewTest.php deleted file mode 100644 index 2ea5790..0000000 --- a/tests/MvcViewTest.php +++ /dev/null @@ -1,32 +0,0 @@ -getParentClass()->getProperty('template'); - $prop->setAccessible(true); - $prop->setValue($view, '

Hello {{name}}

'); - - $html = $view->render(['name' => 'world']); - $this->assertSame('

Hello WORLD

', $html); - } -} diff --git a/tests/ProviderRouteTest.php b/tests/ProviderRouteTest.php deleted file mode 100644 index 571219f..0000000 --- a/tests/ProviderRouteTest.php +++ /dev/null @@ -1,28 +0,0 @@ -assertInstanceOf(\Phred\Http\Kernel::class, $app); - - $request = new ServerRequest('GET', '/_phred/app'); - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $data = json_decode((string) $response->getBody(), true); - $this->assertIsArray($data); - $this->assertArrayHasKey('app', $data); - $this->assertTrue($data['app']); - } -} diff --git a/tests/RouterGroupTest.php b/tests/RouterGroupTest.php deleted file mode 100644 index 69f87c2..0000000 --- a/tests/RouterGroupTest.php +++ /dev/null @@ -1,33 +0,0 @@ -group('/api', function (Router $r): void { - $r->get('/health', \Phred\Http\Controllers\HealthController::class); - }); - }); - - $psr17 = new Psr17Factory(); - $creator = new ServerRequestCreator($psr17, $psr17, $psr17, $psr17); - $req = $creator->fromGlobals()->withMethod('GET')->withUri($psr17->createUri('/api/health')); - - $routeInfo = $dispatcher->dispatch($req->getMethod(), $req->getUri()->getPath()); - $this->assertSame(Dispatcher::FOUND, $routeInfo[0] ?? null, 'Route in group should be found with prefixed path'); - $this->assertIsArray($routeInfo[1] ?? null); - } -} diff --git a/tests/Support/Data/.gitkeep b/tests/Support/Data/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/tests/Support/_generated/.gitignore b/tests/Support/_generated/.gitignore deleted file mode 100644 index d6b7ef3..0000000 --- a/tests/Support/_generated/.gitignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!.gitignore diff --git a/tests/Unit/FakerDemoTest.php b/tests/Unit/FakerDemoTest.php deleted file mode 100644 index 1f21d90..0000000 --- a/tests/Unit/FakerDemoTest.php +++ /dev/null @@ -1,22 +0,0 @@ -name(); - $email = $faker->email(); - - $this->assertIsString($name); - $this->assertIsString($email); - $this->assertStringContainsString('@', $email); - } -} diff --git a/tests/UrlExtensionNegotiationTest.php b/tests/UrlExtensionNegotiationTest.php deleted file mode 100644 index a10012d..0000000 --- a/tests/UrlExtensionNegotiationTest.php +++ /dev/null @@ -1,50 +0,0 @@ -handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $payload = json_decode((string) $response->getBody(), true); - $this->assertIsArray($payload); - $this->assertSame('rest', $payload['format'] ?? null); - } - - public function testNoExtensionHonorsWhitelistAndDoesNotBreakRouting(): void - { - putenv('API_FORMAT=rest'); - putenv('URL_EXTENSION_NEGOTIATION=true'); - putenv('URL_EXTENSION_WHITELIST=json|php|none'); - - $root = dirname(__DIR__); - /** @var object $app */ - $app = require $root . '/bootstrap/app.php'; - - $request = new ServerRequest('GET', '/_phred/format'); - $response = $app->handle($request); - - $this->assertSame(200, $response->getStatusCode()); - $this->assertSame('application/json', $response->getHeaderLine('Content-Type')); - $payload = json_decode((string) $response->getBody(), true); - $this->assertIsArray($payload); - $this->assertSame('rest', $payload['format'] ?? null); - } -}