├── .github ├── FUNDING.yml ├── ISSUE_TEMPLATE.md ├── PULL_REQUEST_TEMPLATE.md └── workflows │ └── quality-assurance.yaml ├── Makefile ├── SECURITY.md ├── composer.json ├── default-.env ├── docker-compose.yml ├── docker └── php │ ├── 81 │ ├── Dockerfile │ └── xdebug.ini │ └── conf.d │ └── error_reporting.ini ├── phpstan.neon.dist └── src ├── ApiProblem.php ├── HttpConverter.php ├── JsonEncodeException.php ├── JsonException.php └── JsonParseException.php /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: [Crell] 4 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Detailed description 4 | 5 | Provide a detailed description of the change or addition you are proposing. 6 | 7 | Make it clear if the issue is a bug, an enhancement or just a question. 8 | 9 | ## Context 10 | 11 | Why is this change important to you? How would you use it? 12 | 13 | How can it benefit other users? 14 | 15 | ## Possible implementation 16 | 17 | Not obligatory, but suggest an idea for implementing addition or change. 18 | 19 | ## Your environment 20 | 21 | Include as many relevant details about the environment you experienced the bug in and how to reproduce it. 22 | 23 | * Version used (e.g. PHP 5.6, HHVM 3): 24 | * Operating system and version (e.g. Ubuntu 16.04, Windows 7): 25 | * Link to your project: 26 | * ... 27 | * ... 28 | -------------------------------------------------------------------------------- /.github/PULL_REQUEST_TEMPLATE.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | ## Description 4 | 5 | Describe your changes in detail. 6 | 7 | ## Motivation and context 8 | 9 | Why is this change required? What problem does it solve? 10 | 11 | If it fixes an open issue, please link to the issue here (if you write `fixes #num` 12 | or `closes #num`, the issue will be automatically closed when the pull is accepted.) 13 | 14 | ## How has this been tested? 15 | 16 | Please describe in detail how you tested your changes. 17 | 18 | Include details of your testing environment, and the tests you ran to 19 | see how your change affects other areas of the code, etc. 20 | 21 | ## Screenshots (if appropriate) 22 | 23 | ## Types of changes 24 | 25 | What types of changes does your code introduce? Put an `x` in all the boxes that apply: 26 | - [ ] Bug fix (non-breaking change which fixes an issue) 27 | - [ ] New feature (non-breaking change which adds functionality) 28 | - [ ] Breaking change (fix or feature that would cause existing functionality to change) 29 | 30 | ## Checklist: 31 | 32 | Go over all the following points, and put an `x` in all the boxes that apply. 33 | 34 | Please, please, please, don't send your pull request until all of the boxes are ticked. Once your pull request is created, it will trigger a build on our [continuous integration](http://www.phptherightway.com/#continuous-integration) server to make sure your [tests and code style pass](https://help.github.com/articles/about-required-status-checks/). 35 | 36 | - [ ] I have read the **[CONTRIBUTING](CONTRIBUTING.md)** document. 37 | - [ ] My pull request addresses exactly one patch/feature. 38 | - [ ] I have created a branch for this patch/feature. 39 | - [ ] Each individual commit in the pull request is meaningful. 40 | - [ ] I have added tests to cover my changes. 41 | - [ ] If my change requires a change to the documentation, I have updated it accordingly. 42 | 43 | If you're unsure about any of these, don't hesitate to ask. We're here to help! 44 | -------------------------------------------------------------------------------- /.github/workflows/quality-assurance.yaml: -------------------------------------------------------------------------------- 1 | --- 2 | name: Quality Assurance 3 | on: 4 | push: 5 | branches: ['master'] 6 | pull_request: ~ 7 | 8 | jobs: 9 | phpunit: 10 | name: PHPUnit tests on ${{ matrix.php }} ${{ matrix.composer-flags }} 11 | runs-on: ubuntu-latest 12 | strategy: 13 | matrix: 14 | php: [ '7.4', '8.0', '8.1', '8.2', '8.3', '8.4' ] 15 | composer-flags: [ '' ] 16 | phpunit-flags: [ '--coverage-text' ] 17 | steps: 18 | - uses: actions/checkout@v2 19 | - uses: shivammathur/setup-php@v2 20 | with: 21 | php-version: ${{ matrix.php }} 22 | coverage: xdebug 23 | tools: composer:v2 24 | - run: composer install --no-progress ${{ matrix.composer-flags }} 25 | - run: vendor/bin/phpunit ${{ matrix.phpunit-flags }} 26 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | compose_command = docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php81 2 | 3 | build: 4 | docker-compose build 5 | 6 | shell: build 7 | $(compose_command) bash 8 | 9 | destroy: 10 | docker-compose down -v 11 | 12 | composer: build 13 | $(compose_command) composer install 14 | 15 | test: build 16 | $(compose_command) vendor/bin/phpunit 17 | 18 | phpstan: build 19 | $(compose_command) vendor/bin/phpstan 20 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Brand Promise 2 | 3 | Perfect security is not an achievable goal, but it is a goal to strive for nonetheless. To that end, we welcome responsible security reports from both users and external security researchers. 4 | 5 | # Scope 6 | 7 | If you believe you've found a security issue in software that is maintained in this repository, we encourage you to notify us. 8 | 9 | | Version | In scope | Source code | 10 | | ------- | -------- |-------------------------------------| 11 | | latest | ✅ | https://github.com/Crell/ApiProblem | 12 | 13 | Only the latest stable release of this library is supported. In general, bug and security fixes will not be backported unless there is a substantial imminent threat to users in not doing so. 14 | 15 | # How to Submit a Report 16 | 17 | To submit a vulnerability report, please contact us through [GitHub](https://github.com/Crell/ApiProblem/security). Your submission will be reviewed as soon as feasible, but as this is a volunteer project we cannot guarantee a response time. 18 | 19 | # Safe Harbor 20 | 21 | We support safe harbor for security researchers who: 22 | 23 | * Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our services. 24 | * Only interact with accounts you own or with explicit permission of the account holder. If you do encounter Personally Identifiable Information (PII) contact us immediately, do not proceed with access, and immediately purge any local information. 25 | * Provide us with a reasonable amount of time to resolve vulnerabilities prior to any disclosure to the public or a third-party. 26 | 27 | We will consider activities conducted consistent with this policy to constitute "authorized" conduct and will not pursue civil action or initiate a complaint to law enforcement. We will help to the extent we can if legal action is initiated by a third party against you. 28 | 29 | Please submit a report to us before engaging in conduct that may be inconsistent with or unaddressed by this policy. 30 | 31 | # Preferences 32 | 33 | * Please provide detailed reports with reproducible steps and a clearly defined impact. 34 | * Include the version number of the vulnerable package in your report. 35 | * Providing a suggested fix is welcome, but not required, and we may choose to implement our own, based on your submitted fix or not. 36 | * This is a volunteer project. We will make every effort to respond to security reports in a timely manner, but that may be a week or two on the first contact. 37 | 38 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "crell/api-problem", 3 | "type": "library", 4 | "description": "PHP wrapper for the api-problem IETF specification", 5 | "license": "MIT", 6 | "keywords": ["api-problem", "rest", "http", "json", "xml"], 7 | "homepage": "https://github.com/Crell/ApiProblem", 8 | "authors": [ 9 | { 10 | "name": "Larry Garfield", 11 | "email": "larry@garfieldtech.com", 12 | "homepage": "http://www.garfieldtech.com/" 13 | } 14 | ], 15 | "autoload": { 16 | "psr-4": {"Crell\\ApiProblem\\": "src/"} 17 | }, 18 | "autoload-dev": { 19 | "psr-4": { 20 | "Crell\\ApiProblem\\": "tests" 21 | } 22 | }, 23 | "require": { 24 | "php": "^7.4 || ^8.0" 25 | }, 26 | "suggest": { 27 | "psr/http-message": "Common interface for HTTP messages", 28 | "psr/http-factory": "Common interfaces for PSR-7 HTTP message factories" 29 | }, 30 | "require-dev": { 31 | "nyholm/psr7": "^1.8", 32 | "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", 33 | "psr/http-factory": "^1.0", 34 | "psr/http-message": "1.*", 35 | "phpstan/phpstan": "^1.3" 36 | }, 37 | "extra": { 38 | "branch-alias": { 39 | "dev-master": "2.0.x-dev" 40 | } 41 | }, 42 | "scripts": { 43 | "test": "php vendor/bin/phpunit", 44 | "test-coverage": "php vendor/bin/phpunit --coverage-html coverage" 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /default-.env: -------------------------------------------------------------------------------- 1 | # See https://docs.docker.com/compose/env-file/ 2 | 3 | # global configuration 4 | # For production 5 | #COMPOSE_FILE=docker-compose.yml 6 | # For local dev 7 | #COMPOSE_FILE=docker-compose.yml:docker-compose.override.yml 8 | # For local dev with tunnel 9 | 10 | COMPOSE_FILE=docker-compose.yml 11 | # Ip of the host that docker can reach 12 | HOST_IP=172.17.0.1 13 | # Xdebug IDE key 14 | IDE_KEY=docker-xdebug 15 | # Port your IDE is listening on 16 | XDEBUG_PORT=9003 17 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | # To use: 2 | # Run "docker-compose build" to rebuild the app container. 3 | # Run "docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php81 composer install" to install dependencies. 4 | # Run "docker-compose run -u $(id -u ${USER}):$(id -g ${USER})--rm php81 vendor/bin/phpunit" to run the test script on 8.1. 5 | # Run "docker-compose down -v" to fully wipe everything and start over. 6 | # Run "docker-compose run -u $(id -u ${USER}):$(id -g ${USER}) --rm php80 bash" to log into the container to run tests selectively. 7 | 8 | version: "3" 9 | services: 10 | php81: 11 | build: ./docker/php/81 12 | volumes: 13 | - ~/.composer:/.composer #uncomment this line to allow usage of local composer cache 14 | - .:/usr/src/myapp 15 | - ./docker/php/81/xdebug.ini:/usr/local/etc/php/conf.d/docker-php-ext-xdebug.ini 16 | - ./docker/php/conf.d/error_reporting.ini:/usr/local/etc/php/conf.d/error_reporting.ini 17 | environment: 18 | XDEBUG_MODE: "develop,debug" 19 | XDEBUG_CONFIG: "client_host=${HOST_IP} idekey=${IDE_KEY} client_port=${XDEBUG_PORT} discover_client_host=1 start_with_request=1" 20 | -------------------------------------------------------------------------------- /docker/php/81/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM php:8.1-cli 2 | WORKDIR /usr/src/myapp 3 | 4 | COPY --from=composer:latest /usr/bin/composer /usr/bin/composer 5 | 6 | RUN apt-get update && apt-get install zip unzip git -y \ 7 | && pecl install xdebug \ 8 | && pecl install pcov 9 | -------------------------------------------------------------------------------- /docker/php/81/xdebug.ini: -------------------------------------------------------------------------------- 1 | zend_extension=/usr/local/lib/php/extensions/no-debug-non-zts-20210902/xdebug.so 2 | xdebug.output_dir=profiles 3 | -------------------------------------------------------------------------------- /docker/php/conf.d/error_reporting.ini: -------------------------------------------------------------------------------- 1 | error_reporting=E_ALL 2 | ; By default this is GPCS - we also want E so that $_ENV would be populated, and 3 | ; we could trigger Xdebug using an environment variable on the command line. 4 | variables_order = "EGPCS" 5 | -------------------------------------------------------------------------------- /phpstan.neon.dist: -------------------------------------------------------------------------------- 1 | parameters: 2 | level: 6 3 | paths: 4 | - src 5 | - tests 6 | -------------------------------------------------------------------------------- /src/ApiProblem.php: -------------------------------------------------------------------------------- 1 | \JsonSerializable 30 | */ 31 | class ApiProblem implements \ArrayAccess, \JsonSerializable 32 | { 33 | 34 | /** 35 | * The content type for a JSON based HTTP response carrying 36 | * problem details. 37 | * 38 | * @var string 39 | */ 40 | public const CONTENT_TYPE_JSON = 'application/problem+json'; 41 | 42 | /** 43 | * The content type for a XML based HTTP response carrying 44 | * problem details. 45 | * 46 | * @var string 47 | */ 48 | public const CONTENT_TYPE_XML = 'application/problem+xml'; 49 | 50 | /** 51 | * A short, human-readable summary of the problem type. 52 | * 53 | * It SHOULD NOT change from occurrence to occurrence of the problem, 54 | * except for purposes of localization. 55 | */ 56 | protected string $title; 57 | 58 | /** 59 | * A URI reference (RFC3986) that identifies the problem type. 60 | * 61 | * This specification encourages that, when dereferenced, it provide 62 | * human-readable documentation for the problem type (e.g., using HTML 63 | * [W3C.REC-html5-20141028]). When this member is not present, its value 64 | * is assumed to be "about:blank". 65 | * 66 | * Consumers MUST use the type string as the primary identifier for the 67 | * problem type. 68 | * 69 | * This value may be an absolute or or relative URI. If relative, it MUST be 70 | * resolved relative to the document's base URI, as per RFC3986, Section 5. 71 | * 72 | * @link http://tools.ietf.org/html/rfc3986 73 | */ 74 | protected string $type; 75 | 76 | /** 77 | * The HTTP status code set by the origin server for this occurrence of the problem. 78 | * 79 | * The status member, if present, is only advisory; it conveys the HTTP 80 | * status code used for the convenience of the consumer. Generators MUST 81 | * use the same status code in the actual HTTP response, to assure that 82 | * generic HTTP software that does not understand this format still behaves 83 | * correctly. 84 | */ 85 | protected int $status = 0; 86 | 87 | /** 88 | * An human readable explanation specific to this occurrence of the problem. 89 | * 90 | * The "detail" member, if present, ought to focus on helping the client 91 | * correct the problem, rather than giving debugging information. 92 | * 93 | * Consumers SHOULD NOT parse the "detail" member for information; extensions 94 | * are more suitable and less error-prone ways to obtain such information. 95 | */ 96 | protected string $detail = ''; 97 | 98 | /** 99 | * A URI reference that identifies the specific occurrence of the problem. 100 | * 101 | * It may or may not yield further information if dereferenced. 102 | * 103 | * This value may be an absolute or or relative URI. If relative, it MUST be 104 | * resolved relative to the document's base URI, as per RFC3986, Section 5. 105 | * 106 | * @link http://tools.ietf.org/html/rfc3986 107 | */ 108 | protected string $instance = ''; 109 | 110 | /** 111 | * Any arbitrary extension properties that have been assigned on this object. 112 | * 113 | * @var array 114 | */ 115 | protected array $extensions = []; 116 | 117 | /** 118 | * Parses a JSON string into a Problem object. 119 | * 120 | * @param string $json 121 | * The JSON string to parse. 122 | * @return ApiProblem 123 | * A newly constructed problem object. 124 | * 125 | * @throws JsonParseException 126 | * Invalid JSON strings will result in a thrown exception. 127 | */ 128 | public static function fromJson(string $json): self 129 | { 130 | if (trim($json) === '') { 131 | throw new JsonParseException('An empty string is not a valid JSON value', JSON_ERROR_SYNTAX, null, $json); 132 | } 133 | $parsed = json_decode($json, true); 134 | 135 | $lastError = json_last_error(); 136 | 137 | if (\JSON_ERROR_NONE !== $lastError) { 138 | throw JsonParseException::fromJsonError($lastError, $json); 139 | } 140 | 141 | return static::decompile($parsed); 142 | } 143 | 144 | /** 145 | * Converts a SimpleXMLElement to a nested array. 146 | * 147 | * @param \SimpleXMLElement $element 148 | * The XML 149 | * @return array 150 | * A nested array corresponding to the XML element provided. 151 | */ 152 | protected static function xmlToArray(\SimpleXMLElement $element): array 153 | { 154 | $data = (array)$element; 155 | foreach ($data as $key => $value) { 156 | if ($value instanceof \SimpleXMLElement) { 157 | $data[$key] = static::xmlToArray($value); 158 | } 159 | } 160 | 161 | return $data; 162 | } 163 | 164 | /** 165 | * Parses an XML string into a Problem object. 166 | * 167 | * @param string $string 168 | * The XML string to parse. 169 | * @return ApiProblem 170 | * A newly constructed problem object. 171 | */ 172 | public static function fromXml(string $string): self 173 | { 174 | $xml = new \SimpleXMLElement($string); 175 | 176 | $data = static::xmlToArray($xml); 177 | 178 | return static::decompile($data); 179 | } 180 | 181 | /** 182 | * Parses an array into a Problem object. 183 | * 184 | * @param array $input 185 | * The array to parse. 186 | * @return ApiProblem 187 | * A newly constructed problem object. 188 | */ 189 | public static function fromArray(array $input): self 190 | { 191 | $defaultInput = ['title' => null, 'type' => null, 'status' => null, 'detail' => null, 'instance' => null]; 192 | 193 | $data = $input + $defaultInput; 194 | 195 | return self::decompile($data); 196 | } 197 | 198 | /** 199 | * Decompiles an array into an ApiProblem object. 200 | * 201 | * @param array $parsed 202 | * An array parsed from JSON or XML to turn into an ApiProblem object. 203 | * @return ApiProblem 204 | * A new ApiProblem object. 205 | */ 206 | protected static function decompile(array $parsed) : self 207 | { 208 | // This line is fine as long as the constructor has only optional arguments. That is a requirement 209 | // that cannot be enforced in code, but is effectively a requirement of the class. 210 | // @phpstan-ignore-next-line 211 | $problem = new static(); 212 | 213 | if (null !== ($title = self::filterStringValue('title', $parsed))) { 214 | $problem->setTitle($title); 215 | } 216 | 217 | if (null !== ($type = self::filterStringValue('type', $parsed))) { 218 | $problem->setType($type); 219 | } 220 | 221 | if (null !== ($status = self::filterIntValue('status', $parsed))) { 222 | $problem->setStatus($status); 223 | } 224 | 225 | if (null !== ($detail = self::filterStringValue('detail', $parsed))) { 226 | $problem->setDetail($detail); 227 | } 228 | 229 | if (null !== ($instance = self::filterStringValue('instance', $parsed))) { 230 | $problem->setInstance($instance); 231 | } 232 | 233 | // Remove the defined keys. That means whatever is left must be a custom 234 | // extension property. 235 | unset($parsed['title'], $parsed['type'], $parsed['status'], $parsed['detail'], $parsed['instance']); 236 | 237 | foreach ($parsed as $key => $value) { 238 | $problem[$key] = $value; 239 | } 240 | 241 | return $problem; 242 | } 243 | 244 | /** 245 | * Parse the incoming value as non empty string. 246 | * The returned value can be used to populate Problem string based properties. 247 | * 248 | * Skip empty string or missing values. The string 0, however is allowed. 249 | * PHP makes this ugly. 250 | * The check on string handles XML decompile which may return an empty array. 251 | * 252 | * @param string|int $key 253 | * @param array $arr 254 | * 255 | * @return string|null 256 | */ 257 | protected static function filterStringValue($key, array $arr): ?string 258 | { 259 | if (!array_key_exists($key, $arr) || !is_string($value = $arr[$key])) { 260 | return null; 261 | } 262 | 263 | if ($value === '') { 264 | return null; 265 | } 266 | 267 | return $value; 268 | } 269 | 270 | /** 271 | * Parse the incoming value as integer 272 | * The returned value can be used to populate Problem integer based properties. 273 | * 274 | * If the value can be parse as an integer it is return as one 275 | * otherwise null is returned. 276 | * 277 | * non integer value will all be discarded float included 278 | * @see https://3v4l.org/vZjLD 279 | * The check on scalar handles XML decompile which may return an empty array. 280 | * 281 | * @param int|string $key 282 | * @param array $arr 283 | * 284 | * @return int|null 285 | */ 286 | protected static function filterIntValue($key, array $arr): ?int 287 | { 288 | if (!array_key_exists($key, $arr) || !is_scalar($value = $arr[$key])) { 289 | return null; 290 | } 291 | 292 | $intValue = intval($value); 293 | if (strval($value) !== strval($intValue)) { 294 | return null; 295 | } 296 | 297 | return $intValue; 298 | } 299 | 300 | /** 301 | * Constructs a new ApiProblem. 302 | * 303 | * @param string $title 304 | * A short, human-readable summary of the problem type. It SHOULD NOT 305 | * change from occurrence to occurrence of the problem, except for 306 | * purposes of localization. 307 | * @param string $type 308 | * An absolute URI (RFC3986) that identifies the problem type. When 309 | * dereferenced, it SHOULD provide human-readable documentation for the 310 | * problem type (e.g., using HTML). 311 | */ 312 | public function __construct(string $title = '', string $type = 'about:blank') 313 | { 314 | $this->title = $title; 315 | $this->type = $type; 316 | } 317 | 318 | /** 319 | * Retrieves the title of the problem. 320 | * 321 | * @return string 322 | * The current title. 323 | */ 324 | public function getTitle(): string 325 | { 326 | return $this->title; 327 | } 328 | 329 | /** 330 | * Sets the title for this problem. 331 | * 332 | * @param string $title 333 | * The title to set. 334 | * @return ApiProblem 335 | * The invoked object. 336 | */ 337 | public function setTitle(string $title): self 338 | { 339 | $this->title = $title; 340 | return $this; 341 | } 342 | 343 | /** 344 | * Retrieves the problem type of this problem. 345 | * 346 | * @return string 347 | * The problem type URI of this problem. 348 | */ 349 | public function getType(): string 350 | { 351 | return $this->type; 352 | } 353 | 354 | /** 355 | * Sets the problem type of this problem. 356 | * 357 | * @param string $type 358 | * The resolvable problem type URI of this problem. 359 | * @return ApiProblem 360 | * The invoked object. 361 | */ 362 | public function setType(string $type): self 363 | { 364 | $this->type = $type; 365 | return $this; 366 | } 367 | 368 | /** 369 | * Retrieves the detail information of the problem. 370 | * 371 | * @return string 372 | * The detail of this problem. 373 | */ 374 | public function getDetail(): string 375 | { 376 | return $this->detail; 377 | } 378 | 379 | /** 380 | * Sets the detail for this problem. 381 | * 382 | * @param string $detail 383 | * The human-readable detail string about this problem. 384 | * @return ApiProblem 385 | * The invoked object. 386 | */ 387 | public function setDetail(string $detail): self 388 | { 389 | $this->detail = $detail; 390 | return $this; 391 | } 392 | 393 | /** 394 | * Returns the problem instance URI of this problem. 395 | * 396 | * @return string 397 | * The problem instance URI of this problem. 398 | */ 399 | public function getInstance(): string 400 | { 401 | return $this->instance; 402 | } 403 | 404 | /** 405 | * Sets the problem instance URI of this problem. 406 | * 407 | * @param string $instance 408 | * An absolute URI that uniquely identifies this problem. It MAY link to 409 | * further information about the error, but that is not required. 410 | * 411 | * @return ApiProblem 412 | * The invoked object. 413 | */ 414 | public function setInstance(string $instance): self 415 | { 416 | $this->instance = $instance; 417 | return $this; 418 | } 419 | 420 | /** 421 | * Returns the current HTTP status code. 422 | * 423 | * @return int 424 | * The current HTTP status code. If not set, it will return 0. 425 | */ 426 | public function getStatus(): int 427 | { 428 | return $this->status; 429 | } 430 | 431 | /** 432 | * Sets the HTTP status code for this problem. 433 | * 434 | * It is an error for this value to be set to a different value than the 435 | * actual HTTP response code. 436 | * 437 | * @param int $status 438 | * A valid HTTP status code. 439 | * @return ApiProblem 440 | * The invoked object. 441 | */ 442 | public function setStatus(int $status): self 443 | { 444 | $this->status = $status; 445 | return $this; 446 | } 447 | 448 | /** 449 | * Renders this problem as JSON. 450 | * 451 | * @param bool $pretty 452 | * Whether or not to pretty-print the JSON string for easier debugging. 453 | * @return string 454 | * A JSON string representing this problem. 455 | */ 456 | public function asJson(bool $pretty = false): string 457 | { 458 | $response = $this->compile(); 459 | 460 | $options = 0; 461 | if ($pretty) { 462 | $options = \JSON_UNESCAPED_SLASHES | \JSON_PRETTY_PRINT; 463 | } 464 | 465 | $json = json_encode($response, $options); 466 | 467 | if (false === $json) { 468 | throw JsonEncodeException::fromJsonError(\json_last_error(), $response); 469 | } 470 | 471 | return $json; 472 | } 473 | 474 | /** 475 | * Renders this problem as XML. 476 | * 477 | * @param bool $pretty 478 | * Whether or not to pretty-print the XML string for easier debugging. 479 | * @return string 480 | * An XML string representing this problem. 481 | */ 482 | public function asXml(bool $pretty = false): string 483 | { 484 | $doc = new \SimpleXMLElement(''); 485 | 486 | $this->arrayToXml($this->compile(), $doc); 487 | 488 | /** @var \DOMElement */ 489 | $dom = dom_import_simplexml($doc); 490 | if ($pretty) { 491 | $dom->ownerDocument->preserveWhiteSpace = false; 492 | $dom->ownerDocument->formatOutput = true; 493 | } 494 | return $dom->ownerDocument->saveXML(); 495 | } 496 | 497 | /** 498 | * Renders this problem as a native PHP array. 499 | * 500 | * This is mostly useful for debugging, or for placing 501 | * this problem response into, say, a Symfony JsonResponse object. 502 | * 503 | * @return array 504 | * The API problem represented as an array. 505 | */ 506 | public function asArray(): array 507 | { 508 | return $this->compile(); 509 | } 510 | 511 | /** 512 | * Supports rendering this problem as a JSON using the json_encode() function. 513 | * 514 | * @return array 515 | * The API problem represented as an array for rendering. 516 | */ 517 | public function jsonSerialize(): array 518 | { 519 | return $this->compile(); 520 | } 521 | 522 | /** 523 | * Compiles the object down to an array format, suitable for serializing. 524 | * 525 | * @return array 526 | * This object, rendered to an array. 527 | */ 528 | protected function compile(): array 529 | { 530 | // Start with any extensions, since that's already an array. 531 | $response = $this->extensions; 532 | 533 | // These properties are optional. 534 | foreach (['title', 'type', 'status', 'detail', 'instance'] as $key) { 535 | // Skip empty string or missing values, as they are optional. 536 | // The string or integer 0, however, are allowed. PHP makes 537 | // this ugly. 538 | if (isset($this->$key) && $this->$key !== 0 && $this->$key !== '') { 539 | $response[$key] = $this->$key; 540 | } 541 | } 542 | 543 | return $response; 544 | } 545 | 546 | /** 547 | * Adds a nested array to a SimpleXML element. 548 | * 549 | * This method was shamelessly coped from the Nocarrier\Hal library: 550 | * 551 | * @link https://github.com/blongden/hal 552 | * 553 | * @param array $data 554 | * The data to add to the element. 555 | * @param \SimpleXMLElement $element 556 | * The XML object to which to add data. 557 | * @param mixed $parent 558 | * Used for internal recursion only. 559 | */ 560 | protected function arrayToXml(array $data, \SimpleXMLElement $element, $parent = null): void 561 | { 562 | foreach ($data as $key => $value) { 563 | if (is_array($value)) { 564 | if (!is_numeric($key)) { 565 | if (count($value) > 0 && isset($value[0])) { 566 | $this->arrayToXml($value, $element, $key); 567 | } else { 568 | $subnode = $element->addChild($key); 569 | $this->arrayToXml($value, $subnode, $key); 570 | } 571 | } else { 572 | $subnode = $element->addChild($parent); 573 | $this->arrayToXml($value, $subnode, $parent); 574 | } 575 | } else { 576 | if (!is_numeric($key)) { 577 | if ($key[0] === '@') { 578 | $element->addAttribute(substr($key, 1), $value); 579 | } elseif ($key === 'value') { 580 | $element->{0} = $value; 581 | } elseif (is_bool($value)) { 582 | $element->addChild($key, strval($value)); 583 | } else { 584 | $element->addChild($key, htmlspecialchars((string) $value, ENT_QUOTES)); 585 | } 586 | } else { 587 | $element->addChild($parent, htmlspecialchars($value, ENT_QUOTES)); 588 | } 589 | } 590 | } 591 | } 592 | 593 | /** 594 | * {@inheritdoc} 595 | * @param string $offset 596 | */ 597 | public function offsetExists($offset): bool 598 | { 599 | return array_key_exists($offset, $this->extensions); 600 | } 601 | 602 | /** 603 | * {@inheritdoc} 604 | * 605 | * @param string $offset 606 | * @return mixed 607 | * 608 | * The proper return type here is `mixed`, which is only available as of 8.0. 609 | */ 610 | #[\ReturnTypeWillChange] 611 | public function &offsetGet($offset) 612 | { 613 | return $this->extensions[$offset]; 614 | } 615 | 616 | /** 617 | * {@inheritdoc} 618 | * 619 | * @param string $offset 620 | * @param mixed $value 621 | */ 622 | public function offsetSet($offset, $value): void 623 | { 624 | $this->extensions[$offset] = $value; 625 | } 626 | 627 | /** 628 | * {@inheritdoc} 629 | * 630 | * @param string $offset 631 | */ 632 | public function offsetUnset($offset): void 633 | { 634 | unset($this->extensions[$offset]); 635 | } 636 | } 637 | -------------------------------------------------------------------------------- /src/HttpConverter.php: -------------------------------------------------------------------------------- 1 | responseFactory = $responseFactory; 33 | $this->pretty = $pretty; 34 | } 35 | 36 | /** 37 | * Converts a problem to a JSON HTTP Response object, provided. 38 | * 39 | * @param ApiProblem $problem 40 | * The problem to convert. 41 | * 42 | * @return ResponseInterface 43 | * The appropriate response object. 44 | */ 45 | public function toJsonResponse(ApiProblem $problem) : ResponseInterface 46 | { 47 | $response = $this->toResponse($problem); 48 | 49 | $body = $response->getBody(); 50 | $body->write($problem->asJson($this->pretty)); 51 | $body->rewind(); 52 | 53 | return $response 54 | ->withHeader('Content-Type', ApiProblem::CONTENT_TYPE_JSON) 55 | ->withBody($body); 56 | } 57 | 58 | /** 59 | * Converts a problem to an XML HTTP Response object, provided. 60 | * 61 | * @param ApiProblem $problem 62 | * The problem to convert. 63 | * 64 | * @return ResponseInterface 65 | * The appropriate response object. 66 | */ 67 | public function toXmlResponse(ApiProblem $problem) : ResponseInterface 68 | { 69 | $response = $this->toResponse($problem); 70 | 71 | $body = $response->getBody(); 72 | $body->write($problem->asXml($this->pretty)); 73 | $body->rewind(); 74 | 75 | return $this->toResponse($problem) 76 | ->withHeader('Content-Type', ApiProblem::CONTENT_TYPE_XML) 77 | ->withBody($body); 78 | } 79 | 80 | /** 81 | * Converts a problem to a provided Response, without the format-sensitive bits. 82 | * 83 | * @param ApiProblem $problem 84 | * The problem to convert. 85 | * 86 | * @return ResponseInterface 87 | * The appropriate response object. 88 | */ 89 | protected function toResponse(ApiProblem $problem) : ResponseInterface 90 | { 91 | $status = $problem->getStatus() ?: 500; 92 | 93 | return $this->responseFactory->createResponse($status); 94 | } 95 | } 96 | -------------------------------------------------------------------------------- /src/JsonEncodeException.php: -------------------------------------------------------------------------------- 1 | 'Maximum stack depth exceeded', 16 | \JSON_ERROR_STATE_MISMATCH => 'Underflow or the modes mismatch', 17 | \JSON_ERROR_CTRL_CHAR => 'Unexpected control character found', 18 | \JSON_ERROR_SYNTAX => 'Syntax error, malformed JSON', 19 | \JSON_ERROR_UTF8 => 'Malformed UTF-8 characters, possibly incorrectly encoded', 20 | \JSON_ERROR_RECURSION => 'One or more recursive references in the value to be encoded', 21 | \JSON_ERROR_INF_OR_NAN => 'One or more NAN or INF values in the value to be encoded', 22 | \JSON_ERROR_UNSUPPORTED_TYPE => 'A value of a type that cannot be encoded was given', 23 | \JSON_ERROR_INVALID_PROPERTY_NAME => 'A property name that cannot be encoded was given', 24 | \JSON_ERROR_UTF16 => 'Malformed UTF-16 characters, possibly incorrectly encoded', 25 | ]; 26 | 27 | /** 28 | * @var mixed 29 | */ 30 | protected $failedValue; 31 | 32 | /** 33 | * Maps a JSON error code to a human-friendly error message. 34 | * 35 | * @param int $jsonError 36 | * the JSON error code, as returned by json_last_error(). 37 | * @return string 38 | */ 39 | protected static function getExceptionMessage(int $jsonError): string 40 | { 41 | return self::EXCEPTION_MESSAGES[$jsonError] ?? 'Unknown error'; 42 | } 43 | 44 | /** 45 | * Creates a new exception object based on the JSON error code. 46 | * 47 | * @param int $jsonError 48 | * the JSON error code. 49 | * @param mixed $failedValue 50 | * The value that failed to parse or encode. 51 | * @return JsonException 52 | * A new exception object. 53 | */ 54 | public static function fromJsonError(int $jsonError, $failedValue): self 55 | { 56 | // This is a valid use of `new static`, even if PHPStan is wrong about it. 57 | // @phpstan-ignore-next-line 58 | return new static(static::getExceptionMessage($jsonError), $jsonError, null, $failedValue); 59 | } 60 | 61 | /** 62 | * @param mixed $failedValue 63 | */ 64 | public function __construct(string $message = '', int $code = 0, \Throwable $previous = null, $failedValue = null) 65 | { 66 | parent::__construct($message, $code, $previous); 67 | $this->setFailedValue($failedValue); 68 | } 69 | 70 | /** 71 | * Sets the value that failed to parse or encode so it can be analyzed later. 72 | * 73 | * @param mixed $failedValue 74 | * The value that failed to parse or encode correctly. 75 | * @return JsonException 76 | * The invoked object. 77 | */ 78 | public function setFailedValue($failedValue) : self 79 | { 80 | $this->failedValue = $failedValue; 81 | return $this; 82 | } 83 | 84 | /** 85 | * Returns the value that failed to parse or encode properly. 86 | * 87 | * @return mixed 88 | * The value that failed to process. 89 | */ 90 | public function getFailedValue() 91 | { 92 | return $this->failedValue; 93 | } 94 | } 95 | -------------------------------------------------------------------------------- /src/JsonParseException.php: -------------------------------------------------------------------------------- 1 | setFailedValue($failedValue); 19 | } 20 | } 21 | --------------------------------------------------------------------------------