Skip to content
Merged
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
10 changes: 4 additions & 6 deletions src/ConfigProvider.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,8 +21,7 @@
* allow: array<string, list<string>>
* }
* @type DefaultConfig = array{
* admin_route_segment: string,
* admin_route_name_prefix: string
* admin_name: string
* }
* @type Dependencies = array{
* factories: array<class-string, class-string>
Expand Down Expand Up @@ -66,11 +65,11 @@ public function getAclConfig(): array
'Administrator' => ['User'],
],
'resources' => [
Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE . 'dashboard.read',
Container\Configuration::ADMIN_NAME . '.dashboard.read',
],
'allow' => [
'Administrator' => [
Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE . 'dashboard.read',
Container\Configuration::ADMIN_NAME . '.dashboard.read',
],
],
];
Expand All @@ -80,8 +79,7 @@ public function getAclConfig(): array
public function getDefaultConfig(): array
{
return [
Container\Configuration::ADMIN_ROUTE_SEGMENT_KEY => Container\Configuration::ADMIN_ROUTE_SEGMENT_VALUE,
Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_KEY => Container\Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE,
Container\Configuration::ADMIN_NAME_KEY => Container\Configuration::ADMIN_NAME,
];
}

Expand Down
64 changes: 64 additions & 0 deletions src/Container/Configuration.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,74 @@

namespace Webware\Admin\Container;

use Psr\Container\ContainerExceptionInterface;
use Psr\Container\ContainerInterface;
use Webware\Admin\AdminInterface;
use Webware\Core\Configuration as Config;

use function is_string;

/**
* Route naming for the admin namespace.
*
* This component owns the admin base every other module nests under. It is resolved here
* once — ADMIN_NAME by default, overridable through ADMIN_NAME_KEY — and consulted by any
* component that publishes admin routes, so the fleet cannot disagree about the base.
*
* This component's own routes are that namespace itself: segment `admin`, name prefix
* `admin.`.
*/
final readonly class Configuration extends Config
{
public const string CONFIG_KEY = AdminInterface::class;

/** This component's own name. */
public const string COMPONENT_NAME = 'admin';

/** Default admin namespace base. */
public const string ADMIN_NAME = 'admin';

/** Config key holding an override for ADMIN_NAME. */
public const string ADMIN_NAME_KEY = 'admin_name';

/**
* The admin namespace base, e.g. `admin`. Consult this — never a local constant —
* when composing an admin route name or segment, so an application that relocates
* the namespace relocates every module's admin routes with it.
*
* @throws ContainerExceptionInterface
*/
public static function getAdminName(ContainerInterface $container, string $callingFactory): string
{
$config = self::getConfig($container, $callingFactory);

/** @var mixed $value */
$value = $config[self::ADMIN_NAME_KEY] ?? null;

Check notice on line 49 in src/Container/Configuration.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.4)

redundant-null-coalesce

Redundant null coalesce: left-hand side is always `null`. >This is always `null` >This right-hand side will always be evaluated The right-hand side of `??` will always be evaluated. Help: Consider directly using the right-hand side expression.

Check warning on line 49 in src/Container/Configuration.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.4)

impossible-nonnull-entry-check

Impossible `isset` check on key `'admin_name'` accessed on `array{'admin_route_name_prefix': string, 'admin_route_segment': string, 'route_name_prefix': string, 'route_segment': string}`. >`isset` on key `'admin_name'` will always be false here. The analysis determined that the key `'admin_name'` definitely does not exist in this array, so checking `isset` is unnecessary. Help: Remove the redundant `isset` check.

Check notice on line 49 in src/Container/Configuration.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.5)

redundant-null-coalesce

Redundant null coalesce: left-hand side is always `null`. >This is always `null` >This right-hand side will always be evaluated The right-hand side of `??` will always be evaluated. Help: Consider directly using the right-hand side expression.

Check warning on line 49 in src/Container/Configuration.php

View workflow job for this annotation

GitHub Actions / Mago (PHP 8.5)

impossible-nonnull-entry-check

Impossible `isset` check on key `'admin_name'` accessed on `array{'admin_route_name_prefix': string, 'admin_route_segment': string, 'route_name_prefix': string, 'route_segment': string}`. >`isset` on key `'admin_name'` will always be false here. The analysis determined that the key `'admin_name'` definitely does not exist in this array, so checking `isset` is unnecessary. Help: Remove the redundant `isset` check.

if (! is_string($value) || '' === $value) {
return self::ADMIN_NAME;
}

return $value;
}

/**
* Prefix shared by this component's own route names, e.g. `admin.`.
*
* @throws ContainerExceptionInterface
*/
public static function getAdminNamePrefix(ContainerInterface $container, string $callingFactory): string
{
return self::getAdminName($container, $callingFactory) . '.';
}

/**
* URI segment this component's own routes are mounted on, e.g. `admin`.
*
* @throws ContainerExceptionInterface
*/
public static function getAdminSegment(ContainerInterface $container, string $callingFactory): string
{
return self::getAdminName($container, $callingFactory);
}
}
11 changes: 7 additions & 4 deletions src/Container/RouteProviderFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,23 @@
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Webware\Admin\RouteProvider;
use Webware\Core\Exception;

final readonly class RouteProviderFactory
{
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception\ExceptionInterface
*/
public function __invoke(ContainerInterface $container): RouteProvider
{
$adminRouteSegment = Configuration::getAdminRouteSegment($container, self::class);
$adminRouteNamePrefix = Configuration::getAdminRouteNamePrefix($container, self::class);
$adminRouteSegment = Configuration::getAdminSegment($container, self::class);
$adminRouteNamePrefix = Configuration::getAdminNamePrefix($container, self::class);

// The admin route segment is the base segment for all admin routes, e.g. 'admin'.
// The admin route name prefix is the base prefix for all admin route names, e.g. 'admin.'.
// This component's own routes are the admin namespace itself: segment 'admin',
// name prefix 'admin.'. Both resolve through getAdminName() so an application that
// relocates the namespace moves these routes with it.
return new RouteProvider(
$adminRouteSegment,
$adminRouteNamePrefix,
Expand Down
4 changes: 3 additions & 1 deletion src/View/Helper/AdminUrlFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -9,18 +9,20 @@
use Psr\Container\ContainerInterface;
use Psr\Container\NotFoundExceptionInterface;
use Webware\Admin\Container\Configuration;
use Webware\Core\Exception;

final readonly class AdminUrlFactory
{
/**
* @throws ContainerExceptionInterface
* @throws NotFoundExceptionInterface
* @throws Exception\ExceptionInterface
*/
public function __invoke(ContainerInterface $container): AdminUrl
{
return new AdminUrl(
urlHelper : $container->get(UrlHelper::class),
routeNamePrefix: Configuration::getAdminRouteNamePrefix($container, self::class),
routeNamePrefix: Configuration::getAdminNamePrefix($container, self::class),
);
}
}
11 changes: 4 additions & 7 deletions test/integration/TestAssets/ExpectedConfig.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ final class ExpectedConfig
{
public static function getDashboardResource(): string
{
return Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE . 'dashboard.read';
return Configuration::ADMIN_NAME . '.dashboard.read';
}

/**
Expand Down Expand Up @@ -74,8 +74,7 @@ public static function getExpectedAclConfig(): array
* allow: array<string, list<string>>
* },
* Webware\Admin\AdminInterface: array{
* admin_route_segment: string,
* admin_route_name_prefix: string
* admin_name: string
* }
* }
*/
Expand All @@ -93,15 +92,13 @@ public static function getExpectedConfig(): array

/**
* @return array{
* admin_route_segment: string,
* admin_route_name_prefix: string
* admin_name: string
* }
*/
public static function getExpectedDefaultConfig(): array
{
return [
Configuration::ADMIN_ROUTE_SEGMENT_KEY => Configuration::ADMIN_ROUTE_SEGMENT_VALUE,
Configuration::ADMIN_ROUTE_NAME_PREFIX_KEY => Configuration::ADMIN_ROUTE_NAME_PREFIX_VALUE,
Configuration::ADMIN_NAME_KEY => Configuration::ADMIN_NAME,
];
}

Expand Down
78 changes: 78 additions & 0 deletions test/unit/Container/ConfigurationTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
<?php

declare(strict_types=1);

namespace WebwareTest\Admin\Container;

use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Webware\Admin\AdminInterface;
use Webware\Admin\Container\Configuration;
use Webware\Core\Exception\ContainerException;

#[CoversClass(Configuration::class)]
final class ConfigurationTest extends TestCase
{
#[Test]
public function aConfiguredAdminNameRelocatesTheWholeNamespace(): void
{
$container = $this->container([Configuration::ADMIN_NAME_KEY => 'control-panel']);

self::assertSame('control-panel', Configuration::getAdminName($container, self::class));
self::assertSame('control-panel.', Configuration::getAdminNamePrefix($container, self::class));
self::assertSame('control-panel', Configuration::getAdminSegment($container, self::class));
}

#[Test]
public function anUnusableConfiguredValueFallsBackToTheDefault(): void
{
foreach ([null, '', 42, ['admin']] as $value) {
$container = $this->container([Configuration::ADMIN_NAME_KEY => $value]);

self::assertSame(
Configuration::ADMIN_NAME,
Configuration::getAdminName($container, self::class),
);
}
}

#[Test]
public function itThrowsWhenTheConfigServiceIsMissing(): void
{
$container = $this->createStub(ContainerInterface::class);
$container->method('has')->willReturn(false);

$this->expectException(ContainerException::class);

Configuration::getAdminName($container, self::class);
}

#[Test]
public function theAdminNameDefaultsToAdmin(): void
{
self::assertSame('admin', Configuration::getAdminName($this->container([]), self::class));
}

#[Test]
public function theDefaultNamespaceIsTheAdminPrefixAndSegment(): void
{
$container = $this->container([]);

self::assertSame('admin.', Configuration::getAdminNamePrefix($container, self::class));
self::assertSame('admin', Configuration::getAdminSegment($container, self::class));
}

/**
* @param array<string, mixed> $config
*/
private function container(array $config): ContainerInterface
{
$container = $this->createStub(ContainerInterface::class);
$container->method('has')->willReturn(true);
$container->method('get')->willReturn([AdminInterface::class => $config]);

return $container;
}
}
87 changes: 87 additions & 0 deletions test/unit/Container/RouteProviderFactoryTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
<?php

declare(strict_types=1);

namespace WebwareTest\Admin\Container;

use Mezzio\MiddlewareFactoryInterface;
use Mezzio\Router\Route;
use Mezzio\Router\RouteCollectorInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
use Psr\Container\ContainerInterface;
use Psr\Http\Server\MiddlewareInterface;
use Webware\Admin\AdminInterface;
use Webware\Admin\Container\Configuration;
use Webware\Admin\Container\RouteProviderFactory;

#[CoversClass(RouteProviderFactory::class)]
final class RouteProviderFactoryTest extends TestCase
{
#[Test]
public function itRegistersTheDashboardUnderTheDefaultNamespace(): void
{
self::assertSame(
['/admin', 'admin.dashboard.read'],
$this->registeredRoute($this->container([])),
);
}

#[Test]
public function theRegisteredRouteFollowsAConfiguredNamespace(): void
{
self::assertSame(
['/control-panel', 'control-panel.dashboard.read'],
$this->registeredRoute(
$this->container([Configuration::ADMIN_NAME_KEY => 'control-panel']),
),
);
}

/**
* @param array<string, mixed> $config
*/
private function container(array $config): ContainerInterface
{
$container = $this->createStub(ContainerInterface::class);
$container->method('has')->willReturn(true);
$container->method('get')->willReturn([AdminInterface::class => $config]);

return $container;
}

/**
* @return array{string, string|null} the path and route name the provider registered
*/
private function registeredRoute(ContainerInterface $container): array
{
/** @var array{string, string|null} $registered */
$registered = [];

$collector = $this->createMock(RouteCollectorInterface::class);
$collector->expects($this->once())
->method('get')
->willReturnCallback(
static function (
string $path,
MiddlewareInterface $middleware,
?string $name = null,
) use (&$registered): Route {
$registered = [$path, $name];

return new Route($path, $middleware, ['GET'], $name);
},
);

$middlewareFactory = $this->createStub(MiddlewareFactoryInterface::class);
$middlewareFactory->method('prepare')
->willReturn(
$this->createStub(MiddlewareInterface::class),
);

(new RouteProviderFactory())($container)->registerRoutes($collector, $middlewareFactory);

return $registered;
}
}
Loading
Loading