├── .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 | [](https://circleci.com/gh/BrandEmbassy/slim-nette-extension)
2 | [](https://packagist.org/packages/brandembassy/slim-nette-extension)
3 | [](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 |
--------------------------------------------------------------------------------