diff --git a/src/bundle/DependencyInjection/Configuration/Parser/Headless.php b/src/bundle/DependencyInjection/Configuration/Parser/Headless.php new file mode 100644 index 000000000..cfc2e1817 --- /dev/null +++ b/src/bundle/DependencyInjection/Configuration/Parser/Headless.php @@ -0,0 +1,136 @@ + ['enabled'], + 'headless.page_builder.preview_url' => ['page_builder', 'preview_url'], + 'headless.content.preview_url' => ['content', 'preview_url'], + 'headless.configuration_url' => ['configuration_url'], + ]; + + /** + * @param array $scopeSettings + */ + public function mapConfig( + array &$scopeSettings, + mixed $currentScope, + ContextualizerInterface $contextualizer + ): void { + if (!isset($scopeSettings[self::NODE]) || !is_array($scopeSettings[self::NODE])) { + return; + } + + $settings = $scopeSettings[self::NODE]; + + foreach (self::PARAMETERS as $parameterName => $path) { + if (!$this->hasPath($settings, $path)) { + continue; + } + + $contextualizer->setContextualParameter( + $parameterName, + $currentScope, + $this->getPath($settings, $path) + ); + } + } + + public function addSemanticConfig(NodeBuilder $nodeBuilder): void + { + $nodeBuilder + ->arrayNode(self::NODE) + ->info('Headless mode configuration.') + ->children() + ->booleanNode('enabled') + ->info('Enables the headless mode for the siteaccess.') + ->end() + ->arrayNode('page_builder') + ->children() + ->scalarNode('preview_url') + ->info('Front-end application URL loaded into the Page Builder preview iframe.') + ->end() + ->end() + ->end() + ->arrayNode('content') + ->children() + ->scalarNode('preview_url') + ->info('Front-end application URL used for content preview.') + ->end() + ->end() + ->end() + ->scalarNode('configuration_url') + ->info('URL of the front-end application configuration page.') + ->end() + ->end() + ->end(); + } + + /** + * @param array $settings + * @param string[] $path + */ + private function hasPath(array $settings, array $path): bool + { + foreach ($path as $key) { + if (!is_array($settings) || !array_key_exists($key, $settings)) { + return false; + } + + $settings = $settings[$key]; + } + + return true; + } + + /** + * @param array $settings + * @param string[] $path + */ + private function getPath(array $settings, array $path): mixed + { + $value = $settings; + foreach ($path as $key) { + $value = $value[$key]; + } + + return $value; + } +} diff --git a/src/bundle/DependencyInjection/IbexaAdminUiExtension.php b/src/bundle/DependencyInjection/IbexaAdminUiExtension.php index 336060a99..e77a7047c 100644 --- a/src/bundle/DependencyInjection/IbexaAdminUiExtension.php +++ b/src/bundle/DependencyInjection/IbexaAdminUiExtension.php @@ -14,6 +14,7 @@ use Symfony\Component\DependencyInjection\ContainerBuilder; use Symfony\Component\DependencyInjection\Extension\Extension; use Symfony\Component\DependencyInjection\Extension\PrependExtensionInterface; +use Symfony\Component\DependencyInjection\Loader\PhpFileLoader; use Symfony\Component\DependencyInjection\Loader\YamlFileLoader; use Symfony\Component\Yaml\Yaml; @@ -51,15 +52,16 @@ final class IbexaAdminUiExtension extends Extension implements PrependExtensionI */ public function load(array $configs, ContainerBuilder $container): void { - $loader = new YamlFileLoader( - $container, - new FileLocator(__DIR__ . '/../Resources/config') - ); + $configLocator = new FileLocator(__DIR__ . '/../Resources/config'); + $loader = new YamlFileLoader($container, $configLocator); $loader->load('default_parameters.yaml'); $loader->load('services.yaml'); $loader->load('role.yaml'); + $phpLoader = new PhpFileLoader($container, $configLocator); + $phpLoader->load('services.php'); + $shouldLoadTestServices = $this->shouldLoadTestServices($container); if ($shouldLoadTestServices) { $loader->load('services/test/feature_contexts.yaml'); diff --git a/src/bundle/IbexaAdminUiBundle.php b/src/bundle/IbexaAdminUiBundle.php index 158cc9607..9f285ec22 100644 --- a/src/bundle/IbexaAdminUiBundle.php +++ b/src/bundle/IbexaAdminUiBundle.php @@ -73,6 +73,7 @@ private function getConfigParsers(): array new Parser\Assets(), new Parser\AdminUiParser(), new Parser\UserProfile(), + new Parser\Headless(), ]; } } diff --git a/src/bundle/Resources/config/ezplatform_default_settings.yaml b/src/bundle/Resources/config/ezplatform_default_settings.yaml index 7babb1ba1..fb3ccf7ac 100644 --- a/src/bundle/Resources/config/ezplatform_default_settings.yaml +++ b/src/bundle/Resources/config/ezplatform_default_settings.yaml @@ -92,3 +92,9 @@ parameters: ibexa.site_access.config.default.user_profile.enabled: false ibexa.site_access.config.default.user_profile.content_types: [] ibexa.site_access.config.default.user_profile.field_groups: [] + + # Headless mode (external front-end application) + ibexa.site_access.config.default.headless.enabled: false + ibexa.site_access.config.default.headless.page_builder.preview_url: ~ + ibexa.site_access.config.default.headless.content.preview_url: ~ + ibexa.site_access.config.default.headless.configuration_url: ~ diff --git a/src/bundle/Resources/config/services.php b/src/bundle/Resources/config/services.php new file mode 100644 index 000000000..237c12446 --- /dev/null +++ b/src/bundle/Resources/config/services.php @@ -0,0 +1,13 @@ +import('services/**/*.php'); +}; diff --git a/src/bundle/Resources/config/services/headless.php b/src/bundle/Resources/config/services/headless.php new file mode 100644 index 000000000..8069238f6 --- /dev/null +++ b/src/bundle/Resources/config/services/headless.php @@ -0,0 +1,25 @@ +services(); + + $services + ->defaults() + ->autowire() + ->autoconfigure() + ->private(); + + $services->set(HeadlessConfiguration::class); + + $services->alias(HeadlessConfigurationInterface::class, HeadlessConfiguration::class); +}; diff --git a/src/lib/Headless/HeadlessConfiguration.php b/src/lib/Headless/HeadlessConfiguration.php new file mode 100644 index 000000000..bab670099 --- /dev/null +++ b/src/lib/Headless/HeadlessConfiguration.php @@ -0,0 +1,45 @@ +configResolver->getParameter('headless.enabled', null, $scope); + } + + public function getPageBuilderPreviewUrl(?string $scope = null): ?string + { + return $this->getUrl('headless.page_builder.preview_url', $scope); + } + + public function getContentPreviewUrl(?string $scope = null): ?string + { + return $this->getUrl('headless.content.preview_url', $scope); + } + + public function getConfigurationUrl(?string $scope = null): ?string + { + return $this->getUrl('headless.configuration_url', $scope); + } + + private function getUrl(string $parameterName, ?string $scope): ?string + { + $value = $this->configResolver->getParameter($parameterName, null, $scope); + + return is_string($value) && $value !== '' ? $value : null; + } +} diff --git a/src/lib/Headless/HeadlessConfigurationInterface.php b/src/lib/Headless/HeadlessConfigurationInterface.php new file mode 100644 index 000000000..4d24f9daa --- /dev/null +++ b/src/lib/Headless/HeadlessConfigurationInterface.php @@ -0,0 +1,40 @@ +.headless`). + * + * All methods resolve the configuration for the current siteaccess unless `$scope` is given. + * + * `isEnabled()` only reflects the flag: consumers must also check that the URL they need is configured + * (for example `getPageBuilderPreviewUrl() !== null`) before switching to the headless behaviour. + * + * @internal + */ +interface HeadlessConfigurationInterface +{ + public function isEnabled(?string $scope = null): bool; + + /** + * URL of the front-end application loaded into the Page Builder preview iframe. + */ + public function getPageBuilderPreviewUrl(?string $scope = null): ?string; + + /** + * URL of the front-end application used to preview content items outside of Page Builder. + */ + public function getContentPreviewUrl(?string $scope = null): ?string; + + /** + * URL of the page where the front-end application can be configured, if the installation provides one. + */ + public function getConfigurationUrl(?string $scope = null): ?string; +} diff --git a/tests/bundle/DependencyInjection/Configuration/Parser/HeadlessTest.php b/tests/bundle/DependencyInjection/Configuration/Parser/HeadlessTest.php new file mode 100644 index 000000000..b3231d753 --- /dev/null +++ b/tests/bundle/DependencyInjection/Configuration/Parser/HeadlessTest.php @@ -0,0 +1,200 @@ + $headless + * @param array $expectedParameters + * @param string[] $expectedMissingParameters + */ + #[DataProvider('dataProviderForTestSettings')] + public function testSettings(array $headless, array $expectedParameters, array $expectedMissingParameters): void + { + $this->load([ + 'system' => [ + self::SITE_ACCESS => [ + 'headless' => $headless, + ], + ], + ]); + + foreach ($expectedParameters as $name => $value) { + self::assertTrue($this->container->hasParameter(self::SITE_ACCESS_PARAMETER_PREFIX . $name)); + $this->assertConfigResolverParameterValue($name, $value, self::SITE_ACCESS); + } + + foreach ($expectedMissingParameters as $name) { + self::assertFalse($this->container->hasParameter(self::SITE_ACCESS_PARAMETER_PREFIX . $name)); + } + } + + /** + * @return iterable, array, string[]}> + */ + public static function dataProviderForTestSettings(): iterable + { + yield 'full configuration' => [ + [ + 'enabled' => true, + 'page_builder' => ['preview_url' => self::PAGE_BUILDER_PREVIEW_URL], + 'content' => ['preview_url' => self::CONTENT_PREVIEW_URL], + 'configuration_url' => self::CONFIGURATION_URL, + ], + [ + 'headless.enabled' => true, + 'headless.page_builder.preview_url' => self::PAGE_BUILDER_PREVIEW_URL, + 'headless.content.preview_url' => self::CONTENT_PREVIEW_URL, + 'headless.configuration_url' => self::CONFIGURATION_URL, + ], + [], + ]; + + yield 'only the flag' => [ + ['enabled' => false], + ['headless.enabled' => false], + [ + 'headless.page_builder.preview_url', + 'headless.content.preview_url', + 'headless.configuration_url', + ], + ]; + + yield 'explicit nulls are mapped as nulls' => [ + [ + 'page_builder' => ['preview_url' => null], + 'configuration_url' => null, + ], + [ + 'headless.page_builder.preview_url' => null, + 'headless.configuration_url' => null, + ], + [ + 'headless.enabled', + 'headless.content.preview_url', + ], + ]; + + yield 'empty nested node maps nothing' => [ + ['page_builder' => []], + [], + [ + 'headless.enabled', + 'headless.page_builder.preview_url', + 'headless.content.preview_url', + 'headless.configuration_url', + ], + ]; + } + + public function testSiteAccessInheritsGroupSettings(): void + { + $this->load([ + 'system' => [ + self::GROUP => [ + 'headless' => [ + 'enabled' => true, + 'page_builder' => ['preview_url' => self::PAGE_BUILDER_PREVIEW_URL], + 'content' => ['preview_url' => self::CONTENT_PREVIEW_URL], + 'configuration_url' => self::CONFIGURATION_URL, + ], + ], + // A siteaccess block without the "headless" node must not shadow the group values + self::SITE_ACCESS => [], + ], + ]); + + self::assertFalse($this->container->hasParameter(self::SITE_ACCESS_PARAMETER_PREFIX . 'headless.enabled')); + $this->assertConfigResolverParameterValue('headless.enabled', true, self::SITE_ACCESS); + $this->assertConfigResolverParameterValue( + 'headless.page_builder.preview_url', + self::PAGE_BUILDER_PREVIEW_URL, + self::SITE_ACCESS + ); + $this->assertConfigResolverParameterValue( + 'headless.content.preview_url', + self::CONTENT_PREVIEW_URL, + self::SITE_ACCESS + ); + $this->assertConfigResolverParameterValue( + 'headless.configuration_url', + self::CONFIGURATION_URL, + self::SITE_ACCESS + ); + } + + public function testEmptyHeadlessNodeOnSiteAccessInheritsGroupSettings(): void + { + $this->load([ + 'system' => [ + self::GROUP => [ + 'headless' => ['enabled' => true], + ], + // "headless: ~" is normalized by Symfony to an empty node and must map nothing + self::SITE_ACCESS => [ + 'headless' => null, + ], + ], + ]); + + self::assertFalse($this->container->hasParameter(self::SITE_ACCESS_PARAMETER_PREFIX . 'headless.enabled')); + $this->assertConfigResolverParameterValue('headless.enabled', true, self::SITE_ACCESS); + } + + public function testSiteAccessOverridesOnlyConfiguredKeys(): void + { + $this->load([ + 'system' => [ + self::GROUP => [ + 'headless' => [ + 'enabled' => true, + 'page_builder' => ['preview_url' => self::PAGE_BUILDER_PREVIEW_URL], + 'configuration_url' => self::CONFIGURATION_URL, + ], + ], + self::SITE_ACCESS => [ + 'headless' => [ + 'enabled' => false, + 'configuration_url' => null, + ], + ], + ], + ]); + + $this->assertConfigResolverParameterValue('headless.enabled', false, self::SITE_ACCESS); + $this->assertConfigResolverParameterValue('headless.configuration_url', null, self::SITE_ACCESS); + $this->assertConfigResolverParameterValue( + 'headless.page_builder.preview_url', + self::PAGE_BUILDER_PREVIEW_URL, + self::SITE_ACCESS + ); + } +} diff --git a/tests/lib/Headless/HeadlessConfigurationTest.php b/tests/lib/Headless/HeadlessConfigurationTest.php new file mode 100644 index 000000000..6681ca304 --- /dev/null +++ b/tests/lib/Headless/HeadlessConfigurationTest.php @@ -0,0 +1,87 @@ + + */ + public static function dataProviderForTestIsEnabled(): iterable + { + yield 'true' => [true, true]; + yield 'false' => [false, false]; + yield 'null' => [null, false]; + } + + #[DataProvider('dataProviderForTestIsEnabled')] + public function testIsEnabled(mixed $parameter, bool $expected): void + { + $configuration = new HeadlessConfiguration( + $this->createConfigResolver(['headless.enabled' => $parameter], 'site') + ); + + self::assertSame($expected, $configuration->isEnabled('site')); + } + + /** + * @return iterable + */ + public static function dataProviderForTestUrlGetters(): iterable + { + yield 'url' => ['https://frontend.example.com/', 'https://frontend.example.com/']; + yield 'empty string' => ['', null]; + yield 'null' => [null, null]; + yield 'not a string' => [false, null]; + } + + #[DataProvider('dataProviderForTestUrlGetters')] + public function testUrlGetters(mixed $parameter, ?string $expected): void + { + $configuration = new HeadlessConfiguration( + $this->createConfigResolver([ + 'headless.page_builder.preview_url' => $parameter, + 'headless.content.preview_url' => $parameter, + 'headless.configuration_url' => $parameter, + ]) + ); + + self::assertSame($expected, $configuration->getPageBuilderPreviewUrl()); + self::assertSame($expected, $configuration->getContentPreviewUrl()); + self::assertSame($expected, $configuration->getConfigurationUrl()); + } + + /** + * @param array $parameters + */ + private function createConfigResolver(array $parameters, ?string $expectedScope = null): ConfigResolverInterface + { + $configResolver = $this->createMock(ConfigResolverInterface::class); + $configResolver + ->method('getParameter') + ->willReturnCallback( + static function (string $name, ?string $namespace, ?string $scope) use ($parameters, $expectedScope): mixed { + self::assertNull($namespace); + self::assertSame($expectedScope, $scope); + self::assertArrayHasKey($name, $parameters); + + return $parameters[$name]; + } + ); + + return $configResolver; + } +}