Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Polymorphics #59

Merged
merged 14 commits into from
Oct 6, 2021
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ composer.lock
/assets/
/phpunit.xml.bak
.idea
.prettierrc.yaml
62 changes: 48 additions & 14 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<p align="center"><img src="https://spectator.s3.us-east-2.amazonaws.com/spectator-logo.png" width="300"></p>

# Spectator

Spectator provides light-weight OpenAPI testing tools you can use within your existing Laravel test suite.

Write tests that verify your API spec doesn't drift from your implementation.
Expand All @@ -27,11 +28,12 @@ The config file will be published in `config/spectator.php`.

### Sources

**Sources** are references to where your API spec lives. Depending on the way you or your team works, or where your spec lives, you may want to configure different sources for different environments.
**Sources** are references to where your API spec lives. Depending on the way you or your team works, or where your spec lives, you may want to configure different sources for different environments.

As you can see from the config, there's three source types available: `local`, `remote`, and `github`. Each source requires the folder where your spec lives to be defined, not the spec file itself. This provides flexibility when working with multiple APIs in one project, or an API fragmented across multiple spec files.

---

#### Local Example

```env
Expand All @@ -40,39 +42,49 @@ As you can see from the config, there's three source types available: `local`, `
SPEC_SOURCE=local
SPEC_PATH=/spec/reference
```

---

#### Remote Example
_This is using the raw access link from Github, but any remote source can be specified. The SPEC_URL_PARAMS can be used to append any additional parameters required for the remote url._

_This is using the raw access link from Github, but any remote source can be specified. The SPEC_URL_PARAMS can be used to append any additional parameters required for the remote url._

```env
## Spectator config

SPEC_PATH="https://raw.githubusercontent.com/path/to/repo"
SPEC_URL_PARAMS="?token=ABEDC3E5AQ3HMUBPPCDTTMDAFPMSM"
```

---

#### Github Example

_This uses the Github Personal Access Token which allows you access to a remote repo containing your contract._

You can view instructions on how to obtain your Personal Access Token from Github at [this link](https://docs.github.com/en/github/authenticating-to-github/creating-a-personal-access-token) .

**Important to note than the SPEC_GITHUB_PATH must included the branch (ex: main) and then the path to the directory containing your contract.**


```env
## Spectator config

SPEC_GITHUB_PATH='main/contracts'
SPEC_GITHUB_PATH='main/contracts'
SPEC_GITHUB_REPO='orgOruser/repo'
SPEC_GITHUB_TOKEN='your personal access token'
```

---

#### Specifying Your File In Your Tests

In your tests you will declare the spec file you want to test against:

```php
public function testBasicExample()
{
Spectator::using('Api.v1.json');

// ...
```

Expand All @@ -82,13 +94,14 @@ public function testBasicExample()

**Now, on to the good stuff.**

At first, spec testing, or contract testing, may seem counter-intuitive, especially when compared with "feature" or "functional" testing as supported by Laravel's [HTTP Tests](https://laravel.com/docs/8.x/http-tests). While functional tests are ensuring that your request validation, controller behavior, events, responses, etc. all behave the way you expect when people interact with your API, contract tests are ensuring that **requests and responses are spec-compliant**, and that's it.
At first, spec testing, or contract testing, may seem counter-intuitive, especially when compared with "feature" or "functional" testing as supported by Laravel's [HTTP Tests](https://laravel.com/docs/8.x/http-tests). While functional tests are ensuring that your request validation, controller behavior, events, responses, etc. all behave the way you expect when people interact with your API, contract tests are ensuring that **requests and responses are spec-compliant**, and that's it.

### Writing Tests

Spectator adds a few simple tools to the existing Laravel testing toolbox.

Here's an example of a typical JSON API test:

```php
<?php

Expand All @@ -111,7 +124,9 @@ class ExampleTest extends TestCase
}
}
```

And here's an example of a contract test:

```php
<?php

Expand All @@ -136,13 +151,15 @@ class ExampleTest extends TestCase
}
}
```

The test is verifying that both the request and the response are valid according to the spec, in this case located in `Api.v1.json`. This type of testing promotes TDD: you can write endpoint contract tests against your endpoints _first_, and then ensure your spec and implementation are aligned.
Within your spec, each possible response should be documented. For example, a single `POST` endpoint may result in a `2xx`, `4xx`, or even `5xx` code response. Additionally, your endpoints will likely have particular parameter validation that needs to be adhered to. This is what makes contract testing different from functional testing: in functional testing, successful and failed responses are tested for outcomes; in contract testing, requests and responses are tested for conformity and outcomes don't matter.

Within your spec, each possible response should be documented. For example, a single `POST` endpoint may result in a `2xx`, `4xx`, or even `5xx` code response. Additionally, your endpoints will likely have particular parameter validation that needs to be adhered to. This is what makes contract testing different from functional testing: in functional testing, successful and failed responses are tested for outcomes; in contract testing, requests and responses are tested for conformity and outcomes don't matter.

## Usage

Define the spec file to test against. This can be defined in your `setUp()` method or in a specific test method.

```php
<?php

Expand All @@ -152,34 +169,37 @@ class ExampleTest extends TestCase
{
public function setUp(): void
{
parent::setUp();
parent::setUp();

Spectator::using('Api.v1.json');
}

public function testApiEndpoint()
{
{
// Test request and response...
}

public function testDifferentApiEndpoint()
{
Spectator::using('Other.v1.json');

// Test request and response...
}
}
```

When testing endpoints, there are a few new methods:

```php
$this->assertValidRequest();
$this->assertValidResponse($status = null);
$this->assertValidationMessage('Expected validation message');
$this->assertErrorsContain('Check for single error');
$this->assertErrorsContain(['Check for', 'Multiple Errors']);
```

Of course, you can continue to use all existing HTTP test methods:

```php
$this
->actingAs($user)
Expand All @@ -190,9 +210,11 @@ $this
->assertValidRequest()
->assertValidResponse();
```

That said, mixing functional and contract testing may become more difficult to manage and read later.

Instead of using the built-in `->assertStatus($status)` method, you may also verify the response that is valid is actually the response you want to check. For example, you may receive a `200` **or** a `202` from a single endpoint, and you want to ensure you're validating the correct response.

```php
$this
->actingAs($user)
Expand Down Expand Up @@ -239,13 +261,25 @@ class ExampleTestCase
}
```

## Core Concepts

### Approach

Spectator works by registering a custom middleware that performs request and response validation against a spec.

### Dependencies

For those interested in contributing to Spectator, it is worth familiarizing yourself with the core dependencies used for spec testing:

- `cebe/php-openapi`: Used to parse specs into usable arrays
- `opis/json-schema`: Used to perform validation of an object/array against a spec

## Credits

- [Adam Campbell](https://github.com/hotmeteor)
- Inspired by [Laravel OpenAPI](https://github.com/mdwheele/laravel-openapi) package by [Dustin Wheeler](https://github.com/mdwheele)
- [All Contributors](../../contributors)


## License

The MIT License (MIT). Please see [License File](LICENSE.md) for more information.
2 changes: 1 addition & 1 deletion src/Assertions.php
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,7 @@ public function assertValidationMessage()
return $this->runAssertion(function () use ($expected) {
$actual = $this->decodeExceptionMessage((array) $this->json());

PHPUnit::assertSame(
PHPUnit::assertStringContainsString(
$expected,
$actual,
'The expected error did not match the actual error.'
Expand Down
23 changes: 19 additions & 4 deletions src/Middleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -21,12 +21,18 @@

class Middleware
{
protected $exceptionHandler;
protected ExceptionHandler $exceptionHandler;

protected $spectator;
protected RequestFactory $spectator;

protected $version = '3.0';
protected string $version = '3.0';

/**
* Middleware constructor.
*
* @param RequestFactory $spectator
* @param ExceptionHandler $exceptionHandler
*/
public function __construct(RequestFactory $spectator, ExceptionHandler $exceptionHandler)
{
$this->spectator = $spectator;
Expand All @@ -37,6 +43,11 @@ public function __construct(RequestFactory $spectator, ExceptionHandler $excepti
* @param Request $request
* @param Closure $next
* @return JsonResponse|Request
*
* @throws InvalidPathException
* @throws MissingSpecException
* @throws RequestValidationException
* @throws \Throwable
*/
public function handle(Request $request, Closure $next)
{
Expand Down Expand Up @@ -88,7 +99,7 @@ protected function formatResponse($exception, $code): JsonResponse
* @return mixed
*
* @throws InvalidPathException
* @throws MissingSpecException
* @throws MissingSpecException|RequestValidationException|ResponseValidationException
*/
protected function validate(Request $request, Closure $next)
{
Expand All @@ -114,6 +125,10 @@ protected function validate(Request $request, Closure $next)
*
* @throws InvalidPathException
* @throws MissingSpecException
* @throws TypeErrorException
* @throws UnresolvableReferenceException
* @throws \cebe\openapi\exceptions\IOException
* @throws \cebe\openapi\json\InvalidJsonPointerSyntaxException
*/
protected function pathItem($request_path, $request_method): PathItem
{
Expand Down
6 changes: 3 additions & 3 deletions src/RequestFactory.php
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,9 @@ class RequestFactory
*/
use Macroable;

protected $specName;
protected ?string $specName = null;

protected $pathPrefix;
protected ?string $pathPrefix = null;

/**
* Set the file name of the spec.
Expand Down Expand Up @@ -184,7 +184,7 @@ protected function getGithubPath(array $source, $file)
/**
* Standardize a file name.
*
* @param $path
* @param $file
* @return string
*/
protected function standardizeFileName($file)
Expand Down
19 changes: 13 additions & 6 deletions src/Validation/AbstractValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,21 +2,24 @@

namespace Spectator\Validation;

use cebe\openapi\spec\Schema;
use Illuminate\Support\Arr;
use Illuminate\Support\Str;

abstract class AbstractValidator
{
protected $version;
protected string $version;

/**
* Check if properties exist, and if so, prepare them based on version.
*
* @param $data
* @param Schema $schema
* @return mixed
*/
protected function prepareData($data)
protected function prepareData(Schema $schema)
{
$data = $schema->getSerializableData();

if (! isset($data->properties)) {
return $data;
}
Expand All @@ -33,9 +36,8 @@ protected function prepareData($data)
}

/**
* Returns an associate array mapping "objects" to "properties" for the purposes of spec testing.
* All nullable properties are resolved. When this function finishes, you should have a
* structure with the following format:.
* Return an associative array mapping "objects" to "properties" for the purposes of spec testing.
* When this function finishes, you should have a structure with the following format:.
*
* [
* "Pet" => "{ resolved properties of a pet }"
Expand Down Expand Up @@ -64,6 +66,11 @@ protected function wrapAttributesToArray($properties)
unset($attributes->nullable);
}

// Before we check recursive cases, make sure this object defines a "type".
if (! isset($attributes->type)) {
break;
}

// This object has a sub-object, recurse...
if ($attributes->type === 'object' && isset($attributes->properties)) {
$attributes->properties = $this->wrapAttributesToArray($attributes->properties);
Expand Down
Loading