diff --git a/src/Middleware.php b/src/Middleware.php index 9a85d6c..96013d5 100644 --- a/src/Middleware.php +++ b/src/Middleware.php @@ -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; @@ -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(); @@ -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; @@ -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); diff --git a/src/Validation/RequestValidator.php b/src/Validation/RequestValidator.php index b9e2471..3b3417a 100644 --- a/src/Validation/RequestValidator.php +++ b/src/Validation/RequestValidator.php @@ -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; @@ -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(); } @@ -30,7 +34,7 @@ protected function handle() { $this->validateParameters(); - if ($this->operation->requestBody !== null) { + if ($this->operation()->requestBody !== null) { $this->validateBody(); } } @@ -38,9 +42,10 @@ protected function handle() 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. @@ -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)) { @@ -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}; + } } diff --git a/tests/Fixtures/Test.v1.json b/tests/Fixtures/Test.v1.json index 7d370c4..8de8143 100644 --- a/tests/Fixtures/Test.v1.json +++ b/tests/Fixtures/Test.v1.json @@ -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": {} } -} +} \ No newline at end of file diff --git a/tests/RequestValidatorTest.php b/tests/RequestValidatorTest.php index 8b4cc96..d4e5f45 100644 --- a/tests/RequestValidatorTest.php +++ b/tests/RequestValidatorTest.php @@ -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; @@ -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' => 'test@test.test', + ]; + })->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 +{ }