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/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 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); } diff --git a/tests/src/Kernel/JsonapiViewsResourceKernelTest.php b/tests/src/Kernel/JsonapiViewsResourceKernelTest.php index 009ff0a..cffedd5 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; @@ -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."); } @@ -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()); + } + } 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); + } + +}