chore: reset Framework project to Idea phase
This commit is contained in:
parent
54303282d7
commit
e4d041afa9
|
|
@ -1,16 +0,0 @@
|
|||
# EditorConfig helps maintain consistent coding styles
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.php]
|
||||
indent_style = space
|
||||
indent_size = 4
|
||||
|
||||
[*.{yml,yaml,json,md}]
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
29
.env.example
29
.env.example
|
|
@ -1,29 +0,0 @@
|
|||
APP_NAME=Phred App
|
||||
APP_ENV=local
|
||||
APP_DEBUG=true
|
||||
APP_TIMEZONE=UTC
|
||||
APP_URL=http://localhost
|
||||
|
||||
API_FORMAT=rest
|
||||
API_PROBLEM_DETAILS=true
|
||||
|
||||
DB_DRIVER=sqlite
|
||||
DB_DATABASE=database/database.sqlite
|
||||
DB_HOST=127.0.0.1
|
||||
DB_PORT=3306
|
||||
DB_USERNAME=root
|
||||
DB_PASSWORD=
|
||||
|
||||
ORM_DRIVER=pairity
|
||||
TEMPLATE_DRIVER=eyrie
|
||||
FLAGS_DRIVER=flagpole
|
||||
TEST_RUNNER=codeception
|
||||
MODULE_NAMESPACE=Modules
|
||||
COMPRESSION_ENABLED=false
|
||||
COMPRESSION_LEVEL_GZIP=-1
|
||||
COMPRESSION_LEVEL_BROTLI=4
|
||||
|
||||
CORS_ALLOWED_ORIGINS=*
|
||||
CORS_ALLOWED_HEADERS="Content-Type, Authorization"
|
||||
CORS_ALLOWED_METHODS="GET, POST, PUT, PATCH, DELETE, OPTIONS"
|
||||
|
||||
10
.gitattributes
vendored
10
.gitattributes
vendored
|
|
@ -1,10 +0,0 @@
|
|||
# Exclude dev files from exported archives
|
||||
/.github export-ignore
|
||||
/tests export-ignore
|
||||
/.editorconfig export-ignore
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/phpstan.neon.dist export-ignore
|
||||
/.php-cs-fixer.php export-ignore
|
||||
/MILESTONES.md export-ignore
|
||||
/README.md export-ignore
|
||||
56
.github/workflows/ci.yml
vendored
56
.github/workflows/ci.yml
vendored
|
|
@ -1,56 +0,0 @@
|
|||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["**"]
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: PHP ${{ matrix.php }}
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
php: ["8.1", "8.2", "8.3"]
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Setup PHP
|
||||
uses: shivammathur/setup-php@v2
|
||||
with:
|
||||
php-version: ${{ matrix.php }}
|
||||
extensions: mbstring, intl, json
|
||||
tools: composer:v2
|
||||
coverage: none
|
||||
|
||||
- name: Validate composer.json
|
||||
run: composer validate --no-check-publish --strict
|
||||
|
||||
- name: Get composer cache directory
|
||||
id: composer-cache
|
||||
run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Cache composer
|
||||
uses: actions/cache@v4
|
||||
with:
|
||||
path: ${{ steps.composer-cache.outputs.dir }}
|
||||
key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }}
|
||||
restore-keys: |
|
||||
${{ runner.os }}-composer-
|
||||
|
||||
- name: Install dependencies
|
||||
run: composer install --no-interaction --prefer-dist --no-progress
|
||||
|
||||
- name: PHP CS Fixer (dry-run)
|
||||
run: vendor/bin/php-cs-fixer fix --dry-run --using-cache=no --verbose src
|
||||
|
||||
- name: PHPStan
|
||||
run: vendor/bin/phpstan analyse --no-progress --memory-limit=1G
|
||||
|
||||
- name: Codeception (if configured)
|
||||
if: hashFiles('**/codeception.yml') != ''
|
||||
run: vendor/bin/codecept run --verbosity 1
|
||||
140
.gitignore
vendored
140
.gitignore
vendored
|
|
@ -1,140 +0,0 @@
|
|||
# ---> Composer
|
||||
composer.phar
|
||||
/vendor/
|
||||
|
||||
# Template policy: do not commit composer.lock in this template repo
|
||||
# (apps generated from this template should commit THEIR lock file)
|
||||
composer.lock
|
||||
|
||||
# ---> JetBrains
|
||||
# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider
|
||||
# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839
|
||||
|
||||
# User-specific stuff
|
||||
.idea/**/workspace.xml
|
||||
.idea/**/tasks.xml
|
||||
.idea/**/usage.statistics.xml
|
||||
.idea/**/dictionaries
|
||||
.idea/**/shelf
|
||||
|
||||
# AWS User-specific
|
||||
.idea/**/aws.xml
|
||||
|
||||
# Generated files
|
||||
.idea/**/contentModel.xml
|
||||
|
||||
# Sensitive or high-churn files
|
||||
.idea/**/dataSources/
|
||||
.idea/**/dataSources.ids
|
||||
.idea/**/dataSources.local.xml
|
||||
.idea/**/sqlDataSources.xml
|
||||
.idea/**/dynamic.xml
|
||||
.idea/**/uiDesigner.xml
|
||||
.idea/**/dbnavigator.xml
|
||||
|
||||
# Gradle
|
||||
.idea/**/gradle.xml
|
||||
.idea/**/libraries
|
||||
|
||||
# Gradle and Maven with auto-import
|
||||
# When using Gradle or Maven with auto-import, you should exclude module files,
|
||||
# since they will be recreated, and may cause churn. Uncomment if using
|
||||
# auto-import.
|
||||
# .idea/artifacts
|
||||
# .idea/compiler.xml
|
||||
# .idea/jarRepositories.xml
|
||||
# .idea/modules.xml
|
||||
# .idea/*.iml
|
||||
# .idea/modules
|
||||
# *.iml
|
||||
# *.ipr
|
||||
|
||||
# CMake
|
||||
cmake-build-*/
|
||||
|
||||
# Mongo Explorer plugin
|
||||
.idea/**/mongoSettings.xml
|
||||
|
||||
# File-based project format
|
||||
*.iws
|
||||
|
||||
# IntelliJ
|
||||
out/
|
||||
|
||||
# mpeltonen/sbt-idea plugin
|
||||
.idea_modules/
|
||||
|
||||
# JIRA plugin
|
||||
atlassian-ide-plugin.xml
|
||||
|
||||
# Cursive Clojure plugin
|
||||
.idea/replstate.xml
|
||||
|
||||
# SonarLint plugin
|
||||
.idea/sonarlint/
|
||||
|
||||
# Crashlytics plugin (for Android Studio and IntelliJ)
|
||||
com_crashlytics_export_strings.xml
|
||||
crashlytics.properties
|
||||
crashlytics-build.properties
|
||||
fabric.properties
|
||||
|
||||
# Editor-based Rest Client
|
||||
.idea/httpRequests
|
||||
|
||||
# Android studio 3.1+ serialized cache file
|
||||
.idea/caches/build_file_checksums.ser
|
||||
|
||||
|
||||
# --- Additional ignores (Phred project) ---
|
||||
|
||||
# Environment files (keep .env.example tracked)
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# IDE/editor folders
|
||||
.idea/
|
||||
.vscode/
|
||||
|
||||
# OS files
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor swap/backup files
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Tool caches and build artifacts
|
||||
.php-cs-fixer.cache
|
||||
.phpunit.result.cache
|
||||
.cache/
|
||||
coverage/
|
||||
.coverage/
|
||||
build/
|
||||
var/
|
||||
tmp/
|
||||
|
||||
# Scaffolding output created during local development of this template
|
||||
# These are part of generated apps, not this template repo
|
||||
/public/
|
||||
/bootstrap/
|
||||
/routes/
|
||||
/resources/
|
||||
/modules/
|
||||
/storage/*
|
||||
!/storage/.gitkeep
|
||||
/config/
|
||||
/console/
|
||||
|
||||
# Local assistant/session preferences (developer-specific)
|
||||
.junie/
|
||||
|
||||
# Codeception outputs
|
||||
tests/_output/
|
||||
tests/_support/_generated/
|
||||
|
||||
/.env
|
||||
/.phpunit.cache
|
||||
/.php-cs-fixer.cache
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?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);
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
# Contributing to Phred
|
||||
|
||||
Thank you for your interest in Phred! We welcome contributions of all kinds.
|
||||
|
||||
## RFC Process
|
||||
|
||||
For major changes, please open an issue first to discuss what you would like to change.
|
||||
|
||||
1. Create a "Request for Comments" (RFC) issue.
|
||||
2. Wait for feedback from the core maintainers.
|
||||
3. Once approved, submit a Pull Request.
|
||||
|
||||
## Pull Request Process
|
||||
|
||||
1. Fork the repository and create your branch from `main`.
|
||||
2. If you've added code that should be tested, add tests.
|
||||
3. Ensure the test suite passes (`composer test`).
|
||||
4. Ensure static analysis passes (`composer analyze`).
|
||||
5. Ensure code style matches PSR-12 (`composer fix`).
|
||||
6. Update the documentation if necessary.
|
||||
|
||||
## Code of Conduct
|
||||
|
||||
Please be respectful and professional in all interactions.
|
||||
22
Dockerfile
22
Dockerfile
|
|
@ -1,22 +0,0 @@
|
|||
FROM php:8.2-fpm
|
||||
|
||||
RUN apt-get update && apt-get install -y \
|
||||
libpq-dev \
|
||||
libzip-dev \
|
||||
zip \
|
||||
unzip \
|
||||
git \
|
||||
&& docker-php-ext-install pdo_mysql pdo_pgsql zip
|
||||
|
||||
COPY --from=composer:latest /usr/bin/composer /usr/bin/composer
|
||||
|
||||
WORKDIR /var/www
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN composer install --no-dev --optimize-autoloader
|
||||
|
||||
RUN chown -R www-data:www-data storage bootstrap/cache
|
||||
|
||||
EXPOSE 9000
|
||||
CMD ["php-fpm"]
|
||||
9
LICENSE
9
LICENSE
|
|
@ -1,9 +0,0 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 funkywaddle
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
136
NOTES.md
Normal file
136
NOTES.md
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
# Phred Framework: Project Notes & Brainstorming
|
||||
|
||||
This document serves as a mind-map and repository for ideas, architectural decisions, and future considerations for the Phred Framework. It is a living document that captures the "why" behind the "what" defined in the specifications and milestones.
|
||||
|
||||
## Core Vision & Philosophy
|
||||
- **Batteries-Included but Swappable**: Provide sensible defaults (REST, JSON:API, Eyrie, Pairity) while ensuring every core component can be swapped via Service Providers.
|
||||
- **Standards First**: Strict adherence to PSRs (PSR-7, PSR-11, PSR-14, PSR-15, PSR-18).
|
||||
- **Modular by Nature**: Django-style "Apps" (Modules) where features are encapsulated.
|
||||
- **Developer Happiness (DX)**: Zero-config where possible, robust scaffolding, and clear documentation.
|
||||
|
||||
## Architectural Brainstorming
|
||||
|
||||
### 1. The HTTP Pipeline (PSR-15)
|
||||
- Use `Relay` for the middleware stack.
|
||||
- **Middleware Layers**:
|
||||
- Global: CORS, Security Headers, Error Handling (Problem Details).
|
||||
- Route-specific: Auth (JWT), Validation, Rate Limiting.
|
||||
- Negotiation: `ContentNegotiationMiddleware` to handle REST vs JSON:API vs HTML.
|
||||
|
||||
### 2. Pluggability & Service Providers
|
||||
- **Contracts Package**: Define all core interfaces in `Phred\Support\Contracts`.
|
||||
- **Driver Selection**: Use `.env` keys like `ORM_DRIVER`, `TEMPLATE_DRIVER`, `CACHE_DRIVER`.
|
||||
- **Lifecycle**: Providers should have `register()` (binding to container) and `boot()` (executing logic like route registration).
|
||||
|
||||
### 3. MVC & View Layer
|
||||
- **Invokable Controllers**: "One action, one class" to prevent controller bloat.
|
||||
- **View Objects**: A dedicated layer for data transformation/preparation before the template. This keeps controllers focused on flow and templates focused on markup.
|
||||
- **Response Factories**: Abstract the creation of REST vs JSON:API responses.
|
||||
|
||||
### 4. Modular Architecture (Django-style)
|
||||
- All user code lives in `modules/`.
|
||||
- **Contract-First Persistence**: Modules should define pure domain models (POPOs) and repository interfaces. The framework uses a "Bridge" architecture to map these to a specific ORM.
|
||||
- **Scaffolding**: CLI should be able to generate an entire module structure in one command.
|
||||
|
||||
### 5. Persistence Bridge Strategy (ORM-Agnostic)
|
||||
To achieve true decoupling, Phred adopts a "Persistence Bridge" pattern, mirroring the TaskerBridges architecture. This removes the need for driver-specific directories (like `Persistence/Pairity/`) within modules and moves implementation details to the framework's infrastructure layer.
|
||||
|
||||
#### Implementation Concept
|
||||
1. **Flattened Directory Structure**:
|
||||
- `modules/<Module>/Models/`: Contains the data objects/entities (POPOs).
|
||||
- `modules/<Module>/Repositories/`: Contains the repository interfaces.
|
||||
2. **The Bridge as a Translator**:
|
||||
- The framework boots an active **ORM Bridge** (e.g., `PairityBridge`, `EloquentBridge`) based on `.env`.
|
||||
- The Bridge is responsible for taking a module's model and explaining it to its respective persistence engine using PHP 8 Attributes (metadata).
|
||||
3. **Automated Discovery & Wiring**:
|
||||
- Similar to Tasker's command discovery, Phred scans the `Models/` directory to register entities.
|
||||
- Phred auto-binds repository implementations to their corresponding interfaces in the PSR-11 container.
|
||||
4. **Environment-Driven**: Connection details and driver selection are handled exclusively via environment variables, keeping module code pure.
|
||||
|
||||
5. **Migration & Schema Management**:
|
||||
- **Auto-Discovery**: Bridges scan `Models/` to detect schema changes.
|
||||
- **Versioned Snapshots**: The framework can cache a "last-known" state of the models to generate diff-based migrations.
|
||||
- **Tooling**: CLI commands can generate migration files by comparing current POPO attributes against the database or a cached snapshot.
|
||||
|
||||
#### Example Domain Model (Attribute-Driven)
|
||||
```php
|
||||
namespace App\Modules\User\Models;
|
||||
|
||||
use Phred\Persistence\Attributes\Entity;
|
||||
use Phred\Persistence\Attributes\Column;
|
||||
|
||||
#[Entity(table: 'users')]
|
||||
class User
|
||||
{
|
||||
#[Column(primary: true, autoIncrement: true)]
|
||||
public int $id;
|
||||
|
||||
#[Column(unique: true)]
|
||||
public string $email;
|
||||
|
||||
#[Column(nullable: true)]
|
||||
public ?string $name;
|
||||
|
||||
#[Column(name: 'created_at')]
|
||||
public \DateTimeImmutable $createdAt;
|
||||
}
|
||||
```
|
||||
|
||||
#### Benefits
|
||||
- **Zero-Config Persistence**: Developers only write the Model and the Interface; Phred handles the connection and injection.
|
||||
- **Multi-ORM Support**: The same model can work across different ORMs by switching the Bridge.
|
||||
- **Simplified DX**: No deeply nested folders for every module; logic is localized and clean.
|
||||
|
||||
### 6. Command Discovery Strategy (Tasker Integration)
|
||||
To provide a "zero-configuration" experience for developers, the framework should implement an automated directory-based command loader that bridges the application's `src/Commands` directory with Tasker.
|
||||
|
||||
#### Proposed Implementation Flow
|
||||
1. **Directory Scan**: Use `RecursiveDirectoryIterator` to find all PHP files in `src/Commands`.
|
||||
2. **Class Resolution**: Map file paths to fully qualified class names following PSR-4 conventions.
|
||||
3. **Container-First Loading**:
|
||||
- Check if the class is already registered in the PSR-11 container.
|
||||
- If found, retrieve the instance from the container (ensuring dependency injection).
|
||||
- If not found, Tasker's `Runner::register($className)` will instantiate it directly.
|
||||
4. **Registration**: Pass the resolved class name or instance to `$taskerRunner->register()`.
|
||||
|
||||
5. **Migration & Schema Management**:
|
||||
- **Auto-Discovery**: Bridges scan `Models/` to detect schema changes.
|
||||
- **Versioned Snapshots**: The framework can cache a "last-known" state of the models to generate diff-based migrations.
|
||||
- **Tooling**: CLI commands can generate migration files by comparing current POPO attributes against the database or a cached snapshot.
|
||||
|
||||
#### Benefits
|
||||
- **Zero Config**: Developers just drop a class into the directory.
|
||||
- **DI Support**: Automatically respects container-managed services.
|
||||
- **Type Safety**: Tasker validates that the class implements `CommandInterface` or uses the `HasAttributes` trait.
|
||||
|
||||
#### Example Discovery Logic
|
||||
```php
|
||||
$commandPath = $appRoot . '/src/Commands';
|
||||
$namespace = 'App\Commands\';
|
||||
|
||||
$files = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($commandPath));
|
||||
|
||||
foreach ($files as $file) {
|
||||
if ($file->getExtension() !== 'php') {
|
||||
continue;
|
||||
}
|
||||
|
||||
$className = $namespace . str_replace(
|
||||
['/', '.php'],
|
||||
['\', ''],
|
||||
substr($file->getPathname(), strlen($commandPath) + 1)
|
||||
);
|
||||
|
||||
$taskerRunner->register($className);
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Security & Auth
|
||||
- JWT by default for APIs.
|
||||
- CSRF protection for traditional web routes.
|
||||
- **Feature Flags**: Native integration with `FlagPole`.
|
||||
|
||||
### 8. Documentation & Discovery
|
||||
- Documentation site built *with* Phred.
|
||||
- **Dynamic Help**: A CLI command that opens the online docs for a specific framework command.
|
||||
- **OpenAPI**: Auto-generation from annotations.
|
||||
149
README.md
149
README.md
|
|
@ -1,149 +0,0 @@
|
|||
# Phred
|
||||
|
||||
A PHP MVC framework intended for projects of all sizes, designed for both solo and team development.
|
||||
|
||||
## Requirements
|
||||
|
||||
* **PHP**: 8.2+
|
||||
* **Web Server**: Apache/Nginx (recommended)
|
||||
* **Package Manager**: Composer
|
||||
|
||||
## Installation
|
||||
|
||||
Install Phred via Composer:
|
||||
|
||||
```bash
|
||||
composer create-project getphred/phred
|
||||
```
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Creating a Module
|
||||
|
||||
Phred uses a modular (Django-style) architecture. All your application logic lives inside modules.
|
||||
|
||||
To scaffold a new module:
|
||||
|
||||
```bash
|
||||
php phred create:module Shop
|
||||
```
|
||||
|
||||
This will create the module structure under `modules/Shop`, register the service provider, and mount the routes.
|
||||
|
||||
After creating a module, update your `composer.json` to include the new namespace:
|
||||
|
||||
```json
|
||||
{
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Modules\\\\Shop\\\\": "modules/Shop/"
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Then run:
|
||||
```bash
|
||||
composer dump-autoload
|
||||
```
|
||||
|
||||
### Running the Application
|
||||
|
||||
Start the local development server:
|
||||
|
||||
```bash
|
||||
php phred run
|
||||
```
|
||||
|
||||
The application will be available at `http://localhost:8000`.
|
||||
|
||||
## Configuration
|
||||
|
||||
Phred uses a `.env` file for configuration. Key settings include:
|
||||
|
||||
* `API_FORMAT`: The default API format (`rest` or `jsonapi`).
|
||||
* `MODULE_NAMESPACE`: The base namespace for your modules (default: `Modules`).
|
||||
* `APP_DEBUG`: Enable debug mode (`true` or `false`).
|
||||
* `COMPRESSION_ENABLED`: Enable response compression (Gzip/Brotli).
|
||||
|
||||
## CLI Usage (phred)
|
||||
|
||||
The `phred` binary provides several utility and scaffolding commands.
|
||||
|
||||
### Generators
|
||||
* `php phred create:module <name>` — Create a new module.
|
||||
* `php phred create:command <name>` — Create a custom CLI command.
|
||||
* `php phred create:<module>:controller <name>` — Create a controller.
|
||||
* `php phred create:<module>:view <name>` — Create a view and template.
|
||||
* `php phred create:<module>:model <name>` — Create a domain model.
|
||||
* `php phred create:<module>:migration <name>` — Create a migration.
|
||||
* `php phred create:<module>:seed <name>` — Create a database seeder.
|
||||
* `php phred create:<module>:test <name>` — Create a test.
|
||||
|
||||
### Database
|
||||
* `php phred migrate` — Run database migrations.
|
||||
* `php phred migration:rollback` — Rollback migrations.
|
||||
* `php phred seed` — Seed the database.
|
||||
* `php phred db:backup` — Backup the database.
|
||||
* `php phred db:restore` — Restore the database.
|
||||
|
||||
### Testing & Utilities
|
||||
* `php phred test` — Run tests for the entire project.
|
||||
* `php phred test:<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.
|
||||
|
||||
## Routing
|
||||
|
||||
Routes are defined in `routes/web.php`, `routes/api.php`, or within your module's `Routes/` directory. Phred uses `nikic/fast-route` for high-performance routing.
|
||||
|
||||
### Basic Routes
|
||||
|
||||
You can define routes using the `$router` instance:
|
||||
|
||||
```php
|
||||
// routes/web.php
|
||||
$router->get('/welcome', WelcomeController::class);
|
||||
$router->post('/submit', SubmitFormController::class);
|
||||
```
|
||||
|
||||
### Route Groups
|
||||
|
||||
Groups allow you to share prefixes and middleware:
|
||||
|
||||
```php
|
||||
$router->group(['prefix' => '/api', 'middleware' => 'api'], function ($router) {
|
||||
$router->get('/users', ListUsersController::class);
|
||||
$router->get('/users/{id}', ShowUserController::class);
|
||||
});
|
||||
```
|
||||
|
||||
### Module Auto-Mounting
|
||||
|
||||
Phred automatically mounts module routes based on folder name:
|
||||
- `modules/Shop/Routes/web.php` → mounted at `/shop`
|
||||
- `modules/Shop/Routes/api.php` → mounted at `/api/shop` (with `api` middleware)
|
||||
|
||||
### Route Listing & Caching
|
||||
|
||||
View all registered routes:
|
||||
```bash
|
||||
php phred route:list
|
||||
```
|
||||
|
||||
In production, cache your routes for maximum performance:
|
||||
```bash
|
||||
php phred route:cache
|
||||
php phred route:clear # To clear cache
|
||||
```
|
||||
|
||||
## Technical Specifications
|
||||
|
||||
For detailed information on the framework architecture, service providers, configuration, and MVC components, please refer to:
|
||||
|
||||
👉 **[SPECS.md](./SPECS.md)** | [MILESTONES.md](./MILESTONES.md)
|
||||
|
||||
## License
|
||||
|
||||
Phred is open-source software licensed under the MIT license.
|
||||
80
bin/phred
80
bin/phred
|
|
@ -1,80 +0,0 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace {
|
||||
// Ensure composer autoload is available whether this is run from repo or installed project
|
||||
$autoloadPaths = [
|
||||
__DIR__ . '/../vendor/autoload.php', // project root vendor
|
||||
__DIR__ . '/../../autoload.php', // vendor/bin scenario
|
||||
];
|
||||
$autoloaded = false;
|
||||
foreach ($autoloadPaths as $path) {
|
||||
if (is_file($path)) {
|
||||
require $path;
|
||||
$autoloaded = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!$autoloaded) {
|
||||
fwrite(STDERR, "Unable to locate Composer autoload.\n");
|
||||
exit(1);
|
||||
}
|
||||
|
||||
$app = new \Symfony\Component\Console\Application('Phred', '0.1');
|
||||
|
||||
// Discover core commands bundled with Phred (moved under src/commands)
|
||||
$coreDir = dirname(__DIR__) . '/src/commands';
|
||||
$generators = [
|
||||
'create:controller',
|
||||
'create:view',
|
||||
'create:model',
|
||||
'create:migration',
|
||||
'create:seed',
|
||||
'create:test'
|
||||
];
|
||||
|
||||
if (is_dir($coreDir)) {
|
||||
foreach (glob($coreDir . '/*.php') as $file) {
|
||||
/** @var \Phred\Console\Command $cmd */
|
||||
$cmd = require $file;
|
||||
if ($cmd instanceof \Phred\Console\Command) {
|
||||
$app->add($cmd->toSymfony());
|
||||
|
||||
// If it's a generator, also register module-specific versions
|
||||
if (in_array($cmd->getName(), $generators, true)) {
|
||||
$modulesDir = getcwd() . '/modules';
|
||||
if (is_dir($modulesDir)) {
|
||||
foreach (scandir($modulesDir) as $module) {
|
||||
if ($module === '.' || $module === '..' || !is_dir($modulesDir . '/' . $module)) {
|
||||
continue;
|
||||
}
|
||||
// Create a module-specific command name: create:shop:controller
|
||||
$moduleCmdName = str_replace('create:', 'create:' . strtolower($module) . ':', $cmd->getName());
|
||||
|
||||
// We need a fresh instance for each command name to avoid overwriting Symfony's command registry
|
||||
$moduleCmd = require $file;
|
||||
$moduleCmd->setName($moduleCmdName);
|
||||
$app->add($moduleCmd->toSymfony());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Discover user commands in console/commands
|
||||
$userDir = getcwd() . '/console/commands';
|
||||
if (is_dir($userDir)) {
|
||||
foreach (glob($userDir . '/*.php') as $file) {
|
||||
$cmd = require $file;
|
||||
if ($cmd instanceof \Phred\Console\Command) {
|
||||
$app->add($cmd->toSymfony());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Run
|
||||
$app->run();
|
||||
}
|
||||
|
|
@ -1,86 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
/**
|
||||
* Automatically generates a Table of Contents and injects breadcrumbs for Markdown files.
|
||||
*/
|
||||
|
||||
function generateToc(string $filePath, string $name): void
|
||||
{
|
||||
if (!is_file($filePath)) {
|
||||
echo "$name not found.\n";
|
||||
return;
|
||||
}
|
||||
|
||||
$content = file_get_contents($filePath);
|
||||
$lines = explode("\n", $content);
|
||||
|
||||
$inToc = false;
|
||||
$headers = [];
|
||||
$bodyLines = [];
|
||||
|
||||
foreach ($lines as $line) {
|
||||
if (trim($line) === '## Table of Contents') {
|
||||
$inToc = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// We assume the TOC ends at the next header or double newline
|
||||
if ($inToc && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) {
|
||||
$inToc = false;
|
||||
}
|
||||
|
||||
if (!$inToc) {
|
||||
if (preg_match('/^(##+) (.*)/', $line, $matches)) {
|
||||
$level = strlen($matches[1]) - 1; // ## is level 1 in TOC
|
||||
if ($level > 0) {
|
||||
$anchor = strtolower(trim($matches[2]));
|
||||
$anchor = str_replace('~~', '', $anchor);
|
||||
$anchor = preg_replace('/[^a-z0-9]+/', '-', $anchor);
|
||||
$anchor = trim($anchor, '-');
|
||||
$headers[] = [
|
||||
'level' => $level,
|
||||
'title' => trim($matches[2]),
|
||||
'anchor' => $anchor
|
||||
];
|
||||
}
|
||||
|
||||
// Add "Back to Top" breadcrumb before level 2 headers, except for the first one or if already present
|
||||
if ($level === 1 && !empty($bodyLines)) {
|
||||
$lastLine = end($bodyLines);
|
||||
if ($lastLine !== '' && !str_contains($lastLine, '[↑ Back to Top]')) {
|
||||
$bodyLines[] = '';
|
||||
$bodyLines[] = '[↑ Back to Top](#table-of-contents)';
|
||||
}
|
||||
}
|
||||
}
|
||||
$bodyLines[] = $line;
|
||||
}
|
||||
}
|
||||
|
||||
// Generate TOC text
|
||||
$tocText = "## Table of Contents\n";
|
||||
foreach ($headers as $header) {
|
||||
if ($header['title'] === 'Table of Contents') continue;
|
||||
$indent = str_repeat(' ', $header['level'] - 1);
|
||||
$tocText .= "{$indent}- [{$header['title']}](#{$header['anchor']})\n";
|
||||
}
|
||||
|
||||
// Reconstruct file
|
||||
$finalLines = [];
|
||||
$tocInserted = false;
|
||||
foreach ($bodyLines as $line) {
|
||||
if (!$tocInserted && (str_starts_with($line, '## ') || (str_contains($content, 'This document outlines') && str_starts_with($line, 'This document outlines')))) {
|
||||
$finalLines[] = $tocText;
|
||||
$tocInserted = true;
|
||||
}
|
||||
$finalLines[] = $line;
|
||||
}
|
||||
|
||||
file_put_contents($filePath, implode("\n", $finalLines));
|
||||
echo "$name TOC and breadcrumbs regenerated successfully.\n";
|
||||
}
|
||||
|
||||
$root = __DIR__ . '/../..';
|
||||
generateToc($root . '/SPECS.md', 'SPECS.md');
|
||||
generateToc($root . '/MILESTONES.md', 'MILESTONES.md');
|
||||
|
|
@ -1,12 +0,0 @@
|
|||
namespace: Tests
|
||||
support_namespace: Support
|
||||
paths:
|
||||
tests: tests
|
||||
output: tests/_output
|
||||
data: tests/Support/Data
|
||||
support: tests/Support
|
||||
envs: tests/_envs
|
||||
actor_suffix: Tester
|
||||
extensions:
|
||||
enabled:
|
||||
- Codeception\Extension\RunFailed
|
||||
|
|
@ -6,9 +6,9 @@
|
|||
"php": "^8.2",
|
||||
"crell/api-problem": "^3.7",
|
||||
"filp/whoops": "^2.15",
|
||||
"getphred/eyrie": "dev-main",
|
||||
"getphred/flagpole": "dev-main",
|
||||
"getphred/pairity": "dev-main",
|
||||
"getphred/eyrie": "dev-master",
|
||||
"getphred/flagpole": "dev-master",
|
||||
"getphred/pairity": "dev-master",
|
||||
"laravel/serializable-closure": "^1.3",
|
||||
"lcobucci/jwt": "^5.2",
|
||||
"league/flysystem": "^3.24",
|
||||
|
|
|
|||
|
|
@ -1,5 +0,0 @@
|
|||
{
|
||||
"autoload": {
|
||||
"psr-4": []
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /var/www/public;
|
||||
|
||||
index index.php;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.php?$query_string;
|
||||
}
|
||||
|
||||
location ~ \.php$ {
|
||||
fastcgi_pass app:9000;
|
||||
fastcgi_index index.php;
|
||||
include fastcgi_params;
|
||||
fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
|
||||
fastcgi_param PATH_INFO $fastcgi_path_info;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,76 +0,0 @@
|
|||
parameters:
|
||||
ignoreErrors:
|
||||
-
|
||||
message: "#^PHPDoc type for property class@anonymous/Console/Command\\.php\\:34\\:\\:\\$name with type string\\|null is not subtype of native type string\\.$#"
|
||||
count: 1
|
||||
path: src/Console/Command.php
|
||||
|
||||
-
|
||||
message: "#^Property class@anonymous/Console/Command\\.php\\:34\\:\\:\\$name is never read, only written\\.$#"
|
||||
count: 1
|
||||
path: src/Console/Command.php
|
||||
|
||||
-
|
||||
message: "#^Call to an undefined method Flagpole\\\\FeatureManager\\:\\:enabled\\(\\)\\.$#"
|
||||
count: 1
|
||||
path: src/Flags/FlagpoleClient.php
|
||||
|
||||
-
|
||||
message: "#^Class FlagPole\\\\Context referenced with incorrect case\\: Flagpole\\\\Context\\.$#"
|
||||
count: 1
|
||||
path: src/Flags/FlagpoleClient.php
|
||||
|
||||
-
|
||||
message: "#^Class FlagPole\\\\Repository\\\\InMemoryFlagRepository referenced with incorrect case\\: Flagpole\\\\Repository\\\\InMemoryFlagRepository\\.$#"
|
||||
count: 1
|
||||
path: src/Flags/FlagpoleClient.php
|
||||
|
||||
-
|
||||
message: "#^Class Flagpole\\\\FeatureManager does not have a constructor and must be instantiated without any parameters\\.$#"
|
||||
count: 1
|
||||
path: src/Flags/FlagpoleClient.php
|
||||
|
||||
-
|
||||
message: "#^Property Phred\\\\Http\\\\Middleware\\\\ContentNegotiationMiddleware\\:\\:\\$negotiator is never read, only written\\.$#"
|
||||
count: 1
|
||||
path: src/Http/Middleware/ContentNegotiationMiddleware.php
|
||||
|
||||
-
|
||||
message: "#^Method Phred\\\\Http\\\\Middleware\\\\ProblemDetailsMiddleware\\:\\:deriveStatus\\(\\) is unused\\.$#"
|
||||
count: 1
|
||||
path: src/Http/Middleware/ProblemDetailsMiddleware.php
|
||||
|
||||
-
|
||||
message: "#^Method Whoops\\\\Handler\\\\PrettyPageHandler\\:\\:handle\\(\\) invoked with 1 parameter, 0 required\\.$#"
|
||||
count: 1
|
||||
path: src/Http/Middleware/ProblemDetailsMiddleware.php
|
||||
|
||||
-
|
||||
message: "#^Function Sentry\\\\init not found\\.$#"
|
||||
count: 1
|
||||
path: src/Providers/Core/LoggingServiceProvider.php
|
||||
|
||||
-
|
||||
message: "#^Parameter \\#1 \\$handler of method Monolog\\\\Logger\\:\\:pushHandler\\(\\) expects Monolog\\\\Handler\\\\HandlerInterface, Sentry\\\\Monolog\\\\Handler given\\.$#"
|
||||
count: 1
|
||||
path: src/Providers/Core/LoggingServiceProvider.php
|
||||
|
||||
-
|
||||
message: "#^Class Phred\\\\Orm\\\\EloquentConnection not found\\.$#"
|
||||
count: 1
|
||||
path: src/Providers/Core/OrmServiceProvider.php
|
||||
|
||||
-
|
||||
message: "#^Method Phred\\\\Providers\\\\Core\\\\StorageServiceProvider\\:\\:createS3Adapter\\(\\) has invalid return type League\\\\Flysystem\\\\AwsS3V3\\\\AwsS3V3Adapter\\.$#"
|
||||
count: 1
|
||||
path: src/Providers/Core/StorageServiceProvider.php
|
||||
|
||||
-
|
||||
message: "#^Strict comparison using \\=\\=\\= between string and null will always evaluate to false\\.$#"
|
||||
count: 1
|
||||
path: src/Support/Config.php
|
||||
|
||||
-
|
||||
message: "#^Deprecated in PHP 8\\.4\\: Parameter \\#1 \\$host \\(string\\) is implicitly nullable via default value null\\.$#"
|
||||
count: 1
|
||||
path: src/Support/Http/CircuitBreakerMiddleware.php
|
||||
16
phpstan.neon
16
phpstan.neon
|
|
@ -1,16 +0,0 @@
|
|||
includes:
|
||||
- phpstan-baseline.neon
|
||||
|
||||
parameters:
|
||||
level: 5
|
||||
paths:
|
||||
- src
|
||||
- modules
|
||||
- config
|
||||
- bootstrap
|
||||
ignoreErrors:
|
||||
- identifier: missingType.iterableValue
|
||||
- identifier: missingType.generics
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
excludePaths:
|
||||
- vendor
|
||||
|
|
@ -1,18 +0,0 @@
|
|||
parameters:
|
||||
level: 5
|
||||
paths:
|
||||
- src
|
||||
ignoreErrors:
|
||||
-
|
||||
identifier: missingType.iterableValue
|
||||
-
|
||||
identifier: missingType.generics
|
||||
reportUnmatchedIgnoredErrors: false
|
||||
checkUninitializedProperties: true
|
||||
inferPrivatePropertyTypeFromConstructor: true
|
||||
|
||||
services:
|
||||
-
|
||||
class: Phred\Support\PhpStan\Rules\InvokableControllerRule
|
||||
tags:
|
||||
- phpstan.rules.rule
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<?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';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Console;
|
||||
|
||||
use Symfony\Component\Console\Command\Command as SymfonyCommand;
|
||||
use Symfony\Component\Console\Input\InputArgument;
|
||||
use Symfony\Component\Console\Input\InputOption;
|
||||
use Symfony\Component\Console\Input\InputInterface as Input;
|
||||
use Symfony\Component\Console\Output\OutputInterface as Output;
|
||||
|
||||
/**
|
||||
* Base command providing a Laravel-like developer experience.
|
||||
* Define $command, $description, and $options; implement handle().
|
||||
*/
|
||||
abstract class Command
|
||||
{
|
||||
protected string $command = '';
|
||||
protected string $description = '';
|
||||
/** @var array<string,array> */
|
||||
protected array $options = [];
|
||||
|
||||
public function getName(): string { return $this->command; }
|
||||
public function setName(string $name): void { $this->command = $name; }
|
||||
public function getDescription(): string { return $this->description; }
|
||||
/** @return array<string,array> */
|
||||
public function getOptions(): array { return $this->options; }
|
||||
|
||||
abstract public function handle(Input $input, Output $output): int;
|
||||
|
||||
public function toSymfony(): SymfonyCommand
|
||||
{
|
||||
$self = $this;
|
||||
return new class($self->getName(), $self) extends SymfonyCommand {
|
||||
public function __construct(string $name, private Command $wrapped)
|
||||
{
|
||||
parent::__construct($name);
|
||||
}
|
||||
protected function configure(): void
|
||||
{
|
||||
$this->setDescription($this->wrapped->getDescription());
|
||||
foreach ($this->wrapped->getOptions() as $key => $def) {
|
||||
$mode = $def['mode'] ?? 'argument';
|
||||
$description = $def['description'] ?? '';
|
||||
$default = $def['default'] ?? null;
|
||||
if ($mode === 'argument') {
|
||||
$argMode = ($def['required'] ?? false)
|
||||
? InputArgument::REQUIRED
|
||||
: InputArgument::OPTIONAL;
|
||||
$this->addArgument($key, $argMode, $description, $default);
|
||||
} elseif ($mode === 'flag') {
|
||||
$shortcut = $def['shortcut'] ?? null;
|
||||
$this->addOption(ltrim($key, '-'), $shortcut, InputOption::VALUE_NONE, $description);
|
||||
} else { // option
|
||||
$shortcut = $def['shortcut'] ?? null;
|
||||
$valueReq = $def['valueRequired'] ?? true;
|
||||
$valueMode = $valueReq ? InputOption::VALUE_REQUIRED : InputOption::VALUE_OPTIONAL;
|
||||
$this->addOption(ltrim($key, '-'), $shortcut, $valueMode, $description, $default);
|
||||
}
|
||||
}
|
||||
}
|
||||
protected function execute(\Symfony\Component\Console\Input\InputInterface $input, \Symfony\Component\Console\Output\OutputInterface $output): int
|
||||
{
|
||||
return $this->wrapped->handle($input, $output);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Flags\Contracts;
|
||||
|
||||
interface FeatureFlagClientInterface
|
||||
{
|
||||
public function isEnabled(string $flagKey, array $context = []): bool;
|
||||
}
|
||||
|
|
@ -1,25 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Flags;
|
||||
|
||||
use Phred\Flags\Contracts\FeatureFlagClientInterface;
|
||||
|
||||
final class FlagpoleClient implements FeatureFlagClientInterface
|
||||
{
|
||||
private \Flagpole\FeatureManager $manager;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
// For now, use an empty repository or load from config if needed.
|
||||
// Milestone M10 calls for a default adapter using Flagpole.
|
||||
$this->manager = new \Flagpole\FeatureManager(
|
||||
new \Flagpole\Repository\InMemoryFlagRepository()
|
||||
);
|
||||
}
|
||||
|
||||
public function isEnabled(string $flagKey, array $context = []): bool
|
||||
{
|
||||
return $this->manager->enabled($flagKey, new \Flagpole\Context($context));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,30 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Abstraction for producing API responses.
|
||||
* Implementations should honor the configured API format (REST or JSON:API).
|
||||
*/
|
||||
interface ApiResponseFactoryInterface
|
||||
{
|
||||
/**
|
||||
* 200 OK with serialized payload.
|
||||
* $context may contain format-specific hints (e.g., JSON:API resource type, includes, fields).
|
||||
*/
|
||||
public function ok(mixed $data, array $context = []): ResponseInterface;
|
||||
|
||||
/**
|
||||
* 201 Created with Location header and serialized payload.
|
||||
*/
|
||||
public function created(string $location, mixed $data, array $context = []): ResponseInterface;
|
||||
|
||||
/**
|
||||
* Generic JSON error payload (format-specific). Not a replacement for Problem Details middleware.
|
||||
*/
|
||||
public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface;
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Contracts;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
interface ApiResponseFactoryInterface
|
||||
{
|
||||
/**
|
||||
* Generic 200 OK with array payload.
|
||||
* Implementations must set appropriate Content-Type.
|
||||
* @param array<string,mixed> $data
|
||||
*/
|
||||
public function ok(array $data = []): ResponseInterface;
|
||||
|
||||
/**
|
||||
* 201 Created with array payload.
|
||||
* @param array<string,mixed> $data
|
||||
* @param string|null $location Optional Location header
|
||||
*/
|
||||
public function created(array $data = [], ?string $location = null): ResponseInterface;
|
||||
|
||||
/**
|
||||
* 204 No Content
|
||||
*/
|
||||
public function noContent(): ResponseInterface;
|
||||
|
||||
/**
|
||||
* Error response with status and details.
|
||||
* @param int $status HTTP status code (4xx/5xx)
|
||||
* @param string $title Short, human-readable summary
|
||||
* @param string|null $detail Detailed description
|
||||
* @param array<string,mixed> $extra Extra members dependent on format
|
||||
*/
|
||||
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface;
|
||||
|
||||
/**
|
||||
* Create a response from a raw associative array payload.
|
||||
* @param array<string,mixed> $payload
|
||||
* @param int $status
|
||||
* @param array<string,string|string[]> $headers
|
||||
*/
|
||||
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Contracts;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
interface ErrorFormatNegotiatorInterface
|
||||
{
|
||||
/**
|
||||
* Determine desired API format based on the request (e.g., Accept header).
|
||||
* Should return 'rest', 'jsonapi', or 'xml'.
|
||||
*/
|
||||
public function apiFormat(ServerRequestInterface $request): string;
|
||||
|
||||
/**
|
||||
* Determine if the client prefers an HTML error representation.
|
||||
*/
|
||||
public function wantsHtml(ServerRequestInterface $request): bool;
|
||||
}
|
||||
|
|
@ -1,14 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Contracts;
|
||||
|
||||
use Throwable;
|
||||
|
||||
interface ExceptionToStatusMapperInterface
|
||||
{
|
||||
/**
|
||||
* Map a Throwable to an HTTP status code (400–599), defaulting to 500 when out of range.
|
||||
*/
|
||||
public function map(Throwable $e): int;
|
||||
}
|
||||
|
|
@ -1,15 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Contracts;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
interface RequestIdProviderInterface
|
||||
{
|
||||
/**
|
||||
* Returns a correlation/request ID for the given request.
|
||||
* Implementations may reuse an incoming header or generate a new one.
|
||||
*/
|
||||
public function provide(ServerRequestInterface $request): string;
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Controllers;
|
||||
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
|
||||
final class FormatController
|
||||
{
|
||||
public function __construct(private ApiResponseFactoryInterface $responses) {}
|
||||
|
||||
public function __invoke(Request $request)
|
||||
{
|
||||
$format = $request->getAttribute(Negotiation::ATTR_API_FORMAT, 'rest');
|
||||
return $this->responses->ok(['format' => $format]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,62 +0,0 @@
|
|||
<?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 OpenApi\Attributes as OA;
|
||||
|
||||
final class HealthController
|
||||
{
|
||||
use \Phred\Http\Support\ConditionalRequestTrait;
|
||||
|
||||
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
|
||||
{
|
||||
$type = $request->getQueryParams()['type'] ?? 'liveness';
|
||||
|
||||
$data = [
|
||||
'ok' => true,
|
||||
'framework' => 'Phred',
|
||||
'type' => $type,
|
||||
'timestamp' => time(),
|
||||
];
|
||||
|
||||
// Readiness check could include DB connection check, etc.
|
||||
if ($type === 'readiness') {
|
||||
// Placeholder for actual checks
|
||||
$data['checks'] = [
|
||||
'database' => 'connected',
|
||||
'storage' => 'writable',
|
||||
];
|
||||
}
|
||||
|
||||
$etag = $this->generateEtag($data);
|
||||
if ($this->isFresh($request, $etag)) {
|
||||
return (new \Nyholm\Psr7\Factory\Psr17Factory())->createResponse(304);
|
||||
}
|
||||
|
||||
return $this->factory->fromArray($data, 200, ['ETag' => $etag]);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,43 +0,0 @@
|
|||
<?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));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,99 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\JsonApi;
|
||||
|
||||
use LogicException;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Nyholm\Psr7\Stream;
|
||||
use Phred\Http\ApiResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Minimal JSON:API response factory stub.
|
||||
* For full functionality, require "neomerx/json-api" and replace internals accordingly.
|
||||
*/
|
||||
class JsonApiResponseFactory implements ApiResponseFactoryInterface
|
||||
{
|
||||
public function ok(mixed $data, array $context = []): ResponseInterface
|
||||
{
|
||||
$document = $this->toResourceDocument($data, $context);
|
||||
return $this->jsonApi(200, $document);
|
||||
}
|
||||
|
||||
public function created(string $location, mixed $data, array $context = []): ResponseInterface
|
||||
{
|
||||
$document = $this->toResourceDocument($data, $context);
|
||||
$response = $this->jsonApi(201, $document);
|
||||
return $response->withHeader('Location', $location);
|
||||
}
|
||||
|
||||
public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface
|
||||
{
|
||||
$payload = [
|
||||
'errors' => [[
|
||||
'status' => (string) $status,
|
||||
'title' => $title,
|
||||
'detail' => $detail,
|
||||
'meta' => (object) $meta,
|
||||
]],
|
||||
];
|
||||
|
||||
return $this->jsonApi($status, $payload);
|
||||
}
|
||||
|
||||
private function jsonApi(int $status, array $document): ResponseInterface
|
||||
{
|
||||
// If neomerx/json-api is installed, you can swap this simple encoding with its encoder.
|
||||
$json = json_encode($document, JSON_THROW_ON_ERROR);
|
||||
$stream = Stream::create($json);
|
||||
return (new Response($status, ['Content-Type' => 'application/vnd.api+json']))->withBody($stream);
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert domain data to a very simple JSON:API resource document.
|
||||
* Context may include: 'type' (required for non-array scalars), 'id', 'includes', 'links', 'meta'.
|
||||
* This is intentionally minimal until a full encoder is wired.
|
||||
*
|
||||
* @param mixed $data
|
||||
* @param array $context
|
||||
* @return array
|
||||
*/
|
||||
private function toResourceDocument(mixed $data, array $context): array
|
||||
{
|
||||
// If neomerx/json-api not present, produce a simple document requiring caller to provide 'type'.
|
||||
if (!isset($context['type'])) {
|
||||
// Keep developer feedback explicit to encourage proper setup.
|
||||
throw new LogicException('JSON:API response requires context["type"]. Consider installing neomerx/json-api for advanced encoding.');
|
||||
}
|
||||
|
||||
$resource = [
|
||||
'type' => (string) $context['type'],
|
||||
];
|
||||
|
||||
if (is_array($data) && array_key_exists('id', $data)) {
|
||||
$resource['id'] = (string) $data['id'];
|
||||
$attributes = $data;
|
||||
unset($attributes['id']);
|
||||
} else {
|
||||
$attributes = $data;
|
||||
if (isset($context['id'])) {
|
||||
$resource['id'] = (string) $context['id'];
|
||||
}
|
||||
}
|
||||
|
||||
$resource['attributes'] = $attributes;
|
||||
|
||||
$document = ['data' => $resource];
|
||||
|
||||
if (!empty($context['links']) && is_array($context['links'])) {
|
||||
$document['links'] = $context['links'];
|
||||
}
|
||||
if (!empty($context['meta']) && is_array($context['meta'])) {
|
||||
$document['meta'] = $context['meta'];
|
||||
}
|
||||
|
||||
return $document;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
|
|
@ -1,273 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use FastRoute\Dispatcher;
|
||||
use FastRoute\RouteCollector;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
||||
use Relay\Relay;
|
||||
|
||||
use function FastRoute\simpleDispatcher;
|
||||
|
||||
/**
|
||||
* Core HTTP Kernel builds container, routes, and PSR-15 pipeline and processes requests.
|
||||
*/
|
||||
final class Kernel
|
||||
{
|
||||
private Container $container;
|
||||
private Dispatcher $dispatcher;
|
||||
|
||||
public function __construct(?Container $container = null, ?Dispatcher $dispatcher = null)
|
||||
{
|
||||
$this->container = $container ?? $this->buildContainer();
|
||||
// Providers may contribute routes during boot; ensure dispatcher is built after container init
|
||||
$this->dispatcher = $dispatcher ?? $this->buildDispatcher();
|
||||
}
|
||||
|
||||
public function container(): Container
|
||||
{
|
||||
return $this->container;
|
||||
}
|
||||
|
||||
public function dispatcher(): Dispatcher
|
||||
{
|
||||
return $this->dispatcher;
|
||||
}
|
||||
|
||||
public function handle(ServerRequest $request): ResponseInterface
|
||||
{
|
||||
// SYNC: Initialize RequestContext with the current request so that
|
||||
// any service resolving it (like DelegatingApiResponseFactory) is correct.
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
|
||||
$psr17 = $this->container->get(\Nyholm\Psr7\Factory\Psr17Factory::class);
|
||||
$config = $this->container->get(\Phred\Support\Contracts\ConfigInterface::class);
|
||||
|
||||
// CORS
|
||||
$corsSettings = new \Neomerx\Cors\Strategies\Settings();
|
||||
$scheme = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_SCHEME) ?: 'http';
|
||||
$host = parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_HOST) ?: 'localhost';
|
||||
$port = (int)parse_url((string)$config->get('APP_URL', 'http://localhost:8000'), PHP_URL_PORT) ?: 80;
|
||||
$corsSettings->init($scheme, $host, $port);
|
||||
$corsSettings->setAllowedOrigins($config->get('cors.origin', ['*']));
|
||||
$corsSettings->setAllowedMethods($config->get('cors.methods', ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']));
|
||||
$corsSettings->setAllowedHeaders($config->get('cors.headers.allow', ['Content-Type', 'Accept', 'Authorization', 'X-Requested-With']));
|
||||
$corsSettings->enableAllOriginsAllowed();
|
||||
$corsSettings->enableAllMethodsAllowed();
|
||||
$corsSettings->enableAllHeadersAllowed();
|
||||
|
||||
// Initialize RequestContext with the original request
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
|
||||
$debug = filter_var($config->get('APP_DEBUG', false), FILTER_VALIDATE_BOOLEAN);
|
||||
|
||||
$middleware = [
|
||||
new Middleware\TrustedProxiesMiddleware($config),
|
||||
new Middleware\CompressionMiddleware(),
|
||||
];
|
||||
|
||||
if ($debug) {
|
||||
$middleware[] = new class extends Middleware\Middleware {
|
||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
self::$timings = []; // Reset timings for each request in debug mode
|
||||
$response = $handler->handle($request);
|
||||
$timings = self::getTimings();
|
||||
if (!empty($timings)) {
|
||||
$encoded = json_encode($timings, JSON_UNESCAPED_SLASHES);
|
||||
if ($encoded) {
|
||||
$response = $response->withHeader('X-Phred-Timings', $encoded);
|
||||
}
|
||||
}
|
||||
return $response;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
$middleware = array_merge($middleware, [
|
||||
// Security headers
|
||||
new Middleware\Security\SecureHeadersMiddleware($config),
|
||||
// CORS
|
||||
new \Middlewares\Cors(\Neomerx\Cors\Analyzer::instance($corsSettings)),
|
||||
new class implements \Psr\Http\Server\MiddlewareInterface {
|
||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
},
|
||||
new Middleware\ProblemDetailsMiddleware(
|
||||
$debug,
|
||||
null,
|
||||
null,
|
||||
filter_var($config->get('API_PROBLEM_DETAILS', 'true'), FILTER_VALIDATE_BOOLEAN)
|
||||
),
|
||||
// Ensure RequestContext is initialized before anyone needs it
|
||||
new class implements \Psr\Http\Server\MiddlewareInterface {
|
||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
},
|
||||
new Middleware\UrlExtensionNegotiationMiddleware(),
|
||||
new Middleware\ContentNegotiationMiddleware($config),
|
||||
new class implements \Psr\Http\Server\MiddlewareInterface {
|
||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
},
|
||||
new \Phred\Http\Middleware\JsonApi\JsonApiQueryMiddleware(),
|
||||
new \Phred\Http\Middleware\Cache\ResponseCacheMiddleware(
|
||||
$this->container->get(\Psr\SimpleCache\CacheInterface::class),
|
||||
(int) $config->get('CACHE_TTL', 3600)
|
||||
),
|
||||
new class implements \Psr\Http\Server\MiddlewareInterface {
|
||||
public function process(\Psr\Http\Message\ServerRequestInterface $request, \Psr\Http\Server\RequestHandlerInterface $handler): \Psr\Http\Message\ResponseInterface
|
||||
{
|
||||
// Refresh RequestContext from the actual request object in the pipeline
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
return $handler->handle($request);
|
||||
}
|
||||
},
|
||||
new Middleware\RoutingMiddleware($this->dispatcher, $psr17),
|
||||
new Middleware\MiddlewareGroupMiddleware($config, $this->container),
|
||||
new Middleware\DispatchMiddleware($this->container, $psr17),
|
||||
]);
|
||||
|
||||
$relay = new Relay($middleware);
|
||||
return $relay->handle($request);
|
||||
}
|
||||
|
||||
private function buildContainer(): Container
|
||||
{
|
||||
$builder = new ContainerBuilder();
|
||||
|
||||
// Allow service providers to register definitions before defaults
|
||||
$configAdapter = new \Phred\Support\DefaultConfig();
|
||||
|
||||
$providers = new \Phred\Support\ProviderRepository($configAdapter);
|
||||
$providers->load();
|
||||
$providers->registerAll($builder);
|
||||
|
||||
// Add core definitions/bindings
|
||||
$builder->addDefinitions([
|
||||
\Phred\Support\Contracts\ConfigInterface::class => \DI\autowire(\Phred\Support\DefaultConfig::class),
|
||||
\Phred\Http\Contracts\ErrorFormatNegotiatorInterface::class => \DI\autowire(\Phred\Http\Support\DefaultErrorFormatNegotiator::class),
|
||||
\Phred\Http\Contracts\RequestIdProviderInterface::class => \DI\autowire(\Phred\Http\Support\DefaultRequestIdProvider::class),
|
||||
\Phred\Http\Contracts\ExceptionToStatusMapperInterface::class => \DI\autowire(\Phred\Http\Support\DefaultExceptionToStatusMapper::class),
|
||||
\Phred\Http\Contracts\ApiResponseFactoryInterface::class => \DI\autowire(\Phred\Http\Responses\DelegatingApiResponseFactory::class),
|
||||
\Phred\Http\Responses\RestResponseFactory::class => \DI\autowire(\Phred\Http\Responses\RestResponseFactory::class),
|
||||
\Phred\Http\Responses\JsonApiResponseFactory::class => \DI\autowire(\Phred\Http\Responses\JsonApiResponseFactory::class),
|
||||
\Phred\Http\Responses\XmlResponseFactory::class => \DI\autowire(\Phred\Http\Responses\XmlResponseFactory::class),
|
||||
\Nyholm\Psr7\Factory\Psr17Factory::class => \DI\autowire(\Nyholm\Psr7\Factory\Psr17Factory::class),
|
||||
\Psr\Http\Message\ServerRequestInterface::class => function () {
|
||||
return \Phred\Http\RequestContext::get();
|
||||
},
|
||||
]);
|
||||
$container = $builder->build();
|
||||
|
||||
// Boot providers after container is available
|
||||
// Reset provider-registered routes to avoid duplicates across multiple kernel instantiations (e.g., tests)
|
||||
\Phred\Http\Routing\RouteRegistry::clear();
|
||||
$providers->bootAll($container);
|
||||
|
||||
return $container;
|
||||
}
|
||||
|
||||
private function buildDispatcher(): Dispatcher
|
||||
{
|
||||
$cachePath = dirname(__DIR__, 2) . '/storage/cache/routes.php';
|
||||
if (file_exists($cachePath)) {
|
||||
/** @noinspection PhpIncludeInspection */
|
||||
$data = require $cachePath;
|
||||
$data = $this->unserializeRoutes($data);
|
||||
return new \FastRoute\Dispatcher\GroupCountBased($data);
|
||||
}
|
||||
|
||||
return simpleDispatcher($this->getRouteCollector());
|
||||
}
|
||||
|
||||
private function unserializeRoutes(array $data): array
|
||||
{
|
||||
array_walk_recursive($data, function (&$item) {
|
||||
if (is_string($item) && str_contains($item, 'SerializableClosure')) {
|
||||
try {
|
||||
$unserialized = unserialize($item);
|
||||
if ($unserialized instanceof \Laravel\SerializableClosure\SerializableClosure) {
|
||||
$item = $unserialized->getClosure();
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// Not a serializable closure or failed to unserialize
|
||||
}
|
||||
}
|
||||
});
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return callable(RouteCollector):void
|
||||
*/
|
||||
public function getRouteCollector(): callable
|
||||
{
|
||||
$routesPath = dirname(__DIR__, 2) . '/routes';
|
||||
$registry = \Phred\Http\Routing\RouteRegistry::class;
|
||||
|
||||
return function (RouteCollector $r) use ($routesPath, $registry): void {
|
||||
// Load user-defined routes if present
|
||||
$router = new Router($r);
|
||||
|
||||
foreach (['web.php', 'api.php'] as $file) {
|
||||
$path = $routesPath . '/' . $file;
|
||||
if (is_file($path)) {
|
||||
$middleware = $file === 'api.php' ? ['api'] : [];
|
||||
$router->group(['prefix' => '', 'middleware' => $middleware], static function (Router $router) use ($path): void {
|
||||
/** @noinspection PhpIncludeInspection */
|
||||
(static function ($router) use ($path) { require $path; })($router);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Load module route files under prefixes defined in routes/web.php via RouteGroups includes.
|
||||
// Additionally, as a convenience, auto-mount modules without explicit includes using folder name as prefix.
|
||||
$modulesDir = dirname(__DIR__, 2) . '/modules';
|
||||
if (is_dir($modulesDir)) {
|
||||
$entries = array_values(array_filter(scandir($modulesDir) ?: [], static fn($e) => $e !== '.' && $e !== '..'));
|
||||
sort($entries, SORT_STRING);
|
||||
foreach ($entries as $mod) {
|
||||
$modRoutes = $modulesDir . '/' . $mod . '/Routes';
|
||||
if (!is_dir($modRoutes)) {
|
||||
continue;
|
||||
}
|
||||
// Auto-mount only if the module's web.php wasn't already included via RouteGroups in root file.
|
||||
$autoInclude = function (string $relative, string $prefix, array $middleware = []) use ($modRoutes, $router, $registry): void {
|
||||
$file = $modRoutes . '/' . $relative;
|
||||
if (is_file($file) && !$registry::isLoaded($file)) {
|
||||
$router->group(['prefix' => '/' . strtolower($prefix), 'middleware' => $middleware], static function (Router $r) use ($file): void {
|
||||
/** @noinspection PhpIncludeInspection */
|
||||
(static function ($router) use ($file) { require $file; })($r);
|
||||
});
|
||||
}
|
||||
};
|
||||
$autoInclude('web.php', $mod);
|
||||
// api.php auto-mounted under /api/<module> with 'api' middleware group
|
||||
$autoInclude('api.php', 'api/' . $mod, ['api']);
|
||||
}
|
||||
}
|
||||
|
||||
// Allow providers to contribute routes
|
||||
$registry::apply($r, $router);
|
||||
|
||||
// Ensure default demo routes exist for acceptance/demo
|
||||
$r->addRoute('GET', '/_phred/health', Controllers\HealthController::class);
|
||||
$r->addRoute('GET', '/_phred/format', Controllers\FormatController::class);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,82 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,72 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,60 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
|
||||
use Phred\Http\Support\DefaultErrorFormatNegotiator;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\DefaultConfig;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
class ContentNegotiationMiddleware extends Middleware
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ?ConfigInterface $config = null,
|
||||
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
|
||||
) {}
|
||||
public const ATTR_API_FORMAT = 'phred.api_format'; // 'rest' | 'jsonapi' | 'xml'
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$cfg = $this->config ?? new DefaultConfig();
|
||||
$defaultFormat = strtolower((string) $cfg->get('API_FORMAT', 'rest'));
|
||||
|
||||
$format = $this->profileSelf(function () use ($request, $defaultFormat) {
|
||||
// First check if a format hint was already set by UrlExtensionNegotiationMiddleware
|
||||
$hint = $request->getAttribute('phred.format_hint');
|
||||
if ($hint && $hint !== 'html') {
|
||||
return $hint;
|
||||
}
|
||||
|
||||
// Allow Accept header to override
|
||||
$accept = $request->getHeaderLine('Accept');
|
||||
if (str_contains($accept, 'application/vnd.api+json')) {
|
||||
return 'jsonapi';
|
||||
}
|
||||
if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) {
|
||||
return 'xml';
|
||||
}
|
||||
if (str_contains($accept, 'application/json') || str_contains($accept, 'application/problem+json')) {
|
||||
return 'rest';
|
||||
}
|
||||
|
||||
return $defaultFormat;
|
||||
});
|
||||
|
||||
// Ensure RequestContext is updated so DelegatingApiResponseFactory sees the change
|
||||
$request = $request->withAttribute(self::ATTR_API_FORMAT, $format);
|
||||
|
||||
// SYNC: Update RequestContext so that any service resolving it (like DelegatingApiResponseFactory)
|
||||
// gets the request with the phred.api_format attribute.
|
||||
\Phred\Http\RequestContext::set($request);
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,94 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use DI\ContainerBuilder;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
|
||||
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\ServerRequestInterface as ServerRequest;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
|
||||
final class DispatchMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private \DI\Container $container,
|
||||
private Psr17Factory $psr17
|
||||
) {}
|
||||
|
||||
public function process(ServerRequest $request, Handler $handler): ResponseInterface
|
||||
{
|
||||
$handlerSpec = $request->getAttribute('phred.route.handler');
|
||||
$vars = (array) $request->getAttribute('phred.route.vars', []);
|
||||
|
||||
if (!$handlerSpec) {
|
||||
return $this->jsonError('No route handler', 500);
|
||||
}
|
||||
|
||||
$format = (string) ($request->getAttribute('phred.api_format', 'rest'));
|
||||
$callable = $this->resolveCallable($handlerSpec, $this->container);
|
||||
|
||||
RequestContext::set($request);
|
||||
try {
|
||||
$response = $this->invokeCallable($callable, $request, $vars);
|
||||
} finally {
|
||||
RequestContext::clear();
|
||||
}
|
||||
|
||||
if (!$response instanceof ResponseInterface) {
|
||||
return $this->normalizeToResponse($response);
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param mixed $payload
|
||||
*/
|
||||
private function normalizeToResponse(mixed $payload): ResponseInterface
|
||||
{
|
||||
if (is_array($payload)) {
|
||||
$json = json_encode($payload, JSON_UNESCAPED_SLASHES);
|
||||
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
|
||||
$res->getBody()->write((string) $json);
|
||||
return $res;
|
||||
}
|
||||
|
||||
$res = $this->psr17->createResponse(200)->withHeader('Content-Type', 'text/plain; charset=utf-8');
|
||||
$res->getBody()->write((string) $payload);
|
||||
return $res;
|
||||
}
|
||||
|
||||
private function jsonError(string $message, int $status): ResponseInterface
|
||||
{
|
||||
$res = $this->psr17->createResponse($status)->withHeader('Content-Type', 'application/json');
|
||||
$res->getBody()->write(json_encode(['error' => $message], JSON_UNESCAPED_SLASHES));
|
||||
return $res;
|
||||
}
|
||||
|
||||
private function resolveCallable(mixed $handlerSpec, \DI\Container $requestContainer): callable
|
||||
{
|
||||
if (is_string($handlerSpec) && class_exists($handlerSpec)) {
|
||||
$controller = $requestContainer->get($handlerSpec);
|
||||
return [$controller, '__invoke'];
|
||||
}
|
||||
|
||||
if (is_callable($handlerSpec)) {
|
||||
return $handlerSpec;
|
||||
}
|
||||
|
||||
throw new \RuntimeException('Invalid route handler. Phred requires invokable controllers (string) or a valid callable.');
|
||||
}
|
||||
|
||||
private function invokeCallable(callable $callable, ServerRequest $request, array $vars): mixed
|
||||
{
|
||||
return $callable($request, ...array_values($vars));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,44 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
abstract class Middleware implements MiddlewareInterface
|
||||
{
|
||||
/** @var array<string, float> */
|
||||
protected static array $timings = [];
|
||||
|
||||
abstract public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface;
|
||||
|
||||
/**
|
||||
* Wrap a handler and measure its execution time.
|
||||
*/
|
||||
protected function profile(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$start = microtime(true);
|
||||
try {
|
||||
return $handler->handle($request);
|
||||
} finally {
|
||||
$duration = microtime(true) - $start;
|
||||
self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple profiler for middleware that don't need to wrap the handler.
|
||||
*/
|
||||
protected function profileSelf(callable $callback): mixed
|
||||
{
|
||||
$start = microtime(true);
|
||||
try {
|
||||
return $callback();
|
||||
} finally {
|
||||
$duration = microtime(true) - $start;
|
||||
self::$timings[static::class] = (self::$timings[static::class] ?? 0) + $duration;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all recorded timings.
|
||||
* @return array<string, float>
|
||||
*/
|
||||
public static function getTimings(): array
|
||||
{
|
||||
return self::$timings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Record a timing manually.
|
||||
*/
|
||||
public static function recordTiming(string $key, float $duration): void
|
||||
{
|
||||
self::$timings[$key] = (self::$timings[$key] ?? 0) + $duration;
|
||||
}
|
||||
|
||||
protected function json(array $data, int $status = 200): ResponseInterface
|
||||
{
|
||||
$response = new \Nyholm\Psr7\Response($status, ['Content-Type' => 'application/json']);
|
||||
$response->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES));
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,198 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use Crell\ApiProblem\ApiProblem;
|
||||
use Nyholm\Psr7\Response;
|
||||
use Nyholm\Psr7\Stream;
|
||||
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
|
||||
use Phred\Http\Contracts\ExceptionToStatusMapperInterface;
|
||||
use Phred\Http\Contracts\RequestIdProviderInterface;
|
||||
use Phred\Http\Support\DefaultExceptionToStatusMapper;
|
||||
use Phred\Http\Support\DefaultErrorFormatNegotiator;
|
||||
use Phred\Http\Support\DefaultRequestIdProvider;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\DefaultConfig;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
use Throwable;
|
||||
|
||||
class ProblemDetailsMiddleware extends Middleware
|
||||
{
|
||||
public function __construct(
|
||||
private readonly bool $debug = false,
|
||||
private readonly ?RequestIdProviderInterface $requestIdProvider = null,
|
||||
private readonly ?ExceptionToStatusMapperInterface $statusMapper = null,
|
||||
private readonly ?bool $useProblemDetails = null,
|
||||
private readonly ?ErrorFormatNegotiatorInterface $negotiator = null,
|
||||
private readonly ?ConfigInterface $config = null,
|
||||
) {
|
||||
}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
try {
|
||||
return $this->profile($request, $handler);
|
||||
} catch (Throwable $e) {
|
||||
$useProblem = $this->shouldUseProblemDetails();
|
||||
$format = $this->determineApiFormat($request);
|
||||
$requestId = $this->provideRequestId($request);
|
||||
|
||||
if ($this->shouldRenderHtml($request)) {
|
||||
return $this->renderWhoopsHtml($e, $requestId);
|
||||
}
|
||||
|
||||
$detail = $this->computeDetail($e);
|
||||
$status = $this->mapStatus($e);
|
||||
|
||||
if ($useProblem && $format !== 'jsonapi') {
|
||||
return $this->respondProblemDetails($e, $status, $detail, $requestId);
|
||||
}
|
||||
|
||||
return $this->respondJsonApiOrJson($e, $status, $detail, $format, $requestId);
|
||||
}
|
||||
}
|
||||
|
||||
private function deriveStatus(Throwable $e): int
|
||||
{
|
||||
// Kept for backward compatibility in case of external references; delegate to default mapper.
|
||||
return (new DefaultExceptionToStatusMapper())->map($e);
|
||||
}
|
||||
|
||||
private function shortClass(object $o): string
|
||||
{
|
||||
$fqcn = get_class($o);
|
||||
$pos = strrpos($fqcn, chr(92)); // '\\' as ASCII 92
|
||||
if ($pos !== false) {
|
||||
return substr($fqcn, $pos + 1);
|
||||
}
|
||||
return $fqcn;
|
||||
}
|
||||
|
||||
private function shouldUseProblemDetails(): bool
|
||||
{
|
||||
$cfg = $this->config ?? new DefaultConfig();
|
||||
$raw = $this->useProblemDetails ?? $cfg->get('API_PROBLEM_DETAILS', 'true');
|
||||
return filter_var((string) $raw, FILTER_VALIDATE_BOOLEAN);
|
||||
}
|
||||
|
||||
private function determineApiFormat(ServerRequestInterface $request): string
|
||||
{
|
||||
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
|
||||
return $neg->apiFormat($request);
|
||||
}
|
||||
|
||||
private function provideRequestId(ServerRequestInterface $request): string
|
||||
{
|
||||
$provider = $this->requestIdProvider ?? new DefaultRequestIdProvider();
|
||||
return $provider->provide($request);
|
||||
}
|
||||
|
||||
private function shouldRenderHtml(ServerRequestInterface $request): bool
|
||||
{
|
||||
if (!$this->debug) {
|
||||
return false;
|
||||
}
|
||||
$neg = $this->negotiator ?? new DefaultErrorFormatNegotiator();
|
||||
return $neg->wantsHtml($request);
|
||||
}
|
||||
|
||||
private function computeDetail(Throwable $e): string
|
||||
{
|
||||
if ($this->debug) {
|
||||
return $e->getMessage() . "\n\n" . $e->getTraceAsString();
|
||||
}
|
||||
return $e->getMessage();
|
||||
}
|
||||
|
||||
private function mapStatus(Throwable $e): int
|
||||
{
|
||||
$mapper = $this->statusMapper ?? new DefaultExceptionToStatusMapper();
|
||||
return $mapper->map($e);
|
||||
}
|
||||
|
||||
private function renderWhoopsHtml(Throwable $e, string $requestId): ResponseInterface
|
||||
{
|
||||
if (class_exists(\Whoops\Run::class)) {
|
||||
$handler = new \Whoops\Handler\PrettyPageHandler();
|
||||
$whoops = new \Whoops\Run();
|
||||
$whoops->allowQuit(false);
|
||||
$whoops->writeToOutput(false);
|
||||
$whoops->pushHandler($handler);
|
||||
$html = (string) $whoops->handleException($e);
|
||||
if ($html === '') {
|
||||
ob_start();
|
||||
$handler->handle($e);
|
||||
$html = (string) ob_get_clean();
|
||||
}
|
||||
if ($html === '') {
|
||||
$html = '<!doctype html><html><head><meta charset="utf-8"><title>Whoops</title></head><body><h1>Whoops</h1><pre>'
|
||||
. htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8')
|
||||
. '</pre></body></html>';
|
||||
}
|
||||
$stream = Stream::create($html);
|
||||
return (new Response(500, [
|
||||
'Content-Type' => 'text/html; charset=UTF-8',
|
||||
'X-Request-Id' => $requestId,
|
||||
]))->withBody($stream);
|
||||
}
|
||||
|
||||
return $this->respondPlainTextFallback($e->getMessage(), $requestId);
|
||||
}
|
||||
|
||||
private function respondPlainTextFallback(string $message, string $requestId): ResponseInterface
|
||||
{
|
||||
$stream = Stream::create($message);
|
||||
return (new Response(500, [
|
||||
'Content-Type' => 'text/plain; charset=UTF-8',
|
||||
'X-Request-Id' => $requestId,
|
||||
]))->withBody($stream);
|
||||
}
|
||||
|
||||
private function respondProblemDetails(Throwable $e, int $status, string $detail, string $requestId): ResponseInterface
|
||||
{
|
||||
$problem = new ApiProblem($this->shortClass($e) ?: 'Error');
|
||||
$problem->setType('about:blank');
|
||||
$problem->setTitle($this->shortClass($e));
|
||||
$problem->setStatus($status);
|
||||
$problem->setDetail($detail ?: 'An error occurred');
|
||||
if ($this->debug) {
|
||||
$problem['exception'] = [
|
||||
'class' => get_class($e),
|
||||
'code' => $e->getCode(),
|
||||
'file' => $e->getFile(),
|
||||
'line' => $e->getLine(),
|
||||
];
|
||||
}
|
||||
|
||||
$json = json_encode($problem, JSON_THROW_ON_ERROR);
|
||||
$stream = Stream::create($json);
|
||||
return (new Response($status, [
|
||||
'Content-Type' => 'application/problem+json',
|
||||
'X-Request-Id' => $requestId,
|
||||
]))->withBody($stream);
|
||||
}
|
||||
|
||||
private function respondJsonApiOrJson(Throwable $e, int $status, string $detail, string $format, string $requestId): ResponseInterface
|
||||
{
|
||||
$payload = [
|
||||
'errors' => [[
|
||||
'status' => (string) $status,
|
||||
'title' => $this->shortClass($e),
|
||||
'detail' => $detail,
|
||||
]],
|
||||
];
|
||||
|
||||
$json = json_encode($payload, JSON_THROW_ON_ERROR);
|
||||
$stream = Stream::create($json);
|
||||
$contentType = $format === 'jsonapi' ? 'application/vnd.api+json' : 'application/json';
|
||||
return (new Response($status, [
|
||||
'Content-Type' => $contentType,
|
||||
'X-Request-Id' => $requestId,
|
||||
]))->withBody($stream);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,50 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use FastRoute\Dispatcher;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
|
||||
final class RoutingMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public function __construct(
|
||||
private Dispatcher $dispatcher,
|
||||
private Psr17Factory $psr17
|
||||
) {}
|
||||
|
||||
public function process(ServerRequest $request, Handler $handler): ResponseInterface
|
||||
{
|
||||
$routeInfo = $this->dispatcher->dispatch($request->getMethod(), $request->getUri()->getPath());
|
||||
switch ($routeInfo[0]) {
|
||||
case Dispatcher::NOT_FOUND:
|
||||
$response = $this->psr17->createResponse(404);
|
||||
$response->getBody()->write(json_encode(['error' => 'Not Found'], JSON_UNESCAPED_SLASHES));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
case Dispatcher::METHOD_NOT_ALLOWED:
|
||||
$response = $this->psr17->createResponse(405);
|
||||
$response->getBody()->write(json_encode(['error' => 'Method Not Allowed'], JSON_UNESCAPED_SLASHES));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
case Dispatcher::FOUND:
|
||||
[$status, $spec, $vars] = $routeInfo;
|
||||
$handlerSpec = is_array($spec) && isset($spec['handler']) ? $spec['handler'] : $spec;
|
||||
$middleware = is_array($spec) && isset($spec['middleware']) ? (array)$spec['middleware'] : [];
|
||||
$name = is_array($spec) && isset($spec['name']) ? (string)$spec['name'] : null;
|
||||
|
||||
$request = $request
|
||||
->withAttribute('phred.route.handler', $handlerSpec)
|
||||
->withAttribute('phred.route.vars', $vars)
|
||||
->withAttribute('phred.route.middleware', $middleware)
|
||||
->withAttribute('phred.route.name', $name);
|
||||
return $handler->handle($request);
|
||||
default:
|
||||
$response = $this->psr17->createResponse(500);
|
||||
$response->getBody()->write(json_encode(['error' => 'Routing failure'], JSON_UNESCAPED_SLASHES));
|
||||
return $response->withHeader('Content-Type', 'application/json');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,40 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware\Security;
|
||||
|
||||
use Phred\Http\Middleware\Middleware;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Basic CSRF protection middleware.
|
||||
* Expects a token in '_csrf' parameter for state-changing requests or 'X-CSRF-TOKEN' header.
|
||||
*/
|
||||
final class CsrfMiddleware extends Middleware
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$method = $request->getMethod();
|
||||
if (in_array($method, ['GET', 'HEAD', 'OPTIONS', 'TRACE'], true)) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$session = $request->getAttribute('session');
|
||||
$token = null;
|
||||
|
||||
if ($session && method_exists($session, 'get')) {
|
||||
$token = $session->get('_csrf_token');
|
||||
}
|
||||
|
||||
$provided = $request->getParsedBody()['_csrf'] ?? $request->getHeaderLine('X-CSRF-TOKEN');
|
||||
|
||||
if (!$token || $token !== $provided) {
|
||||
// In a real app, we might throw a specific exception that maps to 419 or 403
|
||||
throw new \RuntimeException('CSRF token mismatch', 403);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware\Security;
|
||||
|
||||
use Phred\Http\Middleware\Middleware;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
/**
|
||||
* Middleware to add common security headers to the response.
|
||||
*/
|
||||
final class SecureHeadersMiddleware extends Middleware
|
||||
{
|
||||
public function __construct(
|
||||
private readonly ConfigInterface $config
|
||||
) {}
|
||||
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$response = $this->profile($request, $handler);
|
||||
|
||||
// Standard security headers
|
||||
$response = $response->withHeader('X-Content-Type-Options', 'nosniff')
|
||||
->withHeader('X-Frame-Options', 'SAMEORIGIN')
|
||||
->withHeader('X-XSS-Protection', '1; mode=block')
|
||||
->withHeader('Referrer-Policy', 'no-referrer-when-downgrade')
|
||||
->withHeader('Content-Security-Policy', $this->config->get('security.csp', "default-src 'self'"));
|
||||
|
||||
if ($this->config->get('security.hsts', true)) {
|
||||
$response = $response->withHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');
|
||||
}
|
||||
|
||||
return $response;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,45 +0,0 @@
|
|||
<?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);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,104 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use Phred\Support\Config;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as Request;
|
||||
use Psr\Http\Server\MiddlewareInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface as Handler;
|
||||
|
||||
/**
|
||||
* Parses a trailing URL extension and hints content negotiation.
|
||||
*
|
||||
* - Controlled by env/config `URL_EXTENSION_NEGOTIATION` (bool, default true)
|
||||
* - Allowed extensions by env/config `URL_EXTENSION_WHITELIST`
|
||||
* - Pipe-separated list: e.g., "json|php|none" (default: "json|php|none")
|
||||
* - Behavior:
|
||||
* - Detects and strips ".ext" at the end of path if ext is whitelisted (except `none` which means no ext)
|
||||
* - Sets request attribute `phred.format_hint` to ext (json|xml|html) mapping:
|
||||
* json -> json
|
||||
* xml -> xml (not implemented yet; reserved for M12)
|
||||
* php/none -> html
|
||||
* - Optionally, sets Accept header mapping for downstream negotiation:
|
||||
* json -> application/json
|
||||
* xml -> application/xml (reserved)
|
||||
* html -> text/html
|
||||
*/
|
||||
final class UrlExtensionNegotiationMiddleware implements MiddlewareInterface
|
||||
{
|
||||
public const ATTR_FORMAT_HINT = 'phred.format_hint';
|
||||
|
||||
public function process(Request $request, Handler $handler): ResponseInterface
|
||||
{
|
||||
$enabled = filter_var((string) Config::get('URL_EXTENSION_NEGOTIATION', 'true'), FILTER_VALIDATE_BOOLEAN);
|
||||
if (!$enabled) {
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
$whitelistRaw = (string) Config::get('URL_EXTENSION_WHITELIST', 'json|xml|php|none');
|
||||
$allowed = array_filter(array_map('trim', explode('|', strtolower($whitelistRaw))));
|
||||
$allowed = $allowed ?: ['json', 'xml', 'php', 'none'];
|
||||
|
||||
$uri = $request->getUri();
|
||||
$path = $uri->getPath();
|
||||
|
||||
$ext = null;
|
||||
if (preg_match('/\.([a-z0-9]+)$/i', $path, $m)) {
|
||||
$candidate = strtolower($m[1]);
|
||||
if (in_array($candidate, $allowed, true)) {
|
||||
$ext = $candidate;
|
||||
// strip the extension from the path for routing purposes
|
||||
$path = substr($path, 0, - (strlen($candidate) + 1));
|
||||
}
|
||||
} else {
|
||||
// no extension → treat as 'none' if allowed
|
||||
if (in_array('none', $allowed, true)) {
|
||||
$ext = 'none';
|
||||
}
|
||||
}
|
||||
|
||||
if ($ext !== null) {
|
||||
$hint = $this->mapToHint($ext);
|
||||
if ($hint !== null) {
|
||||
$request = $request->withAttribute(self::ATTR_FORMAT_HINT, $hint);
|
||||
// Only set Accept for explicit JSON (and future XML), and only if client didn't set one.
|
||||
$accept = $this->mapToAccept($hint);
|
||||
if ($accept !== null) {
|
||||
$current = trim($request->getHeaderLine('Accept'));
|
||||
if ($current === '') {
|
||||
$request = $request->withHeader('Accept', $accept);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// If we modified the path, update the URI so router matches sans extension
|
||||
if ($path !== $uri->getPath()) {
|
||||
$newUri = $uri->withPath($path === '' ? '/' : $path);
|
||||
$request = $request->withUri($newUri);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
private function mapToHint(string $ext): ?string
|
||||
{
|
||||
return match ($ext) {
|
||||
'json' => 'rest',
|
||||
'xml' => 'xml',
|
||||
'php', 'none' => 'html',
|
||||
default => null,
|
||||
};
|
||||
}
|
||||
|
||||
private function mapToAccept(string $hint): ?string
|
||||
{
|
||||
return match ($hint) {
|
||||
'json' => 'application/json',
|
||||
'xml' => 'application/xml', // reserved for M12
|
||||
default => null, // do not force Accept for html/none
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Middleware;
|
||||
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
use Psr\Http\Server\RequestHandlerInterface;
|
||||
|
||||
abstract class ValidationMiddleware extends Middleware
|
||||
{
|
||||
public function process(ServerRequestInterface $request, RequestHandlerInterface $handler): ResponseInterface
|
||||
{
|
||||
$errors = $this->validate($request);
|
||||
|
||||
if (!empty($errors)) {
|
||||
return $this->json([
|
||||
'errors' => $errors
|
||||
], 422);
|
||||
}
|
||||
|
||||
return $handler->handle($request);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed> List of validation errors
|
||||
*/
|
||||
abstract protected function validate(ServerRequestInterface $request): array;
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http;
|
||||
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
/**
|
||||
* Minimal request context holder for the current request during dispatch.
|
||||
* DispatchMiddleware sets/clears it around controller invocation so that
|
||||
* other services (e.g., response factory selector) can inspect negotiation.
|
||||
*/
|
||||
final class RequestContext
|
||||
{
|
||||
private static ?ServerRequestInterface $current = null;
|
||||
|
||||
public static function set(ServerRequestInterface $request): void
|
||||
{
|
||||
self::$current = $request;
|
||||
}
|
||||
|
||||
public static function get(): ?ServerRequestInterface
|
||||
{
|
||||
return self::$current;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$current = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,59 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Responses;
|
||||
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||
use Phred\Http\Middleware\ContentNegotiationMiddleware as Negotiation;
|
||||
use Phred\Http\RequestContext;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
/**
|
||||
* Delegates to REST or JSON:API factory depending on current request format.
|
||||
* Controllers receive this via DI and call its methods; it inspects
|
||||
* RequestContext (set in DispatchMiddleware) to choose the underlying factory.
|
||||
*/
|
||||
final class DelegatingApiResponseFactory implements ApiResponseFactoryInterface
|
||||
{
|
||||
public function __construct(
|
||||
private RestResponseFactory $rest,
|
||||
private JsonApiResponseFactory $jsonapi,
|
||||
private XmlResponseFactory $xml
|
||||
) {}
|
||||
|
||||
public function ok(array $data = []): ResponseInterface
|
||||
{
|
||||
return $this->delegate()->ok($data);
|
||||
}
|
||||
|
||||
public function created(array $data = [], ?string $location = null): ResponseInterface
|
||||
{
|
||||
return $this->delegate()->created($data, $location);
|
||||
}
|
||||
|
||||
public function noContent(): ResponseInterface
|
||||
{
|
||||
return $this->delegate()->noContent();
|
||||
}
|
||||
|
||||
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
|
||||
{
|
||||
return $this->delegate()->error($status, $title, $detail, $extra);
|
||||
}
|
||||
|
||||
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||
{
|
||||
return $this->delegate()->fromArray($payload, $status, $headers);
|
||||
}
|
||||
|
||||
private function delegate(): ApiResponseFactoryInterface
|
||||
{
|
||||
$req = RequestContext::get();
|
||||
$format = $req?->getAttribute('phred.api_format') ?? 'rest';
|
||||
return match ($format) {
|
||||
'jsonapi' => $this->jsonapi,
|
||||
'xml' => $this->xml,
|
||||
default => $this->rest,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -1,67 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Responses;
|
||||
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class JsonApiResponseFactory implements ApiResponseFactoryInterface
|
||||
{
|
||||
public function __construct(private Psr17Factory $psr17) {}
|
||||
|
||||
public function ok(array $data = []): ResponseInterface
|
||||
{
|
||||
return $this->document(['data' => $data], 200);
|
||||
}
|
||||
|
||||
public function created(array $data = [], ?string $location = null): ResponseInterface
|
||||
{
|
||||
$res = $this->document(['data' => $data], 201);
|
||||
if ($location) {
|
||||
$res = $res->withHeader('Location', $location);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
public function noContent(): ResponseInterface
|
||||
{
|
||||
// JSON:API allows 204 without body
|
||||
return $this->psr17->createResponse(204);
|
||||
}
|
||||
|
||||
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
|
||||
{
|
||||
$error = array_filter([
|
||||
'status' => (string) $status,
|
||||
'title' => $title,
|
||||
'detail' => $detail,
|
||||
], static fn($v) => $v !== null && $v !== '');
|
||||
if (!empty($extra)) {
|
||||
$error = array_merge($error, $extra);
|
||||
}
|
||||
return $this->document(['errors' => [$error]], $status);
|
||||
}
|
||||
|
||||
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||
{
|
||||
// Caller must ensure payload is a valid JSON:API document shape
|
||||
$res = $this->document($payload, $status);
|
||||
foreach ($headers as $name => $value) {
|
||||
$res = $res->withHeader($name, $value);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<string,mixed> $doc
|
||||
*/
|
||||
private function document(array $doc, int $status): ResponseInterface
|
||||
{
|
||||
$res = $this->psr17->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/vnd.api+json');
|
||||
$res->getBody()->write(json_encode($doc, JSON_UNESCAPED_SLASHES));
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,63 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Responses;
|
||||
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
final class RestResponseFactory implements ApiResponseFactoryInterface
|
||||
{
|
||||
public function __construct(private Psr17Factory $psr17) {}
|
||||
|
||||
public function ok(array $data = []): ResponseInterface
|
||||
{
|
||||
return $this->json($data, 200);
|
||||
}
|
||||
|
||||
public function created(array $data = [], ?string $location = null): ResponseInterface
|
||||
{
|
||||
$res = $this->json($data, 201);
|
||||
if ($location) {
|
||||
$res = $res->withHeader('Location', $location);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
public function noContent(): ResponseInterface
|
||||
{
|
||||
return $this->psr17->createResponse(204);
|
||||
}
|
||||
|
||||
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
|
||||
{
|
||||
$payload = array_merge([
|
||||
'type' => $extra['type'] ?? 'about:blank',
|
||||
'title' => $title,
|
||||
'status' => $status,
|
||||
], $detail !== null ? ['detail' => $detail] : [], $extra);
|
||||
|
||||
$res = $this->psr17->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/problem+json');
|
||||
$res->getBody()->write(json_encode($payload, JSON_UNESCAPED_SLASHES));
|
||||
return $res;
|
||||
}
|
||||
|
||||
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||
{
|
||||
$res = $this->json($payload, $status);
|
||||
foreach ($headers as $name => $value) {
|
||||
$res = $res->withHeader($name, $value);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
private function json(array $data, int $status): ResponseInterface
|
||||
{
|
||||
$res = $this->psr17->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/json');
|
||||
$res->getBody()->write(json_encode($data, JSON_UNESCAPED_SLASHES));
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,68 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Responses;
|
||||
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Symfony\Component\Serializer\Encoder\XmlEncoder;
|
||||
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
|
||||
final class XmlResponseFactory implements ApiResponseFactoryInterface
|
||||
{
|
||||
private Serializer $serializer;
|
||||
|
||||
public function __construct(private Psr17Factory $psr17 = new Psr17Factory())
|
||||
{
|
||||
$this->serializer = new Serializer([new ArrayDenormalizer()], [new XmlEncoder()]);
|
||||
}
|
||||
|
||||
public function ok(array $data = []): ResponseInterface
|
||||
{
|
||||
return $this->xml($data, 200);
|
||||
}
|
||||
|
||||
public function created(array $data = [], ?string $location = null): ResponseInterface
|
||||
{
|
||||
$res = $this->xml($data, 201);
|
||||
if ($location) {
|
||||
$res = $res->withHeader('Location', $location);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
public function noContent(): ResponseInterface
|
||||
{
|
||||
return $this->psr17->createResponse(204);
|
||||
}
|
||||
|
||||
public function error(int $status, string $title, ?string $detail = null, array $extra = []): ResponseInterface
|
||||
{
|
||||
$payload = array_merge([
|
||||
'title' => $title,
|
||||
'status' => $status,
|
||||
], $detail !== null ? ['detail' => $detail] : [], $extra);
|
||||
|
||||
return $this->xml(['error' => $payload], $status);
|
||||
}
|
||||
|
||||
public function fromArray(array $payload, int $status = 200, array $headers = []): ResponseInterface
|
||||
{
|
||||
$res = $this->xml($payload, $status);
|
||||
foreach ($headers as $name => $value) {
|
||||
$res = $res->withHeader($name, $value);
|
||||
}
|
||||
return $res;
|
||||
}
|
||||
|
||||
private function xml(array $data, int $status): ResponseInterface
|
||||
{
|
||||
$xml = $this->serializer->serialize($data, 'xml');
|
||||
$res = $this->psr17->createResponse($status)
|
||||
->withHeader('Content-Type', 'application/xml');
|
||||
$res->getBody()->write($xml);
|
||||
return $res;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,47 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Rest;
|
||||
|
||||
use Nyholm\Psr7\Response;
|
||||
use Nyholm\Psr7\Stream;
|
||||
use Phred\Http\ApiResponseFactoryInterface;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
class RestResponseFactory implements ApiResponseFactoryInterface
|
||||
{
|
||||
public function __construct(private readonly SerializerInterface $serializer)
|
||||
{
|
||||
}
|
||||
|
||||
public function ok(mixed $data, array $context = []): ResponseInterface
|
||||
{
|
||||
return $this->json(200, $data, $context);
|
||||
}
|
||||
|
||||
public function created(string $location, mixed $data, array $context = []): ResponseInterface
|
||||
{
|
||||
$response = $this->json(201, $data, $context);
|
||||
return $response->withHeader('Location', $location);
|
||||
}
|
||||
|
||||
public function error(int $status, string $title, ?string $detail = null, array $meta = []): ResponseInterface
|
||||
{
|
||||
$payload = [
|
||||
'title' => $title,
|
||||
'detail' => $detail,
|
||||
'meta' => (object) $meta,
|
||||
];
|
||||
|
||||
return $this->json($status, $payload);
|
||||
}
|
||||
|
||||
private function json(int $status, mixed $data, array $context = []): ResponseInterface
|
||||
{
|
||||
$json = $this->serializer->serialize($data, 'json', $context);
|
||||
$stream = Stream::create($json);
|
||||
return (new Response($status, ['Content-Type' => 'application/json']))->withBody($stream);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,83 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http;
|
||||
|
||||
use FastRoute\RouteCollector;
|
||||
|
||||
/**
|
||||
* Tiny facade around FastRoute\RouteCollector to offer a friendly API in route files.
|
||||
*/
|
||||
final class Router
|
||||
{
|
||||
private array $groupMiddleware = [];
|
||||
|
||||
public function __construct(private RouteCollector $collector, array $groupMiddleware = [])
|
||||
{
|
||||
$this->groupMiddleware = $groupMiddleware;
|
||||
}
|
||||
|
||||
public function get(string $path, string|callable $handler, array $options = []): void
|
||||
{
|
||||
$this->addRoute('GET', $path, $handler, $options);
|
||||
}
|
||||
|
||||
public function post(string $path, string|callable $handler, array $options = []): void
|
||||
{
|
||||
$this->addRoute('POST', $path, $handler, $options);
|
||||
}
|
||||
|
||||
public function put(string $path, string|callable $handler, array $options = []): void
|
||||
{
|
||||
$this->addRoute('PUT', $path, $handler, $options);
|
||||
}
|
||||
|
||||
public function patch(string $path, string|callable $handler, array $options = []): void
|
||||
{
|
||||
$this->addRoute('PATCH', $path, $handler, $options);
|
||||
}
|
||||
|
||||
public function delete(string $path, string|callable $handler, array $options = []): void
|
||||
{
|
||||
$this->addRoute('DELETE', $path, $handler, $options);
|
||||
}
|
||||
|
||||
private function addRoute(string $method, string $path, string|callable $handler, array $options): void
|
||||
{
|
||||
$middleware = $options['middleware'] ?? [];
|
||||
if (is_string($middleware)) {
|
||||
$middleware = [$middleware];
|
||||
}
|
||||
$middleware = array_merge($this->groupMiddleware, $middleware);
|
||||
|
||||
$spec = [
|
||||
'handler' => $handler,
|
||||
'middleware' => $middleware,
|
||||
'name' => $options['name'] ?? null,
|
||||
];
|
||||
|
||||
$this->collector->addRoute($method, $path, $spec);
|
||||
}
|
||||
|
||||
/**
|
||||
* Group routes under a common path prefix and/or middleware.
|
||||
*
|
||||
* Example:
|
||||
* $router->group('/api', function (Router $r) { ... });
|
||||
* $router->group(['prefix' => '/api', 'middleware' => 'api'], function (Router $r) { ... });
|
||||
*/
|
||||
public function group(string|array $attributes, callable $routes): void
|
||||
{
|
||||
$prefix = is_array($attributes) ? ($attributes['prefix'] ?? '') : $attributes;
|
||||
$middleware = is_array($attributes) ? ($attributes['middleware'] ?? []) : [];
|
||||
if (is_string($middleware)) {
|
||||
$middleware = [$middleware];
|
||||
}
|
||||
|
||||
$newMiddleware = array_merge($this->groupMiddleware, $middleware);
|
||||
|
||||
$this->collector->addGroup($prefix, function (RouteCollector $rc) use ($routes, $newMiddleware): void {
|
||||
$routes(new Router($rc, $newMiddleware));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Routing;
|
||||
|
||||
use Phred\Http\Router;
|
||||
|
||||
final class RouteGroups
|
||||
{
|
||||
/**
|
||||
* Include a set of routes under a prefix using the provided Router instance.
|
||||
*/
|
||||
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 {
|
||||
$loader($r);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Routing;
|
||||
|
||||
use FastRoute\RouteCollector;
|
||||
use Phred\Http\Router;
|
||||
|
||||
/**
|
||||
* Allows providers to register route callbacks that will be applied
|
||||
* when the FastRoute dispatcher is built.
|
||||
*/
|
||||
final class RouteRegistry
|
||||
{
|
||||
/** @var list<callable(RouteCollector, Router):void> */
|
||||
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
|
||||
{
|
||||
self::$callbacks[] = $registrar;
|
||||
}
|
||||
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$callbacks = [];
|
||||
self::$loadedFiles = [];
|
||||
}
|
||||
|
||||
public static function apply(RouteCollector $collector, Router $router): void
|
||||
{
|
||||
foreach (self::$callbacks as $cb) {
|
||||
$cb($collector, $router);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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)) . '"';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,29 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Support;
|
||||
|
||||
use Phred\Http\Contracts\ErrorFormatNegotiatorInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface as ServerRequest;
|
||||
|
||||
final class DefaultErrorFormatNegotiator implements ErrorFormatNegotiatorInterface
|
||||
{
|
||||
public function apiFormat(ServerRequest $request): string
|
||||
{
|
||||
$accept = $request->getHeaderLine('Accept');
|
||||
if (str_contains($accept, 'application/vnd.api+json')) {
|
||||
return 'jsonapi';
|
||||
}
|
||||
if (str_contains($accept, 'application/xml') || str_contains($accept, 'text/xml')) {
|
||||
return 'xml';
|
||||
}
|
||||
return 'rest';
|
||||
}
|
||||
|
||||
public function wantsHtml(ServerRequest $request): bool
|
||||
{
|
||||
$accept = $request->getHeaderLine('Accept');
|
||||
// Only return true if text/html is explicitly mentioned and is likely the preferred format
|
||||
return str_contains($accept, 'text/html');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,19 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Support;
|
||||
|
||||
use Phred\Http\Contracts\ExceptionToStatusMapperInterface;
|
||||
use Throwable;
|
||||
|
||||
final class DefaultExceptionToStatusMapper implements ExceptionToStatusMapperInterface
|
||||
{
|
||||
public function map(Throwable $e): int
|
||||
{
|
||||
$code = (int) ($e->getCode() ?: 500);
|
||||
if ($code < 400 || $code > 599) {
|
||||
return 500;
|
||||
}
|
||||
return $code;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Support;
|
||||
|
||||
use Nyholm\Psr7\Response;
|
||||
use Phred\Http\Contracts\RequestIdProviderInterface;
|
||||
use Psr\Http\Message\ServerRequestInterface;
|
||||
|
||||
final class DefaultRequestIdProvider implements RequestIdProviderInterface
|
||||
{
|
||||
public function provide(ServerRequestInterface $request): string
|
||||
{
|
||||
$incoming = $request->getHeaderLine('X-Request-Id');
|
||||
if ($incoming !== '') {
|
||||
return $incoming;
|
||||
}
|
||||
return bin2hex(random_bytes(8));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,84 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Http\Support;
|
||||
|
||||
/**
|
||||
* Helper to build pagination links and metadata.
|
||||
*/
|
||||
final class Paginator
|
||||
{
|
||||
/**
|
||||
* @param array<mixed> $items
|
||||
*/
|
||||
public function __construct(
|
||||
private array $items,
|
||||
private int $total,
|
||||
private int $perPage,
|
||||
private int $currentPage,
|
||||
private string $baseUrl
|
||||
) {}
|
||||
|
||||
public function toArray(): array
|
||||
{
|
||||
$lastPage = (int) ceil($this->total / $this->perPage);
|
||||
|
||||
return [
|
||||
'data' => $this->items,
|
||||
'meta' => [
|
||||
'total' => $this->total,
|
||||
'per_page' => $this->perPage,
|
||||
'current_page' => $this->currentPage,
|
||||
'last_page' => $lastPage,
|
||||
'from' => ($this->currentPage - 1) * $this->perPage + 1,
|
||||
'to' => min($this->currentPage * $this->perPage, $this->total),
|
||||
],
|
||||
'links' => [
|
||||
'first' => $this->getUrl(1),
|
||||
'last' => $this->getUrl($lastPage),
|
||||
'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null,
|
||||
'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null,
|
||||
'self' => $this->getUrl($this->currentPage),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
public function toJsonApi(): array
|
||||
{
|
||||
$lastPage = (int) ceil($this->total / $this->perPage);
|
||||
|
||||
return [
|
||||
'data' => $this->items,
|
||||
'meta' => [
|
||||
'total' => $this->total,
|
||||
'page' => [
|
||||
'size' => $this->perPage,
|
||||
'total' => $lastPage,
|
||||
]
|
||||
],
|
||||
'links' => [
|
||||
'first' => $this->getUrl(1),
|
||||
'last' => $this->getUrl($lastPage),
|
||||
'prev' => $this->currentPage > 1 ? $this->getUrl($this->currentPage - 1) : null,
|
||||
'next' => $this->currentPage < $lastPage ? $this->getUrl($this->currentPage + 1) : null,
|
||||
'self' => $this->getUrl($this->currentPage),
|
||||
]
|
||||
];
|
||||
}
|
||||
|
||||
private function getUrl(int $page): string
|
||||
{
|
||||
$url = parse_url($this->baseUrl);
|
||||
$query = [];
|
||||
if (isset($url['query'])) {
|
||||
parse_str($url['query'], $query);
|
||||
}
|
||||
|
||||
$query['page'] = $page;
|
||||
$query['per_page'] = $this->perPage;
|
||||
|
||||
$queryString = http_build_query($query);
|
||||
|
||||
return ($url['path'] ?? '/') . '?' . $queryString;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Mvc;
|
||||
|
||||
use Phred\Http\Contracts\ApiResponseFactoryInterface as Responses;
|
||||
|
||||
abstract class APIController extends Controller
|
||||
{
|
||||
public function __construct(protected Responses $responses) {}
|
||||
|
||||
protected function ok(array $data = []): object
|
||||
{
|
||||
return $this->responses->ok($data);
|
||||
}
|
||||
|
||||
protected function created(array $data = [], ?string $location = null): object
|
||||
{
|
||||
return $this->responses->created($data, $location);
|
||||
}
|
||||
|
||||
protected function noContent(): object
|
||||
{
|
||||
return $this->responses->noContent();
|
||||
}
|
||||
|
||||
protected function error(int $status, string $title, ?string $detail = null, array $extra = []): object
|
||||
{
|
||||
return $this->responses->error($status, $title, $detail, $extra);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Mvc;
|
||||
|
||||
abstract class Controller
|
||||
{
|
||||
// Common utilities for future use can live here.
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Mvc;
|
||||
|
||||
use Phred\Template\Contracts\RendererInterface;
|
||||
|
||||
abstract class View implements ViewWithDefaultTemplate
|
||||
{
|
||||
protected string $template = '';
|
||||
|
||||
public function __construct(protected RendererInterface $renderer) {}
|
||||
|
||||
/**
|
||||
* Prepare data for the template. Subclasses may override to massage input.
|
||||
*/
|
||||
protected function transformData(array $data): array
|
||||
{
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render using transformed data and either the provided template override or the default template.
|
||||
*/
|
||||
public function render(array $data = [], ?string $template = null): string
|
||||
{
|
||||
$prepared = $this->transformData($data);
|
||||
$tpl = $template ?? $this->defaultTemplate();
|
||||
return $this->renderer->render($tpl, $prepared);
|
||||
}
|
||||
|
||||
public function defaultTemplate(): string
|
||||
{
|
||||
return $this->template;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,42 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Mvc;
|
||||
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Psr\Http\Message\ResponseInterface;
|
||||
|
||||
abstract class ViewController extends Controller
|
||||
{
|
||||
private Psr17Factory $psr17;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->psr17 = new Psr17Factory();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an HTML response with the provided content.
|
||||
*/
|
||||
protected function html(string $content, int $status = 200, array $headers = []): ResponseInterface
|
||||
{
|
||||
$response = $this->psr17->createResponse($status)->withHeader('Content-Type', 'text/html; charset=utf-8');
|
||||
foreach ($headers as $k => $v) {
|
||||
$response = $response->withHeader((string) $k, (string) $v);
|
||||
}
|
||||
$response->getBody()->write($content);
|
||||
return $response;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience to render a module View and return an HTML response.
|
||||
* The `$template` is optional; when omitted (null), the view should use its default template.
|
||||
*/
|
||||
protected function renderView(View $view, array $data = [], ?string $template = null, int $status = 200, array $headers = []): ResponseInterface
|
||||
{
|
||||
// Delegate template selection to the View; when $template is null,
|
||||
// the View may use its default template.
|
||||
$markup = $view->render($data, $template);
|
||||
return $this->html($markup, $status, $headers);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Mvc;
|
||||
|
||||
interface ViewWithDefaultTemplate
|
||||
{
|
||||
public function defaultTemplate(): string;
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
<?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
|
||||
{
|
||||
}
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Orm\Contracts;
|
||||
|
||||
interface ConnectionInterface
|
||||
{
|
||||
public function connect(): void;
|
||||
public function isConnected(): bool;
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Orm;
|
||||
|
||||
use Phred\Orm\Contracts\ConnectionInterface;
|
||||
use Pairity\Manager;
|
||||
|
||||
final class PairityConnection implements ConnectionInterface
|
||||
{
|
||||
private bool $connected = false;
|
||||
private ?Manager $manager = null;
|
||||
|
||||
public function connect(): void
|
||||
{
|
||||
$this->connected = true;
|
||||
$this->manager = new Manager();
|
||||
}
|
||||
|
||||
public function isConnected(): bool
|
||||
{
|
||||
return $this->connected;
|
||||
}
|
||||
|
||||
public function getManager(): Manager
|
||||
{
|
||||
if (!$this->manager) {
|
||||
$this->connect();
|
||||
}
|
||||
return $this->manager;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,33 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Nyholm\Psr7\Factory\Psr17Factory;
|
||||
use Phred\Http\Routing\RouteRegistry;
|
||||
use Phred\Http\Router;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class AppServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
// Place app-specific bindings here as needed.
|
||||
}
|
||||
|
||||
public function boot(Container $container): void
|
||||
{
|
||||
// Demonstrate adding a route from a provider
|
||||
RouteRegistry::add(static function ($collector, Router $router): void {
|
||||
$router->get('/_phred/app', static function () {
|
||||
$psr17 = new Psr17Factory();
|
||||
$res = $psr17->createResponse(200)->withHeader('Content-Type', 'application/json');
|
||||
$res->getBody()->write(json_encode(['app' => true], JSON_UNESCAPED_SLASHES));
|
||||
return $res;
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?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
|
||||
{
|
||||
}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class FlagsServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$driver = (string) $config->get('FLAGS_DRIVER', $config->get('app.drivers.flags', 'flagpole'));
|
||||
|
||||
$impl = match ($driver) {
|
||||
'flagpole' => \Phred\Flags\FlagpoleClient::class,
|
||||
default => throw new \RuntimeException("Unsupported flags driver: {$driver}"),
|
||||
};
|
||||
|
||||
if ($driver === 'flagpole' && !class_exists(\Flagpole\FeatureManager::class)) {
|
||||
throw new \RuntimeException("Flagpole FeatureManager not found. Did you install getphred/flagpole?");
|
||||
}
|
||||
|
||||
$builder->addDefinitions([
|
||||
\Phred\Flags\Contracts\FeatureFlagClientInterface::class => \DI\autowire($impl),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,107 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use GuzzleHttp\Client;
|
||||
use GuzzleHttp\HandlerStack;
|
||||
use GuzzleHttp\Middleware;
|
||||
use GuzzleHttp\MessageFormatter;
|
||||
use Phred\Support\Http\CircuitBreakerMiddleware;
|
||||
use Phred\Http\Middleware\Middleware as PhredMiddleware;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
use Phred\Support\Cache\FileCache;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
use Psr\Http\Client\ClientInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class HttpServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$builder->addDefinitions([
|
||||
CacheInterface::class => function (ConfigInterface $config) {
|
||||
$cacheDir = getcwd() . '/storage/cache';
|
||||
return new FileCache($cacheDir);
|
||||
},
|
||||
ClientInterface::class => function (ConfigInterface $config, Container $c) {
|
||||
$options = $config->get('http.client', [
|
||||
'timeout' => 5.0,
|
||||
'connect_timeout' => 2.0,
|
||||
]);
|
||||
|
||||
$stack = HandlerStack::create();
|
||||
|
||||
// Profiling middleware
|
||||
$stack->push(function (callable $handler) {
|
||||
return function (\Psr\Http\Message\RequestInterface $request, array $options) use ($handler) {
|
||||
$start = microtime(true);
|
||||
return $handler($request, $options)->then(
|
||||
function ($response) use ($start, $request) {
|
||||
$duration = microtime(true) - $start;
|
||||
$host = $request->getUri()->getHost();
|
||||
$key = "HTTP: " . $host;
|
||||
PhredMiddleware::recordTiming($key, $duration);
|
||||
return $response;
|
||||
},
|
||||
function ($reason) use ($start, $request) {
|
||||
$duration = microtime(true) - $start;
|
||||
$host = $request->getUri()->getHost();
|
||||
$key = "HTTP: " . $host;
|
||||
PhredMiddleware::recordTiming($key, $duration);
|
||||
return \GuzzleHttp\Promise\Create::rejectionFor($reason);
|
||||
}
|
||||
);
|
||||
};
|
||||
}, 'profiler');
|
||||
|
||||
// Logging middleware
|
||||
if ($config->get('http.middleware.log', false)) {
|
||||
try {
|
||||
$logger = $c->get(LoggerInterface::class);
|
||||
$stack->push(Middleware::log(
|
||||
$logger,
|
||||
new MessageFormatter(MessageFormatter::SHORT)
|
||||
));
|
||||
} catch (\Throwable) {
|
||||
// Logger not available, skip logging middleware
|
||||
}
|
||||
}
|
||||
|
||||
// Retry middleware
|
||||
if ($config->get('http.middleware.retry.enabled', false)) {
|
||||
$maxRetries = $config->get('http.middleware.retry.max_retries', 3);
|
||||
$stack->push(Middleware::retry(function ($retries, $request, $response, $exception) use ($maxRetries) {
|
||||
if ($retries >= $maxRetries) {
|
||||
return false;
|
||||
}
|
||||
if ($exception instanceof \GuzzleHttp\Exception\ConnectException) {
|
||||
return true;
|
||||
}
|
||||
if ($response && $response->getStatusCode() >= 500) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}));
|
||||
}
|
||||
|
||||
// Circuit Breaker middleware
|
||||
if ($config->get('http.middleware.circuit_breaker.enabled', false)) {
|
||||
$threshold = $config->get('http.middleware.circuit_breaker.threshold', 5);
|
||||
$timeout = $config->get('http.middleware.circuit_breaker.timeout', 30.0);
|
||||
$cache = $c->get(CacheInterface::class);
|
||||
$stack->push(new CircuitBreakerMiddleware($threshold, (float) $timeout, $cache));
|
||||
}
|
||||
|
||||
$options['handler'] = $stack;
|
||||
|
||||
return new Client($options);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,154 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Monolog\Handler\ErrorLogHandler;
|
||||
use Monolog\Handler\RotatingFileHandler;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Handler\SyslogHandler;
|
||||
use Monolog\Handler\SlackWebhookHandler;
|
||||
use Monolog\Logger;
|
||||
use Monolog\LogRecord;
|
||||
use Monolog\Processor\MemoryUsageProcessor;
|
||||
use Monolog\Processor\ProcessIdProcessor;
|
||||
use Nyholm\Psr7\ServerRequest;
|
||||
use Phred\Http\Contracts\RequestIdProviderInterface;
|
||||
use Phred\Http\Support\DefaultRequestIdProvider;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
use Psr\Log\LoggerInterface;
|
||||
|
||||
final class LoggingServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$builder->addDefinitions([
|
||||
LoggerInterface::class => function (Container $c, ConfigInterface $config) {
|
||||
$name = (string) $config->get('APP_NAME', 'Phred');
|
||||
$defaultChannel = (string) $config->get('logging.default', 'stack');
|
||||
|
||||
$logger = new Logger($name);
|
||||
|
||||
$this->createChannel($logger, $defaultChannel, $config);
|
||||
|
||||
// Processors
|
||||
$logger->pushProcessor(new ProcessIdProcessor());
|
||||
$logger->pushProcessor(new MemoryUsageProcessor());
|
||||
$logger->pushProcessor(function (LogRecord $record) use ($c): LogRecord {
|
||||
try {
|
||||
$requestIdProvider = $c->get(RequestIdProviderInterface::class);
|
||||
// We need a request to provide an ID, but logger might be called outside of a request.
|
||||
// Try to get request from container if available, or use dummy.
|
||||
$request = $c->has('request') ? $c->get('request') : new ServerRequest('GET', '/');
|
||||
$id = $requestIdProvider->provide($request);
|
||||
} catch (\Throwable) {
|
||||
$id = bin2hex(random_bytes(8));
|
||||
}
|
||||
$record->extra['request_id'] = $id;
|
||||
return $record;
|
||||
});
|
||||
|
||||
return $logger;
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private function createChannel(Logger $logger, string $channel, ConfigInterface $config): void
|
||||
{
|
||||
$channelConfig = $config->get("logging.channels.$channel");
|
||||
|
||||
if (!$channelConfig) {
|
||||
// Fallback to a basic single log if channel not found
|
||||
$logDir = getcwd() . '/storage/logs';
|
||||
if (!is_dir($logDir)) {
|
||||
@mkdir($logDir, 0777, true);
|
||||
}
|
||||
$logger->pushHandler(new StreamHandler($logDir . '/phred.log'));
|
||||
return;
|
||||
}
|
||||
|
||||
$driver = $channelConfig['driver'] ?? 'single';
|
||||
|
||||
switch ($driver) {
|
||||
case 'stack':
|
||||
foreach ($channelConfig['channels'] ?? [] as $subChannel) {
|
||||
$this->createChannel($logger, $subChannel, $config);
|
||||
}
|
||||
break;
|
||||
case 'single':
|
||||
$this->ensureDir(dirname($channelConfig['path']));
|
||||
$logger->pushHandler(new StreamHandler($channelConfig['path'], $channelConfig['level'] ?? 'debug'));
|
||||
break;
|
||||
case 'daily':
|
||||
$this->ensureDir(dirname($channelConfig['path']));
|
||||
$logger->pushHandler(new RotatingFileHandler(
|
||||
$channelConfig['path'],
|
||||
$channelConfig['days'] ?? 7,
|
||||
$channelConfig['level'] ?? 'debug'
|
||||
));
|
||||
break;
|
||||
case 'syslog':
|
||||
$logger->pushHandler(new SyslogHandler($config->get('APP_NAME', 'Phred'), LOG_USER, $channelConfig['level'] ?? 'debug'));
|
||||
break;
|
||||
case 'errorlog':
|
||||
$logger->pushHandler(new ErrorLogHandler(ErrorLogHandler::OPERATING_SYSTEM, $channelConfig['level'] ?? 'debug'));
|
||||
break;
|
||||
case 'slack':
|
||||
$this->createSlackHandler($logger, $channelConfig);
|
||||
break;
|
||||
case 'sentry':
|
||||
$this->createSentryHandler($logger, $channelConfig);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
private function createSlackHandler(Logger $logger, array $config): void
|
||||
{
|
||||
if (!class_exists(SlackWebhookHandler::class)) {
|
||||
// Silently skip if Monolog Slack handler is missing (usually bundled with Monolog 2/3)
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($config['url'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger->pushHandler(new SlackWebhookHandler(
|
||||
$config['url'],
|
||||
$config['channel'] ?? null,
|
||||
$config['username'] ?? 'Phred Log Bot',
|
||||
true,
|
||||
null,
|
||||
false,
|
||||
true,
|
||||
$config['level'] ?? 'critical'
|
||||
));
|
||||
}
|
||||
|
||||
private function createSentryHandler(Logger $logger, array $config): void
|
||||
{
|
||||
// Using sentry/sentry-monolog if available
|
||||
if (!class_exists(\Sentry\Monolog\Handler::class) || !class_exists(\Sentry\SentrySdk::class)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (empty($config['dsn'])) {
|
||||
return;
|
||||
}
|
||||
|
||||
\Sentry\init(['dsn' => $config['dsn']]);
|
||||
$logger->pushHandler(new \Sentry\Monolog\Handler(\Sentry\SentrySdk::getCurrentHub(), $config['level'] ?? 'error'));
|
||||
}
|
||||
|
||||
private function ensureDir(string $path): void
|
||||
{
|
||||
if (!is_dir($path)) {
|
||||
@mkdir($path, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,31 +0,0 @@
|
|||
<?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);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class OrmServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$driver = (string) $config->get('ORM_DRIVER', $config->get('app.drivers.orm', 'pairity'));
|
||||
|
||||
$impl = match ($driver) {
|
||||
'pairity' => \Phred\Orm\PairityConnection::class,
|
||||
'eloquent' => \Phred\Orm\EloquentConnection::class, // Future proofing or assuming it might be added
|
||||
default => throw new \RuntimeException("Unsupported ORM driver: {$driver}"),
|
||||
};
|
||||
|
||||
// Validate dependencies for the driver
|
||||
if ($driver === 'pairity' && !class_exists(\Pairity\Manager::class)) {
|
||||
throw new \RuntimeException("Pairity Manager not found. Did you install getphred/pairity?");
|
||||
}
|
||||
|
||||
$builder->addDefinitions([
|
||||
\Phred\Orm\Contracts\ConnectionInterface::class => \DI\autowire($impl),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,27 +0,0 @@
|
|||
<?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;
|
||||
|
||||
final class RoutingServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
// No bindings required; route registry is static helper for now.
|
||||
}
|
||||
|
||||
public function boot(Container $container): void
|
||||
{
|
||||
// Core routes can be appended here in future if needed.
|
||||
// Keeping provider to illustrate ordering and future extension point.
|
||||
RouteRegistry::add(static function (): void {
|
||||
// no-op
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,36 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Security\Contracts\TokenServiceInterface;
|
||||
use Phred\Security\Jwt\JwtTokenService;
|
||||
use Phred\Flags\Contracts\FeatureFlagClientInterface;
|
||||
use Phred\Flags\FlagpoleClient;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class SecurityServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$driver = (string) $config->get('AUTH_DRIVER', 'jwt');
|
||||
|
||||
$impl = match ($driver) {
|
||||
'jwt' => \Phred\Security\Jwt\JwtTokenService::class,
|
||||
default => throw new \RuntimeException("Unsupported auth driver: {$driver}"),
|
||||
};
|
||||
|
||||
if ($driver === 'jwt' && !class_exists(\Lcobucci\JWT\Configuration::class)) {
|
||||
throw new \RuntimeException("lcobucci/jwt not found. Did you install lcobucci/jwt?");
|
||||
}
|
||||
|
||||
$builder->addDefinitions([
|
||||
TokenServiceInterface::class => \DI\autowire($impl),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,34 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
use Symfony\Component\Serializer\Encoder\JsonEncoder;
|
||||
use Symfony\Component\Serializer\Encoder\XmlEncoder;
|
||||
use Symfony\Component\Serializer\Normalizer\ArrayDenormalizer;
|
||||
use Symfony\Component\Serializer\Normalizer\ObjectNormalizer;
|
||||
use Symfony\Component\Serializer\Serializer;
|
||||
use Symfony\Component\Serializer\SerializerInterface;
|
||||
|
||||
final class SerializationServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$builder->addDefinitions([
|
||||
SerializerInterface::class => function () {
|
||||
$encoders = [new XmlEncoder(), new JsonEncoder()];
|
||||
// Avoid using ObjectNormalizer if symfony/property-access is missing
|
||||
// Or use it only if available. For now, let's use ArrayDenormalizer which is safer
|
||||
$normalizers = [new ArrayDenormalizer()];
|
||||
|
||||
return new Serializer($normalizers, $encoders);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,81 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
use League\Flysystem\Local\LocalFilesystemAdapter;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use Aws\S3\S3Client;
|
||||
use Phred\Support\Storage\StorageManager;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class StorageServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$builder->addDefinitions([
|
||||
StorageManager::class => function (Container $c) {
|
||||
$config = $c->get(ConfigInterface::class);
|
||||
$storageConfig = $config->get('storage');
|
||||
$defaultFilesystem = $c->get(FilesystemOperator::class);
|
||||
return new StorageManager($defaultFilesystem, $storageConfig);
|
||||
},
|
||||
FilesystemOperator::class => \DI\get(Filesystem::class),
|
||||
Filesystem::class => function (Container $c) {
|
||||
$config = $c->get(ConfigInterface::class);
|
||||
$default = $config->get('storage.default', 'local');
|
||||
$diskConfig = $config->get("storage.disks.$default");
|
||||
|
||||
if (!$diskConfig) {
|
||||
throw new \RuntimeException("Storage disk [$default] is not configured.");
|
||||
}
|
||||
|
||||
$driver = $diskConfig['driver'] ?? 'local';
|
||||
|
||||
$adapter = match ($driver) {
|
||||
'local' => new LocalFilesystemAdapter($diskConfig['root']),
|
||||
's3' => $this->createS3Adapter($diskConfig),
|
||||
default => throw new \RuntimeException("Unsupported storage driver [$driver]."),
|
||||
};
|
||||
|
||||
return new Filesystem($adapter);
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
private function createS3Adapter(array $config): AwsS3V3Adapter
|
||||
{
|
||||
if (!class_exists(S3Client::class)) {
|
||||
throw new \RuntimeException("AWS SDK not found. Did you install aws/aws-sdk-php?");
|
||||
}
|
||||
|
||||
if (!class_exists(AwsS3V3Adapter::class)) {
|
||||
throw new \RuntimeException("Flysystem S3 adapter not found. Did you install league/flysystem-aws-s3-v3?");
|
||||
}
|
||||
|
||||
$clientConfig = [
|
||||
'credentials' => [
|
||||
'key' => $config['key'],
|
||||
'secret' => $config['secret'],
|
||||
],
|
||||
'region' => $config['region'],
|
||||
'version' => 'latest',
|
||||
];
|
||||
|
||||
if (!empty($config['endpoint'])) {
|
||||
$clientConfig['endpoint'] = $config['endpoint'];
|
||||
$clientConfig['use_path_style_endpoint'] = $config['use_path_style_endpoint'] ?? false;
|
||||
}
|
||||
|
||||
$client = new S3Client($clientConfig);
|
||||
|
||||
return new AwsS3V3Adapter($client, $config['bucket']);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class TemplateServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$driver = (string) $config->get('TEMPLATE_DRIVER', $config->get('app.drivers.template', 'eyrie'));
|
||||
|
||||
$impl = match ($driver) {
|
||||
'eyrie' => \Phred\Template\EyrieRenderer::class,
|
||||
default => throw new \RuntimeException("Unsupported template driver: {$driver}"),
|
||||
};
|
||||
|
||||
if ($driver === 'eyrie' && !class_exists(\Eyrie\Engine::class)) {
|
||||
throw new \RuntimeException("Eyrie Engine not found. Did you install getphred/eyrie?");
|
||||
}
|
||||
|
||||
$builder->addDefinitions([
|
||||
\Phred\Template\Contracts\RendererInterface::class => \DI\autowire($impl),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Providers\Core;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
final class TestingServiceProvider implements ServiceProviderInterface
|
||||
{
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void
|
||||
{
|
||||
$driver = (string) $config->get('TEST_RUNNER', $config->get('app.drivers.test_runner', 'codeception'));
|
||||
|
||||
$impl = match ($driver) {
|
||||
'codeception' => \Phred\Testing\CodeceptionRunner::class,
|
||||
default => throw new \RuntimeException("Unsupported test runner driver: {$driver}"),
|
||||
};
|
||||
|
||||
if ($driver === 'codeception' && !class_exists(\Codeception\Codecept::class)) {
|
||||
throw new \RuntimeException("Codeception not found. Did you install codeception/codeception?");
|
||||
}
|
||||
|
||||
$builder->addDefinitions([
|
||||
\Phred\Testing\Contracts\TestRunnerInterface::class => \DI\autowire($impl),
|
||||
]);
|
||||
}
|
||||
|
||||
public function boot(Container $container): void {}
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Security\Contracts;
|
||||
|
||||
/**
|
||||
* Contract for JWT token generation and verification.
|
||||
*/
|
||||
interface TokenServiceInterface
|
||||
{
|
||||
/**
|
||||
* Create a new JWT for the given user identifier.
|
||||
*
|
||||
* @param string|int $userId
|
||||
* @param array $claims Additional claims
|
||||
* @return string
|
||||
*/
|
||||
public function createToken(string|int $userId, array $claims = []): string;
|
||||
|
||||
/**
|
||||
* Parse and validate a JWT string.
|
||||
*
|
||||
* @param string $token
|
||||
* @return array Claims from the token
|
||||
* @throws \Exception if token is invalid
|
||||
*/
|
||||
public function validateToken(string $token): array;
|
||||
}
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Security\Jwt;
|
||||
|
||||
use Lcobucci\JWT\Configuration;
|
||||
use Lcobucci\JWT\Signer\Hmac\Sha256;
|
||||
use Lcobucci\JWT\Signer\Key\InMemory;
|
||||
use Lcobucci\JWT\UnencryptedToken;
|
||||
use Lcobucci\JWT\Validation\Constraint\SignedWith;
|
||||
use Phred\Security\Contracts\TokenServiceInterface;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
|
||||
/**
|
||||
* JWT implementation using lcobucci/jwt.
|
||||
*/
|
||||
final class JwtTokenService implements TokenServiceInterface
|
||||
{
|
||||
private Configuration $config;
|
||||
|
||||
public function __construct(ConfigInterface $appConfig)
|
||||
{
|
||||
$key = (string) $appConfig->get('jwt.secret', 'change-me-to-something-very-secure');
|
||||
$this->config = Configuration::forSymmetricSigner(
|
||||
new Sha256(),
|
||||
InMemory::plainText($key)
|
||||
);
|
||||
|
||||
$this->config->setValidationConstraints(
|
||||
new SignedWith($this->config->signer(), $this->config->signingKey())
|
||||
);
|
||||
}
|
||||
|
||||
public function createToken(string|int $userId, array $claims = []): string
|
||||
{
|
||||
$now = new \DateTimeImmutable();
|
||||
$builder = $this->config->builder()
|
||||
->issuedBy((string) getenv('APP_URL'))
|
||||
->permittedFor((string) getenv('APP_URL'))
|
||||
->identifiedBy(bin2hex(random_bytes(16)))
|
||||
->issuedAt($now)
|
||||
->canOnlyBeUsedAfter($now)
|
||||
->expiresAt($now->modify('+1 hour'))
|
||||
->withClaim('uid', $userId);
|
||||
|
||||
foreach ($claims as $name => $value) {
|
||||
$builder = $builder->withClaim($name, $value);
|
||||
}
|
||||
|
||||
return $builder->getToken($this->config->signer(), $this->config->signingKey())->toString();
|
||||
}
|
||||
|
||||
public function validateToken(string $token): array
|
||||
{
|
||||
$jwt = $this->config->parser()->parse($token);
|
||||
|
||||
$constraints = $this->config->validationConstraints();
|
||||
|
||||
if (!$this->config->validator()->validate($jwt, ...$constraints)) {
|
||||
throw new \RuntimeException('Invalid JWT');
|
||||
}
|
||||
|
||||
if (!$jwt instanceof UnencryptedToken) {
|
||||
throw new \RuntimeException('Parsed JWT is not an unencrypted token');
|
||||
}
|
||||
|
||||
return $jwt->claims()->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,110 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support\Cache;
|
||||
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
final class FileCache implements CacheInterface
|
||||
{
|
||||
public function __construct(private readonly string $directory)
|
||||
{
|
||||
if (!is_dir($this->directory)) {
|
||||
@mkdir($this->directory, 0777, true);
|
||||
}
|
||||
}
|
||||
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
$file = $this->getFilePath($key);
|
||||
if (!file_exists($file)) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$content = file_get_contents($file);
|
||||
if ($content === false) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
$data = unserialize($content);
|
||||
if ($data['expires'] !== 0 && $data['expires'] < time()) {
|
||||
@unlink($file);
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $data['value'];
|
||||
}
|
||||
|
||||
public function set(string $key, mixed $value, \DateInterval|int|null $ttl = null): bool
|
||||
{
|
||||
$expires = 0;
|
||||
if ($ttl !== null) {
|
||||
if ($ttl instanceof \DateInterval) {
|
||||
$expires = (new \DateTime())->add($ttl)->getTimestamp();
|
||||
} else {
|
||||
$expires = time() + $ttl;
|
||||
}
|
||||
}
|
||||
|
||||
$data = [
|
||||
'expires' => $expires,
|
||||
'value' => $value,
|
||||
];
|
||||
|
||||
return file_put_contents($this->getFilePath($key), serialize($data)) !== false;
|
||||
}
|
||||
|
||||
public function delete(string $key): bool
|
||||
{
|
||||
$file = $this->getFilePath($key);
|
||||
if (file_exists($file)) {
|
||||
return @unlink($file);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function clear(): bool
|
||||
{
|
||||
foreach (glob($this->directory . '/*') as $file) {
|
||||
if (is_file($file)) {
|
||||
@unlink($file);
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function getMultiple(iterable $keys, mixed $default = null): iterable
|
||||
{
|
||||
$result = [];
|
||||
foreach ($keys as $key) {
|
||||
$result[$key] = $this->get($key, $default);
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
public function setMultiple(iterable $values, \DateInterval|int|null $ttl = null): bool
|
||||
{
|
||||
foreach ($values as $key => $value) {
|
||||
$this->set($key, $value, $ttl);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function deleteMultiple(iterable $keys): bool
|
||||
{
|
||||
foreach ($keys as $key) {
|
||||
$this->delete($key);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public function has(string $key): bool
|
||||
{
|
||||
return $this->get($key, $this) !== $this;
|
||||
}
|
||||
|
||||
private function getFilePath(string $key): string
|
||||
{
|
||||
return $this->directory . '/' . md5($key) . '.cache';
|
||||
}
|
||||
}
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support;
|
||||
|
||||
final class Config
|
||||
{
|
||||
/** @var array<string,mixed>|null */
|
||||
private static ?array $store = null;
|
||||
|
||||
/**
|
||||
* Get configuration value with precedence:
|
||||
* 1) Environment variables (UPPER_CASE or dot.notation translated)
|
||||
* 2) Loaded config files from config/*.php, accessible via dot.notation (e.g., app.env)
|
||||
* 3) Provided $default
|
||||
*/
|
||||
public static function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
// 1) Environment lookup (supports dot.notation by converting to UPPER_SNAKE)
|
||||
$envKey = strtoupper(str_replace('.', '_', $key));
|
||||
$value = getenv($envKey);
|
||||
if ($value !== false) {
|
||||
return $value;
|
||||
}
|
||||
if (isset($_SERVER[$envKey])) {
|
||||
return $_SERVER[$envKey];
|
||||
}
|
||||
if (isset($_ENV[$envKey])) {
|
||||
return $_ENV[$envKey];
|
||||
}
|
||||
|
||||
// 2) Config files (lazy load once)
|
||||
self::ensureLoaded();
|
||||
if (self::$store) {
|
||||
$fromStore = self::getFromStore($key);
|
||||
if ($fromStore !== null) {
|
||||
return $fromStore;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Default
|
||||
return $default;
|
||||
}
|
||||
|
||||
private static function ensureLoaded(): void
|
||||
{
|
||||
if (self::$store !== null) {
|
||||
return;
|
||||
}
|
||||
self::$store = [];
|
||||
$root = getcwd();
|
||||
$configDir = $root . DIRECTORY_SEPARATOR . 'config';
|
||||
if (!is_dir($configDir)) {
|
||||
return; // no config directory; keep empty store
|
||||
}
|
||||
foreach (glob($configDir . '/*.php') ?: [] as $file) {
|
||||
$key = basename($file, '.php');
|
||||
try {
|
||||
$data = require $file;
|
||||
if (is_array($data)) {
|
||||
self::$store[$key] = $data;
|
||||
}
|
||||
} catch (\Throwable) {
|
||||
// ignore malformed config files to avoid breaking runtime
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static function getFromStore(string $key): mixed
|
||||
{
|
||||
// dot.notation: first segment is file key, remaining traverse array
|
||||
if (str_contains($key, '.')) {
|
||||
$parts = explode('.', $key);
|
||||
$rootKey = array_shift($parts);
|
||||
if ($rootKey === null || !isset(self::$store[$rootKey])) {
|
||||
return null;
|
||||
}
|
||||
$cursor = self::$store[$rootKey];
|
||||
foreach ($parts as $p) {
|
||||
if (is_array($cursor) && array_key_exists($p, $cursor)) {
|
||||
$cursor = $cursor[$p];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return $cursor;
|
||||
}
|
||||
|
||||
// non-dotted: try exact file key
|
||||
return self::$store[$key] ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear the config store (useful for tests).
|
||||
*/
|
||||
public static function clear(): void
|
||||
{
|
||||
self::$store = null;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support\Contracts;
|
||||
|
||||
interface ConfigInterface
|
||||
{
|
||||
/**
|
||||
* Retrieve a configuration value by key.
|
||||
* Supports dot.notation keys. Implementations define precedence.
|
||||
*/
|
||||
public function get(string $key, mixed $default = null): mixed;
|
||||
}
|
||||
|
|
@ -1,24 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support\Contracts;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
|
||||
/**
|
||||
* Service providers can register bindings before the container is built
|
||||
* and perform boot-time work after the container is available.
|
||||
*/
|
||||
interface ServiceProviderInterface
|
||||
{
|
||||
/**
|
||||
* Register container definitions/bindings. Called before the container is built.
|
||||
*/
|
||||
public function register(ContainerBuilder $builder, ConfigInterface $config): void;
|
||||
|
||||
/**
|
||||
* Boot after the container has been built. Safe to resolve services here.
|
||||
*/
|
||||
public function boot(Container $container): void;
|
||||
}
|
||||
|
|
@ -1,17 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support;
|
||||
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
|
||||
/**
|
||||
* Default adapter that delegates to the legacy static Config facade.
|
||||
*/
|
||||
final class DefaultConfig implements ConfigInterface
|
||||
{
|
||||
public function get(string $key, mixed $default = null): mixed
|
||||
{
|
||||
return Config::get($key, $default);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,131 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support\Http;
|
||||
|
||||
use GuzzleHttp\Promise\Create;
|
||||
use Psr\Http\Message\RequestInterface;
|
||||
use Psr\SimpleCache\CacheInterface;
|
||||
|
||||
/**
|
||||
* A circuit breaker middleware for Guzzle with optional PSR-16 persistence.
|
||||
*/
|
||||
final class CircuitBreakerMiddleware
|
||||
{
|
||||
private static array $localFailures = [];
|
||||
private static array $localLastFailureTime = [];
|
||||
private static array $localIsOpen = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly int $threshold = 5,
|
||||
private readonly float $timeout = 30.0,
|
||||
private readonly ?CacheInterface $cache = null
|
||||
) {}
|
||||
|
||||
public function __invoke(callable $handler): callable
|
||||
{
|
||||
return function (RequestInterface $request, array $options) use ($handler) {
|
||||
$host = $request->getUri()->getHost();
|
||||
|
||||
if ($this->isCircuitOpen($host)) {
|
||||
return Create::rejectionFor(
|
||||
new \RuntimeException("Circuit breaker is open for host: $host")
|
||||
);
|
||||
}
|
||||
|
||||
return $handler($request, $options)->then(
|
||||
function ($response) use ($host) {
|
||||
if ($response instanceof \Psr\Http\Message\ResponseInterface && $response->getStatusCode() >= 500) {
|
||||
$this->reportFailure($host);
|
||||
} else {
|
||||
$this->reportSuccess($host);
|
||||
}
|
||||
return $response;
|
||||
},
|
||||
function ($reason) use ($host) {
|
||||
$this->reportFailure($host);
|
||||
return Create::rejectionFor($reason);
|
||||
}
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
private function isCircuitOpen(string $host): bool
|
||||
{
|
||||
$state = $this->getState($host);
|
||||
|
||||
if (!$state['isOpen']) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((microtime(true) - $state['lastFailureTime']) > $this->timeout) {
|
||||
// Half-open state in a real CB, here we just try again
|
||||
$this->reportSuccess($host);
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private function reportSuccess(string $host): void
|
||||
{
|
||||
$this->saveState($host, [
|
||||
'failures' => 0,
|
||||
'lastFailureTime' => 0,
|
||||
'isOpen' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
private function reportFailure(string $host): void
|
||||
{
|
||||
$state = $this->getState($host);
|
||||
$state['failures']++;
|
||||
$state['lastFailureTime'] = microtime(true);
|
||||
|
||||
if ($state['failures'] >= $this->threshold) {
|
||||
$state['isOpen'] = true;
|
||||
}
|
||||
|
||||
$this->saveState($host, $state);
|
||||
}
|
||||
|
||||
private function getState(string $host): array
|
||||
{
|
||||
if ($this->cache) {
|
||||
return $this->cache->get("cb.$host", [
|
||||
'failures' => 0,
|
||||
'lastFailureTime' => 0,
|
||||
'isOpen' => false,
|
||||
]);
|
||||
}
|
||||
|
||||
return [
|
||||
'failures' => self::$localFailures[$host] ?? 0,
|
||||
'lastFailureTime' => self::$localLastFailureTime[$host] ?? 0,
|
||||
'isOpen' => self::$localIsOpen[$host] ?? false,
|
||||
];
|
||||
}
|
||||
|
||||
private function saveState(string $host, array $state): void
|
||||
{
|
||||
if ($this->cache) {
|
||||
$this->cache->set("cb.$host", $state, (int)$this->timeout * 2);
|
||||
return;
|
||||
}
|
||||
|
||||
self::$localFailures[$host] = $state['failures'];
|
||||
self::$localLastFailureTime[$host] = $state['lastFailureTime'];
|
||||
self::$localIsOpen[$host] = $state['isOpen'];
|
||||
}
|
||||
|
||||
public static function clear(?string $host = null): void
|
||||
{
|
||||
if ($host) {
|
||||
unset(self::$localFailures[$host], self::$localLastFailureTime[$host], self::$localIsOpen[$host]);
|
||||
} else {
|
||||
self::$localFailures = [];
|
||||
self::$localLastFailureTime = [];
|
||||
self::$localIsOpen = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,92 +0,0 @@
|
|||
<?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;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,128 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support;
|
||||
|
||||
use DI\Container;
|
||||
use DI\ContainerBuilder;
|
||||
use Phred\Support\Contracts\ConfigInterface;
|
||||
use Phred\Support\Contracts\ServiceProviderInterface;
|
||||
|
||||
/**
|
||||
* Loads and executes service providers in deterministic order.
|
||||
* Order: core → app → modules
|
||||
*/
|
||||
final class ProviderRepository
|
||||
{
|
||||
/** @var list<ServiceProviderInterface> */
|
||||
private array $providers = [];
|
||||
|
||||
public function __construct(private readonly ConfigInterface $config)
|
||||
{
|
||||
}
|
||||
|
||||
public function load(): void
|
||||
{
|
||||
$this->providers = [];
|
||||
// Merge providers from config/providers.php file (authoritative) with any runtime Config entries
|
||||
$fileCore = $fileApp = $fileModules = [];
|
||||
$configFile = dirname(__DIR__, 2) . '/config/providers.php';
|
||||
if (is_file($configFile)) {
|
||||
/** @noinspection PhpIncludeInspection */
|
||||
$arr = require $configFile;
|
||||
if (is_array($arr)) {
|
||||
$fileCore = (array)($arr['core'] ?? []);
|
||||
$fileApp = (array)($arr['app'] ?? []);
|
||||
$fileModules = (array)($arr['modules'] ?? []);
|
||||
}
|
||||
}
|
||||
$core = array_values(array_unique(array_merge($fileCore, (array) Config::get('providers.core', []))));
|
||||
$app = array_values(array_unique(array_merge($fileApp, (array) Config::get('providers.app', []))));
|
||||
$modules = array_values(array_unique(array_merge($fileModules, (array) Config::get('providers.modules', []))));
|
||||
|
||||
$loadedClasses = [];
|
||||
foreach ([$core, $app, $modules] as $group) {
|
||||
foreach ($group as $class) {
|
||||
if (is_string($class) && class_exists($class) && !isset($loadedClasses[$class])) {
|
||||
$instance = new $class();
|
||||
if ($instance instanceof ServiceProviderInterface) {
|
||||
$this->providers[] = $instance;
|
||||
$loadedClasses[$class] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initial module discovery: scan modules/*/Providers/*ServiceProvider.php
|
||||
$root = dirname(__DIR__, 2);
|
||||
$modulesDir = $root . '/modules';
|
||||
if (is_dir($modulesDir)) {
|
||||
foreach (scandir($modulesDir) ?: [] as $entry) {
|
||||
if ($entry === '.' || $entry === '..') {
|
||||
continue;
|
||||
}
|
||||
$modulePath = $modulesDir . '/' . $entry;
|
||||
if (!is_dir($modulePath)) {
|
||||
continue;
|
||||
}
|
||||
$providersPath = $modulePath . '/Providers';
|
||||
if (!is_dir($providersPath)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$namespace = $this->resolveModuleNamespace($modulePath, $entry);
|
||||
|
||||
foreach (scandir($providersPath) ?: [] as $file) {
|
||||
if ($file === '.' || $file === '..' || !str_ends_with($file, '.php')) {
|
||||
continue;
|
||||
}
|
||||
$classBase = substr($file, 0, -4);
|
||||
if (!str_ends_with($classBase, 'ServiceProvider')) {
|
||||
continue;
|
||||
}
|
||||
$fqcn = "{$namespace}Providers\\\\{$classBase}";
|
||||
if (class_exists($fqcn) && !isset($loadedClasses[$fqcn])) {
|
||||
$instance = new $fqcn();
|
||||
if ($instance instanceof ServiceProviderInterface) {
|
||||
$this->providers[] = $instance;
|
||||
$loadedClasses[$fqcn] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function resolveModuleNamespace(string $modulePath, string $moduleName): string
|
||||
{
|
||||
$composerFile = $modulePath . '/composer.json';
|
||||
if (is_file($composerFile)) {
|
||||
$data = json_decode((string)file_get_contents($composerFile), true);
|
||||
if (is_array($data) && isset($data['autoload']['psr-4'])) {
|
||||
foreach ($data['autoload']['psr-4'] as $ns => $path) {
|
||||
// If the path is empty or '.', it maps the root of the module to this namespace
|
||||
if ($path === '' || $path === '.' || $path === './') {
|
||||
return rtrim($ns, '\\') . '\\';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback to default Phred convention
|
||||
$baseNamespace = (string) $this->config->get('MODULE_NAMESPACE', 'Modules');
|
||||
return "{$baseNamespace}\\\\{$moduleName}\\\\";
|
||||
}
|
||||
|
||||
public function registerAll(ContainerBuilder $builder): void
|
||||
{
|
||||
foreach ($this->providers as $provider) {
|
||||
$provider->register($builder, $this->config);
|
||||
}
|
||||
}
|
||||
|
||||
public function bootAll(Container $container): void
|
||||
{
|
||||
foreach ($this->providers as $provider) {
|
||||
$provider->boot($container);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,38 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Support\Storage;
|
||||
|
||||
use League\Flysystem\FilesystemOperator;
|
||||
|
||||
final class StorageManager
|
||||
{
|
||||
/** @var array<string, FilesystemOperator> */
|
||||
private array $disks = [];
|
||||
|
||||
public function __construct(
|
||||
private readonly FilesystemOperator $defaultDisk,
|
||||
private readonly array $config = []
|
||||
) {}
|
||||
|
||||
public function disk(?string $name = null): FilesystemOperator
|
||||
{
|
||||
if ($name === null) {
|
||||
return $this->defaultDisk;
|
||||
}
|
||||
|
||||
return $this->disks[$name] ?? $this->defaultDisk;
|
||||
}
|
||||
|
||||
public function url(string $path, ?string $disk = null): string
|
||||
{
|
||||
$diskName = $disk ?? 'local';
|
||||
$diskConfig = $this->config['disks'][$diskName] ?? null;
|
||||
|
||||
if (!$diskConfig || empty($diskConfig['url'])) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
return rtrim($diskConfig['url'], '/') . '/' . ltrim($path, '/');
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Template\Contracts;
|
||||
|
||||
interface RendererInterface
|
||||
{
|
||||
/**
|
||||
* Render a template with provided data into a string.
|
||||
* Implementation detail depends on selected driver.
|
||||
*/
|
||||
public function render(string $template, array $data = []): string;
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
<?php
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Phred\Template;
|
||||
|
||||
use Phred\Template\Contracts\RendererInterface;
|
||||
|
||||
/**
|
||||
* Minimal placeholder renderer used as default driver.
|
||||
*/
|
||||
final class EyrieRenderer implements RendererInterface
|
||||
{
|
||||
public function render(string $template, array $data = []): string
|
||||
{
|
||||
// naive replacement for demo purposes
|
||||
$out = $template;
|
||||
foreach ($data as $k => $v) {
|
||||
$out = str_replace('{{' . $k . '}}', (string) $v, $out);
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue