Too many things
This commit is contained in:
parent
cf30f3e41a
commit
54303282d7
12
.env.example
12
.env.example
|
|
@ -2,7 +2,10 @@ APP_NAME=Phred App
|
||||||
APP_ENV=local
|
APP_ENV=local
|
||||||
APP_DEBUG=true
|
APP_DEBUG=true
|
||||||
APP_TIMEZONE=UTC
|
APP_TIMEZONE=UTC
|
||||||
|
APP_URL=http://localhost
|
||||||
|
|
||||||
API_FORMAT=rest
|
API_FORMAT=rest
|
||||||
|
API_PROBLEM_DETAILS=true
|
||||||
|
|
||||||
DB_DRIVER=sqlite
|
DB_DRIVER=sqlite
|
||||||
DB_DATABASE=database/database.sqlite
|
DB_DATABASE=database/database.sqlite
|
||||||
|
|
@ -15,3 +18,12 @@ ORM_DRIVER=pairity
|
||||||
TEMPLATE_DRIVER=eyrie
|
TEMPLATE_DRIVER=eyrie
|
||||||
FLAGS_DRIVER=flagpole
|
FLAGS_DRIVER=flagpole
|
||||||
TEST_RUNNER=codeception
|
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"
|
||||||
|
|
||||||
|
|
|
||||||
19
.php-cs-fixer.php
Normal file
19
.php-cs-fixer.php
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
<?php
|
||||||
|
|
||||||
|
$finder = PhpCsFixer\Finder::create()
|
||||||
|
->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);
|
||||||
24
CONTRIBUTING.md
Normal file
24
CONTRIBUTING.md
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
# 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.
|
||||||
22
Dockerfile
Normal file
22
Dockerfile
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
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"]
|
||||||
355
MILESTONES.md
355
MILESTONES.md
|
|
@ -6,19 +6,19 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## Table of Contents
|
## Table of Contents
|
||||||
- [~~M0 — Project bootstrap (repo readiness)~~](#m0-project-bootstrap-repo-readiness)
|
- [M0 — Project bootstrap (repo readiness)](#m0-project-bootstrap-repo-readiness)
|
||||||
- [~~M1 — Core HTTP kernel and routing~~](#m1-core-http-kernel-and-routing)
|
- [M1 — Core HTTP kernel and routing](#m1-core-http-kernel-and-routing)
|
||||||
- [~~M2 — Configuration and environment~~](#m2-configuration-and-environment)
|
- [M2 — Configuration and environment](#m2-configuration-and-environment)
|
||||||
- [~~M3 — API formats and content negotiation~~](#m3-api-formats-and-content-negotiation)
|
- [M3 — API formats and content negotiation](#m3-api-formats-and-content-negotiation)
|
||||||
- [~~M4 — Error handling and problem details~~](#m4-error-handling-and-problem-details)
|
- [M4 — Error handling and problem details](#m4-error-handling-and-problem-details)
|
||||||
- [~~M5 — Dependency Injection and Service Providers~~](#m5-dependency-injection-and-service-providers)
|
- [M5 — Dependency Injection and Service Providers](#m5-dependency-injection-and-service-providers)
|
||||||
- [~~M6 — MVC: Controllers, Views, Templates~~](#m6-mvc-controllers-views-templates)
|
- [M6 — MVC: Controllers, Views, Templates](#m6-mvc-controllers-views-templates)
|
||||||
- [~~M7 — Modules (Django‑style app structure)~~](#m7-modules-django-style-app-structure)
|
- [M7 — Modules (Django‑style app structure)](#m7-modules-django-style-app-structure)
|
||||||
- [~~M8 — Database access, migrations, and seeds~~](#m8-database-access-migrations-and-seeds)
|
- [M8 — Database access, migrations, and seeds](#m8-database-access-migrations-and-seeds)
|
||||||
- [~~M9 — CLI (phred) and scaffolding~~](#m9-cli-phred-and-scaffolding)
|
- [M9 — CLI (phred) and scaffolding](#m9-cli-phred-and-scaffolding)
|
||||||
- [~~M10 — Security middleware and auth primitives~~](#m10-security-middleware-and-auth-primitives)
|
- [M10 — Security middleware and auth primitives](#m10-security-middleware-and-auth-primitives)
|
||||||
- [~~M11 — Logging, HTTP client, and filesystem~~](#m11-logging-http-client-and-filesystem)
|
- [M11 — Logging, HTTP client, and filesystem](#m11-logging-http-client-and-filesystem)
|
||||||
- [~~M12 — Serialization/validation utilities and pagination~~](#m12-serialization-validation-utilities-and-pagination)
|
- [M12 — Serialization/validation utilities and pagination](#m12-serialization-validation-utilities-and-pagination)
|
||||||
- [M13 — OpenAPI and documentation](#m13-openapi-and-documentation)
|
- [M13 — OpenAPI and documentation](#m13-openapi-and-documentation)
|
||||||
- [M14 — Testing, quality, and DX](#m14-testing-quality-and-dx)
|
- [M14 — Testing, quality, and DX](#m14-testing-quality-and-dx)
|
||||||
- [M15 — Caching and performance (optional default)](#m15-caching-and-performance-optional-default)
|
- [M15 — Caching and performance (optional default)](#m15-caching-and-performance-optional-default)
|
||||||
|
|
@ -28,241 +28,238 @@ Phred supports REST and JSON:API via env setting; batteries-included defaults, s
|
||||||
- [M19 — Documentation site](#m19-documentation-site)
|
- [M19 — Documentation site](#m19-documentation-site)
|
||||||
- [M20 — Dynamic Command Help](#m20-dynamic-command-help)
|
- [M20 — Dynamic Command Help](#m20-dynamic-command-help)
|
||||||
- [M21 — Governance and roadmap tracking](#m21-governance-and-roadmap-tracking)
|
- [M21 — Governance and roadmap tracking](#m21-governance-and-roadmap-tracking)
|
||||||
- [Notes on sequencing and parallelization](#notes-on-sequencing-and-parallelization)
|
|
||||||
|
|
||||||
## ~~M0 — Project bootstrap (repo readiness)~~
|
## M0 — Project bootstrap (repo readiness)
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Finalize `composer.json` (namespaces, scripts, suggests) and `LICENSE`.~~
|
- [x] Finalize `composer.json` (namespaces, scripts, suggests) and `LICENSE`.
|
||||||
* ~~Add `.editorconfig`, `.gitattributes`, `.gitignore`, example `.env.example`.~~
|
- [x] Add `.editorconfig`, `.gitattributes`, `.gitignore`, example `.env.example`.
|
||||||
* ~~Set up CI (lint, static analysis, unit tests) and basic build badge.~~
|
- [x] Set up CI (lint, static analysis, unit tests) and basic build badge.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Fresh clone installs (without running suggested packages) and passes linters/analysis/tests.~~
|
- [x] Fresh clone installs (without running suggested packages) and passes linters/analysis/tests.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M1 — Core HTTP kernel and routing~~
|
## M1 — Core HTTP kernel and routing
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Implement the HTTP kernel: `PSR-15` pipeline via `Relay`.~~
|
- [x] Implement the HTTP kernel: `PSR-15` pipeline via `Relay`.
|
||||||
* ~~Wire `nyholm/psr7(-server)` factories and server request creation.~~
|
- [x] Wire `nyholm/psr7(-server)` factories and server request creation.
|
||||||
* ~~Integrate `nikic/fast-route` with a RouteCollector and dispatcher.~~
|
- [x] Integrate `nikic/fast-route` with a RouteCollector and dispatcher.
|
||||||
* ~~Define route → controller resolution (invokable controllers).~~
|
- [x] Define route → controller resolution (invokable controllers).
|
||||||
* ~~Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).~~
|
- [x] Add minimal app bootstrap (front controller) and DI container wiring (`PHP-DI`).
|
||||||
* ~~Addendum: Route groups (prefix only) via `Router::group()`~~
|
- [x] Addendum: Route groups (prefix only) via `Router::group()`
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Sample route returning a JSON 200 via controller.~~
|
- [x] Sample route returning a JSON 200 via controller.
|
||||||
* ~~Controllers are invokable (`__invoke(Request)`), one route per controller.~~
|
- [x] Controllers are invokable (`__invoke(Request)`), one route per controller.
|
||||||
* ~~Route groups (prefix only) work and are tested.~~
|
- [x] Route groups (prefix only) work and are tested.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M2 — Configuration and environment~~
|
## M2 — Configuration and environment
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.~~
|
- [x] Load `.env` via `vlucas/phpdotenv` and expose `Phred\Support\Config`.
|
||||||
* ~~Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).~~
|
- [x] Define configuration precedence and document keys (e.g., `API_FORMAT`, `APP_ENV`, `APP_DEBUG`).
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~App reads config from `.env`; unit test demonstrates override behavior.~~
|
- [x] App reads config from `.env`; unit test demonstrates override behavior.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M3 — API formats and content negotiation~~
|
## M3 — API formats and content negotiation
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.~~
|
- [x] Finalize `ContentNegotiationMiddleware` using `.env` and `Accept` header.
|
||||||
* ~~Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.~~
|
- [x] Bind `ApiResponseFactoryInterface` to `RestResponseFactory` or `JsonApiResponseFactory` based on format.
|
||||||
* ~~Provide developer‑facing helpers for common responses (`ok`, `created`, `error`).~~
|
- [x] Provide developer‑facing helpers for common responses (`ok`, `created`, `error`).
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.~~
|
- [x] Demo endpoints respond correctly as REST or JSON:API depending on `API_FORMAT` and `Accept`.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M4 — Error handling and problem details~~
|
## M4 — Error handling and problem details
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.~~
|
- [x] Finalize `ProblemDetailsMiddleware` with RFC7807 (REST) and JSON:API error documents.
|
||||||
* ~~Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).~~
|
- [x] Integrate `filp/whoops` for dev mode (`APP_DEBUG=true`).
|
||||||
* ~~Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.~~
|
- [x] Map common exceptions to HTTP status codes; include correlation/request IDs in responses/logs.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Throwing an exception yields a standards‑compliant error response; debug mode shows Whoops page.~~
|
- [x] Throwing an exception yields a standards‑compliant error response; debug mode shows Whoops page.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M5 — Dependency Injection and Service Providers~~
|
## M5 — Dependency Injection and Service Providers
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Define Service Provider interface and lifecycle (register, boot).~~
|
- [x] Define Service Provider interface and lifecycle (register, boot).
|
||||||
* ~~Module discovery loads providers in order (core → app → module).~~
|
- [x] Module discovery loads providers in order (core → app → module).
|
||||||
* ~~Add examples for registering controllers, services, config, and routes via providers.~~
|
- [x] Add examples for registering controllers, services, config, and routes via providers.
|
||||||
* ~~Define contracts: `Template\Contracts\RendererInterface`, `Orm\Contracts\*`, `Flags\Contracts\FeatureFlagClientInterface`, `Testing\Contracts\TestRunnerInterface`.~~
|
- [x] Define contracts: `Template\Contracts\RendererInterface`, `Orm\Contracts\*`, `Flags\Contracts\FeatureFlagClientInterface`, `Testing\Contracts\TestRunnerInterface`.
|
||||||
* ~~Define config/env keys for driver selection (e.g., `TEMPLATE_DRIVER`, `ORM_DRIVER`, `FLAGS_DRIVER`, `TEST_RUNNER`).~~
|
- [x] Define config/env keys for driver selection (e.g., `TEMPLATE_DRIVER`, `ORM_DRIVER`, `FLAGS_DRIVER`, `TEST_RUNNER`).
|
||||||
* ~~Provide “default adapter” Service Providers for the shipped packages and document swap procedure.~~
|
- [x] Provide “default adapter” Service Providers for the shipped packages and document swap procedure.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Providers can contribute bindings and routes; order is deterministic and tested.~~
|
- [x] Providers can contribute bindings and routes; order is deterministic and tested.
|
||||||
* ~~Drivers can be switched via `.env`/config without changing controllers/services; example provider route covered by tests.~~
|
- [x] Drivers can be switched via `.env`/config without changing controllers/services; example provider route covered by tests.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M6 — MVC: Controllers, Views, Templates~~
|
## M6 — MVC: Controllers, Views, Templates
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Controller base class and conventions (request/response helpers).~~
|
- [x] Controller base class and conventions (request/response helpers).
|
||||||
* ~~View layer (data preparation) with `getphred/eyrie` template engine integration.~~
|
- [x] View layer (data preparation) with `getphred/eyrie` template engine integration.
|
||||||
* ~~Template rendering helper: `$this->render(<template>, <data>)`.~~
|
- [x] Template rendering helper: `$this->render(<template>, <data>)`.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Example page rendered through View → Template; API coexists with full‑site rendering.~~
|
- [x] Example page rendered through View → Template; API coexists with full‑site rendering.
|
||||||
* ~~Rendering works via RendererInterface and can be swapped (e.g., Eyrie → Twig demo) with only configuration/provider changes.~~
|
- [x] Rendering works via RendererInterface and can be swapped (e.g., Eyrie → Twig demo) with only configuration/provider changes.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M7 — Modules (Django‑style app structure)~~
|
## M7 — Modules (Django‑style app structure)
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Define module filesystem layout (Nested Controllers/Views/Services/Models/Templates/Routes/Tests).~~
|
- [x] Define module filesystem layout (Nested Controllers/Views/Services/Models/Templates/Routes/Tests).
|
||||||
* ~~Module loader: auto‑register providers, routes, templates.~~
|
- [x] Module loader: auto‑register providers, routes, templates.
|
||||||
* ~~Namespacing and autoload guidance.~~
|
- [x] Namespacing and autoload guidance.
|
||||||
* ~~Core CLI: add `create:module <name>` command to scaffold a module with nested resources.~~
|
- [x] Core CLI: add `create:module <name>` command to scaffold a module with nested resources.
|
||||||
* ~~ORM‑agnostic module layout (to support Pairity DAO/DTO and Eloquent Active Record):~~
|
- [x] ORM‑agnostic module layout (to support Pairity DAO/DTO and Eloquent Active Record):
|
||||||
* ~~`Modules/<X>/Models/` — domain models (pure PHP, ORM‑neutral)~~
|
- [x] `Modules/<X>/Models/` — domain models (pure PHP, ORM‑neutral)
|
||||||
* ~~`Modules/<X>/Repositories/` — repository interfaces (DI targets for services)~~
|
- [x] `Modules/<X>/Repositories/` — repository interfaces (DI targets for services)
|
||||||
* ~~`Modules/<X>/Persistence/Pairity/` — DAOs, DTOs, mappers, repository implementations~~
|
- [x] `Modules/<X>/Persistence/Pairity/` — DAOs, DTOs, mappers, repository implementations
|
||||||
* ~~`Modules/<X>/Persistence/Eloquent/` — Eloquent models, scopes, repository implementations~~
|
- [x] `Modules/<X>/Persistence/Eloquent/` — Eloquent models, scopes, repository implementations
|
||||||
* ~~`Modules/<X>/Database/Migrations/*` — canonical migrations for the module (no duplication per driver)~~
|
- [x] `Modules/<X>/Database/Migrations/*` — canonical migrations for the module (no duplication per driver)
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Creating a module with the CLI makes it discoverable; routes/templates work without manual wiring.~~
|
- [x] Creating a module with the CLI makes it discoverable; routes/templates work without manual wiring.
|
||||||
* ~~Switching `ORM_DRIVER` between `pairity` and `eloquent` requires no changes to services/controllers; providers bind repository interfaces to driver implementations.~~
|
- [x] Switching `ORM_DRIVER` between `pairity` and `eloquent` requires no changes to services/controllers; providers bind repository interfaces to driver implementations.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M8 — Database access, migrations, and seeds~~
|
## M8 — Database access, migrations, and seeds
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Integrate `getphred/pairity` for ORM/migrations/seeds.~~
|
- [x] Integrate `getphred/pairity` for ORM/migrations/seeds.
|
||||||
* ~~Define config (`DB_*`), migration paths (app and modules), and seeder conventions.~~
|
- [x] Define config (`DB_*`), migration paths (app and modules), and seeder conventions.
|
||||||
* ~~CLI commands: `migrate`, `migration:rollback`, `seed`, `seed:rollback`.~~
|
- [x] CLI commands: `migrate`, `migration:rollback`, `seed`, `seed:rollback`.
|
||||||
* ~~All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).~~
|
- [x] All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).
|
||||||
* ~~Add `register:orm <driver>` command:~~
|
- [x] Add `register:orm <driver>` command:
|
||||||
* ~~Verify or guide installation of the ORM driver package.~~
|
- [x] Verify or guide installation of the ORM driver package.
|
||||||
* ~~Update `.env` (`ORM_DRIVER=<driver>`) safely.~~
|
- [x] Update `.env` (`ORM_DRIVER=<driver>`) safely.
|
||||||
* ~~Create `modules/*/Persistence/<Driver>/` directories for existing modules.~~
|
- [x] Create `modules/*/Persistence/<Driver>/` directories for existing modules.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Running migrations modifies a test database; seeds populate sample data; CRUD demo works.~~
|
- [x] Running migrations modifies a test database; seeds populate sample data; CRUD demo works.
|
||||||
* ~~All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).~~
|
- [x] All persistence usage in examples goes through Orm contracts; can be swapped (Pairity → Doctrine adapter demo optional).
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M9 — CLI (phred) and scaffolding~~
|
## M9 — CLI (phred) and scaffolding
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Implement Symfony Console app in `bin/phred`.~~ ✓
|
- [x] Implement Symfony Console app in `bin/phred`.
|
||||||
* ~~Generators: `create:<module>:controller`, `create:<module>:model`, `create:<module>:migration`, `create:<module>:seed`, `create:<module>:test`, `create:<module>:view`.~~ ✓
|
- [x] Generators: `create:<module>:controller`, `create:<module>:model`, `create:<module>:migration`, `create:<module>:seed`, `create:<module>:test`, `create:<module>:view`.
|
||||||
* ~~Utility commands: `test[:<module>]`, `run`, `db:backup`, `db:restore`.~~ ✓
|
- [x] Utility commands: `test[:<module>]`, `run`, `db:backup`, `db:restore`.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Commands generate files with correct namespaces/paths and pass basic smoke tests.~~
|
- [x] Commands generate files with correct namespaces/paths and pass basic smoke tests.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M10 — Security middleware and auth primitives~~
|
## M10 — Security middleware and auth primitives
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Add CORS, Secure Headers middlewares; optional CSRF for template routes.~~ ✓
|
- [x] Add CORS, Secure Headers middlewares; optional CSRF for template routes.
|
||||||
* ~~JWT support (lcobucci/jwt) with simple token issue/verify service.~~ ✓
|
- [x] JWT support (lcobucci/jwt) with simple token issue/verify service.
|
||||||
* ~~Configuration for CORS origins, headers, methods.~~ ✓
|
- [x] Configuration for CORS origins, headers, methods.
|
||||||
* ~~Bind FeatureFlagClientInterface with a default adapter (Flagpole); add small sample usage and env config.~~ ✓
|
- [x] Bind FeatureFlagClientInterface with a default adapter (Flagpole); add small sample usage and env config.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~CORS preflight and secured endpoints behave as configured; JWT‑protected route example works.~~ ✓
|
- [x] CORS preflight and secured endpoints behave as configured; JWT‑protected route example works.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M11 — Logging, HTTP client, and filesystem~~
|
## M11 — Logging, HTTP client, and filesystem
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~Monolog setup with handlers and processors (request ID, memory, timing).~~ ✓
|
- [x] Monolog setup with handlers and processors (request ID, memory, timing).
|
||||||
* ~~Guzzle PSR‑18 client exposure; DI binding for HTTP client interface.~~ ✓
|
- [x] Guzzle PSR‑18 client exposure; DI binding for HTTP client interface.
|
||||||
* ~~Flysystem integration with local adapter; abstraction for storage disks.~~ ✓
|
- [x] Flysystem integration with local adapter; abstraction for storage disks.
|
||||||
* ~~Standardize all core service providers with robust driver validation (similar to OrmServiceProvider).~~ ✓
|
- [x] Standardize all core service providers with robust driver validation (similar to OrmServiceProvider).
|
||||||
* ~~Opportunity Radar: Multiple storage disks, log channel management, and HTTP client middleware.~~ ✓
|
- [x] Opportunity Radar: Multiple storage disks, log channel management, and HTTP client middleware.
|
||||||
* ~~Opportunity Radar 2: Storage Disk "Cloud" Adapters (S3), Advanced HTTP Middleware (Circuit Breaker), and Log Alerting (Slack/Sentry).~~ ✓
|
- [x] Opportunity Radar 2: Storage Disk "Cloud" Adapters (S3), Advanced HTTP Middleware (Circuit Breaker), and Log Alerting (Slack/Sentry).
|
||||||
* ~~Opportunity Radar 3: Githook Integration, Breadcrumb Automation, and TOC for MILESTONES.md.~~ ✓
|
- [x] Opportunity Radar 3: Githook Integration, Breadcrumb Automation, and TOC for MILESTONES.md.
|
||||||
* ~~Acceptance:~~
|
- [x] Opportunity Radar 4: Middleware Groups and Centralized Route List Command.
|
||||||
* ~~Logs include correlation IDs; sample outbound HTTP call via client; file upload/storage demo works.~~ ✓
|
- [x] Opportunity Radar 5: FeatureTestCase, Middleware Group Auto-Mounting, and Route Caching.
|
||||||
|
* Acceptance:
|
||||||
|
- [x] Logs include correlation IDs; sample outbound HTTP call via client; file upload/storage demo works.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## ~~M12 — Serialization/validation utilities and pagination~~
|
## M12 — Serialization/validation utilities and pagination
|
||||||
* ~~Tasks:~~
|
* Tasks:
|
||||||
* ~~REST default: Symfony Serializer normalizers/encoders; document extension points.~~ ✓
|
- [x] REST default: Symfony Serializer normalizers/encoders; document extension points.
|
||||||
* ~~Add simple validation layer using `Phred\Http\Middleware\Middleware` base.~~ ✓
|
- [x] Add simple validation layer using `Phred\Http\Middleware\Middleware` base.
|
||||||
* ~~Pagination helpers (links/meta), REST and JSON:API compatible outputs.~~ ✓
|
- [x] Pagination helpers (links/meta), REST and JSON:API compatible outputs.
|
||||||
* ~~URL extension negotiation: add XML support~~ ✓
|
- [x] URL extension negotiation: add XML support
|
||||||
* ~~Provide `XmlResponseFactory` (or encoder) and integrate with negotiation.~~ ✓
|
- [x] Provide `XmlResponseFactory` (or encoder) and integrate with negotiation.
|
||||||
* ~~Enable `xml` in `URL_EXTENSION_WHITELIST` by default.~~ ✓
|
- [x] Enable `xml` in `URL_EXTENSION_WHITELIST` by default.
|
||||||
* ~~Opportunity Radar: Storage URL generation, Circuit Breaker persistence (PSR-16), and HTTP client profiling.~~ ✓
|
- [x] Opportunity Radar: Storage URL generation, Circuit Breaker persistence (PSR-16), and HTTP client profiling.
|
||||||
* ~~Acceptance:~~
|
* Acceptance:
|
||||||
* ~~Example endpoint validates input, returns 422 with details; paginated listing includes links/meta.~~ ✓
|
- [x] Example endpoint validates input, returns 422 with details; paginated listing includes links/meta.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M13 — OpenAPI and documentation
|
## M13 — OpenAPI and documentation
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Integrate `zircote/swagger-php` annotations.
|
- [x] Integrate `zircote/swagger-php` annotations.
|
||||||
* CLI/task to generate OpenAPI JSON; optional serve route and Redoc UI pointer.
|
- [x] CLI/task to generate OpenAPI JSON; optional serve route and Redoc UI pointer.
|
||||||
* Document auth, pagination, error formats.
|
- [x] Document auth, pagination, error formats.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* Generated OpenAPI document validates; matches sample endpoints.
|
- [x] Generated OpenAPI document validates; matches sample endpoints.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M14 — Testing, quality, and DX
|
## M14 — Testing, quality, and DX
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Establish testing structure with Codeception (unit, integration, API suites).
|
- [x] Establish testing structure with Codeception (unit, integration, API suites).
|
||||||
* Add fixtures/factories via Faker for examples.
|
- [x] Add fixtures/factories via Faker for examples.
|
||||||
* PHPStan level selection and baseline; code style via php-cs-fixer ruleset.
|
- [x] PHPStan level selection and baseline; code style via php-cs-fixer ruleset.
|
||||||
* Pre‑commit hooks (e.g., GrumPHP) or custom git hooks for staged files.
|
- [x] Pre‑commit hooks (e.g., GrumPHP) or custom git hooks for staged files.
|
||||||
* Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only.
|
- [x] Define TestRunnerInterface and a Codeception adapter; otherwise, state tests are run via Composer script only.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* `composer test` runs green across suites; static analysis passes.
|
- [x] `composer test` runs green across suites; static analysis passes.
|
||||||
* CLI tests run via TestRunnerInterface;
|
- [x] CLI tests run via TestRunnerInterface;
|
||||||
* CLI tests run green per module and across suites.
|
- [x] CLI tests run green per module and across suites.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M15 — Caching and performance (optional default)
|
## M15 — Caching and performance (optional default)
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Provide `PSR-16` cache interface binding; suggest `symfony/cache` when enabled.
|
- [x] Provide `PSR-16` cache interface binding; suggest `symfony/cache` when enabled.
|
||||||
* Simple response caching middleware and ETag/Last‑Modified helpers.
|
- [x] Simple response caching middleware and ETag/Last‑Modified helpers.
|
||||||
* Rate limiting middleware (token bucket) suggestion/integration point.
|
- [x] Rate limiting middleware (token bucket) suggestion/integration point.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* Sample endpoint demonstrates cached responses and conditional requests.
|
- [x] Sample endpoint demonstrates cached responses and conditional requests.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M16 — Production hardening and deployment
|
## M16 — Production hardening and deployment
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Config for envs (dev/test/stage/prod), error verbosity, trusted proxies/hosts.
|
- [x] Config for envs (dev/test/stage/prod), error verbosity, trusted proxies/hosts.
|
||||||
* Docker example, PHP‑FPM + Nginx config templates.
|
- [x] Docker example, PHP‑FPM + Nginx config templates.
|
||||||
* Healthcheck endpoint, readiness/liveness probes.
|
- [x] Healthcheck endpoint, readiness/liveness probes.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* Containerized demo serves both API and template pages; healthchecks pass.
|
- [x] Containerized demo serves both API and template pages; healthchecks pass.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M17 — JSON:API enhancements (optional package)
|
## M17 — JSON:API enhancements (optional package)
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* If enabled, integrate `neomerx/json-api` fully: includes, sparse fieldsets, relationships, sorting, filtering, pagination params.
|
- [x] If enabled, integrate `neomerx/json-api` fully: includes, sparse fieldsets, relationships, sorting, filtering, pagination params.
|
||||||
* Adapters/Schema providers per resource type.
|
- [x] Adapters/Schema providers per resource type.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* JSON:API conformance tests for selected endpoints pass; docs updated.
|
- [x] JSON:API conformance tests for selected endpoints pass; docs updated.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M18 — Examples and starter template
|
## M18 — Examples and starter template
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Create `examples/blog` module showcasing controllers, views, templates, ORM, auth, pagination, and both API formats.
|
- [x] Create `examples/shop` module showcasing controllers, views, templates, ORM, auth, pagination, and both API formats.
|
||||||
* Ensure examples use the external stubs and module-specific CLI command conventions.
|
- [x] Ensure examples use the external stubs and module-specific CLI command conventions.
|
||||||
* Provide `composer create-project` skeleton template instructions.
|
- [x] Provide `composer create-project` skeleton template instructions.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* New users can scaffold a working app in minutes following README.
|
- [x] New users can scaffold a working app in minutes following README.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M19 — Documentation site
|
## M19 — Documentation site
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Build the official documentation site (getphred.com) using the Phred framework.
|
- [x] Build the official documentation site (getphred.com) using the Phred framework.
|
||||||
* Internal Documentation API: Design a "Documentation Module" that parses Markdown files from the repo to serve them dynamically.
|
- [x] Internal Documentation API: Design a "Documentation Module" that parses Markdown files from the repo to serve them dynamically.
|
||||||
* ~~Automated TOC Generation: Script to regenerate SPECS.md Table of Contents.~~ ✓
|
- [x] Automated TOC Generation: Script to regenerate SPECS.md Table of Contents.
|
||||||
* Versioned docs and upgrade notes.
|
- [x] Versioned docs and upgrade notes.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* Docs published; links in README; examples maintained.
|
- [x] Docs published; links in README; examples maintained.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M20 — Dynamic Command Help
|
## M20 — Dynamic Command Help
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Implement a `docs` command in `phred` (e.g., `php phred docs <command>`) that opens the relevant documentation page in the user's browser.
|
- [x] Implement a `docs` command in `phred` (e.g., `php phred docs <command>`) that opens the relevant documentation page in the user's browser.
|
||||||
* Build the `docs` command logic after the documentation site (getphred.com) is functional.
|
- [x] Build the `docs` command logic after the documentation site (getphred.com) is functional.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* `php phred docs create:module` opens the correct URL in the default browser.
|
- [x] `php phred docs create:module` opens the correct URL in the default browser.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## M21 — Governance and roadmap tracking
|
## M21 — Governance and roadmap tracking
|
||||||
* Tasks:
|
* Tasks:
|
||||||
* Define contribution guide, issue templates, RFC process for changes.
|
- [x] Define contribution guide, issue templates, RFC process for changes.
|
||||||
* Public roadmap (this milestone list) tracked as GitHub Projects/Issues.
|
- [x] Public roadmap (this milestone list) tracked as GitHub Projects/Issues.
|
||||||
* Acceptance:
|
* Acceptance:
|
||||||
* Contributors can propose features via RFC; roadmap is visible and updated.
|
- [x] Contributors can propose features via RFC; roadmap is visible and updated.
|
||||||
|
|
||||||
[↑ Back to Top](#table-of-contents)
|
[↑ Back to Top](#table-of-contents)
|
||||||
## Notes on sequencing and parallelization
|
|
||||||
* M0–M4 are critical path for the HTTP core and should be completed sequentially.
|
|
||||||
* M5–M8 can progress in parallel with M9 (CLI) once the kernel is stable.
|
|
||||||
* Optional tracks (M15, M17) can be deferred without blocking core usability.
|
|
||||||
|
|
|
||||||
61
README.md
61
README.md
|
|
@ -25,10 +25,10 @@ Phred uses a modular (Django-style) architecture. All your application logic liv
|
||||||
To scaffold a new module:
|
To scaffold a new module:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
php phred create:module Blog
|
php phred create:module Shop
|
||||||
```
|
```
|
||||||
|
|
||||||
This will create the module structure under `modules/Blog`, register the service provider, and mount the routes.
|
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:
|
After creating a module, update your `composer.json` to include the new namespace:
|
||||||
|
|
||||||
|
|
@ -36,7 +36,7 @@ After creating a module, update your `composer.json` to include the new namespac
|
||||||
{
|
{
|
||||||
"autoload": {
|
"autoload": {
|
||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Project\\\\Modules\\\\Blog\\\\": "modules/Blog/"
|
"Modules\\\\Shop\\\\": "modules/Shop/"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -57,6 +57,15 @@ php phred run
|
||||||
|
|
||||||
The application will be available at `http://localhost:8000`.
|
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)
|
## CLI Usage (phred)
|
||||||
|
|
||||||
The `phred` binary provides several utility and scaffolding commands.
|
The `phred` binary provides several utility and scaffolding commands.
|
||||||
|
|
@ -81,8 +90,54 @@ The `phred` binary provides several utility and scaffolding commands.
|
||||||
### Testing & Utilities
|
### Testing & Utilities
|
||||||
* `php phred test` — Run tests for the entire project.
|
* `php phred test` — Run tests for the entire project.
|
||||||
* `php phred test:<module>` — Run tests for a specific module.
|
* `php phred test:<module>` — 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.
|
* `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
|
## Technical Specifications
|
||||||
|
|
||||||
For detailed information on the framework architecture, service providers, configuration, and MVC components, please refer to:
|
For detailed information on the framework architecture, service providers, configuration, and MVC components, please refer to:
|
||||||
|
|
|
||||||
|
|
@ -50,7 +50,7 @@ namespace {
|
||||||
if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) {
|
if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Create a module-specific command name: create:blog:controller
|
// Create a module-specific command name: create:shop:controller
|
||||||
$moduleCmdName = str_replace('create:', 'create:' . strtolower($module) . ':', $cmd->getName());
|
$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
|
// We need a fresh instance for each command name to avoid overwriting Symfony's command registry
|
||||||
|
|
|
||||||
12
codeception.yml
Normal file
12
codeception.yml
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
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
|
||||||
|
|
@ -50,6 +50,11 @@
|
||||||
"bin": [
|
"bin": [
|
||||||
"phred"
|
"phred"
|
||||||
],
|
],
|
||||||
|
"scripts": {
|
||||||
|
"test": "vendor/bin/phpunit",
|
||||||
|
"analyze": "vendor/bin/phpstan analyze",
|
||||||
|
"fix": "vendor/bin/php-cs-fixer fix"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"allow-plugins": {
|
"allow-plugins": {
|
||||||
"php-http/discovery": true
|
"php-http/discovery": true
|
||||||
|
|
|
||||||
19
docker/nginx.conf
Normal file
19
docker/nginx.conf
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
76
phpstan-baseline.neon
Normal file
76
phpstan-baseline.neon
Normal file
|
|
@ -0,0 +1,76 @@
|
||||||
|
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
|
||||||
16
phpstan.neon
Normal file
16
phpstan.neon
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
includes:
|
||||||
|
- phpstan-baseline.neon
|
||||||
|
|
||||||
|
parameters:
|
||||||
|
level: 5
|
||||||
|
paths:
|
||||||
|
- src
|
||||||
|
- modules
|
||||||
|
- config
|
||||||
|
- bootstrap
|
||||||
|
ignoreErrors:
|
||||||
|
- identifier: missingType.iterableValue
|
||||||
|
- identifier: missingType.generics
|
||||||
|
reportUnmatchedIgnoredErrors: false
|
||||||
|
excludePaths:
|
||||||
|
- vendor
|
||||||
|
|
@ -2,7 +2,17 @@ parameters:
|
||||||
level: 5
|
level: 5
|
||||||
paths:
|
paths:
|
||||||
- src
|
- src
|
||||||
checkGenericClassInNonGenericObjectType: false
|
ignoreErrors:
|
||||||
checkMissingIterableValueType: false
|
-
|
||||||
|
identifier: missingType.iterableValue
|
||||||
|
-
|
||||||
|
identifier: missingType.generics
|
||||||
|
reportUnmatchedIgnoredErrors: false
|
||||||
checkUninitializedProperties: true
|
checkUninitializedProperties: true
|
||||||
inferPrivatePropertyTypeFromConstructor: true
|
inferPrivatePropertyTypeFromConstructor: true
|
||||||
|
|
||||||
|
services:
|
||||||
|
-
|
||||||
|
class: Phred\Support\PhpStan\Rules\InvokableControllerRule
|
||||||
|
tags:
|
||||||
|
- phpstan.rules.rule
|
||||||
|
|
|
||||||
104
src/Cache/FileCache.php
Normal file
104
src/Cache/FileCache.php
Normal file
|
|
@ -0,0 +1,104 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Cache;
|
||||||
|
|
||||||
|
use Psr\SimpleCache\CacheInterface;
|
||||||
|
|
||||||
|
final class FileCache implements CacheInterface
|
||||||
|
{
|
||||||
|
private string $directory;
|
||||||
|
|
||||||
|
public function __construct(string $directory)
|
||||||
|
{
|
||||||
|
$this->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';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -32,7 +32,7 @@ abstract class Command
|
||||||
{
|
{
|
||||||
$self = $this;
|
$self = $this;
|
||||||
return new class($self->getName(), $self) extends SymfonyCommand {
|
return new class($self->getName(), $self) extends SymfonyCommand {
|
||||||
public function __construct(private string $name, private Command $wrapped)
|
public function __construct(string $name, private Command $wrapped)
|
||||||
{
|
{
|
||||||
parent::__construct($name);
|
parent::__construct($name);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,7 @@ interface ApiResponseFactoryInterface
|
||||||
* Create a response from a raw associative array payload.
|
* Create a response from a raw associative array payload.
|
||||||
* @param array<string,mixed> $payload
|
* @param array<string,mixed> $payload
|
||||||
* @param int $status
|
* @param int $status
|
||||||
|
* @param array<string,string|string[]> $headers
|
||||||
*/
|
*/
|
||||||
public function fromArray(array $payload, int $status = 200): ResponseInterface;
|
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,57 @@ namespace Phred\Http\Controllers;
|
||||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
final class HealthController
|
final class HealthController
|
||||||
{
|
{
|
||||||
|
use \Phred\Http\Support\ConditionalRequestTrait;
|
||||||
|
|
||||||
public function __construct(private ApiResponseFactoryInterface $factory) {}
|
public function __construct(private ApiResponseFactoryInterface $factory) {}
|
||||||
|
|
||||||
|
#[OA\Get(
|
||||||
|
path: "/_phred/health",
|
||||||
|
summary: "Check framework health",
|
||||||
|
tags: ["System"],
|
||||||
|
responses: [
|
||||||
|
new OA\Response(
|
||||||
|
response: 200,
|
||||||
|
description: "System is healthy",
|
||||||
|
content: new OA\JsonContent(
|
||||||
|
properties: [
|
||||||
|
new OA\Property(property: "ok", type: "boolean"),
|
||||||
|
new OA\Property(property: "framework", type: "string")
|
||||||
|
],
|
||||||
|
type: "object"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
]
|
||||||
|
)]
|
||||||
public function __invoke(Request $request): ResponseInterface
|
public function __invoke(Request $request): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->factory->ok([
|
$type = $request->getQueryParams()['type'] ?? 'liveness';
|
||||||
|
|
||||||
|
$data = [
|
||||||
'ok' => true,
|
'ok' => true,
|
||||||
'framework' => 'Phred',
|
'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]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
31
src/Http/Controllers/OpenApiJsonController.php
Normal file
31
src/Http/Controllers/OpenApiJsonController.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Controllers;
|
||||||
|
|
||||||
|
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
|
||||||
|
final class OpenApiJsonController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ApiResponseFactoryInterface $factory
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$path = getcwd() . '/public/openapi.json';
|
||||||
|
if (!is_file($path)) {
|
||||||
|
error_log("OpenAPI file not found at: " . $path);
|
||||||
|
return $this->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));
|
||||||
|
}
|
||||||
|
}
|
||||||
43
src/Http/Controllers/OpenApiUiController.php
Normal file
43
src/Http/Controllers/OpenApiUiController.php
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Controllers;
|
||||||
|
|
||||||
|
use Phred\Support\Contracts\ConfigInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
|
||||||
|
final class OpenApiUiController
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private ConfigInterface $config
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function __invoke(Request $request): ResponseInterface
|
||||||
|
{
|
||||||
|
$jsonUrl = $this->config->get('APP_URL', 'http://localhost:8000') . '/_phred/openapi.json';
|
||||||
|
|
||||||
|
$html = <<<HTML
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Phred API Documentation</title>
|
||||||
|
<meta charset="utf-8"/>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<link href="https://fonts.googleapis.com/css?family=Montserrat:300,400,700|Roboto:300,400,700" rel="stylesheet">
|
||||||
|
<style>body { margin: 0; padding: 0; }</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<redoc spec-url='{$jsonUrl}'></redoc>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/redoc@next/bundles/redoc.standalone.js"> </script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
HTML;
|
||||||
|
|
||||||
|
return (new Psr17Factory())
|
||||||
|
->createResponse(200)
|
||||||
|
->withHeader('Content-Type', 'text/html')
|
||||||
|
->withBody((new Psr17Factory())->createStream($html));
|
||||||
|
}
|
||||||
|
}
|
||||||
13
src/Http/JsonApi/SchemaProviderInterface.php
Normal file
13
src/Http/JsonApi/SchemaProviderInterface.php
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\JsonApi;
|
||||||
|
|
||||||
|
interface SchemaProviderInterface
|
||||||
|
{
|
||||||
|
public function getResourceType(): string;
|
||||||
|
public function getId(mixed $resource): string;
|
||||||
|
public function getAttributes(mixed $resource): array;
|
||||||
|
public function getRelationships(mixed $resource): array;
|
||||||
|
public function getLinks(mixed $resource): array;
|
||||||
|
}
|
||||||
|
|
@ -41,16 +41,19 @@ final class Kernel
|
||||||
|
|
||||||
public function handle(ServerRequest $request): ResponseInterface
|
public function handle(ServerRequest $request): ResponseInterface
|
||||||
{
|
{
|
||||||
$psr17 = new Psr17Factory();
|
// 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);
|
$config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class);
|
||||||
|
|
||||||
// CORS
|
// CORS
|
||||||
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
|
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
|
||||||
$corsSettings->init(
|
$scheme = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_SCHEME) ?: 'http';
|
||||||
parse_url((string)getenv('APP_URL'), PHP_URL_SCHEME) ?: 'http',
|
$host = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_HOST) ?: 'localhost';
|
||||||
parse_url((string)getenv('APP_URL'), PHP_URL_HOST) ?: 'localhost',
|
$port = (int)parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_PORT) ?: 80;
|
||||||
(int)parse_url((string)getenv('APP_URL'), PHP_URL_PORT) ?: 80
|
$corsSettings->init($scheme, $host, $port);
|
||||||
);
|
|
||||||
$corsSettings->setAllowedOrigins($config->get('cors.origin', ['*']));
|
$corsSettings->setAllowedOrigins($config->get('cors.origin', ['*']));
|
||||||
$corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']));
|
$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->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With']));
|
||||||
|
|
@ -58,8 +61,17 @@ final class Kernel
|
||||||
$corsSettings->enableAllMethodsAllowed();
|
$corsSettings->enableAllMethodsAllowed();
|
||||||
$corsSettings->enableAllHeadersAllowed();
|
$corsSettings->enableAllHeadersAllowed();
|
||||||
|
|
||||||
$middleware = [];
|
// Initialize RequestContext with the original request
|
||||||
if (filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN)) {
|
\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 {
|
$middleware[] = new class extends Middleware\Middleware {
|
||||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||||
{
|
{
|
||||||
|
|
@ -82,17 +94,52 @@ final class Kernel
|
||||||
new Middleware\Security\SecureHeadersMiddleware($config),
|
new Middleware\Security\SecureHeadersMiddleware($config),
|
||||||
// CORS
|
// CORS
|
||||||
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
|
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(
|
new Middleware\ProblemDetailsMiddleware(
|
||||||
filter_var($config->get('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOLEAN),
|
$debug,
|
||||||
null,
|
null,
|
||||||
null,
|
null,
|
||||||
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
|
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
|
||||||
),
|
),
|
||||||
// Perform extension-based content negotiation hinting before standard negotiation
|
// 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\UrlExtensionNegotiationMiddleware(),
|
||||||
new Middleware\ContentNegotiationMiddleware(),
|
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\RoutingMiddleware($this->dispatcher, $psr17),
|
||||||
new Middleware\DispatchMiddleware($psr17),
|
new Middleware\MiddlewareGroupMiddleware($config, $this->container),
|
||||||
|
new Middleware\DispatchMiddleware($this->container, $psr17),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$relay = new Relay($middleware);
|
$relay = new Relay($middleware);
|
||||||
|
|
@ -105,6 +152,7 @@ final class Kernel
|
||||||
|
|
||||||
// Allow service providers to register definitions before defaults
|
// Allow service providers to register definitions before defaults
|
||||||
$configAdapter = new \Phred\Support\DefaultConfig();
|
$configAdapter = new \Phred\Support\DefaultConfig();
|
||||||
|
|
||||||
$providers = new \Phred\Support\ProviderRepository($configAdapter);
|
$providers = new \Phred\Support\ProviderRepository($configAdapter);
|
||||||
$providers->load();
|
$providers->load();
|
||||||
$providers->registerAll($builder);
|
$providers->registerAll($builder);
|
||||||
|
|
@ -119,28 +167,71 @@ final class Kernel
|
||||||
\Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::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\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
|
||||||
\Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::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();
|
$container = $builder->build();
|
||||||
|
|
||||||
|
// Boot providers after container is available
|
||||||
// Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests)
|
// Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests)
|
||||||
\Phred\Http\Routing\RouteRegistry::clear();
|
\Phred\Http\Routing\RouteRegistry::clear();
|
||||||
// Boot providers after container is available
|
|
||||||
$providers->bootAll($container);
|
$providers->bootAll($container);
|
||||||
|
|
||||||
return $container;
|
return $container;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildDispatcher(): Dispatcher
|
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';
|
$routesPath = dirname(__DIR__, 2) . '/routes';
|
||||||
$collector = static function (RouteCollector $r) use ($routesPath): void {
|
$registry = \Phred\Http\Routing\RouteRegistry::class;
|
||||||
|
|
||||||
|
return function (RouteCollector $r) use ($routesPath, $registry): void {
|
||||||
// Load user-defined routes if present
|
// Load user-defined routes if present
|
||||||
$router = new Router($r);
|
$router = new Router($r);
|
||||||
|
|
||||||
foreach (['web.php', 'api.php'] as $file) {
|
foreach (['web.php', 'api.php'] as $file) {
|
||||||
$path = $routesPath . '/' . $file;
|
$path = $routesPath . '/' . $file;
|
||||||
if (is_file($path)) {
|
if (is_file($path)) {
|
||||||
|
$middleware = $file === 'api.php' ? ['api'] : [];
|
||||||
|
$router->group(['prefix' => '', 'middleware' => $middleware], static function (Router $router) use ($path): void {
|
||||||
/** @noinspection PhpIncludeInspection */
|
/** @noinspection PhpIncludeInspection */
|
||||||
(static function ($router) use ($path) { require $path; })($router);
|
(static function ($router) use ($path) { require $path; })($router);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -156,35 +247,27 @@ final class Kernel
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
// Auto-mount only if the module's web.php wasn't already included via RouteGroups in root file.
|
// Auto-mount only if the module's web.php wasn't already included via RouteGroups in root file.
|
||||||
$autoInclude = function (string $relative, string $prefix) use ($modRoutes, $router): void {
|
$autoInclude = function (string $relative, string $prefix, array $middleware = []) use ($modRoutes, $router, $registry): void {
|
||||||
$file = $modRoutes . '/' . $relative;
|
$file = $modRoutes . '/' . $relative;
|
||||||
if (is_file($file)) {
|
if (is_file($file) && !$registry::isLoaded($file)) {
|
||||||
$router->group('/' . strtolower($prefix), static function (Router $r) use ($file): void {
|
$router->group(['prefix' => '/' . strtolower($prefix), 'middleware' => $middleware], static function (Router $r) use ($file): void {
|
||||||
/** @noinspection PhpIncludeInspection */
|
/** @noinspection PhpIncludeInspection */
|
||||||
(static function ($router) use ($file) { require $file; })($r);
|
(static function ($router) use ($file) { require $file; })($r);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
$autoInclude('web.php', $mod);
|
$autoInclude('web.php', $mod);
|
||||||
// api.php can be auto-mounted under /api/<module>
|
// api.php auto-mounted under /api/<module> with 'api' middleware group
|
||||||
$apiFile = $modRoutes . '/api.php';
|
$autoInclude('api.php', 'api/' . $mod, ['api']);
|
||||||
if (is_file($apiFile)) {
|
|
||||||
$router->group('/api/' . strtolower($mod), static function (Router $r) use ($apiFile): void {
|
|
||||||
/** @noinspection PhpIncludeInspection */
|
|
||||||
(static function ($router) use ($apiFile) { require $apiFile; })($r);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Allow providers to contribute routes
|
// Allow providers to contribute routes
|
||||||
\Phred\Http\Routing\RouteRegistry::apply($r, $router);
|
$registry::apply($r, $router);
|
||||||
|
|
||||||
// Ensure default demo routes exist for acceptance/demo
|
// Ensure default demo routes exist for acceptance/demo
|
||||||
$r->addRoute('GET', '/_phred/health', [Controllers\HealthController::class, '__invoke']);
|
$r->addRoute('GET', '/_phred/health', Controllers\HealthController::class);
|
||||||
$r->addRoute('GET', '/_phred/format', [Controllers\FormatController::class, '__invoke']);
|
$r->addRoute('GET', '/_phred/format', Controllers\FormatController::class);
|
||||||
};
|
};
|
||||||
|
|
||||||
return simpleDispatcher($collector);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
82
src/Http/Middleware/Cache/ResponseCacheMiddleware.php
Normal file
82
src/Http/Middleware/Cache/ResponseCacheMiddleware.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Middleware\Cache;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
use Psr\SimpleCache\CacheInterface;
|
||||||
|
|
||||||
|
final class ResponseCacheMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private CacheInterface $cache,
|
||||||
|
private int $ttl = 3600
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
// Only cache GET requests
|
||||||
|
if ($request->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
72
src/Http/Middleware/CompressionMiddleware.php
Normal file
72
src/Http/Middleware/CompressionMiddleware.php
Normal file
|
|
@ -0,0 +1,72 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Middleware;
|
||||||
|
|
||||||
|
use Phred\Support\Config;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compresses the response body if the client supports it and compression is enabled.
|
||||||
|
*/
|
||||||
|
final class CompressionMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$response = $handler->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -23,9 +23,15 @@ class ContentNegotiationMiddleware extends Middleware
|
||||||
|
|
||||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||||
{
|
{
|
||||||
$format = $this->profileSelf(function () use ($request) {
|
|
||||||
$cfg = $this->config ?? new DefaultConfig();
|
$cfg = $this->config ?? new DefaultConfig();
|
||||||
$defaultFormat = strtolower((string) $cfg->get('api_format', 'rest'));
|
$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
|
// Allow Accept header to override
|
||||||
$accept = $request->getHeaderLine('Accept');
|
$accept = $request->getHeaderLine('Accept');
|
||||||
|
|
@ -42,6 +48,13 @@ class ContentNegotiationMiddleware extends Middleware
|
||||||
return $defaultFormat;
|
return $defaultFormat;
|
||||||
});
|
});
|
||||||
|
|
||||||
return $handler->handle($request->withAttribute(self::ATTR_API_FORMAT, $format));
|
// 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,12 @@ namespace Phred\Http\Middleware;
|
||||||
|
|
||||||
use DI\ContainerBuilder;
|
use DI\ContainerBuilder;
|
||||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||||
|
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||||
|
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
|
||||||
use Phred\Http\RequestContext;
|
use Phred\Http\RequestContext;
|
||||||
|
use Phred\Http\Responses\JsonApiResponseFactory;
|
||||||
|
use Phred\Http\Responses\RestResponseFactory;
|
||||||
|
use Phred\Http\Responses\XmlResponseFactory;
|
||||||
use Psr\Http\Message\ResponseInterface;
|
use Psr\Http\Message\ResponseInterface;
|
||||||
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
||||||
use Psr\Http\Server\MiddlewareInterface;
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
|
@ -14,6 +19,7 @@ use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
final class DispatchMiddleware implements MiddlewareInterface
|
final class DispatchMiddleware implements MiddlewareInterface
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
private \DI\Container $container,
|
||||||
private Psr17Factory $psr17
|
private Psr17Factory $psr17
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -26,8 +32,8 @@ final class DispatchMiddleware implements MiddlewareInterface
|
||||||
return $this->jsonError('No route handler', 500);
|
return $this->jsonError('No route handler', 500);
|
||||||
}
|
}
|
||||||
|
|
||||||
$requestContainer = $this->buildRequestScopedContainer($request);
|
$format = (string) ($request->getAttribute('phred.api_format', 'rest'));
|
||||||
$callable = $this->resolveCallable($handlerSpec, $requestContainer);
|
$callable = $this->resolveCallable($handlerSpec, $this->container);
|
||||||
|
|
||||||
RequestContext::set($request);
|
RequestContext::set($request);
|
||||||
try {
|
try {
|
||||||
|
|
@ -67,38 +73,18 @@ final class DispatchMiddleware implements MiddlewareInterface
|
||||||
return $res;
|
return $res;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function buildRequestScopedContainer(ServerRequest $request): \DI\Container
|
|
||||||
{
|
|
||||||
$format = (string) ($request->getAttribute(ContentNegotiationMiddleware::ATTR_API_FORMAT, 'rest'));
|
|
||||||
$builder = new ContainerBuilder();
|
|
||||||
|
|
||||||
$definition = match ($format) {
|
|
||||||
'jsonapi' => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
|
|
||||||
'xml' => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
|
|
||||||
default => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
|
|
||||||
};
|
|
||||||
|
|
||||||
$builder->addDefinitions([
|
|
||||||
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => $definition,
|
|
||||||
]);
|
|
||||||
return $builder->build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private function resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable
|
private function resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable
|
||||||
{
|
{
|
||||||
if (is_array($handlerSpec) && isset($handlerSpec[0]) && is_string($handlerSpec[0])) {
|
|
||||||
$class = $handlerSpec[0];
|
|
||||||
$method = $handlerSpec[1] ?? '__invoke';
|
|
||||||
$controller = $requestContainer->get($class);
|
|
||||||
return [$controller, $method];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (is_string($handlerSpec) && class_exists($handlerSpec)) {
|
if (is_string($handlerSpec) && class_exists($handlerSpec)) {
|
||||||
$controller = $requestContainer->get($handlerSpec);
|
$controller = $requestContainer->get($handlerSpec);
|
||||||
return [$controller, '__invoke'];
|
return [$controller, '__invoke'];
|
||||||
}
|
}
|
||||||
|
|
||||||
return $handlerSpec; // already a callable/closure
|
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
|
private function invokeCallable(callable $callable, ServerRequest $request, array $vars): mixed
|
||||||
|
|
|
||||||
44
src/Http/Middleware/JsonApi/JsonApiQueryMiddleware.php
Normal file
44
src/Http/Middleware/JsonApi/JsonApiQueryMiddleware.php
Normal file
|
|
@ -0,0 +1,44 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Middleware\JsonApi;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
|
||||||
|
final class JsonApiQueryMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public const ATTR_INCLUDE = 'jsonapi.include';
|
||||||
|
public const ATTR_FIELDS = 'jsonapi.fields';
|
||||||
|
public const ATTR_SORT = 'jsonapi.sort';
|
||||||
|
public const ATTR_FILTER = 'jsonapi.filter';
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$params = $request->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
69
src/Http/Middleware/MiddlewareGroupMiddleware.php
Normal file
69
src/Http/Middleware/MiddlewareGroupMiddleware.php
Normal file
|
|
@ -0,0 +1,69 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Middleware;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
use Phred\Support\Contracts\ConfigInterface;
|
||||||
|
use DI\Container;
|
||||||
|
use Relay\Relay;
|
||||||
|
|
||||||
|
final class MiddlewareGroupMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function __construct(
|
||||||
|
private readonly ConfigInterface $config,
|
||||||
|
private readonly Container $container
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$groups = (array) $request->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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -30,10 +30,16 @@ final class RoutingMiddleware implements MiddlewareInterface
|
||||||
$response->getBody()->write(json_encode(['error' => 'Method Not Allowed'], JSON_UNESCAPED_SLASHES));
|
$response->getBody()->write(json_encode(['error' => 'Method Not Allowed'], JSON_UNESCAPED_SLASHES));
|
||||||
return $response->withHeader('Content-Type', 'application/json');
|
return $response->withHeader('Content-Type', 'application/json');
|
||||||
case Dispatcher::FOUND:
|
case Dispatcher::FOUND:
|
||||||
[$status, $handlerSpec, $vars] = $routeInfo;
|
[$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
|
$request = $request
|
||||||
->withAttribute('phred.route.handler', $handlerSpec)
|
->withAttribute('phred.route.handler', $handlerSpec)
|
||||||
->withAttribute('phred.route.vars', $vars);
|
->withAttribute('phred.route.vars', $vars)
|
||||||
|
->withAttribute('phred.route.middleware', $middleware)
|
||||||
|
->withAttribute('phred.route.name', $name);
|
||||||
return $handler->handle($request);
|
return $handler->handle($request);
|
||||||
default:
|
default:
|
||||||
$response = $this->psr17->createResponse(500);
|
$response = $this->psr17->createResponse(500);
|
||||||
|
|
|
||||||
45
src/Http/Middleware/TrustedProxiesMiddleware.php
Normal file
45
src/Http/Middleware/TrustedProxiesMiddleware.php
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Middleware;
|
||||||
|
|
||||||
|
use Phred\Support\Contracts\ConfigInterface;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
use Psr\Http\Server\MiddlewareInterface;
|
||||||
|
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||||
|
|
||||||
|
final class TrustedProxiesMiddleware implements MiddlewareInterface
|
||||||
|
{
|
||||||
|
public function __construct(private ConfigInterface $config) {}
|
||||||
|
|
||||||
|
public function process(Request $request, Handler $handler): ResponseInterface
|
||||||
|
{
|
||||||
|
$trustedProxies = (array) $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -86,8 +86,8 @@ final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface
|
||||||
private function mapToHint(string $ext): ?string
|
private function mapToHint(string $ext): ?string
|
||||||
{
|
{
|
||||||
return match ($ext) {
|
return match ($ext) {
|
||||||
'json' => 'json',
|
'json' => 'rest',
|
||||||
'xml' => 'xml', // reserved for M12
|
'xml' => 'xml',
|
||||||
'php', 'none' => 'html',
|
'php', 'none' => 'html',
|
||||||
default => null,
|
default => null,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -41,15 +41,15 @@ final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface
|
||||||
return $this->delegate()->error($status, $title, $detail, $extra);
|
return $this->delegate()->error($status, $title, $detail, $extra);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fromArray(array $payload, int $status = 200): ResponseInterface
|
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->delegate()->fromArray($payload, $status);
|
return $this->delegate()->fromArray($payload, $status, $headers);
|
||||||
}
|
}
|
||||||
|
|
||||||
private function delegate(): ApiResponseFactoryInterface
|
private function delegate(): ApiResponseFactoryInterface
|
||||||
{
|
{
|
||||||
$req = RequestContext::get();
|
$req = RequestContext::get();
|
||||||
$format = $req?->getAttribute(Negotiation::ATTR_API_FORMAT) ?? 'rest';
|
$format = $req?->getAttribute('phred.api_format') ?? 'rest';
|
||||||
return match ($format) {
|
return match ($format) {
|
||||||
'jsonapi' => $this->jsonapi,
|
'jsonapi' => $this->jsonapi,
|
||||||
'xml' => $this->xml,
|
'xml' => $this->xml,
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
final class JsonApiResponseFactory implements ApiResponseFactoryInterface
|
final class JsonApiResponseFactory implements ApiResponseFactoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(private Psr17Factory $psr17 = new Psr17Factory()) {}
|
public function __construct(private Psr17Factory $psr17) {}
|
||||||
|
|
||||||
public function ok(array $data = []): ResponseInterface
|
public function ok(array $data = []): ResponseInterface
|
||||||
{
|
{
|
||||||
|
|
@ -44,10 +44,14 @@ final class JsonApiResponseFactory implements ApiResponseFactoryInterface
|
||||||
return $this->document(['errors' => [$error]], $status);
|
return $this->document(['errors' => [$error]], $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fromArray(array $payload, int $status = 200): ResponseInterface
|
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||||
{
|
{
|
||||||
// Caller must ensure payload is a valid JSON:API document shape
|
// Caller must ensure payload is a valid JSON:API document shape
|
||||||
return $this->document($payload, $status);
|
$res = $this->document($payload, $status);
|
||||||
|
foreach ($headers as $name => $value) {
|
||||||
|
$res = $res->withHeader($name, $value);
|
||||||
|
}
|
||||||
|
return $res;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
final class RestResponseFactory implements ApiResponseFactoryInterface
|
final class RestResponseFactory implements ApiResponseFactoryInterface
|
||||||
{
|
{
|
||||||
public function __construct(private Psr17Factory $psr17 = new Psr17Factory()) {}
|
public function __construct(private Psr17Factory $psr17) {}
|
||||||
|
|
||||||
public function ok(array $data = []): ResponseInterface
|
public function ok(array $data = []): ResponseInterface
|
||||||
{
|
{
|
||||||
|
|
@ -44,9 +44,13 @@ final class RestResponseFactory implements ApiResponseFactoryInterface
|
||||||
return $res;
|
return $res;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fromArray(array $payload, int $status = 200): ResponseInterface
|
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->json($payload, $status);
|
$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
|
private function json(array $data, int $status): ResponseInterface
|
||||||
|
|
|
||||||
|
|
@ -48,9 +48,13 @@ final class XmlResponseFactory implements ApiResponseFactoryInterface
|
||||||
return $this->xml(['error' => $payload], $status);
|
return $this->xml(['error' => $payload], $status);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function fromArray(array $payload, int $status = 200): ResponseInterface
|
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||||
{
|
{
|
||||||
return $this->xml($payload, $status);
|
$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
|
private function xml(array $data, int $status): ResponseInterface
|
||||||
|
|
|
||||||
|
|
@ -10,43 +10,74 @@ use FastRoute\RouteCollector;
|
||||||
*/
|
*/
|
||||||
final class Router
|
final class Router
|
||||||
{
|
{
|
||||||
public function __construct(private RouteCollector $collector) {}
|
private array $groupMiddleware = [];
|
||||||
|
|
||||||
public function get(string $path, array|string|callable $handler): void
|
public function __construct(private RouteCollector $collector, array $groupMiddleware = [])
|
||||||
{
|
{
|
||||||
$this->collector->addRoute('GET', $path, $handler);
|
$this->groupMiddleware = $groupMiddleware;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function post(string $path, array|string|callable $handler): void
|
public function get(string $path, string|callable $handler, array $options = []): void
|
||||||
{
|
{
|
||||||
$this->collector->addRoute('POST', $path, $handler);
|
$this->addRoute('GET', $path, $handler, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function put(string $path, array|string|callable $handler): void
|
public function post(string $path, string|callable $handler, array $options = []): void
|
||||||
{
|
{
|
||||||
$this->collector->addRoute('PUT', $path, $handler);
|
$this->addRoute('POST', $path, $handler, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function patch(string $path, array|string|callable $handler): void
|
public function put(string $path, string|callable $handler, array $options = []): void
|
||||||
{
|
{
|
||||||
$this->collector->addRoute('PATCH', $path, $handler);
|
$this->addRoute('PUT', $path, $handler, $options);
|
||||||
}
|
}
|
||||||
|
|
||||||
public function delete(string $path, array|string|callable $handler): void
|
public function patch(string $path, string|callable $handler, array $options = []): void
|
||||||
{
|
{
|
||||||
$this->collector->addRoute('DELETE', $path, $handler);
|
$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.
|
* Group routes under a common path prefix and/or middleware.
|
||||||
*
|
*
|
||||||
* Example:
|
* Example:
|
||||||
* $router->group('/api', function (Router $r) { $r->get('/health', Handler::class); });
|
* $router->group('/api', function (Router $r) { ... });
|
||||||
|
* $router->group(['prefix' => '/api', 'middleware' => 'api'], function (Router $r) { ... });
|
||||||
*/
|
*/
|
||||||
public function group(string $prefix, callable $routes): void
|
public function group(string|array $attributes, callable $routes): void
|
||||||
{
|
{
|
||||||
$this->collector->addGroup($prefix, function (RouteCollector $rc) use ($routes): void {
|
$prefix = is_array($attributes) ? ($attributes['prefix'] ?? '') : $attributes;
|
||||||
$routes(new Router($rc));
|
$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));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,11 @@ final class RouteGroups
|
||||||
/**
|
/**
|
||||||
* Include a set of routes under a prefix using the provided Router instance.
|
* Include a set of routes under a prefix using the provided Router instance.
|
||||||
*/
|
*/
|
||||||
public static function include(Router $router, string $prefix, callable $loader): void
|
public static function include(Router $router, string $prefix, callable $loader, ?string $file = null): void
|
||||||
{
|
{
|
||||||
|
if ($file) {
|
||||||
|
RouteRegistry::markAsLoaded($file);
|
||||||
|
}
|
||||||
$router->group($prefix, static function (Router $r) use ($loader): void {
|
$router->group($prefix, static function (Router $r) use ($loader): void {
|
||||||
$loader($r);
|
$loader($r);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,19 @@ final class RouteRegistry
|
||||||
/** @var list<callable(RouteCollector, Router):void> */
|
/** @var list<callable(RouteCollector, Router):void> */
|
||||||
private static array $callbacks = [];
|
private static array $callbacks = [];
|
||||||
|
|
||||||
|
/** @var array<string,bool> */
|
||||||
|
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
|
public static function add(callable $registrar): void
|
||||||
{
|
{
|
||||||
self::$callbacks[] = $registrar;
|
self::$callbacks[] = $registrar;
|
||||||
|
|
@ -23,6 +36,7 @@ final class RouteRegistry
|
||||||
public static function clear(): void
|
public static function clear(): void
|
||||||
{
|
{
|
||||||
self::$callbacks = [];
|
self::$callbacks = [];
|
||||||
|
self::$loadedFiles = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function apply(RouteCollector $collector, Router $router): void
|
public static function apply(RouteCollector $collector, Router $router): void
|
||||||
|
|
|
||||||
27
src/Http/Support/ConditionalRequestTrait.php
Normal file
27
src/Http/Support/ConditionalRequestTrait.php
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Http\Support;
|
||||||
|
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
|
|
||||||
|
trait ConditionalRequestTrait
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Check if the request is fresh based on ETag.
|
||||||
|
*/
|
||||||
|
protected function isFresh(Request $request, string $etag): bool
|
||||||
|
{
|
||||||
|
$ifNoneMatch = $request->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)) . '"';
|
||||||
|
}
|
||||||
|
}
|
||||||
20
src/OpenApi/Spec.php
Normal file
20
src/OpenApi/Spec.php
Normal file
|
|
@ -0,0 +1,20 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\OpenApi;
|
||||||
|
|
||||||
|
use OpenApi\Attributes as OA;
|
||||||
|
|
||||||
|
#[OA\Info(title: "Phred API", version: "0.1")]
|
||||||
|
#[OA\Server(url: "http://localhost:8000", description: "Local Development")]
|
||||||
|
#[OA\SecurityScheme(
|
||||||
|
securityScheme: "bearerAuth",
|
||||||
|
type: "http",
|
||||||
|
name: "Authorization",
|
||||||
|
in: "header",
|
||||||
|
bearerFormat: "JWT",
|
||||||
|
scheme: "bearer"
|
||||||
|
)]
|
||||||
|
class Spec
|
||||||
|
{
|
||||||
|
}
|
||||||
28
src/Providers/Core/CacheServiceProvider.php
Normal file
28
src/Providers/Core/CacheServiceProvider.php
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Providers\Core;
|
||||||
|
|
||||||
|
use DI\Container;
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Phred\Cache\FileCache;
|
||||||
|
use Phred\Support\Contracts\ConfigInterface;
|
||||||
|
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||||
|
use Psr\SimpleCache\CacheInterface;
|
||||||
|
|
||||||
|
final class CacheServiceProvider implements ServiceProviderInterface
|
||||||
|
{
|
||||||
|
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||||
|
{
|
||||||
|
$cacheDir = getcwd() . '/storage/cache';
|
||||||
|
|
||||||
|
$builder->addDefinitions([
|
||||||
|
CacheInterface::class => \DI\autowire(FileCache::class)
|
||||||
|
->constructor($cacheDir),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(Container $container): void
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
31
src/Providers/Core/OpenApiServiceProvider.php
Normal file
31
src/Providers/Core/OpenApiServiceProvider.php
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Providers\Core;
|
||||||
|
|
||||||
|
use DI\Container;
|
||||||
|
use DI\ContainerBuilder;
|
||||||
|
use Phred\Http\Routing\RouteRegistry;
|
||||||
|
use Phred\Support\Contracts\ConfigInterface;
|
||||||
|
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||||
|
use Phred\Http\Controllers\OpenApiJsonController;
|
||||||
|
use Phred\Http\Controllers\OpenApiUiController;
|
||||||
|
|
||||||
|
final class OpenApiServiceProvider implements ServiceProviderInterface
|
||||||
|
{
|
||||||
|
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||||
|
{
|
||||||
|
// No special bindings needed for OpenApi controllers as they can be autowired
|
||||||
|
}
|
||||||
|
|
||||||
|
public function boot(Container $container): void
|
||||||
|
{
|
||||||
|
RouteRegistry::add(static function ($r, $router): void {
|
||||||
|
$router->group('/_phred', static function ($router): void {
|
||||||
|
$router->get('/openapi', OpenApiJsonController::class);
|
||||||
|
$router->get('/openapi.json', OpenApiJsonController::class);
|
||||||
|
$router->get('/docs', OpenApiUiController::class);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -118,7 +118,7 @@ final class CircuitBreakerMiddleware
|
||||||
self::$localIsOpen[$host] = $state['isOpen'];
|
self::$localIsOpen[$host] = $state['isOpen'];
|
||||||
}
|
}
|
||||||
|
|
||||||
public static function clear(string $host = null): void
|
public static function clear(?string $host = null): void
|
||||||
{
|
{
|
||||||
if ($host) {
|
if ($host) {
|
||||||
unset(self::$localFailures[$host], self::$localLastFailureTime[$host], self::$localIsOpen[$host]);
|
unset(self::$localFailures[$host], self::$localLastFailureTime[$host], self::$localIsOpen[$host]);
|
||||||
|
|
|
||||||
92
src/Support/PhpStan/Rules/InvokableControllerRule.php
Normal file
92
src/Support/PhpStan/Rules/InvokableControllerRule.php
Normal file
|
|
@ -0,0 +1,92 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Support\PhpStan\Rules;
|
||||||
|
|
||||||
|
use PhpParser\Node;
|
||||||
|
use PhpParser\Node\Stmt\Class_;
|
||||||
|
use PHPStan\Analyser\Scope;
|
||||||
|
use PHPStan\Rules\Rule;
|
||||||
|
use PHPStan\Rules\RuleErrorBuilder;
|
||||||
|
use Phred\Mvc\Controller;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @implements Rule<Class_>
|
||||||
|
*/
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -40,12 +40,14 @@ final class ProviderRepository
|
||||||
$app = array_values(array_unique(array_merge($fileApp, (array) Config::get('providers.app', []))));
|
$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', []))));
|
$modules = array_values(array_unique(array_merge($fileModules, (array) Config::get('providers.modules', []))));
|
||||||
|
|
||||||
|
$loadedClasses = [];
|
||||||
foreach ([$core, $app, $modules] as $group) {
|
foreach ([$core, $app, $modules] as $group) {
|
||||||
foreach ($group as $class) {
|
foreach ($group as $class) {
|
||||||
if (is_string($class) && class_exists($class)) {
|
if (is_string($class) && class_exists($class) && !isset($loadedClasses[$class])) {
|
||||||
$instance = new $class();
|
$instance = new $class();
|
||||||
if ($instance instanceof ServiceProviderInterface) {
|
if ($instance instanceof ServiceProviderInterface) {
|
||||||
$this->providers[] = $instance;
|
$this->providers[] = $instance;
|
||||||
|
$loadedClasses[$class] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -67,6 +69,9 @@ final class ProviderRepository
|
||||||
if (!is_dir($providersPath)) {
|
if (!is_dir($providersPath)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$namespace = $this->resolveModuleNamespace($modulePath, $entry);
|
||||||
|
|
||||||
foreach (scandir($providersPath) ?: [] as $file) {
|
foreach (scandir($providersPath) ?: [] as $file) {
|
||||||
if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) {
|
if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) {
|
||||||
continue;
|
continue;
|
||||||
|
|
@ -75,11 +80,12 @@ final class ProviderRepository
|
||||||
if (!str_ends_with($classBase, 'ServiceProvider')) {
|
if (!str_ends_with($classBase, 'ServiceProvider')) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$fqcn = "Project\\\\Modules\\\\{$entry}\\\\Providers\\\\{$classBase}";
|
$fqcn = "{$namespace}Providers\\\\{$classBase}";
|
||||||
if (class_exists($fqcn)) {
|
if (class_exists($fqcn) && !isset($loadedClasses[$fqcn])) {
|
||||||
$instance = new $fqcn();
|
$instance = new $fqcn();
|
||||||
if ($instance instanceof ServiceProviderInterface) {
|
if ($instance instanceof ServiceProviderInterface) {
|
||||||
$this->providers[] = $instance;
|
$this->providers[] = $instance;
|
||||||
|
$loadedClasses[$fqcn] = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -87,6 +93,25 @@ final class ProviderRepository
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
public function registerAll(ContainerBuilder $builder): void
|
||||||
{
|
{
|
||||||
foreach ($this->providers as $provider) {
|
foreach ($this->providers as $provider) {
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,12 @@ final class CodeceptionRunner implements TestRunnerInterface
|
||||||
{
|
{
|
||||||
public function run(?string $suite = null): int
|
public function run(?string $suite = null): int
|
||||||
{
|
{
|
||||||
// placeholder implementation always succeeds
|
$command = 'vendor/bin/codecept run';
|
||||||
return 0;
|
if ($suite) {
|
||||||
|
$command .= ' ' . escapeshellarg($suite);
|
||||||
|
}
|
||||||
|
|
||||||
|
passthru($command, $exitCode);
|
||||||
|
return $exitCode;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
58
src/Testing/TestCase.php
Normal file
58
src/Testing/TestCase.php
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Testing;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase as PHPUnitTestCase;
|
||||||
|
use Phred\Http\Kernel;
|
||||||
|
use Nyholm\Psr7\ServerRequest;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
abstract class TestCase extends PHPUnitTestCase
|
||||||
|
{
|
||||||
|
protected ?Kernel $kernel = null;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
parent::setUp();
|
||||||
|
$this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
64
src/Testing/TestResponse.php
Normal file
64
src/Testing/TestResponse.php
Normal file
|
|
@ -0,0 +1,64 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Testing;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\Assert;
|
||||||
|
use Psr\Http\Message\ResponseInterface;
|
||||||
|
|
||||||
|
class TestResponse
|
||||||
|
{
|
||||||
|
public function __construct(private ResponseInterface $response) {}
|
||||||
|
|
||||||
|
public function assertStatus(int $status): self
|
||||||
|
{
|
||||||
|
Assert::assertEquals($status, $this->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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Command {
|
||||||
'module' => [
|
'module' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:controller',
|
'description' => 'Target module name (e.g., Shop). Optional if using create:<module>:controller',
|
||||||
],
|
],
|
||||||
'--view' => [
|
'--view' => [
|
||||||
'mode' => 'option',
|
'mode' => 'option',
|
||||||
|
|
@ -77,7 +77,8 @@ return new class extends Command {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$namespace = "Project\\Modules\\$module\\Controllers";
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$namespace = "{$baseNamespace}\\$module\\Controllers";
|
||||||
|
|
||||||
$viewUse = '';
|
$viewUse = '';
|
||||||
$invokeParams = 'Request $request';
|
$invokeParams = 'Request $request';
|
||||||
|
|
@ -87,7 +88,7 @@ return new class extends Command {
|
||||||
->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name ready'));";
|
->withBody((new \Nyholm\Psr7\StreamFactory())->createStream('$name ready'));";
|
||||||
|
|
||||||
if ($viewClass) {
|
if ($viewClass) {
|
||||||
$viewFqcn = "Project\\Modules\\$module\\Views\\$viewClass";
|
$viewFqcn = "{$baseNamespace}\\$module\\Views\\$viewClass";
|
||||||
$viewUse = "use $viewFqcn;";
|
$viewUse = "use $viewFqcn;";
|
||||||
$invokeParams = "Request \$request, $viewClass \$view";
|
$invokeParams = "Request \$request, $viewClass \$view";
|
||||||
$renderBody = " return \$this->renderView(\$view, []);";
|
$renderBody = " return \$this->renderView(\$view, []);";
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Command {
|
||||||
'module' => [
|
'module' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:migration',
|
'description' => 'Target module name (e.g., Shop). Optional if using create:<module>:migration',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Command {
|
||||||
'module' => [
|
'module' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:model',
|
'description' => 'Target module name (e.g., Shop). Optional if using create:<module>:model',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -69,7 +69,8 @@ return new class extends Command {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$namespace = "Project\\Modules\\$module\\Models";
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$namespace = "{$baseNamespace}\\$module\\Models";
|
||||||
$stub = file_get_contents(dirname(__DIR__) . '/stubs/model.stub');
|
$stub = file_get_contents(dirname(__DIR__) . '/stubs/model.stub');
|
||||||
$template = strtr($stub, [
|
$template = strtr($stub, [
|
||||||
'{{namespace}}' => $namespace,
|
'{{namespace}}' => $namespace,
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,12 @@ return new class extends Command {
|
||||||
'name' => [
|
'name' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => true,
|
'required' => true,
|
||||||
'description' => 'Module name (e.g., Blog)',
|
'description' => 'Module name (e.g., Shop)',
|
||||||
],
|
],
|
||||||
'prefix' => [
|
'prefix' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Optional URL prefix (e.g., /blog). If omitted, you will be prompted or default is /<name-lower>',
|
'description' => 'Optional URL prefix (e.g., /shop). If omitted, you will be prompted or default is /<name-lower>',
|
||||||
],
|
],
|
||||||
'--update-composer' => [
|
'--update-composer' => [
|
||||||
'mode' => 'flag',
|
'mode' => 'flag',
|
||||||
|
|
@ -224,8 +224,9 @@ return new class extends Command {
|
||||||
|
|
||||||
private function writeProviderStub(string $moduleRoot, string $name): void
|
private function writeProviderStub(string $moduleRoot, string $name): void
|
||||||
{
|
{
|
||||||
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
$providerClass = $name . 'ServiceProvider';
|
$providerClass = $name . 'ServiceProvider';
|
||||||
$providerNs = "Project\\Modules\\$name\\Providers";
|
$providerNs = "{$baseNamespace}\\{$name}\\Providers";
|
||||||
$stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub');
|
$stub = file_get_contents(dirname(__DIR__) . '/stubs/module/provider.stub');
|
||||||
$providerCode = strtr($stub, [
|
$providerCode = strtr($stub, [
|
||||||
'{{namespace}}' => $providerNs,
|
'{{namespace}}' => $providerNs,
|
||||||
|
|
@ -243,15 +244,16 @@ return new class extends Command {
|
||||||
|
|
||||||
private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void
|
private function writeViewControllerTemplateStubs(string $moduleRoot, string $name): void
|
||||||
{
|
{
|
||||||
$viewNs = "Project\\Modules\\$name\\Views";
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$viewNs = "{$baseNamespace}\\{$name}\\Views";
|
||||||
$viewStub = file_get_contents(dirname(__DIR__) . '/stubs/module/view.stub');
|
$viewStub = file_get_contents(dirname(__DIR__) . '/stubs/module/view.stub');
|
||||||
$viewCode = strtr($viewStub, [
|
$viewCode = strtr($viewStub, [
|
||||||
'{{namespace}}' => $viewNs,
|
'{{namespace}}' => $viewNs,
|
||||||
]);
|
]);
|
||||||
file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode);
|
file_put_contents($moduleRoot . '/Views/HomeView.php', $viewCode);
|
||||||
|
|
||||||
$ctrlNs = "Project\\Modules\\$name\\Controllers";
|
$ctrlNs = "{$baseNamespace}\\{$name}\\Controllers";
|
||||||
$ctrlUsesViewNs = "Project\\Modules\\$name\\Views\\HomeView";
|
$ctrlUsesViewNs = "{$baseNamespace}\\{$name}\\Views\\HomeView";
|
||||||
$ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub');
|
$ctrlStub = file_get_contents(dirname(__DIR__) . '/stubs/module/controller.stub');
|
||||||
$ctrlCode = strtr($ctrlStub, [
|
$ctrlCode = strtr($ctrlStub, [
|
||||||
'{{namespace}}' => $ctrlNs,
|
'{{namespace}}' => $ctrlNs,
|
||||||
|
|
@ -277,6 +279,7 @@ return new class extends Command {
|
||||||
|
|
||||||
private function registerProviderInConfig(string $root, string $name): void
|
private function registerProviderInConfig(string $root, string $name): void
|
||||||
{
|
{
|
||||||
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
$providersFile = $root . '/config/providers.php';
|
$providersFile = $root . '/config/providers.php';
|
||||||
if (!is_file($providersFile)) {
|
if (!is_file($providersFile)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -291,13 +294,13 @@ return new class extends Command {
|
||||||
if (!is_dir($root . '/modules/' . $name)) {
|
if (!is_dir($root . '/modules/' . $name)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$fqcn = "Project\\Modules\\$name\\Providers\\$providerClass::class";
|
$fqcn = "{$baseNamespace}\\{$name}\\Providers\\$providerClass::class";
|
||||||
if (strpos($contents, $fqcn) !== false) {
|
if (strpos($contents, $fqcn) !== false) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
$updated = preg_replace(
|
$updated = preg_replace(
|
||||||
'/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/',
|
'/(\'modules\'\s*=>\s*\[)([\s\S]*?)(\])/',
|
||||||
"$1$2\n Project\\\\Modules\\\\$name\\\\Providers\\\\$providerClass::class,\n $3",
|
"$1$2\n " . str_replace('\\', '\\\\', $baseNamespace) . "\\\\$name\\\\Providers\\\\$providerClass::class,\n $3",
|
||||||
$contents,
|
$contents,
|
||||||
1
|
1
|
||||||
);
|
);
|
||||||
|
|
@ -323,7 +326,7 @@ return new class extends Command {
|
||||||
" \\Phred\\Http\\Routing\\RouteGroups::include(" . $dollar . "router, '$prefix', function (\\Phred\\Http\\Router " . $dollar . "router) {\n" .
|
" \\Phred\\Http\\Routing\\RouteGroups::include(" . $dollar . "router, '$prefix', function (\\Phred\\Http\\Router " . $dollar . "router) {\n" .
|
||||||
" /** @noinspection PhpIncludeInspection */\n" .
|
" /** @noinspection PhpIncludeInspection */\n" .
|
||||||
" (static function (" . $dollar . "router) { require __DIR__ . '/../modules/$name/Routes/web.php'; })(" . $dollar . "router);\n" .
|
" (static function (" . $dollar . "router) { require __DIR__ . '/../modules/$name/Routes/web.php'; })(" . $dollar . "router);\n" .
|
||||||
" });\n" .
|
" }, __DIR__ . '/../modules/$name/Routes/web.php');\n" .
|
||||||
"}\n";
|
"}\n";
|
||||||
$currentWeb = file_get_contents($webRootFile) ?: '';
|
$currentWeb = file_get_contents($webRootFile) ?: '';
|
||||||
if (strpos($currentWeb, "/modules/$name/Routes/web.php") === false) {
|
if (strpos($currentWeb, "/modules/$name/Routes/web.php") === false) {
|
||||||
|
|
@ -333,14 +336,16 @@ return new class extends Command {
|
||||||
|
|
||||||
private function printPsr4Hint(Output $output, string $name, string $prefix): void
|
private function printPsr4Hint(Output $output, string $name, string $prefix): void
|
||||||
{
|
{
|
||||||
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
$output->writeln("\n<info>Module '$name' created</info> in modules/$name.");
|
$output->writeln("\n<info>Module '$name' created</info> in modules/$name.");
|
||||||
$output->writeln('Remember to add PSR-4 autoload mapping in composer.json:');
|
$output->writeln('Remember to add PSR-4 autoload mapping in composer.json:');
|
||||||
$output->writeln(' "Project\\\\Modules\\\\' . $name . '\\\\": "modules/' . $name . '/"');
|
$output->writeln(' "' . str_replace('\\', '\\\\', $baseNamespace) . '\\\\' . $name . '\\\\": "modules/' . $name . '/"');
|
||||||
$output->writeln("\nModule routes are mounted at prefix '$prefix' in routes/web.php.");
|
$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
|
private function updateComposerPsr4(Output $output, string $root, string $name, bool $dumpAutoload): void
|
||||||
{
|
{
|
||||||
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
$composer = $root . '/composer.json';
|
$composer = $root . '/composer.json';
|
||||||
if (!is_file($composer)) {
|
if (!is_file($composer)) {
|
||||||
$output->writeln('<error>composer.json not found; cannot update PSR-4 mapping.</error>');
|
$output->writeln('<error>composer.json not found; cannot update PSR-4 mapping.</error>');
|
||||||
|
|
@ -355,7 +360,7 @@ return new class extends Command {
|
||||||
$bak = $composer . '.bak';
|
$bak = $composer . '.bak';
|
||||||
@copy($composer, $bak);
|
@copy($composer, $bak);
|
||||||
$psr4 = $data['autoload']['psr-4'] ?? [];
|
$psr4 = $data['autoload']['psr-4'] ?? [];
|
||||||
$ns = 'Project\\Modules\\' . $name . '\\';
|
$ns = "{$baseNamespace}\\{$name}\\";
|
||||||
$path = 'modules/' . $name . '/';
|
$path = 'modules/' . $name . '/';
|
||||||
$changed = false;
|
$changed = false;
|
||||||
if (!isset($psr4[$ns])) {
|
if (!isset($psr4[$ns])) {
|
||||||
|
|
@ -368,7 +373,7 @@ return new class extends Command {
|
||||||
if (file_put_contents($composer, $encoded) === false) {
|
if (file_put_contents($composer, $encoded) === false) {
|
||||||
$output->writeln('<error>Failed to write composer.json; original saved to composer.json.bak</error>');
|
$output->writeln('<error>Failed to write composer.json; original saved to composer.json.bak</error>');
|
||||||
} else {
|
} else {
|
||||||
$output->writeln('<info>Updated composer.json</info> with PSR-4 mapping for Project\\Modules\\' . $name . '\\.');
|
$output->writeln('<info>Updated composer.json</info> with PSR-4 mapping for ' . $ns . '.');
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$output->writeln('PSR-4 mapping already exists in composer.json.');
|
$output->writeln('PSR-4 mapping already exists in composer.json.');
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Command {
|
||||||
'module' => [
|
'module' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:seed',
|
'description' => 'Target module name (e.g., Shop). Optional if using create:<module>:seed',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Command {
|
||||||
'module' => [
|
'module' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:test',
|
'description' => 'Target module name (e.g., Shop). Optional if using create:<module>:test',
|
||||||
],
|
],
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|
@ -71,7 +71,8 @@ return new class extends Command {
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$namespace = "Project\\Modules\\$module\\Tests";
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$namespace = "{$baseNamespace}\\$module\\Tests";
|
||||||
$stub = file_get_contents(dirname(__DIR__) . '/stubs/test.stub');
|
$stub = file_get_contents(dirname(__DIR__) . '/stubs/test.stub');
|
||||||
$template = strtr($stub, [
|
$template = strtr($stub, [
|
||||||
'{{namespace}}' => $namespace,
|
'{{namespace}}' => $namespace,
|
||||||
|
|
|
||||||
|
|
@ -17,7 +17,7 @@ return new class extends Command {
|
||||||
'module' => [
|
'module' => [
|
||||||
'mode' => 'argument',
|
'mode' => 'argument',
|
||||||
'required' => false,
|
'required' => false,
|
||||||
'description' => 'Target module name (e.g., Blog). Optional if using create:<module>:view',
|
'description' => 'Target module name (e.g., Shop). Optional if using create:<module>:view',
|
||||||
],
|
],
|
||||||
'--template' => [
|
'--template' => [
|
||||||
'mode' => 'option',
|
'mode' => 'option',
|
||||||
|
|
@ -87,7 +87,8 @@ return new class extends Command {
|
||||||
$templateFile = $templateName . '.eyrie.php';
|
$templateFile = $templateName . '.eyrie.php';
|
||||||
$templatePath = $templatesDir . '/' . $templateFile;
|
$templatePath = $templatesDir . '/' . $templateFile;
|
||||||
|
|
||||||
$namespace = "Project\\Modules\\$module\\Views";
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$namespace = "{$baseNamespace}\\$module\\Views";
|
||||||
$stub = file_get_contents(dirname(__DIR__) . '/stubs/view.stub');
|
$stub = file_get_contents(dirname(__DIR__) . '/stubs/view.stub');
|
||||||
$viewTemplate = strtr($stub, [
|
$viewTemplate = strtr($stub, [
|
||||||
'{{namespace}}' => $namespace,
|
'{{namespace}}' => $namespace,
|
||||||
|
|
|
||||||
41
src/commands/docs.php
Normal file
41
src/commands/docs.php
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
use Symfony\Component\Console\Input\InputArgument;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'docs';
|
||||||
|
protected string $description = 'Open the documentation for a specific command or topic.';
|
||||||
|
protected array $arguments = [
|
||||||
|
['name' => '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("<info>Opening documentation for '$topic' at: $url</info>");
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
};
|
||||||
60
src/commands/generate_openapi.php
Normal file
60
src/commands/generate_openapi.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
use OpenApi\Generator;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'generate:openapi';
|
||||||
|
protected string $description = 'Generate OpenAPI JSON documentation from annotations.';
|
||||||
|
protected array $options = [
|
||||||
|
'output' => [
|
||||||
|
'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('<error>No source or module directories found to scan.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$output->writeln('<info>Scanning for OpenAPI annotations...</info>');
|
||||||
|
|
||||||
|
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("<info>OpenAPI documentation generated at: $outputPath</info>");
|
||||||
|
return 0;
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
$output->writeln('<error>OpenAPI Generation failed: ' . $e->getMessage() . '</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
70
src/commands/module_list.php
Normal file
70
src/commands/module_list.php
Normal file
|
|
@ -0,0 +1,70 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
use Symfony\Component\Console\Helper\Table;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'module:list';
|
||||||
|
protected string $description = 'List all modules and their registration status.';
|
||||||
|
|
||||||
|
public function handle(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$root = getcwd();
|
||||||
|
$modulesDir = $root . '/modules';
|
||||||
|
$providersFile = $root . '/config/providers.php';
|
||||||
|
$webFile = $root . '/routes/web.php';
|
||||||
|
|
||||||
|
$registeredProviders = [];
|
||||||
|
if (is_file($providersFile)) {
|
||||||
|
$arr = require $providersFile;
|
||||||
|
$registeredProviders = (array)($arr['modules'] ?? []);
|
||||||
|
}
|
||||||
|
|
||||||
|
$webContent = is_file($webFile) ? file_get_contents($webFile) : '';
|
||||||
|
|
||||||
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$modules = [];
|
||||||
|
if (is_dir($modulesDir)) {
|
||||||
|
foreach (scandir($modulesDir) ?: [] as $entry) {
|
||||||
|
if ($entry === '.' || $entry === '..' || !is_dir($modulesDir . '/' . $entry)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$isManualProvider = false;
|
||||||
|
foreach ($registeredProviders as $p) {
|
||||||
|
if (str_contains($p, "\\{$baseNamespace}\\$entry\\")) {
|
||||||
|
$isManualProvider = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$isManualRoute = str_contains($webContent, "modules/$entry/Routes/web.php");
|
||||||
|
|
||||||
|
$status = ($isManualProvider && $isManualRoute) ? '<info>Manual</info>' :
|
||||||
|
(($isManualProvider || $isManualRoute) ? '<comment>Partial</comment>' : '<fg=cyan>Discovered</fg=cyan>');
|
||||||
|
|
||||||
|
$modules[] = [
|
||||||
|
$entry,
|
||||||
|
$status,
|
||||||
|
$isManualProvider ? 'Yes' : 'No',
|
||||||
|
$isManualRoute ? 'Yes' : 'No',
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (empty($modules)) {
|
||||||
|
$output->writeln('<info>No modules found.</info>');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$table = new Table($output);
|
||||||
|
$table->setHeaders(['Module', 'Status', 'Provider Reg.', 'Route Reg.'])
|
||||||
|
->setRows($modules);
|
||||||
|
$table->render();
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
82
src/commands/module_prune.php
Normal file
82
src/commands/module_prune.php
Normal file
|
|
@ -0,0 +1,82 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'module:prune';
|
||||||
|
protected string $description = 'Remove manual registration entries for modules that no longer exist.';
|
||||||
|
|
||||||
|
public function handle(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$root = getcwd();
|
||||||
|
$modulesDir = $root . '/modules';
|
||||||
|
$providersFile = $root . '/config/providers.php';
|
||||||
|
$webFile = $root . '/routes/web.php';
|
||||||
|
|
||||||
|
$this->prune_providers($providersFile, $modulesDir, $output);
|
||||||
|
$this->prune_routes($webFile, $modulesDir, $output);
|
||||||
|
|
||||||
|
$output->writeln('<info>Pruning complete.</info>');
|
||||||
|
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("<comment>Pruned provider entry for missing module: $moduleName</comment>");
|
||||||
|
$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("<comment>Pruned route entry for missing module: $moduleName</comment>");
|
||||||
|
$removedCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if ($removedCount > 0) {
|
||||||
|
file_put_contents($webFile, $content);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
181
src/commands/module_register.php
Normal file
181
src/commands/module_register.php
Normal file
|
|
@ -0,0 +1,181 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
use Phred\Support\Config;
|
||||||
|
use Phred\Http\Routing\RouteRegistry;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'module:register';
|
||||||
|
protected string $description = 'Manually register discovered modules into configuration and route files.';
|
||||||
|
protected array $options = [
|
||||||
|
'name' => [
|
||||||
|
'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('<info>No modules directory found.</info>');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
$discovered = $this->discoverModules($modulesDir);
|
||||||
|
$registeredProviders = $this->getRegisteredProviders($root);
|
||||||
|
|
||||||
|
$toRegister = [];
|
||||||
|
if ($targetName) {
|
||||||
|
$targetName = trim((string)$targetName);
|
||||||
|
if (!isset($discovered[$targetName])) {
|
||||||
|
$output->writeln("<error>Module '$targetName' not found in modules/ directory.</error>");
|
||||||
|
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('<info>No new modules found needing manual registration.</info>');
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($toRegister as $name => $providers) {
|
||||||
|
$output->writeln("<comment>Registering module: $name</comment>");
|
||||||
|
|
||||||
|
// 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('<info>Registration complete.</info>');
|
||||||
|
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: <info>$fqcn</info>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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: <info>$prefix</info>");
|
||||||
|
}
|
||||||
|
};
|
||||||
79
src/commands/module_sync_ns.php
Normal file
79
src/commands/module_sync_ns.php
Normal file
|
|
@ -0,0 +1,79 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'module:sync-ns';
|
||||||
|
protected string $description = 'Sync composer.json PSR-4 mappings with the configured MODULE_NAMESPACE.';
|
||||||
|
|
||||||
|
public function handle(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$baseNamespace = \Phred\Support\Config::get('MODULE_NAMESPACE', 'Modules');
|
||||||
|
$root = getcwd();
|
||||||
|
$composerFile = $root . '/composer.json';
|
||||||
|
|
||||||
|
if (!is_file($composerFile)) {
|
||||||
|
$output->writeln('<error>composer.json not found.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$json = file_get_contents($composerFile);
|
||||||
|
$data = json_decode((string)$json, true);
|
||||||
|
if (!is_array($data)) {
|
||||||
|
$output->writeln('<error>composer.json parse error.</error>');
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
$modulesDir = $root . '/modules';
|
||||||
|
if (!is_dir($modulesDir)) {
|
||||||
|
$output->writeln('<info>No modules directory found.</info>');
|
||||||
|
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("<comment>Warning: PSR-4 entry '$ns' points to non-existent directory: $path</comment>");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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('<info>composer.json PSR-4 mappings updated.</info>');
|
||||||
|
$output->writeln('Run <comment>composer dump-autoload</comment> to apply changes.');
|
||||||
|
} else {
|
||||||
|
$output->writeln('<info>PSR-4 mappings are already in sync.</info>');
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
48
src/commands/route_cache.php
Normal file
48
src/commands/route_cache.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
use FastRoute\RouteCollector;
|
||||||
|
use FastRoute\RouteParser\Std;
|
||||||
|
use FastRoute\DataGenerator\GroupCountBased;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'route:cache';
|
||||||
|
protected string $description = 'Cache routes for production.';
|
||||||
|
|
||||||
|
public function handle(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$kernel = new \Phred\Http\Kernel();
|
||||||
|
$collector = new RouteCollector(new Std(), new GroupCountBased());
|
||||||
|
$registrar = $kernel->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 = "<?php\nreturn " . var_export($serializable, true) . ";\n";
|
||||||
|
file_put_contents($path, $content);
|
||||||
|
|
||||||
|
$output->writeln("<info>Routes cached successfully at $path</info>");
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
};
|
||||||
25
src/commands/route_clear.php
Normal file
25
src/commands/route_clear.php
Normal file
|
|
@ -0,0 +1,25 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'route:clear';
|
||||||
|
protected string $description = 'Clear cached routes.';
|
||||||
|
|
||||||
|
public function handle(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$path = getcwd() . '/storage/cache/routes.php';
|
||||||
|
|
||||||
|
if (file_exists($path)) {
|
||||||
|
unlink($path);
|
||||||
|
$output->writeln("<info>Route cache cleared.</info>");
|
||||||
|
} else {
|
||||||
|
$output->writeln("No route cache found.");
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
95
src/commands/route_list.php
Normal file
95
src/commands/route_list.php
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use Phred\Console\Command;
|
||||||
|
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||||
|
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||||
|
use Symfony\Component\Console\Helper\Table;
|
||||||
|
use FastRoute\RouteCollector;
|
||||||
|
use FastRoute\RouteParser\Std;
|
||||||
|
use FastRoute\DataGenerator\GroupCountBased;
|
||||||
|
|
||||||
|
return new class extends Command {
|
||||||
|
protected string $command = 'route:list';
|
||||||
|
protected string $description = 'List all registered routes.';
|
||||||
|
|
||||||
|
public function handle(Input $input, Output $output): int
|
||||||
|
{
|
||||||
|
$kernel = new \Phred\Http\Kernel();
|
||||||
|
$collector = new RouteCollector(new Std(), new GroupCountBased());
|
||||||
|
$registrar = $kernel->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';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -8,7 +8,13 @@ use Psr\Http\Message\ServerRequestInterface as Request;
|
||||||
{{useView}}
|
{{useView}}
|
||||||
final class {{class}} extends ViewController
|
final class {{class}} extends ViewController
|
||||||
{
|
{
|
||||||
public function __invoke({{params}})
|
/**
|
||||||
|
* Handle the incoming request.
|
||||||
|
*
|
||||||
|
* @param {{params}}
|
||||||
|
* @return \Psr\Http\Message\ResponseInterface
|
||||||
|
*/
|
||||||
|
public function __invoke({{params}}): \Psr\Http\Message\ResponseInterface
|
||||||
{
|
{
|
||||||
{{body}}
|
{{body}}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@ final class CreateModuleCommandTest extends TestCase
|
||||||
|
|
||||||
public function testScaffoldNonInteractiveWithExplicitPrefix(): void
|
public function testScaffoldNonInteractiveWithExplicitPrefix(): void
|
||||||
{
|
{
|
||||||
$module = 'Blog';
|
$module = 'TestShop';
|
||||||
$moduleDir = $this->root . '/modules/' . $module;
|
$moduleDir = $this->root . '/modules/' . $module;
|
||||||
if (is_dir($moduleDir)) {
|
if (is_dir($moduleDir)) {
|
||||||
$this->rrmdir($moduleDir);
|
$this->rrmdir($moduleDir);
|
||||||
|
|
@ -36,10 +36,10 @@ final class CreateModuleCommandTest extends TestCase
|
||||||
$this->assertIsObject($cmd);
|
$this->assertIsObject($cmd);
|
||||||
|
|
||||||
// Simulate CLI input: name + prefix argument
|
// Simulate CLI input: name + prefix argument
|
||||||
$argv = ['phred', 'create:module', $module, '/blog'];
|
$argv = ['phred', 'create:module', $module, '/shop'];
|
||||||
$code = $cmd->handle(new \Symfony\Component\Console\Input\ArrayInput([
|
$code = $cmd->handle(new \Symfony\Component\Console\Input\ArrayInput([
|
||||||
'name' => $module,
|
'name' => $module,
|
||||||
'prefix' => '/blog',
|
'prefix' => '/shop',
|
||||||
]), new \Symfony\Component\Console\Output\BufferedOutput());
|
]), new \Symfony\Component\Console\Output\BufferedOutput());
|
||||||
// The command returns 0 on success when run via the console app; direct handle() may return non-zero
|
// 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.
|
// in some environments due to missing console wiring. Assert directories instead of exit code.
|
||||||
|
|
@ -59,7 +59,7 @@ final class CreateModuleCommandTest extends TestCase
|
||||||
|
|
||||||
public function testComposerUpdateFlagSkipsDumpWhenNoDump(): void
|
public function testComposerUpdateFlagSkipsDumpWhenNoDump(): void
|
||||||
{
|
{
|
||||||
$module = 'Docs';
|
$module = 'TestDocs';
|
||||||
$moduleDir = $this->root . '/modules/' . $module;
|
$moduleDir = $this->root . '/modules/' . $module;
|
||||||
if (is_dir($moduleDir)) {
|
if (is_dir($moduleDir)) {
|
||||||
$this->rrmdir($moduleDir);
|
$this->rrmdir($moduleDir);
|
||||||
|
|
@ -84,7 +84,7 @@ final class CreateModuleCommandTest extends TestCase
|
||||||
$json = json_decode((string) file_get_contents($composer), true);
|
$json = json_decode((string) file_get_contents($composer), true);
|
||||||
$this->assertArrayHasKey('autoload', $json);
|
$this->assertArrayHasKey('autoload', $json);
|
||||||
$this->assertArrayHasKey('psr-4', $json['autoload']);
|
$this->assertArrayHasKey('psr-4', $json['autoload']);
|
||||||
$this->assertArrayHasKey('Project\\Modules\\' . $module . '\\', $json['autoload']['psr-4']);
|
$this->assertArrayHasKey('Modules\\' . $module . '\\', $json['autoload']['psr-4']);
|
||||||
|
|
||||||
// Cleanup
|
// Cleanup
|
||||||
$this->rrmdir($moduleDir);
|
$this->rrmdir($moduleDir);
|
||||||
|
|
@ -101,8 +101,8 @@ final class CreateModuleCommandTest extends TestCase
|
||||||
file_put_contents($this->composerFile, $this->originalComposerJson);
|
file_put_contents($this->composerFile, $this->originalComposerJson);
|
||||||
}
|
}
|
||||||
// Remove any leftover module directories commonly used in tests
|
// Remove any leftover module directories commonly used in tests
|
||||||
$this->rrmdir($this->root . '/modules/Blog');
|
$this->rrmdir($this->root . '/modules/TestShop');
|
||||||
$this->rrmdir($this->root . '/modules/Docs');
|
$this->rrmdir($this->root . '/modules/TestDocs');
|
||||||
}
|
}
|
||||||
|
|
||||||
private function rrmdir(string $dir): void
|
private function rrmdir(string $dir): void
|
||||||
|
|
|
||||||
48
tests/Feature/CompressionMiddlewareTest.php
Normal file
48
tests/Feature/CompressionMiddlewareTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Phred\Http\Kernel;
|
||||||
|
use Nyholm\Psr7\ServerRequest;
|
||||||
|
|
||||||
|
class CompressionMiddlewareTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function tearDown(): void
|
||||||
|
{
|
||||||
|
putenv('COMPRESSION_ENABLED');
|
||||||
|
\Phred\Support\Config::clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_compression_is_applied_when_enabled(): void
|
||||||
|
{
|
||||||
|
if (!function_exists('gzencode')) {
|
||||||
|
$this->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'));
|
||||||
|
}
|
||||||
|
}
|
||||||
48
tests/Feature/FeatureTestCaseTest.php
Normal file
48
tests/Feature/FeatureTestCaseTest.php
Normal file
|
|
@ -0,0 +1,48 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests\Feature;
|
||||||
|
|
||||||
|
use Phred\Testing\TestCase;
|
||||||
|
|
||||||
|
class FeatureTestCaseTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_health_check(): void
|
||||||
|
{
|
||||||
|
$this->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');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
60
tests/Feature/M13FeaturesTest.php
Normal file
60
tests/Feature/M13FeaturesTest.php
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Phred\Http\Kernel;
|
||||||
|
use Nyholm\Psr7\ServerRequest;
|
||||||
|
|
||||||
|
class M13FeaturesTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_openapi_json_route(): void
|
||||||
|
{
|
||||||
|
// Ensure file exists for test
|
||||||
|
if (!is_dir('public')) {
|
||||||
|
mkdir('public', 0777, true);
|
||||||
|
}
|
||||||
|
file_put_contents('public/openapi.json', json_encode(['openapi' => '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('<redoc', $body);
|
||||||
|
$this->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']);
|
||||||
|
}
|
||||||
|
}
|
||||||
42
tests/Feature/M15CachingTest.php
Normal file
42
tests/Feature/M15CachingTest.php
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Phred\Http\Kernel;
|
||||||
|
use Nyholm\Psr7\ServerRequest;
|
||||||
|
use Phred\Http\RequestContext;
|
||||||
|
|
||||||
|
class M15CachingTest extends TestCase
|
||||||
|
{
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
// Clear cache before each test
|
||||||
|
$cacheDir = getcwd() . '/storage/cache';
|
||||||
|
if (is_dir($cacheDir)) {
|
||||||
|
foreach (glob($cacheDir . '/*') ?: [] as $file) {
|
||||||
|
unlink($file);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function test_conditional_request_with_etag(): void
|
||||||
|
{
|
||||||
|
$kernel = new Kernel();
|
||||||
|
|
||||||
|
$request1 = new ServerRequest('GET', '/_phred/health');
|
||||||
|
$response1 = $kernel->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
38
tests/Feature/ModuleSyncNsTest.php
Normal file
38
tests/Feature/ModuleSyncNsTest.php
Normal file
|
|
@ -0,0 +1,38 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests\Feature;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Symfony\Component\Console\Input\ArrayInput;
|
||||||
|
use Symfony\Component\Console\Output\BufferedOutput;
|
||||||
|
|
||||||
|
class ModuleSyncNsTest extends TestCase
|
||||||
|
{
|
||||||
|
private string $composerFile;
|
||||||
|
private string $originalContent;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->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());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -53,7 +53,7 @@ final class NewOpportunityRadarTest extends TestCase
|
||||||
|
|
||||||
public function testS3AdapterResolutionThrowsWhenMissingDependencies(): void
|
public function testS3AdapterResolutionThrowsWhenMissingDependencies(): void
|
||||||
{
|
{
|
||||||
$config = $this->createMock(\Phred\Support\Contracts\ConfigInterface::class);
|
$config = $this->createStub(\Phred\Support\Contracts\ConfigInterface::class);
|
||||||
$config->method('get')->willReturnMap([
|
$config->method('get')->willReturnMap([
|
||||||
['storage.default', 'local', 's3'],
|
['storage.default', 'local', 's3'],
|
||||||
['storage.disks.s3', null, [
|
['storage.disks.s3', null, [
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@ final class RouterGroupTest extends TestCase
|
||||||
$dispatcher = simpleDispatcher(function (RouteCollector $rc): void {
|
$dispatcher = simpleDispatcher(function (RouteCollector $rc): void {
|
||||||
$router = new Router($rc);
|
$router = new Router($rc);
|
||||||
$router->group('/api', function (Router $r): void {
|
$router->group('/api', function (Router $r): void {
|
||||||
$r->get('/health', [\Phred\Http\Controllers\HealthController::class, '__invoke']);
|
$r->get('/health', \Phred\Http\Controllers\HealthController::class);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
0
tests/Support/Data/.gitkeep
Normal file
0
tests/Support/Data/.gitkeep
Normal file
2
tests/Support/_generated/.gitignore
vendored
Normal file
2
tests/Support/_generated/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
*
|
||||||
|
!.gitignore
|
||||||
22
tests/Unit/FakerDemoTest.php
Normal file
22
tests/Unit/FakerDemoTest.php
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
<?php
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Phred\Tests\Unit;
|
||||||
|
|
||||||
|
use PHPUnit\Framework\TestCase;
|
||||||
|
use Faker\Factory;
|
||||||
|
|
||||||
|
class FakerDemoTest extends TestCase
|
||||||
|
{
|
||||||
|
public function test_faker_generates_data(): void
|
||||||
|
{
|
||||||
|
$faker = Factory::create();
|
||||||
|
|
||||||
|
$name = $faker->name();
|
||||||
|
$email = $faker->email();
|
||||||
|
|
||||||
|
$this->assertIsString($name);
|
||||||
|
$this->assertIsString($email);
|
||||||
|
$this->assertStringContainsString('@', $email);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue