Skip to content

Commit

Permalink
Path parameters (#52)
Browse files Browse the repository at this point in the history
Fix parsing and matching of path parameters
  • Loading branch information
hotmeteor authored May 18, 2021
1 parent df449f2 commit 3e0614d
Show file tree
Hide file tree
Showing 4 changed files with 228 additions and 16 deletions.
15 changes: 8 additions & 7 deletions src/Middleware.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@

use cebe\openapi\exceptions\TypeErrorException;
use cebe\openapi\exceptions\UnresolvableReferenceException;
use cebe\openapi\spec\Operation;
use cebe\openapi\spec\PathItem;
use Closure;
use Illuminate\Contracts\Debug\ExceptionHandler;
use Illuminate\Http\JsonResponse;
Expand Down Expand Up @@ -93,13 +93,13 @@ protected function validate(Request $request, Closure $next)
{
$request_path = $request->route()->uri();

$operation = $this->operation($request_path, $request->method());
$pathItem = $this->pathItem($request_path, $request->method());

RequestValidator::validate($request, $operation);
RequestValidator::validate($request, $pathItem, $request->method());

$response = $next($request);

ResponseValidator::validate($request_path, $response, $operation, $this->version);
ResponseValidator::validate($request_path, $response, $pathItem->{strtolower($request->method())}, $this->version);

$this->spectator->reset();

Expand All @@ -109,11 +109,11 @@ protected function validate(Request $request, Closure $next)
/**
* @param $request_path
* @param $request_method
* @return Operation
* @return PathItem
* @throws InvalidPathException
* @throws MissingSpecException
*/
protected function operation($request_path, $request_method): Operation
protected function pathItem($request_path, $request_method): PathItem
{
if (! Str::startsWith($request_path, '/')) {
$request_path = '/'.$request_path;
Expand All @@ -127,8 +127,9 @@ protected function operation($request_path, $request_method): Operation
if ($this->resolvePath($path) === $request_path) {
$methods = array_keys($pathItem->getOperations());

// Check if the method exists for this path, and if so return the full PathItem
if (in_array(strtolower($request_method), $methods, true)) {
return $pathItem->getOperations()[strtolower($request_method)];
return $pathItem;
}

throw new InvalidPathException("[{$request_method}] not a valid method for [{$request_path}].", 405);
Expand Down
26 changes: 18 additions & 8 deletions src/Validation/RequestValidator.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
namespace Spectator\Validation;

use cebe\openapi\spec\Operation;
use cebe\openapi\spec\PathItem;
use Illuminate\Http\Request;
use Opis\JsonSchema\Validator;
use Spectator\Exceptions\RequestValidationException;
Expand All @@ -11,17 +12,20 @@ class RequestValidator
{
protected $request;

protected $operation;
protected $pathItem;

public function __construct(Request $request, Operation $operation)
protected $method;

public function __construct(Request $request, PathItem $pathItem, $method)
{
$this->request = $request;
$this->operation = $operation;
$this->pathItem = $pathItem;
$this->method = strtolower($method);
}

public static function validate(Request $request, Operation $operation)
public static function validate(Request $request, PathItem $pathItem, $method)
{
$instance = new self($request, $operation);
$instance = new self($request, $pathItem, $method);

$instance->handle();
}
Expand All @@ -30,17 +34,18 @@ protected function handle()
{
$this->validateParameters();

if ($this->operation->requestBody !== null) {
if ($this->operation()->requestBody !== null) {
$this->validateBody();
}
}

protected function validateParameters()
{
$route = $this->request->route();
$parameters = $this->operation->parameters;
$parameters = $this->pathItem->parameters;

foreach ($parameters as $parameter) {

// Verify presence, if required.
if ($parameter->required === true) {
// Parameters can be found in query, header, path or cookie.
Expand Down Expand Up @@ -88,7 +93,7 @@ protected function validateBody()
{
$contentType = $this->request->header('Content-Type');
$body = $this->request->getContent();
$requestBody = $this->operation->requestBody;
$requestBody = $this->operation()->requestBody;

if ($requestBody->required === true) {
if (empty($body)) {
Expand Down Expand Up @@ -121,4 +126,9 @@ protected function validateBody()
throw RequestValidationException::withError('Request body did not match provided JSON schema.', $result->error());
}
}

protected function operation(): Operation
{
return $this->pathItem->{$this->method};
}
}
126 changes: 125 additions & 1 deletion tests/Fixtures/Test.v1.json
Original file line number Diff line number Diff line change
Expand Up @@ -156,9 +156,133 @@
},
"operationId": "path-with-prefix"
}
},
"/users/{user}": {
"parameters": [
{
"schema": {
"type": "string"
},
"name": "user",
"in": "path",
"required": true
}
],
"get": {
"summary": "Get a single user",
"tags": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"name": {
"type": "string"
},
"email": {
"type": "string"
}
}
}
}
}
}
},
"operationId": "get-users-user"
}
},
"/posts/{postUuid}": {
"parameters": [
{
"schema": {
"type": "string",
"format": "uuid"
},
"name": "postUuid",
"in": "path",
"required": true
}
],
"get": {
"summary": "Get a post by UUID",
"tags": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"title": {
"type": "string"
}
}
}
}
}
}
},
"operationId": "get-posts-postUuid"
}
},
"/posts/{postUuid}/comments/{comment}": {
"parameters": [
{
"schema": {
"type": "string",
"format": "uuid"
},
"name": "postUuid",
"in": "path",
"required": true
},
{
"schema": {
"type": "string"
},
"name": "comment",
"in": "path",
"required": true
}
],
"get": {
"summary": "Get a comment for a post",
"tags": [],
"responses": {
"200": {
"description": "OK",
"content": {
"application/json": {
"schema": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"message": {
"type": "string"
}
}
}
}
}
}
},
"operationId": "get-posts-post-comment"
}
}
},
"components": {
"schemas": {}
}
}
}
77 changes: 77 additions & 0 deletions tests/RequestValidatorTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@

namespace Spectator\Tests;

use Illuminate\Database\Eloquent\Model;
use Illuminate\Support\Facades\Config;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Str;
use Spectator\Middleware;
use Spectator\Spectator;
use Spectator\SpectatorServiceProvider;
Expand Down Expand Up @@ -74,4 +76,79 @@ public function test_resolves_prefixed_path_from_inline_setting()
->assertValidRequest()
->assertValidResponse();
}

public function test_resolve_route_model_binding()
{
Spectator::using('Test.v1.json');

Route::get('/users/{user}', function () {
return [
'id' => 1,
'name' => 'Jim',
'email' => '[email protected]',
];
})->middleware(Middleware::class);

$this->getJson('/users/1')
->assertValidRequest()
->assertValidResponse();
}

public function test_resolve_route_model_explicit_binding()
{
Spectator::using('Test.v1.json');

Route::bind('postUuid', TestUser::class);

Route::get('/posts/{postUuid}', function () {
return [
'id' => 1,
'title' => 'My Post',
];
})->middleware(Middleware::class);

$this->getJson('/posts/'.Str::uuid()->toString())
->assertValidRequest()
->assertValidResponse();
}

public function test_cannot_resolve_route_model_explicit_binding_with_invalid_format()
{
Spectator::using('Test.v1.json');

Route::bind('postUuid', TestUser::class);

Route::get('/posts/{postUuid}', function () {
return [
'id' => 1,
'title' => 'My Post',
];
})->middleware(Middleware::class);

$this->getJson('/posts/invalid')
->assertInvalidRequest()
->assertValidResponse(400);
}

public function test_resolve_route_model_binding_with_multiple_parameters()
{
Spectator::using('Test.v1.json');

Route::bind('postUuid', TestUser::class);

Route::get('/posts/{postUuid}/comments/{comment}', function () {
return [
'id' => 1,
'message' => 'My Comment',
];
})->middleware(Middleware::class);

$this->getJson('/posts/'.Str::uuid()->toString().'/comments/1')
->assertValidRequest()
->assertValidResponse();
}
}

class TestUser extends Model
{
}

0 comments on commit 3e0614d

Please sign in to comment.