From 6c0a0613f9b608a0e1af61dc72478133cfde0b1b Mon Sep 17 00:00:00 2001 From: Luke Holder Date: Fri, 16 Oct 2026 16:30:25 +0800 Subject: [PATCH] Port to Craft 6 (native, adapter-free) via the craft-6-plugin-porting skill - Requires craftcms/cms ^6.0.0 and PHP ^8.5; no craftcms/yii2-adapter - Moves to the CraftCms\ElementApi namespace with Laravel-style folders - Native Plugin and PluginSettings; endpoints from config/craft/element-api.php are registered as Laravel routes - Invokable ElementApiController; caching uses ElementCaches and the dependency-aware cache, with cacheDuration as the default TTL - Response headers via ResponseHeaders::add(); API responses no longer set cookies - DataEvent / EVENT_BEFORE_SEND_DATA replaced by the SendingData event - Adds UPGRADE.md; updates the README and CHANGELOG Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 14 + README.md | 120 ++++--- UPGRADE.md | 179 +++++++++++ composer.json | 15 +- {src => resources}/icon.svg | 0 routes/web.php | 38 +++ .../ResourceAdapter.php} | 8 +- src/DataEvent.php | 20 -- src/Events/SendingData.php | 19 ++ src/Http/Controllers/ElementApiController.php | 256 +++++++++++++++ src/Plugin.php | 118 +------ src/Resources/ElementResource.php | 163 ++++++++++ src/Resources/ResourceFactory.php | 46 +++ .../JsonFeedV1Serializer.php | 24 +- src/Settings.php | 27 +- src/{ => Support}/PaginatorAdapter.php | 27 +- src/{ => Transformers}/ElementTransformer.php | 11 +- src/controllers/DefaultController.php | 300 ------------------ src/resources/ElementResource.php | 178 ----------- stubs/BaseYii.stub | 19 -- 20 files changed, 875 insertions(+), 707 deletions(-) create mode 100644 UPGRADE.md rename {src => resources}/icon.svg (100%) create mode 100644 routes/web.php rename src/{ResourceAdapterInterface.php => Contracts/ResourceAdapter.php} (65%) delete mode 100644 src/DataEvent.php create mode 100644 src/Events/SendingData.php create mode 100644 src/Http/Controllers/ElementApiController.php create mode 100644 src/Resources/ElementResource.php create mode 100644 src/Resources/ResourceFactory.php rename src/{ => Serializers}/JsonFeedV1Serializer.php (61%) rename src/{ => Support}/PaginatorAdapter.php (81%) rename src/{ => Transformers}/ElementTransformer.php (76%) delete mode 100644 src/controllers/DefaultController.php delete mode 100644 src/resources/ElementResource.php delete mode 100644 stubs/BaseYii.stub diff --git a/CHANGELOG.md b/CHANGELOG.md index e073a40..441c279 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,19 @@ # Release Notes for Element API +## Unreleased + +### Changed +- Element API now requires Craft 6 and PHP 8.5, and no longer uses the Yii 2 adapter. +- The plugin was ported to Craft 6’s Laravel-based architecture, and its classes moved to the `CraftCms\ElementApi` namespace. See the [upgrade guide](UPGRADE.md) for the full list. +- The config file moved from `config/element-api.php` to `config/craft/element-api.php`. +- Endpoint URL patterns now use Laravel route syntax (`news/{entryId}.json`) instead of Yii URL rules (`news/.json`). +- Endpoint functions can set response headers with `ResponseHeaders::add()`, and can type-hint services and `Illuminate\Http\Request`. +- Responses no longer set cookies. + +### Removed +- Removed the `beforeSendData` event and `craft\elementapi\DataEvent`. Use the new `CraftCms\ElementApi\Events\SendingData` Laravel event instead. +- Removed the `generateTransformsBeforePageLoad` override. + ## 4.3.0 - 2026-08-05 - Updated Fractal to 0.21. ([#202](https://github.com/craftcms/element-api/issues/202)) diff --git a/README.md b/README.md index d34bce9..550ba93 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,9 @@ It’s powered by Phil Sturgeon’s excellent [Fractal](http://fractal.thephplea ## Requirements -This plugin requires Craft CMS 4.3.0+ or 5.0.0+. +This plugin requires Craft CMS 6.0.0-alpha.1 or later, and PHP 8.5 or later. It doesn’t need the Yii 2 adapter. + +Using Craft 4 or 5? Stay on the 4.x line of this plugin. Upgrading a Craft 5 project that already uses Element API? See the [upgrade guide](UPGRADE.md). ## Installation @@ -28,20 +30,20 @@ cd /path/to/my-project.test composer require craftcms/element-api # tell Craft to install the plugin -./craft plugin/install element-api +php artisan craft:plugin:install element-api ``` ## Setup -To define your API endpoints, create a new `element-api.php` file within your `config/` folder. This file should return an array with an `endpoints` key, which defines your site’s API endpoints. +To define your API endpoints, create a new `element-api.php` file within your `config/craft/` folder. This file should return an array with an `endpoints` key, which defines your site’s API endpoints. -Within the `endpoints` array, keys are URL patterns, and values are functions that define the endpoint configurations. +Within the `endpoints` array, keys are URL patterns ([Laravel route syntax](https://laravel.com/docs/routing#route-parameters), such as `news/{entryId}.json`), and values are functions that define the endpoint configurations. ```php [ @@ -54,13 +56,13 @@ return [ 'id' => $entry->id, 'title' => $entry->title, 'url' => $entry->url, - 'jsonUrl' => UrlHelper::url("news/$entry->id.json"), + 'jsonUrl' => Url::url("news/$entry->id.json"), 'summary' => $entry->summary, ]; }, ]; }, - 'news/.json' => function($entryId) { + 'news/{entryId}.json' => function(int $entryId) { return [ 'elementType' => Entry::class, 'criteria' => ['id' => $entryId], @@ -86,22 +88,22 @@ Endpoint configuration arrays can contain the following settings: #### `class` -The class name of the Fractal resource that should be used to serve the request. If this isn’t set, it will default to `craft\elementapi\resources\ElementResource`. (All of the following configuration settings are specific to that default class.) +The class name of the Fractal resource that should be used to serve the request. If this isn’t set, it will default to `CraftCms\ElementApi\Resources\ElementResource`. (All of the following configuration settings are specific to that default class.) #### `elementType` _(Required)_ -The class name of the element type that the API should be associated with. Craft’s built-in element type classes are: +The class name of the element type that the API should be associated with. Craft’s built-in element type classes include: + +- `CraftCms\Cms\Asset\Elements\Asset` +- `CraftCms\Cms\Entry\Elements\Entry` +- `CraftCms\Cms\User\Elements\User` +- `CraftCms\Cms\Address\Elements\Address` -- `craft\elements\Asset` -- `craft\elements\Category` -- `craft\elements\Entry` -- `craft\elements\GlobalSet` -- `craft\elements\MatrixBlock` -- `craft\elements\Tag` -- `craft\elements\User` +> **Note** +> As of Craft 6.0.0-alpha.20, categories, tags and global sets only exist as legacy classes in the Yii 2 adapter (`craft\elements\Category`, `craft\elements\Tag`, `craft\elements\GlobalSet`), so they can’t be queried without it yet. ```php -'elementType' => craft\elements\Entry::class, +'elementType' => CraftCms\Cms\Entry\Elements\Entry::class, ```` #### `criteria` @@ -135,7 +137,7 @@ The [transformer](http://fractal.thephpleague.com/transformers/) that should be ```php // Can be set to a function -'transformer' => function(craft\elements\Entry $entry) { +'transformer' => function(CraftCms\Cms\Entry\Elements\Entry $entry) { return [ 'title' => $entry->title, 'id' => $entry->id, @@ -155,7 +157,7 @@ Your custom transformer class would look something like this: ```php 'pg', ``` -Note that it cannot be set to `'p'` because that’s the parameter Craft uses to check the requested path. - #### `resourceKey` The key that the elements should be nested under in the response data. By default this will be `'data'`. @@ -238,7 +238,12 @@ Possible values are: The [include names](http://fractal.thephpleague.com/transformers/#including-data) that should be included for the current request, if any. ```php -'includes' => (array)Craft::$app->request->getQueryParam('include'), +'news.json' => function(\Illuminate\Http\Request $request) { + return [ + 'includes' => (array) $request->query('include'), + // ... + ]; +}, ``` Note that this setting requires a custom transformer class that’s prepped to handle includes: @@ -317,7 +322,7 @@ Possible values are: - an integer – results are cached for the given number of seconds. - a [interval spec](http://www.php.net/manual/en/dateinterval.construct.php) string – results are cached for the duration specified. -Note that the `onBeforeSendData` event does not get triggered when the cache is warm. +Note that the [`SendingData` event](#events) does not get triggered when the cache is warm. ```php 'cache' => 'PT1M', // one minute @@ -329,26 +334,67 @@ The key that responses should be cached with, if something custom is needed. ### Dynamic URL Patterns -URL patterns can contain dynamic subpatterns in the format of ``, where `subpatternName` is the name of the subpattern, and `regex` is a valid regular expression. For example, the URL pattern “`news/.json`” will match URLs like `news/100.json`. You can also use the tokens `{handle}` and `{slug}` within your regular expression, which will be replaced with the appropriate regex patterns for matching handles and element slugs. +URL patterns use [Laravel route syntax](https://laravel.com/docs/routing#route-parameters). Wrap a parameter in curly braces to make it dynamic: the pattern “`news/{entryId}.json`” will match URLs like `news/100.json`. + +Any parameters in the URL pattern are passed to the endpoint config function by name. For example, if a URL pattern contains `{entryId}`, then you can add an `$entryId` argument to your endpoint config function, and whatever matches the URL will be passed to `$entryId`. The function can also type-hint services and the request, such as `Illuminate\Http\Request $request`. -Any subpattern matches in the URL pattern will be mapped to the endpoint config function’s arguments. For example, if a URL pattern contains an `entryId` subpattern, then you can add an `$entryId` argument to your endpoint config function, and whatever matches the URL subpattern will be passed to `$entryId`. +If you type-hint a parameter as `int`, the route only matches digits for it, so `news/abc.json` is a 404. ```php -'news/.json' => function($entryId) { +'news/{entryId}.json' => function(int $entryId) { return [ - 'elementType' => craft\elements\Entry::class, + 'elementType' => CraftCms\Cms\Entry\Elements\Entry::class, 'criteria' => ['id' => $entryId], 'one' => true, ]; }, ``` +Endpoints answer `GET`, `HEAD` and `OPTIONS` requests. + +### Response Headers + +To add response headers (for example CORS headers), call `ResponseHeaders::add()` from your endpoint function. Craft applies them to the response, whether it comes from the cache or not. + +```php +use CraftCms\Cms\Support\Facades\ResponseHeaders; + +'news.json' => function() { + ResponseHeaders::add('Access-Control-Allow-Origin', '*'); + + return [ + // ... + ]; +}, +``` + +Endpoints send `X-Robots-Tag: none` unless you set that header yourself. Responses don’t set any cookies. + +### Events + +Element API dispatches a `CraftCms\ElementApi\Events\SendingData` event before an endpoint’s data is serialized and sent. Its `payload` property is the Fractal `Scope` for the response. + +```php +use CraftCms\ElementApi\Events\SendingData; +use Illuminate\Support\Facades\Event; + +Event::listen(function(SendingData $event): void { + $scope = $event->payload; +}); +``` + +### Caching and Deployment Notes + +- Cached responses are tagged with `element-api`. Use **Utilities** → **Clear Caches** → **Element API responses** to clear them. Saving or deleting a relevant element clears them automatically. +- Your endpoint functions are closures, which Laravel can’t serialize, so don’t run `php artisan config:cache` in a project that uses them. +- If you cache routes with `php artisan route:cache`, run it again (or `route:clear`) whenever you change your endpoints. + ### Setting Default Configuration Settings You can specify default values for your endpoint configuration settings by adding a `defaults` key alongside your `endpoints` key (**not** within it). ```php -use craft\elements\Entry; +use CraftCms\Cms\Entry\Elements\Entry; return [ 'defaults' => [ @@ -370,7 +416,7 @@ return [ 'criteria' => ['section' => 'news'], ] }, - 'news/.json' => function($entryId) { + 'news/{entryId}.json' => function(int $entryId) { return [ 'criteria' => ['id' => $entryId], 'one' => true, @@ -391,11 +437,11 @@ Here are a few endpoint examples, and what their response would look like. return [ 'criteria' => ['section' => 'ingredients'], 'elementsPerPage' => 10, - 'transformer' => function(craft\elements\Entry $entry) { + 'transformer' => function(CraftCms\Cms\Entry\Elements\Entry $entry) { return [ 'title' => $entry->title, 'url' => $entry->url, - 'jsonUrl' => UrlHelper::url("ingredients/$entry->slug.json"), + 'jsonUrl' => Url::url("ingredients/$entry->slug.json"), ]; }, 'pretty' => true, @@ -426,7 +472,7 @@ Here are a few endpoint examples, and what their response would look like. "current_page": 1, "total_pages": 7, "links": { - "next": "/ingredients.json?p=2" + "next": "/ingredients.json?page=2" } } } @@ -436,14 +482,14 @@ Here are a few endpoint examples, and what their response would look like. ### Single Entry Endpoint ```php -'ingredients/.json' => function($slug) { +'ingredients/{slug}.json' => function($slug) { return [ 'criteria' => [ 'section' => 'ingredients', 'slug' => $slug ], 'one' => true, - 'transformer' => function(craft\elements\Entry $entry) { + 'transformer' => function(CraftCms\Cms\Entry\Elements\Entry $entry) { // Create an array of all the photo URLs $photos = []; foreach ($entry->photos->all() as $photo) { @@ -483,9 +529,9 @@ Note that `photos`, `body`, `summary`, and `tags` are imaginary custom fields. 'feed.json' => function() { return [ 'serializer' => 'jsonFeed', - 'elementType' => craft\elements\Entry::class, + 'elementType' => CraftCms\Cms\Entry\Elements\Entry::class, 'criteria' => ['section' => 'news'], - 'transformer' => function(craft\elements\Entry $entry) { + 'transformer' => function(CraftCms\Cms\Entry\Elements\Entry $entry) { $image = $entry->photos->one(); return [ diff --git a/UPGRADE.md b/UPGRADE.md new file mode 100644 index 0000000..2f2bf79 --- /dev/null +++ b/UPGRADE.md @@ -0,0 +1,179 @@ +# Upgrading Element API for Craft 6 + +This guide lists what changed when Element API moved to Craft 6, and what to update in your project. Because your endpoints live in a config file, most of the work is there. + +## Requirements + +- Craft CMS 6.0.0-alpha.1 or later +- PHP 8.5 or later +- The plugin no longer needs `craftcms/yii2-adapter`. Your project may still use it for other plugins and modules. + +## Upgrade steps + +1. Upgrade your project to Craft 6 first. +2. Require the Craft 6 version of the plugin: `composer require craftcms/element-api`. +3. Move `config/element-api.php` to `config/craft/element-api.php`. +4. Update your endpoint patterns and the Craft 5 classes used in your endpoint functions, using the changes below. +5. Clear the Element API cache (**Utilities** → **Clear Caches** → **Element API responses**). + +## Breaking changes + +### Config file location + +| Before | After | +| --- | --- | +| `config/element-api.php` | `config/craft/element-api.php` | + +### Endpoint URL patterns + +Patterns now use Laravel route syntax instead of Yii URL rules. + +| Before | After | +| --- | --- | +| `'news/.json'` | `'news/{entryId}.json'` | +| `'ingredients/.json'` | `'ingredients/{slug}.json'` | + +- There are no inline regular expressions, and no `{handle}` or `{slug}` tokens. A parameter matches any value, except that one your function type-hints as `int` only matches digits (so `news/abc.json` is a 404). +- Parameters are still passed to your function by name. Your function can now also type-hint services and `Illuminate\Http\Request`. +- Endpoints answer `GET`, `HEAD` and `OPTIONS` requests. They used to answer any method. + +Before: + +```php +'news/.json' => function($entryId) { + return [ + 'elementType' => craft\elements\Entry::class, + 'criteria' => ['id' => $entryId], + 'one' => true, + ]; +}, +``` + +After: + +```php +'news/{entryId}.json' => function(int $entryId) { + return [ + 'elementType' => CraftCms\Cms\Entry\Elements\Entry::class, + 'criteria' => ['id' => $entryId], + 'one' => true, + ]; +}, +``` + +### Craft 5 classes and APIs in your endpoint functions + +Your config file runs on Craft 6 now, so anything it calls has to exist there. Without the adapter, update: + +| Before | After | +| --- | --- | +| `craft\elements\Entry` | `CraftCms\Cms\Entry\Elements\Entry` | +| `craft\elements\Asset` | `CraftCms\Cms\Asset\Elements\Asset` | +| `craft\elements\User` | `CraftCms\Cms\User\Elements\User` | +| `craft\helpers\UrlHelper::url()` | `CraftCms\Cms\Support\Url::url()` | +| `Craft::$app->getResponse()->getHeaders()->set('X-Foo', 'bar')` | `CraftCms\Cms\Support\Facades\ResponseHeaders::add('X-Foo', 'bar')` | +| `Craft::$app->request->getQueryParam('q')` | `$request->query('q')`, with `Illuminate\Http\Request $request` as an argument of your function | +| `'orderBy' => 'id asc'` (deprecated in Craft 6) | `'orderBy' => ['id' => SORT_ASC]` | + +Categories, tags and global sets have no native Craft 6 classes in 6.0.0-alpha.20 (they only exist as legacy classes in the adapter), so endpoints for them need the adapter for now. + +Before: + +```php +'news.json' => function() { + Craft::$app->getResponse()->getHeaders()->set('Access-Control-Allow-Origin', '*'); + + return [ + 'elementType' => craft\elements\Entry::class, + 'criteria' => ['section' => 'news'], + ]; +}, +``` + +After: + +```php +use CraftCms\Cms\Entry\Elements\Entry; +use CraftCms\Cms\Support\Facades\ResponseHeaders; + +'news.json' => function() { + ResponseHeaders::add('Access-Control-Allow-Origin', '*'); + + return [ + 'elementType' => Entry::class, + 'criteria' => ['section' => 'news'], + ]; +}, +``` + +### Events + +The `EVENT_BEFORE_SEND_DATA` event on `craft\elementapi\controllers\DefaultController` was replaced by a Laravel event, and `craft\elementapi\DataEvent` was removed. + +| Before | After | +| --- | --- | +| `DefaultController::EVENT_BEFORE_SEND_DATA` (`beforeSendData`) | `CraftCms\ElementApi\Events\SendingData` | +| `craft\elementapi\DataEvent` | `CraftCms\ElementApi\Events\SendingData` (the event itself) | + +Before: + +```php +use craft\elementapi\controllers\DefaultController; +use craft\elementapi\DataEvent; +use yii\base\Event; + +Event::on( + DefaultController::class, + DefaultController::EVENT_BEFORE_SEND_DATA, + function(DataEvent $event) { + $scope = $event->payload; + } +); +``` + +After: + +```php +use CraftCms\ElementApi\Events\SendingData; +use Illuminate\Support\Facades\Event; + +Event::listen(function(SendingData $event): void { + $scope = $event->payload; +}); +``` + +Listeners receive the event directly: there is no `sender`. Register them in a service provider’s `boot()` method (for a project, `app/Providers/AppServiceProvider.php`). + +### Classes and namespaces + +The plugin now follows Craft 6 conventions: the namespace is `CraftCms\ElementApi` (PSR-4 root `src/`) and classes are organized by role. Update any code that references the old classes. + +| Before | After | Change | +| --- | --- | --- | +| `craft\elementapi\Plugin` | `CraftCms\ElementApi\Plugin` | Extends `CraftCms\Cms\Plugin\Plugin`. The endpoint lookup and resource creation moved out of it. | +| `craft\elementapi\Settings` | `CraftCms\ElementApi\Settings` | Extends `CraftCms\Cms\Plugin\PluginSettings`. | +| `craft\elementapi\controllers\DefaultController` | `CraftCms\ElementApi\Http\Controllers\ElementApiController` | An invokable controller with no base class. | +| `craft\elementapi\resources\ElementResource` | `CraftCms\ElementApi\Resources\ElementResource` | Extends `CraftCms\Cms\Component\Component` instead of `yii\base\BaseObject`. | +| `Plugin::createResource()` | `CraftCms\ElementApi\Resources\ResourceFactory::create()` | Resolve it from the container. | +| `craft\elementapi\ResourceAdapterInterface` | `CraftCms\ElementApi\Contracts\ResourceAdapter` | Renamed. | +| `craft\elementapi\ElementTransformer` | `CraftCms\ElementApi\Transformers\ElementTransformer` | | +| `craft\elementapi\JsonFeedV1Serializer` | `CraftCms\ElementApi\Serializers\JsonFeedV1Serializer` | Needs a `Request` and `Sites`: get it with `app(JsonFeedV1Serializer::class)`. | +| `craft\elementapi\PaginatorAdapter` | `CraftCms\ElementApi\Support\PaginatorAdapter` | Takes a `Request` as its fourth argument. | +| `craft\elementapi\DataEvent` | removed | See [Events](#events). | + +If your endpoint sets `'class'` to your own resource class, it is created with `new` and your config array when it extends `CraftCms\Cms\Component\Component`, and otherwise resolved from the container and configured with the array. + +## Behavior changes + +- **No cookies:** responses no longer set a session or `XSRF-TOKEN` cookie, so reverse proxies and CDNs can cache them. +- **Cache duration:** `'cache' => true` caches for the `cacheDuration` general config setting (or until a relevant element changes), as before. `0` still means until a relevant element changes. +- **JSONP:** unchanged. `jsonOptions` and `pretty` are still ignored when `callback` is set. +- **Logging:** failed requests are logged through Laravel’s logger. +- **Deployment:** closures can’t be serialized, so `php artisan config:cache` fails in a project that uses them. Run `php artisan route:cache` again after you change your endpoints. + +## Removed + +- `Plugin::registerUrlRules()` and the `EVENT_REGISTER_SITE_URL_RULES` listener. Endpoints are registered as Laravel routes. +- The `generateTransformsBeforePageLoad` setting the controller used to turn on. It has no Craft 6 equivalent. +- The `pageParam` check against the `p` path parameter. Craft 6 has no path parameter. +- The `stubs/BaseYii.stub` PHPStan stub. diff --git a/composer.json b/composer.json index 5848ae1..ef9ec11 100644 --- a/composer.json +++ b/composer.json @@ -20,13 +20,14 @@ "email": "support@craftcms.com", "issues": "https://github.com/craftcms/element-api/issues?state=open", "source": "https://github.com/craftcms/element-api", - "docs": "https://github.com/craftcms/element-api/blob/v2/README.md", - "rss": "https://github.com/craftcms/element-api/commits/v2.atom" + "docs": "https://github.com/craftcms/element-api/blob/5.x/README.md", + "rss": "https://github.com/craftcms/element-api/commits/5.x.atom" }, "minimum-stability": "dev", "prefer-stable": true, "require": { - "craftcms/cms": "^4.3.0|^5.0.0-beta.1", + "php": "^8.5", + "craftcms/cms": "^6.0.0", "league/fractal": "^0.21" }, "require-dev": { @@ -36,7 +37,7 @@ }, "autoload": { "psr-4": { - "craft\\elementapi\\": "src/" + "CraftCms\\ElementApi\\": "src/" } }, "scripts": { @@ -47,14 +48,10 @@ "extra": { "name": "Element API", "handle": "element-api", - "documentationUrl": "https://github.com/craftcms/element-api/blob/v2/README.md" + "documentationUrl": "https://github.com/craftcms/element-api/blob/5.x/README.md" }, "config": { - "platform": { - "php": "8.0.2" - }, "allow-plugins": { - "yiisoft/yii2-composer": true, "craftcms/plugin-installer": true } } diff --git a/src/icon.svg b/resources/icon.svg similarity index 100% rename from src/icon.svg rename to resources/icon.svg diff --git a/routes/web.php b/routes/web.php new file mode 100644 index 0000000..a33ad6d --- /dev/null +++ b/routes/web.php @@ -0,0 +1,38 @@ +getSettings()->endpoints as $pattern => $config) { + $route = Route::match(['GET', 'HEAD', 'OPTIONS'], $pattern, ElementApiController::class) + ->defaults('elementApiEndpoint', $pattern) + // A read-only JSON API doesn't need cookies or a session, and shouldn't set any + // (a Set-Cookie header stops CDNs and reverse proxies from caching the response). + ->withoutMiddleware([ + EncryptCookies::class, + AddQueuedCookiesToResponse::class, + StartSession::class, + ShareErrorsFromSession::class, + PreventRequestForgery::class, + ]); + + // A closure that type-hints a route parameter as `int` only matches digits for it, + // so `news/abc.json` is a 404 rather than an error. + if ($config instanceof Closure) { + foreach ((new ReflectionFunction($config))->getParameters() as $parameter) { + $type = $parameter->getType(); + + if ($type instanceof ReflectionNamedType && $type->getName() === 'int' && str_contains($pattern, '{' . $parameter->getName() . '}')) { + $route->whereNumber($parameter->getName()); + } + } + } +} diff --git a/src/ResourceAdapterInterface.php b/src/Contracts/ResourceAdapter.php similarity index 65% rename from src/ResourceAdapterInterface.php rename to src/Contracts/ResourceAdapter.php index d01bc28..9989b29 100644 --- a/src/ResourceAdapterInterface.php +++ b/src/Contracts/ResourceAdapter.php @@ -1,21 +1,19 @@ * @since 2.0 */ -interface ResourceAdapterInterface +interface ResourceAdapter { /** * Returns the Fractal data resource. - * - * @return ResourceInterface */ public function getResource(): ResourceInterface; } diff --git a/src/DataEvent.php b/src/DataEvent.php deleted file mode 100644 index bbf9259..0000000 --- a/src/DataEvent.php +++ /dev/null @@ -1,20 +0,0 @@ - - * @since 2.0 - */ -class DataEvent extends Event -{ - /** - * @var Scope The Fractal data associated with the event - */ - public Scope $payload; -} diff --git a/src/Events/SendingData.php b/src/Events/SendingData.php new file mode 100644 index 0000000..ab17405 --- /dev/null +++ b/src/Events/SendingData.php @@ -0,0 +1,19 @@ + + * @since 2.0 + */ +class SendingData +{ + /** + * @param Scope $payload The Fractal data associated with the event + */ + public function __construct(public Scope $payload) {} +} diff --git a/src/Http/Controllers/ElementApiController.php b/src/Http/Controllers/ElementApiController.php new file mode 100644 index 0000000..c0dcea9 --- /dev/null +++ b/src/Http/Controllers/ElementApiController.php @@ -0,0 +1,256 @@ + + * @since 2.0 + */ +class ElementApiController +{ + public function __construct( + private readonly Settings $settings, + private readonly Sites $sites, + private readonly ElementCaches $elementCaches, + private readonly ResourceFactory $resources, + private readonly ResponseHeaders $responseHeaders, + private readonly GeneralConfig $generalConfig, + ) {} + + public function __invoke(Request $request): Response + { + $callback = null; + $jsonOptions = null; + $pretty = false; + $cache = true; + $statusCode = 200; + $statusText = null; + $contentType = null; + + try { + $pattern = $request->route('elementApiEndpoint'); + $config = $this->settings->getEndpoint($pattern); + + if (is_callable($config)) { + // Pass the route parameters to the closure by name + $params = Arr::except($request->route()->parametersWithoutNulls(), 'elementApiEndpoint'); + $config = app()->call($config, $params); + } + + if ($request->isMethod('OPTIONS')) { + // Now that the endpoint has had a chance to add CORS response headers, end the request + return new Response('', 200); + } + + if (is_array($config)) { + // Merge in the defaults + $config = Arr::merge($this->settings->getDefaults(), $config); + } + + // Prevent API endpoints from getting indexed (unless the endpoint set its own header) + if (!$this->hasResponseHeader('X-Robots-Tag')) { + $this->responseHeaders->add('X-Robots-Tag', 'none'); + } + + // Before anything else, check the cache + $cache = Arr::pull($config, 'cache', true); + if ($request->isPreview()) { + // Ignore config & disable cache for previews + $cache = false; + } + + $cacheKey = Arr::pull($config, 'cacheKey') + ?? implode(':', [ + 'elementapi', + $this->sites->getCurrentSite()->id, + $request->path(), + $request->getQueryString(), + ]); + + if ($cache) { + $cached = DependencyCache::get($cacheKey); + + if (isset($cached['content'], $cached['contentType'])) { + return $this->respond($cached['content'], 200, null, $cached['contentType']); + } + + $this->elementCaches->startCollectingCacheInfo(); + } + + // Extract config settings that aren't meant for the resource + $serializer = Arr::pull($config, 'serializer'); + $callback = Arr::pull($config, 'callback'); + $jsonOptions = Arr::pull($config, 'jsonOptions', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + $pretty = Arr::pull($config, 'pretty', false); + $includes = Arr::pull($config, 'includes', []); + $excludes = Arr::pull($config, 'excludes', []); + $contentType = Arr::pull($config, 'contentType'); + + // Get the data resource + $resource = $this->resources->create($config); + + // Load Fractal + $fractal = new Manager(); + + // Serialize the data + if (!$serializer instanceof SerializerAbstract) { + switch ($serializer) { + case 'dataArray': + $serializer = new DataArraySerializer(); + break; + case 'jsonApi': + $serializer = new JsonApiSerializer(); + break; + case 'jsonFeed': + $serializer = app(JsonFeedV1Serializer::class); + $contentType ??= 'application/feed+json'; + break; + default: + $serializer = new ArraySerializer(); + } + } + + $fractal->setSerializer($serializer); + + // Parse includes/excludes + $fractal->parseIncludes($includes); + $fractal->parseExcludes($excludes); + + $data = $fractal->createData($resource); + + // Let listeners inspect the data before it's sent + event(new SendingData($data)); + + $data = $data->toArray(); + } catch (Throwable $e) { + $isHttpException = $e instanceof HttpExceptionInterface; + $statusCode = $isHttpException ? $e->getStatusCode() : 500; + $message = $isHttpException + ? ($e->getMessage() ?: (SymfonyResponse::$statusTexts[$statusCode] ?? 'Error')) + : 'A server error occurred.'; + + $data = [ + 'error' => [ + 'code' => $isHttpException ? $statusCode : $e->getCode(), + 'message' => $message, + ], + ]; + + $statusText = $isHttpException + ? (preg_split('/[\r\n]/', $message, 2)[0] ?: null) + : 'Server error'; + + // We were collecting cache info for a response that won't be cached + if ($cache && $this->elementCaches->isCollectingCacheInfo()) { + $this->elementCaches->stopCollectingCacheInfo(); + } + + // Log the exception + Log::error('Error resolving Element API endpoint: ' . $e->getMessage(), ['exception' => $e]); + report($e); + } + + [$content, $responseContentType] = $this->encode($data, $callback, $jsonOptions, $pretty, $contentType); + + // Cache it? + if ($statusCode !== 200) { + $cache = false; + } + + if ($cache) { + // `true` uses the `cacheDuration` general config setting. As in Craft, 0 means "until invalidated" + // (Laravel treats a 0 TTL as "don't cache", and null as "forever"). + $expire = Config::durationInSeconds($cache === true ? $this->generalConfig->cacheDuration : $cache) ?: null; + + [$dependency, $maxDuration] = $this->elementCaches->stopCollectingCacheInfo(); + $dependency ??= new TagDependency(); + $dependency->tags[] = 'element-api'; + + if ($maxDuration) { + $expire = $expire !== null ? min($expire, $maxDuration) : $maxDuration; + } + + DependencyCache::put($cacheKey, [ + 'content' => $content, + 'contentType' => $responseContentType, + ], $expire, $dependency); + } + + return $this->respond($content, $statusCode, $statusText, $responseContentType); + } + + /** + * Encodes the data as JSON (or JSONP), and returns the body and its content type. + * + * @param array $data + * @return array{string, string} + */ + private function encode(array $data, ?string $callback, ?int $jsonOptions, bool $pretty, ?string $contentType): array + { + if ($callback !== null) { + // JSONP responses use HTML-safe encoding, and ignore `jsonOptions` and `pretty` + $json = $callback . '(' . json_encode( + $data, + JSON_UNESCAPED_UNICODE | JSON_HEX_QUOT | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_THROW_ON_ERROR, + ) . ');'; + } else { + $json = json_encode( + $data, + ($jsonOptions ?? 0) | ($pretty ? JSON_PRETTY_PRINT : 0) | JSON_THROW_ON_ERROR, + ); + } + + $contentType = match (true) { + $contentType === null => $callback !== null ? 'application/javascript; charset=UTF-8' : 'application/json; charset=UTF-8', + !str_contains($contentType, 'charset') => "$contentType; charset=UTF-8", + default => $contentType, + }; + + return [$json, $contentType]; + } + + private function respond(string $content, int $statusCode, ?string $statusText, string $contentType): Response + { + $response = new Response($content, $statusCode, ['Content-Type' => $contentType]); + + if ($statusText !== null) { + $response->setStatusCode($statusCode, $statusText); + } + + return $response; + } + + private function hasResponseHeader(string $name): bool + { + return array_any( + $this->responseHeaders->headers, + fn(array $header) => strcasecmp($header['header'], $name) === 0, + ); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 306afc1..bc8056d 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -1,130 +1,36 @@ * @since 2.0 */ -class Plugin extends \craft\base\Plugin +class Plugin extends \CraftCms\Cms\Plugin\Plugin { - /** - * @var array The default Fractal resource adapter configuration - * @see getDefaultResourceAdapterConfig() - */ - private ?array $_defaultResourceAdapterConfig = null; - /** * @inheritdoc */ - public function init(): void - { - parent::init(); - - Event::on( - UrlManager::class, - UrlManager::EVENT_REGISTER_SITE_URL_RULES, - [$this, 'registerUrlRules'] - ); - - Event::on( - ClearCaches::class, - ClearCaches::EVENT_REGISTER_TAG_OPTIONS, - function(RegisterCacheOptionsEvent $event) { - $event->options[] = [ - 'tag' => 'element-api', - 'label' => Craft::t('element-api', 'Element API responses'), - ]; - } - ); - } - - /** - * Returns the endpoint config for a given URL pattern. - * - * @param string $pattern - * @return callable|array|ResourceAdapterInterface|null - */ - public function getEndpoint(string $pattern) - { - return $this->getSettings()->endpoints[$pattern] ?? null; - } - - /** - * Returns the default endpoint configuration. - * - * @return array - */ - public function getDefaultResourceAdapterConfig(): array + public function boot(): void { - if ($this->_defaultResourceAdapterConfig !== null) { - return $this->_defaultResourceAdapterConfig; - } - - return $this->_defaultResourceAdapterConfig = $this->getSettings()->getDefaults(); - } + // Let classes type-hint the settings instead of looking up the plugin + $this->app->singleton(Settings::class, fn() => $this->getSettings()); - /** - * Registers the site URL rules. - * - * @param RegisterUrlRulesEvent $event - */ - public function registerUrlRules(RegisterUrlRulesEvent $event): void - { - foreach ($this->getSettings()->endpoints as $pattern => $config) { - $event->rules[$pattern] = [ - 'route' => 'element-api', - 'defaults' => ['pattern' => $pattern], - ]; - } + ClearCaches::addTag('element-api', t('Element API responses', category: 'element-api')); } /** - * Creates a Fractal resource based on the given config. - * - * @param array|ResourceInterface|ResourceAdapterInterface $config - * @return ResourceInterface + * @inheritdoc */ - public function createResource($config): ResourceInterface - { - if ($config instanceof ResourceInterface) { - return $config; - } - - if ($config instanceof ResourceAdapterInterface) { - return $config->getResource(); - } - - if (!isset($config['class'])) { - // Default to ElementResourceAdapter - $config['class'] = ElementResource::class; - } - - /** @var ResourceInterface|ResourceAdapterInterface $resource */ - $resource = Craft::createObject($config); - - if ($resource instanceof ResourceAdapterInterface) { - $resource = $resource->getResource(); - } - - return $resource; - } - - protected function createSettingsModel(): ?Model + protected static function createSettings(): ?PluginSettings { return new Settings(); } diff --git a/src/Resources/ElementResource.php b/src/Resources/ElementResource.php new file mode 100644 index 0000000..36f52f3 --- /dev/null +++ b/src/Resources/ElementResource.php @@ -0,0 +1,163 @@ + + * @since 2.0 + */ +class ElementResource extends Component implements ResourceAdapter +{ + /** + * @var class-string The element type that should be queried + */ + public string $elementType; + + /** + * @var array The element query criteria + */ + public array $criteria = []; + + /** + * @var callable|class-string|array|TransformerAbstract The transformer that should be used + */ + public $transformer = ElementTransformer::class; + + /** + * @var bool Whether only the first element should be returned + */ + public bool $one = false; + + /** + * @var bool Whether the results should be paginated + */ + public bool $paginate = true; + + /** + * @var int The number of elements per page + */ + public int $elementsPerPage = 100; + + /** + * @var string The query string param that holds the page number + */ + public string $pageParam = 'page'; + + /** + * @var string|null The resource key + */ + public ?string $resourceKey = 'data'; + + /** + * @var array|null Meta data to include in the response + */ + public ?array $meta = null; + + /** + * @param array $config + */ + public function __construct(array $config = [], protected ?Request $request = null) + { + if (array_key_exists('first', $config)) { + $config['one'] = Arr::pull($config, 'first'); + Deprecator::log('ElementAPI:first', 'The `first` Element API endpoint setting has been renamed to `one`.'); + } + + parent::__construct($config); + + $this->request ??= app(Request::class); + + if (!isset($this->elementType) || !is_subclass_of($this->elementType, ElementInterface::class)) { + throw new InvalidArgumentException('Endpoint has an invalid elementType'); + } + } + + /** + * @inheritdoc + */ + public function getResource(): ResourceInterface + { + $query = $this->getElementQuery(); + $transformer = $this->getTransformer(); + + if ($this->one) { + $element = $query->one(); + + if (!$element) { + throw new NotFoundHttpException('No element exists that matches the endpoint criteria'); + } + + $resource = new Item($element, $transformer, $this->resourceKey); + } elseif ($this->paginate) { + // Create the paginator + $paginator = new PaginatorAdapter($this->elementsPerPage, $query->count(), $this->pageParam, $this->request); + + // Fetch this page's elements + $query->offset(($query->offset ?: 0) + $this->elementsPerPage * ($paginator->getCurrentPage() - 1)); + $query->limit($this->elementsPerPage); + $elements = $query->all(); + $paginator->setCount(count($elements)); + + $resource = new Collection($elements, $transformer, $this->resourceKey); + $resource->setPaginator($paginator); + } else { + $resource = new Collection($query->all(), $transformer, $this->resourceKey); + } + + if ($this->meta !== null) { + $resource->setMeta($this->meta); + } + + return $resource; + } + + /** + * Returns the element query, configured with the endpoint's criteria. + */ + protected function getElementQuery(): ElementQueryInterface + { + $elementType = $this->elementType; + $query = $elementType::find(); + Typecast::configure($query, $this->criteria); + + return $query; + } + + /** + * Returns the transformer. + */ + protected function getTransformer(): callable|TransformerAbstract + { + if (is_callable($this->transformer) || $this->transformer instanceof TransformerAbstract) { + return $this->transformer; + } + + if (is_array($this->transformer)) { + $config = $this->transformer; + $class = Arr::pull($config, 'class'); + + return Typecast::configure(app($class), $config); + } + + return app($this->transformer); + } +} diff --git a/src/Resources/ResourceFactory.php b/src/Resources/ResourceFactory.php new file mode 100644 index 0000000..6c43d2b --- /dev/null +++ b/src/Resources/ResourceFactory.php @@ -0,0 +1,46 @@ +|ResourceInterface|ResourceAdapter $config + */ + public function create(array|ResourceInterface|ResourceAdapter $config): ResourceInterface + { + if ($config instanceof ResourceInterface) { + return $config; + } + + if ($config instanceof ResourceAdapter) { + return $config->getResource(); + } + + // Default to ElementResource + $class = Arr::pull($config, 'class', ElementResource::class); + + $resource = is_subclass_of($class, Component::class) + ? new $class($config) + : Typecast::configure(app($class), $config); + + if ($resource instanceof ResourceAdapter) { + $resource = $resource->getResource(); + } + + return $resource; + } +} diff --git a/src/JsonFeedV1Serializer.php b/src/Serializers/JsonFeedV1Serializer.php similarity index 61% rename from src/JsonFeedV1Serializer.php rename to src/Serializers/JsonFeedV1Serializer.php index e18b27e..4715360 100644 --- a/src/JsonFeedV1Serializer.php +++ b/src/Serializers/JsonFeedV1Serializer.php @@ -1,19 +1,27 @@ + * @since 2.0 + * @link https://jsonfeed.org/version/1.1 */ class JsonFeedV1Serializer extends ArraySerializer { + public function __construct( + private readonly Request $request, + private readonly Sites $sites, + ) {} + /** * @inheritdoc */ @@ -29,9 +37,9 @@ public function meta(array $meta): array { return array_merge([ 'version' => 'https://jsonfeed.org/version/1.1', - 'title' => \Craft::$app->getSites()->getCurrentSite()->name, - 'home_page_url' => UrlHelper::baseSiteUrl(), - 'feed_url' => UrlHelper::url(Craft::$app->getRequest()->getPathInfo()), + 'title' => $this->sites->getCurrentSite()->name, + 'home_page_url' => Url::baseSiteUrl(), + 'feed_url' => Url::url($this->request->path()), ], $meta); } diff --git a/src/Settings.php b/src/Settings.php index c4fdef3..68515ba 100644 --- a/src/Settings.php +++ b/src/Settings.php @@ -1,35 +1,44 @@ * @since 2.0 */ -class Settings extends Model +class Settings extends PluginSettings { /** - * @var callable|array The default endpoint configuration. + * @var array|callable The default configuration for each endpoint */ - public $defaults = []; + public mixed $defaults = []; /** - * @var array The endpoint configurations. + * @var array|callable> The endpoints, indexed by Laravel route pattern (e.g. `news/{entryId}`) */ public array $endpoints = []; /** * Returns the default endpoint configuration. * - * @return array The default endpoint configuration. - * @since 2.6.0 + * @return array */ public function getDefaults(): array { return is_callable($this->defaults) ? call_user_func($this->defaults) : $this->defaults; } + + /** + * Returns the configuration for an endpoint. + * + * @return array|callable|null + */ + public function getEndpoint(string $pattern): array|callable|null + { + return $this->endpoints[$pattern] ?? null; + } } diff --git a/src/PaginatorAdapter.php b/src/Support/PaginatorAdapter.php similarity index 81% rename from src/PaginatorAdapter.php rename to src/Support/PaginatorAdapter.php index 22bc2ff..6a41e8f 100644 --- a/src/PaginatorAdapter.php +++ b/src/Support/PaginatorAdapter.php @@ -1,9 +1,9 @@ elementsPerPage = $elementsPerPage; $this->totalElements = $totalElements; $this->pageParam = $pageParam; @@ -128,16 +132,13 @@ public function getPerPage(): int */ public function getUrl(int $page): string { - $request = Craft::$app->getRequest(); - - // Get the query string params without the path or `pattern` - parse_str($request->getQueryString(), $params); - $pathParam = Craft::$app->getConfig()->getGeneral()->pathParam; - unset($params[$pathParam], $params['pattern']); + // Get the query string params + $params = $this->request->query(); // Return the URL with the page param $params[$this->pageParam] = $page; - return UrlHelper::url($request->getPathInfo(), $params); + + return Url::url($this->request->path(), $params); } /** @@ -147,7 +148,7 @@ public function getUrl(int $page): string */ private function _currentPage(): int { - $currentPage = Craft::$app->getRequest()->getQueryParam($this->pageParam, 1); + $currentPage = $this->request->query($this->pageParam, 1); if (is_numeric($currentPage) && $currentPage > $this->totalPages) { $currentPage = $this->totalPages > 0 ? $this->totalPages : 1; diff --git a/src/ElementTransformer.php b/src/Transformers/ElementTransformer.php similarity index 76% rename from src/ElementTransformer.php rename to src/Transformers/ElementTransformer.php index 1b415f3..37356a0 100644 --- a/src/ElementTransformer.php +++ b/src/Transformers/ElementTransformer.php @@ -1,9 +1,9 @@ + */ public function transform(ElementInterface $element): array { // Get the serialized custom field values diff --git a/src/controllers/DefaultController.php b/src/controllers/DefaultController.php deleted file mode 100644 index 0335817..0000000 --- a/src/controllers/DefaultController.php +++ /dev/null @@ -1,300 +0,0 @@ - - * @since 2.0 - */ -class DefaultController extends Controller -{ - /** - * @event DataEvent The event that is triggered before sending the response data - */ - public const EVENT_BEFORE_SEND_DATA = 'beforeSendData'; - - /** - * @inheritdoc - */ - protected array|int|bool $allowAnonymous = true; - - /** - * Returns the requested elements as JSON. - * - * @param string $pattern The endpoint URL pattern that was matched - * @return Response - * @throws InvalidConfigException - */ - public function actionIndex(string $pattern): Response - { - $callback = null; - $jsonOptions = null; - $pretty = false; - /** @var mixed $cache */ - $cache = true; - $statusCode = 200; - $statusText = null; - - try { - $plugin = Plugin::getInstance(); - $config = $plugin->getEndpoint($pattern); - - if (is_callable($config)) { - /** @phpstan-ignore-next-line */ - $params = Craft::$app->getUrlManager()->getRouteParams(); - $config = $this->_callWithParams($config, $params); - } - - if ($this->request->getIsOptions()) { - // Now that the endpoint has had a chance to add CORS response headers, end the request - $this->response->format = Response::FORMAT_RAW; - return $this->response; - } - - if (is_array($config)) { - // Merge in the defaults - $config = ArrayHelper::merge($plugin->getDefaultResourceAdapterConfig(), $config); - } - - // Prevent API endpoints from getting indexed - $this->response->getHeaders()->setDefault('X-Robots-Tag', 'none'); - - // Before anything else, check the cache - $cache = ArrayHelper::remove($config, 'cache', true); - if ($this->request->getIsPreview() || $this->request->getIsLivePreview()) { - // Ignore config & disable cache for live preview - $cache = false; - } - - $cacheKey = ArrayHelper::remove($config, 'cacheKey') - ?? implode(':', [ - 'elementapi', - Craft::$app->getSites()->getCurrentSite()->id, - $this->request->getPathInfo(), - $this->request->getQueryStringWithoutPath(), - ]); - - if ($cache) { - $cacheService = Craft::$app->getCache(); - - if (($cachedContent = $cacheService->get($cacheKey)) !== false) { - if (StringHelper::startsWith($cachedContent, 'data:')) { - [$contentType, $cachedContent] = explode(',', substr($cachedContent, 5), 2); - } - // Set the JSON headers - $formatter = new JsonResponseFormatter([ - 'contentType' => $contentType ?? null, - ]); - $formatter->format($this->response); - - // Set the cached JSON on the response and return - $this->response->format = Response::FORMAT_RAW; - $this->response->content = $cachedContent; - return $this->response; - } - - $elementsService = Craft::$app->getElements(); - $elementsService->startCollectingCacheInfo(); - } - - // Extract config settings that aren't meant for createResource() - $serializer = ArrayHelper::remove($config, 'serializer'); - $callback = ArrayHelper::remove($config, 'callback'); - $jsonOptions = ArrayHelper::remove($config, 'jsonOptions', JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); - $pretty = ArrayHelper::remove($config, 'pretty', false); - $includes = ArrayHelper::remove($config, 'includes', []); - $excludes = ArrayHelper::remove($config, 'excludes', []); - $contentType = ArrayHelper::remove($config, 'contentType'); - - // Generate all transforms immediately - Craft::$app->getConfig()->getGeneral()->generateTransformsBeforePageLoad = true; - - // Get the data resource - $resource = $plugin->createResource($config); - - // Load Fractal - $fractal = new Manager(); - - // Serialize the data - if (!$serializer instanceof SerializerAbstract) { - switch ($serializer) { - case 'dataArray': - $serializer = new DataArraySerializer(); - break; - case 'jsonApi': - $serializer = new JsonApiSerializer(); - break; - case 'jsonFeed': - $serializer = new JsonFeedV1Serializer(); - if ($contentType === null) { - $contentType = 'application/feed+json'; - } - break; - default: - $serializer = new ArraySerializer(); - } - } - - $fractal->setSerializer($serializer); - - // Parse includes/excludes - $fractal->parseIncludes($includes); - $fractal->parseExcludes($excludes); - - $data = $fractal->createData($resource); - - // Fire a 'beforeSendData' event - if ($this->hasEventHandlers(self::EVENT_BEFORE_SEND_DATA)) { - $this->trigger(self::EVENT_BEFORE_SEND_DATA, new DataEvent([ - 'payload' => $data, - ])); - } - - $data = $data->toArray(); - } catch (\Throwable $e) { - $data = [ - 'error' => [ - 'code' => $e instanceof HttpException ? $e->statusCode : $e->getCode(), - 'message' => $e instanceof UserException ? $e->getMessage() : 'A server error occurred.', - ], - ]; - $statusCode = $e instanceof HttpException ? $e->statusCode : 500; - if ($e instanceof UserException && ($message = $e->getMessage())) { - $statusText = preg_split('/[\r\n]/', $message, 2)[0]; - } else { - $statusText = 'Server error'; - } - - // Log the exception - Craft::error('Error resolving Element API endpoint: ' . $e->getMessage(), __METHOD__); - Craft::$app->getErrorHandler()->logException($e); - } - - // Create a JSON response formatter with custom options - $formatter = new JsonResponseFormatter([ - 'contentType' => $contentType ?? null, - 'useJsonp' => $callback !== null, - 'encodeOptions' => $jsonOptions, - 'prettyPrint' => $pretty, - ]); - - // Manually format the response ahead of time, so we can access and cache the JSON - if ($callback !== null) { - $this->response->data = [ - 'data' => $data, - 'callback' => $callback, - ]; - } else { - $this->response->data = $data; - } - - $formatter->format($this->response); - $this->response->data = null; - $this->response->format = Response::FORMAT_RAW; - - // Cache it? - if ($statusCode !== 200) { - $cache = false; - } - if ($cache) { - if ($cache !== true) { - $expire = ConfigHelper::durationInSeconds($cache); - } else { - $expire = null; - } - - /** @phpstan-ignore-next-line */ - [$dep, $maxDuration] = $elementsService->stopCollectingCacheInfo(); - $dep ??= new TagDependency(); - $dep->tags[] = 'element-api'; - - if ($maxDuration) { - if ($expire !== null) { - $expire = min($expire, $maxDuration); - } else { - $expire = $maxDuration; - } - } - - $cachedContent = $this->response->content; - if (isset($contentType)) { - $cachedContent = "data:$contentType,$cachedContent"; - } - /** @phpstan-ignore-next-line */ - $cacheService->set($cacheKey, $cachedContent, $expire, $dep); - } - - // Don't double-encode the data - $this->response->format = Response::FORMAT_RAW; - $this->response->setStatusCode($statusCode, $statusText); - return $this->response; - } - - /** - * Calls a given function. If any params are given, they will be mapped to the function's arguments. - * - * @param callable $func The function to call - * @param array $params Any params that should be mapped to function arguments - * @return mixed The result of the function - * @throws InvalidConfigException - */ - private function _callWithParams($func, $params) - { - if (empty($params)) { - return $func(); - } - - $ref = new ReflectionFunction($func); - $args = []; - - foreach ($ref->getParameters() as $param) { - $name = $param->getName(); - - if (isset($params[$name])) { - if ($param->isArray()) { - $args[] = is_array($params[$name]) ? $params[$name] : [$params[$name]]; - } elseif (!is_array($params[$name])) { - $args[] = $params[$name]; - } else { - throw new InvalidConfigException("Unable to resolve $name param"); - } - } elseif ($param->isDefaultValueAvailable()) { - $args[] = $param->getDefaultValue(); - } else { - throw new InvalidConfigException("Unable to resolve $name param"); - } - } - - return $ref->invokeArgs($args); - } -} diff --git a/src/resources/ElementResource.php b/src/resources/ElementResource.php deleted file mode 100644 index a549135..0000000 --- a/src/resources/ElementResource.php +++ /dev/null @@ -1,178 +0,0 @@ - - * @since 2.0 - */ -class ElementResource extends BaseObject implements ResourceAdapterInterface -{ - /** - * @var string The element type class name - */ - public string $elementType; - - /** - * @var array The element criteria params that should be used to filter the matching elements - */ - public array $criteria = []; - - /** - * @var callable|string|array|TransformerAbstract The transformer config, or an actual transformer object - */ - public $transformer = ElementTransformer::class; - - /** - * @var bool Whether to only return one result - */ - public bool $one = false; - - /** - * @var bool Whether to paginate the results - */ - public bool $paginate = true; - - /** - * @var int The number of elements to include per page - * @see paginate - */ - public int $elementsPerPage = 100; - - /** - * @var string The query string param name that should be used to specify the page number - * @see paginate - */ - public string $pageParam = 'page'; - - /** - * @var string|null The resource key that should be set on the resource - */ - public ?string $resourceKey = 'data'; - - /** - * @var array|null Custom meta values - */ - public ?array $meta = null; - - /** - * @inheritdoc - */ - public function __construct(array $config = []) - { - if (array_key_exists('first', $config)) { - $config['one'] = ArrayHelper::remove($config, 'first'); - Craft::$app->getDeprecator()->log('ElementAPI:first', 'The `first` Element API endpoint setting has been renamed to `one`.'); - } - - parent::__construct($config); - } - - /** - * @inheritdoc - * @throws InvalidConfigException - */ - public function init(): void - { - if (!isset($this->elementType) || !is_subclass_of($this->elementType, ElementInterface::class)) { - throw new InvalidConfigException('Endpoint has an invalid elementType'); - } - - if ($this->paginate) { - // Make sure the page param != the path param - $pathParam = Craft::$app->getConfig()->getGeneral()->pathParam; - if ($this->pageParam === $pathParam) { - throw new InvalidConfigException("The pageParam cannot be set to \"{$pathParam}\" because that's the parameter Craft uses to check the requested path."); - } - } - } - - /** - * @inheritdoc - * @throws Exception if [[one]] is true and no element matches [[criteria]] - */ - public function getResource(): ResourceInterface - { - /** @var ElementQuery $query */ - $query = $this->getElementQuery(); - $transformer = $this->getTransformer(); - - if ($this->one) { - $element = $query->one(); - - if (!$element) { - throw new NotFoundHttpException('No element exists that matches the endpoint criteria'); - } - - $resource = new Item($element, $transformer, $this->resourceKey); - } elseif ($this->paginate) { - // Create the paginator - $paginator = new PaginatorAdapter($this->elementsPerPage, $query->count(), $this->pageParam); - - // Fetch this page's elements - $query->offset(($query->offset ?: 0) + $this->elementsPerPage * ($paginator->getCurrentPage() - 1)); - $query->limit($this->elementsPerPage); - $elements = $query->all(); - $paginator->setCount(count($elements)); - - $resource = new Collection($elements, $transformer, $this->resourceKey); - $resource->setPaginator($paginator); - } else { - $resource = new Collection($query->all(), $transformer, $this->resourceKey); - } - - if ($this->meta !== null) { - $resource->setMeta($this->meta); - } - - return $resource; - } - - /** - * Returns the element query based on [[elementType]] and [[criteria]] - * - * @return ElementQueryInterface - */ - protected function getElementQuery(): ElementQueryInterface - { - /** @var string|ElementInterface $elementType */ - $elementType = $this->elementType; - $query = $elementType::find(); - Craft::configure($query, $this->criteria); - - return $query; - } - - /** - * Returns the transformer based on the given endpoint - * - * @return callable|TransformerAbstract - */ - protected function getTransformer() - { - if (is_callable($this->transformer) || $this->transformer instanceof TransformerAbstract) { - return $this->transformer; - } - - return Craft::createObject($this->transformer); - } -} diff --git a/stubs/BaseYii.stub b/stubs/BaseYii.stub deleted file mode 100644 index 2fe3456..0000000 --- a/stubs/BaseYii.stub +++ /dev/null @@ -1,19 +0,0 @@ -