From 20f453aca8fc82a8ea05eefb069b4b6cbaa6ac3a Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Thu, 6 Aug 2026 07:04:59 +0000 Subject: [PATCH 1/4] fix(#3265781): add resource type route default for single-bundle views --- AGENTS.md | 7 +++++++ src/Routing/Routes.php | 29 +++++++++++++++++++++++------ 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index face4be..3f00564 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/src/Routing/Routes.php b/src/Routing/Routes.php index d712d37..3c63406 100644 --- a/src/Routing/Routes.php +++ b/src/Routing/Routes.php @@ -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; @@ -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) { @@ -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); } From e3468f7ced5447791eb02d4fd481cacfda316b31 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Thu, 6 Aug 2026 07:05:07 +0000 Subject: [PATCH 2/4] test(#3265781): cover resource type default for single- and multi-bundle views --- .../Kernel/JsonapiViewsResourceKernelTest.php | 95 +++++++++++++++++++ .../Kernel/NullingResourceTypeRepository.php | 59 ++++++++++++ 2 files changed, 154 insertions(+) create mode 100644 tests/src/Kernel/NullingResourceTypeRepository.php diff --git a/tests/src/Kernel/JsonapiViewsResourceKernelTest.php b/tests/src/Kernel/JsonapiViewsResourceKernelTest.php index 009ff0a..13f21ec 100644 --- a/tests/src/Kernel/JsonapiViewsResourceKernelTest.php +++ b/tests/src/Kernel/JsonapiViewsResourceKernelTest.php @@ -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; @@ -419,4 +422,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()); + } + } diff --git a/tests/src/Kernel/NullingResourceTypeRepository.php b/tests/src/Kernel/NullingResourceTypeRepository.php new file mode 100644 index 0000000..f92fe04 --- /dev/null +++ b/tests/src/Kernel/NullingResourceTypeRepository.php @@ -0,0 +1,59 @@ +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); + } + +} From 8b8a2b7a3607f5299c4c4a15c98cb73fa4776d30 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sat, 8 Aug 2026 23:51:31 +0000 Subject: [PATCH 3/4] docs(#3265781): update changelog --- CHANGELOG.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 351ce56..6602b97 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 From 543bbb38309464e21bfc90ed126aec9070602a46 Mon Sep 17 00:00:00 2001 From: Stuart Clark Date: Sun, 9 Aug 2026 00:43:27 +0000 Subject: [PATCH 4/4] test(#3484714): assert cache spy recorded calls --- tests/src/Kernel/JsonapiViewsResourceKernelTest.php | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/src/Kernel/JsonapiViewsResourceKernelTest.php b/tests/src/Kernel/JsonapiViewsResourceKernelTest.php index 13f21ec..cffedd5 100644 --- a/tests/src/Kernel/JsonapiViewsResourceKernelTest.php +++ b/tests/src/Kernel/JsonapiViewsResourceKernelTest.php @@ -289,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."); }