├── CHANGELOG.md ├── LICENSE.md ├── README.md ├── composer.json └── src ├── ConfigProvider.php ├── Exception ├── CommonProblemDetailsExceptionTrait.php ├── ExceptionInterface.php ├── InvalidResponseBodyException.php └── ProblemDetailsExceptionInterface.php ├── ProblemDetailsMiddleware.php ├── ProblemDetailsMiddlewareFactory.php ├── ProblemDetailsNotFoundHandler.php ├── ProblemDetailsNotFoundHandlerFactory.php ├── ProblemDetailsResponseFactory.php └── ProblemDetailsResponseFactoryFactory.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | All notable changes to this project will be documented in this file, in reverse chronological order by release. 4 | 5 | Versions 0.3.0 and prior were released as "weierophinney/problem-details". 6 | 7 | ## 1.1.1 - TBD 8 | 9 | ### Added 10 | 11 | - Nothing. 12 | 13 | ### Changed 14 | 15 | - Nothing. 16 | 17 | ### Deprecated 18 | 19 | - Nothing. 20 | 21 | ### Removed 22 | 23 | - Nothing. 24 | 25 | ### Fixed 26 | 27 | - Nothing. 28 | 29 | ## 1.1.0 - 2019-12-30 30 | 31 | ### Added 32 | 33 | - [#51](https://github.com/zendframework/zend-problem-details/pull/51) adds a new `problem-details.default_types_map` config option, which can be used to define custom `type` values based on status codes. 34 | 35 | ### Changed 36 | 37 | - Nothing. 38 | 39 | ### Deprecated 40 | 41 | - Nothing. 42 | 43 | ### Removed 44 | 45 | - Nothing. 46 | 47 | ### Fixed 48 | 49 | - Nothing. 50 | 51 | ## 1.0.2 - 2019-01-09 52 | 53 | ### Added 54 | 55 | - Nothing. 56 | 57 | ### Changed 58 | 59 | - Nothing. 60 | 61 | ### Deprecated 62 | 63 | - Nothing. 64 | 65 | ### Removed 66 | 67 | - Nothing. 68 | 69 | ### Fixed 70 | 71 | - [#46](https://github.com/zendframework/zend-problem-details/pull/46) adds code to ensure newlines are stripped when creating key names for XML 72 | payloads. 73 | 74 | ## 1.0.1 - 2018-07-25 75 | 76 | ### Added 77 | 78 | - Nothing. 79 | 80 | ### Changed 81 | 82 | - Nothing. 83 | 84 | ### Deprecated 85 | 86 | - Nothing. 87 | 88 | ### Removed 89 | 90 | - Nothing. 91 | 92 | ### Fixed 93 | 94 | - [#39](https://github.com/zendframework/zend-problem-details/pull/39) adds the `public` visibility modifier to all constants. 95 | 96 | - [#41](https://github.com/zendframework/zend-problem-details/pull/41) prevents crashes when the `ProblemDetailsResponseFactory` attempts to 97 | encode malformed UTF-8 sequences to JSON by ensuring the 98 | `JSON_PARTIAL_OUTPUT_ON_ERROR` flag is enabled. 99 | 100 | ## 1.0.0 - 2018-03-15 101 | 102 | ### Added 103 | 104 | - [#30](https://github.com/zendframework/zend-problem-details/pull/30) 105 | adds PSR-15 support. 106 | 107 | ### Changed 108 | 109 | - [#24](https://github.com/zendframework/zend-problem-details/pull/24) 110 | updates all classes to use scalar and return type hints, including nullable 111 | and void types. If you were extending classes within an earlier release, you 112 | may need to update signatures of any methods you override. 113 | 114 | - [#35](https://github.com/zendframework/zend-problem-details/pull/35) 115 | modifies the constructor of `Zend\ProblemDetails\ProblemDetailsResponseFactory` 116 | such that it now has the following signature: 117 | 118 | ```php 119 | public function __construct( 120 | callable $responseFactory, 121 | bool $isDebug = self::EXCLUDE_THROWABLE_DETAILS, 122 | int $jsonFlags = null, 123 | bool $exceptionDetailsInResponse = false, 124 | string $defaultDetailMessage = self::DEFAULT_DETAIL_MESSAGE 125 | ) 126 | ``` 127 | 128 | Note that the first argument is now a `$responseFactory`, is required, and 129 | must be `callable`. The previous `$responsePrototype` and `$streamFactory` 130 | arguments are now removed. 131 | 132 | The `$responseFactory` will be invoked with no arguments, and MUST return a 133 | PSR-7 ResponseInterface instance. 134 | 135 | - [#35](https://github.com/zendframework/zend-problem-details/pull/35) modifies 136 | internals of `Zend\ProblemDetails\ProblemDetailsResponseFactoryFactory` as 137 | follows: 138 | 139 | - It no longer looks for a `Zend\ProblemDetails\StreamFactory` service. 140 | - It now _requires_ the `Psr\Http\Message\ResponseInterface` service, and 141 | expects it to resolve to a PHP callable capable of producing such an instance 142 | (instead of a response instance directly). 143 | 144 | - [#35](https://github.com/zendframework/zend-problem-details/pull/35) 145 | modifies the constructor of `Zend\ProblemDetails\ProblemDetailsMiddleware`; 146 | the `$responseFactory` argument is now required. 147 | 148 | - [#35](https://github.com/zendframework/zend-problem-details/pull/35) 149 | modifies the constructor of `Zend\ProblemDetails\ProblemDetailsNotFoundHandler`; 150 | the `$responseFactory` argument is now required. 151 | 152 | - [#34](https://github.com/zendframework/zend-problem-details/pull/34) updates 153 | the behavior when passing null as the `$jsonFlag` parameter to the 154 | `Zend\ProblemDetails\ProblemDetailsResponseFactory` constructor; in such 155 | situations, the default `json_encode()` flags will include `JSON_PRETTY_PRINT` 156 | only when the `$isDebug` argument is boolean `true`. 157 | 158 | ### Deprecated 159 | 160 | - Nothing. 161 | 162 | ### Removed 163 | 164 | - [#22](https://github.com/zendframework/zend-problem-details/pull/22) and 165 | [#30](https://github.com/zendframework/zend-problem-details/pull/30) 166 | remove support for both `http-interop/http-middleware` and 167 | `http-interop/http-server-middleware`. 168 | 169 | - [#22](https://github.com/zendframework/zend-problem-details/pull/22) 170 | removes `MissingResponseException` as it cannot be thrown anymore, 171 | because interfaces have PHP7 return type and `TypeError` will be thrown. 172 | 173 | ### Fixed 174 | 175 | - Nothing. 176 | 177 | ## 0.5.3 - 2018-03-12 178 | 179 | ### Added 180 | 181 | - Nothing. 182 | 183 | ### Changed 184 | 185 | - [#32](https://github.com/zendframework/zend-problem-details/pull/32) updates 186 | the `ProblemDetailsResponseFactoryFactory` to allow the `ResponseInterface` 187 | service to either return an instance, or a factory capable of generating an 188 | instance. 189 | 190 | ### Deprecated 191 | 192 | - Nothing. 193 | 194 | ### Removed 195 | 196 | - Nothing. 197 | 198 | ### Fixed 199 | 200 | - Nothing. 201 | 202 | ## 0.5.2 - 2018-01-10 203 | 204 | ### Added 205 | 206 | - [#29](https://github.com/zendframework/zend-problem-details/pull/29) adds 207 | the ability for the `ProblemDetailsMiddleware` to trigger listeners when 208 | it catches a `Throwable` to produce a response. Listeners are PHP callables 209 | and receive the following arguments, in the following order: 210 | 211 | - `Throwable $error`: the throwable/exception caught by the 212 | `ProblemDetailsMiddleware`. 213 | - `ServerRequestInterface $request`: the request handled by the 214 | `ProblemDetailsMiddleware`. 215 | - `ResponseInterface $response`: the response generated by the 216 | `ProblemDetailsMiddleware`. 217 | 218 | Attach listeners using the `ProblemDetailsMiddleware::attachListeners()` 219 | instance method. 220 | 221 | ### Changed 222 | 223 | - Nothing. 224 | 225 | ### Deprecated 226 | 227 | - Nothing. 228 | 229 | ### Removed 230 | 231 | - Nothing. 232 | 233 | ### Fixed 234 | 235 | - Nothing. 236 | 237 | ## 0.5.1 - 2017-12-07 238 | 239 | ### Added 240 | 241 | - Nothing. 242 | 243 | ### Changed 244 | 245 | - Nothing. 246 | 247 | ### Deprecated 248 | 249 | - Nothing. 250 | 251 | ### Removed 252 | 253 | - Nothing. 254 | 255 | ### Fixed 256 | 257 | - [#20](https://github.com/zendframework/zend-problem-details/pull/20) fixes an 258 | issue with serialization when PHP resources are within the `$additional` 259 | aspect of the payload. When these values are encountered, the response factory 260 | now will instead return `Resource of type {resource type}`. 261 | 262 | - [#21](https://github.com/zendframework/zend-problem-details/pull/21) provides 263 | a defence for `$additional` data keys that would otherwise create malformed 264 | XML tag names. 265 | 266 | ## 0.5.0 - 2017-10-09 267 | 268 | ### Added 269 | 270 | - In [#1](https://github.com/zendframework/zend-problem-details/pull/1), 271 | `Zend\ProblemDetails\ProblemDetailsResponseFactory` was updated to attempt to 272 | generate a secure-by-default and secure-in-production Problem Details response 273 | when the response is generated from an exception; essentially, it now defaults 274 | to NOT exposing this information, in order to prevent exposing internals of 275 | the application in production. 276 | 277 | To provide this, it adds two new, optional, constructor arguments: 278 | 279 | - `bool $exceptionDetailsInResponse` is a flag detailing whether or not 280 | details from an exception (except `ProblemDetailsException` custom data) 281 | should be used in the Problem Details response; by default this is `false` 282 | - `string $defaultDetailMessage` is a default message to use for the `detail` 283 | key of the response in such situations; the default value is `An unknown 284 | error occurred.`. 285 | 286 | Additionally, `ProblemDetailsResponseFactoryFactory` was updated to re-use the 287 | configuration `debug` setting for the `$exceptionDetailsInResponse` flag. 288 | 289 | - [#7](https://github.com/zendframework/zend-problem-details/pull/7) adds a 290 | `ProblemDetailsNotFoundHandler` class and associated factory. This can be used 291 | in place of the default application `NotFoundHandler`, in addition to it, or 292 | within specific routed pipelines in order to provide Problem Details 404 293 | responses. 294 | 295 | - [#8](https://github.com/zendframework/zend-problem-details/pull/8) adds 296 | `Zend\Expressive\ProblemDetails\Exception\ExceptionInterface`, a marker 297 | interface for exceptions provided by the package. 298 | 299 | - [#12](https://github.com/zendframework/zend-problem-details/pull/12) adds 300 | support for http-interop/http-middleware 0.5.0 via a polyfill provided by the 301 | package webimpress/http-middleware-compatibility. Essentially, this means you 302 | can drop this package into an application targeting either the 0.4.1 or 0.5.0 303 | versions of http-middleware, and it will "just work". 304 | 305 | ### Changed 306 | 307 | - [#8](https://github.com/zendframework/zend-problem-details/pull/8) renames the 308 | interface `ProblemDetailsException` to `ProblemDetailsExceptionInterface`. 309 | This was done to make the naming consistent with other ZF packages. 310 | 311 | - [#8](https://github.com/zendframework/zend-problem-details/pull/8) renames the 312 | trait `CommonProblemDetailsException` to `CommonProblemDetailsExceptionTrait`. 313 | This was done to make the naming consistent with other ZF packages. 314 | 315 | - [#8](https://github.com/zendframework/zend-problem-details/pull/8) updates the 316 | shipped `InvalidResponseBodyException` and `MissingResponseException` to 317 | extend the new `ExceptionInterface`. 318 | 319 | ### Deprecated 320 | 321 | - Nothing. 322 | 323 | ### Removed 324 | 325 | - Nothing. 326 | 327 | ### Fixed 328 | 329 | - Nothing. 330 | 331 | ## 0.4.0 - 2017-08-01 332 | 333 | ### Added 334 | 335 | - Nothing. 336 | 337 | ### Changed 338 | 339 | - The package is now named "zendframework/zend-problem-details". 340 | - The top-level namespace is now named `Zend\ProblemDetails`. 341 | 342 | ### Deprecated 343 | 344 | - Nothing. 345 | 346 | ### Removed 347 | 348 | - Nothing. 349 | 350 | ### Fixed 351 | 352 | - Nothing. 353 | 354 | ## 0.3.0 - 2017-07-31 355 | 356 | ### Added 357 | 358 | - [#7](https://github.com/weierophinney/problem-details/pull/7) adds an explicit 359 | dependency on ext/json. 360 | 361 | ### Changed 362 | 363 | - [#7](https://github.com/weierophinney/problem-details/pull/7) updates each 364 | of the following to place them under the new `ProblemDetails\Exception` 365 | namespace: 366 | - `CommonProblemDetailsException` 367 | - `InvalidResponseBodyException` 368 | - `MissingResponseException` 369 | - `ProblemDetailsException` 370 | 371 | ### Deprecated 372 | 373 | - Nothing. 374 | 375 | ### Removed 376 | 377 | - Nothing. 378 | 379 | ### Fixed 380 | 381 | - Nothing. 382 | 383 | ## 0.2.1 - 2017-06-13 384 | 385 | ### Added 386 | 387 | - Nothing. 388 | 389 | ### Deprecated 390 | 391 | - Nothing. 392 | 393 | ### Removed 394 | 395 | - Nothing. 396 | 397 | ### Fixed 398 | 399 | - [#5](https://github.com/weierophinney/problem-details/pull/5) updates the 400 | response factory and middleware to treat lack of/empty `Accept` header values 401 | as `*/*`, per RFC-7231 section 5.3.2. 402 | 403 | ## 0.2.0 - 2017-05-30 404 | 405 | ### Added 406 | 407 | - [#4](https://github.com/weierophinney/problem-details/pull/4) adds 408 | `ProblemDetailsReponseFactoryFactory` for generating a 409 | `ProblemDetailsResponseFactory` instance. 410 | 411 | ### Changed 412 | 413 | - [#4](https://github.com/weierophinney/problem-details/pull/4) changes the 414 | `ProblemDetailsResponseFactory` in several ways: 415 | - It is now instantiable. The constructor accepts a boolean indicating debug 416 | status (`false` by default), an integer bitmask of JSON encoding flags, a 417 | PSR-7 `ResponseInterface` instance, and a callable factory for generating a 418 | writable PSR-7 `StreamInterface` for the final problem details response 419 | content. 420 | - `createResponse()` is now an instance method, and its first argument is no 421 | longer an `Accept` header, but a PSR-7 `ServerRequestInterface` instance. 422 | - `createResponseFromThrowable()` is now an instance method, and its first 423 | argument is no longer an `Accept` header, but a PSR-7 424 | `ServerRequestInterface` instance. 425 | 426 | - [#4](https://github.com/weierophinney/problem-details/pull/4) changes the 427 | `ProblemDetailsMiddleware`; it now composes a `ProblemDetailsResponseFactory` 428 | insteead of an `isDebug` flag. Additionally, it no longer wraps processing of 429 | the delegate in a try/catch block if the request cannot accept JSON or XML. 430 | 431 | - [#4](https://github.com/weierophinney/problem-details/pull/4) changes the 432 | `ProblemDetailsMiddlewareFactory` to inject the `ProblemDetailsMiddleware` 433 | with a `ProblemDetailsResponseFactory` instead of an `isDebug` flag. 434 | 435 | ### Deprecated 436 | 437 | - Nothing. 438 | 439 | ### Removed 440 | 441 | - [#4](https://github.com/weierophinney/problem-details/pull/4) removes the 442 | `ProblemDetailsJsonResponse`; use the `ProblemDetailsResponseFactory` instead. 443 | 444 | - [#4](https://github.com/weierophinney/problem-details/pull/4) removes the 445 | `ProblemDetailsXmlResponse`; use the `ProblemDetailsResponseFactory` instead. 446 | 447 | - [#4](https://github.com/weierophinney/problem-details/pull/4) removes the 448 | `CommonProblemDetails` trait; the logic is now incorporated in the 449 | `ProblemDetailsResponseFactory`. 450 | 451 | - [#4](https://github.com/weierophinney/problem-details/pull/4) removes the 452 | `ProblemDetailsResponse` interface; PSR-7 response prototypes are now used 453 | instead. 454 | 455 | ### Fixed 456 | 457 | - [#4](https://github.com/weierophinney/problem-details/pull/4) updates JSON 458 | response generation to allow specifying your own JSON encoding flags. By 459 | default, it now does pretty JSON, with unescaped slashes and unicode. 460 | 461 | ## 0.1.0 - 2017-05-03 462 | 463 | Initial Release. 464 | 465 | ### Added 466 | 467 | - Nothing. 468 | 469 | ### Deprecated 470 | 471 | - Nothing. 472 | 473 | ### Removed 474 | 475 | - Nothing. 476 | 477 | ### Fixed 478 | 479 | - Nothing. 480 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | Copyright (c) 2017, Zend Technologies USA, Inc. 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without modification, 5 | are permitted provided that the following conditions are met: 6 | 7 | - Redistributions of source code must retain the above copyright notice, this 8 | list of conditions and the following disclaimer. 9 | 10 | - Redistributions in binary form must reproduce the above copyright notice, this 11 | list of conditions and the following disclaimer in the documentation and/or 12 | other materials provided with the distribution. 13 | 14 | - Neither the name of Zend Technologies USA, Inc. nor the names of its 15 | contributors may be used to endorse or promote products derived from this 16 | software without specific prior written permission. 17 | 18 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND 19 | ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED 20 | WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE 21 | DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR 22 | ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES 23 | (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; 24 | LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON 25 | ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 26 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS 27 | SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Problem Details for PSR-7 Applications 2 | 3 | > ## Repository abandoned 2019-12-31 4 | > 5 | > This repository has moved to [mezzio/mezzio-problem-details](https://github.com/mezzio/mezzio-problem-details). 6 | 7 | [![Build Status](https://secure.travis-ci.org/zendframework/zend-problem-details.svg?branch=master)](https://secure.travis-ci.org/zendframework/zend-problem-details) 8 | [![Coverage Status](https://coveralls.io/repos/github/zendframework/zend-problem-details/badge.svg?branch=master)](https://coveralls.io/github/zendframework/zend-problem-details?branch=master) 9 | 10 | This library provides a factory for generating Problem Details 11 | responses, error handling middleware for automatically generating Problem 12 | Details responses from errors and exceptions, and custom exception types for 13 | [PSR-7](http://www.php-fig.org/psr/psr-7/) applications. 14 | 15 | ## Installation 16 | 17 | Run the following to install this library: 18 | 19 | ```bash 20 | $ composer require zendframework/zend-problem-details 21 | ``` 22 | 23 | ## Documentation 24 | 25 | Documentation is [in the doc tree](docs/book/), and can be compiled using [mkdocs](http://www.mkdocs.org): 26 | 27 | ```bash 28 | $ mkdocs build 29 | ``` 30 | 31 | You may also [browse the documentation online](https://docs.zendframework.com/zend-problem-details/). 32 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zendframework/zend-problem-details", 3 | "description": "Problem Details for PSR-7 HTTP APIs", 4 | "license": "BSD-3-Clause", 5 | "keywords": [ 6 | "api", 7 | "rest", 8 | "problem-details", 9 | "zendframework", 10 | "zf" 11 | ], 12 | "support": { 13 | "docs": "https://docs.zendframework.com/zend-problem-details/", 14 | "issues": "https://github.com/zendframework/zend-problem-details/issues", 15 | "source": "https://github.com/zendframework/zend-problem-details", 16 | "rss": "https://github.com/zendframework/zend-problem-details/releases.atom", 17 | "slack": "https://zendframework-slack.herokuapp.com", 18 | "forum": "https://discourse.zendframework.com/c/questions/expressive" 19 | }, 20 | "require": { 21 | "php": "^7.1", 22 | "ext-json": "*", 23 | "fig/http-message-util": "^1.1.2", 24 | "psr/container": "^1.0", 25 | "psr/http-message": "^1.0", 26 | "psr/http-server-middleware": "^1.0", 27 | "spatie/array-to-xml": "^2.3", 28 | "willdurand/negotiation": "^2.3" 29 | }, 30 | "require-dev": { 31 | "phpunit/phpunit": "^7.0.1", 32 | "zendframework/zend-coding-standard": "~1.0.0" 33 | }, 34 | "autoload": { 35 | "psr-4": { 36 | "Zend\\ProblemDetails\\": "src/" 37 | } 38 | }, 39 | "autoload-dev": { 40 | "psr-4": { 41 | "ZendTest\\ProblemDetails\\": "test/" 42 | } 43 | }, 44 | "config": { 45 | "sort-packages": true 46 | }, 47 | "extra": { 48 | "zf": { 49 | "config-provider": "Zend\\ProblemDetails\\ConfigProvider" 50 | }, 51 | "branch-alias": { 52 | "dev-master": "1.1.x-dev", 53 | "dev-develop": "1.2.x-dev" 54 | } 55 | }, 56 | "scripts": { 57 | "check": [ 58 | "@cs-check", 59 | "@test" 60 | ], 61 | "cs-check": "phpcs", 62 | "cs-fix": "phpcbf", 63 | "test": "phpunit --colors=always", 64 | "test-coverage": "phpunit --colors=always --coverage-clover clover.xml" 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/ConfigProvider.php: -------------------------------------------------------------------------------- 1 | $this->getDependencies(), 26 | ]; 27 | } 28 | 29 | /** 30 | * Returns the container dependencies. 31 | */ 32 | public function getDependencies() : array 33 | { 34 | return [ 35 | 'factories' => [ 36 | ProblemDetailsMiddleware::class => ProblemDetailsMiddlewareFactory::class, 37 | ProblemDetailsNotFoundHandler::class => ProblemDetailsNotFoundHandlerFactory::class, 38 | ProblemDetailsResponseFactory::class => ProblemDetailsResponseFactoryFactory::class, 39 | ], 40 | ]; 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/Exception/CommonProblemDetailsExceptionTrait.php: -------------------------------------------------------------------------------- 1 | status; 55 | } 56 | 57 | public function getType() : string 58 | { 59 | return $this->type; 60 | } 61 | 62 | public function getTitle() : string 63 | { 64 | return $this->title; 65 | } 66 | 67 | public function getDetail() : string 68 | { 69 | return $this->detail; 70 | } 71 | 72 | public function getAdditionalData() : array 73 | { 74 | return $this->additional; 75 | } 76 | 77 | /** 78 | * Serialize the exception to an array of problem details. 79 | * 80 | * Likely useful for the JsonSerializable implementation, but also 81 | * for cases where the XML variant is desired. 82 | */ 83 | public function toArray() : array 84 | { 85 | $problem = [ 86 | 'status' => $this->status, 87 | 'detail' => $this->detail, 88 | 'title' => $this->title, 89 | 'type' => $this->type, 90 | ]; 91 | 92 | if ($this->additional) { 93 | $problem = array_merge($this->additional, $problem); 94 | } 95 | 96 | return $problem; 97 | } 98 | 99 | /** 100 | * Allow serialization via json_encode(). 101 | * 102 | * @return array 103 | */ 104 | public function jsonSerialize() 105 | { 106 | return $this->toArray(); 107 | } 108 | } 109 | -------------------------------------------------------------------------------- /src/Exception/ExceptionInterface.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 45 | } 46 | 47 | /** 48 | * {@inheritDoc} 49 | */ 50 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface 51 | { 52 | // If we cannot provide a representation, act as a no-op. 53 | if (! $this->canActAsErrorHandler($request)) { 54 | return $handler->handle($request); 55 | } 56 | 57 | try { 58 | set_error_handler($this->createErrorHandler()); 59 | $response = $handler->handle($request); 60 | } catch (Throwable $e) { 61 | $response = $this->responseFactory->createResponseFromThrowable($request, $e); 62 | $this->triggerListeners($e, $request, $response); 63 | } finally { 64 | restore_error_handler(); 65 | } 66 | 67 | return $response; 68 | } 69 | 70 | /** 71 | * Attach an error listener. 72 | * 73 | * Each listener receives the following three arguments: 74 | * 75 | * - Throwable $error 76 | * - ServerRequestInterface $request 77 | * - ResponseInterface $response 78 | * 79 | * These instances are all immutable, and the return values of 80 | * listeners are ignored; use listeners for reporting purposes 81 | * only. 82 | */ 83 | public function attachListener(callable $listener) : void 84 | { 85 | if (in_array($listener, $this->listeners, true)) { 86 | return; 87 | } 88 | 89 | $this->listeners[] = $listener; 90 | } 91 | 92 | /** 93 | * Can the middleware act as an error handler? 94 | * 95 | * Returns a boolean false if negotiation fails. 96 | */ 97 | private function canActAsErrorHandler(ServerRequestInterface $request) : bool 98 | { 99 | $accept = $request->getHeaderLine('Accept') ?: '*/*'; 100 | 101 | return null !== (new Negotiator()) 102 | ->getBest($accept, ProblemDetailsResponseFactory::NEGOTIATION_PRIORITIES); 103 | } 104 | 105 | /** 106 | * Creates and returns a callable error handler that raises exceptions. 107 | * 108 | * Only raises exceptions for errors that are within the error_reporting mask. 109 | */ 110 | private function createErrorHandler() : callable 111 | { 112 | /** 113 | * @param int $errno 114 | * @param string $errstr 115 | * @param string $errfile 116 | * @param int $errline 117 | * @return void 118 | * @throws ErrorException if error is not within the error_reporting mask. 119 | */ 120 | return function (int $errno, string $errstr, string $errfile, int $errline) : void { 121 | if (! (error_reporting() & $errno)) { 122 | // error_reporting does not include this error 123 | return; 124 | } 125 | 126 | throw new ErrorException($errstr, 0, $errno, $errfile, $errline); 127 | }; 128 | } 129 | 130 | /** 131 | * Trigger all error listeners. 132 | */ 133 | private function triggerListeners( 134 | Throwable $error, 135 | ServerRequestInterface $request, 136 | ResponseInterface $response 137 | ) : void { 138 | array_walk($this->listeners, function ($listener) use ($error, $request, $response) { 139 | $listener($error, $request, $response); 140 | }); 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/ProblemDetailsMiddlewareFactory.php: -------------------------------------------------------------------------------- 1 | get(ProblemDetailsResponseFactory::class)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ProblemDetailsNotFoundHandler.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 34 | } 35 | 36 | /** 37 | * Creates and returns a 404 response. 38 | */ 39 | public function process(ServerRequestInterface $request, RequestHandlerInterface $handler) : ResponseInterface 40 | { 41 | // If we cannot provide a representation, act as a no-op. 42 | if (! $this->canActAsErrorHandler($request)) { 43 | return $handler->handle($request); 44 | } 45 | 46 | return $this->responseFactory->createResponse( 47 | $request, 48 | 404, 49 | sprintf('Cannot %s %s!', $request->getMethod(), (string) $request->getUri()) 50 | ); 51 | } 52 | 53 | /** 54 | * Can the middleware act as an error handler? 55 | */ 56 | private function canActAsErrorHandler(ServerRequestInterface $request) : bool 57 | { 58 | $accept = $request->getHeaderLine('Accept') ?: '*/*'; 59 | 60 | return null !== (new Negotiator()) 61 | ->getBest($accept, ProblemDetailsResponseFactory::NEGOTIATION_PRIORITIES); 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /src/ProblemDetailsNotFoundHandlerFactory.php: -------------------------------------------------------------------------------- 1 | get(ProblemDetailsResponseFactory::class)); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/ProblemDetailsResponseFactory.php: -------------------------------------------------------------------------------- 1 | 'Bad Request', 82 | StatusCode::STATUS_UNAUTHORIZED => 'Unauthorized', 83 | StatusCode::STATUS_PAYMENT_REQUIRED => 'Payment Required', 84 | StatusCode::STATUS_FORBIDDEN => 'Forbidden', 85 | StatusCode::STATUS_NOT_FOUND => 'Not Found', 86 | StatusCode::STATUS_METHOD_NOT_ALLOWED => 'Method Not Allowed', 87 | StatusCode::STATUS_NOT_ACCEPTABLE => 'Not Acceptable', 88 | StatusCode::STATUS_PROXY_AUTHENTICATION_REQUIRED => 'Proxy Authentication Required', 89 | StatusCode::STATUS_REQUEST_TIMEOUT => 'Request Timeout', 90 | StatusCode::STATUS_CONFLICT => 'Conflict', 91 | StatusCode::STATUS_GONE => 'Gone', 92 | StatusCode::STATUS_LENGTH_REQUIRED => 'Length Required', 93 | StatusCode::STATUS_PRECONDITION_FAILED => 'Precondition Failed', 94 | StatusCode::STATUS_PAYLOAD_TOO_LARGE => 'Payload Too Large', 95 | StatusCode::STATUS_URI_TOO_LONG => 'Request-URI Too Long', 96 | StatusCode::STATUS_UNSUPPORTED_MEDIA_TYPE => 'Unsupported Media Type', 97 | StatusCode::STATUS_RANGE_NOT_SATISFIABLE => 'Requested Range Not Satisfiable', 98 | StatusCode::STATUS_EXPECTATION_FAILED => 'Expectation Failed', 99 | StatusCode::STATUS_IM_A_TEAPOT => 'I\'m a teapot', 100 | StatusCode::STATUS_MISDIRECTED_REQUEST => 'Misdirected Request', 101 | StatusCode::STATUS_UNPROCESSABLE_ENTITY => 'Unprocessable Entity', 102 | StatusCode::STATUS_LOCKED => 'Locked', 103 | StatusCode::STATUS_FAILED_DEPENDENCY => 'Failed Dependency', 104 | StatusCode::STATUS_UPGRADE_REQUIRED => 'Upgrade Required', 105 | StatusCode::STATUS_PRECONDITION_REQUIRED => 'Precondition Required', 106 | StatusCode::STATUS_TOO_MANY_REQUESTS => 'Too Many Requests', 107 | StatusCode::STATUS_REQUEST_HEADER_FIELDS_TOO_LARGE => 'Request Header Fields Too Large', 108 | 444 => 'Connection Closed Without Response', 109 | StatusCode::STATUS_UNAVAILABLE_FOR_LEGAL_REASONS => 'Unavailable For Legal Reasons', 110 | 499 => 'Client Closed Request', 111 | // 5×× Server Error 112 | StatusCode::STATUS_INTERNAL_SERVER_ERROR => 'Internal Server Error', 113 | StatusCode::STATUS_NOT_IMPLEMENTED => 'Not Implemented', 114 | StatusCode::STATUS_BAD_GATEWAY => 'Bad Gateway', 115 | StatusCode::STATUS_SERVICE_UNAVAILABLE => 'Service Unavailable', 116 | StatusCode::STATUS_GATEWAY_TIMEOUT => 'Gateway Timeout', 117 | StatusCode::STATUS_VERSION_NOT_SUPPORTED => 'HTTP Version Not Supported', 118 | StatusCode::STATUS_VARIANT_ALSO_NEGOTIATES => 'Variant Also Negotiates', 119 | StatusCode::STATUS_INSUFFICIENT_STORAGE => 'Insufficient Storage', 120 | StatusCode::STATUS_LOOP_DETECTED => 'Loop Detected', 121 | StatusCode::STATUS_NOT_EXTENDED => 'Not Extended', 122 | StatusCode::STATUS_NETWORK_AUTHENTICATION_REQUIRED => 'Network Authentication Required', 123 | 599 => 'Network Connect Timeout Error', 124 | ]; 125 | 126 | /** 127 | * Constant value to indicate throwable details (backtrace, previous 128 | * exceptions, etc.) should be excluded when generating a response from a 129 | * Throwable. 130 | * 131 | * @var bool 132 | */ 133 | public const EXCLUDE_THROWABLE_DETAILS = false; 134 | 135 | /** 136 | * Constant value to indicate throwable details (backtrace, previous 137 | * exceptions, etc.) should be included when generating a response from a 138 | * Throwable. 139 | * 140 | * @var bool 141 | */ 142 | public const INCLUDE_THROWABLE_DETAILS = true; 143 | 144 | /** 145 | * @var string[] Accept header types to match. 146 | */ 147 | public const NEGOTIATION_PRIORITIES = [ 148 | 'application/json', 149 | 'application/*+json', 150 | 'application/xml', 151 | 'application/*+xml', 152 | ]; 153 | 154 | /** 155 | * Whether or not to include debug details. 156 | * 157 | * Debug details are only included for responses created from throwables, 158 | * and include full exception details and previous exceptions and their 159 | * details. 160 | * 161 | * @var bool 162 | */ 163 | private $isDebug; 164 | 165 | /** 166 | * JSON flags to use when generating JSON response payload. 167 | * 168 | * On non-debug mode: 169 | * defaults to JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION 170 | * | JSON_PARTIAL_OUTPUT_ON_ERROR 171 | * 172 | * On debug mode: 173 | * defaults to JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_PRESERVE_ZERO_FRACTION 174 | * | JSON_PARTIAL_OUTPUT_ON_ERROR 175 | * 176 | * @var int 177 | */ 178 | private $jsonFlags; 179 | 180 | /** 181 | * Factory to use to generate prototype response used when generating a 182 | * problem details response. 183 | * 184 | * @var callable 185 | */ 186 | private $responseFactory; 187 | 188 | /** 189 | * Flag to enable show exception details in detail field. 190 | * 191 | * Disabled by default for security reasons. 192 | * 193 | * @var bool 194 | */ 195 | private $exceptionDetailsInResponse; 196 | 197 | /** 198 | * Default detail field value. Will be visible when 199 | * $exceptionDetailsInResponse disabled. 200 | * 201 | * Empty string by default 202 | * 203 | * @var string 204 | */ 205 | private $defaultDetailMessage; 206 | 207 | /** 208 | * A map used to infer the "type" property based on the status code. 209 | * 210 | * Defaults to an empty map. 211 | * 212 | * @var array 213 | */ 214 | private $defaultTypesMap; 215 | 216 | public function __construct( 217 | callable $responseFactory, 218 | bool $isDebug = self::EXCLUDE_THROWABLE_DETAILS, 219 | int $jsonFlags = null, 220 | bool $exceptionDetailsInResponse = false, 221 | string $defaultDetailMessage = self::DEFAULT_DETAIL_MESSAGE, 222 | array $defaultTypesMap = [] 223 | ) { 224 | // Ensures type safety of the composed factory 225 | $this->responseFactory = function () use ($responseFactory) : ResponseInterface { 226 | return $responseFactory(); 227 | }; 228 | $this->isDebug = $isDebug; 229 | if (! $jsonFlags) { 230 | $jsonFlags = JSON_UNESCAPED_SLASHES 231 | | JSON_UNESCAPED_UNICODE 232 | | JSON_PRESERVE_ZERO_FRACTION 233 | | JSON_PARTIAL_OUTPUT_ON_ERROR; 234 | if ($isDebug) { 235 | $jsonFlags = JSON_PRETTY_PRINT | $jsonFlags; 236 | } 237 | } 238 | $this->jsonFlags = $jsonFlags; 239 | $this->exceptionDetailsInResponse = $exceptionDetailsInResponse; 240 | $this->defaultDetailMessage = $defaultDetailMessage; 241 | $this->defaultTypesMap = $defaultTypesMap; 242 | } 243 | 244 | public function createResponse( 245 | ServerRequestInterface $request, 246 | int $status, 247 | string $detail, 248 | string $title = '', 249 | string $type = '', 250 | array $additional = [] 251 | ) : ResponseInterface { 252 | $status = $this->normalizeStatus($status); 253 | $title = $title ?: $this->createTitleFromStatus($status); 254 | $type = $type ?: $this->createTypeFromStatus($status); 255 | 256 | $payload = [ 257 | 'title' => $title, 258 | 'type' => $type, 259 | 'status' => $status, 260 | 'detail' => $detail, 261 | ]; 262 | 263 | if ($additional) { 264 | // ensure payload can be json_encoded 265 | array_walk_recursive($additional, function (&$value) { 266 | if (is_resource($value)) { 267 | $value = print_r($value, true) . ' of type ' . get_resource_type($value); 268 | } 269 | }); 270 | $payload = array_merge($additional, $payload); 271 | } 272 | 273 | return $this->getResponseGenerator($request)($payload); 274 | } 275 | 276 | /** 277 | * Create a problem-details response from a Throwable. 278 | */ 279 | public function createResponseFromThrowable( 280 | ServerRequestInterface $request, 281 | Throwable $e 282 | ) : ResponseInterface { 283 | if ($e instanceof Exception\ProblemDetailsExceptionInterface) { 284 | return $this->createResponse( 285 | $request, 286 | $e->getStatus(), 287 | $e->getDetail(), 288 | $e->getTitle(), 289 | $e->getType(), 290 | $e->getAdditionalData() 291 | ); 292 | } 293 | 294 | $detail = $this->isDebug || $this->exceptionDetailsInResponse ? $e->getMessage() : $this->defaultDetailMessage; 295 | $additionalDetails = $this->isDebug ? $this->createThrowableDetail($e) : []; 296 | $code = $this->isDebug || $this->exceptionDetailsInResponse ? $this->getThrowableCode($e) : 500; 297 | 298 | return $this->createResponse( 299 | $request, 300 | $code, 301 | $detail, 302 | '', 303 | '', 304 | $additionalDetails 305 | ); 306 | } 307 | 308 | protected function getThrowableCode(Throwable $e) : int 309 | { 310 | $code = $e->getCode(); 311 | 312 | return is_int($code) ? $code : 0; 313 | } 314 | 315 | protected function generateJsonResponse(array $payload) : ResponseInterface 316 | { 317 | return $this->generateResponse( 318 | $payload['status'], 319 | self::CONTENT_TYPE_JSON, 320 | json_encode($payload, $this->jsonFlags) 321 | ); 322 | } 323 | 324 | /** 325 | * Ensure all keys in this associative array are valid XML tag names by replacing invalid 326 | * characters with an `_`. 327 | */ 328 | private function cleanKeysForXml(array $input): array 329 | { 330 | $return = []; 331 | foreach ($input as $key => $value) { 332 | $key = str_replace("\n", '_', $key); 333 | $startCharacterPattern = 334 | '[A-Z]|_|[a-z]|[\xC0-\xD6]|[\xD8-\xF6]|[\xF8-\x{2FF}]|[\x{370}-\x{37D}]|[\x{37F}-\x{1FFF}]|' 335 | . '[\x{200C}-\x{200D}]|[\x{2070}-\x{218F}]|[\x{2C00}-\x{2FEF}]|[\x{3001}-\x{D7FF}]|[\x{F900}-\x{FDCF}]' 336 | . '|[\x{FDF0}-\x{FFFD}]'; 337 | $characterPattern = $startCharacterPattern . '|\-|\.|[0-9]|\xB7|[\x{300}-\x{36F}]|[\x{203F}-\x{2040}]'; 338 | 339 | $key = preg_replace('/(?!'.$characterPattern.')./u', '_', $key); 340 | $key = preg_replace('/^(?!'.$startCharacterPattern.')./u', '_', $key); 341 | 342 | if (is_array($value)) { 343 | $value = $this->cleanKeysForXml($value); 344 | } 345 | $return[$key] = $value; 346 | } 347 | return $return; 348 | } 349 | 350 | protected function generateXmlResponse(array $payload) : ResponseInterface 351 | { 352 | // Ensure any objects are flattened to arrays first 353 | $content = json_decode(json_encode($payload), true); 354 | 355 | // ensure all keys are valid XML can be json_encoded 356 | $cleanedContent = $this->cleanKeysForXml($content); 357 | 358 | $converter = new ArrayToXml($cleanedContent, 'problem'); 359 | $dom = $converter->toDom(); 360 | $root = $dom->firstChild; 361 | $root->setAttribute('xmlns', 'urn:ietf:rfc:7807'); 362 | 363 | return $this->generateResponse( 364 | $payload['status'], 365 | self::CONTENT_TYPE_XML, 366 | $dom->saveXML() 367 | ); 368 | } 369 | 370 | protected function generateResponse(int $status, string $contentType, string $payload) : ResponseInterface 371 | { 372 | $response = ($this->responseFactory)(); 373 | $response->getBody()->write($payload); 374 | 375 | return $response 376 | ->withStatus($status) 377 | ->withHeader('Content-Type', $contentType); 378 | } 379 | 380 | private function getResponseGenerator(ServerRequestInterface $request) : callable 381 | { 382 | $accept = $request->getHeaderLine('Accept') ?: '*/*'; 383 | $mediaType = (new Negotiator())->getBest($accept, self::NEGOTIATION_PRIORITIES); 384 | 385 | return ! $mediaType || false === strpos($mediaType->getValue(), 'json') 386 | ? Closure::fromCallable([$this, 'generateXmlResponse']) 387 | : Closure::fromCallable([$this, 'generateJsonResponse']); 388 | } 389 | 390 | private function normalizeStatus(int $status) : int 391 | { 392 | if ($status < 400 || $status > 599) { 393 | return 500; 394 | } 395 | 396 | return $status; 397 | } 398 | 399 | private function createTitleFromStatus(int $status) : string 400 | { 401 | return self::DEFAULT_TITLE_MAP[$status] ?? 'Unknown Error'; 402 | } 403 | 404 | private function createTypeFromStatus(int $status) : string 405 | { 406 | return $this->defaultTypesMap[$status] ?? sprintf('https://httpstatus.es/%s', $status); 407 | } 408 | 409 | private function createThrowableDetail(Throwable $e) : array 410 | { 411 | $detail = [ 412 | 'class' => get_class($e), 413 | 'code' => $e->getCode(), 414 | 'message' => $e->getMessage(), 415 | 'file' => $e->getFile(), 416 | 'line' => $e->getLine(), 417 | 'trace' => $e->getTrace(), 418 | ]; 419 | 420 | $previous = []; 421 | while ($e = $e->getPrevious()) { 422 | $previous[] = [ 423 | 'class' => get_class($e), 424 | 'code' => $e->getCode(), 425 | 'message' => $e->getMessage(), 426 | 'file' => $e->getFile(), 427 | 'line' => $e->getLine(), 428 | 'trace' => $e->getTrace(), 429 | ]; 430 | } 431 | 432 | if (! empty($previous)) { 433 | $detail['stack'] = $previous; 434 | } 435 | 436 | return ['exception' => $detail]; 437 | } 438 | } 439 | -------------------------------------------------------------------------------- /src/ProblemDetailsResponseFactoryFactory.php: -------------------------------------------------------------------------------- 1 | has('config') ? $container->get('config') : []; 20 | $includeThrowableDetail = $config['debug'] ?? ProblemDetailsResponseFactory::EXCLUDE_THROWABLE_DETAILS; 21 | 22 | $problemDetailsConfig = $config['problem-details'] ?? []; 23 | $jsonFlags = $problemDetailsConfig['json_flags'] ?? null; 24 | $defaultTypesMap = $problemDetailsConfig['default_types_map'] ?? []; 25 | 26 | return new ProblemDetailsResponseFactory( 27 | $container->get(ResponseInterface::class), 28 | $includeThrowableDetail, 29 | $jsonFlags, 30 | $includeThrowableDetail, 31 | ProblemDetailsResponseFactory::DEFAULT_DETAIL_MESSAGE, 32 | $defaultTypesMap 33 | ); 34 | } 35 | } 36 | --------------------------------------------------------------------------------