├── .gitignore ├── tests ├── unregister-route-config.neon ├── Tools │ ├── ReplacementPair.php │ ├── DateTimeAssertions.php │ ├── ResponseAssertions.php │ ├── FileLoader.php │ └── JsonValuesReplacer.php ├── Sample │ ├── HelloWorldRoute.php │ ├── CreateChannelRoute.php │ ├── ErrorThrowingRoute.php │ ├── NotAllowedHandler.php │ ├── NotFoundHandler.php │ ├── ApiErrorHandler.php │ ├── BeforeRouteMiddleware.php │ ├── BeforeRequestMiddleware.php │ ├── GroupMiddleware.php │ ├── OnlyApiGroupMiddleware.php │ ├── ListChannelsRoute.php │ ├── CreateChannelUserRoute.php │ ├── GoldenKeyAuthMiddleware.php │ └── InvokeCounterMiddleware.php ├── Response │ └── ResponseTest.php ├── MiddlewareInvocationCounter.php ├── typo-in-config.neon ├── Route │ ├── __fixtures__ │ │ ├── expected_routes.json │ │ ├── expected_routes_with_custom_components.json │ │ └── all_routes.json │ └── OnlyNecessaryRoutesProviderTest.php ├── SlimAppTester.php ├── no-prefix-config.neon ├── config.neon ├── Request │ └── RequestTest.php └── SlimApplicationFactoryTest.php ├── src ├── Request │ ├── RequestException.php │ ├── RequestFactory.php │ ├── DefaultRequestFactory.php │ ├── QueryParamMissingException.php │ ├── RequestFieldMissingException.php │ ├── RouteArgumentMissingException.php │ ├── RequestAttributeMissingException.php │ ├── RequestInterface.php │ └── Request.php ├── Response │ ├── ResponseFactory.php │ ├── DefaultResponseFactory.php │ ├── Response.php │ └── ResponseInterface.php ├── Route │ ├── Route.php │ ├── InvalidRouteDefinitionException.php │ ├── UrlPatternResolver.php │ ├── OnlyNecessaryRoutesProvider.php │ ├── RouteDefinitionFactory.php │ ├── RouteDefinition.php │ └── RouteRegister.php ├── Middleware │ ├── Middleware.php │ ├── BeforeRouteMiddlewares.php │ ├── MiddlewareGroups.php │ └── MiddlewareFactory.php ├── ErrorHandler.php ├── SlimSettings.php ├── DI │ ├── ServiceProvider.php │ └── SlimApiExtension.php ├── SlimApp.php ├── SlimContainerFactory.php └── SlimApplicationFactory.php ├── phpstan.neon ├── rector.php ├── .github └── workflows │ ├── blackduck.yaml │ └── main.yml ├── LICENSE ├── phpunit.xml ├── ecs.php ├── composer.json └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | composer.lock 2 | tests/temp/ 3 | vendor 4 | .phpunit.result.cache 5 | 6 | # Temp directories 7 | temp/ 8 | /tests/temp/ 9 | -------------------------------------------------------------------------------- /tests/unregister-route-config.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - config.neon 3 | 4 | slimApi: 5 | routes: 6 | "api": 7 | "/channels": 8 | post!: null 9 | -------------------------------------------------------------------------------- /src/Request/RequestException.php: -------------------------------------------------------------------------------- 1 | withJson(['Hello World']); 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /tests/Sample/CreateChannelRoute.php: -------------------------------------------------------------------------------- 1 | withJson(['status' => 'created'], 201); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/SlimSettings.php: -------------------------------------------------------------------------------- 1 | 'bar']; 17 | $response = new Response(); 18 | $response = $response->withJson($parsedBody); 19 | 20 | Assert::assertSame($parsedBody, $response->getParsedBodyAsArray()); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /tests/MiddlewareInvocationCounter.php: -------------------------------------------------------------------------------- 1 | withHeader($headerName, 'invoked-' . self::$counter++); 18 | } 19 | 20 | 21 | public static function reset(): void 22 | { 23 | self::$counter = 0; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /tests/Sample/NotAllowedHandler.php: -------------------------------------------------------------------------------- 1 | withJson(['error' => 'Sample NotAllowedHandler here!'], 405); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /tests/typo-in-config.neon: -------------------------------------------------------------------------------- 1 | extensions: 2 | slimApi: \BrandEmbassy\Slim\DI\SlimApiExtension 3 | 4 | 5 | services: 6 | - BrandEmbassyTest\Slim\Sample\BeforeRouteMiddleware 7 | - BrandEmbassyTest\Slim\Sample\HelloWorldRoute 8 | 9 | slimApi: 10 | apiPrefix: '/tests' 11 | slimConfiguration: 12 | settings: 13 | removeDefaultHandlers: true 14 | 15 | routes: 16 | "app": 17 | "/hello-world": 18 | get: 19 | service: BrandEmbassyTest\Slim\Sample\HelloWorldRoute 20 | middleware: 21 | - BrandEmbassyTest\Slim\Sample\BeforeRouteMiddleware 22 | -------------------------------------------------------------------------------- /phpstan.neon: -------------------------------------------------------------------------------- 1 | includes: 2 | - vendor/brandembassy/coding-standard/default-phpstan.neon 3 | - vendor/spaze/phpstan-disallowed-calls/extension.neon 4 | - vendor/marc-mabe/php-enum-phpstan/extension.neon 5 | - phar://vendor/phpstan/phpstan/phpstan.phar/conf/bleedingEdge.neon 6 | 7 | parameters: 8 | level: 8 9 | paths: 10 | - src 11 | - tests 12 | 13 | tmpDir: temp/phpstan 14 | 15 | ignoreErrors: 16 | - 17 | message: '#Cognitive complexity for "BrandEmbassy\\Slim\\Route\\OnlyNecessaryRoutesProvider::getRoutes\(\)" is 14, keep it under 9#' 18 | path: src/Route/OnlyNecessaryRoutesProvider.php 19 | 20 | -------------------------------------------------------------------------------- /tests/Sample/NotFoundHandler.php: -------------------------------------------------------------------------------- 1 | withJson(['error' => 'Sample NotFoundHandler here!'], 404); 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /rector.php: -------------------------------------------------------------------------------- 1 | withPHPStanConfigs([__DIR__ . '/phpstan.neon']) 14 | ->withCache(sys_get_temp_dir() . '/slim-nette-extension-rector') 15 | ->withPaths([ 16 | __DIR__ . '/src', 17 | __DIR__ . '/tests', 18 | ]) 19 | ->withSkip($defaultSkipList); 20 | 21 | return $rectorConfigBuilder; -------------------------------------------------------------------------------- /src/DI/ServiceProvider.php: -------------------------------------------------------------------------------- 1 | getByName($serviceIdentifier); 19 | } catch (MissingServiceException $exception) { 20 | assert(class_exists($serviceIdentifier)); 21 | 22 | return $container->getByType($serviceIdentifier); 23 | } 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/Response/Response.php: -------------------------------------------------------------------------------- 1 | getBody(), Json::FORCE_ARRAY); 24 | assert(is_array($parsedBody)); 25 | 26 | return $parsedBody; 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /tests/Sample/ApiErrorHandler.php: -------------------------------------------------------------------------------- 1 | getMessage() 22 | : 'Unknown error.'; 23 | 24 | return $response->withJson(['error' => $error], 500); 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /tests/Sample/BeforeRouteMiddleware.php: -------------------------------------------------------------------------------- 1 | middlewares = $middlewareFactory->createFromIdentifiers($beforeRouteMiddlewares); 22 | } 23 | 24 | 25 | /** 26 | * @return callable[] 27 | */ 28 | public function getMiddlewares(): array 29 | { 30 | return $this->middlewares; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /tests/Sample/GroupMiddleware.php: -------------------------------------------------------------------------------- 1 | withJson( 17 | [ 18 | [ 19 | 'id' => 1, 20 | 'name' => 'First channel', 21 | ], 22 | [ 23 | 'id' => 2, 24 | 'name' => 'Second channel', 25 | ], 26 | ], 27 | 200 28 | ); 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /tests/Sample/CreateChannelUserRoute.php: -------------------------------------------------------------------------------- 1 | request = $request; 21 | 22 | return $response; 23 | } 24 | 25 | 26 | public function getRequest(): RequestInterface 27 | { 28 | $request = $this->request; 29 | assert($request instanceof RequestInterface); 30 | 31 | return $request; 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /tests/Sample/GoldenKeyAuthMiddleware.php: -------------------------------------------------------------------------------- 1 | getHeaderLine('X-Api-Key'); 20 | 21 | if ($token !== self::ACCESS_TOKEN) { 22 | return $response->withJson(['error' => 'YOU SHALL NOT PASS!'], 401); 23 | } 24 | 25 | return $next($request, $response); 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/blackduck.yaml: -------------------------------------------------------------------------------- 1 | name: blackduck 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | schedule: 8 | # Execute at 00:00 on 1st day of every month 9 | - cron: '0 0 1 * *' 10 | 11 | jobs: 12 | # A job to execute a blackduck scan 13 | blackduck: 14 | runs-on: ubuntu-latest 15 | steps: 16 | - uses: actions/checkout@v2 17 | 18 | - name: Run Synopsys Detect 19 | uses: synopsys-sig/detect-action@v0.3.0 20 | env: 21 | DETECT_TOOLS: DETECTOR 22 | DETECT_PROJECT_NAME: ${{ github.repository }} 23 | NODE_EXTRA_CA_CERTS: ${{ secrets.LOCAL_CA_CERT_PATH }} 24 | with: 25 | github-token: ${{ secrets.GITHUB_TOKEN }} 26 | detect-version: 7.9.0 27 | blackduck-url: https://nice2.app.blackduck.com/ 28 | blackduck-api-token: ${{ secrets.BLACKDUCK_API_TOKEN }} 29 | scan-mode: INTELLIGENT 30 | -------------------------------------------------------------------------------- /src/SlimApp.php: -------------------------------------------------------------------------------- 1 | getHeader('Content-Type'); 24 | $contentType = reset($contentTypes); 25 | 26 | if ($contentType === 'text/html; charset=UTF-8' && $response->getBody()->getSize() === 0) { 27 | $response = $response->withHeader('Content-Type', 'text/plain; charset=UTF-8'); 28 | } 29 | 30 | if (!$silent) { 31 | $this->respond($response); 32 | } 33 | 34 | return $response; 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /tests/Sample/InvokeCounterMiddleware.php: -------------------------------------------------------------------------------- 1 | ident = $ident; 23 | } 24 | 25 | 26 | public function __invoke(RequestInterface $request, ResponseInterface $response, callable $next): ResponseInterface 27 | { 28 | $newResponse = MiddlewareInvocationCounter::invoke(self::getName($this->ident), $response); 29 | 30 | return $next($request, $newResponse); 31 | } 32 | 33 | 34 | public static function getName(string $ident): string 35 | { 36 | return self::HEADER_NAME_PREFIX . $ident; 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /tests/Tools/DateTimeAssertions.php: -------------------------------------------------------------------------------- 1 | getTimestamp(), $dateTimeImmutable->getTimestamp()); 19 | } 20 | 21 | 22 | public static function assertDateTimeTimestampEqualsDateTime( 23 | int $expectedTimestamp, 24 | DateTimeImmutable $dateTime 25 | ): void { 26 | Assert::assertSame($expectedTimestamp, $dateTime->getTimestamp()); 27 | } 28 | 29 | 30 | public static function assertDateTimeAtomEqualsDateTime( 31 | string $expectedDateTimeInAtom, 32 | DateTimeImmutable $dateTime 33 | ): void { 34 | Assert::assertSame($expectedDateTimeInAtom, $dateTime->format(DateTime::ATOM)); 35 | } 36 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Brand Embassy 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /phpunit.xml: -------------------------------------------------------------------------------- 1 | 2 | 13 | 14 | 15 | tests 16 | temp 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | src 27 | 28 | 29 | src 30 | 31 | 32 | -------------------------------------------------------------------------------- /tests/Route/__fixtures__/expected_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "1.0": { 3 | "endpoints": { 4 | "get": { 5 | "service": "api.route.endpoints", 6 | "middlewares": [], 7 | "middlewareGroups": [], 8 | "ignoreVersionMiddlewareGroup": false, 9 | "name": null 10 | } 11 | }, 12 | "brand\/{brandId}\/channel\/{channelPlatformId}": { 13 | "get": { 14 | "service": "channelInfo.route", 15 | "middlewares": [ 16 | "channelInfo.cache.cachedChannelInfoMiddleware", 17 | "api.middleware.corsHeadersResolver" 18 | ], 19 | "middlewareGroups": [], 20 | "ignoreVersionMiddlewareGroup": false, 21 | "name": null 22 | }, 23 | "options": { 24 | "service": "api.route.preflightRequest", 25 | "middlewares": [ 26 | "api.middleware.corsHeadersResolver" 27 | ], 28 | "middlewareGroups": [], 29 | "ignoreVersionMiddlewareGroup": false, 30 | "name": null 31 | } 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Route/UrlPatternResolver.php: -------------------------------------------------------------------------------- 1 | apiPrefix = $apiPrefix; 18 | } 19 | 20 | 21 | public function resolve(string $apiNamespace, string $routePattern): string 22 | { 23 | $routePath = $this->resolveRoutePath($apiNamespace, $routePattern); 24 | 25 | $pattern = $this->apiPrefix . $routePath; 26 | 27 | if ($pattern === '') { 28 | return '/'; 29 | } 30 | 31 | return $pattern; 32 | } 33 | 34 | 35 | public function resolveRoutePath(string $apiNamespace, string $routePattern): string 36 | { 37 | $apiNamespace = trim($apiNamespace, '/'); 38 | $routePattern = trim($routePattern, '/'); 39 | 40 | if ($apiNamespace !== '') { 41 | $apiNamespace = '/' . $apiNamespace; 42 | } 43 | 44 | if ($routePattern !== '') { 45 | $routePattern = '/' . $routePattern; 46 | } 47 | 48 | return $apiNamespace . $routePattern; 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /src/SlimContainerFactory.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 28 | $this->requestFactory = $requestFactory; 29 | $this->router = $router; 30 | } 31 | 32 | 33 | /** 34 | * @param array $configuration 35 | */ 36 | public function create(array $configuration): Container 37 | { 38 | if (!isset($configuration['response'])) { 39 | $configuration['response'] = $this->responseFactory->create(); 40 | } 41 | 42 | if (!isset($configuration['request'])) { 43 | $configuration['request'] = $this->requestFactory->create(); 44 | } 45 | 46 | if (!isset($configuration['router'])) { 47 | $configuration['router'] = $this->router; 48 | } 49 | 50 | return new Container($configuration); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Middleware/MiddlewareGroups.php: -------------------------------------------------------------------------------- 1 | 15 | */ 16 | private array $groups; 17 | 18 | 19 | /** 20 | * @param array $middlewareGroups 21 | */ 22 | public function __construct(array $middlewareGroups, MiddlewareFactory $middlewareFactory) 23 | { 24 | $this->groups = array_map( 25 | static fn(array $middlewares): array => $middlewareFactory->createFromIdentifiers($middlewares), 26 | $middlewareGroups, 27 | ); 28 | } 29 | 30 | 31 | /** 32 | * @return callable[] 33 | */ 34 | public function getMiddlewares(string $groupName): array 35 | { 36 | return $this->groups[$groupName] ?? []; 37 | } 38 | 39 | 40 | /** 41 | * @param string[] $groupNames 42 | * 43 | * @return callable[] 44 | */ 45 | public function getMiddlewaresForMultipleGroups(array $groupNames): array 46 | { 47 | if ($groupNames === []) { 48 | return []; 49 | } 50 | 51 | $groupsToMerge = array_map( 52 | fn(string $groupName): array => $this->getMiddlewares($groupName), 53 | $groupNames, 54 | ); 55 | 56 | return array_merge_recursive(...$groupsToMerge); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /tests/SlimAppTester.php: -------------------------------------------------------------------------------- 1 | getByType(SlimApplicationFactory::class); 22 | 23 | return $factory->create(); 24 | } 25 | 26 | 27 | public static function runSlimApp(string $configPath = __DIR__ . '/config.neon'): ResponseInterface 28 | { 29 | $slimApp = self::createSlimApp($configPath); 30 | 31 | return $slimApp->run(true); 32 | } 33 | 34 | 35 | public static function createContainer(string $configPath = __DIR__ . '/config.neon'): Container 36 | { 37 | $loader = new ContainerLoader(__DIR__ . '/../temp', true); 38 | $class = $loader->load( 39 | static function (Compiler $compiler) use ($configPath): void { 40 | $compiler->loadConfig($configPath); 41 | $compiler->addExtension('extensions', new ExtensionsExtension()); 42 | }, 43 | md5($configPath) 44 | ); 45 | 46 | /** @var Container $returnClass */ 47 | $returnClass = new $class(); 48 | 49 | return $returnClass; 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /tests/Route/__fixtures__/expected_routes_with_custom_components.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom-components": { 3 | "endpoints": { 4 | "get": { 5 | "service": "api.route.endpoints", 6 | "middlewares": [], 7 | "middlewareGroups": [], 8 | "ignoreVersionMiddlewareGroup": false, 9 | "name": null 10 | } 11 | } 12 | }, 13 | "1.0": { 14 | "endpoints": { 15 | "get": { 16 | "service": "api.route.endpoints", 17 | "middlewares": [], 18 | "middlewareGroups": [], 19 | "ignoreVersionMiddlewareGroup": false, 20 | "name": null 21 | } 22 | }, 23 | "brand\/{brandId}\/channel\/{channelPlatformId}": { 24 | "get": { 25 | "service": "channelInfo.route", 26 | "middlewares": [ 27 | "channelInfo.cache.cachedChannelInfoMiddleware", 28 | "api.middleware.corsHeadersResolver" 29 | ], 30 | "middlewareGroups": [], 31 | "ignoreVersionMiddlewareGroup": false, 32 | "name": null 33 | }, 34 | "options": { 35 | "service": "api.route.preflightRequest", 36 | "middlewares": [ 37 | "api.middleware.corsHeadersResolver" 38 | ], 39 | "middlewareGroups": [], 40 | "ignoreVersionMiddlewareGroup": false, 41 | "name": null 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/Middleware/MiddlewareFactory.php: -------------------------------------------------------------------------------- 1 | container = $container; 24 | } 25 | 26 | 27 | public function createFromIdentifier(string $middlewareIdentifier): callable 28 | { 29 | $container = $this->container; 30 | 31 | return function ( 32 | RequestInterface $request, 33 | ResponseInterface $response, 34 | callable $next 35 | ) use ( 36 | $middlewareIdentifier, 37 | $container 38 | ): ResponseInterface { 39 | $middleware = ServiceProvider::getService($container, $middlewareIdentifier); 40 | assert(is_callable($middleware)); 41 | 42 | return $middleware($request, $response, $next); 43 | }; 44 | } 45 | 46 | 47 | /** 48 | * @param string[] $middlewareIdentifiers 49 | * 50 | * @return callable[] 51 | */ 52 | public function createFromIdentifiers(array $middlewareIdentifiers): array 53 | { 54 | return array_map( 55 | fn(string $middlewareIdentifier): callable => $this->createFromIdentifier($middlewareIdentifier), 56 | $middlewareIdentifiers, 57 | ); 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /tests/Tools/ResponseAssertions.php: -------------------------------------------------------------------------------- 1 | $expectedHeaders 34 | */ 35 | public static function assertResponseHeaders(array $expectedHeaders, ResponseInterface $response): void 36 | { 37 | foreach ($expectedHeaders as $headerName => $headerValue) { 38 | self::assertResponseHeader($headerValue, $headerName, $response); 39 | } 40 | } 41 | 42 | 43 | public static function assertResponseHeader( 44 | string $expectedHeaderValue, 45 | string $headerName, 46 | ResponseInterface $response 47 | ): void { 48 | Assert::assertSame($expectedHeaderValue, $response->getHeaderLine($headerName)); 49 | } 50 | 51 | 52 | public static function assertResponseStatusCode(int $expectedStatusCode, ResponseInterface $response): void 53 | { 54 | Assert::assertSame($expectedStatusCode, $response->getStatusCode()); 55 | } 56 | 57 | 58 | public static function parseAsString(ResponseInterface $response): string 59 | { 60 | return (string)$response->getBody(); 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /src/Route/OnlyNecessaryRoutesProvider.php: -------------------------------------------------------------------------------- 1 | $apiRoutes) { 38 | $apiNameIsCurrentFromRequest = strpos($url, '/' . $apiName . '/') !== false; 39 | 40 | if (!$apiNameIsCurrentFromRequest && !in_array($apiName, $apiNamesAlwaysInclude, true)) { 41 | continue; 42 | } 43 | 44 | $cacheKey = sprintf('filtered_routes.%s', $apiName); 45 | if ($apiNameIsCurrentFromRequest && $useApcuCache && apcu_exists($cacheKey)) { 46 | return apcu_fetch($cacheKey); 47 | } 48 | 49 | foreach ($apiRoutes as $routeName => $routeDefinitions) { 50 | foreach ($routeDefinitions as $routeHttpMethod => $routeDefinition) { 51 | $filteredRoutes[$apiName][$routeName][$routeHttpMethod] = $routeDefinition; 52 | } 53 | } 54 | 55 | if ($apiNameIsCurrentFromRequest && $useApcuCache) { 56 | apcu_store($cacheKey, $filteredRoutes); 57 | } 58 | } 59 | 60 | return $filteredRoutes; 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /tests/Route/OnlyNecessaryRoutesProviderTest.php: -------------------------------------------------------------------------------- 1 | getRoutes($sampleRequestUri, $sampleRoutes, false); 25 | 26 | Assert::assertSame($expectedRoutes, $routes); 27 | } 28 | 29 | 30 | public function testGetRoutesReturnsFilteredRoutesWithAlwaysIncludeApp(): void 31 | { 32 | $sampleRequestUri = '/chat/1.0/brand/1000/channel/chat_f00189ec-6a4b-4c76-b504-03c7ebcadb9c?parameter=12345'; 33 | 34 | $sampleRoutes = FileLoader::loadArrayFromJsonFile(__DIR__ . '/__fixtures__/all_routes.json'); 35 | $expectedRoutes = FileLoader::loadArrayFromJsonFile( 36 | __DIR__ . '/__fixtures__/expected_routes_with_custom_components.json' 37 | ); 38 | 39 | $provider = new OnlyNecessaryRoutesProvider(); 40 | 41 | $routes = $provider->getRoutes($sampleRequestUri, $sampleRoutes, false, ['custom-components']); 42 | 43 | Assert::assertSame($expectedRoutes, $routes); 44 | } 45 | 46 | 47 | public function testGetRoutesReturnsAllRoutesWhenRequestUriIsNull(): void 48 | { 49 | $sampleRoutes = FileLoader::loadArrayFromJsonFile(__DIR__ . '/__fixtures__/all_routes.json'); 50 | 51 | $provider = new OnlyNecessaryRoutesProvider(); 52 | 53 | $routes = $provider->getRoutes(null, $sampleRoutes, false); 54 | 55 | Assert::assertSame($sampleRoutes, $routes); 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /src/Route/RouteDefinitionFactory.php: -------------------------------------------------------------------------------- 1 | container = $container; 27 | $this->middlewareFactory = $middlewareFactory; 28 | } 29 | 30 | 31 | /** 32 | * @param array $routeDefinitionData 33 | */ 34 | public function create(string $method, array $routeDefinitionData): RouteDefinition 35 | { 36 | $route = function ( 37 | RequestInterface $request, 38 | ResponseInterface $response 39 | ) use ( 40 | $routeDefinitionData 41 | ): ResponseInterface { 42 | $route = $this->getRoute($routeDefinitionData[RouteDefinition::SERVICE]); 43 | 44 | return $route($request, $response); 45 | }; 46 | 47 | $middlewares = $this->middlewareFactory->createFromIdentifiers( 48 | $routeDefinitionData[RouteDefinition::MIDDLEWARES], 49 | ); 50 | 51 | return new RouteDefinition( 52 | $method, 53 | $route, 54 | $middlewares, 55 | $routeDefinitionData[RouteDefinition::MIDDLEWARE_GROUPS], 56 | $routeDefinitionData[RouteDefinition::NAME], 57 | $routeDefinitionData[RouteDefinition::IGNORE_VERSION_MIDDLEWARE_GROUP], 58 | ); 59 | } 60 | 61 | 62 | private function getRoute(string $routeIdentifier): Route 63 | { 64 | $route = ServiceProvider::getService($this->container, $routeIdentifier); 65 | 66 | if ($route instanceof Route) { 67 | return $route; 68 | } 69 | 70 | throw new LogicException('Defined route service should implement ' . Route::class); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /tests/no-prefix-config.neon: -------------------------------------------------------------------------------- 1 | extensions: 2 | slimApi: BrandEmbassy\Slim\DI\SlimApiExtension 3 | 4 | 5 | services: 6 | - BrandEmbassyTest\Slim\Sample\HelloWorldRoute 7 | invokeCounterA: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('A') 8 | invokeCounterB: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('B') 9 | invokeCounterC: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('C') 10 | invokeCounterD: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('D') 11 | invokeCounterE: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('E') 12 | invokeCounterF: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('F') 13 | invokeCounterG: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('G') 14 | invokeCounterH: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('H') 15 | invokeCounterI: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('I') 16 | invokeCounterJ: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('J') 17 | invokeCounterK: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('K') 18 | invokeCounterL: BrandEmbassyTest\Slim\Sample\InvokeCounterMiddleware('L') 19 | 20 | slimApi: 21 | routes: 22 | '/': 23 | '/': 24 | get: 25 | service: BrandEmbassyTest\Slim\Sample\HelloWorldRoute 26 | 'api': 27 | '/test': 28 | post: 29 | service: BrandEmbassyTest\Slim\Sample\HelloWorldRoute 30 | middlewares: 31 | - invokeCounterL 32 | - invokeCounterK 33 | middlewareGroups: 34 | - testGroupB 35 | - testGroupA 36 | '/ignore-version-middlewares': 37 | post: 38 | service: BrandEmbassyTest\Slim\Sample\HelloWorldRoute 39 | ignoreVersionMiddlewareGroup: true 40 | middlewareGroups: 41 | - testGroupA 42 | 43 | middlewareGroups: 44 | testGroupA: 45 | - invokeCounterH 46 | - invokeCounterG 47 | testGroupB: 48 | - invokeCounterJ 49 | - invokeCounterI 50 | api: 51 | - invokeCounterF 52 | - invokeCounterE 53 | 54 | beforeRouteMiddlewares: 55 | - invokeCounterD 56 | - invokeCounterC 57 | 58 | beforeRequestMiddlewares: 59 | - invokeCounterB 60 | - invokeCounterA 61 | -------------------------------------------------------------------------------- /ecs.php: -------------------------------------------------------------------------------- 1 | paths([ 15 | __DIR__ . '/src', 16 | ]); 17 | 18 | $skipList = [ 19 | InlineCommentSniff::class => [__DIR__ . '/default-ecs.php'], 20 | CommentedOutCodeSniff::class => [__DIR__ . '/ecs.php', __DIR__ . '/default-ecs.php'], 21 | ArrayDeclarationSniff::class => [__DIR__ . '/ecs.php', __DIR__ . '/default-ecs.php'], 22 | UnusedFunctionParameterSniff::class . '.FoundInImplementedInterface' => [ 23 | __DIR__ . '/src/BrandEmbassyCodingStandard/PhpStan/Rules/Method/ImmutableWitherMethodRule.php', 24 | ], 25 | UnusedFunctionParameterSniff::class . '.FoundInImplementedInterfaceAfterLastUsed' => [ 26 | __DIR__ . '/src/BrandEmbassyCodingStandard/PhpStan/Rules/Method/ImmutableWitherMethodRule.php', 27 | ], 28 | 'SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint' => [ 29 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/Classes/ClassesWithoutSelfReferencingSniff.php', 30 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/Classes/FinalClassByAnnotationSniff.php', 31 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/Classes/TraitUsePositionSniff.php', 32 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/Commenting/CreateMockFunctionReturnTypeOrderSniff.php', 33 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/Commenting/FunctionCommentSniff.php', 34 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/NamingConvention/CamelCapsFunctionNameSniff.php', 35 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/WhiteSpace/BlankLineBeforeReturnSniff.php', 36 | __DIR__ . '/src/BrandEmbassyCodingStandard/Sniffs/WhiteSpace/BlankLineBeforeThrowSniff.php', 37 | ], 38 | __DIR__ . '/*/__fixtures__/*', 39 | __DIR__ . '/*/__fixtures/*', 40 | ]; 41 | 42 | $ecsConfig->skip(array_merge($defaultSkipList, $skipList)); 43 | }; 44 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "brandembassy/slim-nette-extension", 3 | "description": "", 4 | "license": "MIT", 5 | "autoload": { 6 | "psr-4": { 7 | "BrandEmbassy\\Slim\\": "src" 8 | } 9 | }, 10 | "autoload-dev": { 11 | "psr-4": { 12 | "BrandEmbassyTest\\Slim\\": "tests" 13 | } 14 | }, 15 | "require": { 16 | "php": ">=8.2", 17 | "ext-apcu": "*", 18 | "adbario/php-dot-notation": "^2.2 || ^3.0", 19 | "nette/di": "^3.0", 20 | "nette/neon": "^3.0", 21 | "nette/utils": ">=3.2", 22 | "psr/http-message": "^1.0", 23 | "slim/slim": "^3.12" 24 | }, 25 | "require-dev": { 26 | "brandembassy/coding-standard": "^14.3", 27 | "marc-mabe/php-enum-phpstan": "^3.0", 28 | "mockery/mockery": "^1.2", 29 | "phpunit/phpunit": "^11.5", 30 | "rector/rector": "^2.1", 31 | "roave/security-advisories": "dev-master", 32 | "spaze/phpstan-disallowed-calls": "^4.7" 33 | }, 34 | "scripts": { 35 | "check-cs": "vendor/bin/ecs check --ansi", 36 | "fix-cs": "vendor/bin/ecs check --fix --ansi", 37 | "phpstan": "php -dxdebug.mode=off vendor/bin/phpstan analyse --memory-limit=-1", 38 | "phpstan-generate-baseline": "php -dxdebug.mode=off vendor/bin/phpstan analyse --memory-limit=-1 --generate-baseline", 39 | "phpunit": "vendor/bin/phpunit -c phpunit.xml --no-coverage", 40 | "phpunit-cc": [ 41 | "php -dxdebug.mode=coverage vendor/bin/phpunit --testsuite rector --coverage-php=coverage/coverage-rector.cov --log-junit=test-report-rector.xml", 42 | "php -dxdebug.mode=coverage vendor/bin/phpunit --testsuite phpstan --coverage-php=coverage/coverage-phpstan.cov --log-junit=test-report-phpstan.xml", 43 | "php -dxdebug.mode=coverage vendor/bin/phpunit --testsuite sniffs --coverage-php=coverage/coverage-sniffs.cov --log-junit=test-report-sniffs.xml", 44 | "vendor/bin/junit-merger merge:files --output-file=test-report.xml test-report-rector.xml test-report-phpstan.xml test-report-sniffs.xml", 45 | "vendor/bin/phpcov merge --clover coverage.xml coverage" 46 | ], 47 | 48 | "check-rector": "vendor/bin/rector process --dry-run --ansi", 49 | "fix-rector": "vendor/bin/rector process --ansi" 50 | }, 51 | "minimum-stability": "dev", 52 | "prefer-stable": true, 53 | "config": { 54 | "sort-packages": true, 55 | "process-timeout": 600, 56 | "allow-plugins": { 57 | "dealerdirect/phpcodesniffer-composer-installer": true 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/Route/RouteDefinition.php: -------------------------------------------------------------------------------- 1 | method = $method; 63 | $this->route = $route; 64 | $this->middlewares = $middlewares; 65 | $this->middlewareGroups = $middlewareGroups; 66 | $this->name = $name; 67 | $this->ignoreVersionMiddlewareGroup = $ignoreVersionMiddlewareGroup; 68 | } 69 | 70 | 71 | public function getMethod(): string 72 | { 73 | return $this->method; 74 | } 75 | 76 | 77 | public function getRoute(): callable 78 | { 79 | return $this->route; 80 | } 81 | 82 | 83 | /** 84 | * @return callable[] 85 | */ 86 | public function getMiddlewares(): array 87 | { 88 | return $this->middlewares; 89 | } 90 | 91 | 92 | /** 93 | * @return string[] 94 | */ 95 | public function getMiddlewareGroups(): array 96 | { 97 | return $this->middlewareGroups; 98 | } 99 | 100 | 101 | public function getName(): ?string 102 | { 103 | return $this->name; 104 | } 105 | 106 | 107 | public function shouldIgnoreVersionMiddlewareGroup(): bool 108 | { 109 | return $this->ignoreVersionMiddlewareGroup; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /tests/Route/__fixtures__/all_routes.json: -------------------------------------------------------------------------------- 1 | { 2 | "custom-components": { 3 | "endpoints": { 4 | "get": { 5 | "service": "api.route.endpoints", 6 | "middlewares": [], 7 | "middlewareGroups": [], 8 | "ignoreVersionMiddlewareGroup": false, 9 | "name": null 10 | } 11 | } 12 | }, 13 | "1.0": { 14 | "endpoints": { 15 | "get": { 16 | "service": "api.route.endpoints", 17 | "middlewares": [], 18 | "middlewareGroups": [], 19 | "ignoreVersionMiddlewareGroup": false, 20 | "name": null 21 | } 22 | }, 23 | "brand\/{brandId}\/channel\/{channelPlatformId}": { 24 | "get": { 25 | "service": "channelInfo.route", 26 | "middlewares": [ 27 | "channelInfo.cache.cachedChannelInfoMiddleware", 28 | "api.middleware.corsHeadersResolver" 29 | ], 30 | "middlewareGroups": [], 31 | "ignoreVersionMiddlewareGroup": false, 32 | "name": null 33 | }, 34 | "options": { 35 | "service": "api.route.preflightRequest", 36 | "middlewares": [ 37 | "api.middleware.corsHeadersResolver" 38 | ], 39 | "middlewareGroups": [], 40 | "ignoreVersionMiddlewareGroup": false, 41 | "name": null 42 | } 43 | } 44 | }, 45 | "2.0": { 46 | "channel\/{channelPlatformId}\/outbound": { 47 | "post": { 48 | "service": "outboundFlow.v2.outboundMessageRoute", 49 | "middlewares": [ 50 | "thirdParty.message.afterOutboundMessageCreatedMiddleware", 51 | "outboundFlow.validation.outboundMessageRequestValidatorMiddleware", 52 | "outboundFlow.validation.outboundMessageRequestMessageContentFallbackValidator", 53 | "outboundFlow.outboundMessageRequestExceptionFallbackMiddleware", 54 | "api.middleware.platform.outbound.outboundMessageRequestResolver", 55 | "outboundFlow.v2.chat.threadIdOnExternalPlatformMiddleware", 56 | "outboundFlow.validation.recipientValidatorMiddleware", 57 | "api.middleware.platform.outbound.outboundChannelResolver", 58 | "api.middleware.platform.outbound.outboundBrandResolver", 59 | "outboundFlow.validation.outboundMessageValidatorMiddleware", 60 | "api.middleware.platform.outbound.outboundErrorResponseHandler", 61 | "authorization.api.apiAuthorizationMiddleware" 62 | ], 63 | "middlewareGroups": [], 64 | "ignoreVersionMiddlewareGroup": false, 65 | "name": null 66 | } 67 | } 68 | } 69 | } 70 | -------------------------------------------------------------------------------- /src/Request/RequestInterface.php: -------------------------------------------------------------------------------- 1 | 16 | */ 17 | public function getRouteArguments(): array; 18 | 19 | 20 | public function hasRouteArgument(string $argument): bool; 21 | 22 | 23 | public function getRouteArgument(string $argument): string; 24 | 25 | 26 | public function findRouteArgument(string $argument, ?string $default = null): ?string; 27 | 28 | 29 | /** 30 | * @return mixed[] 31 | */ 32 | public function getParsedBodyAsArray(): array; 33 | 34 | 35 | /** 36 | * @return mixed 37 | */ 38 | public function getField(string $name); 39 | 40 | 41 | /** 42 | * @param mixed $default 43 | * 44 | * @return mixed 45 | */ 46 | public function findField(string $fieldName, $default = null); 47 | 48 | 49 | public function hasField(string $fieldName): bool; 50 | 51 | 52 | /** 53 | * @return string|string[]|null 54 | */ 55 | public function findQueryParam(string $key, ?string $default = null); 56 | 57 | 58 | /** 59 | * @return string|string[] 60 | * 61 | * @throws QueryParamMissingException 62 | */ 63 | public function getQueryParamStrict(string $key); 64 | 65 | 66 | public function findQueryParamAsString(string $key, ?string $default = null): ?string; 67 | 68 | 69 | /** 70 | * @throws QueryParamMissingException 71 | */ 72 | public function getQueryParamAsString(string $key): string; 73 | 74 | 75 | public function hasAttribute(string $name): bool; 76 | 77 | 78 | /** 79 | * @param mixed $default 80 | * 81 | * @return mixed 82 | */ 83 | public function findAttribute(string $name, $default = null); 84 | 85 | 86 | /** 87 | * @return mixed 88 | */ 89 | public function getAttributeStrict(string $name); 90 | 91 | 92 | public function hasQueryParam(string $key): bool; 93 | 94 | 95 | public function getDateTimeQueryParam(string $key): DateTimeImmutable; 96 | 97 | 98 | public function isHtml(): bool; 99 | 100 | 101 | /** 102 | * @deprecated use getAttributeStrict or findAttribute 103 | * 104 | * @phpcsSuppress SlevomatCodingStandard.TypeHints.ParameterTypeHint.MissingNativeTypeHint 105 | * 106 | * @param string $name 107 | * @param mixed $default 108 | * 109 | * @return mixed 110 | */ 111 | public function getAttribute($name, $default = null); 112 | 113 | 114 | /** 115 | * @deprecated use getQueryParamStrict or findQueryParam 116 | * 117 | * @param mixed|null $default 118 | * 119 | * @return mixed 120 | */ 121 | public function getQueryParam(string $key, $default = null); 122 | } 123 | -------------------------------------------------------------------------------- /tests/config.neon: -------------------------------------------------------------------------------- 1 | extensions: 2 | slimApi: \BrandEmbassy\Slim\DI\SlimApiExtension 3 | 4 | 5 | services: 6 | - BrandEmbassyTest\Slim\Sample\NotFoundHandler 7 | - BrandEmbassyTest\Slim\Sample\NotAllowedHandler 8 | - BrandEmbassyTest\Slim\Sample\ApiErrorHandler 9 | - BrandEmbassyTest\Slim\Sample\GoldenKeyAuthMiddleware 10 | - BrandEmbassyTest\Slim\Sample\CreateChannelRoute 11 | - BrandEmbassyTest\Slim\Sample\ListChannelsRoute 12 | - BrandEmbassyTest\Slim\Sample\ErrorThrowingRoute 13 | - BrandEmbassyTest\Slim\Sample\BeforeRequestMiddleware 14 | - BrandEmbassyTest\Slim\Sample\BeforeRouteMiddleware 15 | - BrandEmbassyTest\Slim\Sample\CreateChannelUserRoute 16 | - BrandEmbassyTest\Slim\Sample\GroupMiddleware 17 | - BrandEmbassyTest\Slim\Sample\OnlyApiGroupMiddleware 18 | helloWorldRoute: BrandEmbassyTest\Slim\Sample\HelloWorldRoute 19 | 20 | slimApi: 21 | apiPrefix: '/tests' 22 | slimConfiguration: 23 | settings: 24 | removeDefaultHandlers: true 25 | myCustomOption: 'Sample' 26 | 27 | handlers: 28 | notFoundHandler: BrandEmbassyTest\Slim\Sample\NotFoundHandler 29 | notAllowedHandler: BrandEmbassyTest\Slim\Sample\NotAllowedHandler 30 | errorHandler: BrandEmbassyTest\Slim\Sample\ApiErrorHandler 31 | 32 | routes: 33 | "app": 34 | "/hello-world-as-class-name": 35 | get: 36 | service: BrandEmbassyTest\Slim\Sample\HelloWorldRoute 37 | extraField: 'foo' 38 | middlewareGroups: 39 | - testGroup 40 | "/hello-world-as-service-name": 41 | get: 42 | service: helloWorldRoute 43 | middlewareGroups: 44 | - testGroup 45 | 46 | "api": 47 | '/channels': 48 | get: 49 | service: BrandEmbassyTest\Slim\Sample\ListChannelsRoute 50 | middlewareGroups: 51 | - testGroup 52 | post: 53 | service: BrandEmbassyTest\Slim\Sample\CreateChannelRoute 54 | middlewares: 55 | - BrandEmbassyTest\Slim\Sample\GoldenKeyAuthMiddleware 56 | middlewareGroups: 57 | - testGroup 58 | 59 | '/channels/{channelId}/users': 60 | post: 61 | name: getChannelUsers 62 | service: BrandEmbassyTest\Slim\Sample\CreateChannelUserRoute 63 | middlewareGroups: 64 | - testGroup 65 | 66 | '/error': 67 | post: 68 | service: BrandEmbassyTest\Slim\Sample\ErrorThrowingRoute 69 | middlewareGroups: 70 | - testGroup 71 | 72 | middlewareGroups: 73 | testGroup: 74 | - BrandEmbassyTest\Slim\Sample\GroupMiddleware 75 | api: 76 | - BrandEmbassyTest\Slim\Sample\OnlyApiGroupMiddleware 77 | 78 | beforeRouteMiddlewares: 79 | - BrandEmbassyTest\Slim\Sample\BeforeRouteMiddleware 80 | 81 | beforeRequestMiddlewares: 82 | - BrandEmbassyTest\Slim\Sample\BeforeRequestMiddleware 83 | -------------------------------------------------------------------------------- /tests/Tools/FileLoader.php: -------------------------------------------------------------------------------- 1 | $valuesToReplace 31 | * 32 | * @return mixed[] 33 | */ 34 | public static function loadArrayFromJsonFileAndReplace(string $jsonFilePath, array $valuesToReplace): array 35 | { 36 | $fileContents = self::loadJsonStringFromJsonFileAndReplace($jsonFilePath, $valuesToReplace); 37 | 38 | return self::decodeJsonAsArray($jsonFilePath, $fileContents); 39 | } 40 | 41 | 42 | /** 43 | * @param array $valuesToReplace 44 | */ 45 | public static function loadObjectFromJsonFileAndReplace(string $jsonFilePath, array $valuesToReplace): stdClass 46 | { 47 | $fileContents = self::loadJsonStringFromJsonFileAndReplace($jsonFilePath, $valuesToReplace); 48 | 49 | return self::decodeJsonAsObject($jsonFilePath, $fileContents); 50 | } 51 | 52 | 53 | /** 54 | * @param array $valuesToReplace 55 | */ 56 | public static function loadJsonStringFromJsonFileAndReplace(string $jsonFilePath, array $valuesToReplace): string 57 | { 58 | $fileContents = self::loadAsString($jsonFilePath); 59 | 60 | return JsonValuesReplacer::replace($valuesToReplace, $fileContents); 61 | } 62 | 63 | 64 | public static function loadAsString(string $filePath): string 65 | { 66 | try { 67 | return FileSystem::read($filePath); 68 | } catch (IOException $exception) { 69 | throw new LogicException('Cannot load file ' . $filePath . ': ' . $exception->getMessage(), $exception->getCode(), $exception); 70 | } 71 | } 72 | 73 | 74 | /** 75 | * @return mixed[]|stdClass 76 | */ 77 | private static function decodeJson(string $jsonFilePath, string $fileContents, bool $asArray): mixed 78 | { 79 | try { 80 | $decoded = Json::decode($fileContents, $asArray ? Json::FORCE_ARRAY : 0); 81 | if ($asArray) { 82 | assert(is_array($decoded)); 83 | } else { 84 | assert($decoded instanceof stdClass); 85 | } 86 | 87 | return $decoded; 88 | } catch (JsonException $exception) { 89 | throw new LogicException('File ' . $jsonFilePath . ' is not JSON: ' . $exception->getMessage(), $exception->getCode(), $exception); 90 | } 91 | } 92 | 93 | 94 | /** 95 | * @return mixed[] 96 | */ 97 | private static function decodeJsonAsArray(string $jsonFilePath, string $fileContents): array 98 | { 99 | $array = self::decodeJson($jsonFilePath, $fileContents, true); 100 | assert(is_array($array)); 101 | 102 | return $array; 103 | } 104 | 105 | 106 | private static function decodeJsonAsObject(string $jsonFilePath, string $fileContents): stdClass 107 | { 108 | $object = self::decodeJson($jsonFilePath, $fileContents, false); 109 | assert($object instanceof stdClass); 110 | 111 | return $object; 112 | } 113 | } 114 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![CircleCI](https://circleci.com/gh/BrandEmbassy/slim-nette-extension.svg?style=svg)](https://circleci.com/gh/BrandEmbassy/slim-nette-extension) 2 | [![Total Downloads](https://poser.pugx.org/BrandEmbassy/slim-nette-extension/downloads)](https://packagist.org/packages/brandembassy/slim-nette-extension) 3 | [![Latest Stable Version](https://poser.pugx.org/BrandEmbassy/slim-nette-extension/v/stable)](https://github.com/BrandEmbassy/slim-nette-extension/releases) 4 | 5 | # Nette Extension for integration of SLIM for API 6 | 7 | This extension brings the power of [Slim](https://www.slimframework.com/) for applications using [Nette DI](https://github.com/nette/di). It enables you to easily work with Slim middleware stack and develop your API easily. 8 | 9 | The general idea has been discussed in this [article](https://petrhejna.org/blog/api-chain-of-responsibility-approach). (Czech language) 10 | 11 | ## Philosophy 12 | 13 | ### Single Responsibility 14 | The main idea is to delegate responsibilities of the code handling requests to separated middlewares. For example: 15 | * authentication 16 | * validation 17 | * business logic 18 | 19 | How middlewares in Slim work is described [here](https://www.slimframework.com/docs/v3/concepts/middleware.html). 20 | 21 | ### Easy configuration 22 | Empowered by Nette DI and it's `neon` configuration syntax this package provides powerful and easy way to define your API. 23 | 24 | ## Usage 25 | So let's start! 26 | ``` 27 | composer require brandembassy/slim-nette-extension 28 | ``` 29 | 30 | ### Extension 31 | Now register new extension by adding this code into your `config.neon`: 32 | ```yaml 33 | extensions: 34 | slimApi: BrandEmbassy\Slim\DI\SlimApiExtension # Register extension 35 | 36 | slimApi: # Configure it 37 | slimConfiguration: 38 | settings: 39 | removeDefaultHandlers: true # It's recommended to disable original error handling 40 | # and use your own error handlers suited for needs of your app. 41 | 42 | apiDefinitionKey: api # Your API definition will be under this key in "parameters" section. 43 | ``` 44 | 45 | 46 | ### First API endpoint 47 | Now let's say you want to make a REST endpoint creating channels, `[POST] /new-api/2.0/channels` 48 | 49 | You need to define in `parameters.api` section in `config.neon`. 50 | 51 | > **Both services and middlewares must be registered services in DI Container.** 52 | 53 | ```yaml 54 | slimApi: 55 | handlers: 56 | notFound: App\NotFoundHandler # Called when not route isn't matched by URL 57 | notAllowed: App\NotAllowedHandler # Called when route isn't matched by method 58 | error: App\ApiErrorHandler # Called when unhandled exception bubbles out 59 | 60 | routes: 61 | "2.0": # Version of your API 62 | "channels": # Matched URL will be "your-domain.org/2.0/channels" 63 | post: 64 | # This is service will be invoked to handle the request 65 | service: App\CreateChannelAction 66 | 67 | # Here middleware stack is defined. It's evaluated from bottom to top. 68 | middlewares: 69 | - App\SomeOtherMiddleware # last in row 70 | - App\UsuallyRequestDataValidationMiddleware # second in row 71 | - App\SomeAuthMiddleware # this one is called first 72 | 73 | beforeRouteMiddlewares: 74 | # this is called for each route, before route middlewares 75 | - App\SomeBeforeRouteMiddleware 76 | 77 | beforeRequestMiddlewares: 78 | # this is called for each request, even when route does NOT exist (404 requests) 79 | - App\SomeBeforeRequestMiddleware 80 | ``` 81 | 82 | You can also reference the named service by its name. 83 | 84 | See `tests/SlimApplicationFactoryTest.php` and `tests/config.neon` for more examples. 85 | 86 | ### Execution 87 | Now you can simply get `SlimApplicationFactory` class from your DI Container (or better autowire it), create app and run it. 88 | 89 | ```php 90 | $factory = $container->getByType(SlimApplicationFactory::class); 91 | $factory->create()->run(); 92 | ``` 93 | -------------------------------------------------------------------------------- /tests/Tools/JsonValuesReplacer.php: -------------------------------------------------------------------------------- 1 | > $valuesToReplace 24 | */ 25 | public static function replace(array $valuesToReplace, string $jsonString): string 26 | { 27 | /** @var string[] $keys */ 28 | $keys = []; 29 | /** @var string[] $values */ 30 | $values = []; 31 | foreach ($valuesToReplace as $key => $value) { 32 | foreach (self::getReplacementPairs($key, $value) as $replacementPair) { 33 | $keys[] = $replacementPair->key; 34 | $values[] = $replacementPair->value; 35 | } 36 | } 37 | 38 | return str_replace($keys, $values, $jsonString); 39 | } 40 | 41 | 42 | /** 43 | * @return ReplacementPair[] 44 | */ 45 | private static function getReplacementPairs(string $key, mixed $value): array 46 | { 47 | $replacementPairs = []; 48 | if (is_array($value)) { 49 | $replacementPairs[] = new ReplacementPair( 50 | sprintf('"%s"', self::decorateReplacementKey($key)), 51 | Json::encode($value) 52 | ); 53 | 54 | return $replacementPairs; 55 | } 56 | 57 | $replacementPairs[] = new ReplacementPair( 58 | '%%' . $key . '%%', 59 | (string)$value 60 | ); 61 | 62 | if (is_int($value) || is_float($value)) { 63 | $replacementPairs[] = new ReplacementPair( 64 | self::decorateReplacementKey($key, 'string'), 65 | (string)$value 66 | ); 67 | $replacementPairs[] = new ReplacementPair( 68 | sprintf('"%s"', self::decorateReplacementKey($key)), 69 | (string)$value 70 | ); 71 | 72 | return $replacementPairs; 73 | } 74 | 75 | if (is_bool($value)) { 76 | $replacementPairs[] = new ReplacementPair( 77 | sprintf('"%s"', self::decorateReplacementKey($key)), 78 | $value ? 'true' : 'false' 79 | ); 80 | 81 | return $replacementPairs; 82 | } 83 | 84 | $replacementPairs[] = new ReplacementPair( 85 | sprintf('"%s"', self::decorateReplacementKey($key, 'int')), 86 | $value === null ? 'null' : (string)(int)$value 87 | ); 88 | 89 | $replacementPairs[] = new ReplacementPair( 90 | sprintf('"%s"', self::decorateReplacementKey($key, 'float')), 91 | $value === null ? 'null' : (string)(float)$value 92 | ); 93 | 94 | $replacementPairs[] = new ReplacementPair( 95 | sprintf('"%s"', self::decorateReplacementKey($key, 'bool')), 96 | (bool)$value ? 'true' : 'false' 97 | ); 98 | 99 | if ($value === null) { 100 | $replacementPairs[] = new ReplacementPair( 101 | sprintf('"%s"', self::decorateReplacementKey($key, 'string')), 102 | 'null' 103 | ); 104 | 105 | $replacementPairs[] = new ReplacementPair( 106 | sprintf('"%s"', self::decorateReplacementKey($key)), 107 | 'null' 108 | ); 109 | 110 | return $replacementPairs; 111 | } 112 | 113 | $replacementPairs[] = new ReplacementPair( 114 | self::decorateReplacementKey($key), 115 | (string)$value 116 | ); 117 | 118 | return $replacementPairs; 119 | } 120 | 121 | 122 | private static function decorateReplacementKey(string $key, ?string $datatype = null): string 123 | { 124 | if ($datatype !== null) { 125 | $key .= '|' . $datatype; 126 | } 127 | 128 | return '%' . $key . '%'; 129 | } 130 | } 131 | -------------------------------------------------------------------------------- /src/Route/RouteRegister.php: -------------------------------------------------------------------------------- 1 | router = $router; 36 | $this->routeDefinitionFactory = $routeDefinitionFactory; 37 | $this->urlPatternResolver = $urlPatternResolver; 38 | $this->beforeRouteMiddlewares = $beforeRouteMiddlewares; 39 | $this->middlewareGroups = $middlewareGroups; 40 | } 41 | 42 | 43 | /** 44 | * @param array $routeData 45 | */ 46 | public function register( 47 | string $apiNamespace, 48 | string $routePattern, 49 | array $routeData, 50 | bool $detectTyposInRouteConfiguration = true 51 | ): void { 52 | $urlPattern = $this->urlPatternResolver->resolve($apiNamespace, $routePattern); 53 | $resolveRoutePath = $this->urlPatternResolver->resolveRoutePath( 54 | $apiNamespace, 55 | $routePattern, 56 | ); 57 | 58 | foreach ($routeData as $method => $routeDefinitionData) { 59 | if ($routeDefinitionData === $this->getEmptyRouteDefinitionData()) { 60 | continue; 61 | } 62 | 63 | if ($detectTyposInRouteConfiguration) { 64 | $this->detectTyposInRouteConfiguration([$apiNamespace, $routePattern, $method], $routeDefinitionData); 65 | } 66 | 67 | $routeDefinition = $this->routeDefinitionFactory->create($method, $routeDefinitionData); 68 | 69 | $routeName = $routeDefinition->getName() ?? $resolveRoutePath; 70 | 71 | $routeToAdd = $this->router->map( 72 | [$routeDefinition->getMethod()], 73 | $urlPattern, 74 | $routeDefinition->getRoute(), 75 | ); 76 | $routeToAdd->setName($routeName); 77 | 78 | $middlewaresToAdd = $this->getAllMiddlewares($apiNamespace, $routeDefinition); 79 | 80 | foreach ($middlewaresToAdd as $middleware) { 81 | $routeToAdd->add($middleware); 82 | } 83 | } 84 | } 85 | 86 | 87 | /** 88 | * @return callable[] 89 | */ 90 | private function getAllMiddlewares(string $version, RouteDefinition $routeDefinition): array 91 | { 92 | $versionMiddlewares = $routeDefinition->shouldIgnoreVersionMiddlewareGroup() 93 | ? [] 94 | : $this->middlewareGroups->getMiddlewares($version); 95 | 96 | $middlewaresFromGroups = $this->middlewareGroups->getMiddlewaresForMultipleGroups( 97 | $routeDefinition->getMiddlewareGroups(), 98 | ); 99 | 100 | return array_merge_recursive( 101 | $routeDefinition->getMiddlewares(), 102 | $middlewaresFromGroups, 103 | $versionMiddlewares, 104 | $this->beforeRouteMiddlewares->getMiddlewares(), 105 | ); 106 | } 107 | 108 | 109 | /** 110 | * @return array 111 | */ 112 | private function getEmptyRouteDefinitionData(): array 113 | { 114 | return [ 115 | RouteDefinition::SERVICE => null, 116 | RouteDefinition::MIDDLEWARES => [], 117 | RouteDefinition::MIDDLEWARE_GROUPS => [], 118 | RouteDefinition::IGNORE_VERSION_MIDDLEWARE_GROUP => false, 119 | RouteDefinition::NAME => null, 120 | ]; 121 | } 122 | 123 | 124 | /** 125 | * @param string[] $path 126 | * @param mixed[] $routeDefinitionData 127 | */ 128 | private function detectTyposInRouteConfiguration(array $path, array $routeDefinitionData): void 129 | { 130 | $usedKeys = array_keys($routeDefinitionData); 131 | foreach ($usedKeys as $usedKey) { 132 | foreach (RouteDefinition::ALL_DEFINED_KEYS as $definedKey) { 133 | $levenshteinDistance = levenshtein($usedKey, $definedKey); 134 | if ($levenshteinDistance > 0 && $levenshteinDistance < 2) { 135 | $path[] = $usedKey; 136 | 137 | throw new InvalidRouteDefinitionException($path, $definedKey); 138 | } 139 | } 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/DI/SlimApiExtension.php: -------------------------------------------------------------------------------- 1 | $this->createServiceExpect(), 35 | RouteDefinition::MIDDLEWARES => Expect::arrayOf($this->createServiceExpect()) 36 | ->default([]), 37 | RouteDefinition::MIDDLEWARE_GROUPS => Expect::listOf('string')->default([]), 38 | RouteDefinition::IGNORE_VERSION_MIDDLEWARE_GROUP => Expect::bool(false), 39 | RouteDefinition::NAME => Expect::type('string')->default(null), 40 | ]; 41 | 42 | return Expect::structure( 43 | [ 44 | SlimApplicationFactory::ROUTES => Expect::arrayOf( 45 | Expect::arrayOf( 46 | Expect::arrayOf( 47 | Expect::structure($routeSchema) 48 | ->castTo('array') 49 | ->otherItems(), 50 | ), 51 | ), 52 | ), 53 | SlimApplicationFactory::HANDLERS => Expect::arrayOf($this->createServiceExpect())->default([]), 54 | SlimApplicationFactory::BEFORE_REQUEST_MIDDLEWARES => Expect::arrayOf($this->createServiceExpect()) 55 | ->default([]), 56 | SlimApplicationFactory::BEFORE_ROUTE_MIDDLEWARES => Expect::arrayOf($this->createServiceExpect()) 57 | ->default([]), 58 | SlimApplicationFactory::SLIM_CONFIGURATION => Expect::array()->default([]), 59 | SlimApplicationFactory::API_PREFIX => Expect::string()->default(''), 60 | SlimApplicationFactory::MIDDLEWARE_GROUPS => Expect::arrayOf( 61 | Expect::arrayOf($this->createServiceExpect()) 62 | ->default([]), 63 | ), 64 | ], 65 | ); 66 | } 67 | 68 | 69 | public function loadConfiguration(): void 70 | { 71 | $builder = $this->getContainerBuilder(); 72 | $config = (array)$this->config; 73 | 74 | $builder->addDefinition($this->prefix('urlPatterResolver')) 75 | ->setFactory(UrlPatternResolver::class, [$config[SlimApplicationFactory::API_PREFIX]]); 76 | 77 | $builder->addDefinition($this->prefix('beforeRouteMiddlewares')) 78 | ->setFactory(BeforeRouteMiddlewares::class, [$config[SlimApplicationFactory::BEFORE_ROUTE_MIDDLEWARES]]); 79 | 80 | $builder->addDefinition($this->prefix('middlewareGroups')) 81 | ->setFactory(MiddlewareGroups::class, [$config[SlimApplicationFactory::MIDDLEWARE_GROUPS]]); 82 | 83 | $builder->addDefinition($this->prefix('slimAppFactory')) 84 | ->setFactory(SlimApplicationFactory::class, [$config]); 85 | 86 | $builder->addDefinition($this->prefix('slimContainerFactory')) 87 | ->setFactory(SlimContainerFactory::class); 88 | 89 | $builder->addDefinition($this->prefix('slimContainer')) 90 | ->setType(Container::class) 91 | ->setFactory( 92 | [ 93 | new Reference(SlimContainerFactory::class), 94 | 'create', 95 | ], 96 | [$config[SlimApplicationFactory::SLIM_CONFIGURATION]], 97 | ); 98 | 99 | $builder->addDefinition($this->prefix('routeDefinitionFactory')) 100 | ->setFactory(RouteDefinitionFactory::class); 101 | 102 | $builder->addDefinition($this->prefix('routeRegister')) 103 | ->setFactory(RouteRegister::class); 104 | 105 | $builder->addDefinition($this->prefix('requestFactory')) 106 | ->setType(RequestFactory::class) 107 | ->setFactory(DefaultRequestFactory::class); 108 | 109 | $builder->addDefinition($this->prefix('responseFactory')) 110 | ->setType(ResponseFactory::class) 111 | ->setFactory(DefaultResponseFactory::class); 112 | 113 | $builder->addDefinition($this->prefix('middlewareFactory')) 114 | ->setFactory(MiddlewareFactory::class); 115 | 116 | $builder->addDefinition($this->prefix('slimRouter')) 117 | ->setFactory(Router::class); 118 | 119 | $builder->addDefinition($this->prefix('onlyNecessaryRoutesProvider')) 120 | ->setFactory(OnlyNecessaryRoutesProvider::class); 121 | } 122 | 123 | 124 | private function createServiceExpect(): Schema 125 | { 126 | return Expect::anyOf(Expect::string('string')); 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /tests/Request/RequestTest.php: -------------------------------------------------------------------------------- 1 | getDispatchedRequest(); 32 | 33 | Assert::assertTrue($request->hasField('thisIsNull')); 34 | Assert::assertFalse($request->hasField('nonExistingField')); 35 | Assert::assertTrue($request->hasField('thisIsGandalf')); 36 | } 37 | 38 | 39 | public function testShouldRaiseExceptionForMissingRequiredField(): void 40 | { 41 | $request = $this->getDispatchedRequest(); 42 | 43 | Assert::assertSame('gandalf', $request->getField('thisIsGandalf')); 44 | $this->expectException(RequestFieldMissingException::class); 45 | $this->expectExceptionMessage('Field "nonExistingField" is missing in request body'); 46 | $request->getField('nonExistingField'); 47 | } 48 | 49 | 50 | public function testGettingDateTimeQueryParam(): void 51 | { 52 | $request = $this->getDispatchedRequest('?' . self::PARAM_NAME . '=' . urlencode(self::DATE_TIME_STRING)); 53 | 54 | $dateTime = $request->getDateTimeQueryParam(self::PARAM_NAME); 55 | DateTimeAssertions::assertDateTimeAtomEqualsDateTime(self::DATE_TIME_STRING, $dateTime); 56 | } 57 | 58 | 59 | public function testQueryParamResolving(): void 60 | { 61 | $request = $this->getDispatchedRequest('?foo=bar&two=2&null=null&array[]=item1&array[]=item2'); 62 | 63 | Assert::assertTrue($request->hasQueryParam('null')); 64 | Assert::assertFalse($request->hasQueryParam('non-existing')); 65 | Assert::assertSame('bar', $request->getQueryParamStrict('foo')); 66 | Assert::assertSame('bar', $request->getQueryParamAsString('foo')); 67 | Assert::assertSame('bar', $request->findQueryParamAsString('foo')); 68 | Assert::assertSame('2', $request->getQueryParamStrict('two')); 69 | Assert::assertSame('null', $request->getQueryParamStrict('null')); 70 | Assert::assertSame(['item1', 'item2'], $request->getQueryParamStrict('array')); 71 | Assert::assertSame('default', $request->findQueryParam('non-existing', 'default')); 72 | Assert::assertNull($request->findQueryParam('non-existing')); 73 | Assert::assertNull($request->findQueryParamAsString('non-existing')); 74 | 75 | $this->expectException(QueryParamMissingException::class); 76 | $this->expectExceptionMessage('Query param "non-existing" is missing in request URI'); 77 | 78 | $request->getQueryParamStrict('non-existing'); 79 | } 80 | 81 | 82 | public function testGetRoute(): void 83 | { 84 | $request = $this->getDispatchedRequest('?foo=bar&two=2&null=null&array[]=item1&array[]=item2'); 85 | $response = new Response(); 86 | $response = $response->withHeader('hasBeenCalled', 'true'); 87 | 88 | /** @var Response $response */ 89 | $responseFromRoute = ($request->getRoute()->getCallable())($request, $response); 90 | 91 | Assert::assertSame(['true'], $responseFromRoute->getHeader('hasBeenCalled')); 92 | } 93 | 94 | 95 | public function testRestResolvingAttributes(): void 96 | { 97 | $request = $this->getDispatchedRequest(); 98 | 99 | Assert::assertSame('123', $request->getRouteArgument('channelId')); 100 | Assert::assertTrue($request->hasRouteArgument('channelId')); 101 | Assert::assertFalse($request->hasRouteArgument('non-existing')); 102 | Assert::assertSame(['channelId' => '123'], $request->getRouteArguments()); 103 | Assert::assertSame('123', $request->findRouteArgument('channelId')); 104 | Assert::assertSame('default', $request->findRouteArgument('non-existing', 'default')); 105 | } 106 | 107 | 108 | public function testResolvingFields(): void 109 | { 110 | $request = $this->getDispatchedRequest(); 111 | 112 | Assert::assertSame('value', $request->getField('level-1.level-2')); 113 | Assert::assertNull($request->getField('level-1.level-2-null')); 114 | } 115 | 116 | 117 | private function getDispatchedRequest(string $queryString = ''): RequestInterface 118 | { 119 | $this->prepareEnvironment($queryString); 120 | 121 | $container = SlimAppTester::createContainer(); 122 | $container->getByType(SlimApplicationFactory::class)->create()->run(); 123 | 124 | $updateChannelRoute = $container->getByType(CreateChannelUserRoute::class); 125 | 126 | return $updateChannelRoute->getRequest(); 127 | } 128 | 129 | 130 | private function prepareEnvironment(string $queryString): void 131 | { 132 | $_SERVER['HTTP_HOST'] = 'api.brandembassy.com'; 133 | $_SERVER['HTTP_CONTENT_TYPE'] = 'multipart/form-data'; 134 | $_SERVER['REQUEST_URI'] = '/tests/api/channels/' . self::CHANNEL_ID . '/users' . $queryString; 135 | $_SERVER['REQUEST_METHOD'] = 'POST'; 136 | 137 | $_POST = [ 138 | 'thisIsNull' => null, 139 | 'thisIsGandalf' => 'gandalf', 140 | 'level-1' => [ 141 | 'level-2' => 'value', 142 | 'level-2-null' => null, 143 | ], 144 | ]; 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /.github/workflows/main.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: [pull_request, workflow_dispatch] 4 | 5 | jobs: 6 | phpstan: 7 | runs-on: ubuntu-latest 8 | strategy: 9 | fail-fast: false 10 | matrix: 11 | php-version: 12 | - 8.2 13 | - 8.3 14 | - 8.4 15 | 16 | steps: 17 | - name: Checkout 18 | uses: actions/checkout@v4 19 | 20 | - name: Setup PHP 21 | uses: shivammathur/setup-php@v2 22 | with: 23 | php-version: ${{ matrix.php-version }} 24 | extensions: mbstring, sockets 25 | 26 | - name: Get composer cache directory 27 | id: composercache 28 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 29 | 30 | - name: Cache composer dependencies 31 | uses: actions/cache@v4 32 | with: 33 | path: ${{ steps.composercache.outputs.dir }} 34 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 35 | restore-keys: ${{ runner.os }}-composer- 36 | 37 | - name: Install dependencies 38 | run: composer install --prefer-dist 39 | 40 | - name: Run phpstan 41 | run: composer phpstan 42 | 43 | 44 | ecs: 45 | runs-on: ubuntu-latest 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | php-version: 50 | - 8.2 51 | - 8.3 52 | - 8.4 53 | 54 | steps: 55 | - name: Checkout 56 | uses: actions/checkout@v4 57 | 58 | - name: Setup PHP 59 | uses: shivammathur/setup-php@v2 60 | with: 61 | php-version: ${{ matrix.php-version }} 62 | extensions: mbstring, sockets 63 | 64 | - name: Get composer cache directory 65 | id: composercache 66 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 67 | 68 | - name: Cache composer dependencies 69 | uses: actions/cache@v4 70 | with: 71 | path: ${{ steps.composercache.outputs.dir }} 72 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 73 | restore-keys: ${{ runner.os }}-composer- 74 | 75 | - name: Install dependencies 76 | run: composer install --prefer-dist 77 | 78 | - name: Run code-sniffer 79 | run: composer check-cs 80 | 81 | 82 | phpunit: 83 | runs-on: ubuntu-latest 84 | strategy: 85 | fail-fast: false 86 | matrix: 87 | composer-arg: 88 | - "install" 89 | - "update --prefer-lowest" 90 | php-version: 91 | - 8.2 92 | - 8.3 93 | - 8.4 94 | 95 | steps: 96 | - name: Checkout 97 | uses: actions/checkout@v4 98 | 99 | - name: Setup PHP 100 | uses: shivammathur/setup-php@v2 101 | with: 102 | php-version: ${{ matrix.php-version }} 103 | extensions: mbstring, sockets 104 | 105 | - name: Get composer cache directory 106 | id: composercache 107 | run: echo "::set-output name=dir::$(composer config cache-files-dir)" 108 | 109 | - name: Cache composer dependencies 110 | uses: actions/cache@v4 111 | with: 112 | path: ${{ steps.composercache.outputs.dir }} 113 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 114 | restore-keys: ${{ runner.os }}-composer- 115 | 116 | - name: Install dependencies 117 | run: composer ${{ matrix.composer-arg }} --prefer-dist 118 | 119 | - name: Run tests 120 | run: composer phpunit 121 | 122 | rector: 123 | runs-on: ubuntu-latest 124 | strategy: 125 | fail-fast: false 126 | matrix: 127 | php-version: ['8.2', '8.3', '8.4' ] 128 | 129 | steps: 130 | - name: Checkout 131 | uses: actions/checkout@v4 132 | 133 | - name: Setup PHP 134 | uses: shivammathur/setup-php@v2 135 | with: 136 | ini-values: zend.assertions=1 137 | php-version: ${{ matrix.php-version }} 138 | extensions: mbstring, sockets, imap, gmp, memcached 139 | 140 | - name: Cache composer dependencies 141 | uses: actions/cache@v4 142 | with: 143 | path: vendor 144 | key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} 145 | restore-keys: ${{ runner.os }}-composer- 146 | 147 | - name: Install dependencies 148 | run: composer install --no-progress --prefer-dist --optimize-autoloader --ignore-platform-reqs 149 | 150 | - name: Restore rector cache 151 | id: cache-rector-restore 152 | uses: actions/cache/restore@v4 153 | with: 154 | path: ./temp/rector 155 | key: ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}-${{ github.ref }} 156 | restore-keys: | 157 | ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}- 158 | ${{ runner.os }}-rector- 159 | 160 | # Only --dry-run will fail when changes are detected 161 | - name: Rector 162 | run: vendor/bin/rector process --dry-run 163 | 164 | # Explicitly save rector cache 165 | - name: Cache rector save 166 | id: cache-rector-save 167 | uses: actions/cache/save@v4 168 | with: 169 | path: ./temp/rector 170 | # Adding github.run_id to force cache update https://github.com/actions/cache/blob/main/tips-and-workarounds.md#update-a-cache 171 | key: ${{ runner.os }}-rector-${{ hashFiles('**/composer.lock') }}-${{ github.ref }}-${{ github.run_id }} 172 | 173 | # GitHub treats skipped checks as successful (meaning untested code can be merged into master if the check is skipped) 174 | check: 175 | name: All checks passing 176 | runs-on: ubuntu-latest 177 | if: always() 178 | needs: 179 | - phpstan 180 | - rector 181 | - phpunit 182 | - ecs 183 | 184 | steps: 185 | - name: Decide whether the needed jobs succeeded or failed 186 | uses: re-actors/alls-green@release/v1 187 | with: 188 | jobs: ${{ toJSON(needs) }} -------------------------------------------------------------------------------- /src/Request/Request.php: -------------------------------------------------------------------------------- 1 | |null 32 | */ 33 | protected $dotAnnotatedRequestBody; 34 | 35 | 36 | public function __clone() 37 | { 38 | parent::__clone(); 39 | $this->dotAnnotatedRequestBody = null; 40 | } 41 | 42 | 43 | public function getRoute(): Route 44 | { 45 | $route = $this->getAttribute(self::ROUTE_ATTRIBUTE); 46 | assert($route instanceof Route); 47 | 48 | return $route; 49 | } 50 | 51 | 52 | /** 53 | * @return array 54 | */ 55 | public function getRouteArguments(): array 56 | { 57 | $routeInfoAttribute = $this->getAttribute(self::ROUTE_INFO_ATTRIBUTE); 58 | 59 | if (is_array($routeInfoAttribute) && isset($routeInfoAttribute[2])) { 60 | return $routeInfoAttribute[2]; 61 | } 62 | 63 | return []; 64 | } 65 | 66 | 67 | public function hasRouteArgument(string $argument): bool 68 | { 69 | return isset($this->getRouteArguments()[$argument]); 70 | } 71 | 72 | 73 | /** 74 | * @throws RouteArgumentMissingException 75 | */ 76 | public function getRouteArgument(string $argument): string 77 | { 78 | if ($this->hasRouteArgument($argument)) { 79 | return $this->getRouteArguments()[$argument]; 80 | } 81 | 82 | throw RouteArgumentMissingException::create($argument); 83 | } 84 | 85 | 86 | public function findRouteArgument(string $argument, ?string $default = null): ?string 87 | { 88 | return $this->getRouteArguments()[$argument] ?? $default; 89 | } 90 | 91 | 92 | /** 93 | * @return mixed[] 94 | */ 95 | public function getParsedBodyAsArray(): array 96 | { 97 | return (array)$this->getParsedBody(); 98 | } 99 | 100 | 101 | /** 102 | * @return mixed 103 | * 104 | * @throws RequestFieldMissingException 105 | */ 106 | public function getField(string $fieldName) 107 | { 108 | if ($this->hasField($fieldName)) { 109 | return $this->getDotAnnotatedRequestBody()->get($fieldName); 110 | } 111 | 112 | throw RequestFieldMissingException::create($fieldName); 113 | } 114 | 115 | 116 | /** 117 | * @param mixed $default 118 | * 119 | * @return mixed 120 | */ 121 | public function findField(string $fieldName, $default = null) 122 | { 123 | return $this->getDotAnnotatedRequestBody()->get($fieldName, $default); 124 | } 125 | 126 | 127 | public function hasField(string $fieldName): bool 128 | { 129 | return $this->getDotAnnotatedRequestBody()->has($fieldName); 130 | } 131 | 132 | 133 | /** 134 | * @return string|string[]|null 135 | */ 136 | public function findQueryParam(string $key, ?string $default = null) 137 | { 138 | return $this->getQueryParam($key) ?? $default; 139 | } 140 | 141 | 142 | /** 143 | * @return string|string[] 144 | * 145 | * @throws QueryParamMissingException 146 | */ 147 | public function getQueryParamStrict(string $key) 148 | { 149 | $value = $this->findQueryParam($key); 150 | 151 | if ($value !== null) { 152 | return $value; 153 | } 154 | 155 | throw QueryParamMissingException::create($key); 156 | } 157 | 158 | 159 | public function findQueryParamAsString(string $key, ?string $default = null): ?string 160 | { 161 | $queryParam = $this->getQueryParam($key); 162 | assert(!is_array($queryParam)); 163 | 164 | return $queryParam ?? $default; 165 | } 166 | 167 | 168 | /** 169 | * @throws QueryParamMissingException 170 | */ 171 | public function getQueryParamAsString(string $key): string 172 | { 173 | $value = $this->findQueryParamAsString($key); 174 | 175 | if ($value !== null) { 176 | return $value; 177 | } 178 | 179 | throw QueryParamMissingException::create($key); 180 | } 181 | 182 | 183 | public function hasQueryParam(string $key): bool 184 | { 185 | return array_key_exists($key, $this->getQueryParams()); 186 | } 187 | 188 | 189 | /** 190 | * @throws QueryParamMissingException 191 | * @throws InvalidArgumentException 192 | */ 193 | public function getDateTimeQueryParam(string $field, string $format = DateTime::ATOM): DateTimeImmutable 194 | { 195 | $datetimeParam = $this->getQueryParamStrict($field); 196 | assert(is_string($datetimeParam)); 197 | 198 | $dateTime = DateTimeImmutable::createFromFormat($format, $datetimeParam); 199 | 200 | if ($dateTime === false) { 201 | throw new InvalidArgumentException(sprintf('Field %s is not in %s format', $field, $format)); 202 | } 203 | 204 | return $dateTime; 205 | } 206 | 207 | 208 | public function isHtml(): bool 209 | { 210 | $acceptHeader = $this->getHeaderLine('accept'); 211 | 212 | return str_contains($acceptHeader, 'html'); 213 | } 214 | 215 | 216 | public function hasAttribute(string $name): bool 217 | { 218 | return array_key_exists($name, $this->getAttributes()); 219 | } 220 | 221 | 222 | /** 223 | * @param mixed $default 224 | * 225 | * @return mixed 226 | */ 227 | public function findAttribute(string $name, $default = null) 228 | { 229 | return $this->getAttribute($name, $default); 230 | } 231 | 232 | 233 | /** 234 | * @return mixed 235 | * 236 | * @throws RequestAttributeMissingException 237 | */ 238 | public function getAttributeStrict(string $name) 239 | { 240 | if ($this->hasAttribute($name)) { 241 | return $this->getAttribute($name); 242 | } 243 | 244 | throw RequestAttributeMissingException::create($name); 245 | } 246 | 247 | 248 | /** 249 | * @return Dot 250 | */ 251 | private function getDotAnnotatedRequestBody(): Dot 252 | { 253 | if ($this->dotAnnotatedRequestBody === null) { 254 | $this->dotAnnotatedRequestBody = new Dot($this->getParsedBodyAsArray()); 255 | } 256 | 257 | return $this->dotAnnotatedRequestBody; 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/SlimApplicationFactory.php: -------------------------------------------------------------------------------- 1 | configuration = $configuration; 79 | $this->container = $container; 80 | $this->middlewareFactory = $middlewareFactory; 81 | $this->slimContainerFactory = $slimContainerFactory; 82 | $this->routeRegister = $routeRegister; 83 | $this->onlyNecessaryRoutesProvider = $onlyNecessaryRoutesProvider; 84 | } 85 | 86 | 87 | public function create(): SlimApp 88 | { 89 | /** @var array $slimConfiguration */ 90 | $slimConfiguration = $this->configuration[self::SLIM_CONFIGURATION]; 91 | $detectTyposInRouteConfiguration = (bool)$this->getSlimSettings( 92 | SlimSettings::DETECT_TYPOS_IN_ROUTE_CONFIGURATION, 93 | true, 94 | ); 95 | $registerOnlyNecessaryRoutes = (bool)$this->getSlimSettings( 96 | SlimSettings::REGISTER_ONLY_NECESSARY_ROUTES, 97 | false, 98 | ); 99 | $useApcuCache = (bool)$this->getSlimSettings( 100 | SlimSettings::USE_APCU_CACHE, 101 | true, 102 | ); 103 | $disableUsingSlimContainer = (bool)$this->getSlimSettings( 104 | SlimSettings::DISABLE_USING_SLIM_CONTAINER, 105 | false, 106 | ); 107 | 108 | $routeApiNamesAlwaysInclude = (array)$this->getSlimSettings( 109 | SlimSettings::ROUTE_API_NAMES_ALWAYS_INCLUDE, 110 | [], 111 | ); 112 | 113 | if ($useApcuCache && !apcu_enabled()) { 114 | // @intentionally For cli scripts is APCU disabled by default 115 | $useApcuCache = false; 116 | } 117 | 118 | if ($disableUsingSlimContainer && !($this->container instanceof ContainerInterface)) { 119 | throw new LogicException('Container must be instance of \Psr\Container\ContainerInterface'); 120 | } 121 | 122 | $slimContainer = $this->slimContainerFactory->create($slimConfiguration); 123 | 124 | if ($disableUsingSlimContainer) { 125 | /** @var Container&ContainerInterface $netteContainer */ 126 | $netteContainer = $this->container; 127 | $this->copyServicesFromSlimContainerToNetteContainer($netteContainer, $slimContainer); 128 | $app = new SlimApp($netteContainer); 129 | } 130 | 131 | if (!$disableUsingSlimContainer) { 132 | $app = new SlimApp($slimContainer); 133 | } 134 | 135 | $routesToRegister = $this->configuration[self::ROUTES]; 136 | if ($registerOnlyNecessaryRoutes) { 137 | /** @var Request $request */ 138 | $request = $slimContainer->get('request'); 139 | $requestUri = $request->getServerParam('REQUEST_URI'); 140 | 141 | $routesToRegister = $this->onlyNecessaryRoutesProvider->getRoutes( 142 | $requestUri, 143 | $routesToRegister, 144 | $useApcuCache, 145 | $routeApiNamesAlwaysInclude, 146 | ); 147 | } 148 | 149 | foreach ($routesToRegister as $apiNamespace => $routes) { 150 | $this->registerApi($apiNamespace, $routes, $detectTyposInRouteConfiguration); 151 | } 152 | 153 | $this->registerHandlers( 154 | $this->container, 155 | $slimContainer, 156 | $this->configuration[self::HANDLERS], 157 | $disableUsingSlimContainer, 158 | ); 159 | 160 | foreach ($this->configuration[self::BEFORE_REQUEST_MIDDLEWARES] as $middleware) { 161 | $middlewareService = $this->middlewareFactory->createFromIdentifier($middleware); 162 | $app->add($middlewareService); 163 | } 164 | 165 | return $app; 166 | } 167 | 168 | 169 | /** 170 | * @param array $handlers 171 | */ 172 | private function registerHandlers( 173 | Container $netteContainer, 174 | SlimContainer $slimContainer, 175 | array $handlers, 176 | bool $disableUsingSlimContainer 177 | ): void { 178 | foreach ($handlers as $handlerName => $handlerClass) { 179 | $this->validateHandlerName($handlerName); 180 | $handlerService = ServiceProvider::getService($this->container, $handlerClass); 181 | assert(is_callable($handlerService)); 182 | 183 | if ($disableUsingSlimContainer) { 184 | /** @var Container&ContainerInterface&ArrayAccess $netteContainer */ 185 | unset($netteContainer[$handlerName]); 186 | $netteContainer[$handlerName] = ServiceProvider::getService($netteContainer, $handlerClass); 187 | continue; 188 | } 189 | 190 | $slimContainer[$handlerName] = (static fn() => $handlerService); 191 | } 192 | } 193 | 194 | 195 | private function validateHandlerName(string $handlerName): void 196 | { 197 | if (in_array($handlerName, self::ALLOWED_HANDLERS, true)) { 198 | return; 199 | } 200 | 201 | $error = sprintf( 202 | '%s handler name is not allowed, available handlers: %s', 203 | $handlerName, 204 | implode(', ', self::ALLOWED_HANDLERS), 205 | ); 206 | 207 | throw new LogicException($error); 208 | } 209 | 210 | 211 | /** 212 | * @param mixed[] $routes 213 | */ 214 | private function registerApi(string $apiNamespace, array $routes, bool $detectTyposInRouteConfiguration): void 215 | { 216 | foreach ($routes as $routePattern => $routeData) { 217 | $this->routeRegister->register($apiNamespace, $routePattern, $routeData, $detectTyposInRouteConfiguration); 218 | } 219 | } 220 | 221 | 222 | /** 223 | * @param bool|array $defaultValue 224 | */ 225 | private function getSlimSettings(string $key, bool|array $defaultValue): mixed 226 | { 227 | return $this->configuration[self::SLIM_CONFIGURATION][self::SETTINGS][$key] ?? $defaultValue; 228 | } 229 | 230 | 231 | /** 232 | * @param Container&ContainerInterface $netteContainer 233 | */ 234 | private function copyServicesFromSlimContainerToNetteContainer( 235 | $netteContainer, 236 | SlimContainer $slimContainer 237 | ): void { 238 | $netteContainer->removeService('request'); 239 | $netteContainer->removeService('response'); 240 | $netteContainer->addService('request', $slimContainer->get('request')); 241 | $netteContainer->addService('response', $slimContainer->get('response')); 242 | 243 | if (!$netteContainer->hasService('settings')) { 244 | $netteContainer->addService('settings', $slimContainer->get('settings')); 245 | $netteContainer->addService('environment', $slimContainer->get('environment')); 246 | $netteContainer->addService('router', $slimContainer->get('router')); 247 | $netteContainer->addService('foundHandler', $slimContainer->get('foundHandler')); 248 | $netteContainer->addService('phpErrorHandler', $slimContainer->get('phpErrorHandler')); 249 | $netteContainer->addService('errorHandler', $slimContainer->get('errorHandler')); 250 | $netteContainer->addService('notFoundHandler', $slimContainer->get('notFoundHandler')); 251 | $netteContainer->addService('notAllowedHandler', $slimContainer->get('notAllowedHandler')); 252 | $netteContainer->addService('callableResolver', new CallableResolver($netteContainer)); 253 | } 254 | } 255 | } 256 | -------------------------------------------------------------------------------- /tests/SlimApplicationFactoryTest.php: -------------------------------------------------------------------------------- 1 | getContainer()->get('settings'); 27 | 28 | Assert::assertSame('Sample', $settings['myCustomOption']); 29 | } 30 | 31 | 32 | /** 33 | * @dataProvider routeResponseDataProvider 34 | * 35 | * @param mixed[] $expectedResponseBody 36 | * @param mixed[] $expectedResponseHeaders 37 | * @param array $headers 38 | */ 39 | public function testRouteIsDispatchedAndProcessed( 40 | array $expectedResponseBody, 41 | array $expectedResponseHeaders, 42 | int $expectedStatusCode, 43 | string $httpMethod, 44 | string $requestUri, 45 | array $headers = [] 46 | ): void { 47 | $this->prepareEnvironment($httpMethod, $requestUri, $headers); 48 | $response = SlimAppTester::runSlimApp(); 49 | 50 | ResponseAssertions::assertJsonResponseEqualsArray($expectedResponseBody, $response, $expectedStatusCode); 51 | ResponseAssertions::assertResponseHeaders($expectedResponseHeaders, $response); 52 | } 53 | 54 | 55 | /** 56 | * @return mixed[][] 57 | */ 58 | public static function routeResponseDataProvider(): array 59 | { 60 | return [ 61 | '200 Hello world as class name' => [ 62 | 'expectedResponseBody' => ['Hello World'], 63 | 'expectedResponseHeaders' => [ 64 | BeforeRequestMiddleware::HEADER_NAME => 'invoked-0', 65 | BeforeRouteMiddleware::HEADER_NAME => 'invoked-1', 66 | GroupMiddleware::HEADER_NAME => 'invoked-2', 67 | ], 68 | 'expectedStatusCode' => 200, 69 | 'httpMethod' => 'GET', 70 | 'requestUri' => '/tests/app/hello-world-as-class-name', 71 | ], 72 | '200 Hello world as service name' => [ 73 | 'expectedResponseBody' => ['Hello World'], 74 | 'expectedResponseHeaders' => [ 75 | BeforeRequestMiddleware::HEADER_NAME => 'invoked-0', 76 | BeforeRouteMiddleware::HEADER_NAME => 'invoked-1', 77 | GroupMiddleware::HEADER_NAME => 'invoked-2', 78 | ], 79 | 'expectedStatusCode' => 200, 80 | 'httpMethod' => 'GET', 81 | 'requestUri' => '/tests/app/hello-world-as-service-name', 82 | ], 83 | '404 Not found' => [ 84 | 'expectedResponseBody' => ['error' => 'Sample NotFoundHandler here!'], 85 | 'expectedResponseHeaders' => [BeforeRequestMiddleware::HEADER_NAME => 'invoked-0'], 86 | 'expectedStatusCode' => 404, 87 | 'httpMethod' => 'POST', 88 | 'requestUri' => '/tests/non-existing/path', 89 | ], 90 | '405 Not allowed' => [ 91 | 'expectedResponseBody' => ['error' => 'Sample NotAllowedHandler here!'], 92 | 'expectedResponseHeaders' => [BeforeRequestMiddleware::HEADER_NAME => 'invoked-0'], 93 | 'expectedStatusCode' => 405, 94 | 'httpMethod' => 'PATCH', 95 | 'requestUri' => '/tests/api/channels', 96 | ], 97 | '500 is 500' => [ 98 | 'expectedResponseBody' => ['error' => "Error or not to error, that's the question!"], 99 | 'expectedResponseHeaders' => [], 100 | 'expectedStatusCode' => 500, 101 | 'httpMethod' => 'POST', 102 | 'requestUri' => '/tests/api/error', 103 | ], 104 | '401 Unauthorized' => [ 105 | 'expectedResponseBody' => ['error' => 'YOU SHALL NOT PASS!'], 106 | 'expectedResponseHeaders' => [BeforeRequestMiddleware::HEADER_NAME => 'invoked-0'], 107 | 'expectedStatusCode' => 401, 108 | 'httpMethod' => 'POST', 109 | 'requestUri' => '/tests/api/channels', 110 | ], 111 | 'Token authorization passed' => [ 112 | 'expectedResponseBody' => ['status' => 'created'], 113 | 'expectedResponseHeaders' => [ 114 | BeforeRequestMiddleware::HEADER_NAME => 'invoked-0', 115 | BeforeRouteMiddleware::HEADER_NAME => 'invoked-1', 116 | OnlyApiGroupMiddleware::HEADER_NAME => 'invoked-2', 117 | GroupMiddleware::HEADER_NAME => 'invoked-3', 118 | ], 119 | 'expectedStatusCode' => 201, 120 | 'httpMethod' => 'POST', 121 | 'requestUri' => '/tests/api/channels', 122 | 'headers' => ['HTTP_X_API_KEY' => GoldenKeyAuthMiddleware::ACCESS_TOKEN], 123 | ], 124 | 'Get channel list' => [ 125 | 'expectedResponseBody' => [['id' => 1, 'name' => 'First channel'], ['id' => 2, 'name' => 'Second channel']], 126 | 'expectedResponseHeaders' => [ 127 | BeforeRequestMiddleware::HEADER_NAME => 'invoked-0', 128 | BeforeRouteMiddleware::HEADER_NAME => 'invoked-1', 129 | OnlyApiGroupMiddleware::HEADER_NAME => 'invoked-2', 130 | GroupMiddleware::HEADER_NAME => 'invoked-3', 131 | ], 132 | 'expectedStatusCode' => 200, 133 | 'httpMethod' => 'GET', 134 | 'requestUri' => '/tests/api/channels', 135 | ], 136 | ]; 137 | } 138 | 139 | 140 | public function testMiddlewareInvokeOrder(): void 141 | { 142 | $expectedHeaders = [ 143 | InvokeCounterMiddleware::getName('A') => 'invoked-0', 144 | InvokeCounterMiddleware::getName('B') => 'invoked-1', 145 | InvokeCounterMiddleware::getName('C') => 'invoked-2', 146 | InvokeCounterMiddleware::getName('D') => 'invoked-3', 147 | InvokeCounterMiddleware::getName('E') => 'invoked-4', 148 | InvokeCounterMiddleware::getName('F') => 'invoked-5', 149 | InvokeCounterMiddleware::getName('G') => 'invoked-6', 150 | InvokeCounterMiddleware::getName('H') => 'invoked-7', 151 | InvokeCounterMiddleware::getName('I') => 'invoked-8', 152 | InvokeCounterMiddleware::getName('J') => 'invoked-9', 153 | InvokeCounterMiddleware::getName('K') => 'invoked-10', 154 | InvokeCounterMiddleware::getName('L') => 'invoked-11', 155 | ]; 156 | 157 | $this->prepareEnvironment('POST', '/api/test'); 158 | $response = SlimAppTester::runSlimApp(__DIR__ . '/no-prefix-config.neon'); 159 | 160 | ResponseAssertions::assertResponseHeaders($expectedHeaders, $response); 161 | } 162 | 163 | 164 | public function testVersionMiddlewareGroupIsIgnored(): void 165 | { 166 | $expectedHeaders = [ 167 | InvokeCounterMiddleware::getName('A') => 'invoked-0', 168 | InvokeCounterMiddleware::getName('B') => 'invoked-1', 169 | InvokeCounterMiddleware::getName('C') => 'invoked-2', 170 | InvokeCounterMiddleware::getName('D') => 'invoked-3', 171 | InvokeCounterMiddleware::getName('G') => 'invoked-4', 172 | InvokeCounterMiddleware::getName('H') => 'invoked-5', 173 | ]; 174 | 175 | $this->prepareEnvironment('POST', '/api/ignore-version-middlewares'); 176 | $response = SlimAppTester::runSlimApp(__DIR__ . '/no-prefix-config.neon'); 177 | 178 | ResponseAssertions::assertResponseHeaders($expectedHeaders, $response); 179 | } 180 | 181 | 182 | public function testRouteCanBeUnregistered(): void 183 | { 184 | $slimAppWithAllRoutes = SlimAppTester::createSlimApp(__DIR__ . '/config.neon'); 185 | $routerWithAllRoutes = $slimAppWithAllRoutes->getContainer()->get('router'); 186 | assert($routerWithAllRoutes instanceof Router); 187 | 188 | $slimAppWithUnregisteredRoute = SlimAppTester::createSlimApp(__DIR__ . '/unregister-route-config.neon'); 189 | $routerWithUnregisteredRoute = $slimAppWithUnregisteredRoute->getContainer()->get('router'); 190 | assert($routerWithUnregisteredRoute instanceof Router); 191 | 192 | $expectedRouteCount = count($routerWithAllRoutes->getRoutes()) - 1; 193 | 194 | Assert::assertCount($expectedRouteCount, $routerWithUnregisteredRoute->getRoutes()); 195 | } 196 | 197 | 198 | public function testRootRouteIsDispatched(): void 199 | { 200 | $this->prepareEnvironment('GET', '/'); 201 | $response = SlimAppTester::runSlimApp(__DIR__ . '/no-prefix-config.neon'); 202 | 203 | ResponseAssertions::assertResponseStatusCode(200, $response); 204 | } 205 | 206 | 207 | public function testRouteNameIsResolved(): void 208 | { 209 | $slimApp = SlimAppTester::createSlimApp(); 210 | $container = $slimApp->getContainer(); 211 | 212 | $router = $container->get('router'); 213 | assert($router instanceof Router); 214 | 215 | Assert::assertSame( 216 | '/tests/api/channels/1234/users', 217 | $router->urlFor('getChannelUsers', ['channelId' => '1234']) 218 | ); 219 | 220 | Assert::assertSame('/tests/api/channels', $router->urlFor('/api/channels')); 221 | } 222 | 223 | 224 | /** 225 | * @param array $headers 226 | */ 227 | private function prepareEnvironment(string $requestMethod, string $requestUrlPath, array $headers = []): void 228 | { 229 | MiddlewareInvocationCounter::reset(); 230 | 231 | $_SERVER['HTTP_HOST'] = 'api.brandembassy.com'; 232 | $_SERVER['REQUEST_URI'] = $requestUrlPath; 233 | $_SERVER['REQUEST_METHOD'] = $requestMethod; 234 | 235 | foreach ($headers as $name => $value) { 236 | $_SERVER[$name] = $value; 237 | } 238 | } 239 | 240 | 241 | public function testRouteConfigWillFailWhenMisconfigured(): void 242 | { 243 | $this->expectExceptionMessage( 244 | 'Unexpected route definition key in "app › /hello-world › get › middleware", did you mean "middlewares"?' 245 | ); 246 | 247 | SlimAppTester::createSlimApp(__DIR__ . '/typo-in-config.neon'); 248 | } 249 | } 250 | --------------------------------------------------------------------------------