diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 45b706f..656fe13 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,7 +13,7 @@ jobs: strategy: fail-fast: false matrix: - php-version: ['8.2', '8.3', '8.4'] + php-version: ['8.3', '8.4', '8.5'] db-type: [sqlite, mysql, pgsql] prefer-lowest: [''] @@ -59,7 +59,7 @@ jobs: fi - name: Setup problem matchers for PHPUnit - if: matrix.php-version == '8.2' && matrix.db-type == 'mysql' + if: matrix.php-version == '8.3' && matrix.db-type == 'mysql' run: echo "::add-matcher::${{ runner.tool_cache }}/phpunit.json" - name: Run PHPUnit @@ -67,14 +67,14 @@ jobs: if [[ ${{ matrix.db-type }} == 'sqlite' ]]; then export DB_URL='sqlite:///:memory:'; fi if [[ ${{ matrix.db-type }} == 'mysql' ]]; then export DB_URL='mysql://root:root@127.0.0.1/cakephp?encoding=utf8'; fi if [[ ${{ matrix.db-type }} == 'pgsql' ]]; then export DB_URL='postgres://postgres:postgres@127.0.0.1/postgres'; fi - if [[ ${{ matrix.php-version }} == '8.2' ]]; then + if [[ ${{ matrix.php-version }} == '8.3' ]]; then export CODECOVERAGE=1 && vendor/bin/phpunit --display-deprecations --display-incomplete --display-skipped --coverage-clover=coverage.xml else vendor/bin/phpunit fi - name: Submit code coverage - if: matrix.php-version == '8.2' + if: matrix.php-version == '8.3' uses: codecov/codecov-action@v5 cs-stan: @@ -87,7 +87,7 @@ jobs: - name: Setup PHP uses: shivammathur/setup-php@v2 with: - php-version: '8.2' + php-version: '8.3' extensions: mbstring, intl, apcu coverage: none @@ -103,21 +103,10 @@ jobs: uses: actions/cache@v4 with: path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-${{ matrix.prefer-lowest }} + key: ${{ runner.os }}-composer-${{ steps.key-date.outputs.date }}-${{ hashFiles('composer.json') }}-cs-stan - name: composer install - run: composer stan-setup - - - name: Run PHP CodeSniffer - run: composer cs-check - continue-on-error: true - - - name: Run psalm - if: success() || failure() - run: vendor/bin/psalm.phar --output-format=github - continue-on-error: true + run: composer install --prefer-dist --no-progress - - name: Run phpstan - if: success() || failure() - run: composer stan - continue-on-error: true + - name: Run All Gates + run: composer check diff --git a/README.md b/README.md index ebb71f5..0148c9b 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Versions and branches Documentation ------------- -For documentation, as well as tutorials, see the [Docs](docs/home.md) directory of this repository. +For documentation, see the [Docs](docs/index.md) directory of this repository. Support ------- diff --git a/composer.json b/composer.json index 289120a..642bf1a 100644 --- a/composer.json +++ b/composer.json @@ -27,10 +27,11 @@ "source": "https://github.com/CakeDC/cakephp-api" }, "require": { - "php": ">=8.2", + "php": ">=8.3", "ext-json": "*", "cakephp/cakephp": "^5.1", "cakedc/users": "^16.0", + "crustum/cakephp-attribute-resolver": "^1.0", "lcobucci/clock": "^2.2.0,<2.3.0", "lcobucci/jwt": "^5.5.0", "firebase/php-jwt": "^6.3 || ^7.0" @@ -100,13 +101,14 @@ "analyse": [ "@stan" ], - "cs-check": "phpcs -p --standard=phpcs.xml src/ tests/", - "cs-fix": "phpcbf --standard=phpcs.xml src/ tests/", + "cs-check": "vendor/bin/phpcs -p --standard=phpcs.xml src/ tests/", + "cs-fix": "vendor/bin/phpcbf --standard=phpcs.xml src/ tests/", "fix-eol": "powershell -NoProfile -ExecutionPolicy Bypass -File tools/fix-lineendings.ps1", "test": "phpunit --stderr", "rector-check": "vendor/bin/rector process --dry-run", "rector-fix": "vendor/bin/rector process", "stan": "phpstan analyse src/", - "stan-baseline": "phpstan analyse --generate-baseline" + "stan-baseline": "phpstan analyse --generate-baseline", + "structarmed": "vendor/bin/structarmed analyze src" } } diff --git a/config/bootstrap.php b/config/bootstrap.php index 5faf982..bb8ed51 100644 --- a/config/bootstrap.php +++ b/config/bootstrap.php @@ -9,6 +9,9 @@ * @license MIT License (http://www.opensource.org/licenses/mit-license.php) */ +use Cake\AttributeResolver\AttributeResolver; +use Cake\Cache\Cache; +use Cake\Cache\Engine\FileEngine; use Cake\Core\Configure; use Cake\Log\Log; @@ -30,3 +33,27 @@ 'file' => Configure::read('Api.Log.file'), ]); } + +// Attribute resolver cache. Auto-registered with defaults; the host application +// may override by configuring the `_cakedc_api_attributes_` engine (or its own +// engine via the resolver config) before the plugin loads. +if (!in_array('_cakedc_api_attributes_', Cache::configured(), true)) { + Cache::setConfig('_cakedc_api_attributes_', [ + 'className' => FileEngine::class, + 'prefix' => 'cakedc_api_attributes_', + 'path' => CACHE . 'persistent' . DS, + 'serialize' => true, + 'duration' => '+1 hour', + ]); +} + +// Attribute routing resolver config. Auto-registered so attribute-declared +// service routes work out of the box; override `paths` / `cache` / `validateFiles` +// by configuring the `default` resolver config in the application bootstrap. +if (AttributeResolver::getConfig('default') === null) { + AttributeResolver::setConfig('default', [ + 'paths' => ['src/Service/*.php', 'src/Service/**/*.php'], + 'cache' => '_cakedc_api_attributes_', + 'validateFiles' => true, + ]); +} diff --git a/docs/attributes.md b/docs/attributes.md new file mode 100644 index 0000000..d7e7503 --- /dev/null +++ b/docs/attributes.md @@ -0,0 +1,419 @@ +# Attribute Routing for Services + +- [Introduction](#introduction) +- [How It Works](#how-it-works) +- [Service-Level Attributes](#service-level-attributes) + - [ApiScope](#apiscope) + - [ApiResource](#apiresource) +- [Declaring Actions](#declaring-actions) + - [ApiActions on the Service](#apiactions-on-the-service) + - [ApiRoute on Action Classes](#apiroute-on-action-classes) + - [HTTP Method Shortcuts](#http-method-shortcuts) +- [Route Parameters](#route-parameters) + - [Placeholders](#placeholders) + - [Patterns](#patterns) + - [Pass](#pass) + - [CORS Preflight](#cors-preflight) +- [Nesting](#nesting) +- [Inheritance](#inheritance) +- [Caching](#caching) +- [Attribute Reference](#attribute-reference) + + +## Introduction + +Service routes can be declared with PHP attributes. Following the CakePHP 6 +attribute-routing model, each **action class declares its own route**, and the +service lists which actions it owns. Discovery and caching are backed by the +[`crustum/cakephp-attribute-resolver`](https://packagist.org/packages/crustum/cakephp-attribute-resolver) +package (a CakePHP 5.4 backport of `Cake\AttributeResolver`). + +A typical `Service::mapAction()` block: + +```php +$this->mapAction('featured', FeaturedAction::class, [ + 'method' => ['GET'], + 'mapCors' => true, + 'path' => '/featured', +]); +$this->mapAction('publish', PublishAction::class, [ + 'method' => ['POST'], + 'mapCors' => true, + 'path' => '/{id}/publish', +]); +``` + +becomes: + +```php +// src/Service/ArticlesService.php +use App\Service\Action\FeaturedAction; +use App\Service\Action\PublishAction; +use CakeDC\Api\Service\Attribute\ApiActions; +use CakeDC\Api\Service\FallbackService; + +#[ApiActions([ + FeaturedAction::class, + PublishAction::class, +])] +class ArticlesService extends FallbackService +{ +} +``` + +```php +// src/Service/Action/FeaturedAction.php +use CakeDC\Api\Service\Action\CrudAction; +use CakeDC\Api\Service\Attribute\ApiGet; + +#[ApiGet(action: 'featured', path: '/featured', mapCors: true)] +class FeaturedAction extends CrudAction +{ + public function execute(): mixed + { + // ... + } +} +``` + +```php +// src/Service/Action/PublishAction.php +use CakeDC\Api\Service\Action\CrudAction; +use CakeDC\Api\Service\Attribute\ApiPost; + +#[ApiPost(action: 'publish', path: '/{id}/publish', mapCors: true)] +class PublishAction extends CrudAction +{ + public function execute(): mixed + { + // $this->request->getParam('id') + } +} +``` + +> [!NOTE] +> The `/api` base path and the version prefix are **config-driven** +> (`Api.routeBase`, `Api.useVersioning`, `Api.versionPrefix`). Attribute paths are +> relative to the service resource and must not include `/api` or a version segment. + +Attribute routing is **fully optional** and composes with the existing actions map. + + +## How It Works + +1. When a service is constructed, `Service::initialize()` runs the + `ServiceAttributeConnector`. +2. The connector reads `ApiScope` / `ApiResource` / `ApiActions` from the service + class (and its parents). +3. For every action class listed in `ApiActions`, it reads the action's route + attributes and calls `Service::mapAction()` — exactly like the config-based + registration above. +4. The existing `ApiRouter` builds the actual routes from the actions map, so + attribute routes produce the same routes as config-based ones. + +Attribute routing is **opt-in**: the connector is a no-op until a resolver +configuration exists (see [Caching](#caching)). + + +## Service-Level Attributes + + +### ApiScope + +`#[ApiScope]` sets a path prefix, shared defaults, and shared patterns for all +routes of the service's actions. Repeatable and stacked across inheritance: + +```php +use CakeDC\Api\Service\Attribute\ApiScope; + +#[ApiScope('/v1')] +#[ApiActions([FeaturedAction::class])] +class ArticlesService extends FallbackService +{ +} +``` + +| Parameter | Type | Description | +|---|---|---| +| `path` | `string` | Path prefix prepended to every route path. | +| `defaults` | `array` | Default route values merged into every route. | +| `patterns` | `array` | Shared regex patterns for placeholders. | + +> [!TIP] +> `ApiScope::path` only adds a sub-path **within** the service resource +> (`/api/{service}/{scope}/{action}`). Use it for grouping actions, not for the +> API or version base — those come from `Api.routeBase` / `Api.useVersioning`. + + +### ApiResource + +`#[ApiResource]` configures the REST resource routes generated for the service +(equivalent to options passed to `$routes->resources()`): + +```php +use CakeDC\Api\Service\Attribute\ApiResource; + +#[ApiResource(only: ['index', 'view', 'featured'])] +class ArticlesService extends FallbackService +{ +} +``` + +| Parameter | Type | Description | +|---|---|---| +| `path` | `string\|null` | Override the resource URL path. | +| `only` | `array` | Limit which routes are generated (index/view/add/edit/delete + custom). | +| `actions` | `array` | Map REST actions to custom action classes. | +| `map` | `array` | Additional non-standard resource routes. | +| `id` | `string` | Regex pattern for the resource identifier. | + +> [!WARNING] +> `only` filters **all** resource routes, including actions declared via +> `ApiActions`. List every custom action name in `only` when using both. + + +## Declaring Actions + + +### ApiActions on the Service + +`#[ApiActions]` lists the action classes owned by a service. It is the attribute +equivalent of the list of `mapAction()` calls: + +```php +use App\Service\Action\FeaturedAction; +use App\Service\Action\IndexAction; +use App\Service\Action\PublishAction; +use CakeDC\Api\Service\Attribute\ApiActions; +use CakeDC\Api\Service\FallbackService; + +#[ApiActions([ + FeaturedAction::class, + PublishAction::class, + IndexAction::class, +])] +class ArticlesService extends FallbackService +{ +} +``` + +Pass the action classes as an **array**. Each listed action class must carry at +least one route attribute. + + +### ApiRoute on Action Classes + +`#[ApiRoute]` declares one route of an action class. Repeatable — one action can +serve multiple routes: + +```php +use CakeDC\Api\Service\Attribute\ApiRoute; + +#[ApiRoute(action: 'search', path: '/search', method: ['GET', 'POST'])] +class SearchAction extends CrudAction +{ +} +``` + +| Parameter | Type | Description | +|---|---|---| +| `action` | `string` | Action name (route key, e.g. `save_answer`). | +| `path` | `string` | Route path, relative to the service resource. Supports `{placeholder}` params. | +| `method` | `string\|array` | HTTP method(s). Default: `GET`. | +| `mapCors` | `bool` | Register an OPTIONS route for CORS preflight. | +| `name` | `string\|null` | Optional route name. | +| `patterns` | `array` | Regex patterns for route placeholders. | +| `defaults` | `array` | Additional route defaults. | +| `pass` | `array\|null` | Placeholder names passed to the action. | + + +### HTTP Method Shortcuts + +| Attribute | HTTP Method | +|---|---| +| `#[ApiGet]` | GET | +| `#[ApiPost]` | POST | +| `#[ApiPut]` | PUT | +| `#[ApiPatch]` | PATCH | +| `#[ApiDelete]` | DELETE | +| `#[ApiOptions]` | OPTIONS | + +These accept the same parameters as `#[ApiRoute]` except `method`. + + +## Route Parameters + + +### Placeholders + +`{placeholder}` segments in `path` become route params, read inside the action +via `$this->request->getParam('name')` (or the `id` param for `CrudAction`): + +```php +#[ApiGet(action: 'view', path: '/{id}')] +class ViewAction extends CrudAction +{ + public function execute(): mixed + { + $id = $this->request->getParam('id'); + } +} +``` + +``` +GET /api/articles/42 -> id=42 +``` + + +### Patterns + +Constrain placeholders with regex: + +```php +#[ApiGet(action: 'item', path: '/item/{id}', patterns: ['id' => '\d+'])] +``` + +Shared patterns can be set at the service level via `ApiScope`. + + +### Pass + +`pass` controls which placeholders are passed to the action as positional +arguments. When omitted, placeholders are available as route params: + +```php +#[ApiGet(action: 'view', path: '/{id}', pass: ['id'])] +``` + + +### CORS Preflight + +`mapCors: true` registers an OPTIONS counterpart route for the action, mirroring +`'mapCors' => true` in `mapAction()`. + + +## Nesting + +Attribute routing is **orthogonal** to nesting. Nested resources keep using the +existing mechanism (`FallbackService::loadRoutes()` walks the parent table's +`HasMany` associations, registers the nested routes, and the child service is +resolved through the parent and scoped by the parent foreign key). + +A child service can mix attribute routes with nesting freely — its action routes +are reachable under the parent's nested path: + +``` +# AuthorsService hasMany Articles +GET /api/authors/1/articles/featured # child service attribute route +GET /api/authors/1/articles # nested index +``` + +Attributes only add routes **on top of** whatever nesting the parent declares. + + +## Inheritance + +Service-level attributes support class inheritance: + +- `#[ApiScope]` **stacks** — parent and child scope paths/defaults/patterns are + concatenated/merged (parent first). +- `#[ApiResource]` and `#[ApiActions]` on a child **override** the parent's. +- Abstract service classes are skipped (their routes are only connected through + concrete subclasses). + +```php +#[ApiScope('/base')] +abstract class BaseApiService extends FallbackService +{ +} + +#[ApiScope('/v1')] +#[ApiActions([FeaturedAction::class])] +class ArticlesService extends BaseApiService +{ + // /api/articles/base/v1/featured +} +``` + + +## Caching + +Attribute discovery follows the CakePHP 6 model: the resolver scans your service +and action directories **once** and the **whole attribute collection is cached as a +single unit** under one cache key. Every subsequent `Service::initialize()` only +filters that cached collection — the scan/parse happens once per process. + +**The cache is registered automatically.** When the plugin boots +(`config/bootstrap.php`) it registers, idempotently: + +1. A `_cakedc_api_attributes_` **cache engine** (File, serialized, in + `tmp/cache/persistent`), only if the host has not configured one. +2. A `default` **resolver config** pointing at that engine, scanning + `src/Service/*.php` + `src/Service/**/*.php` with `validateFiles = true`, only + if the host has not configured `default`. + +So attribute routes work **out of the box** — no manual setup required. + +### Overriding the cache + +Both registrations respect an existing configuration (Cake-core style: defaults +provided, overridable). To use a different engine, duration, or scan scope, +configure it **before the plugin loads**: + +```php +// config/bootstrap.php (before plugins load, or in Application::bootstrap()) +use Cake\AttributeResolver\AttributeResolver; +use Cake\Cache\Cache; + +Cache::setConfig('_cakedc_api_attributes_', [ + 'className' => 'File', + 'path' => CACHE, + 'duration' => '+1 day', +]); + +AttributeResolver::setConfig('default', [ + // include BOTH patterns: **/*.php does not match files directly in the folder + 'paths' => ['src/Service/*.php', 'src/Service/**/*.php'], + 'cache' => '_cakedc_api_attributes_', + 'validateFiles' => true, +]); +``` + +> [!TIP] +> `FileEngine` + `serialize => true` is the working baseline. The resolver README +> mentions PhpEngine as an optional optimization on top of a File adapter. In +> tests, use the `Array` engine (memory-only) or `'cache' => false`. + +### Changing the scan scope at runtime + +If you re-configure the resolver with a different `paths`/`basePath`/`excludePaths` +(not just on boot), the cached collection is stale. Invalidate it before +re-configuring, mirroring the Codex `CommandBus` pattern: + +```php +if (AttributeResolver::getConfig('default') !== null) { + AttributeResolver::clear('default'); + AttributeResolver::drop('default'); +} +AttributeResolver::setConfig('default', [...new scope...]); +``` + +`bin/cake service routes ` prints attribute routes too — constructing +the service applies them. + + +## Attribute Reference + +All attributes live in the `CakeDC\Api\Service\Attribute` namespace. + +| Attribute | Target | Repeatable | Description | +|---|---|---|---| +| `#[ApiActions]` | Service class | No | Lists the service's action classes. | +| `#[ApiRoute]` | Action class | Yes | Route with any HTTP method(s). | +| `#[ApiGet]` / `#[ApiPost]` / `#[ApiPut]` / `#[ApiPatch]` / `#[ApiDelete]` / `#[ApiOptions]` | Action class | Yes | HTTP method shortcut. | +| `#[ApiScope]` | Service class | Yes | Path prefix, defaults, patterns. | +| `#[ApiResource]` | Service class | No | REST resource route options. | + +Dependencies: `crustum/cakephp-attribute-resolver` (`^1.0`) drives attribute +discovery and caching; the connector requires a resolver configuration (see +[Caching](#caching)). diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000..a256a20 --- /dev/null +++ b/docs/index.md @@ -0,0 +1,1269 @@ +# CakePHP API Plugin + +- [Introduction](#introduction) +- [Quickstart](#quickstart) + - [Installing the Plugin](#installing-the-plugin) + - [Configuration](#configuration) + - [Exposing Your First Resource](#exposing-your-first-resource) + - [Next Steps](#quickstart-next-steps) +- [Request Lifecycle](#request-lifecycle) +- [Services](#services) + - [Service Types](#service-types) + - [Resolving Services](#resolving-services) + - [Defining a Custom Service](#defining-a-custom-service) + - [Actions Map](#actions-map) + - [Service Extensions](#service-extensions) + - [Attribute Routing](#attribute-routing) + - [Renderers](#renderers) + - [JSend (Default)](#jsend-default) + - [Json](#json) + - [Raw](#raw) + - [Xml](#xml) + - [File](#file) + - [Flysystem](#flysystem) + - [Responses](#responses) + - [Error Codes](#error-codes) +- [Actions](#actions) + - [CRUD Actions](#crud-actions) + - [Authentication Actions](#authentication-actions) + - [Custom Actions](#custom-actions) + - [Action Extensions](#extensions) + - [Default Extensions](#default-extensions) + - [Paginate](#paginate) + - [Sort](#sort) + - [Filter](#filter) + - [CursorPaginate](#cursor-paginate) + - [ExtendedSort](#extended-sort) + - [CrudHateoas](#crud-hateoas) + - [CrudRelations](#crud-relations) + - [Nested](#nested-extension) + - [Cors](#cors-extension) + - [CrudAutocompleteList](#crud-autocomplete-list) + - [Authentication](#authentication-extension) + - [Pagination, Sorting and Filtering](#pagination-sorting-and-filtering) + - [HATEOAS & Relations](#hateoas-relations) + - [CORS](#cors) + - [Nested Resources](#nested-resources) + - [Transformers](#transformers) +- [Authentication](#authentication) + - [Authenticators](#authenticators) + - [JWT Authentication](#jwt-authentication) + - [Token Responses](#token-responses) +- [Two-Factor Authentication](#two-factor-authentication) + - [One-Time Passwords](#one-time-passwords) + - [Webauthn](#webauthn) + - [RBAC Scopes](#rbac-scopes) +- [Authorization & Permissions](#authorization-permissions) + - [Permission Format](#permission-format) + - [Rules](#rules) + - [Cached RBAC](#cached-rbac) + - [CakeDC/Auth Integration](#cakedc-auth-integration) +- [Versioning](#versioning) +- [Model & JWT Refresh Tokens](#model-jwt-refresh-tokens) +- [Testing](#testing) +- [Console Commands](#console-commands) +- [Legacy & Obsolete Components](#legacy-obsolete-components) + + +## Introduction + +The **CakePHP API** plugin exposes your CakePHP application as a REST API with a few lines of configuration. It sits on top of the CakePHP framework's HTTP layer and provides: + +- **Service-driven endpoints** — a request to `/api/articles` is resolved to a *service*, which builds an *action* that reads and writes an ORM table. +- **Out-of-the-box CRUD** — `index`, `view`, `add`, `edit`, `delete` actions are provided by the fallback service with no service class required. +- **Authentication & authorization** — session, form, token and JWT authenticators, plus a config-driven RBAC layer. +- **Response rendering** — JSend by default, with JSON, XML and raw renderers available. +- **Optional versioning** — versioned service namespaces (`Service/v1`, `Service/v2`, …). +- **Reusable extensions** — pagination, sorting, filtering, HATEOAS links, relations, CORS and nested resources. + +The plugin targets **CakePHP 5.x**. + + +## Quickstart + + +### Installing the Plugin + +Install via Composer: + +```bash +composer require cakedc/cakephp-api +``` + +Load the plugin and its configuration: + +```bash +bin/cake plugin load CakeDC/Api +``` + +> [!NOTE] +> The plugin ships its default configuration in `config/api.php`. Load it in your application's `config/bootstrap.php` (or via the manifest system) so `Configure::read('Api.*')` is populated: + +```php +Cake\Core\Configure::load('api'); +``` + +Alternatively, load the plugin and configure it in `Application::bootstrap()`: + +```php +// In src/Application.php +public function bootstrap(): void +{ + parent::bootstrap(); + + $this->addPlugin('CakeDC/Api'); +} +``` + + +### Configuration + +All plugin configuration lives under the `Api` configuration key. The important defaults are: + +```php +'Api' => [ + // Fallback service used when no service class exists for an endpoint + 'ServiceFallback' => '\\CakeDC\\Api\\Service\\FallbackService', + // Response renderer (JSend by default) + 'renderer' => 'CakeDC/Api.JSend', + // Parser used to read request data + 'parser' => 'CakeDC/Api.Form', + + // Route inflection: 'underscore', 'dasherize' or false + 'routesInflectorMethod' => false, + + // Versioning is disabled by default + 'useVersioning' => false, + 'versionPrefix' => 'v', + + // Service class lookup + 'lookupMode' => 'underscore', + + // JWT tokens (disabled by default) + 'Jwt' => [ + 'enabled' => false, + 'AccessToken' => ['lifetime' => 600, 'secret' => ''], + 'RefreshToken' => ['lifetime' => 14 * 86400, 'secret' => ''], + ], + // 2FA (disabled by default) + '2fa' => ['enabled' => false], +], +``` + +> [!TIP] +> JWT and 2FA are **off** by default. Enable them only when your application needs them. + + +### Exposing Your First Resource + +No service class is required for a basic CRUD resource. If your application has a `ArticlesTable` (ORM table), the plugin will serve it automatically: + +``` +GET /api/articles -> articles index +GET /api/articles/1 -> view article 1 +POST /api/articles -> add an article +PUT /api/articles/1 -> edit article 1 +DELETE /api/articles/1 -> delete article 1 +``` + +A `GET /api/articles` response (JSend): + +```json +{ + "status": "success", + "data": [ + {"id": 1, "title": "Hello", "published": "Y"} + ], + "pagination": { + "count": 1, + "page": 1, + "pages": 1, + "limit": 20 + } +} +``` + +By default the plugin is configured for **public access** (`Auth.allow = '*'`). Read [Authorization & Permissions](#authorization-permissions) to restrict access. + +> [!WARNING] +> **The fallback service exposes any table by its URL — this is unsafe without an auth layer.** +> +> Any request to `/api/{name}` that has no service class resolves to a `FallbackService` over the table `{Name}` (pluralized). Because the default configuration is public (`Auth.allow = '*'`), an unauthenticated request to `/api/users` would return the **entire users table** — including password hashes, `api_token` values, and anything else the entity serializes. +> +> Before going to production you **must**: +> +> 1. Enable authentication and RBAC permissions (see [Authorization & Permissions](#authorization-permissions)) so only authenticated/authorized requests are allowed. +> 2. Explicitly deny (or create a service class for) sensitive resources such as `users`. +> +> Never rely on the fallback service for resources that contain sensitive data. + + +#### Next Steps + +Once your first endpoint works, read about [Services](#services), [Authentication](#authentication), and [Extensions](#extensions) to tailor the plugin to your application. + + +## Request Lifecycle + +A request to `/api/articles/1` is processed by the middleware chain registered in `Api.Middleware`: + +1. **`BodyParserMiddleware`** — parses the request body into `$request->getData()`. +2. **`AuthenticationMiddleware`** — resolves the identity (session, form, token or JWT) and stores it in the `authentication` request attribute. +3. **`ParseApiRequestMiddleware`** — matches the URL against `#/api/{service}{base}#` (or the versioned pattern), resolves the service, and attaches it to the request as the `service` attribute. If no service resolves, it responds with the error directly. +4. **`AuthorizationMiddleware`** (RBAC) — checks the request against the [permissions](#authorization-permissions). +5. **`ProcessApiRequestMiddleware`** — executes the service action and renders the result through the configured [renderer](#renderers). + +The URL structure is: + +``` +/api/{service}[/{action|nested-id}[/...]] +``` + +Service and action names are **underscored** by default. For example `/api/auth/jwt_login` resolves to the `AuthService` and its `jwt_login` action. + + +### Overloaded Router (`ApiRouter`) + +The plugin **overloads** the CakePHP `Router` with `CakeDC\Api\Routing\ApiRouter`. It is what turns a service's actions map into real resource routes at runtime: + +- **`resources($serviceName, $options)`** — generates the collection + item routes (`/articles`, `/articles/{id}`) from the service's actions map and HTTP methods. +- **`parseRequest($request)`** — resolves a URL into routing params (controller/action/pass/`_method`); it is what `Service::parseRoute()` delegates to. +- **Reverse routing** — `ApiRouter::reverse($params)` / `ApiRouter::url($route)` rebuild a URL from route params; services expose this via `routeReverse()` and `routeUrl()`. The HATEOAS links produced by `CrudHateoas` and `ReverseRouting` rely on it. +- **Named expressions** — `ID`, `UUID`, `YEAR`, `MONTH`, `DAY`, `ACTION` constants for route templates (e.g. `'/articles/{id}'`). + +Routes are registered **per service** inside a `routesWrapper()` that reloads the router, connects the service's resource routes, runs the callback, and resets the router — so a service never pollutes the global route table. Inspect what a service exposes with `bin/cake service routes ` (see [Console Commands](#console-commands)). + + +## Services + +A **service** is the unit that owns an endpoint. It is responsible for: + +- Knowing its **name** (`articles`, `auth`, …) and base URL. +- Declaring an **actions map** (which HTTP method + path maps to which action). +- Building **actions** for an incoming request. +- Holding the **request** and **response** objects for the duration of the call. + + +### Service Types + +| Service | Purpose | +| --- | --- | +| `FallbackService` | Default CRUD service over an ORM table (used when no class is found). | +| `CrudService` | Abstract base for services that expose CRUD actions. | +| `NestedCrudService` | Adds nested-resource support (e.g. `/authors/1/articles`). | +| `AuthService` | Authentication actions: `login`, `register`, `jwt_login`, `otp_verify`, … | +| `DescribeService` / `ListingService` | Introspection endpoints (`/api/describe`, `/api/list`). | +| `RecaptchaService` | `validate` action using the reCaptcha trait. | + + +### Resolving Services + +Services are resolved and cached by `ServiceRegistry` / `ServiceLocator`: + +```php +use CakeDC\Api\Service\ServiceRegistry; + +$service = ServiceRegistry::getServiceLocator()->get('articles', [ + 'version' => null, + 'request' => $request, + 'response' => $response, + 'baseUrl' => '/articles', +]); +``` + +Resolution order (`lookupMode = underscore`): + +1. A class named `{App}\Service\ArticlesService` (camelized alias + `Service` suffix). +2. The same class in each plugin listed in `Api.serviceLookupPlugins` (defaults to `CakeDC/Api`). +3. The **fallback service** (`Api.ServiceFallback`, i.e. `FallbackService`) — a generic CRUD service over the `Articles` table. + +> [!WARNING] +> **The fallback is a catch-all.** Any endpoint with no service class silently falls back to CRUD over the matching table (`/api/users` → the `Users` table, `/api/orders` → the `Orders` table, …). Combined with the default public `Auth.allow = '*'` configuration this lets unauthenticated requests read every serialized field of those tables (e.g. `GET /api/users`). +> +> Treat the fallback as **development convenience only**. For production: +> +> - secure the API with authentication + RBAC (see [Authorization & Permissions](#authorization-permissions)), and +> - add explicit [permissions](#permission-format) that deny services you do not want exposed, or define dedicated service classes for them. + +> [!WARNING] +> Service names are single words under the default `underscore` lookup mode. Multi-word names currently do not resolve consistently (`lookupMode = dasherize` is the workaround). + + +### Defining a Custom Service + +Create a service class in your application's `Service` directory: + +```php + ['method' => ['GET'], 'path' => 'featured'], + 'data' => ['method' => ['GET'], 'path' => 'data'], + ]; + + public function initialize(): void + { + parent::initialize(); + + $this->setTable('Articles'); + } +} +``` + +Now `/api/articles/featured` and `/api/articles/data` are available in addition to the CRUD routes. + +> [!NOTE] +> `FallbackService::initialize()` infers the table from the service name (`articles` → `ArticlesTable`). Override with `setTable()` when the table name differs. + + +### Actions Map + +The actions map declares which HTTP method + path an action responds to: + +```php +protected array $actions = [ + 'featured' => ['method' => ['GET'], 'path' => 'featured'], + 'publish' => ['method' => ['POST'], 'path' => '{id}/publish'], +]; +``` + +Within `initialize()`, you can register an action with a specific action class and extra options: + +```php +public function initialize(): void +{ + parent::initialize(); + + $this->mapAction('data', DataAction::class, [ + 'method' => ['GET'], + 'path' => 'data', + 'mapCors' => true, // also register an OPTIONS route for CORS preflight + ]); +} +``` + + +### Attribute Routing + +Routes can also be declared directly on the service and action classes with PHP +attributes (`#[ApiActions]`, `#[ApiRoute]`, `#[ApiGet]`, `#[ApiScope]`, +`#[ApiResource]`), following the CakePHP 6 attribute-routing approach and backed +by the `crustum/cakephp-attribute-resolver` package. See +[Attribute Routing for Services](attributes.md) for the full reference. + + +### Service Extensions + +Service-level extensions attach to `Service.beforeDispatch` / `Service.afterDispatch` and modify the whole service lifecycle rather than a single action (action-level extensions are covered under [Extensions](#extensions)). + +#### Collection + +Adds **bulk collection** routes to the service on `Service.beforeDispatch`: + +| Route | HTTP | Action | +| --- | --- | --- | +| `/api/{service}/bulk` | POST | `AddEditAction` (bulk create) | +| `/api/{service}/bulk` | PUT | `AddEditAction` (bulk update) | +| `/api/{service}/bulk` | DELETE | `DeleteAction` (bulk delete) | + +Each route also registers an OPTIONS (CORS) counterpart. + +#### Log + +Logs request timing for the service. Starts a timer on `Service.beforeDispatch` and logs the elapsed time (and outcome) on `Service.afterDispatch`. Useful for profiling API endpoints in development. + +#### OptionsHandler + +Answers **OPTIONS** requests (and forced CORS preflights) with a `DummyAction`, returning an empty result so preflight requests get a response without hitting a real action. Useful when clients send `Access-Control-Request-Method` preflights. + + +### Renderers + +The renderer determines the response body format and is set via `Api.renderer`: + +```php +'Api' => [ + 'renderer' => 'CakeDC/Api.JSend', +], +``` + +All renderers extend `CakeDC\Api\Service\Renderer\BaseRenderer` and implement three methods: + +- `accept()` — content negotiation: whether the renderer can serve the current request (`Accept` header). +- `response(?Result $result)` — build the success body from a `Result` (data + payload). +- `error(Exception $exception)` — build the error body when a service call throws. + +BaseRenderer also provides the shared `buildMessage()`, `stackTrace()`, and JSON `encode()` helpers used by the JSON-family renderers. + + +#### JSend (Default) + +`CakeDC/Api.JSend` wraps every response in the [JSend](https://github.com/omniti-labs/jsend) envelope and is the default renderer. + +- Accepts `application/json`, `text/json`, or `text/javascript`. +- `success` status when the result code is `0` or in `200-399`; `error` otherwise. +- On errors the **HTTP status is forced to `200`** (the `errorCode` property) — the real code lives in the body: + +```json +{"status": "error", "message": "Missing route", "code": 404, "data": null} +``` + +- In debug mode the body is pretty-printed and an exception `trace` is included. +- `ValidationException` puts the field errors under `data`. + +> [!WARNING] +> Because JSend keeps the HTTP status at `200` even for failures, clients must inspect the body `status`/`code`, not the HTTP status code. + + +#### Json + +`CakeDC/Api.Json` returns plain JSON with **no envelope** — the result data is the body (payload merged into the data). + +```json +[{"id": 1, "title": "Hello", "published": "Y"}] +``` + +Errors are returned as `{"error": {"code": ..., "message": ..., "trace": ..., "validation": {...}}}` (trace and validation only when applicable). Unlike JSend, the HTTP status is set from the result code. + + +#### Raw + +`CakeDC/Api.Raw` returns the raw payload without JSON encoding: + +- Arrays are rendered with `print_r()`. +- Scalars/strings are cast to the body directly. + +Content type is `text/plain`; errors are the plain exception message (with file/line and a `print_r` trace in debug mode). Useful for debugging or non-JSON consumers. + + +#### Xml + +`CakeDC/Api.Xml` serializes the result data to XML with content type `application/xml`. Errors are rendered as an `` structure containing `code` and `message` (plus `trace` in debug mode and `validation` for `ValidationException`). + + +#### File + +`CakeDC/Api.File` streams a file as the response body using `Response::withFile()`: + +```php +// The action returns a filesystem path as its data +return '/srv/assets/report.pdf'; +``` + +The HTTP status is set from the result code. Errors fall back to a JSON error body. + + +#### Flysystem + +`CakeDC/Api.Flysystem` extends the file renderer to stream a file from a [Flysystem](https://flysystem.thephpleague.com) filesystem. The result data must contain: + +```php +return [ + 'filesystem' => $filesystem, // League\Flysystem\Filesystem + 'path' => 'reports/report.pdf', + 'name' => 'report.pdf', // download name +]; +``` + +A missing file yields an HTTP `404`. + + +### Responses + +Every service/action call produces a `Result` that carries: + +- **data** — the action payload. +- **code** — the application-level result code (`200`, `404`, `422`, `500`, …). +- **exception** — the exception that produced the error, when applicable. +- **payload** — extra response metadata (e.g. pagination, links). + +The result is rendered by the configured renderer. + + +#### Error Codes + +| Code | Meaning | +| --- | --- | +| `200` | Success. | +| `400` | Bad request / generic service error. | +| `401` | Not authenticated. | +| `403` | Not authorized (RBAC denied). | +| `404` | Route or record not found. | +| `405` | Method not allowed. | +| `422` | Validation failed (field errors under `data`). | +| `500` | Server error. | + +Validation failures return the field errors under the response `data` key: + +```json +{ + "status": "error", + "message": "Validation failed", + "code": 422, + "data": { + "title": ["This field is required"] + } +} +``` + + +## Actions + +An **action** is a single endpoint implementation (`index`, `view`, `login`, …). Actions read data from the request, execute, and return data that is wrapped into a `Result`. + + +### CRUD Actions + +The `FallbackService`/`CrudService` provide these actions out of the box: + +| Action | Route | HTTP | +| --- | --- | --- | +| `index` | `/api/articles` | GET | +| `view` | `/api/articles/{id}` | GET | +| `add` | `/api/articles` | POST | +| `edit` | `/api/articles/{id}` | PUT | +| `delete` | `/api/articles/{id}` | DELETE | +| `describe` | `/api/articles` | OPTIONS | + + +### Authentication Actions + +The `AuthService` exposes these endpoints under `/api/auth`: + +| Action | HTTP | Description | +| --- | --- | --- | +| `login` | POST | Form login, returns the session identity. | +| `register` | POST | Register a new user. | +| `reset_password_request` | POST | Request a password reset. | +| `reset_password` | POST | Reset the password with a token. | +| `validate_account_request` | POST | Request account validation. | +| `validate_account` | POST | Validate the account with a token. | +| `social_login` | POST | Login through a social provider. | +| `jwt_login` | POST | Login and issue JWT access + refresh tokens. | +| `jwt_refresh` | POST | Refresh an access token. | +| `jwt_social_login` | POST | Social login issuing JWT tokens. | +| `otp_verify` | GET | Fetch/verify the OTP shared secret (QR code). | +| `otp_verify_check` | POST | Verify an OTP code. | +| `webauthn2fa` | GET | 2FA status. | +| `webauthn2fa_register` / `webauthn2fa_register_options` | GET/POST | Webauthn registration. | +| `webauthn2fa_auth` / `webauthn2fa_auth_options` | GET/POST | Webauthn authentication. | + +> [!NOTE] +> The users table is provided by the **CakeDC/Users** plugin. `ApiInitializer` resolves identities against `CakeDC/Users.Users` with the `active` finder. + + +### Custom Actions + +Create an action by extending `CakeDC\Api\Service\Action\Action` (or `CrudAction` for table-backed actions) and registering it in the service: + +```php +getTable() + ->find() + ->where(['published' => 'Y']) + ->toArray(); + } +} +``` + +Register it in the service: + +```php +$this->mapAction('featured', FeaturedAction::class, [ + 'method' => ['GET'], + 'path' => 'featured', +]); +``` + + +### Action Extensions + +**Action extensions** (`CakeDC\Api\Service\Action\Extension\*`) are the sub-action layer: attached per action via the `Extension` config key, they hook into `Action.Crud.*` / `Action.Auth.*` events. Service-level extensions are covered under [Services → Service Extensions](#service-extensions). + +Action extensions are enabled in configuration: + +```php +'Api' => [ + 'Service' => [ + 'default' => [ + 'Action' => [ + 'default' => [ + 'Extension' => [ + 'CakeDC/Api.Cors', + 'CakeDC/Api.Sort', + 'CakeDC/Api.CrudHateoas', + 'CakeDC/Api.CrudRelations', + ], + ], + 'Index' => [ + 'Extension' => ['CakeDC/Api.Paginate'], + ], + ], + ], + ], +], +``` + + +#### Default Extensions + +The default set is defined under `Api.Service.default.Action.default.Extension` (shown above), plus per-action overrides such as `Index`. + + +#### Paginate + +Applies pagination to index queries and appends a `pagination` payload to the response. + +- Query params: `page` and `limit` (field names configurable via `pageField` / `limitField`). +- Default page size: `20` (`defaultLimit`). +- Listens to `Action.Crud.onFindEntities` (limit + page) and `Action.Crud.afterFindEntities` (pagination metadata). + +```json +{ + "status": "success", + "data": [...], + "pagination": {"count": 42, "page": 2, "pages": 5, "limit": 10} +} +``` + +> [!NOTE] +> `pagination.count` is the **total** row count, not the page size. + + +#### Sort + +Orders index queries by a single field. + +- Query params: `sort` and `direction` (field names via `sortField` / `directionField`). +- Default direction: `asc`. +- Listens to `Action.Crud.onFindEntities` and applies `orderBy`. + +``` +GET /api/posts?sort=title&direction=desc +``` + + +#### Filter + +Filters index queries on any schema column of the table. + +- Field values in the request data are turned into `WHERE` conditions for matching schema columns. +- Supports comparison postfixes on the field name: + +| Postfix | Operator | +| --- | --- | +| *(none)* | `=` | +| `ge` | `>=` | +| `le` | `<=` | +| `gt` | `>` | +| `lt` | `<` | +| `like` / `llike` / `rlike` | `LIKE` | +| `ne` | `!=` | + +``` +GET /api/posts?published=Y&views$ge=100 +``` + + +#### CursorPaginate + +Cursor-based pagination, better suited to large/fast-changing datasets than page-based pagination. + +- Config: `cursorField` (default `id`), `countField` (`count`), `defaultCount` (`20`), `maxIdField` (`max_id`), `sinceIdField` (`since_id`). +- Accepts `count`, `max_id`, `since_id` query params and emits prev/next links via `ReverseRouting`. + + +#### ExtendedSort + +Multi-field sorting. The `sort` param is a JSON-encoded associative array passed straight to `orderBy`: + +``` +GET /api/posts?sort={"title":"asc","created":"desc"} +``` + + +#### CrudHateoas + +Adds a `links` collection to `index`/`view` responses describing the available actions (self, add, edit, delete, and parent links for nested resources). + +```json +"links": [ + {"name": "self", "href": "http://example.com/api/articles/1", "rel": "/api/articles/1", "method": "GET"}, + {"name": "articles:edit", "href": "http://example.com/api/articles/1", "rel": "/api/articles/1", "method": "PUT"} +] +``` + + +#### CrudRelations + +Eager-loads **HasOne** and **BelongsTo** associations so related entities are included in `index`, `view`, and `edit` responses. Listens to `Action.Crud.onFindEntities` / `Action.Crud.onFindEntity`. + + +#### Nested + +Scopes queries by the parent resource for nested endpoints (e.g. `/api/authors/1/articles`). Listens to `onFindEntities`, `onFindEntity`, and `onPatchEntity`, filtering by the parent foreign key (`getParentId()` / `getParentIdName()`). Added automatically by `NestedCrudService`. See also [Nested Resources](#nested-resources). + + +#### Cors + +Appends CORS headers to the response (see [CORS](#cors)). Listens to `Action.beforeProcess`. + + +#### CrudAutocompleteList + +Transforms an index query into an autocomplete list. When the request carries an `autocomplete_list` param, the query selects only the id + display fields. + +``` +GET /api/articles?autocomplete_list=title +``` + + +#### Authentication + +Provides per-action authentication for the action layer: + +- Config: `requireIdentity` (default `true`), `identityAttribute` (`identity`), `logoutRedirect`. +- Actions registered via `allowUnauthenticated()` skip identity checks. +- Listens to `Action.Auth.onAuthentication` / `Action.onAuth`; throws `UnauthenticatedException` when an identity is required but absent. + + +#### Pagination, Sorting and Filtering + +``` +GET /api/posts?page=2&limit=10&sort=title&direction=asc&published=Y +``` + +```json +{ + "status": "success", + "data": [...], + "pagination": { + "count": 42, + "page": 2, + "pages": 5, + "limit": 10 + } +} +``` + +> [!NOTE] +> `pagination.count` is the **total** row count, not the page size. + + +#### HATEOAS & Relations + +With `CakeDC/Api.CrudHateoas`, item and index responses include a `links` collection: + +```json +"links": [ + {"name": "self", "href": "http://example.com/api/articles/1", "rel": "/api/articles/1", "method": "GET"}, + {"name": "articles:edit", "href": "http://example.com/api/articles/1", "rel": "/api/articles/1", "method": "PUT"}, + {"name": "articles:delete", "href": "http://example.com/api/articles/1", "rel": "/api/articles/1", "method": "DELETE"}, + {"name": "articles:index", "href": "http://example.com/api/articles", "rel": "/api/articles", "method": "GET"} +] +``` + + +#### CORS + +The `Cors` extension appends CORS headers to the response for requests with an `Origin` header: + +```php +$this->_request['headers']['Origin'] = 'http://foobar.com'; +``` + +``` +Access-Control-Allow-Origin: * +Access-Control-Allow-Methods: GET, POST, PUT, DELETE, OPTIONS, HEAD, PATCH +Access-Control-Allow-Credentials: true +Access-Control-Max-Age: 300 +``` + + +#### Nested Resources + +The plugin supports **nested resources** out of the box. `FallbackService::loadRoutes()` walks the table's `HasMany` associations and registers nested routes automatically: + +``` +GET /api/authors/1/articles -> articles index scoped to author 1 +GET /api/authors/1/articles/1 -> article 1 of author 1 +POST /api/authors/1/articles -> add an article under author 1 +PUT /api/authors/1/articles/1 -> edit article 1 of author 1 +DELETE /api/authors/1/articles/1 -> delete article 1 of author 1 +``` + +How nesting works: + +- **`NestedCrudService`** is the base for nested-aware services; it injects the `CakeDC/Api.Nested` extension into the child action options. +- **`NestedExtension`** scopes every query by the parent foreign key — on `onFindEntities` / `onFindEntity` it adds `where([$parentIdName => $parentId])`, and on `onPatchEntity` it enforces the parent when patching. +- **Parent resolution** — when the route's controller matches one of the parent's `innerServices`, the plugin resolves the child service through `ServiceRegistry`, calls `setParentService($parent)` on it, and the child exposes `getParentService()`. `innerServices` is populated automatically from the parent table's `HasMany` associations. +- **HATEOAS** — nested index/view responses include a parent link (`authors:view`) pointing back to the parent resource. +- **Arbitrary nesting depth** — because each level resolves a child service with its own parent, `/api/authors/1/articles/2/tags` works the same way. + +Use `getParentService()` inside actions when you need access to the parent record or its id. + + +### Transformers + +Transformers shape **action response data** — they convert entities/arrays (the model layer) into the API response format an action returns. Implement `CakeDC\Api\Transformer\TransformerInterface` (`transform(mixed $data): array`) or extend `AbstractTransformer` for the built-in helpers: + +- `when($condition, $value, $default)` — conditional field value. +- `get($data, $key, $default)` — value from array, object, or entity. +- `timestamp($date)` — ISO-8601 formatting of dates. +- `item($entity, $class)` / `collection($entities, $class)` — transform nested items. +- `matchingData(...)` / `joinData(...)` — transform CakePHP association payloads. + +```php + $this->get($data, 'id'), + 'title' => $this->get($data, 'title'), + 'published_at' => $this->timestamp($this->get($data, 'published')), + ]; + } +} +``` + +A transformer is used inside an action to shape the payload before it is wrapped into a `Result` and rendered. `all()` returns a collection (`CollectionInterface`), so the transform is applied lazily via the collection's `map()`: + +```php +getTable() + ->find() + ->where(['published' => 'Y']) + ->all() + ->map(fn($article): array => $transformer->transform($article)); + } +} +``` + +The mapped collection becomes the action's result data and is rendered by the configured renderer. + + +## Authentication + +`ApiInitializer` builds the `AuthenticationService` used by the `AuthenticationMiddleware`. + + +### Authenticators + +The following authenticators are loaded: + +- **`Authentication.Session`** — reads the identity from the session (`Auth` key). +- **`CakeDC/Auth.Form`** — form login against the users table. +- **`Authentication.Token`** — static token via `?token=` query param, matched against the `api_token` column. +- **`Authentication.Jwt`** — JWT bearer tokens (`Authorization: Bearer `), HS512, signed with `Api.Jwt.AccessToken.secret`. + + +### JWT Authentication + +Enable JWT in your configuration: + +```php +'Api' => [ + 'Jwt' => [ + 'enabled' => true, + 'AccessToken' => [ + 'lifetime' => 600, + 'secret' => env('JWT_SECRET'), // >= 512 bits for HS512 + ], + 'RefreshToken' => [ + 'lifetime' => 14 * 86400, + 'secret' => env('JWT_REFRESH_SECRET'), + ], + ], +], +``` + +> [!IMPORTANT] +> HS512 requires a signing key of **at least 512 bits** (64 bytes). Shorter secrets cause `Lcobucci\JWT\Signer\InvalidKeyProvided`. + +Login issues both tokens: + +``` +POST /api/auth/jwt_login +{ + "username": "user-1", + "password": "12345" +} +``` + +```json +{ + "status": "success", + "data": { + "id": 1, + "username": "user-1", + "access_token": "eyJ0eXAi...", + "refresh_token": "eyJ0eXAi...", + "expired": "2026-08-15T12:00:00+00:00", + "enabled2FA": false, + "enabledWebauthn": false, + "enabledOtp": false + } +} +``` + +> [!NOTE] +> The three `enabled*` flags are all `false` unless `Api.2fa.enabled` is turned on (and the relevant checker requires the user). + +Refresh tokens are persisted in the `jwt_refresh_tokens` table (upserted per `model` + `foreign_key`). + + +### Token Responses + +The token payload strips sensitive fields (`secret`, `secret_verified`, `additional_data`) and includes the 2FA flags `enabled2FA`, `enabledWebauthn` and `enabledOtp`. + +The JWT `aud` claim depends on 2FA state: + +- Normal login → `Router::url('/', true)`. +- 2FA-enabled login (`type = login` with 2FA active) → `Router::url('/2fa', true)` — this is what the RBAC `TwoFactorScope` checks. + + +## Two-Factor Authentication + +2FA is disabled by default: + +```php +'Api' => [ + '2fa' => ['enabled' => false], + 'OneTimePasswordAuthenticator' => [ + 'login' => false, + 'checker' => \CakeDC\Api\Service\Auth\TwoFactorAuthentication\DefaultOneTimePasswordAuthenticationChecker::class, + ], + 'Webauthn2fa' => [ + 'checker' => \CakeDC\Api\Service\Auth\TwoFactorAuthentication\DefaultWebauthn2fAuthenticationChecker::class, + ], +], +``` + + +### One-Time Passwords + +OTP uses `RobThree\Auth\TwoFactorAuth` (TOTP, base32 shared secret). + +- `GET /api/auth/otp_verify` — returns the shared secret and a QR-code data URI (`secretDataUri`) until the secret is marked verified. +- `POST /api/auth/otp_verify_check` — verifies a code against the secret and marks `secret_verified`. + +The checker contract: + +```php +$checker->isEnabled(); // Configure::read('Api.OneTimePasswordAuthenticator.login') !== false +$checker->isRequired($user); // non-empty user && enabled +``` + + +### Webauthn + +Webauthn registration and authentication are exposed through the `webauthn2fa_*` actions. The checker reads `Api.Webauthn2fa.enabled`; custom checkers can be injected via the `checker` config keys. + + +### RBAC Scopes + +Two RBAC rules gate 2FA flows (used inside permissions as `rule` entries): + +| Rule | Allows when | +| --- | --- | +| `CakeDC\Api\Rbac\Rules\TwoFactorScope` | the JWT `aud` claim equals `Router::url('/2fa', true)` | +| `CakeDC\Api\Rbac\Rules\TwoFactorPassedScope` | the JWT `aud` claim equals `Router::url('/', true)` | + +A typical permission grants the 2FA endpoints only to tokens whose audience is `/2fa`, and everything else to fully-verified tokens: + +```php +return [ + 'CakeDC/Auth.api_permissions' => [ + ['role' => '*', 'service' => 'Auth', 'action' => ['OtpVerify', 'OtpVerifyCheck'], 'rule' => [ + 'className' => \CakeDC\Api\Rbac\Rules\TwoFactorScope::class, + ]], + ['role' => '*', 'service' => '*', 'action' => '*', 'rule' => [ + 'className' => \CakeDC\Api\Rbac\Rules\TwoFactorPassedScope::class, + ]], + ], +]; +``` + + +## Authorization & Permissions + +Authorization is handled by the **CakeDC/Auth** `RbacPolicy`, backed by the plugin's `ApiRbac` adapter. Each permission is a rule array; the first matching rule decides the outcome. + +Permissions are loaded from a configuration file (`ApiConfigProvider`), keyed as `CakeDC/Auth.api_permissions`: + +```php +// config/api_permissions.php +return [ + 'CakeDC/Auth.api_permissions' => [ + // Admin can do everything + ['role' => 'admin', 'service' => '*', 'action' => '*'], + // Public login is always allowed + ['role' => '*', 'service' => 'Auth', 'action' => 'login', 'bypassAuth' => true], + ], +]; +``` + +> [!TIP] +> If the config file is missing, the plugin falls back to `ApiConfigProvider`'s default permissions (Auth bypass + admin-all + user-GET). + + +### Permission Format + +A permission entry is a plain array: + +| Key | Description | +| --- | --- | +| `role` | User role(s); missing → `*`. | +| `service` | Service name(s); `*` matches any. | +| `action` | Action name(s); `*` matches any. | +| `method` | HTTP method (`GET`, `POST`, …). | +| `bypassAuth` | `true` → allow without authentication. | +| `allowed` | Explicit `true`/`false`; defaults to `true`. | +| `rule` | Callable, `Rule` instance, or `['className' => ..., 'options' => [...]]`. | +| any other key | Matched against the user array (e.g. `'id' => 1`). | + +- Action names in config use **camelized** form (`MyAction`), matching the route's underscored `my_action`. +- Keys prefixed with `*` are **inverted** (`'*service' => 'articles'` denies when the service is `articles`). +- A permission missing both `service` and `action` is rejected (`cannot evaluate`). + + +### Rules + +```php +// Callable +['role' => 'user', 'service' => 'articles', 'action' => 'index', + 'rule' => fn($user, $role, $request) => $user['id'] === 1], + +// Rule object +['role' => 'user', 'service' => 'articles', 'action' => 'edit', + 'rule' => new IsOwnerRule()], + +// Rule class via the rule registry +['role' => 'user', 'service' => 'articles', 'action' => 'edit', + 'rule' => ['className' => \App\Rbac\IsOwnerRule::class]], +``` + + +### Cached RBAC + +For high-throughput applications, `CachedApiRbac` precomputes a permissions map (keyed by role → service) and stores it in the cache engine `_cakedc_api_auth_`: + +```php +use CakeDC\Api\Rbac\CachedApiRbac; + +// Use CachedApiRbac as the RBAC adapter instead of ApiRbac +``` + +> [!NOTE] +> The cache engine name is currently hardcoded to `_cakedc_api_auth_`; configure that engine (or an Array engine in tests) before using `CachedApiRbac`. + + +### CakeDC/Auth Integration + +The plugin is a first-class citizen of the **CakeDC/Auth** authorization stack. `ApiInitializer::getAuthorizationService()` wires the RBAC adapter into CakePHP's `Authorization` plugin: + +```php +$map = new MapResolver(); +$rbac = new ApiRbac(); // swap for CachedApiRbac to enable the cached permissions map +$map->map( + ServerRequest::class, + new CollectionPolicy([ + new RbacPolicy(['adapter' => $rbac]), + ]) +); +$resolver = new ResolverCollection([$map, new OrmResolver()]); + +return new AuthorizationService($resolver); +``` + +What that gives you: + +- **`RbacPolicy`** evaluates every API request against the permissions (see [Authorization & Permissions](#authorization-permissions)); the `adapter` is the plugin's `ApiRbac`. +- **Cached case** — swap the adapter for `CachedApiRbac`: it precomputes a permissions map (role → service → rules) once via `buildPermissionsMap()`, stores it under the `_cakedc_api_auth_` cache engine, and `checkPermissions()` walks the cached map instead of re-evaluating every rule — useful for high-traffic APIs. +- **2FA-aware rules** — the same `RbacPolicy` accepts the plugin's `TwoFactorScope` / `TwoFactorPassedScope` rules for gating 2FA endpoints (see [RBAC Scopes](#rbac-scopes)). +- Permissions load from `CakeDC/Auth.api_permissions` via `ApiConfigProvider`, with a sensible default when the config file is missing. + + +## Versioning + +Enable versioning in the configuration: + +```php +'Api' => [ + 'useVersioning' => true, + 'versionPrefix' => 'v', +], +``` + +With versioning enabled, the URL prefix includes the version and the service is resolved from a versioned namespace: + +``` +/api/v1/articles -> App\Service\v1\ArticlesService +/api/v2/articles -> App\Service\v2\ArticlesService +``` + +```php +// src/Service/v1/ArticlesService.php +namespace App\Service\v1; + +use App\Service\ArticlesService as BaseArticlesService; + +class ArticlesService extends BaseArticlesService +{ +} +``` + +> [!NOTE] +> The version directory mirrors the version string (`v1` → `Service/v1`). An unresolvable version falls back to `FallbackService`. `Api.defaultVersion` provides the version used when none is given. + +`/api/describe` and `/api/list` also honor the version prefix when versioning is enabled. + + +## Model & JWT Refresh Tokens + +The plugin ships two ORM tables: + +| Table | Purpose | +| --- | --- | +| `jwt_refresh_tokens` | Persists refresh tokens (`model`, `foreign_key`, `token`, `expired`); the `token` is hidden from JSON serialization. | +| `auth_store` | Stores serialized authentication state (used by the Webauthn adapters); the `store` column is typed as JSON. | + +> [!NOTE] +> `AuthStore`'s validator uses `scalar('store')`, so array payloads are rejected by the standard marshalling path — pass a scalar (e.g. a JSON string) or save with `['validate' => false]`. + + +## Testing + +The plugin ships an `IntegrationTestCase` that boots the real HTTP stack. The test application under `tests/App` provides sample services (`ArticlesService`, `PostsService`, `TagsService`) and fixtures. + +```php +_tokenAccess(); + $this->getDefaultUser(Settings::USER1); + } + + public function testIndex(): void + { + $this->sendRequest('/articles', 'GET', ['limit' => 5]); + $result = $this->getJsonResponse(); + $this->assertSuccess($result); + $this->assertNotEmpty($result['data']); + } + + public function testValidationError(): void + { + $this->sendRequest('/articles', 'POST', ['title' => '']); + $result = $this->getJsonResponse(); + $this->assertError($result, 422); + $this->assertErrorMessage($result, 'Validation failed'); + } +} +``` + +Useful assertions: `assertSuccess`, `assertError($result, $code)`, `assertErrorMessage`, `assertStatus`, `getJsonResponse`. + + +## Console Commands + + +### `service routes` + +Prints all routes registered by a service — handy for verifying the action map, URL templates, and route collisions before going live. + +```bash +bin/cake service routes +``` + +Example: + +```bash +bin/cake service routes articles +``` + +Output (as a console table): + +``` ++--------------+-------------+------------------+----------+--------+----------+ +| Route name | Method(s) | URI template | Service | Action | Plugin | ++--------------+-------------+------------------+----------+--------+----------+ +| | GET, POST | /articles | articles | index | | +| | GET, PUT, DELETE | /articles/:id | articles | view | | ++--------------+-------------+------------------+----------+--------+----------+ +``` + +Options: + +- `--verbose` — adds a `Defaults` column with the full route defaults (JSON). +- `--sort` — sorts the route table by route name. +- `service` (required argument) — the service name to inspect. + +The command also detects and warns about **possible route collisions** (same template + HTTP method matched by more than one route). + + +## Legacy & Obsolete Components + +The following components originate from the CakePHP 2/3 era. They are **kept** because consumers depend on them, but they are not part of the current request lifecycle: + +| Component | Status | +| --- | --- | +| `CakeDC\Api\Service\Auth\Auth` (`allow`/`deny`) | Old Cake2-style auth layer. | +| `Middleware\RequestHandlerMiddleware` | Deprecated no-op subclass of `BodyParserMiddleware`; use `BodyParserMiddleware` directly. | + +Do not remove these without a major-version compatibility plan. diff --git a/src/ApiPlugin.php b/src/ApiPlugin.php index 7ec0e29..d08a81e 100644 --- a/src/ApiPlugin.php +++ b/src/ApiPlugin.php @@ -29,6 +29,7 @@ class ApiPlugin extends BasePlugin /** * @inheritDoc */ + #[\Override] public function routes(\Cake\Routing\RouteBuilder $routes): void { $middlewares = Configure::read('Api.Middleware', []); @@ -90,6 +91,7 @@ public function services(ContainerInterface $container): void * @param \Cake\Console\CommandCollection $commands The command collection to update * @return \Cake\Console\CommandCollection */ + #[\Override] public function console(CommandCollection $commands): CommandCollection { return $commands->add('service routes', ServiceRoutesCommand::class); diff --git a/src/Command/ServiceRoutesCommand.php b/src/Command/ServiceRoutesCommand.php index bac3327..ff1781d 100644 --- a/src/Command/ServiceRoutesCommand.php +++ b/src/Command/ServiceRoutesCommand.php @@ -27,6 +27,7 @@ class ServiceRoutesCommand extends Command /** * @inheritDoc */ + #[\Override] public static function defaultName(): string { return 'service routes'; @@ -38,6 +39,7 @@ public static function defaultName(): string * @param \Cake\Console\ConsoleOptionParser $parser The option parser to update * @return \Cake\Console\ConsoleOptionParser */ + #[\Override] protected function buildOptionParser(ConsoleOptionParser $parser): ConsoleOptionParser { $parser = parent::buildOptionParser($parser); diff --git a/src/Model/Table/AuthStoreTable.php b/src/Model/Table/AuthStoreTable.php index 14bfbab..fe69a7b 100644 --- a/src/Model/Table/AuthStoreTable.php +++ b/src/Model/Table/AuthStoreTable.php @@ -50,6 +50,7 @@ public function initialize(array $config): void * @param \Cake\Validation\Validator $validator Validator instance. * @return \Cake\Validation\Validator */ + #[\Override] public function validationDefault(Validator $validator): Validator { $validator @@ -64,6 +65,7 @@ public function validationDefault(Validator $validator): Validator * * @return \Cake\Database\Schema\TableSchemaInterface */ + #[\Override] public function getSchema(): TableSchemaInterface { $schema = parent::getSchema(); diff --git a/src/Model/Table/JwtRefreshTokensTable.php b/src/Model/Table/JwtRefreshTokensTable.php index b363224..a75c85d 100644 --- a/src/Model/Table/JwtRefreshTokensTable.php +++ b/src/Model/Table/JwtRefreshTokensTable.php @@ -53,6 +53,7 @@ public function initialize(array $config): void * @param \Cake\Validation\Validator $validator Validator instance. * @return \Cake\Validation\Validator */ + #[\Override] public function validationDefault(Validator $validator): Validator { $validator diff --git a/src/Rbac/CachedApiRbac.php b/src/Rbac/CachedApiRbac.php index b8a0e33..b35c1cf 100644 --- a/src/Rbac/CachedApiRbac.php +++ b/src/Rbac/CachedApiRbac.php @@ -89,6 +89,7 @@ public function buildPermissionsMap(): array * @param \Psr\Http\Message\ServerRequestInterface $request request * @return bool true if there is a match in permissions */ + #[\Override] public function checkPermissions(array|\ArrayAccess $user, ServerRequestInterface $request): bool { $roleField = $this->getConfig('role_field'); diff --git a/src/Service/Action/Auth/JwtLoginAction.php b/src/Service/Action/Auth/JwtLoginAction.php index df8830d..75e6289 100644 --- a/src/Service/Action/Auth/JwtLoginAction.php +++ b/src/Service/Action/Auth/JwtLoginAction.php @@ -31,6 +31,7 @@ class JwtLoginAction extends Action * @param bool $socialLogin is social login * @return array */ + #[\Override] protected function afterIdentifyUser(?array $user, bool $socialLogin = false): array { $user = parent::afterIdentifyUser($user, $socialLogin); diff --git a/src/Service/Action/Auth/JwtRefreshAction.php b/src/Service/Action/Auth/JwtRefreshAction.php index 70d89dd..fcb07ec 100644 --- a/src/Service/Action/Auth/JwtRefreshAction.php +++ b/src/Service/Action/Auth/JwtRefreshAction.php @@ -43,6 +43,7 @@ class JwtRefreshAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -53,6 +54,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $authHeader = $this->getService()->getRequest()->getHeader('Authorization'); diff --git a/src/Service/Action/Auth/JwtSocialLoginAction.php b/src/Service/Action/Auth/JwtSocialLoginAction.php index bac6266..0e5d358 100644 --- a/src/Service/Action/Auth/JwtSocialLoginAction.php +++ b/src/Service/Action/Auth/JwtSocialLoginAction.php @@ -30,6 +30,7 @@ class JwtSocialLoginAction extends Action * @return false|array * @throws \Exception */ + #[\Override] public function execute(): false|array { $user = parent::execute(); diff --git a/src/Service/Action/Auth/LoginAction.php b/src/Service/Action/Auth/LoginAction.php index f3a197e..714ca4e 100644 --- a/src/Service/Action/Auth/LoginAction.php +++ b/src/Service/Action/Auth/LoginAction.php @@ -43,6 +43,7 @@ class LoginAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { if (isset($config['identifiedField'])) { @@ -60,6 +61,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); @@ -130,6 +132,7 @@ protected function afterIdentifyUser(?array $user, bool $socialLogin = false): a * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Auth/OtpVerifyAction.php b/src/Service/Action/Auth/OtpVerifyAction.php index 081b3f0..7d21441 100644 --- a/src/Service/Action/Auth/OtpVerifyAction.php +++ b/src/Service/Action/Auth/OtpVerifyAction.php @@ -38,6 +38,7 @@ abstract class OtpVerifyAction extends Action * @param array $config Configuration. * @return void */ + #[\Override] public function initialize(array $config): void { $this->tfa = new TwoFactorAuth( diff --git a/src/Service/Action/Auth/RegisterAction.php b/src/Service/Action/Auth/RegisterAction.php index f2c0f29..8c2349e 100644 --- a/src/Service/Action/Auth/RegisterAction.php +++ b/src/Service/Action/Auth/RegisterAction.php @@ -38,6 +38,7 @@ class RegisterAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -49,6 +50,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { /** @var \CakeDC\Users\Model\Behavior\RegisterBehavior $registerBehavior */ @@ -135,6 +137,7 @@ protected function afterRegister(EntityInterface $userSaved): EntityInterface|ar * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Auth/ResetPasswordAction.php b/src/Service/Action/Auth/ResetPasswordAction.php index 4f5166c..c1c9da0 100644 --- a/src/Service/Action/Auth/ResetPasswordAction.php +++ b/src/Service/Action/Auth/ResetPasswordAction.php @@ -39,6 +39,7 @@ class ResetPasswordAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -50,6 +51,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); @@ -144,6 +146,7 @@ protected function changePassword($userId): string * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Auth/ResetPasswordRequestAction.php b/src/Service/Action/Auth/ResetPasswordRequestAction.php index 55d7bd8..58839ac 100644 --- a/src/Service/Action/Auth/ResetPasswordRequestAction.php +++ b/src/Service/Action/Auth/ResetPasswordRequestAction.php @@ -38,6 +38,7 @@ class ResetPasswordRequestAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -49,6 +50,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); @@ -108,6 +110,7 @@ public function execute(): mixed * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Auth/SocialLoginAction.php b/src/Service/Action/Auth/SocialLoginAction.php index c066905..cd81ccf 100644 --- a/src/Service/Action/Auth/SocialLoginAction.php +++ b/src/Service/Action/Auth/SocialLoginAction.php @@ -41,6 +41,7 @@ class SocialLoginAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -52,6 +53,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); @@ -108,6 +110,7 @@ public function execute(): mixed * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Auth/ValidateAccountAction.php b/src/Service/Action/Auth/ValidateAccountAction.php index 8fa544a..0e2b51e 100644 --- a/src/Service/Action/Auth/ValidateAccountAction.php +++ b/src/Service/Action/Auth/ValidateAccountAction.php @@ -38,6 +38,7 @@ class ValidateAccountAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -49,6 +50,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); @@ -95,6 +97,7 @@ public function execute(): mixed * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Auth/ValidateAccountRequestAction.php b/src/Service/Action/Auth/ValidateAccountRequestAction.php index 0999d16..3b7e802 100644 --- a/src/Service/Action/Auth/ValidateAccountRequestAction.php +++ b/src/Service/Action/Auth/ValidateAccountRequestAction.php @@ -38,6 +38,7 @@ class ValidateAccountRequestAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -49,6 +50,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); @@ -101,6 +103,7 @@ public function execute(): mixed * * @return array */ + #[\Override] protected function authConfig(): array { return Hash::merge(parent::authConfig(), [ diff --git a/src/Service/Action/Collection/AddEditAction.php b/src/Service/Action/Collection/AddEditAction.php index d62ac45..cd05725 100644 --- a/src/Service/Action/Collection/AddEditAction.php +++ b/src/Service/Action/Collection/AddEditAction.php @@ -35,6 +35,7 @@ class AddEditAction extends CollectionAction * * @return bool */ + #[\Override] public function validates(): bool { return $this->validateMany(); diff --git a/src/Service/Action/Collection/DeleteAction.php b/src/Service/Action/Collection/DeleteAction.php index 1e93e94..0187b88 100644 --- a/src/Service/Action/Collection/DeleteAction.php +++ b/src/Service/Action/Collection/DeleteAction.php @@ -26,6 +26,7 @@ class DeleteAction extends CollectionAction /** * @inheritDoc */ + #[\Override] public function validates(): bool { $data = $this->getData(); diff --git a/src/Service/Action/CrudAction.php b/src/Service/Action/CrudAction.php index 36cbf08..110b1fe 100644 --- a/src/Service/Action/CrudAction.php +++ b/src/Service/Action/CrudAction.php @@ -135,6 +135,7 @@ public function setTable(Table $table) /** * @return \CakeDC\Api\Service\CrudService */ + #[\Override] public function getService(): \CakeDC\Api\Service\CrudService { return $this->service; diff --git a/src/Service/Action/CrudAddAction.php b/src/Service/Action/CrudAddAction.php index 752ca1a..c6eb2e4 100644 --- a/src/Service/Action/CrudAddAction.php +++ b/src/Service/Action/CrudAddAction.php @@ -27,6 +27,7 @@ class CrudAddAction extends CrudAction * * @return bool */ + #[\Override] public function validates(): bool { $validator = $this->getTable()->getValidator(); diff --git a/src/Service/Action/CrudEditAction.php b/src/Service/Action/CrudEditAction.php index faacb84..21d0f7a 100644 --- a/src/Service/Action/CrudEditAction.php +++ b/src/Service/Action/CrudEditAction.php @@ -27,6 +27,7 @@ class CrudEditAction extends CrudAction * * @return bool */ + #[\Override] public function validates(): bool { $validator = $this->getTable()->getValidator(); diff --git a/src/Service/Action/DescribeAction.php b/src/Service/Action/DescribeAction.php index 04f667c..d3c57ec 100644 --- a/src/Service/Action/DescribeAction.php +++ b/src/Service/Action/DescribeAction.php @@ -30,6 +30,7 @@ class DescribeAction extends Action * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); diff --git a/src/Service/Action/DummyAction.php b/src/Service/Action/DummyAction.php index e4600a9..afcf612 100644 --- a/src/Service/Action/DummyAction.php +++ b/src/Service/Action/DummyAction.php @@ -25,6 +25,7 @@ class DummyAction extends Action * * @return bool */ + #[\Override] public function validates(): bool { return true; diff --git a/src/Service/Action/ListAction.php b/src/Service/Action/ListAction.php index 10314fa..0f2de38 100644 --- a/src/Service/Action/ListAction.php +++ b/src/Service/Action/ListAction.php @@ -29,6 +29,7 @@ class ListAction extends Action * @param array $config Configuration options passed to the constructor * @return void */ + #[\Override] public function initialize(array $config): void { parent::initialize($config); diff --git a/src/Service/Attribute/ApiActions.php b/src/Service/Attribute/ApiActions.php new file mode 100644 index 0000000..0150d52 --- /dev/null +++ b/src/Service/Attribute/ApiActions.php @@ -0,0 +1,35 @@ + $actions Action class names. + */ + public function __construct( + public string|array $actions, + ) { + } +} diff --git a/src/Service/Attribute/ApiDelete.php b/src/Service/Attribute/ApiDelete.php new file mode 100644 index 0000000..29142c3 --- /dev/null +++ b/src/Service/Attribute/ApiDelete.php @@ -0,0 +1,46 @@ + $patterns Route patterns. + * @param array $defaults Route defaults. + * @param array|null $pass Passed placeholders. + */ + public function __construct( + string $action, + string $path, + bool $mapCors = false, + ?string $name = null, + array $patterns = [], + array $defaults = [], + ?array $pass = null, + ) { + parent::__construct($action, $path, 'DELETE', $mapCors, $name, $patterns, $defaults, $pass); + } +} diff --git a/src/Service/Attribute/ApiGet.php b/src/Service/Attribute/ApiGet.php new file mode 100644 index 0000000..30b08a8 --- /dev/null +++ b/src/Service/Attribute/ApiGet.php @@ -0,0 +1,46 @@ + $patterns Route patterns. + * @param array $defaults Route defaults. + * @param array|null $pass Passed placeholders. + */ + public function __construct( + string $action, + string $path, + bool $mapCors = false, + ?string $name = null, + array $patterns = [], + array $defaults = [], + ?array $pass = null, + ) { + parent::__construct($action, $path, 'GET', $mapCors, $name, $patterns, $defaults, $pass); + } +} diff --git a/src/Service/Attribute/ApiOptions.php b/src/Service/Attribute/ApiOptions.php new file mode 100644 index 0000000..1a97cfb --- /dev/null +++ b/src/Service/Attribute/ApiOptions.php @@ -0,0 +1,46 @@ + $patterns Route patterns. + * @param array $defaults Route defaults. + * @param array|null $pass Passed placeholders. + */ + public function __construct( + string $action, + string $path, + bool $mapCors = false, + ?string $name = null, + array $patterns = [], + array $defaults = [], + ?array $pass = null, + ) { + parent::__construct($action, $path, 'OPTIONS', $mapCors, $name, $patterns, $defaults, $pass); + } +} diff --git a/src/Service/Attribute/ApiPatch.php b/src/Service/Attribute/ApiPatch.php new file mode 100644 index 0000000..05eb8aa --- /dev/null +++ b/src/Service/Attribute/ApiPatch.php @@ -0,0 +1,46 @@ + $patterns Route patterns. + * @param array $defaults Route defaults. + * @param array|null $pass Passed placeholders. + */ + public function __construct( + string $action, + string $path, + bool $mapCors = false, + ?string $name = null, + array $patterns = [], + array $defaults = [], + ?array $pass = null, + ) { + parent::__construct($action, $path, 'PATCH', $mapCors, $name, $patterns, $defaults, $pass); + } +} diff --git a/src/Service/Attribute/ApiPost.php b/src/Service/Attribute/ApiPost.php new file mode 100644 index 0000000..089fd1a --- /dev/null +++ b/src/Service/Attribute/ApiPost.php @@ -0,0 +1,46 @@ + $patterns Route patterns. + * @param array $defaults Route defaults. + * @param array|null $pass Passed placeholders. + */ + public function __construct( + string $action, + string $path, + bool $mapCors = false, + ?string $name = null, + array $patterns = [], + array $defaults = [], + ?array $pass = null, + ) { + parent::__construct($action, $path, 'POST', $mapCors, $name, $patterns, $defaults, $pass); + } +} diff --git a/src/Service/Attribute/ApiPut.php b/src/Service/Attribute/ApiPut.php new file mode 100644 index 0000000..0dcc973 --- /dev/null +++ b/src/Service/Attribute/ApiPut.php @@ -0,0 +1,46 @@ + $patterns Route patterns. + * @param array $defaults Route defaults. + * @param array|null $pass Passed placeholders. + */ + public function __construct( + string $action, + string $path, + bool $mapCors = false, + ?string $name = null, + array $patterns = [], + array $defaults = [], + ?array $pass = null, + ) { + parent::__construct($action, $path, 'PUT', $mapCors, $name, $patterns, $defaults, $pass); + } +} diff --git a/src/Service/Attribute/ApiResource.php b/src/Service/Attribute/ApiResource.php new file mode 100644 index 0000000..4c0ea3f --- /dev/null +++ b/src/Service/Attribute/ApiResource.php @@ -0,0 +1,40 @@ +resources()` when the service routes are loaded. + */ +#[Attribute(Attribute::TARGET_CLASS)] +readonly class ApiResource +{ + /** + * @param string|null $path Override the resource URL path. + * @param array $only Limit which REST actions are generated (index/view/add/edit/delete). + * @param array $actions Map REST actions to custom action classes. + * @param array $map Additional non-standard resource routes. + * @param string $id Regex pattern for the resource identifier. + */ + public function __construct( + public ?string $path = null, + public array $only = [], + public array $actions = [], + public array $map = [], + public string $id = '[0-9]+', + ) { + } +} diff --git a/src/Service/Attribute/ApiRoute.php b/src/Service/Attribute/ApiRoute.php new file mode 100644 index 0000000..e7bcabc --- /dev/null +++ b/src/Service/Attribute/ApiRoute.php @@ -0,0 +1,48 @@ + $method HTTP method(s). + * @param bool $mapCors Register an OPTIONS route for CORS preflight. + * @param string|null $name Optional route name. + * @param array $patterns Regex patterns for route placeholders. + * @param array $defaults Additional route defaults. + * @param array|null $pass Placeholder names passed to the action. + */ + public function __construct( + public string $action, + public string $path, + public string|array $method = 'GET', + public bool $mapCors = false, + public ?string $name = null, + public array $patterns = [], + public array $defaults = [], + public ?array $pass = null, + ) { + } +} diff --git a/src/Service/Attribute/ApiScope.php b/src/Service/Attribute/ApiScope.php new file mode 100644 index 0000000..364581c --- /dev/null +++ b/src/Service/Attribute/ApiScope.php @@ -0,0 +1,38 @@ + $defaults Default route values merged into every route. + * @param array $patterns Shared regex patterns for placeholders. + */ + public function __construct( + public string $path = '', + public array $defaults = [], + public array $patterns = [], + ) { + } +} diff --git a/src/Service/Attribute/ServiceAttributeConnector.php b/src/Service/Attribute/ServiceAttributeConnector.php new file mode 100644 index 0000000..4188dcf --- /dev/null +++ b/src/Service/Attribute/ServiceAttributeConnector.php @@ -0,0 +1,223 @@ + + */ + protected const array SUPPORTED_ATTRIBUTES = [ + ApiActions::class, + ApiRoute::class, + ApiGet::class, + ApiPost::class, + ApiPut::class, + ApiPatch::class, + ApiDelete::class, + ApiOptions::class, + ApiScope::class, + ApiResource::class, + ]; + + /** + * Apply routing attributes declared on the service class to the given instance. + * + * @param \CakeDC\Api\Service\Service $service Service instance. + * @return void + */ + public function apply(Service $service): void + { + if (AttributeResolver::getConfig('default') === null) { + return; + } + + $className = $service::class; + $hierarchy = array_reverse(array_values(class_parents($className))); + $hierarchy[] = $className; + + $collection = AttributeResolver::collection('default') + ->withAttribute(static::SUPPORTED_ATTRIBUTES); + + $serviceInfos = $collection->withClassName($hierarchy)->toList(); + + $state = $this->buildClassState($hierarchy, $serviceInfos); + if ($state['resource'] !== null) { + $service->setResourceOptions($state['resource']); + } + + foreach ($this->actionClasses($serviceInfos) as $actionClass) { + foreach ($this->routeAttributes($collection->withClassName($actionClass)->toList()) as $entry) { + $this->connectRoute($service, $state, $actionClass, $entry['action'], $entry['route']); + } + } + } + + /** + * Aggregate class-level service state across the class hierarchy. + * + * @param list $hierarchy Parent-to-child class names. + * @param list<\Cake\AttributeResolver\ValueObject\AttributeInfo> $infos Attribute metadata. + * @return array{scopePath: string, scopeDefaults: array, scopePatterns: array, resource: array|null} + */ + protected function buildClassState(array $hierarchy, array $infos): array + { + $byClass = []; + foreach ($infos as $info) { + if ($info->target->type === AttributeTargetType::CLASS_) { + $byClass[$info->className][] = $info; + } + } + + $state = [ + 'scopePath' => '', + 'scopeDefaults' => [], + 'scopePatterns' => [], + 'resource' => null, + ]; + + foreach ($hierarchy as $serviceClass) { + foreach ($byClass[$serviceClass] ?? [] as $info) { + $instance = $info->getInstance(); + if ($instance instanceof ApiScope) { + $state['scopePath'] .= $instance->path; + $state['scopeDefaults'] = array_merge($state['scopeDefaults'], $instance->defaults); + $state['scopePatterns'] = array_merge($state['scopePatterns'], $instance->patterns); + + continue; + } + if ($instance instanceof ApiResource) { + $state['resource'] = [ + 'path' => $instance->path, + 'only' => $instance->only, + 'actions' => $instance->actions, + 'map' => $instance->map, + 'id' => $instance->id, + ]; + } + } + } + + return $state; + } + + /** + * Collect action classes listed by `ApiActions` attributes across the hierarchy. + * + * @param list<\Cake\AttributeResolver\ValueObject\AttributeInfo> $infos Attribute metadata. + * @return list + */ + protected function actionClasses(array $infos): array + { + $classes = []; + foreach ($infos as $info) { + if ($info->target->type !== AttributeTargetType::CLASS_) { + continue; + } + if (!$info->isInstanceOf(ApiActions::class)) { + continue; + } + $instance = $info->getInstance(); + foreach ((array)$instance->actions as $actionClass) { + $classes[] = $actionClass; + } + } + + return $classes; + } + + /** + * Collect route attributes declared on an action class. + * + * @param list<\Cake\AttributeResolver\ValueObject\AttributeInfo> $infos Attribute metadata. + * @return list + */ + protected function routeAttributes(array $infos): array + { + $routes = []; + foreach ($infos as $info) { + if ($info->target->type !== AttributeTargetType::CLASS_) { + continue; + } + if (!$info->isInstanceOf(ApiRoute::class)) { + continue; + } + $route = $info->getInstance(); + $routes[] = [ + 'action' => $route->action, + 'route' => $route, + ]; + } + + return $routes; + } + + /** + * Connect a route attribute into the service actions map. + * + * @param \CakeDC\Api\Service\Service $service Service instance. + * @param array{scopePath: string, scopeDefaults: array, scopePatterns: array, resource: array|null} $state Service state. + * @param string $actionClass Action class name. + * @param string $actionName Action name. + * @param \CakeDC\Api\Service\Attribute\ApiRoute $route Route attribute. + * @return void + */ + protected function connectRoute(Service $service, array $state, string $actionClass, string $actionName, ApiRoute $route): void + { + $path = trim($state['scopePath'], '/') . '/' . trim($route->path, '/'); + $path = trim($path, '/'); + + $options = [ + 'method' => (array)$route->method, + 'mapCors' => $route->mapCors, + ] + $route->defaults; + + if ($path !== '') { + $options['path'] = $path; + } + + if ($route->patterns !== []) { + $options['patterns'] = array_merge($state['scopePatterns'], $route->patterns); + } elseif ($state['scopePatterns'] !== []) { + $options['patterns'] = $state['scopePatterns']; + } + if ($route->pass !== null) { + $options['pass'] = $route->pass; + } + if ($route->name !== null) { + $options['name'] = $route->name; + } + + $service->mapAction($actionName, $actionClass, $options); + } +} diff --git a/src/Service/AuthService.php b/src/Service/AuthService.php index 08be782..f5d0a4d 100644 --- a/src/Service/AuthService.php +++ b/src/Service/AuthService.php @@ -43,6 +43,7 @@ class AuthService extends Service /** * @inheritDoc */ + #[\Override] public function initialize(): void { parent::initialize(); @@ -80,6 +81,7 @@ public function initialize(): void * @param array $route Action route. * @return array */ + #[\Override] protected function actionOptions(array $route): array { $options = []; diff --git a/src/Service/CrudService.php b/src/Service/CrudService.php index 13345d3..d0aa575 100644 --- a/src/Service/CrudService.php +++ b/src/Service/CrudService.php @@ -92,6 +92,7 @@ public function setTable(string $table) * @param array $route Activated route. * @return array */ + #[\Override] protected function actionOptions(array $route): array { $id = null; diff --git a/src/Service/DescribeService.php b/src/Service/DescribeService.php index 4886ff7..a2e753f 100644 --- a/src/Service/DescribeService.php +++ b/src/Service/DescribeService.php @@ -28,6 +28,7 @@ class DescribeService extends Service /** * @inheritDoc */ + #[\Override] public function loadRoutes(): void { $builder = ApiRouter::createRouteBuilder('/', []); diff --git a/src/Service/FallbackService.php b/src/Service/FallbackService.php index 728cc7b..a171dcf 100644 --- a/src/Service/FallbackService.php +++ b/src/Service/FallbackService.php @@ -39,6 +39,7 @@ class FallbackService extends NestedCrudService * * @return void */ + #[\Override] public function initialize(): void { parent::initialize(); @@ -52,6 +53,7 @@ public function initialize(): void * * @return void */ + #[\Override] public function loadRoutes(): void { $table = $this->fetchTable($this->table); diff --git a/src/Service/ListingService.php b/src/Service/ListingService.php index a37e3da..931ff89 100644 --- a/src/Service/ListingService.php +++ b/src/Service/ListingService.php @@ -32,6 +32,7 @@ class ListingService extends Service * * @return void */ + #[\Override] public function loadRoutes(): void { $builder = ApiRouter::createRouteBuilder('/', []); diff --git a/src/Service/NestedCrudService.php b/src/Service/NestedCrudService.php index b22483e..ba9a33f 100644 --- a/src/Service/NestedCrudService.php +++ b/src/Service/NestedCrudService.php @@ -44,6 +44,7 @@ public function __construct(array $config = []) * @param array $route Action route, * @return array */ + #[\Override] protected function actionOptions(array $route): array { $parent = $this->getParentService(); diff --git a/src/Service/RecaptchaService.php b/src/Service/RecaptchaService.php index e82b0fb..15f55b8 100644 --- a/src/Service/RecaptchaService.php +++ b/src/Service/RecaptchaService.php @@ -25,6 +25,7 @@ class RecaptchaService extends Service /** * @inheritDoc */ + #[\Override] public function initialize(): void { parent::initialize(); diff --git a/src/Service/Renderer/FlysystemRenderer.php b/src/Service/Renderer/FlysystemRenderer.php index 08a588e..5923164 100644 --- a/src/Service/Renderer/FlysystemRenderer.php +++ b/src/Service/Renderer/FlysystemRenderer.php @@ -38,6 +38,7 @@ class FlysystemRenderer extends FileRenderer * @param \CakeDC\Api\Service\Action\Result $result The result object returned by the Service. * @return bool */ + #[\Override] public function response(?Result $result = null): bool { $data = $result->getData(); @@ -108,6 +109,7 @@ public function deliverAsset(Response $response, File $file, ?string $name): Res * @param \Exception $exception thrown at service or action * @return void */ + #[\Override] public function error(Exception $exception): void { $code = $exception->getCode(); diff --git a/src/Service/Renderer/JSendRenderer.php b/src/Service/Renderer/JSendRenderer.php index 23536fb..f88af45 100644 --- a/src/Service/Renderer/JSendRenderer.php +++ b/src/Service/Renderer/JSendRenderer.php @@ -58,6 +58,7 @@ class JSendRenderer extends BaseRenderer * * @return bool */ + #[\Override] public function accept(): bool { $request = $this->service->getRequest(); diff --git a/src/Service/Service.php b/src/Service/Service.php index 5403c89..1edb407 100644 --- a/src/Service/Service.php +++ b/src/Service/Service.php @@ -32,6 +32,7 @@ use CakeDC\Api\Service\Action\Action; use CakeDC\Api\Service\Action\DummyAction; use CakeDC\Api\Service\Action\Result; +use CakeDC\Api\Service\Attribute\ServiceAttributeConnector; use CakeDC\Api\Service\Exception\MissingActionException; use CakeDC\Api\Service\Exception\MissingParserException; use CakeDC\Api\Service\Exception\MissingRendererException; @@ -65,6 +66,15 @@ abstract class Service implements EventListenerInterface, EventDispatcherInterfa */ protected array $actions = []; + /** + * Resource route options, merged into the resource route generation. + * + * Populated from the `ApiResource` attribute by the attribute connector. + * + * @var array + */ + protected array $resourceOptions = []; + /** * Actions classes map, indexed by action name. * @@ -225,6 +235,8 @@ public function initialize(): void $className = (new \ReflectionClass($this))->getShortName(); $this->setName(Inflector::underscore(str_replace('Service', '', $className))); } + + (new ServiceAttributeConnector())->apply($this); } /** @@ -397,7 +409,7 @@ public function routerDefaultOptions(): array return [ 'map' => $mapList, - ]; + ] + $this->resourceOptions; } /** @@ -855,6 +867,48 @@ public function mapAction(string $actionName, string $className, array $route): $this->actions[$actionName] = $route; } + /** + * Define an action route without an action class. + * + * The action class is resolved by convention during action building + * (`{ServiceNamespace}\Action\{ServiceName}{ActionName}Action`). + * + * @param string $actionName Action name. + * @param array $route Route config. + * @return void + */ + public function addAction(string $actionName, array $route): void + { + $route += ['mapCors' => false]; + if (!isset($route['path'])) { + $route['path'] = $actionName; + } + $this->actions[$actionName] = $route; + } + + /** + * Sets resource route options (e.g. from the ApiResource attribute). + * + * @param array $options Resource options. + * @return $this + */ + public function setResourceOptions(array $options) + { + $this->resourceOptions = $options; + + return $this; + } + + /** + * Gets resource route options. + * + * @return array + */ + public function getResourceOptions(): array + { + return $this->resourceOptions; + } + /** * Lists supported events. * diff --git a/src/Webauthn/PublicKeyCredentialLoader.php b/src/Webauthn/PublicKeyCredentialLoader.php index b23f327..25925c5 100644 --- a/src/Webauthn/PublicKeyCredentialLoader.php +++ b/src/Webauthn/PublicKeyCredentialLoader.php @@ -20,6 +20,7 @@ class PublicKeyCredentialLoader extends \Webauthn\PublicKeyCredentialLoader /** * @inheritDoc */ + #[\Override] public function loadArray(array $json): PublicKeyCredential { if (isset($json['response']['clientDataJSON']) && is_string($json['response']['clientDataJSON'])) { diff --git a/tests/App/Application.php b/tests/App/Application.php index 75da924..4787492 100644 --- a/tests/App/Application.php +++ b/tests/App/Application.php @@ -36,6 +36,7 @@ */ class Application extends BaseApplication { + #[\Override] public function bootstrap(): void { parent::bootstrap(); @@ -49,6 +50,7 @@ public function bootstrap(): void ]); } + #[\Override] public function pluginBootstrap(): void { parent::pluginBootstrap(); @@ -84,6 +86,7 @@ public function middleware(MiddlewareQueue $middleware): MiddlewareQueue /** * @inheritDoc */ + #[\Override] public function routes(\Cake\Routing\RouteBuilder $routes): void { $middlewares = Configure::read('Api.Middleware'); diff --git a/tests/App/Model/Table/ArticlesTable.php b/tests/App/Model/Table/ArticlesTable.php index 4694299..9cdddba 100644 --- a/tests/App/Model/Table/ArticlesTable.php +++ b/tests/App/Model/Table/ArticlesTable.php @@ -40,6 +40,7 @@ public function initialize(array $config): void * @param \Cake\Validation\Validator $validator Validator instance. * @return \Cake\Validation\Validator */ + #[\Override] public function validationDefault(Validator $validator): Validator { $validator diff --git a/tests/App/Model/Table/PostsTable.php b/tests/App/Model/Table/PostsTable.php index 8e218d6..c379eb3 100644 --- a/tests/App/Model/Table/PostsTable.php +++ b/tests/App/Model/Table/PostsTable.php @@ -39,6 +39,7 @@ public function initialize(array $config): void * @param \Cake\Validation\Validator $validator Validator instance. * @return \Cake\Validation\Validator */ + #[\Override] public function validationDefault(Validator $validator): Validator { $validator diff --git a/tests/App/Service/Action/ArticlesDataAction.php b/tests/App/Service/Action/ArticlesDataAction.php index 2ba0077..d5df826 100644 --- a/tests/App/Service/Action/ArticlesDataAction.php +++ b/tests/App/Service/Action/ArticlesDataAction.php @@ -26,6 +26,7 @@ public function __construct(TestService $testService, array $config = []) parent::__construct($config); } + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -37,6 +38,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { return true; diff --git a/tests/App/Service/Action/ArticlesTagAction.php b/tests/App/Service/Action/ArticlesTagAction.php index 2f27b2f..c7e4bbe 100644 --- a/tests/App/Service/Action/ArticlesTagAction.php +++ b/tests/App/Service/Action/ArticlesTagAction.php @@ -20,6 +20,7 @@ class ArticlesTagAction extends CrudAction { + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -31,6 +32,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { $validator = new Validator(); diff --git a/tests/App/Service/Action/ArticlesUntagAction.php b/tests/App/Service/Action/ArticlesUntagAction.php index cea84d1..cf176c1 100644 --- a/tests/App/Service/Action/ArticlesUntagAction.php +++ b/tests/App/Service/Action/ArticlesUntagAction.php @@ -22,6 +22,7 @@ class ArticlesUntagAction extends Action * * @return bool */ + #[\Override] public function validates(): bool { return true; diff --git a/tests/App/Service/Action/AttributeFeaturedAction.php b/tests/App/Service/Action/AttributeFeaturedAction.php new file mode 100644 index 0000000..f97bbe9 --- /dev/null +++ b/tests/App/Service/Action/AttributeFeaturedAction.php @@ -0,0 +1,33 @@ +getTable() + ->find() + ->where(['published' => 'Y']) + ->limit(2) + ->all(); + } +} diff --git a/tests/App/Service/Action/AttributesItemAction.php b/tests/App/Service/Action/AttributesItemAction.php new file mode 100644 index 0000000..8433398 --- /dev/null +++ b/tests/App/Service/Action/AttributesItemAction.php @@ -0,0 +1,29 @@ + $this->id]; + } +} diff --git a/tests/App/Service/Action/Author/IndexAction.php b/tests/App/Service/Action/Author/IndexAction.php index 448e14a..e876e1f 100644 --- a/tests/App/Service/Action/Author/IndexAction.php +++ b/tests/App/Service/Action/Author/IndexAction.php @@ -17,6 +17,7 @@ class IndexAction extends CrudAction { + #[\Override] public function initialize(array $config): void { parent::initialize($config); @@ -28,6 +29,7 @@ public function initialize(array $config): void * * @return bool */ + #[\Override] public function validates(): bool { return true; diff --git a/tests/App/Service/ArticlesCollectionService.php b/tests/App/Service/ArticlesCollectionService.php index 56dcdeb..a7ad880 100644 --- a/tests/App/Service/ArticlesCollectionService.php +++ b/tests/App/Service/ArticlesCollectionService.php @@ -19,6 +19,7 @@ class ArticlesCollectionService extends FallbackService { + #[\Override] public function initialize(): void { parent::initialize(); diff --git a/tests/App/Service/ArticlesService.php b/tests/App/Service/ArticlesService.php index 55872a9..236ea8e 100644 --- a/tests/App/Service/ArticlesService.php +++ b/tests/App/Service/ArticlesService.php @@ -25,6 +25,7 @@ class ArticlesService extends FallbackService 'featured' => ['method' => ['GET'], 'path' => 'featured'], ]; + #[\Override] public function initialize(): void { parent::initialize(); diff --git a/tests/App/Service/AttributesService.php b/tests/App/Service/AttributesService.php new file mode 100644 index 0000000..815d79f --- /dev/null +++ b/tests/App/Service/AttributesService.php @@ -0,0 +1,33 @@ + 15, 'author_id' => 1, 'title' => 'Article N15', 'body' => 'Article N15 Body', 'published' => 'Y'], ]; + #[\Override] public function insert(ConnectionInterface $db): bool { $result = parent::insert($db); diff --git a/tests/Fixture/AttributesFixture.php b/tests/Fixture/AttributesFixture.php new file mode 100644 index 0000000..236315d --- /dev/null +++ b/tests/Fixture/AttributesFixture.php @@ -0,0 +1,33 @@ + 1, 'title' => 'First Attribute', 'body' => 'First Attribute Body', 'published' => 'Y'], + ['id' => 2, 'title' => 'Second Attribute', 'body' => 'Second Attribute Body', 'published' => 'Y'], + ['id' => 3, 'title' => 'Third Attribute', 'body' => 'Third Attribute Body', 'published' => 'N'], + ]; +} diff --git a/tests/Fixture/PostsFixture.php b/tests/Fixture/PostsFixture.php index a3852d7..6fc5a35 100644 --- a/tests/Fixture/PostsFixture.php +++ b/tests/Fixture/PostsFixture.php @@ -31,6 +31,7 @@ class PostsFixture extends TestFixture ['id' => 4, 'title' => 'Fourth Post', 'body' => 'Fourth Post Body', 'published' => 'Y'], ]; + #[\Override] public function insert(ConnectionInterface $db): bool { $result = parent::insert($db); diff --git a/tests/TestCase/Command/ServiceRoutesCommandTest.php b/tests/TestCase/Command/ServiceRoutesCommandTest.php index a26fe3c..3752f22 100644 --- a/tests/TestCase/Command/ServiceRoutesCommandTest.php +++ b/tests/TestCase/Command/ServiceRoutesCommandTest.php @@ -3,6 +3,7 @@ namespace CakeDC\Api\Test\TestCase\Command; +use Cake\AttributeResolver\AttributeResolver; use Cake\Command\Command; use Cake\Console\TestSuite\ConsoleIntegrationTestTrait; use Cake\TestSuite\TestCase; @@ -30,6 +31,7 @@ protected function tearDown(): void { parent::tearDown(); ServiceRegistry::getServiceLocator()->clear(); + AttributeResolver::drop('default'); } /** @@ -72,6 +74,55 @@ private function getHeaderRow(): array ]; } + /** + * Test attribute-declared routes are printed by the command. + */ + public function testServiceRoutesListsAttributeRoutes(): void + { + if (AttributeResolver::getConfig('default') !== null) { + AttributeResolver::clear('default'); + AttributeResolver::drop('default'); + } + AttributeResolver::setConfig('default', [ + 'paths' => ['tests/App/Service/*.php', 'tests/App/Service/**/*.php'], + 'cache' => false, + ]); + + $this->exec('service routes attributes'); + $this->assertExitCode(Command::CODE_SUCCESS); + + $this->assertOutputContainsRow($this->getHeaderRow()); + $this->assertOutputContainsRow([ + 'attributes:featured', + 'GET', + '/attributes/featured', + 'attributes', + 'featured', + '', + ]); + $this->assertOutputContainsRow([ + 'attributes:index', + 'GET', + '/attributes', + 'attributes', + 'index', + '', + ]); + + // route with a {id} placeholder + $this->assertOutputContainsRow([ + 'attributes:item', + 'GET', + '/attributes/item/{id}', + 'attributes', + 'item', + '', + ]); + + // add is excluded by ApiResource(only: ...) and must not be printed + $this->assertOutputNotContains('attributes:add'); + } + private function getArticleRoutes(): array { return [ diff --git a/tests/TestCase/Integration/Service/Action/AttributeRoutingTest.php b/tests/TestCase/Integration/Service/Action/AttributeRoutingTest.php new file mode 100644 index 0000000..2778c93 --- /dev/null +++ b/tests/TestCase/Integration/Service/Action/AttributeRoutingTest.php @@ -0,0 +1,88 @@ + ['tests/App/Service/*.php', 'tests/App/Service/**/*.php'], + 'cache' => false, + ]); + $this->_tokenAccess(); + $this->getDefaultUser(Settings::USER1); + } + + protected function tearDown(): void + { + parent::tearDown(); + AttributeResolver::drop('default'); + } + + public function testAttributeCustomActionRoute(): void + { + $this->sendRequest('/attributes/featured', 'GET', []); + $result = $this->getJsonResponse(); + $this->assertSuccess($result); + $this->assertNotEmpty($result['data']); + $this->assertResponseContains('First Attribute'); + } + + public function testApiResourceEnabledRoutes(): void + { + // index is enabled through ApiResource + $this->sendRequest('/attributes', 'GET', []); + $result = $this->getJsonResponse(); + $this->assertSuccess($result); + $this->assertNotEmpty($result['data']); + } + + public function testApiResourceOnlyExcludesUnlistedRoutes(): void + { + // add is not listed in ApiResource(only: ...) -> route not found + $this->sendRequest('/attributes', 'POST', ['title' => 'x']); + $result = $this->getJsonResponse(); + $this->assertError($result, 404); + } + + public function testAttributeRouteWithParams(): void + { + $this->sendRequest('/attributes/item/5', 'GET', []); + $result = $this->getJsonResponse(); + $this->assertSuccess($result); + $this->assertSame('5', $result['data']['id']); + } +} diff --git a/tests/schema.php b/tests/schema.php index 7b80c55..b3c67ae 100644 --- a/tests/schema.php +++ b/tests/schema.php @@ -22,6 +22,18 @@ 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], ], ], + [ + 'table' => 'attributes', + 'columns' => [ + 'id' => ['type' => 'integer'], + 'title' => ['type' => 'string', 'null' => true], + 'body' => 'text', + 'published' => ['type' => 'string', 'length' => 1, 'default' => 'N'], + ], + 'constraints' => [ + 'primary' => ['type' => 'primary', 'columns' => ['id'], 'length' => []], + ], + ], [ 'table' => 'authors', 'columns' => [