44
55namespace Api \App \Middleware ;
66
7+ use Api \App \Exception \NotAcceptableException ;
8+ use Api \App \Exception \UnsupportedMediaTypeException ;
9+ use Core \App \Message ;
710use Dot \DependencyInjection \Attribute \Inject ;
811use Fig \Http \Message \StatusCodeInterface ;
912use Laminas \Diactoros \Response \JsonResponse ;
1316use Psr \Http \Server \MiddlewareInterface ;
1417use Psr \Http \Server \RequestHandlerInterface ;
1518
16- use function array_filter ;
17- use function array_intersect ;
18- use function array_map ;
19+ use function count ;
1920use function explode ;
2021use function in_array ;
2122use function is_array ;
23+ use function preg_match ;
2224use function str_contains ;
23- use function strtok ;
25+ use function str_ends_with ;
26+ use function str_starts_with ;
27+ use function strpos ;
28+ use function substr ;
2429use function trim ;
30+ use function usort ;
2531
26- readonly class ContentNegotiationMiddleware implements MiddlewareInterface
32+ class ContentNegotiationMiddleware implements MiddlewareInterface
2733{
34+ public const DEFAULT_HEADERS = 'default ' ;
35+
2836 #[Inject(
2937 'config.content-negotiation ' ,
3038 )]
3139 public function __construct (
32- private array $ config ,
40+ private readonly array $ config ,
3341 ) {
3442 }
3543
44+ /**
45+ * @throws NotAcceptableException
46+ * @throws UnsupportedMediaTypeException
47+ */
3648 public function process (
3749 ServerRequestInterface $ request ,
3850 RequestHandlerInterface $ handler
@@ -44,92 +56,180 @@ public function process(
4456
4557 $ routeName = (string ) $ routeResult ->getMatchedRouteName ();
4658
47- $ accept = $ this ->formatAcceptRequest ($ request ->getHeaderLine ('Accept ' ));
48- if (! $ this ->checkAccept ($ routeName , $ accept )) {
49- return $ this ->notAcceptableResponse ('Not Acceptable ' );
59+ // Parse Accept header including quality values
60+ $ acceptedTypes = $ this ->parseAcceptHeader ($ request ->getHeaderLine ('Accept ' ));
61+ if (count ($ acceptedTypes ) === 0 ) {
62+ // If no Accept header is provided, assume a wildcard
63+ $ acceptedTypes = [['mediaType ' => '*/* ' , 'quality ' => 1.0 ]];
5064 }
5165
52- $ contentType = $ request -> getHeaderLine ( ' Content-Type ' );
53- if (! $ this ->checkContentType ( $ routeName , $ contentType )) {
54- return $ this -> unsupportedMediaTypeResponse ( ' Unsupported Media Type ' );
66+ $ supportedTypes = $ this -> getConfiguredTypes ( $ routeName , ' Accept ' );
67+ if (! $ this ->isAcceptable ( $ acceptedTypes , $ supportedTypes )) {
68+ throw NotAcceptableException:: create (Message:: notAcceptable ( $ supportedTypes ) );
5569 }
5670
57- $ response = $ handler ->handle ($ request );
58-
59- $ responseContentType = $ response ->getHeaderLine ('Content-Type ' );
71+ $ contentTypeHeader = $ request ->getHeaderLine ('Content-Type ' );
72+ if (! empty ($ contentTypeHeader )) {
73+ $ contentType = $ this ->parseContentTypeHeader ($ contentTypeHeader );
74+ $ acceptableContentTypes = $ this ->getConfiguredTypes ($ routeName , 'Content-Type ' );
75+ if (! $ this ->isContentTypeSupported ($ contentType , $ acceptableContentTypes )) {
76+ throw UnsupportedMediaTypeException::create (Message::unsupportedMediaType ($ acceptableContentTypes ));
77+ }
78+ }
6079
61- if (! $ this ->validateResponseContentType ($ responseContentType , $ accept )) {
62- return $ this ->notAcceptableResponse ('Unable to resolve Accept header to a representation ' );
80+ $ response = $ handler ->handle ($ request );
81+ if (! $ this ->isResponseContentTypeValid ($ response ->getHeaderLine ('Content-Type ' ), $ acceptedTypes )) {
82+ throw NotAcceptableException::create ('Unable to provide content in any of the accepted formats. ' );
6383 }
6484
6585 return $ response ;
6686 }
6787
68- public function formatAcceptRequest (string $ accept ): array
88+ private function parseAcceptHeader (string $ header ): array
6989 {
70- $ accept = array_map (
71- fn ($ item ): string => trim ((string ) strtok ($ item , '; ' )),
72- explode (', ' , $ accept )
73- );
90+ if (empty ($ header )) {
91+ return [];
92+ }
7493
75- return array_filter ($ accept );
94+ $ types = [];
95+ $ parts = explode (', ' , $ header );
96+
97+ foreach ($ parts as $ part ) {
98+ $ part = trim ($ part );
99+ $ quality = 1.0 ;
100+ $ mediaType = $ part ;
101+ if (str_contains ($ part , '; ' )) {
102+ [$ mediaType , $ parameters ] = explode ('; ' , $ part , 2 );
103+
104+ $ mediaType = trim ($ mediaType );
105+
106+ // Extract quality value if present
107+ if (preg_match ('/q=([0-9]*\.?[0-9]+)/ ' , $ parameters , $ matches )) {
108+ $ quality = (float ) $ matches [1 ];
109+ }
110+ }
111+
112+ // Skip empty media types
113+ if (empty ($ mediaType )) {
114+ continue ;
115+ }
116+
117+ $ types [] = [
118+ 'mediaType ' => $ mediaType ,
119+ 'quality ' => $ quality ,
120+ ];
121+ }
122+
123+ // Sort by quality in descending order
124+ usort ($ types , fn ($ a , $ b ) => $ b ['quality ' ] <=> $ a ['quality ' ]);
125+
126+ return $ types ;
76127 }
77128
78- public function checkAccept (string $ routeName , array $ accept ): bool
129+ private function parseContentTypeHeader (string $ header ): array
79130 {
80- if (in_array ( ' */* ' , $ accept , true )) {
81- return true ;
131+ if (empty ( $ header )) {
132+ return [] ;
82133 }
83134
84- $ acceptList = $ this ->config ['default ' ]['Accept ' ] ?? [];
85- if (! empty ($ this ->config [$ routeName ]['Accept ' ])) {
86- $ acceptList = $ this ->config [$ routeName ]['Accept ' ] ?? [];
87- }
135+ $ parts = explode ('; ' , $ header );
88136
89- if (is_array ($ acceptList )) {
90- return ! empty (array_intersect ($ accept , $ acceptList ));
91- } else {
92- return in_array ($ acceptList , $ accept , true );
137+ $ params = [];
138+ for ($ i = 1 ; $ i < count ($ parts ); $ i ++) {
139+ $ paramParts = explode ('= ' , $ parts [$ i ], 2 );
140+ if (count ($ paramParts ) === 2 ) {
141+ $ params [trim ($ paramParts [0 ])] = trim ($ paramParts [1 ], ' " \'' );
142+ }
93143 }
144+
145+ return [
146+ 'mediaType ' => trim ($ parts [0 ]),
147+ 'parameters ' => $ params ,
148+ ];
94149 }
95150
96- public function checkContentType (string $ routeName , string $ contentType ): bool
151+ private function getConfiguredTypes (string $ routeName , string $ headerType ): array
97152 {
98- if (empty ($ contentType )) {
99- return true ;
153+ $ types = $ this ->config [self ::DEFAULT_HEADERS ][$ headerType ] ?? [];
154+ if (! empty ($ this ->config [$ routeName ][$ headerType ])) {
155+ $ types = $ this ->config [$ routeName ][$ headerType ];
100156 }
101157
102- $ contentType = explode ('; ' , $ contentType );
158+ return is_array ($ types ) ? $ types : [$ types ];
159+ }
103160
104- $ acceptList = $ this ->config ['default ' ]['Content-Type ' ] ?? [];
105- if (! empty ($ this ->config [$ routeName ]['Content-Type ' ])) {
106- $ acceptList = $ this ->config [$ routeName ]['Content-Type ' ] ?? [];
161+ private function isAcceptable (array $ acceptedTypes , array $ supportedTypes ): bool
162+ {
163+ foreach ($ acceptedTypes as $ accept ) {
164+ // Wildcard accept
165+ if ($ accept ['mediaType ' ] === '*/* ' ) {
166+ return true ;
167+ }
168+
169+ // Type a wildcard like image/*
170+ if (str_ends_with ($ accept ['mediaType ' ], '/* ' )) {
171+ $ prefix = substr ($ accept ['mediaType ' ], 0 , strpos ($ accept ['mediaType ' ], '/* ' ));
172+ foreach ($ supportedTypes as $ supported ) {
173+ if (str_starts_with ($ supported , $ prefix . '/ ' )) {
174+ return true ;
175+ }
176+ }
177+ }
178+
179+ // Direct match
180+ if (in_array ($ accept ['mediaType ' ], $ supportedTypes , true )) {
181+ return true ;
182+ }
107183 }
108184
109- if (is_array ($ acceptList )) {
110- return ! empty (array_intersect ($ contentType , $ acceptList ));
111- } else {
112- return in_array ($ acceptList , $ contentType , true );
113- }
185+ return false ;
114186 }
115187
116- public function validateResponseContentType (? string $ contentType , array $ accept ): bool
188+ private function isContentTypeSupported ( array $ contentType , array $ supportedTypes ): bool
117189 {
118- if (in_array ( ' */* ' , $ accept , true )) {
190+ if (empty ( $ contentType )) {
119191 return true ;
120192 }
121193
122- if (null === $ contentType ) {
123- return false ;
124- }
194+ return in_array ($ contentType ['mediaType ' ], $ supportedTypes , true );
195+ }
125196
126- $ accept = array_map (fn (string $ item ): string => str_contains ($ item , 'json ' ) ? 'json ' : $ item , $ accept );
197+ private function isResponseContentTypeValid (?string $ responseType , array $ acceptedTypes ): bool
198+ {
199+ if (empty ($ responseType )) {
200+ return true ;
201+ }
127202
128- if (str_contains ($ contentType , 'json ' )) {
129- $ contentType = 'json ' ;
203+ // Parse response content type to handle parameters
204+ $ parts = explode ('; ' , $ responseType );
205+ $ mediaType = trim ($ parts [0 ]);
206+
207+ // Check for wildcard accept
208+ foreach ($ acceptedTypes as $ accept ) {
209+ if ($ accept ['mediaType ' ] === '*/* ' ) {
210+ return true ;
211+ }
212+
213+ // Type a wildcard like image/*
214+ if (str_ends_with ($ accept ['mediaType ' ], '/* ' )) {
215+ $ prefix = substr ($ accept ['mediaType ' ], 0 , strpos ($ accept ['mediaType ' ], '/* ' ));
216+ if (str_starts_with ($ mediaType , $ prefix . '/ ' )) {
217+ return true ;
218+ }
219+ }
220+
221+ // Handle +json suffix matching
222+ if (str_ends_with ($ mediaType , '+json ' ) && str_ends_with ($ accept ['mediaType ' ], '+json ' )) {
223+ return true ;
224+ }
225+
226+ // Direct match
227+ if ($ mediaType === $ accept ['mediaType ' ]) {
228+ return true ;
229+ }
130230 }
131231
132- return in_array ( $ contentType , $ accept , true ) ;
232+ return false ;
133233 }
134234
135235 public function notAcceptableResponse (string $ message ): ResponseInterface
0 commit comments