From 14e77976e5f20e9f6f19ee8bcb385eaa83cea0cd Mon Sep 17 00:00:00 2001 From: Caio Fernandes Date: Sat, 10 Oct 2026 15:28:59 -0300 Subject: [PATCH] =?UTF-8?q?[SPEC-089]=20Skeleton=20for=20the=20PivotPHP=20?= =?UTF-8?q?4=20ecosystem=20=E2=80=94=202.0.0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Requires pivotphp/core ^4.0.1 (http, core-routing ^2.2, security); create-project installs again - bootstrap/app.php shared by public/index.php and the tests; config/ loaded; debug off by default - Controllers on ExpressRequest/ExpressResponse with 201/204/404/422; CORS via pivotphp/security - Tests exercise every route through Application::handle(); CI with a built-in server smoke test - Removed custom CorsMiddleware, storage/ layout, unused config keys, hard-coded versions/figures Closes #7 Closes #8 Closes #9 Closes #10 Co-Authored-By: Claude Opus 5.5 --- .env.example | 22 +-- .github/workflows/ci.yml | 61 ++++++++ .gitignore | 9 ++ CHANGELOG.md | 29 ++++ README.md | 225 +++++++--------------------- app/Controllers/ApiController.php | 53 +++---- app/Controllers/UserController.php | 227 ++++++++--------------------- app/Middleware/CorsMiddleware.php | 36 ----- bootstrap/app.php | 37 +++++ composer.json | 30 ++-- config/app.php | 70 +++------ phpunit.xml | 8 +- post-create-project.php | 135 ++--------------- public/index.php | 19 +-- routes/api.php | 125 +++------------- tests/ApiTest.php | 175 +++++++++++++++++----- 16 files changed, 481 insertions(+), 780 deletions(-) create mode 100644 .github/workflows/ci.yml delete mode 100644 app/Middleware/CorsMiddleware.php create mode 100644 bootstrap/app.php diff --git a/.env.example b/.env.example index 113b56d..0d42ea4 100644 --- a/.env.example +++ b/.env.example @@ -1,21 +1,9 @@ -# PivotPHP Skeleton Environment +# Copied to .env by `composer create-project`. Real environment variables take precedence. APP_NAME="PivotPHP Skeleton API" APP_ENV=development -APP_DEBUG=true -APP_URL=http://localhost:8000 -# OpenAPI/Swagger -OPENAPI_ENABLED=true -SWAGGER_UI_ENABLED=true +# Debug responses expose exception messages and stack traces. Never enable in production. +APP_DEBUG=false -# Performance -CACHE_ROUTES=false -OPTIMIZE_RESPONSES=true - -# Security -CORS_ENABLED=true -RATE_LIMITING=false -SECURE_HEADERS=false - -# Logging -LOG_LEVEL=debug \ No newline at end of file +# Comma-separated list of origins allowed by CORS (e.g. http://localhost:5173). Empty = disabled. +CORS_ALLOWED_ORIGINS= diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..4977f32 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,61 @@ +name: CI + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +permissions: + contents: read + +jobs: + tests: + runs-on: ubuntu-latest + name: PHP ${{ matrix.php }} ยท ${{ matrix.deps }} + strategy: + fail-fast: false + matrix: + include: + - php: '8.1' + deps: 'lowest' + - php: '8.3' + deps: 'highest' + - php: '8.4' + deps: 'highest' + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + extensions: mbstring, json + coverage: none + + - name: Validate composer.json + run: composer validate --strict + + - name: Install dependencies + run: | + if [ "${{ matrix.deps }}" = "lowest" ]; then + composer update --prefer-lowest --prefer-stable --no-progress --no-interaction + else + composer update --prefer-stable --no-progress --no-interaction + fi + + - name: Audit dependencies + run: composer audit + + - name: Run PHPUnit + run: vendor/bin/phpunit + + - name: Smoke test (built-in server) + run: | + php -S 127.0.0.1:8000 -t public > /dev/null 2>&1 & + sleep 1 + curl -fsS http://127.0.0.1:8000/health + curl -fsS -o /dev/null -w '%{http_code}\n' -X POST http://127.0.0.1:8000/api/users \ + -H 'Content-Type: application/json' -d '{"name":"Ana","email":"ana@example.com"}' | grep -q 201 diff --git a/.gitignore b/.gitignore index 9a29cbc..38f2e2e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,11 @@ +/vendor/ +composer.lock +.env +/coverage/ +/.phpunit.cache/ +.phpunit.result.cache +.idea/ +.vscode/ +.DS_Store CLAUDE.md .claude/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 691f46c..08682fe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,35 @@ All notable changes to the PivotPHP Skeleton project will be documented in this The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [2.0.0] - 2026-10-10 + +Template for the PivotPHP 4 ecosystem (SPEC-089). + +### Changed + +- Requires **`pivotphp/core` ^4.0.1** (brings `pivotphp/http`, `pivotphp/core-routing` ^2.2 and + `pivotphp/security`); `composer create-project` installs again (SPEC-098). +- Controllers use `ExpressRequest`/`ExpressResponse` (`$req->input()`, `$req->param()`) and return + proper status codes: `201` + `Location`, `204` without body, `404`, `422` with field errors. +- New `bootstrap/app.php` builds the application for both `public/index.php` and the tests. +- `config/app.php` is loaded (application created with its base path) and reads the environment; + `.env` is copied from `.env.example` on `create-project`. +- CORS via `pivotphp/security` `CorsMiddleware`, restricted to `CORS_ALLOWED_ORIGINS`. + +### Fixed + +- `POST`/`PUT /api/users` returned 500 (`body()` treated as an array) (SPEC-082). +- `config/` was never loaded, and debug defaulted to **on** when `APP_DEBUG` was unset, exposing stack + traces (SPEC-084). Debug is now off unless `APP_DEBUG=true`. +- Tests depended on `storage/` (created only by `create-project`) and on a hard-coded framework version + (SPEC-083). + +### Removed + +- `App\Middleware\CorsMiddleware` (wildcard origin, preflight answered with 200 and a JSON body). +- Unused configuration keys (`openapi`, `performance`, `security`, `logging`), the `storage/` layout and + hard-coded framework versions and performance figures. + ## [1.1.1] - 2026-10-09 ### Fixed diff --git a/README.md b/README.md index 17af242..db7d66d 100644 --- a/README.md +++ b/README.md @@ -1,205 +1,82 @@ # PivotPHP Skeleton -**The fastest way to start building APIs with PivotPHP v2.2.0** +Project template for [PivotPHP](https://github.com/PivotPHP/pivotphp-core) 4 โ€” an Express.js-style PHP +API on PSR-7/PSR-15. -[![PivotPHP](https://img.shields.io/badge/PivotPHP-v2.2.0-blue.svg)](https://github.com/PivotPHP/pivotphp-core) -[![PHP Version](https://img.shields.io/badge/PHP-^8.1-777BB4.svg)](https://php.net) -[![License](https://img.shields.io/badge/License-MIT-green.svg)](LICENSE) - -> ๐Ÿš€ **"Route Syntax & DX Edition"** - PivotPHP v2.2.0 Edition - -## โœจ What's Included - -This skeleton provides everything you need to start building modern PHP APIs: - -- ๐ŸŽฏ **PivotPHP v2.2.0** - Latest framework with educational focus -- ๐Ÿ“š **Optional OpenAPI/Swagger** - Interactive API documentation at `/swagger`, opt-in (a few lines in `public/index.php` โ€” see [Enabling API Documentation](#enabling-api-documentation-optional) below) -- ๐Ÿš€ **Express.js Syntax** - Familiar, intuitive routing patterns -- ๐Ÿ—๏ธ **MVC Structure** - Controllers, middleware, and clean organization -- โœ… **PHPUnit Testing** - Ready-to-use testing setup -- ๐Ÿ”ง **Development Tools** - Built-in server, debugging, and more - -## ๐Ÿš€ Quick Start +## Quick start ```bash -# Create a new project composer create-project pivotphp/skeleton my-api - -# Enter project directory and start server -cd my-api && composer serve +cd my-api +composer serve # http://localhost:8000 ``` -Your API is now running at **http://localhost:8000**! ๐ŸŽ‰ - -## ๐Ÿ“– Available Endpoints +`create-project` copies `.env.example` to `.env`. Requires PHP 8.1+ with `ext-mbstring`. -Once running, you can access: +## Endpoints -| Endpoint | Description | -|----------|-------------| -| `GET /` | Welcome message with API info | -| `GET /health` | Health check endpoint | -| `GET /api/status` | API status and metadata | -| `GET /api/users` | List all users (CRUD example) | -| `POST /api/users` | Create new user | -| `GET /api/users/{id}` | Get user by ID | -| `PUT /api/users/{id}` | Update user | -| `DELETE /api/users/{id}` | Delete user | +| Method | Path | Description | +|---|---|---| +| GET | `/` | Welcome message | +| GET | `/health` | Health check | +| GET | `/api/status` | Name, environment and framework version (from `config/app.php`) | +| GET | `/api/users` | List users | +| POST | `/api/users` | Create a user (`name`, `email`; JSON or form) โ€” `201` + `Location`, `422` on invalid data | +| GET | `/api/users/:id` | Show a user โ€” `404` if missing | +| PUT | `/api/users/:id` | Update `name`/`email` | +| DELETE | `/api/users/:id` | Delete โ€” `204` | -`GET /swagger` and `GET /docs` (interactive API documentation and its OpenAPI 3.0 JSON spec) are **not** available out of the box โ€” they require enabling `ApiDocumentationMiddleware` first. See [Enabling API Documentation](#enabling-api-documentation-optional) below. +The example users live in memory, so every request starts from the same three users. Replace the array +in `UserController` with your persistence layer. -## ๐Ÿ—๏ธ Project Structure +## Structure ``` -my-api/ -โ”œโ”€โ”€ app/ -โ”‚ โ”œโ”€โ”€ Controllers/ # API controllers -โ”‚ โ”‚ โ”œโ”€โ”€ ApiController.php -โ”‚ โ”‚ โ””โ”€โ”€ UserController.php -โ”‚ โ””โ”€โ”€ Middleware/ # Custom middleware -โ”‚ โ””โ”€โ”€ CorsMiddleware.php -โ”œโ”€โ”€ config/ -โ”‚ โ””โ”€โ”€ app.php # Application configuration -โ”œโ”€โ”€ public/ -โ”‚ โ””โ”€โ”€ index.php # Application entry point -โ”œโ”€โ”€ routes/ -โ”‚ โ””โ”€โ”€ api.php # API route definitions -โ”œโ”€โ”€ storage/ -โ”‚ โ””โ”€โ”€ logs/ # Application logs -โ”œโ”€โ”€ tests/ -โ”‚ โ””โ”€โ”€ ApiTest.php # Example tests -โ”œโ”€โ”€ .env # Environment configuration -โ”œโ”€โ”€ composer.json # Dependencies and scripts -โ””โ”€โ”€ README.md # This file +app/Controllers/ ApiController (container-injected), UserController (CRUD example) +bootstrap/app.php builds the application: config, middlewares, controllers, routes +config/app.php app.* configuration, read from the environment +public/index.php HTTP entry point +routes/api.php routes +tests/ApiTest.php tests through the real application ($app->handle()) ``` -## ๐Ÿ› ๏ธ Development Commands - -```bash -# Start development server -composer serve - -# Run tests -composer test +## Configuration -# Run tests with coverage report -composer test:coverage -``` - -## ๐Ÿ“š Enabling API Documentation (optional) +`.env` is loaded before `config/`; real environment variables take precedence. -The `@route`, `@summary`, `@tags` and `@response` PHPDoc comments you'll see above -each route in `routes/api.php` are for human readers only โ€” `pivotphp/core` does -not parse them, and this skeleton does not register anything that would. By -default, **`/swagger` and `/docs` are not available**. +| Variable | Default | Purpose | +|---|---|---| +| `APP_NAME` | `PivotPHP Skeleton API` | Application name | +| `APP_ENV` | `production` | Environment name | +| `APP_DEBUG` | `false` | Error responses with messages and stack traces โ€” **never enable in production** | +| `CORS_ALLOWED_ORIGINS` | *(empty)* | Comma-separated origins allowed by CORS; empty disables CORS headers | -To enable them, register `PivotPHP\Core\Middleware\Http\ApiDocumentationMiddleware` -in `public/index.php`, before `$app->run()`: +## Writing routes ```php -use PivotPHP\Core\Middleware\Http\ApiDocumentationMiddleware; - -$app->use(new ApiDocumentationMiddleware([ - 'docs_path' => '/docs', // JSON OpenAPI 3.0 spec (note: not /openapi.json) - 'swagger_path' => '/swagger', // Swagger UI -])); -``` - -Once registered, the middleware builds the OpenAPI spec from the routes actually -registered on the `Router` at runtime โ€” the PHPDoc blocks above are not read by it, -they are purely documentation for developers browsing `routes/api.php`. +// routes/api.php +$app->get('/hello/:name', fn ($req, $res) => $res->json(['hello' => $req->param('name')])); -After adding this, visit **http://localhost:8000/swagger** to see interactive API docs. - -## ๐ŸŽฏ Express.js-Style Routing - -Write routes that feel familiar: - -```php -// Simple route -$app->get('/', function($req, $res) { - return $res->json(['message' => 'Hello World!']); +$app->post('/items', function ($req, $res) { + $name = $req->input('name'); // JSON or form body + return $res->status(201)->json(['name' => $name]); }); - -// Route parameters -$app->get('/users/{id}', function($req, $res) { - $id = $req->param('id'); - return $res->json(['user_id' => $id]); -}); - -// Array callables (work from PHP 8.1+; the legacy 'Controller@method' string -// syntax is what breaks under PHP 8.4+, not array callables themselves) -$app->post('/users', [UserController::class, 'store']); - -// Middleware โ€” CorsMiddleware ships as an example class in -// app/Middleware/CorsMiddleware.php but is NOT registered anywhere by -// default. Register it yourself in public/index.php before $app->run(): -$app->use(new CorsMiddleware()); ``` -## ๐Ÿ”ง Configuration - -Edit `config/app.php` to customize your application: - -```php -return [ - 'name' => 'My Awesome API', - 'openapi' => [ - 'title' => 'My API Documentation', - 'version' => '2.0.0' - ], - 'performance' => [ - 'cache_routes' => true, // Enable in production - 'optimize_responses' => true - ] -]; -``` +Handlers receive `PivotPHP\Http\ExpressRequest`/`ExpressResponse` and must return the response. +Controllers are array callables (`[UserController::class, 'index']`); register controllers that need +constructor dependencies in the container (see `bootstrap/app.php`). -## ๐Ÿงช Testing +Security middlewares (CORS, security headers, JWT, CSRF, rate limiting) come from +[`pivotphp/security`](https://github.com/PivotPHP/pivotphp-security); this template enables CORS. -Write tests in the `tests/` directory: +## Commands -```php -class MyApiTest extends TestCase -{ - public function testWelcomeEndpoint(): void - { - // Your API tests here - $this->assertTrue(true); - } -} +```bash +composer serve # built-in server on localhost:8000 (docroot: public/) +composer test # PHPUnit ``` -## ๐Ÿ“ˆ Performance - -Historical PivotPHP v1.2.0 benchmark figures (Docker-validated at the time), not -revalidated against this skeleton or the current `pivotphp/core` release line โ€” -treat as indicative, not a guarantee: - -- **2,122 req/sec** peak HTTP performance -- **3.6M ops/sec** OpenAPI generation -- **Docker validated** benchmarks (as of the v1.2.0 release) - -## ๐Ÿš€ Next Steps - -1. **Customize your API** - Edit routes in `routes/api.php` -2. **Add controllers** - Create new controllers in `app/Controllers/` -3. **Build middleware** - Add custom middleware in `app/Middleware/` -4. **Write tests** - Add tests in `tests/` -5. **Deploy** - Use your preferred deployment method - -## ๐Ÿ“– Learn More - -- [PivotPHP Documentation](https://pivotphp.github.io/website/) -- [GitHub Repository](https://github.com/PivotPHP/pivotphp-core) -- [Performance Benchmarks](https://pivotphp.github.io/website/docs/benchmarks/) - -## ๐Ÿ“„ License - -The PivotPHP Skeleton is open-sourced software licensed under the [MIT license](LICENSE). - ---- - -**Built with โค๏ธ by the PivotPHP Team** +## License -*"Making PHP development joyful again"* +MIT diff --git a/app/Controllers/ApiController.php b/app/Controllers/ApiController.php index bf657b5..4bd81fa 100644 --- a/app/Controllers/ApiController.php +++ b/app/Controllers/ApiController.php @@ -4,44 +4,27 @@ namespace App\Controllers; -/** - * API Controller - * Handles general API endpoints - */ -class ApiController +use PivotPHP\Core\Core\Application; +use PivotPHP\Http\ExpressRequest; +use PivotPHP\Http\ExpressResponse; +use Psr\Http\Message\ResponseInterface; + +final class ApiController { - /** - * Get API status and metadata - * - * @param mixed $req Request object - * @param mixed $res Response object - * @return mixed JSON response - */ - public function status($req, $res) + public function __construct(private readonly Application $app) { + } + + public function status(ExpressRequest $req, ExpressResponse $res): ResponseInterface + { + $config = $this->app->getConfig(); + return $res->json([ - 'api' => [ - 'name' => 'PivotPHP Skeleton API', - 'version' => '1.0.0', - 'framework' => 'PivotPHP v2.2.0', - 'edition' => 'Route Syntax & DX Edition' - ], - 'features' => [ - 'express_syntax' => true, - 'array_callables' => true - ], - 'performance' => [ - 'http_peak_rps' => 2122, - 'docker_validated' => true - ], - 'endpoints' => [ - 'welcome' => '/', - 'health' => '/health', - 'api_status' => '/api/status', - 'users' => '/api/users' - ], + 'name' => $config->get('app.name'), + 'environment' => $config->get('app.env'), + 'framework' => 'PivotPHP ' . Application::VERSION, + 'php' => PHP_VERSION, 'timestamp' => date('c'), - 'server_time' => time() ]); } -} \ No newline at end of file +} diff --git a/app/Controllers/UserController.php b/app/Controllers/UserController.php index bcbff11..e991f73 100644 --- a/app/Controllers/UserController.php +++ b/app/Controllers/UserController.php @@ -4,203 +4,102 @@ namespace App\Controllers; +use PivotPHP\Http\ExpressRequest; +use PivotPHP\Http\ExpressResponse; +use Psr\Http\Message\ResponseInterface; + /** - * User Controller - * Handles user-related endpoints + * Example CRUD controller. + * + * Data lives in memory: under PHP-FPM / the built-in server every request starts again from the + * initial list. Replace the array with your persistence layer (e.g. PivotPHP\Core\Database\Database). */ -class UserController +final class UserController { - /** - * Sample users data - */ - private static array $users = [ - ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com', 'created_at' => '2025-07-21T12:00:00Z'], - ['id' => 2, 'name' => 'Jane Smith', 'email' => 'jane@example.com', 'created_at' => '2025-07-21T12:15:00Z'], - ['id' => 3, 'name' => 'Bob Johnson', 'email' => 'bob@example.com', 'created_at' => '2025-07-21T12:30:00Z'], + /** @var array */ + private array $users = [ + 1 => ['id' => 1, 'name' => 'John Doe', 'email' => 'john@example.com'], + 2 => ['id' => 2, 'name' => 'Jane Smith', 'email' => 'jane@example.com'], + 3 => ['id' => 3, 'name' => 'Bob Johnson', 'email' => 'bob@example.com'], ]; - /** - * Get all users - * - * @param mixed $req Request object - * @param mixed $res Response object - * @return mixed JSON response - */ - public function index($req, $res) + public function index(ExpressRequest $req, ExpressResponse $res): ResponseInterface { - return $res->json([ - 'users' => self::$users, - 'total' => count(self::$users), - 'timestamp' => date('c') - ]); + return $res->json(['users' => array_values($this->users), 'total' => count($this->users)]); } - /** - * Get user by ID - * - * @param mixed $req Request object - * @param mixed $res Response object - * @return mixed JSON response - */ - public function show($req, $res) + public function show(ExpressRequest $req, ExpressResponse $res): ResponseInterface { - $id = (int) $req->param('id'); - - $user = collect(self::$users)->firstWhere('id', $id); - - if (!$user) { - return $res->status(404)->json([ - 'error' => 'User not found', - 'message' => "User with ID {$id} does not exist", - 'timestamp' => date('c') - ]); - } + $user = $this->users[(int) $req->param('id')] ?? null; - return $res->json([ - 'user' => $user, - 'timestamp' => date('c') - ]); + return $user === null ? $res->error(404, 'User not found') : $res->json(['user' => $user]); } - /** - * Create new user - * - * @param mixed $req Request object - * @param mixed $res Response object - * @return mixed JSON response - */ - public function store($req, $res) + public function store(ExpressRequest $req, ExpressResponse $res): ResponseInterface { - $data = $req->body(); - - // Simple validation - if (!isset($data['name']) || !isset($data['email'])) { - return $res->status(400)->json([ - 'error' => 'Validation failed', - 'message' => 'Name and email are required', - 'timestamp' => date('c') - ]); + $errors = $this->validate($req, requireAll: true); + if ($errors !== []) { + return $res->status(422)->json(['errors' => $errors]); } - // Create new user - $newUser = [ - 'id' => count(self::$users) + 1, - 'name' => $data['name'], - 'email' => $data['email'], - 'created_at' => date('c') + $user = [ + 'id' => max(array_keys($this->users)) + 1, + 'name' => trim((string) $req->input('name')), + 'email' => (string) $req->input('email'), ]; - self::$users[] = $newUser; - - return $res->status(201)->json([ - 'message' => 'User created successfully', - 'user' => $newUser, - 'timestamp' => date('c') - ]); + return $res->status(201)->header('Location', '/api/users/' . $user['id'])->json(['user' => $user]); } - /** - * Update user - * - * @param mixed $req Request object - * @param mixed $res Response object - * @return mixed JSON response - */ - public function update($req, $res) + public function update(ExpressRequest $req, ExpressResponse $res): ResponseInterface { $id = (int) $req->param('id'); - $data = $req->body(); - - $userIndex = null; - foreach (self::$users as $index => $user) { - if ($user['id'] === $id) { - $userIndex = $index; - break; - } - } - - if ($userIndex === null) { - return $res->status(404)->json([ - 'error' => 'User not found', - 'message' => "User with ID {$id} does not exist", - 'timestamp' => date('c') - ]); + $user = $this->users[$id] ?? null; + if ($user === null) { + return $res->error(404, 'User not found'); } - // Update user data - if (isset($data['name'])) { - self::$users[$userIndex]['name'] = $data['name']; - } - if (isset($data['email'])) { - self::$users[$userIndex]['email'] = $data['email']; + $errors = $this->validate($req, requireAll: false); + if ($errors !== []) { + return $res->status(422)->json(['errors' => $errors]); } - self::$users[$userIndex]['updated_at'] = date('c'); - return $res->json([ - 'message' => 'User updated successfully', - 'user' => self::$users[$userIndex], - 'timestamp' => date('c') - ]); + $name = $req->input('name'); + $email = $req->input('email'); + $user['name'] = is_string($name) ? trim($name) : $user['name']; + $user['email'] = is_string($email) ? $email : $user['email']; + + return $res->json(['user' => $user]); + } + + public function destroy(ExpressRequest $req, ExpressResponse $res): ResponseInterface + { + return isset($this->users[(int) $req->param('id')]) + ? $res->noContent() + : $res->error(404, 'User not found'); } /** - * Delete user - * - * @param mixed $req Request object - * @param mixed $res Response object - * @return mixed JSON response + * @return array */ - public function destroy($req, $res) + private function validate(ExpressRequest $req, bool $requireAll): array { - $id = (int) $req->param('id'); - - $userIndex = null; - foreach (self::$users as $index => $user) { - if ($user['id'] === $id) { - $userIndex = $index; - break; + $errors = []; + $name = $req->input('name'); + $email = $req->input('email'); + + if ($name !== null || $requireAll) { + if (!is_string($name) || trim($name) === '') { + $errors['name'] = 'The name field is required.'; } } - - if ($userIndex === null) { - return $res->status(404)->json([ - 'error' => 'User not found', - 'message' => "User with ID {$id} does not exist", - 'timestamp' => date('c') - ]); - } - // Remove user - $deletedUser = self::$users[$userIndex]; - array_splice(self::$users, $userIndex, 1); + if ($email !== null || $requireAll) { + if (!is_string($email) || filter_var($email, FILTER_VALIDATE_EMAIL) === false) { + $errors['email'] = 'The email field must be a valid e-mail address.'; + } + } - return $res->status(204)->json([ - 'message' => 'User deleted successfully', - 'deleted_user' => $deletedUser, - 'timestamp' => date('c') - ]); + return $errors; } } - -/** - * Helper function to simulate Laravel's collect() - */ -function collect(array $items): object -{ - return new class($items) { - private array $items; - - public function __construct(array $items) { - $this->items = $items; - } - - public function firstWhere(string $key, mixed $value): ?array { - foreach ($this->items as $item) { - if (isset($item[$key]) && $item[$key] === $value) { - return $item; - } - } - return null; - } - }; -} \ No newline at end of file diff --git a/app/Middleware/CorsMiddleware.php b/app/Middleware/CorsMiddleware.php deleted file mode 100644 index 6d69597..0000000 --- a/app/Middleware/CorsMiddleware.php +++ /dev/null @@ -1,36 +0,0 @@ -header('Access-Control-Allow-Origin', '*'); - $res->header('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); - $res->header('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); - $res->header('Access-Control-Max-Age', '3600'); - - // Handle preflight requests - if ($req->getMethod() === 'OPTIONS') { - return $res->status(200)->json(['message' => 'CORS preflight successful']); - } - - return $next($req, $res); - } -} \ No newline at end of file diff --git a/bootstrap/app.php b/bootstrap/app.php new file mode 100644 index 0000000..ef49293 --- /dev/null +++ b/bootstrap/app.php @@ -0,0 +1,37 @@ +boot(); + +$config = $app->getConfig(); + +// CORS: only the origins listed in CORS_ALLOWED_ORIGINS (comma-separated). Empty = no CORS headers. +$origins = $config->get('app.cors.allowed_origins', []); +$app->use(new CorsMiddleware(new Psr17Factory(), new CorsConfig( + allowedOrigins: is_array($origins) ? array_values(array_filter($origins, 'is_string')) : [], +))); + +// Controllers with constructor dependencies are registered in the container; controllers without +// dependencies are instantiated directly. +$app->singleton(ApiController::class, fn () => new ApiController($app)); + +require __DIR__ . '/../routes/api.php'; + +return $app; diff --git a/composer.json b/composer.json index efec841..123efe3 100644 --- a/composer.json +++ b/composer.json @@ -1,7 +1,17 @@ { "name": "pivotphp/skeleton", - "description": "Skeleton project for PivotPHP v2.2.0 - The evolutionary PHP microframework", - "keywords": ["framework", "api", "rest", "express", "skeleton", "project-template"], + "description": "Project template for PivotPHP 4 โ€” Express.js-style PHP API on PSR-7/PSR-15", + "keywords": [ + "pivotphp", + "framework", + "api", + "rest", + "express", + "psr-7", + "psr-15", + "skeleton", + "project-template" + ], "license": "MIT", "type": "project", "authors": [ @@ -10,14 +20,15 @@ "email": "team@pivotphp.com" } ], - "minimum-stability": "dev", + "minimum-stability": "stable", "prefer-stable": true, "require": { "php": "^8.1", - "pivotphp/core": "^2.2" + "pivotphp/core": "^4.0.1" }, "require-dev": { - "phpunit/phpunit": "^10.0 || ^11.0" + "nyholm/psr7": "^1.8", + "phpunit/phpunit": "^10.5.62|^11.5.50" }, "autoload": { "psr-4": { @@ -32,7 +43,6 @@ "scripts": { "serve": "php -S localhost:8000 -t public", "test": "phpunit", - "test:coverage": "phpunit --coverage-html coverage", "post-create-project-cmd": [ "@php post-create-project.php" ] @@ -40,11 +50,5 @@ "config": { "optimize-autoloader": true, "sort-packages": true - }, - "extra": { - "pivotphp": { - "version": "2.2.0", - "edition": "Route Syntax & DX Edition" - } } -} \ No newline at end of file +} diff --git a/config/app.php b/config/app.php index a2559cd..d5ab432 100644 --- a/config/app.php +++ b/config/app.php @@ -3,60 +3,24 @@ declare(strict_types=1); /** - * Application Configuration - * Configure your PivotPHP application settings + * Application configuration (available as app.* via $app->getConfig()). + * Values come from the environment / .env, with safe defaults. */ +$env = static fn (string $key, ?string $default = null): ?string => + is_string($_ENV[$key] ?? null) ? $_ENV[$key] : (getenv($key) !== false ? (string) getenv($key) : $default); + return [ - // Application settings - 'name' => 'PivotPHP Skeleton API', - 'version' => '1.0.0', - 'env' => $_ENV['APP_ENV'] ?? 'development', - 'debug' => (bool) ($_ENV['APP_DEBUG'] ?? true), - - // Framework settings - 'framework' => [ - 'name' => 'PivotPHP', - 'version' => '2.2.0', - 'edition' => 'Route Syntax & DX Edition' - ], - - // OpenAPI/Swagger settings - 'openapi' => [ - 'enabled' => true, - 'title' => 'PivotPHP Skeleton API', - 'description' => 'Example API built with PivotPHP v2.2.0 skeleton', - 'version' => '1.0.0', - 'contact' => [ - 'name' => 'API Support', - 'email' => 'support@example.com' - ], - 'servers' => [ - [ - 'url' => 'http://localhost:8000', - 'description' => 'Development server' - ] - ] - ], - - // Performance settings - 'performance' => [ - 'cache_routes' => false, // Enable in production - 'optimize_responses' => true, - 'enable_compression' => false // Enable in production - ], - - // Security settings - 'security' => [ - 'cors_enabled' => true, - 'rate_limiting' => false, // Enable in production - 'secure_headers' => false // Enable in production + 'name' => $env('APP_NAME', 'PivotPHP Skeleton API'), + 'env' => $env('APP_ENV', 'production'), + + // Debug responses include exception messages and stack traces: off unless explicitly enabled. + 'debug' => filter_var($env('APP_DEBUG', 'false'), FILTER_VALIDATE_BOOL), + + 'cors' => [ + 'allowed_origins' => array_values(array_filter(array_map( + 'trim', + explode(',', (string) $env('CORS_ALLOWED_ORIGINS', '')) + ))), ], - - // Logging settings - 'logging' => [ - 'enabled' => true, - 'level' => 'debug', - 'file' => __DIR__ . '/../storage/logs/app.log' - ] -]; \ No newline at end of file +]; diff --git a/phpunit.xml b/phpunit.xml index 7840d33..709750e 100644 --- a/phpunit.xml +++ b/phpunit.xml @@ -1,11 +1,11 @@ + cacheDirectory=".phpunit.cache"> - + ./tests @@ -14,4 +14,4 @@ ./app - \ No newline at end of file + diff --git a/post-create-project.php b/post-create-project.php index b6e1a5f..cabf31c 100644 --- a/post-create-project.php +++ b/post-create-project.php @@ -3,136 +3,23 @@ declare(strict_types=1); /** - * Post Create Project Script - * Runs after composer create-project to setup the skeleton + * Runs after `composer create-project`: creates .env from .env.example. */ -echo "\n๐Ÿš€ Setting up your PivotPHP v2.2.0 project...\n\n"; +$root = __DIR__; -// Get current directory -$currentDir = getcwd(); - -// Create .env file if it doesn't exist -if (!file_exists($currentDir . '/.env')) { - echo "๐Ÿ“ Creating .env file...\n"; - - $envContent = <<run(); \ No newline at end of file +$app->run(); diff --git a/routes/api.php b/routes/api.php index 05319ec..08b9cb6 100644 --- a/routes/api.php +++ b/routes/api.php @@ -3,120 +3,33 @@ declare(strict_types=1); /** - * API Routes for PivotPHP v2.2.0 Skeleton - * Define your API endpoints here + * API routes. $app is the PivotPHP\Core\Core\Application built in bootstrap/app.php. + * + * Handlers receive PivotPHP\Http\ExpressRequest ($req) and ExpressResponse ($res) and must + * return the response. */ use App\Controllers\ApiController; use App\Controllers\UserController; +use PivotPHP\Core\Core\Application; -/** - * Welcome endpoint - * @route GET / - * @summary Welcome message for the API - * @tags Welcome - * @response 200 Welcome message - */ -$app->get('/', function($req, $res) { - return $res->json([ - 'message' => 'Welcome to PivotPHP v2.2.0!', - 'framework' => 'PivotPHP', - 'version' => '2.2.0', - 'edition' => 'Route Syntax & DX Edition', - 'features' => [ - 'health_check' => '/health' - ], - 'performance' => [ - 'http_peak_rps' => 2122, - 'http_average_rps' => 1418, - 'docker_validated' => true - ], - 'timestamp' => date('c') - ]); -}); +/** @var Application $app */ -/** - * Health check endpoint - * @route GET /health - * @summary Health check for monitoring - * @tags Health - * @response 200 Health status - */ -$app->get('/health', function($req, $res) { - return $res->json([ - 'status' => 'healthy', - 'framework' => 'PivotPHP v2.2.0', - 'timestamp' => date('c'), - 'uptime' => 'ready' - ]); -}); +$app->get('/', fn ($req, $res) => $res->json([ + 'message' => 'Welcome to your PivotPHP API', + 'framework' => 'PivotPHP ' . Application::VERSION, + 'endpoints' => ['/health', '/api/status', '/api/users'], +])); + +$app->get('/health', fn ($req, $res) => $res->json([ + 'status' => 'healthy', + 'timestamp' => date('c'), +])); -/** - * API status endpoint - * @route GET /api/status - * @summary API status and metadata - * @tags API - * @response 200 API status information - */ $app->get('/api/status', [ApiController::class, 'status']); -/** - * Users endpoints - * @route GET /api/users - * @summary List all users - * @tags Users - * @response 200 List of users - */ $app->get('/api/users', [UserController::class, 'index']); - -/** - * @route GET /api/users/{id} - * @summary Get user by ID - * @tags Users - * @parameter {integer} id.path.required - User ID - * @response 200 User details - * @response 404 User not found - */ -$app->get('/api/users/{id}', [UserController::class, 'show']); - -/** - * @route POST /api/users - * @summary Create new user - * @tags Users - * @parameter {object} body.body.required - User data - * @response 201 User created successfully - * @response 400 Invalid input - */ $app->post('/api/users', [UserController::class, 'store']); - -/** - * @route PUT /api/users/{id} - * @summary Update user - * @tags Users - * @parameter {integer} id.path.required - User ID - * @parameter {object} body.body.required - Updated user data - * @response 200 User updated successfully - * @response 404 User not found - */ -$app->put('/api/users/{id}', [UserController::class, 'update']); - -/** - * @route DELETE /api/users/{id} - * @summary Delete user - * @tags Users - * @parameter {integer} id.path.required - User ID - * @response 204 User deleted successfully - * @response 404 User not found - */ -$app->delete('/api/users/{id}', [UserController::class, 'destroy']); - -/** - * Example error endpoint for testing - * @route GET /api/error - * @summary Trigger example error - * @tags Testing - * @response 500 Example error response - */ -$app->get('/api/error', function($req, $res) { - throw new \Exception('This is an example error for testing purposes'); -}); \ No newline at end of file +$app->get('/api/users/:id<\d+>', [UserController::class, 'show']); +$app->put('/api/users/:id<\d+>', [UserController::class, 'update']); +$app->delete('/api/users/:id<\d+>', [UserController::class, 'destroy']); diff --git a/tests/ApiTest.php b/tests/ApiTest.php index 02e7dd1..e15696b 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -4,65 +4,164 @@ namespace Tests; +use Nyholm\Psr7\Factory\Psr17Factory; use PHPUnit\Framework\TestCase; +use PivotPHP\Core\Core\Application; +use Psr\Http\Message\ResponseInterface; /** - * Basic API Tests - * Example tests for the skeleton application + * Exercises the real application (bootstrap/app.php) through Application::handle(). */ -class ApiTest extends TestCase +final class ApiTest extends TestCase { + private const ORIGIN = 'http://localhost:5173'; + + private Application $app; + private Psr17Factory $factory; + + protected function setUp(): void + { + putenv('CORS_ALLOWED_ORIGINS=' . self::ORIGIN); + $_ENV['CORS_ALLOWED_ORIGINS'] = self::ORIGIN; + + $this->app = require __DIR__ . '/../bootstrap/app.php'; + $this->factory = new Psr17Factory(); + } + + protected function tearDown(): void + { + putenv('CORS_ALLOWED_ORIGINS'); + unset($_ENV['CORS_ALLOWED_ORIGINS']); + } + /** - * Test that we can run basic tests + * @param array $headers + * @param array|null $json */ - public function testBasicAssertion(): void + private function request(string $method, string $uri, ?array $json = null, array $headers = []): ResponseInterface { - $this->assertTrue(true); - $this->assertEquals(4, 2 + 2); + $request = $this->factory->createServerRequest($method, $uri); + foreach ($headers as $name => $value) { + $request = $request->withHeader($name, $value); + } + if ($json !== null) { + $request = $request + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream((string) json_encode($json))); + } + + return $this->app->handle($request); } /** - * Test application configuration + * @return array */ - public function testConfiguration(): void + private static function body(ResponseInterface $response): array { - $config = require __DIR__ . '/../config/app.php'; - - $this->assertIsArray($config); - $this->assertEquals('PivotPHP Skeleton API', $config['name']); - $this->assertEquals('1.0.0', $config['version']); - $this->assertEquals('PivotPHP', $config['framework']['name']); - $this->assertEquals('2.2.0', $config['framework']['version']); + $data = json_decode((string) $response->getBody(), true); + + return is_array($data) ? $data : []; + } + + public function testWelcomeAndHealth(): void + { + $welcome = $this->request('GET', '/'); + $this->assertSame(200, $welcome->getStatusCode()); + $this->assertSame('PivotPHP ' . Application::VERSION, self::body($welcome)['framework']); + + $this->assertSame('healthy', self::body($this->request('GET', '/health'))['status']); + } + + public function testStatusReadsConfiguration(): void + { + $body = self::body($this->request('GET', '/api/status')); + + $this->assertSame('PivotPHP Skeleton API', $body['name']); + $this->assertSame('production', $body['environment']); + } + + public function testListAndShowUsers(): void + { + $this->assertSame(3, self::body($this->request('GET', '/api/users'))['total']); + $this->assertSame('Jane Smith', self::body($this->request('GET', '/api/users/2'))['user']['name']); + $this->assertSame(404, $this->request('GET', '/api/users/99')->getStatusCode()); + $this->assertSame(404, $this->request('GET', '/api/users/abc')->getStatusCode()); } /** - * Test that required directories exist + * SPEC-082: POST/PUT returned 500 because body() was treated as an array. */ - public function testDirectoryStructure(): void + public function testCreateUser(): void { - $basePath = __DIR__ . '/..'; - - $this->assertDirectoryExists($basePath . '/public'); - $this->assertDirectoryExists($basePath . '/app'); - $this->assertDirectoryExists($basePath . '/app/Controllers'); - $this->assertDirectoryExists($basePath . '/app/Middleware'); - $this->assertDirectoryExists($basePath . '/config'); - $this->assertDirectoryExists($basePath . '/routes'); - $this->assertDirectoryExists($basePath . '/storage'); - $this->assertDirectoryExists($basePath . '/storage/logs'); + $response = $this->request('POST', '/api/users', ['name' => 'Ana', 'email' => 'ana@example.com']); + + $this->assertSame(201, $response->getStatusCode()); + $this->assertSame('/api/users/4', $response->getHeaderLine('Location')); + $this->assertSame('Ana', self::body($response)['user']['name']); + } + + public function testCreateUserValidation(): void + { + $response = $this->request('POST', '/api/users', ['name' => '', 'email' => 'not-an-email']); + + $this->assertSame(422, $response->getStatusCode()); + $this->assertArrayHasKey('name', self::body($response)['errors']); + $this->assertArrayHasKey('email', self::body($response)['errors']); + } + + public function testUpdateAndDeleteUser(): void + { + $updated = $this->request('PUT', '/api/users/1', ['name' => 'John Updated']); + $this->assertSame(200, $updated->getStatusCode()); + $this->assertSame('John Updated', self::body($updated)['user']['name']); + $this->assertSame('john@example.com', self::body($updated)['user']['email']); + + $this->assertSame(404, $this->request('PUT', '/api/users/99', ['name' => 'X'])->getStatusCode()); + + $deleted = $this->request('DELETE', '/api/users/1'); + $this->assertSame(204, $deleted->getStatusCode()); + $this->assertSame('', (string) $deleted->getBody()); + } + + public function testMalformedJsonIsRejected(): void + { + $request = $this->factory->createServerRequest('POST', '/api/users') + ->withHeader('Content-Type', 'application/json') + ->withBody($this->factory->createStream('{invalid')); + + $this->assertSame(400, $this->app->handle($request)->getStatusCode()); + } + + public function testWrongMethodReturns405(): void + { + $response = $this->request('PATCH', '/api/users'); + + $this->assertSame(405, $response->getStatusCode()); + $this->assertStringContainsString('GET', $response->getHeaderLine('Allow')); } /** - * Test that required files exist + * SPEC-084: debug is off by default โ€” errors do not expose details. */ - public function testRequiredFiles(): void + public function testDebugIsOffByDefault(): void { - $basePath = __DIR__ . '/..'; - - $this->assertFileExists($basePath . '/public/index.php'); - $this->assertFileExists($basePath . '/routes/api.php'); - $this->assertFileExists($basePath . '/config/app.php'); - $this->assertFileExists($basePath . '/composer.json'); - $this->assertFileExists($basePath . '/README.md'); + $this->assertFalse($this->app->getConfig()->get('app.debug')); + + $body = self::body($this->request('GET', '/api/users/1/missing')); + $this->assertArrayNotHasKey('trace', $body); + } + + public function testCorsAllowsOnlyConfiguredOrigins(): void + { + $allowed = $this->request('OPTIONS', '/api/users', null, [ + 'Origin' => self::ORIGIN, + 'Access-Control-Request-Method' => 'POST', + 'Access-Control-Request-Headers' => 'Content-Type', + ]); + $this->assertSame(204, $allowed->getStatusCode()); + $this->assertSame(self::ORIGIN, $allowed->getHeaderLine('Access-Control-Allow-Origin')); + + $denied = $this->request('GET', '/api/users', null, ['Origin' => 'https://evil.example']); + $this->assertFalse($denied->hasHeader('Access-Control-Allow-Origin')); } -} \ No newline at end of file +}