Repository: hotmeteor/spectator (php)

Issue:
application/problem+json giving strange expected/actual comparison
The recent @ethanbray PR #104 made some improvements to `application/problem+json` handling but I still have other probems using it.

```
^ {#5230
  +"exception": "Spectator\Exceptions\ResponseValidationException"
  +"message": """
    Response did not match any specified content type.\n
    \n
      Expected: application/json\n
      Actual: DNE\n
    \n
      ---
    """
  +"specErrors": []
}
```

Test:

```php
it('returns a 404 for invalid record', function () {
    $non_existent_uuid = '53d4faeb-e046-4ab1-91ff-6b6e35c4c052';
    $this
        ->withHeaders(['Authorization' => 'Bearer '.$this->api_key])
        ->getJson(sprintf('/orders/%s', $non_existent_uuid))
        ->dump()
        ->assertValidResponse(404);
});
```

OpenAPI:

```yaml

  '/orders/{order}':
    parameters:
      - in: path
        name: order
        required: true
        schema:
          type: string
          format: uuid
          example: 53f7bd7b-2ee2-4be4-a15b-e09c76a150f9
    get:
      operationId: get-orders-uuid
      summary: Get Order
      description: 'Orders are made by organizations to fund tree planting, and when trees are planted those orders will be marked as fullfilled. Orders may be partially fulfilled, and it is up to you if you wish to import those. Make sure to deduplicate the trees if you do.'
      tags:
        - Order
      responses:
        '200':
          description: OK
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Order'
        '401':
          description: Unauthorized
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Generic_Problem'
        '404':
          description: Organization not found.
          content:
            application/problem+json:
              schema:
                $ref: '#/components/schemas/Generic_Problem'
```

I tried switching the $ref to just a `type: object` but thats not making any difference.

_Originally posted by @philsturgeon in https://github.com/hotmeteor/spectator/issues/103#issuecomment-1196498686_

Pull request:
#114
Better handling of `application/problem+json` responses when frameworks return `application/json` on errors.

Resolves #114 

Patch:
diff --git a/src/Validation/ResponseValidator.php b/src/Validation/ResponseValidator.php
index 27f3562..1e66655 100644
--- a/src/Validation/ResponseValidator.php
+++ b/src/Validation/ResponseValidator.php
@@ -5,9 +5,10 @@ namespace Spectator\Validation;
 use cebe\openapi\spec\Operation;
 use cebe\openapi\spec\Response;
 use cebe\openapi\spec\Schema;
-use Opis\JsonSchema\ValidationResult;
+use Illuminate\Support\Str;
 use Opis\JsonSchema\Validator;
 use Spectator\Exceptions\ResponseValidationException;
+use Spectator\Exceptions\SchemaValidationException;
 
 class ResponseValidator extends AbstractValidator
 {
@@ -34,6 +35,7 @@ class ResponseValidator extends AbstractValidator
 
     /**
      * @throws ResponseValidationException
+     * @throws SchemaValidationException
      */
     protected function handle()
     {
@@ -46,17 +48,27 @@ class ResponseValidator extends AbstractValidator
 
     /**
      * @throws ResponseValidationException
+     * @throws SchemaValidationException
      */
     protected function parseResponse(Response $response)
     {
         $contentType = $this->contentType();
 
+        // This is a bit hacky, but will allow resolving other JSON responses like application/problem+json
+        // when returning standard JSON responses from frameworks (See hotmeteor/spectator#114)
+
+        $specTypes = array_values(array_map(
+            fn ($type) => $contentType === 'application/json' && Str::endsWith($type, '+json') ? 'application/json' : $type,
+            array_keys($response->content)
+        ));
+
         // Does the response match any of the specified media types?
-        if (! array_key_exists($contentType, $response->content)) {
+        if (! in_array($contentType, $specTypes)) {
             $message = 'Response did not match any specified content type.';
-            $message .= PHP_EOL.PHP_EOL.'  Expected: '.$contentType;
-            $message .= PHP_EOL.'  Actual: DNE';
+            $message .= PHP_EOL.PHP_EOL.'  Expected: '.$specTypes[0];
+            $message .= PHP_EOL.'  Actual: '.$contentType;
             $message .= PHP_EOL.PHP_EOL.'  ---';
+
             throw new ResponseValidationException($message);
         }
 
@@ -69,9 +81,11 @@ class ResponseValidator extends AbstractValidator
     }
 
     /**
+     * @param  Schema  $schema
      * @param $body
      *
      * @throws ResponseValidationException
+     * @throws SchemaValidationException
      */
     protected function validateResponse(Schema $schema, $body)
     {
@@ -80,11 +94,9 @@ class ResponseValidator extends AbstractValidator
         $validator = $this->validator();
         $result = $validator->validate($body, $expected_schema);
 
-        if ($result instanceof ValidationResult && $result->isValid() === false) {
+        if ($result->isValid() === false) {
             $message = ResponseValidationException::validationErrorMessage($expected_schema, $result->error());
             throw ResponseValidationException::withError($message, $result->error());
-        } elseif ($result->isValid() === false) {
-            throw ResponseValidationException::withError($result->error()->message(), $result->error());
         }
     }
 


Test patch:
diff --git a/tests/Fixtures/Test.v1.json b/tests/Fixtures/Test.v1.json
index f06e5e7..a61b479 100644
--- a/tests/Fixtures/Test.v1.json
+++ b/tests/Fixtures/Test.v1.json
@@ -362,9 +362,82 @@
         },
         "operationId": "get-posts-post-comment"
       }
+    },
+    "/orders/{order}": {
+      "parameters": [
+        {
+          "schema": {
+            "type": "string",
+            "format": "uuid",
+            "example": "53f7bd7b-2ee2-4be4-a15b-e09c76a150f9"
+          },
+          "name": "order",
+          "in": "path",
+          "required": true
+        }
+      ],
+      "get": {
+        "summary": "Get Order",
+        "description": "Orders are made by organizations to fund tree planting, and when trees are planted those orders will be marked as fullfilled. Orders may be partially fulfilled, and it is up to you if you wish to import those. Make sure to deduplicate the trees if you do.",
+        "tags": [],
+        "responses": {
+          "200": {
+            "description": "OK",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Order"
+                }
+              }
+            }
+          },
+          "401": {
+            "description": "Unauthorized",
+            "content": {
+              "application/problem+json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Generic_Problem"
+                }
+              }
+            }
+          },
+          "404": {
+            "description": "Order not found",
+            "content": {
+              "application/problem+json": {
+                "schema": {
+                  "$ref": "#/components/schemas/Generic_Problem"
+                }
+              }
+            }
+          }
+        },
+        "operationId": "get-orders-uuid"
+      }
     }
   },
   "components": {
-    "schemas": {}
+    "schemas": {
+      "Order": {
+        "type": "object",
+        "properties": {
+          "uuid": {
+            "type": "string",
+            "format": "uuid"
+          }
+        }
+      },
+      "Generic_Problem": {
+        "type": "object",
+        "properties": {
+          "message": {
+            "type": "string"
+          },
+          "code": {
+            "type": "integer"
+          }
+        }
+      }
+    }
   }
 }
diff --git a/tests/ResponseValidatorTest.php b/tests/ResponseValidatorTest.php
index 4bec98c..4686616 100644
--- a/tests/ResponseValidatorTest.php
+++ b/tests/ResponseValidatorTest.php
@@ -115,6 +115,29 @@ class ResponseValidatorTest extends TestCase
             ->assertValidationMessage('All array items must match schema');
     }
 
+    public function test_validates_problem_json_response_using_components()
+    {
+        Spectator::using('Test.v1.json');
+
+        $uuid = (string) Str::uuid();
+
+        Route::get('/orders/{order}', static function ($order) use ($uuid) {
+            if ($order !== $uuid) {
+                abort(404);
+            }
+
+            return [
+                'uuid' => $uuid,
+            ];
+        })->middleware(Middleware::class);
+
+        $response = $this->getJson("/orders/{$uuid}")
+            ->assertValidResponse(200);
+
+        $response = $this->getJson('/orders/invalid')
+            ->assertValidResponse(404);
+    }
+
     public function test_fallback_to_request_uri_if_operationId_not_given(): void
     {
         Spectator::using('Test.v1.json');