diff --git a/README.md b/README.md index cd37f63..3c9388b 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,6 @@ [![Installs](https://img.shields.io/packagist/dt/phpro/api-problem.svg)](https://packagist.org/packages/phpro/api-problem/stats) [![Packagist](https://img.shields.io/packagist/v/phpro/api-problem.svg)](https://packagist.org/packages/phpro/api-problem) - # Api Problem This package provides a [RFC7807](https://tools.ietf.org/html/rfc7807) Problem details implementation. @@ -13,7 +12,6 @@ Since handling the exceptions is up to the framework, here is a list of known fr - **Symfony** `^4.1`: [ApiProblemBundle](https://www.github.com/phpro/api-problem-bundle) - ## Installation ```sh @@ -34,14 +32,26 @@ throw new ApiProblemException( ### Built-in problems -- [ExceptionApiProblem](#exceptionapiproblem) -- [ForbiddenProblem](#forbiddenproblem) -- [HttpApiProblem](#httpapiproblem) -- [NotFoundProblem](#notfoundproblem) -- [UnauthorizedProblem](#unauthorizedproblem) -- [ValidationApiProblem](#validationapiproblem) -- [BadRequestProblem](#badrequestproblem) -- [ConflictProblem](#conflictproblem) +- General problems + - [ExceptionApiProblem](#exceptionapiproblem) + - [HttpApiProblem](#httpapiproblem) + +- Symfony integration problems + - [ValidationApiProblem](#validationapiproblem) + +- Http problems + - 400 [BadRequestProblem](#badrequestproblem) + - 401 [UnauthorizedProblem](#unauthorizedproblem) + - 403 [ForbiddenProblem](#forbiddenproblem) + - 404 [NotFoundProblem](#notfoundproblem) + - 405 [MethodNotAllowedProblem](#methodnotallowedproblem) + - 409 [ConflictProblem](#conflictproblem) + - 412 [PreconditionFailedProblem](#preconditionfailedproblem) + - 415 [UnsupportedMediaTypeProblem](#unsupportedmediatypeproblem) + - 418 [IAmATeapotProblem](#iamateapotproblem) + - 422 [UnprocessableEntityProblem](#unprocessableentityproblem) + - 423 [LockedProblem](#lockedproblem) + - 428 [PreconditionRequiredProblem](#preconditionrequiredproblem) #### ExceptionApiProblem @@ -90,54 +100,69 @@ new ExceptionApiProblem(new \Exception('message', 500)); } ```` -#### ForbiddenProblem +#### HttpApiProblem ```php -use Phpro\ApiProblem\Http\ForbiddenProblem; +use Phpro\ApiProblem\Http\HttpApiProblem; -new ForbiddenProblem('Not authorized to access gold.'); +new HttpApiProblem(404, ['detail' => 'The book could not be found.']); ``` ```json { - "status": 403, - "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", - "title": "Forbidden", - "detail": "Not authorized to access gold." + "status": 404, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Not found", + "detail": "The book could not be found." } ```` -#### HttpApiProblem +#### ValidationApiProblem + +```sh +composer require symfony/validator:^4.1 +``` ```php -use Phpro\ApiProblem\Http\HttpApiProblem; +use Phpro\ApiProblem\Http\ValidationApiProblem; +use Symfony\Component\Validator\ConstraintViolation; +use Symfony\Component\Validator\ConstraintViolationList; -new HttpApiProblem(404, ['detail' => 'The book could not be found.']); +new ValidationApiProblem(new ConstraintViolationList([ + new ConstraintViolation('Invalid email', '', [], '', 'email', '', null, '8615ecd9-afcb-479a-9c78-8bcfe260cf2a'), +])); ``` ```json { - "status": 404, - "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", - "title": "Not found", - "detail": "The book could not be found." + "status": 400, + "type": "https:\/\/symfony.com\/errors\/validation", + "title": "Validation Failed", + "detail": "email: Invalid Email", + "violations": [ + { + "propertyPath": "email", + "title": "Invalid email", + "type": "urn:uuid:8615ecd9-afcb-479a-9c78-8bcfe260cf2a" + } + ] } ```` -#### NotFoundProblem +#### BadRequestProblem ```php -use Phpro\ApiProblem\Http\NotFoundProblem; +use Phpro\ApiProblem\Http\BadRequestProblem; -new NotFoundProblem('The book with ID 20 could not be found.'); +new BadRequestProblem('Bad request. Bad!.'); ``` ```json { - "status": 404, + "status": 400, "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", - "title": "Not found", - "detail": "The book with ID 20 could not be found." + "title": "Bad Request", + "detail": "Bad request. Bad!" } ```` @@ -158,52 +183,54 @@ new UnauthorizedProblem('You are not authorized to access X.'); } ```` -#### ValidationApiProblem +#### ForbiddenProblem -```sh -composer require symfony/validator:^4.1 +```php +use Phpro\ApiProblem\Http\ForbiddenProblem; + +new ForbiddenProblem('Not authorized to access gold.'); ``` +```json +{ + "status": 403, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Forbidden", + "detail": "Not authorized to access gold." +} +```` + +#### NotFoundProblem + ```php -use Phpro\ApiProblem\Http\ValidationApiProblem; -use Symfony\Component\Validator\ConstraintViolation; -use Symfony\Component\Validator\ConstraintViolationList; +use Phpro\ApiProblem\Http\NotFoundProblem; -new ValidationApiProblem(new ConstraintViolationList([ - new ConstraintViolation('Invalid email', '', [], '', 'email', '', null, '8615ecd9-afcb-479a-9c78-8bcfe260cf2a'), -])); +new NotFoundProblem('The book with ID 20 could not be found.'); ``` ```json { - "status": 400, - "type": "https:\/\/symfony.com\/errors\/validation", - "title": "Validation Failed", - "detail": "email: Invalid Email", - "violations": [ - { - "propertyPath": "email", - "title": "Invalid email", - "type": "urn:uuid:8615ecd9-afcb-479a-9c78-8bcfe260cf2a" - } - ] + "status": 404, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Not found", + "detail": "The book with ID 20 could not be found." } ```` -#### BadRequestProblem +#### MethodNotAllowedProblem ```php -use Phpro\ApiProblem\Http\BadRequestProblem; +use Phpro\ApiProblem\Http\MethodNotAllowedProblem; -new BadRequestProblem('Bad request. Bad!.'); +new MethodNotAllowedProblem('Only POST and GET allowed.'); ``` ```json { - "status": 400, + "status": 405, "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", - "title": "Bad Request", - "detail": "Bad request. Bad!" + "title": "Method Not Allowed", + "detail": "Only POST and GET allowed." } ```` @@ -223,6 +250,108 @@ new ConflictProblem('Duplicated key for book with ID 20.'); } ```` +#### PreconditionFailedProblem + +```php +use Phpro\ApiProblem\Http\PreconditionFailedProblem; + +new PreconditionFailedProblem('Incorrect entity tag provided.'); +``` + +```json +{ + "status": 412, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Precondition Failed", + "detail": "Incorrect entity tag provided." +} +```` + +#### UnsupportedMediaTypeProblem + +```php +use Phpro\ApiProblem\Http\UnsupportedMediaTypeProblem; + +new UnsupportedMediaTypeProblem('Please provide valid JSON.'); +``` + +```json +{ + "status": 415, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Unsupported Media Type", + "detail": "Please provide valid JSON." +} +```` + +#### IAmATeapotProblem + +```php +use Phpro\ApiProblem\Http\IAmATeapotProblem; + +new IAmATeapotProblem('More tea please.'); +``` + +```json +{ + "status": 418, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "I'm a teapot", + "detail": "More tea please." +} +```` + +#### UnprocessableEntityProblem + +```php +use Phpro\ApiProblem\Http\UnprocessableEntityProblem; + +new UnprocessableEntityProblem('Unable to process the contained instructions.'); +``` + +```json +{ + "status": 422, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Unprocessable Entity", + "detail": "Unable to process the contained instructions." +} +```` + +#### LockedProblem + +```php +use Phpro\ApiProblem\Http\LockedProblem; + +new LockedProblem('This door is locked.'); +``` + +```json +{ + "status": 423, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Locked", + "detail": "This door is locked." +} +```` + +#### PreconditionRequiredProblem + +```php +use Phpro\ApiProblem\Http\PreconditionRequiredProblem; + +new PreconditionRequiredProblem('If-match header is required.'); +``` + +```json +{ + "status": 428, + "type": "http:\/\/www.w3.org\/Protocols\/rfc2616\/rfc2616-sec10.html", + "title": "Precondition Required", + "detail": "If-match header is required." +} +```` + ### Creating your own problem Creating problem sounds scary right!? diff --git a/spec/Http/IAmATeapotProblemSpec.php b/spec/Http/IAmATeapotProblemSpec.php new file mode 100644 index 0000000..99fd4aa --- /dev/null +++ b/spec/Http/IAmATeapotProblemSpec.php @@ -0,0 +1,37 @@ +beConstructedWith('i am a teapot'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(IAmATeapotProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 418, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(418), + 'detail' => 'i am a teapot', + ]); + } +} diff --git a/spec/Http/LockedProblemSpec.php b/spec/Http/LockedProblemSpec.php new file mode 100644 index 0000000..c44dfa8 --- /dev/null +++ b/spec/Http/LockedProblemSpec.php @@ -0,0 +1,37 @@ +beConstructedWith('locked'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(LockedProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 423, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(423), + 'detail' => 'locked', + ]); + } +} diff --git a/spec/Http/MethodNotAllowedProblemSpec.php b/spec/Http/MethodNotAllowedProblemSpec.php new file mode 100644 index 0000000..23f00f3 --- /dev/null +++ b/spec/Http/MethodNotAllowedProblemSpec.php @@ -0,0 +1,65 @@ +beConstructedWith('method not allowed'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(MethodNotAllowedProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 405, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(405), + 'detail' => 'method not allowed', + ]); + } + + public function it_should_be_an_allowed_method_with_multiple_methods(): void + { + $allowedMethods = ['POST', 'GET']; + $currentMethod = 'OPTIONS'; + + $this->beConstructedThrough('invalidMethod', [$allowedMethods, $currentMethod]); + $this->toArray()->shouldBe([ + 'status' => 405, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(405), + 'detail' => 'OPTIONS not allowed. Should be: POST or GET', + ]); + } + + public function it_should_be_an_allowed_method_with_a_single_methods(): void + { + $allowedMethods = ['POST']; + $currentMethod = 'OPTIONS'; + + $this->beConstructedThrough('invalidMethod', [$allowedMethods, $currentMethod]); + $this->toArray()->shouldBe([ + 'status' => 405, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(405), + 'detail' => 'OPTIONS not allowed. Should be: POST', + ]); + } +} diff --git a/spec/Http/PreconditionFailedProblemSpec.php b/spec/Http/PreconditionFailedProblemSpec.php new file mode 100644 index 0000000..db0d10f --- /dev/null +++ b/spec/Http/PreconditionFailedProblemSpec.php @@ -0,0 +1,37 @@ +beConstructedWith('precondition failed'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(PreconditionFailedProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 412, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(412), + 'detail' => 'precondition failed', + ]); + } +} diff --git a/spec/Http/PreconditionRequiredProblemSpec.php b/spec/Http/PreconditionRequiredProblemSpec.php new file mode 100644 index 0000000..a124aee --- /dev/null +++ b/spec/Http/PreconditionRequiredProblemSpec.php @@ -0,0 +1,37 @@ +beConstructedWith('precondition required'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(PreconditionRequiredProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 428, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(428), + 'detail' => 'precondition required', + ]); + } +} diff --git a/spec/Http/UnprocessableEntityProblemSpec.php b/spec/Http/UnprocessableEntityProblemSpec.php new file mode 100644 index 0000000..c172575 --- /dev/null +++ b/spec/Http/UnprocessableEntityProblemSpec.php @@ -0,0 +1,37 @@ +beConstructedWith('unprocessable entity'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(UnprocessableEntityProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 422, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(422), + 'detail' => 'unprocessable entity', + ]); + } +} diff --git a/spec/Http/UnsupportedMediaTypeProblemSpec.php b/spec/Http/UnsupportedMediaTypeProblemSpec.php new file mode 100644 index 0000000..5873743 --- /dev/null +++ b/spec/Http/UnsupportedMediaTypeProblemSpec.php @@ -0,0 +1,65 @@ +beConstructedWith('unsupported media type'); + } + + public function it_is_initializable(): void + { + $this->shouldHaveType(UnsupportedMediaTypeProblem::class); + } + + public function it_is_an_http_api_problem(): void + { + $this->shouldHaveType(HttpApiProblem::class); + } + + public function it_can_parse_to_array(): void + { + $this->toArray()->shouldBe([ + 'status' => 415, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(415), + 'detail' => 'unsupported media type', + ]); + } + + public function it_should_be_a_media_type_with_allowed_content_encodings(): void + { + $allowedEncodings = ['gzip', 'identity']; + $currentEncoding = ['compress', 'none']; + + $this->beConstructedThrough('invalidContentEncoding', [$allowedEncodings, $currentEncoding]); + $this->toArray()->shouldBe([ + 'status' => 415, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(415), + 'detail' => 'compress and none not allowed. Should be: gzip or identity', + ]); + } + + public function it_should_be_a_media_type_with_allowed_content_types(): void + { + $allowedEncodings = ['text/html', 'multipart/form-data']; + $currentEncoding = 'application/json'; + + $this->beConstructedThrough('invalidContentType', [$allowedEncodings, $currentEncoding]); + $this->toArray()->shouldBe([ + 'status' => 415, + 'type' => HttpApiProblem::TYPE_HTTP_RFC, + 'title' => HttpApiProblem::getTitleForStatusCode(415), + 'detail' => 'application/json not allowed. Should be: text/html or multipart/form-data', + ]); + } +} diff --git a/src/Http/IAmATeapotProblem.php b/src/Http/IAmATeapotProblem.php new file mode 100644 index 0000000..4034388 --- /dev/null +++ b/src/Http/IAmATeapotProblem.php @@ -0,0 +1,15 @@ + $detail, + ]); + } +} diff --git a/src/Http/LockedProblem.php b/src/Http/LockedProblem.php new file mode 100644 index 0000000..e2610a7 --- /dev/null +++ b/src/Http/LockedProblem.php @@ -0,0 +1,15 @@ + $detail, + ]); + } +} diff --git a/src/Http/MethodNotAllowedProblem.php b/src/Http/MethodNotAllowedProblem.php new file mode 100644 index 0000000..ab315e4 --- /dev/null +++ b/src/Http/MethodNotAllowedProblem.php @@ -0,0 +1,23 @@ + $detail, + ]); + } + + public static function invalidMethod(array $allowMethods, string $currentMethod): self + { + $allowMethods[] = implode(' or ', array_splice($allowMethods, -2)); + $detail = $currentMethod.' not allowed. Should be: '.implode(', ', $allowMethods); + + return new self($detail); + } +} diff --git a/src/Http/PreconditionFailedProblem.php b/src/Http/PreconditionFailedProblem.php new file mode 100644 index 0000000..1815ac7 --- /dev/null +++ b/src/Http/PreconditionFailedProblem.php @@ -0,0 +1,15 @@ + $detail, + ]); + } +} diff --git a/src/Http/PreconditionRequiredProblem.php b/src/Http/PreconditionRequiredProblem.php new file mode 100644 index 0000000..fabc946 --- /dev/null +++ b/src/Http/PreconditionRequiredProblem.php @@ -0,0 +1,15 @@ + $detail, + ]); + } +} diff --git a/src/Http/UnprocessableEntityProblem.php b/src/Http/UnprocessableEntityProblem.php new file mode 100644 index 0000000..9ff4429 --- /dev/null +++ b/src/Http/UnprocessableEntityProblem.php @@ -0,0 +1,15 @@ + $detail, + ]); + } +} diff --git a/src/Http/UnsupportedMediaTypeProblem.php b/src/Http/UnsupportedMediaTypeProblem.php new file mode 100644 index 0000000..468cbd1 --- /dev/null +++ b/src/Http/UnsupportedMediaTypeProblem.php @@ -0,0 +1,34 @@ + $detail, + ]); + } + + public static function invalidContentEncoding(array $allowedEncodings, array $providedEncodings): self + { + $allowedEncodings[] = implode(' or ', array_splice($allowedEncodings, -2)); + $providedEncodings[] = implode(' and ', array_splice($providedEncodings, -2)); + + $detail = implode(', ', $providedEncodings).' not allowed. Should be: '.implode(', ', $allowedEncodings); + + return new self($detail); + } + + public static function invalidContentType(array $allowedTypes, string $providedType): self + { + $allowedTypes[] = implode(' or ', array_splice($allowedTypes, -2)); + + $detail = $providedType.' not allowed. Should be: '.implode(', ', $allowedTypes); + + return new self($detail); + } +}