diff --git a/.env.example b/.env.example index 3a5d481..c61def6 100644 --- a/.env.example +++ b/.env.example @@ -2,8 +2,8 @@ # Values here are local dev defaults only! # PostgreSQL -# localhost works for `composer serve` on your machine. Inside Docker Compose, -# the php service overrides this with it's own service name: DB_HOST=postgres +# Inside Docker Compose, the php service overrides this with it's own service +# name: DB_HOST=postgres; localhost applies to `composer serve` outside DB_HOST=localhost # Database name - postgres container creates it on first start @@ -14,3 +14,8 @@ DB_USER=your_user_name # Password for DB_USER - postgres image refuses to start with an empty password DB_PASSWORD=changeme + +# Redis +# Inside Docker Compose, the redis service overrides this with it's own service +# name: REDIS_HOST=redis; localhost applies to `composer serve` outside +REDIS_HOST=localhost diff --git a/.zed/tasks.json b/.zed/tasks.json index 05f5c16..a603853 100644 --- a/.zed/tasks.json +++ b/.zed/tasks.json @@ -1,5 +1,4 @@ [ - { "label": "analyze", "command": "composer analyze", @@ -35,6 +34,13 @@ "shell": "system", "reveal": "always", }, + { + "label": "lint:spec", + "command": "composer lint:spec", + "cwd": "$ZED_WORKTREE_ROOT", + "shell": "system", + "reveal": "always", + }, { "label": "serve", "command": "composer serve", diff --git a/README.md b/README.md index edbcf8b..2e39779 100644 --- a/README.md +++ b/README.md @@ -1,180 +1,198 @@ # laminas-api-sample -![CI](https://github.com/blakesimpson-dev/laminas-api-sample/actions/workflows/ci.yml/badge.svg) +[![CI](https://github.com/blakesimpson-dev/laminas-api-sample/actions/workflows/ci.yml/badge.svg)](https://github.com/blakesimpson-dev/laminas-api-sample/actions/workflows/ci.yml) +![PHP 8.5](https://img.shields.io/badge/PHP-8.5-777bb4?logo=php&logoColor=white) +![OpenAPI 3.1](https://img.shields.io/badge/OpenAPI-3.1-6ba539?logo=openapiinitiative&logoColor=white) +[![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) -A learning project using PHP 8.5, Laminas and Doctrine: a small REST backend -modelled on a slice of the +A small REST backend in PHP 8.5, Laminas and Doctrine, replicating a slice of +the [Path of Exile developer API](https://www.pathofexile.com/developer/docs/reference). +Paths, response shapes, error codes, auth and rate limiting follow the published +docs. Data remains local, however, and GGG's API is not called. -Whilst paths, response shapes and error codes all follow the published docs, the -API serves its own data and does not call GGG's API. - -The API is hand-wired from Laminas components, rather than a full framework or -starter template. This way each part can be understood, explained, and reasoned -for. The commit history follows that progression. +It has been hand-wired from Laminas components, rather than go full-framework, +so that every part can be explained. The commit history follows that +progression. ![Demo](docs/demo.gif) ## Features -- `GET /profile`, `GET|POST /item-filter`, `GET|POST /item-filter/{id}`, with - documented response and error shapes -- Mocked OAuth 2.1 bearer authentication - route scopes, expire and revoke for - tokens and per-profile ownership for item filters -- OpenAPI 3.1 spec (`docs/openapi.json`) with Swagger UI available at `/docs`. A - contract test keeps the model synchronized. -- Create and partial update with validation - actions are recorded as timestamps - on each entity via an injected system clock -- Doctrine entities, embeddables, and reviewed migrations -- Nginx + php-fpm + PostgreSQL + Swagger UI in Docker Compose, credentials in - shared environment from `.env` -- Fixtures seeded from real data -- PHPUnit testing for entities, input validation, adapters and the HTTP layer -- Written with adherence to modern PHP conventions using strict static analysis +- **Endpoints:** + [`/profile`](https://www.pathofexile.com/developer/docs/reference#profile-get) + and + [`/item-filter`](https://www.pathofexile.com/developer/docs/reference#itemfilters) + (read-many, read, create, partial update), with the documented + [response shapes](https://www.pathofexile.com/developer/docs/reference#type-ItemFilter) + and [error codes](https://www.pathofexile.com/developer/docs/index#errors) +- **Auth:** mocked OAuth 2.1 bearer tokens with per-route scopes, expiry, + revocation and per-profile ownership ([details](#auth)) +- **Rate limiting:** per token in Redis, with the documented headers and 429 + responses ([details](#rate-limiting)) +- **API docs:** hand-written OpenAPI 3.1 spec with Swagger UI; a contract test + keeps it in step with the code ([details](#api-docs)) +- **Persistence:** Doctrine entities and embeddables, reviewed migrations, + created/updated timestamps from an injected clock, fixtures seeded from real + item filters +- **Stack:** Nginx, php-fpm, PostgreSQL, Redis and Swagger UI in Docker Compose +- **Quality:** PHPUnit unit and contract tests; Mago formatting, linting and + strict static analysis, all enforced in CI ## Auth -This replication only covers the resource server (`api.pathofexile.com`). Token -issuance (`www.pathofexil.com/oauth`) is out of scope. Development tokens are -seeded as if the authorization server had isued them, and stored only as SHA-256 -hashes. Like the real API, the token identifies it's owner, so `account:*` -endpoints do not need an `id` in the path. 401 and 403 responses carry -`WWW-Authenticate` headers (RFC 6750). - -| Endpoint | Scope | -| ------------------------------------------------------- | --------------------- | -| `GET /profile` | `account:profile` | -| `GET\|POST /item-filter`, `GET\|POST /item-filter/{id}` | `account:item_filter` | - -| Dev token | Demonstrates | -| ------------------------- | ----------------------------------------------- | -| `dev-token-full` | Full access for `ProfileOne` | -| `dev-token-profile-only` | 403 (code 6) on `/item-filter`: missing scope | -| `dev-token-expired` | 401 (code 8): expired | -| `dev-token-revoked` | 401 (code 8): revoked | -| `dev-token-other-profile` | `ProfileTwo`. (`ProfileOne` filters return 404) | - -```bash -# 200: Ok -curl -i -H 'Authorization: Bearer dev-token-full' localhost:8000/profile - -# 403: Forbidden -curl -i -H 'Authorization: Bearer dev-token-profile-only' localhost:8000/item-filter +Replicates the resource server (`api.pathofexile.com`) only. Token issuance +(`www.pathofexile.com/oauth`) is out of scope: dev tokens are seeded as if the +authorization server had issued them, and stored as SHA-256 hashes. As in the +[real API](https://www.pathofexile.com/developer/docs/authorization#request), +the token identifies its owner, so `account:*` endpoints need no id in the path. + +| Endpoint | [Scope](https://www.pathofexile.com/developer/docs/authorization#scopes-accounts) | +| ------------------------------------------------------- | --------------------------------------------------------------------------------- | +| `GET /profile` | `account:profile` | +| `GET\|POST /item-filter`, `GET\|POST /item-filter/{id}` | `account:item_filter` | + +| Dev token | Demonstrates | +| ------------------------- | --------------------------------------------- | +| `dev-token-full` | full access, as `ProfileOne` | +| `dev-token-profile-only` | 403 (code 6) on `/item-filter`: missing scope | +| `dev-token-expired` | 401 (code 8): expired | +| `dev-token-revoked` | 401 (code 8): revoked | +| `dev-token-other-profile` | `ProfileTwo`: `ProfileOne`'s filters are 404 | + +401 and 403 responses carry `WWW-Authenticate` +([RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750#section-3)). Another +profile's filter is a 404, not a 403, so its existence isn't revealed. + +## Rate limiting + +Follows the documented +[rate-limit headers](https://www.pathofexile.com/developer/docs/index#ratelimits), +with one policy (`api`) and one rule (`client`): 10 requests per 5 s per token, +then restricted for 10 s. Every authorised response reports the state; over the +limit it's `429 Too Many Requests` with `Retry-After` and error code 3. + +```http +X-Rate-Limit-Policy: api +X-Rate-Limit-Rules: client +X-Rate-Limit-Client: 10:5:10 # :: +X-Rate-Limit-Client-State: 1:5:0 # :: ``` +The limiter runs after authentication, so 401 and 403 carry no rate-limit +headers. If Redis is down, requests fail closed with a 500 (code 4). + ## How it works ```mermaid flowchart TD - C[Client] --> |request| N[Nginx :8000
or composer serve :8080] - N --> I[public/index.php] - I --> R[Router
route + method] - R --> Au[BearerAuthenticator
token + scope] - Au --> H[Handler] - H --> V[JsonBody + InputFilter
parse + validate] - V --> Rp[Repository] - Rp --> A[Adapter
entity → documented shape] - A --> J[JsonResponseFactory] - J --> |response| C - - Au -.->|token lookup| D[(PostgreSQL)] - Rp -.->|read / write| D +C[Client] -->|request| N[Nginx :8000
or composer serve :8080] +N --> I[public/index.php] +I --> R[Router
route + method] +R --> Au[BearerAuthenticator
token + scope] +Au --> RL[RateLimiter
per token] +RL --> H[Handler] +H --> V[JsonBody + InputFilter
parse + validate] +V --> Rp[Repository] +Rp --> A[Adapter
entity → documented shape] +A --> J[JsonResponseFactory] +J -->|response| C + +Au -.->|token lookup| D[(PostgreSQL)] +Rp -.->|read / write| D +RL -.->|count hits| RD[(Redis)] + +R -. "404 no route · 405 method" .-> J +Au -. "401 token · 403 scope" .-> J +RL -. "429 over limit" .-> J +V -. "400 JSON / content type · 422 fields" .-> J +Rp -. "404 not found or not owned" .-> J ``` +Dotted exits are the request-level errors. Server-side failures (500, 501) and +every response per endpoint are in the [API docs](#api-docs). + ## Build and run -Requires Docker and Composer. PHP 8.5 with `pdo_pgsql` is needed locally for +Requires Docker and Composer, plus PHP 8.5 with `pdo_pgsql` locally for the Composer scripts, tests and `composer serve`. ```bash git clone https://github.com/blakesimpson-dev/laminas-api-sample.git cd laminas-api-sample composer install -cp .env.example .env # local dev defaults, adjust if needed -composer reset # fresh Postgres + migrations -composer fixtures:load # seeding -``` - -### Run via Nginx (prod style) +cp .env.example .env # local dev defaults +composer reset # fresh Postgres + migrations +composer fixtures:load # seed data and dev tokens +composer up # Postgres, Redis, php-fpm, Nginx, Swagger UI -```bash -composer up # Postgres, php-fpm, Nginx on :8000 export AUTH='Authorization: Bearer dev-token-full' -curl -H "$AUTH" localhost:8000/profile # get account profile -curl -H "$AUTH" localhost:8000/item-filter # list item filters -curl -H "$AUTH" localhost:8000/item-filter/ # get one item filter -``` - -Nginx serves `public/`, passes `index.php` to php-fpm over FastCGI and returns -404 for any other `.php` path. JSON responses are gzipped and server and PHP -versions are stripped from headers. - -### Run via built-in server - -```bash -composer serve # http://localhost:8080 -curl -H "$AUTH" localhost:8080/profile +curl -H "$AUTH" localhost:8000/profile +curl -H "$AUTH" localhost:8000/item-filter +curl -H "$AUTH" localhost:8000/item-filter/ ``` -Both can run at the same time against the same database. +- **Nginx (:8000)** serves `public/`, passes only `index.php` to php-fpm (any + other `.php` path is a 404), gzips JSON and hides server and PHP versions. +- **`composer serve` (:8080)** is the quick local alternative; both can run + against the same database. ### API docs -The API contract is a hand-written OpenAPI 3.1 spec, `docs/openapi.json`, served -through Nginx with Swagger UI: +`docs/openapi.json` is the contract, written by hand 😭 Open Swagger UI at +, click **Authorize**, paste a dev token, then **Try +it out**. It's same-origin with the API, so there's no CORS. -```bash -composer up # then open http://localhost:8000/docs -``` +- **Spec first:** a contract test fails if an adapter adds or drops a key the + spec doesn't match, in either direction. +- **Scopes:** OpenAPI can't attach scopes to bearer auth, so each operation + states its scope in its description and `x-required-scope`. +- **Lint:** + `docker run --rm -v "$PWD/docs:/spec" redocly/cli lint /spec/openapi.json` ## Scripts -| Script | Purpose | -| ------------------------------------------------- | ------------------------------------------------------- | -| `composer serve` | PHP built-in server on port 8080 | -| `composer migrations:diff` / `migrations:migrate` | Doctrine Migrations | -| `composer fixtures:load` | Purge and reseed | -| `composer reset` | Recreate Postgres and migrate | -| `composer test` | Run the PHPUnit unit tests | -| `composer up` | Build and start Postgres, php-fpm and Nginx (port 8000) | -| `composer down` | Stop the Docker services, volume is kept | -| `composer lint` / `analyze` | Mago linter and static analyzer (fail on warnings) | -| `composer format` / `format:check` | Mago formatter (PER-CS based) | +| Script | Purpose | +| --------------------------------------- | ------------------------------------------------ | +| `composer up` / `down` | Start (building images) / stop the Docker stack | +| `composer serve` | PHP built-in server on :8080 | +| `composer reset` | Recreate Postgres and run migrations | +| `composer fixtures:load` | Purge and reseed | +| `composer migrations:diff` / `:migrate` | Generate / apply Doctrine migrations | +| `composer test` | PHPUnit unit and contract tests | +| `composer lint` / `analyze` | Mago linter / static analyzer (fail on warnings) | +| `composer format` / `format:check` | Mago formatter (PER-CS based) | ## Layout -| Path | Purpose | -| -------------------- | ------------------------------------------------------------------------ | -| `config` | Routes, service manager, Doctrine | -| `src/Http` | Router, JSON body parsing, error responses | -| `src/Domains` | One folder per domain: entity, repository, adapter, handlers, validation | -| `src/Infrastructure` | Doctrine entity manager factory, system clock | -| `docker` | php-fpm image and Nginx site config | -| `migrations` | Reviewed Doctrine migrations | -| `test` | Unit tests, fixtures and fixture data | - -## Roadmap - -- Lightweight Vite + Vue 3 + TypeScript web client, with Pinia and SASS styling -- Per-token rate limiting in Redis, with the documented headers +| Path | Contents | +| -------------------- | ------------------------------------------------------------- | +| `config` | Routes, service manager, Doctrine | +| `src/Domains` | Per domain: entity, repository, adapter, handlers, validation | +| `src/Http` | Router, auth, rate limiting, JSON parsing, responses | +| `src/Infrastructure` | Doctrine, clock, Redis store, migration tooling | +| `docker` | php-fpm image, Nginx site config | +| `docs` | OpenAPI spec, README demo | +| `migrations` | Reviewed Doctrine migrations | +| `test` | Unit and contract tests, fixtures | ## Credits Item filter fixtures are [NeverSink's filters](https://github.com/NeverSinkDev/NeverSink-Filter) (MIT), -exported from FilterBlade. - -The application code is my own (MIT, see [LICENSE](LICENSE)). An LLM was used -for explanations and tooling configuration. - -This project isn't affiliated with or endorsed by Grinding Gear Games in any -way. +exported from FilterBlade. The application code is my own (MIT, see +[LICENSE](LICENSE)); an LLM was used for explanations and tooling configuration. +Not affiliated with or endorsed by Grinding Gear Games. ## References -- [Path of Exile developer docs](https://www.pathofexile.com/developer/docs) -- [Laminas](https://docs.laminas.dev/) -- [Doctrine ORM](https://www.doctrine-project.org/projects/orm.html), +- Path of Exile developer docs: + [API reference](https://www.pathofexile.com/developer/docs/reference), + [authorization](https://www.pathofexile.com/developer/docs/authorization), + [errors and rate limits](https://www.pathofexile.com/developer/docs/index#errors), + [item filter format](https://www.pathofexile.com/developer/docs/game#itemfilters) +- [Laminas](https://docs.laminas.dev/), + [Doctrine ORM](https://www.doctrine-project.org/projects/orm.html), [Migrations](https://www.doctrine-project.org/projects/migrations.html), [Data Fixtures](https://www.doctrine-project.org/projects/data-fixtures.html) - [OpenAPI 3.1](https://spec.openapis.org/oas/v3.1.0), diff --git a/bin/fixtures/load.php b/bin/fixtures/load.php index 5296c16..2eaac2c 100644 --- a/bin/fixtures/load.php +++ b/bin/fixtures/load.php @@ -2,43 +2,29 @@ declare(strict_types=1); -use Doctrine\Common\DataFixtures\Executor\ORMExecutor; -use Doctrine\Common\DataFixtures\Loader; -use Doctrine\Common\DataFixtures\Purger\ORMPurger; use Doctrine\ORM\EntityManager; use Laminas\ServiceManager\ServiceManager; use LaminasApiSample\Infrastructure\DoctrineEntityManagerFactory as DoctrineEMF; -use Psr\Container\ContainerExceptionInterface; - -try { - require dirname(__DIR__, 2) . '/bootstrap.php'; - load_fixtures(build_loader()); -} catch (Throwable $e) { - $detail = implode(' - ', [$e::class, $e->getMessage()]); - fwrite(STDERR, "Loading fixtures failed: \n{$detail}\n"); - exit(1); -} - -function build_loader(): Loader -{ - $loader = new Loader(); - $loader->loadFromDirectory(dirname(__DIR__, 2) . '/test/Fixtures'); - - return $loader; -} - -/** @throws ContainerExceptionInterface */ -function load_fixtures(Loader $loader): void -{ - /** @var ServiceManager $serviceManager */ - $serviceManager = require - dirname(__DIR__, 2) . '/config/service-manager.php'; - - /** @var EntityManager $entityManager */ - $entityManager = $serviceManager->get(DoctrineEMF::SERVICE_NAME); - - // ! Purge is destructive - be careful! - $executor = new ORMExecutor($entityManager, new ORMPurger()); - $executor->execute($loader->getFixtures()); - echo "Loading fixtures completed.\n"; -} +use LaminasApiSampleTest\Fixtures\FixtureLoader; + +(static function (): void { + try { + require dirname(__DIR__, 2) . '/bootstrap.php'; + + /** @var ServiceManager $serviceManager */ + $serviceManager = require + dirname(__DIR__, 2) . '/config/service-manager.php'; + + /** @var EntityManager $entityManager */ + $entityManager = $serviceManager->get(DoctrineEMF::SERVICE_NAME); + + new FixtureLoader($entityManager)->load( + dirname(__DIR__, 2) . '/test/Fixtures', + ); + echo "Loading fixtures completed.\n"; + } catch (Throwable $exception) { + $detail = implode(' - ', [$exception::class, $exception->getMessage()]); + fwrite(STDERR, "Loading fixtures failed:\n{$detail}\n"); + exit(1); + } +})(); diff --git a/bin/migrations/generate-diff.php b/bin/migrations/generate-diff.php index 344e75e..5d66d52 100644 --- a/bin/migrations/generate-diff.php +++ b/bin/migrations/generate-diff.php @@ -4,126 +4,25 @@ use Doctrine\Migrations\DependencyFactory; use Doctrine\Migrations\Generator\Exception\NoChangesDetected; +use LaminasApiSample\Infrastructure\Migrations\MigrationDiffGenerator; -try { - require dirname(__DIR__, 2) . '/bootstrap.php'; - generate_diff(); -} catch (NoChangesDetected) { - fwrite(STDOUT, "No changes detected.\n"); - exit(0); -} catch (Throwable $e) { - $detail = implode(' - ', [$e::class, $e->getMessage()]); - fwrite(STDERR, "Generate diff failed: \n{$detail}\n"); - exit(1); -} +(static function (): void { + try { + require dirname(__DIR__, 2) . '/bootstrap.php'; -/** - * @throws NoChangesDetected - * @throws RuntimeException - */ -function generate_diff(): void -{ - /** @var DependencyFactory $dependencyFactory */ - $dependencyFactory = require __DIR__ . '/config/dependency-factory.php'; + /** @var DependencyFactory $dependencyFactory */ + $dependencyFactory = require __DIR__ . '/config/dependency-factory.php'; - $migrationClassNamespace = array_key_first( - $dependencyFactory->getConfiguration()->getMigrationDirectories(), - ); - - if ($migrationClassNamespace === null) { - throw new RuntimeException('Migration namespace not found.'); - } - - $migrationClassName = $dependencyFactory - ->getClassNameGenerator() - ->generateClassName($migrationClassNamespace); - - $result = $dependencyFactory->getDiffGenerator()->generate( - $migrationClassName, - null, - ); - - tidy_migration($result); - format_migration($result); - echo "Generated migration: {$result}\n"; -} - -/** @throws RuntimeException */ -function tidy_migration(string $path): void -{ - $contents = file_get_contents($path); - if ($contents === false) { - throw new RuntimeException("Could not read {$path}."); - } - - $version = basename($path, '.php'); - $new_header = << $output */ - $output = []; - $exitCode = 0; - exec( - escapeshellarg($mago) . ' format ' . escapeshellarg($path) . ' 2>&1', - $output, - $exitCode, - ); - - if ($exitCode !== 0) { - fwrite( - STDERR, - "Mago format failed; {$path} left unformatted:\n" - . implode("\n", $output) - . "\n", + $generator = new MigrationDiffGenerator( + $dependencyFactory, + dirname(__DIR__, 2) . '/vendor/bin/mago', ); + echo 'Generated migration: ' . $generator->generate() . "\n"; + } catch (NoChangesDetected) { + echo "No changes detected.\n"; + } catch (Throwable $exception) { + $detail = implode(' - ', [$exception::class, $exception->getMessage()]); + fwrite(STDERR, "Generate diff failed:\n{$detail}\n"); + exit(1); } -} +})(); diff --git a/bin/migrations/migrate.php b/bin/migrations/migrate.php index 9d4f129..01a160f 100644 --- a/bin/migrations/migrate.php +++ b/bin/migrations/migrate.php @@ -4,51 +4,22 @@ use Doctrine\Migrations\DependencyFactory; use Doctrine\Migrations\Exception\NoMigrationsToExecute; -use Doctrine\Migrations\Metadata\MigrationPlanList; -use Doctrine\Migrations\MigratorConfiguration; - -try { - require dirname(__DIR__, 2) . '/bootstrap.php'; - migrate(); -} catch (NoMigrationsToExecute) { - fwrite(STDOUT, 'No migrations to execute.\n'); - exit(0); -} catch (Throwable $e) { - $detail = implode(' - ', [$e::class, $e->getMessage()]); - fwrite(STDERR, "Migration failed: \n{$detail}\n"); - exit(1); -} - -/** @throws NoMigrationsToExecute */ -function migrate(): void -{ - /** @var DependencyFactory $dependencyFactory */ - $dependencyFactory = require __DIR__ . '/config/dependency-factory.php'; - - $plan = build_plan($dependencyFactory); - $config = new MigratorConfiguration()->setAllOrNothing( - $dependencyFactory->getConfiguration()->isAllOrNothing(), - ); - - $dependencyFactory->getMigrator()->migrate($plan, $config); - echo "Migration completed.\n"; -} - -/** @throws NoMigrationsToExecute */ -function build_plan(DependencyFactory $dependencyFactory): MigrationPlanList -{ - $dependencyFactory->getMetadataStorage()->ensureInitialized(); - - $latestVersion = $dependencyFactory - ->getVersionAliasResolver() - ->resolveVersionAlias('latest'); - - $plan = $dependencyFactory - ->getMigrationPlanCalculator() - ->getPlanUntilVersion($latestVersion); - if (!count($plan)) { - throw NoMigrationsToExecute::new(); +use LaminasApiSample\Infrastructure\Migrations\MigrationRunner; + +(static function (): void { + try { + require dirname(__DIR__, 2) . '/bootstrap.php'; + + /** @var DependencyFactory $dependencyFactory */ + $dependencyFactory = require __DIR__ . '/config/dependency-factory.php'; + + new MigrationRunner($dependencyFactory)->run(); + echo "Migration completed.\n"; + } catch (NoMigrationsToExecute) { + echo "No migrations to execute.\n"; + } catch (Throwable $exception) { + $detail = implode(' - ', [$exception::class, $exception->getMessage()]); + fwrite(STDERR, "Migration failed:\n{$detail}\n"); + exit(1); } - - return $plan; -} +})(); diff --git a/composer.json b/composer.json index 0b3dbc9..ad85bef 100644 --- a/composer.json +++ b/composer.json @@ -25,6 +25,7 @@ "laminas/laminas-router": "^3.19", "laminas/laminas-servicemanager": "^3.24", "laminas/laminas-validator": "^2.65", + "predis/predis": "^3.6", "psr/clock": "^1.0", "ramsey/uuid": "^4.9", "roave/psr-container-doctrine": "^6.2", @@ -49,6 +50,7 @@ "lint": "mago lint --minimum-fail-level=warning", "format": "mago format", "format:check": "mago format --dry-run", + "lint:spec": "docker run --rm -v \"$PWD/docs:/spec\" redocly/cli lint /spec/openapi.json", "test": "phpunit", "serve": [ "Composer\\Config::disableProcessTimeout", @@ -67,6 +69,7 @@ "format:check": "Check formatting with Mago without writing changes.", "lint": "Run Mago's linter.", "test": "Run the PHPUnit unit tests.", + "lint:spec": "Run redocly/cli linter targeting the OpenApi 3.1 spec", "serve": "Start the built-in PHP web server and serve the API sample.", "migrations:diff": "Generate a new migration from differences between the entity mapping and the database schema.", "migrations:migrate": "Apply all pending migrations to the database.", diff --git a/composer.lock b/composer.lock index dd56f44..1832450 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b499a71da94733bc57cfb1b276a69281", + "content-hash": "2570c84361aeb192d7c3b73638d4e6b0", "packages": [ { "name": "brick/math", @@ -2019,6 +2019,69 @@ ], "time": "2026-08-24T00:54:40+00:00" }, + { + "name": "predis/predis", + "version": "v3.6.1", + "source": { + "type": "git", + "url": "https://github.com/predis/predis.git", + "reference": "e2db96312ecf57a01a3477bf192d1ebc0ad7e9fb" + }, + "dist": { + "type": "zip", + "url": "https://api.github.com/repos/predis/predis/zipball/e2db96312ecf57a01a3477bf192d1ebc0ad7e9fb", + "reference": "e2db96312ecf57a01a3477bf192d1ebc0ad7e9fb", + "shasum": "" + }, + "require": { + "php": "^7.2 || ^8.0", + "psr/http-message": "^1.0|^2.0" + }, + "require-dev": { + "friendsofphp/php-cs-fixer": "^3.3", + "phpstan/phpstan": "^1.9", + "phpunit/phpcov": "^6.0 || ^8.0", + "phpunit/phpunit": "^8.0 || ~9.4.4" + }, + "suggest": { + "ext-relay": "Faster connection with in-memory caching (>=0.6.2)" + }, + "type": "library", + "autoload": { + "psr-4": { + "Predis\\": "src/" + } + }, + "notification-url": "https://packagist.org/downloads/", + "license": [ + "MIT" + ], + "authors": [ + { + "name": "Till Krüss", + "homepage": "https://till.im", + "role": "Maintainer" + } + ], + "description": "A flexible and feature-complete Redis/Valkey client for PHP.", + "homepage": "http://github.com/predis/predis", + "keywords": [ + "nosql", + "predis", + "redis" + ], + "support": { + "issues": "https://github.com/predis/predis/issues", + "source": "https://github.com/predis/predis/tree/v3.6.1" + }, + "funding": [ + { + "url": "https://github.com/sponsors/tillkruss", + "type": "github" + } + ], + "time": "2026-09-17T20:41:00+00:00" + }, { "name": "psr/cache", "version": "3.0.0", diff --git a/config/doctrine.php b/config/doctrine.php index 43cf8b2..f4d9d43 100644 --- a/config/doctrine.php +++ b/config/doctrine.php @@ -15,7 +15,7 @@ 'port' => 5432, 'dbname' => $_ENV['DB_NAME'], 'user' => $_ENV['DB_USER'], - 'password' => $_ENV['DB_PASSWORD'], + 'password' => $_ENV['DB_PASSWORD'] ?? '', ], ], ], diff --git a/config/service-manager.php b/config/service-manager.php index 6fb92e1..dfe5875 100644 --- a/config/service-manager.php +++ b/config/service-manager.php @@ -7,6 +7,7 @@ use LaminasApiSample\Domains\Auth\AuthProvider; use LaminasApiSample\Domains\ItemFilter\ItemFilterProvider; use LaminasApiSample\Domains\Profile\ProfileProvider; +use LaminasApiSample\Http\RateLimiting\RateLimitProvider; use LaminasApiSample\Infrastructure\DoctrineEntityManagerFactory as DoctrineEMF; use LaminasApiSample\Infrastructure\SystemClock; use Psr\Clock\ClockInterface; @@ -14,22 +15,25 @@ /** @var array $doctrineConfig */ $doctrineConfig = require __DIR__ . '/doctrine.php'; -$auth = (new AuthProvider())(); -$itemFilter = (new ItemFilterProvider())(); -$profile = (new ProfileProvider())(); +$authProvider = (new AuthProvider())(); +$itemFilterProvider = (new ItemFilterProvider())(); +$profileProvider = (new ProfileProvider())(); +$rateLimitProvider = (new RateLimitProvider())(); return new ServiceManager([ 'factories' => [ - ...$auth['factories'], - ...$itemFilter['factories'], - ...$profile['factories'], + ...$authProvider['factories'], + ...$itemFilterProvider['factories'], + ...$profileProvider['factories'], + ...$rateLimitProvider['factories'], DoctrineEMF::SERVICE_NAME => DoctrineEMF::class, SystemClock::class => InvokableFactory::class, ], 'aliases' => [ - ...$auth['aliases'], - ...$itemFilter['aliases'], - ...$profile['aliases'], + ...$authProvider['aliases'], + ...$itemFilterProvider['aliases'], + ...$profileProvider['aliases'], + ...$rateLimitProvider['aliases'], ClockInterface::class => SystemClock::class, ], 'services' => [ diff --git a/docker-compose.yml b/docker-compose.yml index 852ffde..9f4473f 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,15 +15,15 @@ services: timeout: 5s retries: 5 - php: - build: docker/php - volumes: - - .:/app - environment: - DB_HOST: postgres - depends_on: - postgres: - condition: service_healthy + redis: + image: redis:8-alpine + ports: + - "6379:6379" + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 3s + retries: 5 nginx: image: nginx:1.29-alpine @@ -35,6 +35,19 @@ services: depends_on: - php + php: + build: docker/php + volumes: + - .:/app + environment: + DB_HOST: postgres + REDIS_HOST: redis + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + swagger-ui: image: swaggerapi/swagger-ui environment: diff --git a/docs/openapi.json b/docs/openapi.json index 7d0a76e..2c73514 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -9,6 +9,10 @@ }, "description": "Backend modelled on a slice of the Path of Exile public API." }, + "externalDocs": { + "description": "OpenAPI 3.1 specification", + "url": "https://swagger.io/specification/v3.2" + }, "servers": [ { "url": "/", @@ -320,6 +324,61 @@ } } } + }, + "TooManyRequests": { + "description": "Rate limit exceeded (code 3). Wait Retry-After seconds before retrying.", + "headers": { + "X-Rate-Limit-Policy": { + "$ref": "#/components/headers/X-Rate-Limit-Policy" + }, + "X-Rate-Limit-Rules": { + "$ref": "#/components/headers/X-Rate-Limit-Rules" + }, + "X-Rate-Limit-Client": { + "$ref": "#/components/headers/X-Rate-Limit-Client" + }, + "X-Rate-Limit-Client-State": { + "$ref": "#/components/headers/X-Rate-Limit-Client-State" + }, + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { "$ref": "#/components/schemas/Error" } + } + } + } + }, + "headers": { + "X-Rate-Limit-Policy": { + "description": "The rate-limit policy that applies to this request.", + "schema": { "type": "string", "example": "api" } + }, + "X-Rate-Limit-Rules": { + "description": "Comma-separated list of the rules that apply, e.g. client.", + "schema": { "type": "string", "example": "client" } + }, + "X-Rate-Limit-Client": { + "description": "The client rule, as ::.", + "schema": { + "type": "string", + "pattern": "^\\d+:\\d+:\\d+$", + "example": "10:5:10" + } + }, + "X-Rate-Limit-Client-State": { + "description": "Current state of the client rule, as ::.", + "schema": { + "type": "string", + "pattern": "^\\d+:\\d+:\\d+$", + "example": "1:5:0" + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying. Sent only on 429.", + "schema": { "type": "integer", "example": 10 } } } }, @@ -339,6 +398,20 @@ "$ref": "#/components/schemas/Profile" } } + }, + "headers": { + "X-Rate-Limit-Policy": { + "$ref": "#/components/headers/X-Rate-Limit-Policy" + }, + "X-Rate-Limit-Rules": { + "$ref": "#/components/headers/X-Rate-Limit-Rules" + }, + "X-Rate-Limit-Client": { + "$ref": "#/components/headers/X-Rate-Limit-Client" + }, + "X-Rate-Limit-Client-State": { + "$ref": "#/components/headers/X-Rate-Limit-Client-State" + } } }, "401": { @@ -347,6 +420,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, "500": { "$ref": "#/components/responses/ServerError" } @@ -378,6 +454,20 @@ } } } + }, + "headers": { + "X-Rate-Limit-Policy": { + "$ref": "#/components/headers/X-Rate-Limit-Policy" + }, + "X-Rate-Limit-Rules": { + "$ref": "#/components/headers/X-Rate-Limit-Rules" + }, + "X-Rate-Limit-Client": { + "$ref": "#/components/headers/X-Rate-Limit-Client" + }, + "X-Rate-Limit-Client-State": { + "$ref": "#/components/headers/X-Rate-Limit-Client-State" + } } }, "401": { @@ -386,6 +476,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, "500": { "$ref": "#/components/responses/ServerError" } @@ -422,6 +515,20 @@ } } } + }, + "headers": { + "X-Rate-Limit-Policy": { + "$ref": "#/components/headers/X-Rate-Limit-Policy" + }, + "X-Rate-Limit-Rules": { + "$ref": "#/components/headers/X-Rate-Limit-Rules" + }, + "X-Rate-Limit-Client": { + "$ref": "#/components/headers/X-Rate-Limit-Client" + }, + "X-Rate-Limit-Client-State": { + "$ref": "#/components/headers/X-Rate-Limit-Client-State" + } } }, "400": { @@ -433,6 +540,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, "422": { "$ref": "#/components/responses/Unprocessable" }, @@ -469,6 +579,20 @@ } } } + }, + "headers": { + "X-Rate-Limit-Policy": { + "$ref": "#/components/headers/X-Rate-Limit-Policy" + }, + "X-Rate-Limit-Rules": { + "$ref": "#/components/headers/X-Rate-Limit-Rules" + }, + "X-Rate-Limit-Client": { + "$ref": "#/components/headers/X-Rate-Limit-Client" + }, + "X-Rate-Limit-Client-State": { + "$ref": "#/components/headers/X-Rate-Limit-Client-State" + } } }, "401": { @@ -477,6 +601,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, "404": { "$ref": "#/components/responses/NotFound" }, @@ -516,6 +643,20 @@ } } } + }, + "headers": { + "X-Rate-Limit-Policy": { + "$ref": "#/components/headers/X-Rate-Limit-Policy" + }, + "X-Rate-Limit-Rules": { + "$ref": "#/components/headers/X-Rate-Limit-Rules" + }, + "X-Rate-Limit-Client": { + "$ref": "#/components/headers/X-Rate-Limit-Client" + }, + "X-Rate-Limit-Client-State": { + "$ref": "#/components/headers/X-Rate-Limit-Client-State" + } } }, "400": { @@ -527,6 +668,9 @@ "403": { "$ref": "#/components/responses/Forbidden" }, + "429": { + "$ref": "#/components/responses/TooManyRequests" + }, "404": { "$ref": "#/components/responses/NotFound" }, diff --git a/mago.toml b/mago.toml index 1879d39..738f585 100644 --- a/mago.toml +++ b/mago.toml @@ -1,6 +1,4 @@ #:schema https://mago.carthage.software/1.50.0/schema.json -# Welcome to Mago! -# For full documentation, see https://mago.carthage.software/1.50.0/en/tools/overview/ version = "1" php-version = "8.5.0" @@ -13,36 +11,119 @@ excludes = [] [source.glob] literal-separator = true -[formatter] -preset = "default" -print-width = 80 -indent-heredoc = false - [linter] -integrations = ["phpunit"] excludes = ["migrations/"] +integrations = ["phpunit"] +baseline-variant = "loose" +minimum-fail-level = "Error" [linter.rules] ambiguous-function-call = { enabled = false } literal-named-argument = { enabled = false } halstead = { effort-threshold = 7000 } +prefer-self-return-type = { enabled = true } +require-namespace = { enabled = true } +property-name = { enabled = true } +no-side-effects-with-declarations = { enabled = true } +no-inline = { enabled = true } +variable-name = { enabled = true } +validated-sanitized-input = { enabled = true } +prefer-test-attribute = { enabled = true } +prefer-early-return = { enabled = true } + +[linter.rules.missing-docs] +enabled = false +level = "Help" +functions = true +classes = false +interfaces = false +traits = false +enums = false +enum-cases = true +constants = true +statics = true +methods = true +properties = true + +[linter.rules.missing-docs.exclusions] +functions = [] +classes = [] +interfaces = [] +traits = [] +enums = [] +enum-cases = [] +constants = [] +statics = [] +methods = [] +properties = [] + +[parser] +enable-short-tags = true + +[formatter] +preset = "default" +print-width = 80 +indent-heredoc = false +expand-use-groups = false [analyzer] excludes = ["migrations/"] +ignore = [] +baseline-variant = "loose" +minimum-fail-level = "Error" +disable-default-plugins = false plugins = [] find-unused-definitions = true find-unused-expressions = true +find-overly-wide-return-types = false analyze-dead-code = true memoize-properties = true -allow-possibly-undefined-array-keys = true check-throws = true unchecked-exceptions = ["Error", "LogicException"] unchecked-exception-classes = [] -check-missing-override = true -find-unused-parameters = true strict-list-index-checks = true strict-array-index-existence = true allow-array-truthy-operand = true no-boolean-literal-comparison = true check-missing-type-hints = true +check-closure-missing-type-hints = false +check-arrow-function-missing-type-hints = false +allow-implicit-pipe-callable-types = false register-super-globals = true +check-missing-override = true +find-unused-parameters = true +trust-existence-checks = true +class-initializers = [] +check-property-initialization = false +check-use-statements = true +check-experimental = false +check-name-casing = false +enforce-class-finality = false +require-api-or-internal = false +allow-side-effects-in-conditions = true + +[analyzer.performance] +saturation-complexity-threshold = 8192 +disjunction-complexity-threshold = 4096 +negation-complexity-threshold = 4096 +consensus-limit-threshold = 256 +formula-size-threshold = 512 +string-combination-threshold = 128 +integer-combination-threshold = 128 +array-combination-threshold = 32 +loop-assignment-depth-threshold = 1 + +[guard] +excludes = [] +baseline-variant = "loose" +minimum-fail-level = "Error" +mode = "default" + +[guard.perimeter] +layers = {} +layering = [] +rules = [] +restrictions = [] + +[guard.structural] +rules = [] diff --git a/public/index.php b/public/index.php index e2d9e4b..ce934ff 100644 --- a/public/index.php +++ b/public/index.php @@ -2,59 +2,41 @@ declare(strict_types=1); -use Laminas\Http\Header\HeaderInterface; -use Laminas\Http\Header\MultipleHeaderInterface; +declare(strict_types=1); + use Laminas\Http\PhpEnvironment\Request as HttpRequest; -use Laminas\Http\PhpEnvironment\Response as HttpResponse; use Laminas\Router\Http\TreeRouteStack; use Laminas\ServiceManager\ServiceManager; use LaminasApiSample\Http\Auth\AuthenticatorInterface; use LaminasApiSample\Http\JsonResponseFactory; +use LaminasApiSample\Http\RateLimiting\RateLimiter; +use LaminasApiSample\Http\ResponseEmitter; use LaminasApiSample\Http\Router; -use Psr\Container\ContainerExceptionInterface; - -try { - require dirname(__DIR__) . '/bootstrap.php'; - handle_request(); -} catch (Throwable $e) { - $detail = implode(' - ', [$e::class, $e->getMessage()]); - error_log($detail); - send_response(JsonResponseFactory::serverError()); -} - -/** @throws ContainerExceptionInterface */ -function handle_request(): void -{ - /** @var TreeRouteStack $routeStack */ - $routeStack = require dirname(__DIR__) . '/config/route-stack.php'; - /** @var ServiceManager $serviceManager */ - $serviceManager = require dirname(__DIR__) . '/config/service-manager.php'; - /** @var AuthenticatorInterface $authenticator */ - $authenticator = $serviceManager->get(AuthenticatorInterface::class); - $router = new Router($routeStack, $serviceManager, $authenticator); - $response = $router->dispatch(new HttpRequest()); - - send_response($response); -} - -/** - * Note: PHP forces a 401 whenever a WWW-Authenticate header is sent... - * ... so $response->send(); is dishonest - * - * Here we reassert the status before sending the response content - - * otherwise 401 is returned for 'insufficient scope' instead of 403 - */ -function send_response(HttpResponse $response): void -{ - /** @var HeaderInterface $header */ - foreach ($response->getHeaders() as $header) { - header( - $header->toString(), - !$header instanceof MultipleHeaderInterface, +(static function (): void { + try { + require dirname(__DIR__) . '/bootstrap.php'; + + /** @var TreeRouteStack $routeStack */ + $routeStack = require dirname(__DIR__) . '/config/route-stack.php'; + /** @var ServiceManager $serviceManager */ + $serviceManager = require + dirname(__DIR__) . '/config/service-manager.php'; + /** @var AuthenticatorInterface $authenticator */ + $authenticator = $serviceManager->get(AuthenticatorInterface::class); + /** @var RateLimiter $rateLimiter */ + $rateLimiter = $serviceManager->get(RateLimiter::class); + + $router = new Router( + $routeStack, + $serviceManager, + $authenticator, + $rateLimiter, ); - } - http_response_code($response->getStatusCode()); - echo $response->getBody(); -} + ResponseEmitter::emit($router->dispatch(new HttpRequest())); + } catch (Throwable $exception) { + error_log($exception::class . ' - ' . $exception->getMessage()); + ResponseEmitter::emit(JsonResponseFactory::serverError()); + } +})(); diff --git a/src/Domains/Auth/Factories/AccessTokenRepositoryFactory.php b/src/Domains/Auth/AccessTokenRepositoryFactory.php similarity index 82% rename from src/Domains/Auth/Factories/AccessTokenRepositoryFactory.php rename to src/Domains/Auth/AccessTokenRepositoryFactory.php index 956cb7c..e29b775 100644 --- a/src/Domains/Auth/Factories/AccessTokenRepositoryFactory.php +++ b/src/Domains/Auth/AccessTokenRepositoryFactory.php @@ -2,12 +2,10 @@ declare(strict_types=1); -namespace LaminasApiSample\Domains\Auth\Factories; +namespace LaminasApiSample\Domains\Auth; use Doctrine\ORM\EntityManager; use Laminas\ServiceManager\Factory\FactoryInterface; -use LaminasApiSample\Domains\Auth\AccessTokenEntity; -use LaminasApiSample\Domains\Auth\AccessTokenRepository; use LaminasApiSample\Infrastructure\DoctrineEntityManagerFactory as DoctrineEMF; use Override; use Psr\Container\ContainerInterface; diff --git a/src/Domains/Auth/AuthProvider.php b/src/Domains/Auth/AuthProvider.php index 75c6f33..543f468 100644 --- a/src/Domains/Auth/AuthProvider.php +++ b/src/Domains/Auth/AuthProvider.php @@ -7,7 +7,7 @@ use Laminas\ServiceManager\Factory\FactoryInterface; use LaminasApiSample\Http\Auth\AuthenticatorInterface; use LaminasApiSample\Http\Auth\BearerAuthenticator; -use LaminasApiSample\Http\Auth\Factories\BearerAuthenticatorFactory; +use LaminasApiSample\Http\Auth\BearerAuthenticatorFactory; final class AuthProvider { @@ -22,7 +22,7 @@ public function __invoke(): array return [ 'factories' => [ AccessTokenRepository::class => - Factories\AccessTokenRepositoryFactory::class, + AccessTokenRepositoryFactory::class, BearerAuthenticator::class => BearerAuthenticatorFactory::class, ], 'aliases' => [ diff --git a/src/Domains/Profile/ProfileAdapter.php b/src/Domains/Profile/ProfileAdapter.php index 9db9b11..5e3f912 100644 --- a/src/Domains/Profile/ProfileAdapter.php +++ b/src/Domains/Profile/ProfileAdapter.php @@ -4,8 +4,6 @@ namespace LaminasApiSample\Domains\Profile; -use LaminasApiSample\Domains\Profile\Embedded\TwitchEmbeddable; - final class ProfileAdapter { /** diff --git a/src/Domains/Profile/ProfileEntity.php b/src/Domains/Profile/ProfileEntity.php index f8809fe..e427492 100644 --- a/src/Domains/Profile/ProfileEntity.php +++ b/src/Domains/Profile/ProfileEntity.php @@ -6,12 +6,11 @@ use DateTimeImmutable; use Doctrine\DBAL\Types\Types as DoctrineDBTypes; +use Doctrine\ORM\Mapping; use Doctrine\ORM\Mapping\Column; -use Doctrine\ORM\Mapping\Embedded; use Doctrine\ORM\Mapping\Entity; use Doctrine\ORM\Mapping\Id; use Doctrine\ORM\Mapping\Table; -use LaminasApiSample\Domains\Profile\Embedded\TwitchEmbeddable; use LaminasApiSample\Domains\TimestampedEntity; use Ramsey\Uuid\Uuid; @@ -28,7 +27,7 @@ final class ProfileEntity extends TimestampedEntity #[Column(nullable: true)] private readonly ?string $locale; - #[Embedded(class: TwitchEmbeddable::class)] + #[Mapping\Embedded(class: TwitchEmbeddable::class)] private readonly ?TwitchEmbeddable $twitch; public function __construct( diff --git a/src/Domains/Profile/ProfileProvider.php b/src/Domains/Profile/ProfileProvider.php index 56eb68f..3c27348 100644 --- a/src/Domains/Profile/ProfileProvider.php +++ b/src/Domains/Profile/ProfileProvider.php @@ -20,11 +20,10 @@ public function __invoke(): array return [ 'factories' => [ ProfileAdapter::class => InvokableFactory::class, - Handlers\ProfileReadHandler::class => - Factories\ProfileReadHandlerFactory::class, + ProfileReadHandler::class => ProfileReadHandlerFactory::class, ], 'aliases' => [ - 'profile.read' => Handlers\ProfileReadHandler::class, + 'profile.read' => ProfileReadHandler::class, ], ]; } diff --git a/src/Domains/Profile/Handlers/ProfileReadHandler.php b/src/Domains/Profile/ProfileReadHandler.php similarity index 87% rename from src/Domains/Profile/Handlers/ProfileReadHandler.php rename to src/Domains/Profile/ProfileReadHandler.php index 80e6720..ee89f4a 100644 --- a/src/Domains/Profile/Handlers/ProfileReadHandler.php +++ b/src/Domains/Profile/ProfileReadHandler.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace LaminasApiSample\Domains\Profile\Handlers; +namespace LaminasApiSample\Domains\Profile; use Laminas\Http\PhpEnvironment\Request as HttpRequest; use Laminas\Http\PhpEnvironment\Response as HttpResponse; -use LaminasApiSample\Domains\Profile\ProfileAdapter; use LaminasApiSample\Http\Auth\AuthContext; use LaminasApiSample\Http\HandlerInterface; use LaminasApiSample\Http\JsonResponseFactory; diff --git a/src/Domains/Profile/Factories/ProfileReadHandlerFactory.php b/src/Domains/Profile/ProfileReadHandlerFactory.php similarity index 75% rename from src/Domains/Profile/Factories/ProfileReadHandlerFactory.php rename to src/Domains/Profile/ProfileReadHandlerFactory.php index f6684e5..757d4f0 100644 --- a/src/Domains/Profile/Factories/ProfileReadHandlerFactory.php +++ b/src/Domains/Profile/ProfileReadHandlerFactory.php @@ -2,11 +2,9 @@ declare(strict_types=1); -namespace LaminasApiSample\Domains\Profile\Factories; +namespace LaminasApiSample\Domains\Profile; use Laminas\ServiceManager\Factory\FactoryInterface; -use LaminasApiSample\Domains\Profile\Handlers\ProfileReadHandler; -use LaminasApiSample\Domains\Profile\ProfileAdapter; use Override; use Psr\Container\ContainerInterface; diff --git a/src/Domains/Profile/Embedded/TwitchEmbeddable.php b/src/Domains/Profile/TwitchEmbeddable.php similarity index 86% rename from src/Domains/Profile/Embedded/TwitchEmbeddable.php rename to src/Domains/Profile/TwitchEmbeddable.php index eba873e..8d79ca0 100644 --- a/src/Domains/Profile/Embedded/TwitchEmbeddable.php +++ b/src/Domains/Profile/TwitchEmbeddable.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace LaminasApiSample\Domains\Profile\Embedded; +namespace LaminasApiSample\Domains\Profile; use Doctrine\ORM\Mapping\Column; use Doctrine\ORM\Mapping\Embeddable; diff --git a/src/Http/Auth/Factories/BearerAuthenticatorFactory.php b/src/Http/Auth/BearerAuthenticatorFactory.php similarity index 88% rename from src/Http/Auth/Factories/BearerAuthenticatorFactory.php rename to src/Http/Auth/BearerAuthenticatorFactory.php index 27b5cfb..f034c3b 100644 --- a/src/Http/Auth/Factories/BearerAuthenticatorFactory.php +++ b/src/Http/Auth/BearerAuthenticatorFactory.php @@ -2,11 +2,10 @@ declare(strict_types=1); -namespace LaminasApiSample\Http\Auth\Factories; +namespace LaminasApiSample\Http\Auth; use Laminas\ServiceManager\Factory\FactoryInterface; use LaminasApiSample\Domains\Auth\AccessTokenLookupInterface; -use LaminasApiSample\Http\Auth\BearerAuthenticator; use Override; use Psr\Clock\ClockInterface; use Psr\Container\ContainerInterface; diff --git a/src/Http/JsonBody.php b/src/Http/JsonBody.php index eb4684b..a85faf4 100644 --- a/src/Http/JsonBody.php +++ b/src/Http/JsonBody.php @@ -32,8 +32,11 @@ public static function parse(HttpRequest $request): array try { // @mago-expect analysis:mixed-assignment $data = json_decode($content, true, flags: JSON_THROW_ON_ERROR); - } catch (JsonException $e) { - throw new MalformedJsonException($e->getMessage(), previous: $e); + } catch (JsonException $exception) { + throw new MalformedJsonException( + $exception->getMessage(), + previous: $exception, + ); } if (!is_array($data) || !str_starts_with(ltrim($content), '{')) { diff --git a/src/Http/JsonResponseFactory.php b/src/Http/JsonResponseFactory.php index d0ffe00..27d0613 100644 --- a/src/Http/JsonResponseFactory.php +++ b/src/Http/JsonResponseFactory.php @@ -5,6 +5,8 @@ namespace LaminasApiSample\Http; use Laminas\Http\PhpEnvironment\Response as HttpResponse; +use LaminasApiSample\Http\RateLimiting\RateLimitHeaders; +use LaminasApiSample\Http\RateLimiting\RateLimitResult; // @mago-expect lint:too-many-methods final class JsonResponseFactory @@ -82,6 +84,14 @@ public static function unprocessable(string $message): HttpResponse return self::error(status: 422, code: 10, message: $message); } + public static function rateLimited(RateLimitResult $result): HttpResponse + { + return RateLimitHeaders::apply( + self::error(status: 429, code: 3, message: 'Rate limit exceeded'), + $result, + ); + } + public static function serverError(): HttpResponse { return self::error(status: 500, code: 4, message: 'Internal error'); diff --git a/src/Http/RateLimiting/CounterStoreInterface.php b/src/Http/RateLimiting/CounterStoreInterface.php new file mode 100644 index 0000000..fa56012 --- /dev/null +++ b/src/Http/RateLimiting/CounterStoreInterface.php @@ -0,0 +1,16 @@ +get(CounterStoreInterface::class); + + return new RateLimiter( + store: $store, + policy: 'api', + rule: new RateLimitRule('client', 10, 5, 10), + ); + } +} diff --git a/src/Http/RateLimiting/Factories/RedisCounterStoreFactory.php b/src/Http/RateLimiting/Factories/RedisCounterStoreFactory.php new file mode 100644 index 0000000..b6c0eae --- /dev/null +++ b/src/Http/RateLimiting/Factories/RedisCounterStoreFactory.php @@ -0,0 +1,26 @@ + $_ENV['REDIS_HOST'] ?? 'localhost', + 'port' => 6379, + ])); + } +} diff --git a/src/Http/RateLimiting/InMemoryCounterStore.php b/src/Http/RateLimiting/InMemoryCounterStore.php new file mode 100644 index 0000000..4b1aa4e --- /dev/null +++ b/src/Http/RateLimiting/InMemoryCounterStore.php @@ -0,0 +1,66 @@ + $counters + */ + private array $counters = []; + + /** @var array */ + private array $restrictions = []; + + public function __construct( + private readonly ClockInterface $clock, + ) {} + + #[Override] + public function hit(string $key, int $periodSeconds): int + { + $now = $this->clock->now()->getTimestamp(); + $counter = $this->counters[$key] ?? null; + if ($counter === null || $counter['expiresAt'] <= $now) { + $counter = ['count' => 0, 'expiresAt' => $now + $periodSeconds]; + } + + $counter['count']++; + $this->counters[$key] = $counter; + + return $counter['count']; + } + + #[Override] + public function current(string $key): int + { + $counter = $this->counters[$key] ?? null; + $now = $this->clock->now()->getTimestamp(); + + return $counter === null || $counter['expiresAt'] <= $now + ? 0 + : $counter['count']; + } + + #[Override] + public function restrict(string $key, int $seconds): void + { + $this->restrictions[$key] = + $this->clock->now()->getTimestamp() + $seconds; + } + + #[Override] + public function restrictedFor(string $key): int + { + $until = $this->restrictions[$key] ?? 0; + return max(0, $until - $this->clock->now()->getTimestamp()); + } +} diff --git a/src/Http/RateLimiting/RateLimitHeaders.php b/src/Http/RateLimiting/RateLimitHeaders.php new file mode 100644 index 0000000..f5e8839 --- /dev/null +++ b/src/Http/RateLimiting/RateLimitHeaders.php @@ -0,0 +1,37 @@ +rule->name); + $headers = $response->getHeaders(); + $headers->addHeaderLine('X-Rate-Limit-Policy', $result->policy); + $headers->addHeaderLine('X-Rate-Limit-Rules', $result->rule->name); + $headers->addHeaderLine( + "X-Rate-Limit-{$rule}", + $result->rule->header(), + ); + $headers->addHeaderLine( + "X-Rate-Limit-{$rule}-State", + $result->stateHeader(), + ); + + if ($result->isLimited()) { + $headers->addHeaderLine( + 'Retry-After', + (string) $result->restrictedFor, + ); + } + + return $response; + } +} diff --git a/src/Http/RateLimiting/RateLimitProvider.php b/src/Http/RateLimiting/RateLimitProvider.php new file mode 100644 index 0000000..ff22216 --- /dev/null +++ b/src/Http/RateLimiting/RateLimitProvider.php @@ -0,0 +1,31 @@ +>, + * aliases: array, + * } + */ + public function __invoke(): array + { + return [ + 'factories' => [ + RateLimiter::class => Factories\RateLimiterFactory::class, + RedisCounterStore::class => + Factories\RedisCounterStoreFactory::class, + ], + 'aliases' => [ + CounterStoreInterface::class => RedisCounterStore::class, + ], + ]; + } +} diff --git a/src/Http/RateLimiting/RateLimitResult.php b/src/Http/RateLimiting/RateLimitResult.php new file mode 100644 index 0000000..352331f --- /dev/null +++ b/src/Http/RateLimiting/RateLimitResult.php @@ -0,0 +1,29 @@ +restrictedFor > 0; + } + + public function stateHeader(): string + { + return ( + "{$this->hits}:" + . "{$this->rule->periodSeconds}:" + . "{$this->restrictedFor}" + ); + } +} diff --git a/src/Http/RateLimiting/RateLimitRule.php b/src/Http/RateLimiting/RateLimitRule.php new file mode 100644 index 0000000..a6da1c0 --- /dev/null +++ b/src/Http/RateLimiting/RateLimitRule.php @@ -0,0 +1,24 @@ +maxHits}:" + . "{$this->periodSeconds}:" + . "{$this->restrictSeconds}" + ); + } +} diff --git a/src/Http/RateLimiting/RateLimiter.php b/src/Http/RateLimiting/RateLimiter.php new file mode 100644 index 0000000..b39e26d --- /dev/null +++ b/src/Http/RateLimiting/RateLimiter.php @@ -0,0 +1,43 @@ +policy}:{$this->rule->name}:{$clientKey}"; + + $restrictedFor = $this->store->restrictedFor($key); + if ($restrictedFor > 0) { + return $this->result($this->store->current($key), $restrictedFor); + } + + $hits = $this->store->hit($key, $this->rule->periodSeconds); + if ($hits > $this->rule->maxHits) { + $this->store->restrict($key, $this->rule->restrictSeconds); + + return $this->result($hits, $this->rule->restrictSeconds); + } + + return $this->result($hits, 0); + } + + private function result(int $hits, int $restrictedFor): RateLimitResult + { + return new RateLimitResult( + $this->policy, + $this->rule, + $hits, + $restrictedFor, + ); + } +} diff --git a/src/Http/ResponseEmitter.php b/src/Http/ResponseEmitter.php new file mode 100644 index 0000000..2ba9e47 --- /dev/null +++ b/src/Http/ResponseEmitter.php @@ -0,0 +1,34 @@ +send() PHP forces 401 whenever a + * WWW-Authenticate header is sent... and a status header() blocks using + * http_response_code() + * + * So headers go first, then the real status, then the body + */ + +final class ResponseEmitter +{ + public static function emit(HttpResponse $response): void + { + /** @var HeaderInterface $header */ + foreach ($response->getHeaders() as $header) { + header( + $header->toString(), + !$header instanceof MultipleHeaderInterface, + ); + } + + http_response_code($response->getStatusCode()); + echo $response->getBody(); + } +} diff --git a/src/Http/Router.php b/src/Http/Router.php index 11abbf6..c9f95d5 100644 --- a/src/Http/Router.php +++ b/src/Http/Router.php @@ -7,8 +7,13 @@ use Laminas\Http\PhpEnvironment\Request as HttpRequest; use Laminas\Http\PhpEnvironment\Response as HttpResponse; use Laminas\Router\Http\TreeRouteStack; +use Laminas\Router\RouteMatch; use Laminas\ServiceManager\Exception\ServiceNotFoundException; use Laminas\ServiceManager\ServiceManager; +use LaminasApiSample\Domains\Auth\AccessTokenEntity; +use LaminasApiSample\Http\Auth\AuthContext; +use LaminasApiSample\Http\RateLimiting\RateLimiter; +use LaminasApiSample\Http\RateLimiting\RateLimitHeaders; use Psr\Container\ContainerExceptionInterface; final class Router @@ -17,41 +22,31 @@ public function __construct( private TreeRouteStack $routeStack, private ServiceManager $serviceManager, private Auth\AuthenticatorInterface $authenticator, + private RateLimiter $rateLimiter, ) {} /** @throws ContainerExceptionInterface */ public function dispatch(HttpRequest $request): HttpResponse { - $routeMatch = $this->routeStack->match($request); - if ($routeMatch === null) { + $route = $this->routeStack->match($request); + if ($route === null) { return JsonResponseFactory::notFound(); } - $allParams = $routeMatch->getParams(); - - /** @var array $handlers */ - $handlers = $allParams['handlers'] ?? []; - - $serviceName = $handlers[$request->getMethod()] ?? null; + $serviceName = $this->handlerName($route, $request); if ($serviceName === null) { - return JsonResponseFactory::methodNotAllowed(array_keys($handlers)); + return JsonResponseFactory::methodNotAllowed($this->allowedMethods( + $route, + )); } - /** @var array $params */ - $params = array_diff_key($allParams, [ - 'handlers' => true, - 'scope' => true, - ]); - - /** @var null|string $scope */ - $scope = $allParams['scope'] ?? null; - if (!is_string($scope)) { + $scope = $this->requiredScope($route); + if ($scope === null) { return JsonResponseFactory::serverError(); } - try { - $token = $this->authenticator->authenticate($request); - } catch (Auth\AuthenticationFailedException) { + $token = $this->authenticate($request); + if ($token === null) { return JsonResponseFactory::unauthorized(); } @@ -59,19 +54,87 @@ public function dispatch(HttpRequest $request): HttpResponse return JsonResponseFactory::forbidden($scope); } - try { - /** @var HandlerInterface $handler */ - $handler = $this->serviceManager->get($serviceName); - } catch (ServiceNotFoundException $e) { - $detail = implode(' - ', [$e::class, $e->getMessage()]); - error_log("Error: \n{$detail}\n"); + $limit = $this->rateLimiter->check('client:' . $token->getId()); + if ($limit->isLimited()) { + return JsonResponseFactory::rateLimited($limit); + } + + $handler = $this->loadHandler($serviceName); + if ($handler === null) { return JsonResponseFactory::notImplemented(); } - return $handler( + $response = $handler( $request, - $params, - new Auth\AuthContext($token->getProfile(), $token->getScopes()), + $this->routeParams($route), + $this->authContext($token), ); + + return RateLimitHeaders::apply($response, $limit); + } + + private function handlerName( + RouteMatch $route, + HttpRequest $request, + ): ?string { + /** @var array $handlers */ + $handlers = $route->getParam('handlers', []); + return $handlers[$request->getMethod()] ?? null; + } + + /** @return list */ + private function allowedMethods(RouteMatch $route): array + { + /** @var array $handlers */ + $handlers = $route->getParam('handlers', []); + return array_keys($handlers); + } + + private function requiredScope(RouteMatch $route): ?string + { + // @mago-expect analysis:mixed-assignment + $scope = $route->getParam('scope'); + return is_string($scope) ? $scope : null; + } + + private function authenticate(HttpRequest $request): ?AccessTokenEntity + { + try { + return $this->authenticator->authenticate($request); + } catch (Auth\AuthenticationFailedException) { + return null; + } + } + + /** @throws ContainerExceptionInterface */ + private function loadHandler(string $serviceName): ?HandlerInterface + { + try { + /** @var HandlerInterface */ + return $this->serviceManager->get($serviceName); + } catch (ServiceNotFoundException $exception) { + $detail = implode(' - ', [ + $exception::class, + $exception->getMessage(), + ]); + error_log("Error: \n{$detail}\n"); + return null; + } + } + + /** @return array */ + private function routeParams(RouteMatch $route): array + { + /** @var array */ + return array_diff_key($route->getParams(), [ + 'handlers' => true, + 'scope' => true, + ]); + } + + // @mago-expect lint:sensitive-parameter + private function authContext(AccessTokenEntity $token): AuthContext + { + return new AuthContext($token->getProfile(), $token->getScopes()); } } diff --git a/src/Infrastructure/Migrations/MigrationDiffGenerator.php b/src/Infrastructure/Migrations/MigrationDiffGenerator.php new file mode 100644 index 0000000..7bb07a9 --- /dev/null +++ b/src/Infrastructure/Migrations/MigrationDiffGenerator.php @@ -0,0 +1,129 @@ +dependencyFactory + ->getConfiguration() + ->getMigrationDirectories(), + ); + if ($namespace === null) { + throw new RuntimeException('Migration namespace not found.'); + } + + $className = $this->dependencyFactory + ->getClassNameGenerator() + ->generateClassName($namespace); + + $path = $this->dependencyFactory->getDiffGenerator()->generate( + $className, + null, + ); + + $this->tidy($path); + $this->format($path); + + return $path; + } + + /** @throws RuntimeException */ + private function tidy(string $path): void + { + $contents = file_get_contents($path); + if ($contents === false) { + throw new RuntimeException("Could not read {$path}."); + } + + $version = basename($path, '.php'); + $new_header = <<magoPath)) { + fwrite(STDERR, "Mago not found; {$path} left unformatted.\n"); + return; + } + + /** @var list $output */ + $output = []; + $exitCode = 0; + exec( + escapeshellarg($this->magoPath) + . ' format ' + . escapeshellarg($path) + . ' 2>&1', + $output, + $exitCode, + ); + + if ($exitCode !== 0) { + fwrite( + STDERR, + "Mago format failed; {$path} left unformatted:\n" + . implode("\n", $output) + . "\n", + ); + } + } +} diff --git a/src/Infrastructure/Migrations/MigrationRunner.php b/src/Infrastructure/Migrations/MigrationRunner.php new file mode 100644 index 0000000..c288dba --- /dev/null +++ b/src/Infrastructure/Migrations/MigrationRunner.php @@ -0,0 +1,50 @@ +setAllOrNothing( + $this->dependencyFactory->getConfiguration()->isAllOrNothing(), + ); + + $this->dependencyFactory->getMigrator()->migrate( + $this->plan(), + $config, + ); + } + + /** @throws NoMigrationsToExecute */ + private function plan(): MigrationPlanList + { + $this->dependencyFactory->getMetadataStorage()->ensureInitialized(); + + $latest = $this->dependencyFactory + ->getVersionAliasResolver() + ->resolveVersionAlias('latest'); + + $plan = $this->dependencyFactory + ->getMigrationPlanCalculator() + ->getPlanUntilVersion($latest); + + if (count($plan) === 0) { + throw NoMigrationsToExecute::new(); + } + + return $plan; + } +} diff --git a/src/Infrastructure/RedisCounterStore.php b/src/Infrastructure/RedisCounterStore.php new file mode 100644 index 0000000..956d4b4 --- /dev/null +++ b/src/Infrastructure/RedisCounterStore.php @@ -0,0 +1,53 @@ +redis->transaction(static function (MultiExec $tx) use ( + $key, + $periodSeconds, + ): void { + $tx->incr($key); + $tx->expire($key, $periodSeconds, 'NX'); + }); + + return (int) $replies[0]; + } + + #[Override] + public function current(string $key): int + { + $value = $this->redis->get($key); + + return $value === null ? 0 : (int) $value; + } + + #[Override] + public function restrict(string $key, int $seconds): void + { + $this->redis->set("{$key}:restricted", '1', 'EX', $seconds); + } + + #[Override] + public function restrictedFor(string $key): int + { + // Note: TTL returns -2 for a missing key and -1 for no expiry + return max(0, (int) $this->redis->ttl("{$key}:restricted")); + } +} diff --git a/test/Fixtures/FixtureLoader.php b/test/Fixtures/FixtureLoader.php new file mode 100644 index 0000000..b4344ae --- /dev/null +++ b/test/Fixtures/FixtureLoader.php @@ -0,0 +1,27 @@ +loadFromDirectory($directory); + + new ORMExecutor($this->entityManager, new ORMPurger())->execute( + $loader->getFixtures(), + ); + } +} diff --git a/test/Fixtures/ProfileFixtures.php b/test/Fixtures/ProfileFixtures.php index cb3e842..ec7e34b 100644 --- a/test/Fixtures/ProfileFixtures.php +++ b/test/Fixtures/ProfileFixtures.php @@ -7,8 +7,8 @@ use DateTimeImmutable; use Doctrine\Common\DataFixtures\AbstractFixture; use Doctrine\Persistence\ObjectManager; -use LaminasApiSample\Domains\Profile\Embedded\TwitchEmbeddable; use LaminasApiSample\Domains\Profile\ProfileEntity; +use LaminasApiSample\Domains\Profile\TwitchEmbeddable; use Override; final class ProfileFixtures extends AbstractFixture diff --git a/test/Support/GetHeaderValue.php b/test/Support/GetHeaderValue.php new file mode 100644 index 0000000..b3e0240 --- /dev/null +++ b/test/Support/GetHeaderValue.php @@ -0,0 +1,34 @@ +getHeaders()->get($name); + if ($header instanceof ArrayIterator) { + /** @var HeaderInterface|null $current */ + $current = $header->current(); + $header = $current; + } + + if (!$header instanceof HeaderInterface) { + static::fail("Missing {$name} header"); + } + + // Laminas' RetryAfter returns an int despite HeaderInterface declaring + // string... :) + // @mago-expect analysis:redundant-cast + return (string) $header->getFieldValue(); + } +} diff --git a/test/Support/MutableClock.php b/test/Support/MutableClock.php new file mode 100644 index 0000000..2d170a7 --- /dev/null +++ b/test/Support/MutableClock.php @@ -0,0 +1,27 @@ +now; + } + + public function advance(int $seconds): void + { + $this->now = $this->now->modify("+{$seconds} seconds"); + } +} diff --git a/test/Unit/Http/MockAuthenticator.php b/test/Support/StubAuthenticator.php similarity index 86% rename from test/Unit/Http/MockAuthenticator.php rename to test/Support/StubAuthenticator.php index f17c9aa..efac971 100644 --- a/test/Unit/Http/MockAuthenticator.php +++ b/test/Support/StubAuthenticator.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace LaminasApiSampleTest\Unit\Http; +namespace LaminasApiSampleTest\Support; use Laminas\Http\PhpEnvironment\Request as HttpRequest; use LaminasApiSample\Domains\Auth\AccessTokenEntity; @@ -10,7 +10,7 @@ use LaminasApiSample\Http\Auth\AuthenticatorInterface; use Override; -final class MockAuthenticator implements AuthenticatorInterface +final class StubAuthenticator implements AuthenticatorInterface { public function __construct( // @mago-expect lint:sensitive-parameter diff --git a/test/Unit/Http/MockHandler.php b/test/Support/StubHandler.php similarity index 88% rename from test/Unit/Http/MockHandler.php rename to test/Support/StubHandler.php index 30d6c28..fb23274 100644 --- a/test/Unit/Http/MockHandler.php +++ b/test/Support/StubHandler.php @@ -2,7 +2,7 @@ declare(strict_types=1); -namespace LaminasApiSampleTest\Unit\Http; +namespace LaminasApiSampleTest\Support; use Laminas\Http\PhpEnvironment\Request as HttpRequest; use Laminas\Http\PhpEnvironment\Response as HttpResponse; @@ -11,7 +11,7 @@ use LaminasApiSample\Http\JsonResponseFactory; use Override; -final class MockHandler implements HandlerInterface +final class StubHandler implements HandlerInterface { /** @var array|null */ public ?array $receivedParams = null; diff --git a/test/Unit/Contract/OpenApiContractTest.php b/test/Unit/Contract/OpenApiContractTest.php index 403ecec..2a474a7 100644 --- a/test/Unit/Contract/OpenApiContractTest.php +++ b/test/Unit/Contract/OpenApiContractTest.php @@ -7,9 +7,9 @@ use JsonException; use LaminasApiSample\Domains\ItemFilter\ItemFilterAdapter; use LaminasApiSample\Domains\ItemFilter\ItemFilterEntity; -use LaminasApiSample\Domains\Profile\Embedded\TwitchEmbeddable; use LaminasApiSample\Domains\Profile\ProfileAdapter; use LaminasApiSample\Domains\Profile\ProfileEntity; +use LaminasApiSample\Domains\Profile\TwitchEmbeddable; use LaminasApiSampleTest\Support\FixedTime; use LaminasApiSampleTest\Support\TestProfile; use PHPUnit\Framework\Attributes\CoversClass; diff --git a/test/Unit/Domains/Auth/AccessTokenEntityTest.php b/test/Unit/Domains/Auth/AccessTokenEntityTest.php index 65f2e6f..e0f2357 100644 --- a/test/Unit/Domains/Auth/AccessTokenEntityTest.php +++ b/test/Unit/Domains/Auth/AccessTokenEntityTest.php @@ -89,7 +89,7 @@ public function hashIsDeterministicAndDistinct(): void ); } - private static function buildStubToken( + private static function stubToken( ?DateTimeImmutable $expiresAt = null, ?DateTimeImmutable $revokedAt = null, ): AccessTokenEntity { @@ -107,7 +107,7 @@ private static function buildStubToken( #[Test] public function isActiveBeforeExpiresAt(): void { - $entity = self::buildStubToken(); + $entity = self::stubToken(); static::assertTrue($entity->isActive(FixedTime::ofEvent())); } @@ -115,7 +115,7 @@ public function isActiveBeforeExpiresAt(): void #[Test] public function isNotActiveAtExpiresAt(): void { - $entity = self::buildStubToken(expiresAt: FixedTime::ofEvent()); + $entity = self::stubToken(expiresAt: FixedTime::ofEvent()); static::assertFalse($entity->isActive(FixedTime::ofEvent())); } @@ -123,7 +123,7 @@ public function isNotActiveAtExpiresAt(): void #[Test] public function isNotActiveAfterExpiresAt(): void { - $entity = self::buildStubToken(expiresAt: FixedTime::ofEvent()); + $entity = self::stubToken(expiresAt: FixedTime::ofEvent()); static::assertFalse($entity->isActive(FixedTime::now())); } @@ -131,7 +131,7 @@ public function isNotActiveAfterExpiresAt(): void #[Test] public function isNotActiveAfterRevokedAt(): void { - $entity = self::buildStubToken(revokedAt: FixedTime::ofEvent()); + $entity = self::stubToken(revokedAt: FixedTime::ofEvent()); static::assertFalse($entity->isActive(FixedTime::now())); } @@ -139,7 +139,7 @@ public function isNotActiveAfterRevokedAt(): void #[Test] public function canRevokeToken(): void { - $entity = self::buildStubToken(); + $entity = self::stubToken(); $entity->revoke(FixedTime::ofEvent()); static::assertFalse($entity->isActive(FixedTime::now())); } diff --git a/test/Unit/Domains/Profile/ProfileAdapterTest.php b/test/Unit/Domains/Profile/ProfileAdapterTest.php index a325119..f703b70 100644 --- a/test/Unit/Domains/Profile/ProfileAdapterTest.php +++ b/test/Unit/Domains/Profile/ProfileAdapterTest.php @@ -4,9 +4,9 @@ namespace LaminasApiSampleTest\Unit\Domains\Profile; -use LaminasApiSample\Domains\Profile\Embedded\TwitchEmbeddable; use LaminasApiSample\Domains\Profile\ProfileAdapter; use LaminasApiSample\Domains\Profile\ProfileEntity; +use LaminasApiSample\Domains\Profile\TwitchEmbeddable; use LaminasApiSampleTest\Support\FixedTime; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; diff --git a/test/Unit/Domains/Profile/ProfileEntityTest.php b/test/Unit/Domains/Profile/ProfileEntityTest.php index fe7d2ee..db671d6 100644 --- a/test/Unit/Domains/Profile/ProfileEntityTest.php +++ b/test/Unit/Domains/Profile/ProfileEntityTest.php @@ -4,8 +4,8 @@ namespace LaminasApiSampleTest\Unit\Domains\Profile; -use LaminasApiSample\Domains\Profile\Embedded\TwitchEmbeddable; use LaminasApiSample\Domains\Profile\ProfileEntity; +use LaminasApiSample\Domains\Profile\TwitchEmbeddable; use LaminasApiSampleTest\Support\FixedTime; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; diff --git a/test/Unit/Http/JsonResponseFactoryTest.php b/test/Unit/Http/JsonResponseFactoryTest.php index 1b53fc7..82433bb 100644 --- a/test/Unit/Http/JsonResponseFactoryTest.php +++ b/test/Unit/Http/JsonResponseFactoryTest.php @@ -4,11 +4,10 @@ namespace LaminasApiSampleTest\Unit\Http; -use ArrayIterator; -use Laminas\Http\Header\HeaderInterface; use Laminas\Http\PhpEnvironment\Response as HttpResponse; use LaminasApiSample\Domains\Auth\AuthScope; use LaminasApiSample\Http\JsonResponseFactory; +use LaminasApiSampleTest\Support\GetHeaderValue; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\Attributes\Test; @@ -18,25 +17,6 @@ #[CoversClass(JsonResponseFactory::class)] final class JsonResponseFactoryTest extends TestCase { - /** @throws PHPUnitException */ - private static function getHeaderValueByName( - HttpResponse $response, - string $name, - ): string { - $header = $response->getHeaders()->get($name); - if ($header instanceof ArrayIterator) { - /** @var HeaderInterface|null $current */ - $current = $header->current(); - $header = $current; - } - - if (!$header instanceof HeaderInterface) { - static::fail("Missing {$name} header"); - } - - return $header->getFieldValue(); - } - /** @return array */ private static function decodeBody(HttpResponse $response): array { @@ -124,7 +104,7 @@ public function errorMatchesDocumentedShape( string $message, ): void { static::assertSame($status, $response->getStatusCode()); - static::assertSame('application/json', self::getHeaderValueByName( + static::assertSame('application/json', GetHeaderValue::byName( $response, 'Content-Type', )); @@ -140,7 +120,7 @@ public function methodNotAllowedListsAllowedMethods(): void { $response = JsonResponseFactory::methodNotAllowed(['GET', 'POST']); - static::assertSame('GET, POST', self::getHeaderValueByName( + static::assertSame('GET, POST', GetHeaderValue::byName( $response, 'Allow', )); @@ -154,7 +134,7 @@ public function assertOkResponseEncoded(): void $response = JsonResponseFactory::ok($data); static::assertSame(200, $response->getStatusCode()); - static::assertSame('application/json', self::getHeaderValueByName( + static::assertSame('application/json', GetHeaderValue::byName( $response, 'Content-Type', )); @@ -169,7 +149,7 @@ public function authErrorsCarryWwwAuthenticate(): void Bearer error="invalid_token" EOT; - static::assertSame($message, self::getHeaderValueByName( + static::assertSame($message, GetHeaderValue::byName( JsonResponseFactory::unauthorized(), 'WWW-Authenticate', )); @@ -178,7 +158,7 @@ public function authErrorsCarryWwwAuthenticate(): void Bearer error="insufficient_scope", scope="account:item_filter" EOT; - static::assertSame($message, self::getHeaderValueByName( + static::assertSame($message, GetHeaderValue::byName( JsonResponseFactory::forbidden(AuthScope::AccountItemFilter->value), 'WWW-Authenticate', )); diff --git a/test/Unit/Http/RateLimiting/RateLimiterTest.php b/test/Unit/Http/RateLimiting/RateLimiterTest.php new file mode 100644 index 0000000..a4d0007 --- /dev/null +++ b/test/Unit/Http/RateLimiting/RateLimiterTest.php @@ -0,0 +1,126 @@ +check($key); + } + + return $limiter->check($key); + } + + /** @throws PHPUnitException */ + #[Test] + public function ruleHeaderUsesDocumentedFormat(): void + { + $limiter = self::limiter(new MutableClock(FixedTime::now())); + $result = self::hitTimes($limiter, 1); + static::assertSame('10:5:10', $result->rule->header()); + } + + /** @throws PHPUnitException */ + #[Test] + public function allowsUpToMaxHits(): void + { + $limiter = self::limiter(new MutableClock(FixedTime::now())); + $result = self::hitTimes($limiter, 10); + static::assertFalse($result->isLimited()); + static::assertSame('10:5:0', $result->stateHeader()); + } + + /** @throws PHPUnitException */ + #[Test] + public function restrictsOnTheHitOverTheLimit(): void + { + $limiter = self::limiter(new MutableClock(FixedTime::now())); + $result = self::hitTimes($limiter, 11); + static::assertTrue($result->isLimited()); + static::assertSame('11:5:10', $result->stateHeader()); + } + + /** @throws PHPUnitException */ + #[Test] + public function remainsLimitedAfterPeriodRollsOver(): void + { + $clock = new MutableClock(FixedTime::now()); + $limiter = self::limiter($clock); + self::hitTimes($limiter, 11); + + $clock->advance(6); + $result = $limiter->check('client:a'); + + static::assertTrue($result->isLimited()); + static::assertSame('0:5:4', $result->stateHeader()); + } + + /** @throws PHPUnitException */ + #[Test] + public function recoversAfterRestriction(): void + { + $clock = new MutableClock(FixedTime::now()); + $limiter = self::limiter($clock); + self::hitTimes($limiter, 11); + + $clock->advance(10); + $result = $limiter->check('client:a'); + + static::assertFalse($result->isLimited()); + static::assertSame('1:5:0', $result->stateHeader()); + } + + /** @throws PHPUnitException */ + #[Test] + public function keysAreIndependent(): void + { + $limiter = self::limiter(new MutableClock(FixedTime::now())); + + $result = self::hitTimes($limiter, 11); + $otherResult = $limiter->check('client:b'); + + static::assertTrue($result->isLimited()); + static::assertSame('11:5:10', $result->stateHeader()); + static::assertFalse($otherResult->isLimited()); + static::assertSame('1:5:0', $otherResult->stateHeader()); + } +} diff --git a/test/Unit/Http/RouterTest.php b/test/Unit/Http/RouterTest.php index 610c640..cfef81f 100644 --- a/test/Unit/Http/RouterTest.php +++ b/test/Unit/Http/RouterTest.php @@ -15,8 +15,16 @@ use LaminasApiSample\Http\Auth\AuthenticatorInterface; use LaminasApiSample\Http\HandlerInterface; use LaminasApiSample\Http\JsonResponseFactory; +use LaminasApiSample\Http\RateLimiting\CounterStoreInterface; +use LaminasApiSample\Http\RateLimiting\InMemoryCounterStore; +use LaminasApiSample\Http\RateLimiting\RateLimiter; +use LaminasApiSample\Http\RateLimiting\RateLimitRule; use LaminasApiSample\Http\Router; use LaminasApiSampleTest\Support\FixedTime; +use LaminasApiSampleTest\Support\GetHeaderValue; +use LaminasApiSampleTest\Support\MutableClock; +use LaminasApiSampleTest\Support\StubAuthenticator; +use LaminasApiSampleTest\Support\StubHandler; use LaminasApiSampleTest\Support\TestProfile; use PHPUnit\Framework\Attributes\CoversClass; use PHPUnit\Framework\Attributes\Test; @@ -28,8 +36,8 @@ #[ CoversClass(Router::class), UsesClass(JsonResponseFactory::class), - UsesClass(MockHandler::class), - UsesClass(MockAuthenticator::class), + UsesClass(StubHandler::class), + UsesClass(StubAuthenticator::class), UsesClass(AccessTokenEntity::class), UsesClass(ProfileEntity::class), UsesClass(AuthContext::class), @@ -39,9 +47,29 @@ final class RouterTest extends TestCase { public const string TEST_SCOPE = 'test:scope'; + private static function stubCounterStore(): CounterStoreInterface + { + return new InMemoryCounterStore(new MutableClock(FixedTime::now())); + } + + private static function stubRateLimiter(?RateLimitRule $rule = null): RateLimiter + { + return new RateLimiter( + store: self::stubCounterStore(), + policy: 'api', + rule: $rule ?? new RateLimitRule( + name: 'client', + maxHits: 10, + periodSeconds: 5, + restrictSeconds: 10, + ), + ); + } + private static function stubRouter( HandlerInterface $handler, AuthenticatorInterface $authenticator, + ?RateLimiter $rateLimiter = null, ?string $scope = self::TEST_SCOPE, ): Router { $defaults = ['handlers' => [ @@ -63,12 +91,15 @@ private static function stubRouter( route: $testRoute, ); + $serviceManager = new ServiceManager(['services' => [ + 'character.read' => $handler, + ]]); + return new Router( $routeStack, - new ServiceManager(['services' => [ - 'character.read' => $handler, - ]]), + $serviceManager, $authenticator, + $rateLimiter ?? self::stubRateLimiter(), ); } @@ -98,6 +129,154 @@ private static function stubToken(array $scopes = [ ); } + /** + * @throws ContainerExceptionInterface + * @throws PHPUnitException + */ + #[Test] + public function requestCarriesRateLimitingHeaders(): void + { + $response = self::stubRouter( + handler: new StubHandler(), + authenticator: new StubAuthenticator(self::stubToken()), + ) + ->dispatch(self::buildStubRequest('GET', '/character/1')); + + static::assertSame(200, $response->getStatusCode()); + static::assertSame('api', GetHeaderValue::byName( + $response, + 'X-Rate-Limit-Policy', + )); + static::assertSame('client', GetHeaderValue::byName( + $response, + 'X-Rate-Limit-Rules', + )); + static::assertSame('10:5:10', GetHeaderValue::byName( + $response, + 'X-Rate-Limit-Client', + )); + static::assertSame('1:5:0', GetHeaderValue::byName( + $response, + 'X-Rate-Limit-Client-State', + )); + } + + /** + * @throws ContainerExceptionInterface + * @throws PHPUnitException + */ + #[Test] + public function requestLimitedAfterFirstHit(): void + { + $rateLimiterRule = new RateLimitRule( + name: 'client', + maxHits: 1, + periodSeconds: 5, + restrictSeconds: 10, + ); + $rateLimiter = self::stubRateLimiter($rateLimiterRule); + + $router = self::stubRouter( + handler: new StubHandler(), + authenticator: new StubAuthenticator(self::stubToken()), + rateLimiter: $rateLimiter, + ); + + $request = self::buildStubRequest('GET', '/character/1'); + $router->dispatch($request); + + $secondResponse = $router->dispatch($request); + + static::assertSame(429, $secondResponse->getStatusCode()); + static::assertSame('10', GetHeaderValue::byName( + $secondResponse, + 'Retry-After', + )); + static::assertSame( + ['error' => ['code' => 3, 'message' => 'Rate limit exceeded']], + json_decode( + $secondResponse->getBody(), + true, + flags: JSON_THROW_ON_ERROR, + ), + ); + + static::assertSame('api', GetHeaderValue::byName( + $secondResponse, + 'X-Rate-Limit-Policy', + )); + static::assertSame('client', GetHeaderValue::byName( + $secondResponse, + 'X-Rate-Limit-Rules', + )); + static::assertSame('1:5:10', GetHeaderValue::byName( + $secondResponse, + 'X-Rate-Limit-Client', + )); + static::assertSame('2:5:10', GetHeaderValue::byName( + $secondResponse, + 'X-Rate-Limit-Client-State', + )); + } + + /** + * @throws ContainerExceptionInterface + * @throws PHPUnitException + */ + #[Test] + public function rateLimitHeadersOmittedOn401(): void + { + $response = self::stubRouter( + handler: new StubHandler(), + authenticator: new StubAuthenticator(null), + ) + ->dispatch(self::buildStubRequest('GET', '/character/1')); + + static::assertSame(401, $response->getStatusCode()); + + $headers = $response->getHeaders(); + foreach ([ + 'X-Rate-Limit-Policy', + 'X-Rate-Limit-Rules', + 'X-Rate-Limit-Client', + 'X-Rate-Limit-Client-State', + ] as $name) { + static::assertFalse( + $headers->has($name), + "{$name} should not be set on a 401", + ); + } + } + + /** + * @throws ContainerExceptionInterface + * @throws PHPUnitException + */ + #[Test] + public function rateLimitHeadersOmittedOn403(): void + { + $response = self::stubRouter( + handler: new StubHandler(), + authenticator: new StubAuthenticator(self::stubToken([])), + ) + ->dispatch(self::buildStubRequest('GET', '/character/1')); + + static::assertSame(403, $response->getStatusCode()); + + $headers = $response->getHeaders(); + foreach ([ + 'X-Rate-Limit-Policy', + 'X-Rate-Limit-Rules', + 'X-Rate-Limit-Client', + 'X-Rate-Limit-Client-State', + ] as $name) { + static::assertFalse( + $headers->has($name), + "{$name} should not be set on a 403", + ); + } + } + /** * @throws ContainerExceptionInterface * @throws PHPUnitException @@ -106,8 +285,8 @@ private static function stubToken(array $scopes = [ public function unknownPathIsNotFound(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(self::stubToken()), + new StubHandler(), + new StubAuthenticator(self::stubToken()), ) ->dispatch(self::buildStubRequest('GET', '/stash')); @@ -122,8 +301,8 @@ public function unknownPathIsNotFound(): void public function paramFailingConstraintIsNotFound(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(self::stubToken()), + new StubHandler(), + new StubAuthenticator(self::stubToken()), ) ->dispatch(self::buildStubRequest('GET', '/character/abc')); @@ -138,8 +317,8 @@ public function paramFailingConstraintIsNotFound(): void public function unmappedMethodIsNotAllowed(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(self::stubToken()), + new StubHandler(), + new StubAuthenticator(self::stubToken()), ) ->dispatch(self::buildStubRequest('DELETE', '/character/1')); @@ -158,8 +337,8 @@ public function unmappedMethodIsNotAllowed(): void public function unregisteredHandlerIsNotImplemented(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(self::stubToken()), + new StubHandler(), + new StubAuthenticator(self::stubToken()), ) ->dispatch(self::buildStubRequest('POST', '/character/1')); @@ -173,10 +352,10 @@ public function unregisteredHandlerIsNotImplemented(): void #[Test] public function matchedRouteCallsHandlerWithParamsOnly(): void { - $handler = new MockHandler(); + $handler = new StubHandler(); $response = self::stubRouter( $handler, - new MockAuthenticator(self::stubToken()), + new StubAuthenticator(self::stubToken()), ) ->dispatch(self::buildStubRequest('GET', '/character/1')); @@ -192,8 +371,8 @@ public function matchedRouteCallsHandlerWithParamsOnly(): void public function failedAuthenticationIsUnauthorized(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(null), + new StubHandler(), + new StubAuthenticator(null), ) ->dispatch(self::buildStubRequest('GET', '/character/1')); @@ -208,8 +387,8 @@ public function failedAuthenticationIsUnauthorized(): void public function tokenWithoutRouteScopeIsForbidden(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(self::stubToken(['other:scope'])), + new StubHandler(), + new StubAuthenticator(self::stubToken(['other:scope'])), ) ->dispatch(self::buildStubRequest('GET', '/character/1')); @@ -224,8 +403,8 @@ public function tokenWithoutRouteScopeIsForbidden(): void public function routeWithoutScopeFailsClosed(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(self::stubToken()), + new StubHandler(), + new StubAuthenticator(self::stubToken()), scope: null, ) ->dispatch(self::buildStubRequest('GET', '/character/1')); @@ -240,10 +419,10 @@ public function routeWithoutScopeFailsClosed(): void #[Test] public function handlerReceivesAuthContextForTokenProfile(): void { - $handler = new MockHandler(); + $handler = new StubHandler(); $token = self::stubToken(); - self::stubRouter($handler, new MockAuthenticator($token)) + self::stubRouter($handler, new StubAuthenticator($token)) ->dispatch(self::buildStubRequest('GET', '/character/1')); $auth = $handler->receivedAuth; @@ -263,8 +442,8 @@ public function handlerReceivesAuthContextForTokenProfile(): void public function methodIsCheckedBeforeAuthentication(): void { $response = self::stubRouter( - new MockHandler(), - new MockAuthenticator(null), + new StubHandler(), + new StubAuthenticator(null), ) ->dispatch(self::buildStubRequest('DELETE', '/character/1'));