Skip to content

Commit 8401092

Browse files
authored
Merge pull request #4 from lordrhodos/feature/request-validation
Add support to validate HttpFoundation Requests and PSR ServerRequests
2 parents 0dfa181 + 88eef6e commit 8401092

11 files changed

Lines changed: 606 additions & 5 deletions

README.md

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,23 +5,23 @@
55
[![Build Status](https://travis-ci.com/osteel/openapi-httpfoundation-testing.svg?token=SDx8eeySnDpzswpLVTU3&branch=main)](https://travis-ci.com/osteel/openapi-httpfoundation-testing)
66
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/osteel/openapi-httpfoundation-testing/badges/quality-score.png?b=main&s=bef9ddbf29dac69612a3092e4761e14ce768bccd)](https://scrutinizer-ci.com/g/osteel/openapi-httpfoundation-testing/?branch=main)
77

8-
Strengthen your API tests by validating HttpFoundation responses against OpenAPI (3.0.x) definitions.
8+
Strengthen your API tests by validating HttpFoundation requests and responses against OpenAPI (3.0.x) definitions.
99

1010
See [this article](https://tech.osteel.me/posts/openapi-backed-api-testing-in-php-projects-a-laravel-example "OpenAPI-backed API testing in PHP projects – a Laravel example") for more details, and [this repository](https://github.com/osteel/openapi-httpfoundation-testing-laravel-example) for an example use in a Laravel project.
1111

1212
## Why?
1313

1414
[OpenAPI](https://swagger.io/specification/) is a specification intended to describe RESTful APIs in a way that is understood by humans and machines alike.
1515

16-
By validating an API's responses against the OpenAPI definition that describes it, we guarantee that the API's behaviour conforms to the documentation we provide, thus making the OpenAPI definition the single source of truth.
16+
By validating an API's responses against the OpenAPI definition that describes it, we guarantee that the API's behaviour conforms to the documentation we provide, thus making the OpenAPI definition the single source of truth. The request validation on the other hand supports you in the process of doing the server side validation of the requests payloads.
1717

1818
The [HttpFoundation component](https://symfony.com/doc/current/components/http_foundation.html) is developed and maintained as part of the [Symfony framework](https://symfony.com/). It is used to handle HTTP requests and responses in projects such as Symfony, Laravel, Drupal, and many other major industry players (see the [extended list](https://symfony.com/components/HttpFoundation)).
1919

2020
## How does it work?
2121

2222
This package is built upon the [OpenAPI PSR-7 Message Validator](https://github.com/thephpleague/openapi-psr7-validator) package, which validates [PSR-7 messages](https://www.php-fig.org/psr/psr-7/) against OpenAPI definitions.
2323

24-
It essentially converts HttpFoundation response objects to PSR-7 messages using Symfony's [PSR-7 Bridge](https://symfony.com/doc/current/components/psr7.html) and [Tobias Nyholm](https://github.com/Nyholm)'s [PSR-7 implementation](https://github.com/Nyholm/psr7), before passing them on to the OpenAPI PSR-7 Message Validator.
24+
It essentially converts HttpFoundation response and request objects to PSR-7 messages using Symfony's [PSR-7 Bridge](https://symfony.com/doc/current/components/psr7.html) and [Tobias Nyholm](https://github.com/Nyholm)'s [PSR-7 implementation](https://github.com/Nyholm/psr7), before passing them on to the OpenAPI PSR-7 Message Validators.
2525

2626
## Install
2727

@@ -35,6 +35,7 @@ $ composer require --dev osteel/openapi-httpfoundation-testing
3535

3636
## Usage
3737

38+
### Response Validation
3839
First, import the builder in the class that will perform the validation:
3940

4041
```php
@@ -71,6 +72,35 @@ $validator->post('/users', $response);
7172

7273
The `validate` method returns `true` in case of success, and throws `\Osteel\OpenApi\Testing\Exceptions\ValidationException` exceptions in case of error.
7374

75+
### Request Validation
76+
The procedure is the same as described in the [Response Validation](https://github.com/osteel/openapi-httpfoundation-testing#response-validation) section. Import the builder first:
77+
78+
```php
79+
use Osteel\OpenApi\Testing\RequestValidatorBuilder;
80+
```
81+
82+
and use it to create a `\Osteel\OpenApi\Testing\RequestValidator` object, feeding it a YAML or JSON OpenAPI definition:
83+
84+
```php
85+
$validator = RequestValidatorBuilder::fromYaml('my-definition.yaml')->getValidator();
86+
87+
// or
88+
89+
$validator = RequestValidatorBuilder::fromJson('my-definition.json')->getValidator();
90+
```
91+
92+
💡 _Instead of a file, you can also pass a YAML or JSON string directly._
93+
94+
You can now validate a `\Symfony\Component\HttpFoundation\Request` object:
95+
96+
```php
97+
$validator->validate($request);
98+
```
99+
100+
💡 _For convenience, requests implementing `\Psr\Http\Message\ServerRequestInterface` are also accepted._
101+
102+
The `validate` method returns `true` in case of success, and throws `\Osteel\OpenApi\Testing\Exceptions\ValidationException` exceptions in case of error.
103+
74104
## Change log
75105

76106
Please see the [Releases section](../../releases) for more information on what has changed recently.
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Osteel\OpenApi\Testing\HttpFoundation;
6+
7+
use InvalidArgumentException;
8+
use Nyholm\Psr7\Factory\Psr17Factory;
9+
use Osteel\OpenApi\Testing\RequestAdapter;
10+
use Psr\Http\Message\ServerRequestInterface;
11+
use Symfony\Bridge\PsrHttpMessage\Factory\PsrHttpFactory;
12+
use Symfony\Component\HttpFoundation\Request;
13+
14+
class HttpFoundationRequestAdapter implements RequestAdapter
15+
{
16+
/**
17+
* {@inheritDoc}
18+
*
19+
* @param Request|ServerRequestInterface $request The request object to convert.
20+
* @return ServerRequestInterface
21+
* @throws InvalidArgumentException
22+
*/
23+
public function convert(object $request): ServerRequestInterface
24+
{
25+
if ($request instanceof ServerRequestInterface) {
26+
return $request;
27+
}
28+
29+
if ($request instanceof Request) {
30+
$psr17Factory = new Psr17Factory();
31+
$psrHttpFactory = new PsrHttpFactory($psr17Factory, $psr17Factory, $psr17Factory, $psr17Factory);
32+
33+
return $psrHttpFactory->createRequest($request);
34+
}
35+
36+
throw new InvalidArgumentException(sprintf(
37+
'Can only validate requests of type %s or %s; %s received',
38+
Request::class,
39+
ServerRequestInterface::class,
40+
get_class($request)
41+
));
42+
}
43+
}

src/RequestAdapter.php

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Osteel\OpenApi\Testing;
6+
7+
use Psr\Http\Message\ServerRequestInterface;
8+
9+
interface RequestAdapter
10+
{
11+
/**
12+
* Convert a request to a PSR-7 HTTP message.
13+
*
14+
* @param object $request The request object to convert.
15+
* @return ServerRequestInterface
16+
*/
17+
public function convert(object $request): ServerRequestInterface;
18+
}

src/RequestValidator.php

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Osteel\OpenApi\Testing;
6+
7+
use League\OpenAPIValidation\PSR7\Exception\ValidationFailed;
8+
use League\OpenAPIValidation\PSR7\RequestValidator as BaseRequestValidator;
9+
use Osteel\OpenApi\Testing\Exceptions\ValidationException;
10+
11+
/**
12+
* This class is a wrapper for League\OpenAPIValidation\PSR7\RequestValidator objects,
13+
* providing an interface to validate HTTP requests against an OpenAPI definition.
14+
*/
15+
final class RequestValidator
16+
{
17+
/**
18+
* @var \League\OpenAPIValidation\PSR7\RequestValidator
19+
*/
20+
private $validator;
21+
22+
/**
23+
* @var RequestAdapter
24+
*/
25+
private $adapter;
26+
27+
/**
28+
* Constructor.
29+
*
30+
* @param \League\OpenAPIValidation\PSR7\RequestValidator $validator
31+
* @param RequestAdapter $adapter
32+
* @return void
33+
*/
34+
public function __construct(BaseRequestValidator $validator, RequestAdapter $adapter)
35+
{
36+
$this->validator = $validator;
37+
$this->adapter = $adapter;
38+
}
39+
40+
/**
41+
* Validate a request against the specified OpenAPI definition.
42+
*
43+
* @param object $request The request object to validate.
44+
*
45+
* @return bool
46+
* @throws ValidationException
47+
*/
48+
public function validate(object $request): bool
49+
{
50+
try {
51+
$this->validator->validate($this->adapter->convert($request));
52+
} catch (ValidationFailed $exception) {
53+
throw ValidationException::fromValidationFailed($exception);
54+
}
55+
56+
return true;
57+
}
58+
}

src/RequestValidatorBuilder.php

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Osteel\OpenApi\Testing;
6+
7+
use InvalidArgumentException;
8+
use League\OpenAPIValidation\PSR7\ValidatorBuilder;
9+
use Osteel\OpenApi\Testing\HttpFoundation\HttpFoundationRequestAdapter;
10+
11+
/**
12+
* This class creates RequestValidator objects based on OpenAPI definitions.
13+
*/
14+
class RequestValidatorBuilder
15+
{
16+
/**
17+
* @var ValidatorBuilder
18+
*/
19+
protected $validatorBuilder;
20+
21+
/**
22+
* @var string
23+
*/
24+
protected $adapter = HttpFoundationRequestAdapter::class;
25+
26+
/**
27+
* Constructor.
28+
*
29+
* @param ValidatorBuilder $builder
30+
* @return void
31+
*/
32+
public function __construct(ValidatorBuilder $builder)
33+
{
34+
$this->validatorBuilder = $builder;
35+
}
36+
37+
/**
38+
* Create a RequestValidator object based on a YAML OpenAPI definition.
39+
*
40+
* @param string $definition The OpenAPI definition.
41+
* @return RequestValidatorBuilder
42+
*/
43+
public static function fromYaml(string $definition): RequestValidatorBuilder
44+
{
45+
$method = is_file($definition) ? 'fromYamlFile' : 'fromYaml';
46+
47+
return self::fromMethod($method, $definition);
48+
}
49+
50+
/**
51+
* Create a RequestValidator object based on a JSON OpenAPI definition.
52+
*
53+
* @param string $definition The OpenAPI definition.
54+
* @return RequestValidatorBuilder
55+
*/
56+
public static function fromJson(string $definition): RequestValidatorBuilder
57+
{
58+
$method = is_file($definition) ? 'fromJsonFile' : 'fromJson';
59+
60+
return self::fromMethod($method, $definition);
61+
}
62+
63+
/**
64+
* Create a RequestValidator object based on an OpenAPI definition.
65+
*
66+
* @param string $method The ValidatorBuilder object's method to use.
67+
* @param string $definition The OpenAPI definition.
68+
* @return RequestValidatorBuilder
69+
*/
70+
private static function fromMethod(string $method, string $definition): RequestValidatorBuilder
71+
{
72+
$builder = (new ValidatorBuilder())->$method($definition);
73+
74+
return new RequestValidatorBuilder($builder);
75+
}
76+
77+
/**
78+
* Return the RequestValidator object.
79+
*
80+
* @return RequestValidator
81+
*/
82+
public function getValidator(): RequestValidator
83+
{
84+
return new RequestValidator($this->validatorBuilder->getRequestValidator(), new $this->adapter());
85+
}
86+
87+
/**
88+
* Change the request adapter to be used. The provided class must
89+
* implement the \Osteel\OpenApi\Testing\RequestAdapter interface.
90+
*
91+
* @param string $class The adapter's class.
92+
* @return self
93+
* @throws InvalidArgumentException
94+
*/
95+
public function setAdapter(string $class): self
96+
{
97+
if (! is_subclass_of($class, RequestAdapter::class)) {
98+
throw new InvalidArgumentException(sprintf(
99+
'Class %s does not implement the %s interface',
100+
$class,
101+
RequestAdapter::class
102+
));
103+
}
104+
105+
$this->adapter = $class;
106+
107+
return $this;
108+
}
109+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
<?php
2+
3+
namespace Osteel\OpenApi\Testing\Tests\HttpFoundation;
4+
5+
use InvalidArgumentException;
6+
use Osteel\OpenApi\Testing\HttpFoundation\HttpFoundationRequestAdapter;
7+
use Osteel\OpenApi\Testing\Tests\TestCase;
8+
use Psr\Http\Message\ServerRequestInterface;
9+
use Symfony\Component\HttpFoundation\Request;
10+
11+
class HttpFoundationRequestAdapterTest extends TestCase
12+
{
13+
/**
14+
* @var HttpFoundationRequestAdapter
15+
*/
16+
private $sut;
17+
18+
protected function setUp(): void
19+
{
20+
parent::setUp();
21+
22+
$this->sut = new HttpFoundationRequestAdapter();
23+
}
24+
25+
public function testItDoesNotConvertTheRequestBecauseItsTypeIsNotSupported()
26+
{
27+
$this->expectException(InvalidArgumentException::class);
28+
$this->expectExceptionMessage(sprintf(
29+
'Can only validate requests of type %s or %s; InvalidArgumentException received',
30+
Request::class,
31+
ServerRequestInterface::class
32+
));
33+
34+
$this->sut->convert(new InvalidArgumentException());
35+
}
36+
37+
public function testItConvertsTheHttpFoundationRequest()
38+
{
39+
$result = $this->sut->convert(Request::create('/foo'));
40+
41+
$this->assertInstanceOf(ServerRequestInterface::class, $result);
42+
}
43+
44+
public function testItLeavesTheRequestInterfaceUntouched()
45+
{
46+
$request = $this->createMock(ServerRequestInterface::class);
47+
$result = $this->sut->convert($request);
48+
49+
$this->assertEquals($request, $result);
50+
}
51+
}

0 commit comments

Comments
 (0)