Skip to content

Commit

Permalink
IBX-8173: Implemented routing language expression extension handling …
Browse files Browse the repository at this point in the history
…`Content-Type` header
  • Loading branch information
barw4 committed Jul 2, 2024
1 parent ddbe28c commit 7138efe
Show file tree
Hide file tree
Showing 10 changed files with 235 additions and 23 deletions.
5 changes: 0 additions & 5 deletions phpstan-baseline.neon
Original file line number Diff line number Diff line change
Expand Up @@ -5515,11 +5515,6 @@ parameters:
count: 1
path: tests/bundle/Routing/OptionsLoader/MapperTest.php

-
message: "#^Method Ibexa\\\\Tests\\\\Bundle\\\\Rest\\\\Routing\\\\OptionsLoader\\\\RouteCollectionMapperTest\\:\\:createRoute\\(\\) has parameter \\$methods with no value type specified in iterable type array\\.$#"
count: 1
path: tests/bundle/Routing/OptionsLoader/RouteCollectionMapperTest.php

-
message: "#^Method Ibexa\\\\Tests\\\\Bundle\\\\Rest\\\\Routing\\\\OptionsLoader\\\\RouteCollectionMapperTest\\:\\:testAddRestRoutesCollection\\(\\) has no return type specified\\.$#"
count: 1
Expand Down
10 changes: 10 additions & 0 deletions src/bundle/Resources/config/services.yml
Original file line number Diff line number Diff line change
Expand Up @@ -390,3 +390,13 @@ services:
parent: hautelook.router.template
calls:
- [ setOption, [ strict_requirements, ~ ] ]

Ibexa\Bundle\Rest\Routing\ExpressionLanguage\ContentTypeHeaderMatcherExpressionFunction:
arguments:
$mediaTypeParser: '@Ibexa\Contracts\Rest\Input\MediaTypeParserInterface'
tags:
- { name: routing.expression_language_function, function: 'ibexa_get_media_type' }

Ibexa\Contracts\Rest\Input\MediaTypeParser: ~

Ibexa\Contracts\Rest\Input\MediaTypeParserInterface: '@Ibexa\Contracts\Rest\Input\MediaTypeParser'
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Bundle\Rest\Routing\ExpressionLanguage;

use Ibexa\Contracts\Rest\Input\MediaTypeParserInterface;
use Symfony\Component\HttpFoundation\Request;

final readonly class ContentTypeHeaderMatcherExpressionFunction
{
public function __construct(
private MediaTypeParserInterface $mediaTypeParser
) {
}

public function __invoke(Request $request): ?string
{
$contentTypeHeaderValue = $request->headers->get('Content-Type');
if ($contentTypeHeaderValue === null) {
return null;
}

return $this->mediaTypeParser->parseContentTypeHeader($contentTypeHeaderValue);
}
}
20 changes: 14 additions & 6 deletions src/bundle/Routing/OptionsLoader/Mapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -65,15 +65,23 @@ public function mergeMethodsDefault(Route $optionsRoute, Route $restRoute)

/**
* Returns the OPTIONS name of a REST route.
*
* @param $route Route
*
* @return string
*/
public function getOptionsRouteName(Route $route)
public function getOptionsRouteName(Route $route): string
{
$name = str_replace('/', '_', $route->getPath());

return 'ibexa.rest.options.' . trim($name, '_');
$parts = [
'ibexa.rest.options',
trim($name, '_'),
];

// Routes that share path 1-to-1 can result in overwrite.
// Use "options_route_suffix" to ensure uniqueness.
$routeSuffix = $route->getOption('options_route_suffix');
if ($routeSuffix !== null) {
$parts[] = $routeSuffix;
}

return implode('.', $parts);
}
}
23 changes: 23 additions & 0 deletions src/contracts/Input/MediaTypeParser.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Contracts\Rest\Input;

final class MediaTypeParser implements MediaTypeParserInterface
{
private const string MEDIA_TYPE_PATTERN = '/application\/vnd\.ibexa\.api\.([^.]+)\+/';

public function parseContentTypeHeader(string $header): ?string
{
if (preg_match(self::MEDIA_TYPE_PATTERN, $header, $matches)) {
return $matches[1];
}

return null;
}
}
14 changes: 14 additions & 0 deletions src/contracts/Input/MediaTypeParserInterface.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Contracts\Rest\Input;

interface MediaTypeParserInterface
{
public function parseContentTypeHeader(string $header): ?string;
}
16 changes: 11 additions & 5 deletions tests/bundle/Functional/HttpOptionsTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,21 @@ class HttpOptionsTest extends TestCase
*
* @dataProvider providerForTestHttpOptions
*
* @param string $route
* @param string[] $expectedMethods
* @param array<string> $expectedMethods
*/
public function testHttpOptions(string $route, array $expectedMethods): void
{
public function testHttpOptions(
string $route,
array $expectedMethods,
?string $contentType = null
): void {
$restAPIPrefix = '/api/ibexa/v2';

$response = $this->sendHttpRequest(
$this->createHttpRequest('OPTIONS', "{$restAPIPrefix}{$route}")
$this->createHttpRequest(
'OPTIONS',
"{$restAPIPrefix}{$route}",
$contentType ?? '',
)
);

self::assertHttpResponseCodeEquals($response, 200);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Bundle\Rest\Routing\ExpressionLanguage;

use Ibexa\Bundle\Rest\Routing\ExpressionLanguage\ContentTypeHeaderMatcherExpressionFunction;
use Ibexa\Contracts\Rest\Input\MediaTypeParser;
use PHPUnit\Framework\TestCase;
use Symfony\Component\HttpFoundation\Request;

final class ContentTypeHeaderMatcherExpressionFunctionTest extends TestCase
{
private readonly ContentTypeHeaderMatcherExpressionFunction $contentTypeHeaderMatcher;

protected function setUp(): void
{
$contentTypeHeaderMatcher = new ContentTypeHeaderMatcherExpressionFunction(
new MediaTypeParser(),
);
$this->contentTypeHeaderMatcher = $contentTypeHeaderMatcher;
}

public function testGetMediaType(): void
{
$request = new Request();
$request->headers->add([
'Content-Type' => 'application/vnd.ibexa.api.CopyContentTypeInput+json',
]);

$closure = $this->contentTypeHeaderMatcher;

self::assertSame('CopyContentTypeInput', $closure($request));
}

public function testRequestContentTypeDoesNotMatchRoute(): void
{
$request = new Request();
$request->headers->add([
'Content-Type' => 'application.CreateContentTypeInput+xml',
]);

$closure = $this->contentTypeHeaderMatcher;

self::assertNull($closure($request));
}
}
40 changes: 33 additions & 7 deletions tests/bundle/Routing/OptionsLoader/RouteCollectionMapperTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,14 +63,40 @@ public function testAddRestRoutesCollection()
);
}

public function testAddRestRoutesCollectionWithConditionAndSuffix(): void
{
$restRoutesCollection = new RouteCollection();
$restRoutesCollection->add(
'ibexa.rest.route_three_post',
$this->createRoute(
'/route/three',
['POST'],
'ibexa_get_media_type(request) === "RouteThreeInput"',
['options_route_suffix' => 'RouteThreeInput'],
),
);

$optionsRouteCollection = $this->collectionMapper->mapCollection($restRoutesCollection);

self::assertCount(1, $optionsRouteCollection);

$optionsRoute = $optionsRouteCollection->get('ibexa.rest.options.route_three.RouteThreeInput');

self::assertInstanceOf(Route::class, $optionsRoute);

self::assertEquals('POST', $optionsRoute->getDefault('allowedMethods'));
}

/**
* @param string $path
* @param array $methods
*
* @return \Symfony\Component\Routing\Route
* @param array<string> $methods
* @param array<string> $options
*/
private function createRoute($path, array $methods)
{
return new Route($path, [], [], [], '', [], $methods);
private function createRoute(
string $path,
array $methods,
?string $condition = null,
array $options = [],
): Route {
return new Route($path, [], [], $options, '', [], $methods, $condition);
}
}
49 changes: 49 additions & 0 deletions tests/lib/Input/MediaTypeParserTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
<?php

/**
* @copyright Copyright (C) Ibexa AS. All rights reserved.
* @license For full copyright and license information view LICENSE file distributed with this source code.
*/
declare(strict_types=1);

namespace Ibexa\Tests\Rest\Input;

use Ibexa\Contracts\Rest\Input\MediaTypeParser;
use Ibexa\Contracts\Rest\Input\MediaTypeParserInterface;
use PHPUnit\Framework\TestCase;

final class MediaTypeParserTest extends TestCase
{
private readonly MediaTypeParserInterface $mediaTypeParser;

protected function setUp(): void
{
$this->mediaTypeParser = new MediaTypeParser();
}

public function testParsingSuccesses(): void
{
$header = 'application/vnd.ibexa.api.CopyContentTypeInput+json';

self::assertSame('CopyContentTypeInput', $this->mediaTypeParser->parseContentTypeHeader($header));
}

/**
* @dataProvider providerForParsingFails
*/
public function testParsingFails(string $header): void
{
self::assertNull($this->mediaTypeParser->parseContentTypeHeader($header));
}

/**
* @return iterable<array<int, string>>
*/
public function providerForParsingFails(): iterable
{
yield 'a' => ['application.CopyContentTypeInput+json'];
yield 'b' => ['application.CopyContentTypeInput'];
yield 'c' => ['CopyContentTypeInput+json'];
yield 'd' => ['CopyContentTypeInput'];
}
}

0 comments on commit 7138efe

Please sign in to comment.