├── LICENSE ├── composer.json ├── renovate.json └── src ├── ContentCouldNotBeFormatted.php ├── ContentTypeMiddleware.php ├── Formatter.php ├── Formatter ├── ContentOnly.php ├── JmsSerializer.php ├── Json.php ├── NotAcceptable.php ├── Plates.php ├── StringCast.php └── Twig.php └── UnformattedResponse.php /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Luís Cobucci 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "lcobucci/content-negotiation-middleware", 3 | "description": "A PSR-15 middleware to handle content negotiation", 4 | "license": "MIT", 5 | "type": "library", 6 | "authors": [ 7 | { 8 | "name": "Luís Cobucci", 9 | "email": "lcobucci@gmail.com" 10 | } 11 | ], 12 | "require": { 13 | "php": "~8.3.0 || ~8.4.0", 14 | "ext-json": "*", 15 | "fig/http-message-util": "^1.1.5", 16 | "psr/http-factory": "^1.1", 17 | "psr/http-message": "^2.0", 18 | "psr/http-server-middleware": "^1.0.2" 19 | }, 20 | "require-dev": { 21 | "infection/infection": "^0.29", 22 | "jms/serializer": "^3.31", 23 | "laminas/laminas-diactoros": "^3.5", 24 | "lcobucci/coding-standard": "^11.1", 25 | "league/plates": "^3.6", 26 | "middlewares/negotiation": "^2.1", 27 | "phpstan/extension-installer": "^1.4.3", 28 | "phpstan/phpstan": "^2.0", 29 | "phpstan/phpstan-deprecation-rules": "^2.0", 30 | "phpstan/phpstan-phpunit": "^2.0", 31 | "phpstan/phpstan-strict-rules": "^2.0", 32 | "phpunit/phpunit": "^11.4", 33 | "twig/twig": "^3.16" 34 | }, 35 | "suggest": { 36 | "jms/serializer": "For content formatting using a more flexible serializer", 37 | "laminas/laminas-diactoros": "For concrete implementation of PSR-7", 38 | "league/plates": "For content formatting using Plates as template engine", 39 | "middlewares/negotiation": "For acceptable format identification", 40 | "twig/twig": "For content formatting using Twig as template engine" 41 | }, 42 | "autoload": { 43 | "psr-4": { 44 | "Lcobucci\\ContentNegotiation\\": "src" 45 | } 46 | }, 47 | "autoload-dev": { 48 | "psr-4": { 49 | "Lcobucci\\ContentNegotiation\\Tests\\": "tests" 50 | } 51 | }, 52 | "config": { 53 | "allow-plugins": { 54 | "dealerdirect/phpcodesniffer-composer-installer": true, 55 | "infection/extension-installer": true, 56 | "phpstan/extension-installer": true 57 | }, 58 | "preferred-install": "dist", 59 | "sort-packages": true 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /renovate.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://docs.renovatebot.com/renovate-schema.json", 3 | "extends": [ 4 | "local>lcobucci/.github:renovate-config" 5 | ] 6 | } 7 | -------------------------------------------------------------------------------- /src/ContentCouldNotBeFormatted.php: -------------------------------------------------------------------------------- 1 | negotiator->process($request, $handler); 51 | 52 | if (! $response instanceof UnformattedResponse) { 53 | return $response; 54 | } 55 | 56 | $contentType = $this->extractContentType($response->getHeaderLine('Content-Type')); 57 | 58 | return ($this->formatters[$contentType] ?? new NotAcceptable()) 59 | ->format($response, $this->streamFactory); 60 | } 61 | 62 | private function extractContentType(string $contentType): string 63 | { 64 | $charsetSeparatorPosition = strpos($contentType, ';'); 65 | 66 | if ($charsetSeparatorPosition === false) { 67 | return $contentType; 68 | } 69 | 70 | return substr($contentType, 0, $charsetSeparatorPosition); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/Formatter.php: -------------------------------------------------------------------------------- 1 | withBody( 18 | $streamFactory->createStream( 19 | $this->formatContent($response->getUnformattedContent(), $response->getAttributes()), 20 | ), 21 | ); 22 | } 23 | 24 | /** @param mixed[] $attributes */ 25 | abstract public function formatContent(mixed $content, array $attributes = []): string; 26 | } 27 | -------------------------------------------------------------------------------- /src/Formatter/JmsSerializer.php: -------------------------------------------------------------------------------- 1 | serializer->serialize($content, $this->format); 26 | } catch (Throwable $exception) { 27 | throw new ContentCouldNotBeFormatted( 28 | sprintf('Given content could not be formatted in %s using JMS Serializer', $this->format), 29 | $exception->getCode(), 30 | $exception, 31 | ); 32 | } 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /src/Formatter/Json.php: -------------------------------------------------------------------------------- 1 | flags | JSON_THROW_ON_ERROR); 33 | } catch (Throwable $exception) { 34 | throw new ContentCouldNotBeFormatted( 35 | sprintf('An exception was thrown during JSON formatting: %s', $exception->getMessage()), 36 | $exception->getCode(), 37 | $exception, 38 | ); 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/Formatter/NotAcceptable.php: -------------------------------------------------------------------------------- 1 | withBody($streamFactory->createStream()) 17 | ->withStatus(StatusCodeInterface::STATUS_NOT_ACCEPTABLE); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/Formatter/Plates.php: -------------------------------------------------------------------------------- 1 | render($content, $attributes); 28 | } catch (Throwable $exception) { 29 | throw new ContentCouldNotBeFormatted( 30 | 'An error occurred while formatting using plates', 31 | $exception->getCode(), 32 | $exception, 33 | ); 34 | } 35 | } 36 | 37 | /** 38 | * @param mixed[] $attributes 39 | * 40 | * @throws Throwable 41 | */ 42 | private function render(mixed $content, array $attributes = []): string 43 | { 44 | $template = $attributes[$this->attributeName] ?? ''; 45 | assert(is_string($template)); 46 | 47 | return $this->engine->render($template, ['content' => $content]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Formatter/StringCast.php: -------------------------------------------------------------------------------- 1 | render($content, $attributes); 28 | } catch (Throwable $exception) { 29 | throw new ContentCouldNotBeFormatted( 30 | 'An error occurred while formatting using twig', 31 | $exception->getCode(), 32 | $exception, 33 | ); 34 | } 35 | } 36 | 37 | /** 38 | * @param mixed[] $attributes 39 | * 40 | * @throws Throwable 41 | */ 42 | private function render(mixed $content, array $attributes = []): string 43 | { 44 | $template = $attributes[$this->attributeName] ?? ''; 45 | assert(is_string($template)); 46 | 47 | return $this->environment->render($template, ['content' => $content]); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/UnformattedResponse.php: -------------------------------------------------------------------------------- 1 | $attributes */ 12 | public function __construct( 13 | private readonly ResponseInterface $decoratedResponse, 14 | private readonly mixed $unformattedContent, 15 | private readonly array $attributes = [], 16 | ) { 17 | } 18 | 19 | public function getUnformattedContent(): mixed 20 | { 21 | return $this->unformattedContent; 22 | } 23 | 24 | public function getProtocolVersion(): string 25 | { 26 | return $this->decoratedResponse->getProtocolVersion(); 27 | } 28 | 29 | public function withProtocolVersion(string $version): self 30 | { 31 | return new self( 32 | $this->decoratedResponse->withProtocolVersion($version), 33 | $this->unformattedContent, 34 | $this->attributes, 35 | ); 36 | } 37 | 38 | /** {@inheritDoc} */ 39 | public function getHeaders(): array 40 | { 41 | return $this->decoratedResponse->getHeaders(); 42 | } 43 | 44 | public function hasHeader(string $name): bool 45 | { 46 | return $this->decoratedResponse->hasHeader($name); 47 | } 48 | 49 | /** {@inheritDoc} */ 50 | public function getHeader(string $name): array 51 | { 52 | return $this->decoratedResponse->getHeader($name); 53 | } 54 | 55 | /** {@inheritDoc} */ 56 | public function getHeaderLine(string $name): string 57 | { 58 | return $this->decoratedResponse->getHeaderLine($name); 59 | } 60 | 61 | public function withHeader(string $name, mixed $value): self 62 | { 63 | return new self( 64 | $this->decoratedResponse->withHeader($name, $value), 65 | $this->unformattedContent, 66 | $this->attributes, 67 | ); 68 | } 69 | 70 | public function withAddedHeader(string $name, mixed $value): self 71 | { 72 | return new self( 73 | $this->decoratedResponse->withAddedHeader($name, $value), 74 | $this->unformattedContent, 75 | $this->attributes, 76 | ); 77 | } 78 | 79 | public function withoutHeader(string $name): self 80 | { 81 | return new self( 82 | $this->decoratedResponse->withoutHeader($name), 83 | $this->unformattedContent, 84 | $this->attributes, 85 | ); 86 | } 87 | 88 | public function getBody(): StreamInterface 89 | { 90 | return $this->decoratedResponse->getBody(); 91 | } 92 | 93 | public function withBody(StreamInterface $body): self 94 | { 95 | return new self( 96 | $this->decoratedResponse->withBody($body), 97 | $this->unformattedContent, 98 | $this->attributes, 99 | ); 100 | } 101 | 102 | public function getStatusCode(): int 103 | { 104 | return $this->decoratedResponse->getStatusCode(); 105 | } 106 | 107 | public function withStatus(int $code, string $reasonPhrase = ''): self 108 | { 109 | return new self( 110 | $this->decoratedResponse->withStatus($code, $reasonPhrase), 111 | $this->unformattedContent, 112 | $this->attributes, 113 | ); 114 | } 115 | 116 | public function getReasonPhrase(): string 117 | { 118 | return $this->decoratedResponse->getReasonPhrase(); 119 | } 120 | 121 | /** 122 | * Returns an instance with the specified attribute 123 | */ 124 | public function withAttribute(string $name, mixed $value): self 125 | { 126 | return new self( 127 | $this->decoratedResponse, 128 | $this->unformattedContent, 129 | [$name => $value] + $this->attributes, 130 | ); 131 | } 132 | 133 | /** 134 | * Retrieve the configured attributes 135 | * 136 | * @return array 137 | */ 138 | public function getAttributes(): array 139 | { 140 | return $this->attributes; 141 | } 142 | } 143 | --------------------------------------------------------------------------------