Skip to content

Commit

Permalink
Added a simple way to negotiate request and response bodies (#253)
Browse files Browse the repository at this point in the history
* Minor refactorings, working to add ability to grab parsed response body from integration tests

* Refactored ResponseAssertions to take in a content negotiator so that any custom content negotiation logic the dev may have written is used in integration tests

* Added interface for body negotiation that can be reused for controller param resolution and integration test response body resolution

* Refactored controller param resolution to use IBodyNegotiator instead of IContentNegotiator for DRY

* Updated CHANGELOG

* Updated integration test class to expose methods for negotiating request and response bodies
  • Loading branch information
davidbyoung committed Apr 29, 2023
1 parent 8968490 commit a259240
Show file tree
Hide file tree
Showing 17 changed files with 516 additions and 119 deletions.
5 changes: 5 additions & 0 deletions CHANGELOG.md
Expand Up @@ -6,6 +6,11 @@

- Updated to PHPUnit 10.1 ([#248](https://github.com/aphiria/aphiria/pull/248), [#250](https://github.com/aphiria/aphiria/pull/250))

### Added

- Added `IBodyNegotiator` and `BodyNegotiator` to simplify negotiating request and response bodies ([#253](https://github.com/aphiria/aphiria/pull/253))
- Added ability to easily negotiate request and response bodies in integration tests ([#253](https://github.com/aphiria/aphiria/pull/253))

## [v1.0.0-alpha8](https://github.com/aphiria/aphiria/compare/v1.0.0-alpha7...v1.0.0-alpha8) (2022-12-10)

### Changed
Expand Down
58 changes: 23 additions & 35 deletions src/Api/src/Controllers/ControllerParameterResolver.php
Expand Up @@ -12,8 +12,9 @@

namespace Aphiria\Api\Controllers;

use Aphiria\ContentNegotiation\ContentNegotiator;
use Aphiria\ContentNegotiation\IContentNegotiator;
use Aphiria\ContentNegotiation\BodyNegotiator;
use Aphiria\ContentNegotiation\FailedContentNegotiationException;
use Aphiria\ContentNegotiation\IBodyNegotiator;
use Aphiria\ContentNegotiation\MediaTypeFormatters\SerializationException;
use Aphiria\Net\Formatting\UriParser;
use Aphiria\Net\Http\IRequest;
Expand All @@ -26,11 +27,11 @@
final class ControllerParameterResolver implements IControllerParameterResolver
{
/**
* @param IContentNegotiator $contentNegotiator The content negotiator
* @param IBodyNegotiator $bodyNegotiator The body negotiator
* @param UriParser $uriParser The URI parser to use
*/
public function __construct(
private readonly IContentNegotiator $contentNegotiator = new ContentNegotiator(),
private readonly IBodyNegotiator $bodyNegotiator = new BodyNegotiator(),
private readonly UriParser $uriParser = new UriParser()
) {
}
Expand Down Expand Up @@ -83,8 +84,8 @@ public function resolveParameter(
* @param IRequest $request The current request
* @return object|null The resolved parameter
* @throws FailedRequestContentNegotiationException Thrown if the request content negotiation failed
* @throws RequestBodyDeserializationException Thrown if there was an error deserializing the request body
* @throws MissingControllerParameterValueException Thrown if there was no valid value for the parameter
* @throws RequestBodyDeserializationException Thrown if the request body could not be deserialized
* @psalm-suppress InvalidReturnType The media type formatter will resolve to the parameter type, which will be an object
* @psalm-suppress InvalidReturnStatement Ditto
*/
Expand All @@ -93,47 +94,34 @@ private function resolveObjectParameter(
ReflectionNamedType $type,
IRequest $request
): ?object {
$body = $request->getBody();
try {
$negotiatedBody = $this->bodyNegotiator->negotiateRequestBody($type->getName(), $request);

if ($body === null) {
if (!$reflectionParameter->allowsNull()) {
if ($negotiatedBody === null && !$reflectionParameter->allowsNull()) {
throw new MissingControllerParameterValueException(
"Body is null when resolving parameter {$reflectionParameter->getName()}"
);
}

return null;
}

$requestContentNegotiationResult = $this->contentNegotiator->negotiateRequestContent(
$type->getName(),
$request
);
$mediaTypeFormatter = $requestContentNegotiationResult->formatter;

if ($mediaTypeFormatter === null) {
if (!$reflectionParameter->allowsNull()) {
throw new FailedRequestContentNegotiationException(
"Failed to negotiate request content with type $type"
);
return $negotiatedBody;
} catch (FailedContentNegotiationException $ex) {
if ($reflectionParameter->allowsNull()) {
return null;
}

return null;
}

try {
return $mediaTypeFormatter
->readFromStream($body->readAsStream(), $type->getName());
throw new FailedRequestContentNegotiationException(
"Failed to negotiate request content with type $type"
);
} catch (SerializationException $ex) {
if (!$reflectionParameter->allowsNull()) {
throw new RequestBodyDeserializationException(
"Failed to deserialize request body when resolving parameter {$reflectionParameter->getName()}",
0,
$ex
);
if ($reflectionParameter->allowsNull()) {
return null;
}

return null;
throw new RequestBodyDeserializationException(
"Failed to deserialize request body when resolving parameter {$reflectionParameter->getName()}",
0,
$ex
);
}
}

Expand Down
8 changes: 4 additions & 4 deletions src/Api/src/Controllers/RouteActionInvoker.php
Expand Up @@ -13,6 +13,7 @@
namespace Aphiria\Api\Controllers;

use Aphiria\Api\Validation\IRequestBodyValidator;
use Aphiria\ContentNegotiation\BodyNegotiator;
use Aphiria\ContentNegotiation\ContentNegotiator;
use Aphiria\ContentNegotiation\IContentNegotiator;
use Aphiria\ContentNegotiation\NegotiatedResponseFactory;
Expand Down Expand Up @@ -52,7 +53,8 @@ public function __construct(
IControllerParameterResolver $controllerParameterResolver = null
) {
$this->responseFactory = $responseFactory ?? new NegotiatedResponseFactory($contentNegotiator);
$this->controllerParameterResolver = $controllerParameterResolver ?? new ControllerParameterResolver($contentNegotiator);
$this->controllerParameterResolver = $controllerParameterResolver
?? new ControllerParameterResolver(new BodyNegotiator($contentNegotiator));
}

/**
Expand Down Expand Up @@ -85,9 +87,7 @@ public function invokeRouteAction(
$routeVariables
);

if ($this->requestBodyValidator !== null) {
$this->requestBodyValidator->validate($request, $resolvedParameter);
}
$this->requestBodyValidator?->validate($request, $resolvedParameter);

/** @psalm-suppress MixedAssignment The resolved parameter could legitimately be mixed */
$resolvedParameters[] = $resolvedParameter;
Expand Down
63 changes: 22 additions & 41 deletions src/Api/tests/Controllers/ControllerParameterResolverTest.php
Expand Up @@ -19,9 +19,8 @@
use Aphiria\Api\Controllers\RequestBodyDeserializationException;
use Aphiria\Api\Tests\Controllers\Mocks\ControllerWithEndpoints;
use Aphiria\Api\Tests\Controllers\Mocks\User;
use Aphiria\ContentNegotiation\ContentNegotiationResult;
use Aphiria\ContentNegotiation\IContentNegotiator;
use Aphiria\ContentNegotiation\MediaTypeFormatters\IMediaTypeFormatter;
use Aphiria\ContentNegotiation\FailedContentNegotiationException;
use Aphiria\ContentNegotiation\IBodyNegotiator;
use Aphiria\ContentNegotiation\MediaTypeFormatters\SerializationException;
use Aphiria\Net\Http\Request;
use Aphiria\Net\Http\StringBody;
Expand All @@ -34,12 +33,12 @@
class ControllerParameterResolverTest extends TestCase
{
private ControllerParameterResolver $resolver;
private IContentNegotiator&MockObject $contentNegotiator;
private IBodyNegotiator&MockObject $bodyNegotiator;

protected function setUp(): void
{
$this->contentNegotiator = $this->createMock(IContentNegotiator::class);
$this->resolver = new ControllerParameterResolver($this->contentNegotiator);
$this->bodyNegotiator = $this->createMock(IBodyNegotiator::class);
$this->resolver = new ControllerParameterResolver($this->bodyNegotiator);
}

public static function scalarParameterTestDataProvider(): array
Expand Down Expand Up @@ -78,33 +77,27 @@ public function testResolvingNonNullableObjectParameterWithBodyThatCannotDeseria
$this->expectExceptionMessage('Failed to deserialize request body when resolving parameter user');
$request = $this->createRequestWithoutBody('http://foo.com');
$request->setBody(new StringBody('dummy body'));
/** @var IMediaTypeFormatter&MockObject $mediaTypeFormatter */
$mediaTypeFormatter = $this->createMock(IMediaTypeFormatter::class);
$mediaTypeFormatter->expects($this->once())
->method('readFromStream')
->with($request->getBody()?->readAsStream(), User::class)
->willThrowException(new SerializationException());
$this->contentNegotiator->expects($this->once())
->method('negotiateRequestContent')
$this->bodyNegotiator->expects($this->once())
->method('negotiateRequestBody')
->with(User::class, $request)
->willReturn(new ContentNegotiationResult($mediaTypeFormatter, null, null, null));
->willThrowException(new SerializationException());
$this->resolver->resolveParameter(
new ReflectionParameter([ControllerWithEndpoints::class, 'objectParameter'], 'user'),
$request,
[]
);
}

public function testResolvingNonNullableObjectParameterWithBodyThatHasNoMediaTypeFormatterThrowsException(): void
public function testResolvingNonNullableObjectParameterWithBodyThatFailedContentNegotiationRethrowsException(): void
{
$this->expectException(FailedRequestContentNegotiationException::class);
$this->expectExceptionMessage('Failed to negotiate request content with type ' . User::class);
$request = $this->createRequestWithoutBody('http://foo.com');
$request->setBody(new StringBody('dummy body'));
$this->contentNegotiator->expects($this->once())
->method('negotiateRequestContent')
$this->bodyNegotiator->expects($this->once())
->method('negotiateRequestBody')
->with(User::class, $request)
->willReturn(new ContentNegotiationResult(null, null, null, null));
->willThrowException(new FailedContentNegotiationException());
$this->resolver->resolveParameter(
new ReflectionParameter([ControllerWithEndpoints::class, 'objectParameter'], 'user'),
$request,
Expand All @@ -116,16 +109,10 @@ public function testResolvingNullableObjectParameterWithBodyThatCannotDeserializ
{
$request = $this->createRequestWithoutBody('http://foo.com');
$request->setBody(new StringBody('dummy body'));
/** @var IMediaTypeFormatter&MockObject $mediaTypeFormatter */
$mediaTypeFormatter = $this->createMock(IMediaTypeFormatter::class);
$mediaTypeFormatter->expects($this->once())
->method('readFromStream')
->with($request->getBody()?->readAsStream(), User::class)
->willThrowException(new SerializationException());
$this->contentNegotiator->expects($this->once())
->method('negotiateRequestContent')
$this->bodyNegotiator->expects($this->once())
->method('negotiateRequestBody')
->with(User::class, $request)
->willReturn(new ContentNegotiationResult($mediaTypeFormatter, null, null, null));
->willThrowException(new SerializationException());
$resolvedParameter = $this->resolver->resolveParameter(
new ReflectionParameter([ControllerWithEndpoints::class, 'nullableObjectParameter'], 'user'),
$request,
Expand All @@ -134,14 +121,14 @@ public function testResolvingNullableObjectParameterWithBodyThatCannotDeserializ
$this->assertNull($resolvedParameter);
}

public function testResolvingNullableObjectParameterWithBodyThatHasNoMediaTypeFormatterPassesNull(): void
public function testResolvingNullableObjectParameterWithBodyThatFailsContentNegotiationReturnsNull(): void
{
$request = $this->createRequestWithoutBody('http://foo.com');
$request->setBody(new StringBody('dummy body'));
$this->contentNegotiator->expects($this->once())
->method('negotiateRequestContent')
$this->bodyNegotiator->expects($this->once())
->method('negotiateRequestBody')
->with(User::class, $request)
->willReturn(new ContentNegotiationResult(null, null, null, null));
->willThrowException(new FailedContentNegotiationException());
$resolvedParameter = $this->resolver->resolveParameter(
new ReflectionParameter([ControllerWithEndpoints::class, 'nullableObjectParameter'], 'user'),
$request,
Expand Down Expand Up @@ -187,16 +174,10 @@ public function testResolvingObjectParameterReadsFromRequestBodyFirst(): void
$request = $this->createRequestWithoutBody('http://foo.com');
$request->setBody(new StringBody('dummy body'));
$expectedUser = new User(123, 'foo@bar.com');
/** @var IMediaTypeFormatter&MockObject $mediaTypeFormatter */
$mediaTypeFormatter = $this->createMock(IMediaTypeFormatter::class);
$mediaTypeFormatter->expects($this->once())
->method('readFromStream')
->with($request->getBody()?->readAsStream(), User::class)
->willReturn($expectedUser);
$this->contentNegotiator->expects($this->once())
->method('negotiateRequestContent')
$this->bodyNegotiator->expects($this->once())
->method('negotiateRequestBody')
->with(User::class, $request)
->willReturn(new ContentNegotiationResult($mediaTypeFormatter, null, null, null));
->willReturn($expectedUser);
$resolvedParameter = $this->resolver->resolveParameter(
new ReflectionParameter([ControllerWithEndpoints::class, 'objectParameter'], 'user'),
$request,
Expand Down
67 changes: 67 additions & 0 deletions src/ContentNegotiation/src/BodyNegotiator.php
@@ -0,0 +1,67 @@
<?php

/**
* Aphiria
*
* @link https://www.aphiria.com
* @copyright Copyright (C) 2023 David Young
* @license https://github.com/aphiria/aphiria/blob/1.x/LICENSE.md
*/

declare(strict_types=1);

namespace Aphiria\ContentNegotiation;

use Aphiria\Net\Http\IRequest;
use Aphiria\Net\Http\IResponse;

/**
* Defines a body negotiator
*/
final class BodyNegotiator implements IBodyNegotiator
{
/**
* @param IContentNegotiator $contentNegotiator The content negotiator to use when negotiating message bodies
*/
public function __construct(private readonly IContentNegotiator $contentNegotiator = new ContentNegotiator())
{
}

/**
* @inheritdoc
*/
public function negotiateRequestBody(string $type, IRequest $request): float|object|int|bool|array|string|null
{
if (($body = $request->getBody()) === null) {
return null;
}

$contentNegotiationResult = $this->contentNegotiator->negotiateRequestContent($type, $request);
$mediaTypeFormatter = $contentNegotiationResult->formatter;

if ($mediaTypeFormatter === null) {
throw new FailedContentNegotiationException("No media type formatter available for $type");
}

return $mediaTypeFormatter->readFromStream($body->readAsStream(), $type);
}

/**
* @inheritdoc
*/
public function negotiateResponseBody(string $type, IRequest $request, IResponse $response): float|object|int|bool|array|string|null
{
if (($body = $response->getBody()) === null) {
return null;
}

$contentNegotiationResult = $this->contentNegotiator->negotiateResponseContent($type, $request);
$mediaTypeFormatter = $contentNegotiationResult->formatter;

if ($mediaTypeFormatter === null) {
throw new FailedContentNegotiationException("No media type formatter available for $type");
}

return $mediaTypeFormatter->readFromStream($body->readAsStream(), $type);
}
}
22 changes: 22 additions & 0 deletions src/ContentNegotiation/src/FailedContentNegotiationException.php
@@ -0,0 +1,22 @@
<?php

/**
* Aphiria
*
* @link https://www.aphiria.com
* @copyright Copyright (C) 2023 David Young
* @license https://github.com/aphiria/aphiria/blob/1.x/LICENSE.md
*/

declare(strict_types=1);

namespace Aphiria\ContentNegotiation;

use Exception;

/**
* Defines the exception that is thrown when content negotiation fails
*/
class FailedContentNegotiationException extends Exception
{
}

0 comments on commit a259240

Please sign in to comment.