Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ Run each tool through its `make` wrapper, never the binary directly:
- **Dynamic routing**: `Routes::routes()` iterates enabled View displays and
generates one JSON:API route per display that has the `jsonapi_views`
display extender enabled.
- **Resource type route default**: each route always carries the plural
`_jsonapi_resource_types` default (every bundle name the view's entity
type has). It carries the singular `resource_type` default only when
the entity type has exactly one bundle. Tools such as the OpenAPI
module's JSON:API discovery read `resource_type` to describe a route.
A view with several bundles has no single correct resource type. Its
route leaves `resource_type` unset.
- **Display extender opt-out**: exposure is per-display, not per-view - each
display's "Expose via JSON:API" checkbox is stored via
`JsonapiViews::defineOptions()`/`submitOptionsForm()` and read back via
Expand Down
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ and this project adheres to
- [#3202583](https://www.drupal.org/project/jsonapi_views/issues/3202583):
Propagated the view's full cache metadata (tags, contexts, max-age) to the
JSON:API response and skipped view execution for access-denied requests.
- [#3265781](https://www.drupal.org/project/jsonapi_views/issues/3265781):
Added the resource type route default for single-bundle views so route
introspection tools (for example the OpenAPI module) can resolve them.
- [#3376193](https://www.drupal.org/project/jsonapi_views/issues/3376193):
Keyed each exposed filter in the preview URL by its identifier instead of
its field name, so filters are no longer dropped and no longer raise PHP
Expand Down
29 changes: 23 additions & 6 deletions src/Routing/Routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@

use Drupal\Core\DependencyInjection\ContainerInjectionInterface;
use Drupal\Core\Entity\EntityTypeBundleInfoInterface;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
use Drupal\jsonapi\Routing\Routes as JsonApiRoutes;
use Drupal\jsonapi_views\Resource\ViewsResource;
use Drupal\views\Views;
use Symfony\Component\DependencyInjection\ContainerInterface;
Expand Down Expand Up @@ -75,18 +77,24 @@ public function routes(): RouteCollection {
}
$entity_type = $entity_type->id();
if (array_key_exists($entity_type, $resource_by_entity_type)) {
$resource_types = $resource_by_entity_type[$entity_type];
$bundle_resource_types = $resource_by_entity_type[$entity_type];
}
else {
$bundle_info = $this->entityTypeBundleInfo->getBundleInfo($entity_type);
$bundles = array_keys($bundle_info);
$resource_types = array_map(fn(int|string $bundle) => $this->resourceTypeRepository->get($entity_type, (string) $bundle)->getTypeName(), $bundles);
$resource_by_entity_type[$entity_type] = $resource_types;
// A bundle can exist without a matching resource type. For example,
// jsonapi_extras can disable a bundle's JSON:API resource. get()
// returns NULL for that bundle. Drop those bundles here. A dropped
// bundle must not appear in _jsonapi_resource_types or become the
// resource_type default.
$bundle_resource_types = array_filter(array_map(fn(int|string $bundle) => $this->resourceTypeRepository->get($entity_type, (string) $bundle), $bundles));
$resource_by_entity_type[$entity_type] = $bundle_resource_types;
}

if (empty($resource_types)) {
if (empty($bundle_resource_types)) {
continue;
}
$resource_types = array_map(fn(ResourceType $resource_type) => $resource_type->getTypeName(), $bundle_resource_types);

// Create routes for each display.
foreach ($view->get('display') as $display) {
Expand All @@ -97,12 +105,21 @@ public function routes(): RouteCollection {
$view_name,
$display_id,
]));
$views_display_route->addDefaults([
$defaults = [
static::JSONAPI_RESOURCE_KEY => static::RESOURCE_NAME,
static::JSONAPI_RESOURCE_TYPES_KEY => $resource_types,
static::VIEW_KEY => $view->id(),
static::DISPLAY_KEY => $display_id,
]);
];
// A view with exactly one bundle has one correct resource type.
// Set it here. Tools like the OpenAPI module's JSON:API
// discovery read this default to describe the route.
// A view with several bundles has no single correct resource
// type. Leave the default unset. Do not report a fake type.
if (count($bundle_resource_types) === 1) {
$defaults[JsonApiRoutes::RESOURCE_TYPE_KEY] = reset($bundle_resource_types);
}
$views_display_route->addDefaults($defaults);

$jsonapi_views_routes->add(sprintf('jsonapi_views.%s.%s', $view_name, $display_id), $views_display_route);
}
Expand Down
96 changes: 96 additions & 0 deletions tests/src/Kernel/JsonapiViewsResourceKernelTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
use Drupal\Tests\jsonapi_resources\Kernel\Traits\RequestTrait;
use Drupal\Tests\node\Traits\NodeCreationTrait;
use Drupal\Tests\user\Traits\UserCreationTrait;
use Drupal\jsonapi\ResourceType\ResourceType;
use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;
use Drupal\jsonapi\Routing\Routes as JsonApiRoutes;
use Drupal\jsonapi_views\Plugin\views\display_extender\JsonapiViews;
use Drupal\jsonapi_views\Routing\Routes;
use Drupal\node\Entity\NodeType;
use Drupal\user\Entity\Role;
use Drupal\user\RoleInterface;
Expand Down Expand Up @@ -286,6 +289,7 @@ public function testMultipleViewsShareEntityTypeResourceTypeCache(): void {
// Each bundle of the shared 'node' entity type must resolve once for
// the whole rebuild. One call for two views proves the second view
// used the cache.
$this->assertNotEmpty($counting_repository->callsByBundle);
foreach ($counting_repository->callsByBundle as $key => $count) {
$this->assertSame(1, $count, "$key resolved more than once across the two views.");
}
Expand Down Expand Up @@ -419,4 +423,96 @@ public function testPreviewFilterIdentifier(): void {
$this->assertStringNotContainsString('views-filter[type]', $markup);
}

/**
* Tests that a multi-bundle view's route omits the resource type default.
*
* Some tools read a JSON:API route's "resource_type" default. They use
* it to resolve the route to one ResourceType object. The OpenAPI
* module's discovery code does this.
*
* The test fixture's node view has two bundles: location and room.
* There is no single correct resource type for it. The route must
* leave the default unset. It must not report a fake type.
*
* See #3265781.
*/
public function testRouteOmitsResourceTypeDefaultForMultiBundleView(): void {
$route_provider = $this->container->get('router.route_provider');
$route = $route_provider->getRouteByName('jsonapi_views.jsonapi_views_test_node_view.page_1');

$this->assertFalse($route->hasDefault(JsonApiRoutes::RESOURCE_TYPE_KEY));
}

/**
* Tests that a single-bundle view's route carries the real resource type.
*
* The "user" entity type has exactly one bundle. A view built on it
* has one correct resource type.
*
* See #3265781.
*/
public function testRouteHasResourceTypeDefaultForSingleBundleView(): void {
$user_view = View::create([
'id' => 'jsonapi_views_test_user_view',
'label' => 'JSON:API Views Test User View',
'base_table' => 'users_field_data',
'display' => [
'default' => [
'display_plugin' => 'default',
'id' => 'default',
'display_options' => [
'access' => ['type' => 'perm', 'options' => ['perm' => 'access user profiles']],
],
],
'page_1' => [
'display_plugin' => 'page',
'id' => 'page_1',
'display_options' => [
'path' => 'jsonapi-views-test-user-view',
],
],
],
]);
$user_view->save();
$this->container->get('router.builder')->rebuild();

$route_provider = $this->container->get('router.route_provider');
$route = $route_provider->getRouteByName('jsonapi_views.jsonapi_views_test_user_view.page_1');

$resource_type = $route->getDefault(JsonApiRoutes::RESOURCE_TYPE_KEY);
$this->assertInstanceOf(ResourceType::class, $resource_type);
$this->assertSame('user', $resource_type->getEntityTypeId());
$this->assertSame('user', $resource_type->getBundle());
$this->assertSame('user--user', $resource_type->getTypeName());
}

/**
* Tests that a bundle with no resource type is dropped, not kept as NULL.
*
* A bundle can exist with no matching resource type. For example,
* jsonapi_extras can disable a bundle's JSON:API resource. The route
* must drop that bundle. It must not put NULL in
* "_jsonapi_resource_types". If only one bundle is left, the route
* must use it as the "resource_type" default.
*
* See #3265781.
*/
public function testRouteDropsBundleWithNoResourceType(): void {
$real_repository = $this->container->get('jsonapi.resource_type.repository');
assert($real_repository instanceof ResourceTypeRepositoryInterface);
$nulling_repository = new NullingResourceTypeRepository($real_repository, 'node', 'room');
$this->container->set('jsonapi.resource_type.repository', $nulling_repository);
$this->container->get('router.builder')->rebuild();

$route_provider = $this->container->get('router.route_provider');
$route = $route_provider->getRouteByName('jsonapi_views.jsonapi_views_test_node_view.page_1');

$resource_types = $route->getDefault(Routes::JSONAPI_RESOURCE_TYPES_KEY);
$this->assertSame(['node--location'], $resource_types);

$resource_type = $route->getDefault(JsonApiRoutes::RESOURCE_TYPE_KEY);
$this->assertInstanceOf(ResourceType::class, $resource_type);
$this->assertSame('location', $resource_type->getBundle());
}

}
59 changes: 59 additions & 0 deletions tests/src/Kernel/NullingResourceTypeRepository.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<?php

declare(strict_types=1);

namespace Drupal\Tests\jsonapi_views\Kernel;

use Drupal\jsonapi\ResourceType\ResourceTypeRepositoryInterface;

/**
* Wraps a resource type repository. Hides one bundle.
*
* Test double for JsonapiViewsResourceKernelTest. get() returns NULL for
* one chosen entity type and bundle. Every other call goes to the real
* repository. This copies a bundle with no JSON:API resource type. For
* example, jsonapi_extras can disable a bundle this way. See #3265781.
*/
final readonly class NullingResourceTypeRepository implements ResourceTypeRepositoryInterface {

public function __construct(
/**
* The real repository.
*/
private ResourceTypeRepositoryInterface $resourceTypeRepository,
/**
* The entity type ID to hide.
*/
private string $hiddenEntityTypeId,
/**
* The bundle to hide.
*/
private string $hiddenBundle,
) {
}

/**
* {@inheritdoc}
*/
public function all() {
return $this->resourceTypeRepository->all();
}

/**
* {@inheritdoc}
*/
public function get($entity_type_id, $bundle) {
if ($entity_type_id === $this->hiddenEntityTypeId && $bundle === $this->hiddenBundle) {
return NULL;
}
return $this->resourceTypeRepository->get($entity_type_id, $bundle);
}

/**
* {@inheritdoc}
*/
public function getByTypeName($type_name) {
return $this->resourceTypeRepository->getByTypeName($type_name);
}

}