1010use Opis \JsonSchema \Validator ;
1111
1212use function array_keys ;
13+ use function implode ;
1314use function json_decode ;
1415use function json_encode ;
1516use function str_ends_with ;
17+ use function strstr ;
1618use function strtolower ;
19+ use function trim ;
1720
1821final class OpenApiResponseValidator
1922{
@@ -23,6 +26,7 @@ public function validate(
2326 string $ requestPath ,
2427 int $ statusCode ,
2528 mixed $ responseBody ,
29+ ?string $ responseContentType = null ,
2630 ): OpenApiValidationResult {
2731 $ spec = OpenApiSpecLoader::load ($ specName );
2832
@@ -45,7 +49,7 @@ public function validate(
4549 if (!isset ($ pathSpec [$ lowerMethod ])) {
4650 return OpenApiValidationResult::failure ([
4751 "Method {$ method } not defined for path {$ matchedPath } in ' {$ specName }' spec. " ,
48- ]);
52+ ], $ matchedPath );
4953 }
5054
5155 $ statusCodeStr = (string ) $ statusCode ;
@@ -54,7 +58,7 @@ public function validate(
5458 if (!isset ($ responses [$ statusCodeStr ])) {
5559 return OpenApiValidationResult::failure ([
5660 "Status code {$ statusCode } not defined for {$ method } {$ matchedPath } in ' {$ specName }' spec. " ,
57- ]);
61+ ], $ matchedPath );
5862 }
5963
6064 $ responseSpec = $ responses [$ statusCodeStr ];
@@ -66,6 +70,32 @@ public function validate(
6670
6771 /** @var array<string, array<string, mixed>> $content */
6872 $ content = $ responseSpec ['content ' ];
73+
74+ // When the actual response Content-Type is provided, handle content negotiation:
75+ // non-JSON types are checked for spec presence only, while JSON-compatible types
76+ // fall through to schema validation against the first JSON media type in the spec.
77+ if ($ responseContentType !== null ) {
78+ $ normalizedType = $ this ->normalizeMediaType ($ responseContentType );
79+
80+ if (!$ this ->isJsonContentType ($ normalizedType )) {
81+ // Non-JSON response: check if the content type is defined in the spec.
82+ if ($ this ->isContentTypeInSpec ($ normalizedType , $ content )) {
83+ return OpenApiValidationResult::success ($ matchedPath );
84+ }
85+
86+ $ defined = implode (', ' , array_keys ($ content ));
87+
88+ return OpenApiValidationResult::failure ([
89+ "Response Content-Type ' {$ normalizedType }' is not defined for {$ method } {$ matchedPath } (status {$ statusCode }) in ' {$ specName }' spec. Defined content types: {$ defined }" ,
90+ ], $ matchedPath );
91+ }
92+
93+ // JSON-compatible response: fall through to existing JSON schema validation.
94+ // JSON types are treated as interchangeable (e.g. application/vnd.api+json
95+ // validates against an application/json spec entry) because the schema is
96+ // the same regardless of the specific JSON media type.
97+ }
98+
6999 $ jsonContentType = $ this ->findJsonContentType ($ content );
70100
71101 // If no JSON-compatible content type is defined, skip body validation.
@@ -82,7 +112,7 @@ public function validate(
82112 if ($ responseBody === null ) {
83113 return OpenApiValidationResult::failure ([
84114 "Response body is empty but {$ method } {$ matchedPath } (status {$ statusCode }) defines a JSON-compatible response schema in ' {$ specName }' spec. " ,
85- ]);
115+ ], $ matchedPath );
86116 }
87117
88118 /** @var array<string, mixed> $schema */
@@ -121,7 +151,7 @@ public function validate(
121151 }
122152 }
123153
124- return OpenApiValidationResult::failure ($ errors );
154+ return OpenApiValidationResult::failure ($ errors, $ matchedPath );
125155 }
126156
127157 /**
@@ -138,11 +168,51 @@ private function findJsonContentType(array $content): ?string
138168 foreach ($ content as $ contentType => $ mediaType ) {
139169 $ lower = strtolower ($ contentType );
140170
141- if ($ lower === ' application/json ' || str_ends_with ($ lower, ' +json ' )) {
171+ if ($ this -> isJsonContentType ($ lower )) {
142172 return $ contentType ;
143173 }
144174 }
145175
146176 return null ;
147177 }
178+
179+ /**
180+ * Extract the media type portion before any parameters (e.g. charset),
181+ * and return it lower-cased.
182+ *
183+ * Example: "text/html; charset=utf-8" → "text/html"
184+ */
185+ private function normalizeMediaType (string $ contentType ): string
186+ {
187+ $ mediaType = strstr ($ contentType , '; ' , true );
188+
189+ return strtolower (trim ($ mediaType !== false ? $ mediaType : $ contentType ));
190+ }
191+
192+ /**
193+ * Check whether the given (already normalised, lower-cased) response content
194+ * type matches any content type key defined in the spec. Spec keys are
195+ * lower-cased before comparison.
196+ *
197+ * @param array<string, array<string, mixed>> $content
198+ */
199+ private function isContentTypeInSpec (string $ responseContentType , array $ content ): bool
200+ {
201+ foreach ($ content as $ specContentType => $ mediaType ) {
202+ if (strtolower ($ specContentType ) === $ responseContentType ) {
203+ return true ;
204+ }
205+ }
206+
207+ return false ;
208+ }
209+
210+ /**
211+ * True for "application/json" or any "+json" structured syntax suffix (RFC 6838).
212+ * Expects a lower-cased media type without parameters.
213+ */
214+ private function isJsonContentType (string $ lowerContentType ): bool
215+ {
216+ return $ lowerContentType === 'application/json ' || str_ends_with ($ lowerContentType , '+json ' );
217+ }
148218}
0 commit comments