0
0
Fork 0
mirror of https://github.com/discourse/discourse.git synced 2026-08-06 13:08:40 +08:00
discourse/spec/requests/api/shared/shared_examples.rb
Martin Brennan cd98d818dc
DEV: Improve JSON schema failure output in API specs (#41182)
The shared JSON endpoint examples used to validate schemas with a
boolean
assertion and print one ad hoc hint to stdout. A failure was hard to act
on:

```
  VALUE AT "/access_control": {"mandatory_acl" => {}}
  POSSIBLE ISSUE W/: /access_control

  expected: true
       got: false
```

That output hid the real validator error, only showed the first failure,
and did not explain whether the response or schema needed to change.

Build a proper RSpec failure message from all JSONSchemer validation
results
instead. Each error now includes the issue, validator error, data path,
schema path, offending value, optional parent/details context, and a
concrete
suggested fix.

For unexpected properties:

```
  JSON schema validation failed with 1 error:

  1. Unexpected property at /access_control
     Error: object property at `/access_control` is a disallowed additional property
     Data path: /access_control
     Schema path: /additionalProperties
     Value:
       {
         "mandatory_acl": {}
       }
     Suggested fix: If this response/request field is intentional, add this entry
     to the parent schema's `properties` object:
       {
         "access_control": {
           "type": "object",
           "additionalProperties": false,
           "properties": {
             "mandatory_acl": {
               "type": "object",
               "additionalProperties": true
             }
           },
           "required": [
             "mandatory_acl"
           ]
         }
       }
       If the field is always present, also add "access_control" to the parent
       schema's `required` array.
```

For missing required properties, the message now groups missing keys and
shows
the response root plus validator details:

```
  Missing required properties default_archetype, notification_types at root
  Error: object at root is missing required properties: ...
  Details:
    {
      "missing_keys": [
        "default_archetype",
        "notification_types"
      ]
    }
  Suggested fix: Add default_archetype, notification_types to the
  response/request, or remove them from `required` at root schema.
```

For type mismatches, the message now points at the mismatched schema
node and
suggests an inferred replacement shape:

```
  Error: value at `/access_control` is not an array
  Data path: /access_control
  Schema path: /properties/access_control
  Value:
    {
      "mandatory_acl": {}
    }
  Suggested fix: Update the payload to match the documented `type`, or replace
  the schema at /properties/access_control with:
    {
      "type": "object",
      "additionalProperties": false,
      "properties": {
        "mandatory_acl": {
          "type": "object",
          "additionalProperties": true
        }
      },
      "required": [
        "mandatory_acl"
      ]
    }
```
2026-06-25 15:55:40 +10:00

180 lines
6 KiB
Ruby
Vendored

# frozen_string_literal: true
RSpec.shared_examples "a JSON endpoint" do |expected_response_status|
before { |example| submit_request(example.metadata) }
# JSON Pointer (RFC 6901) path, e.g. "/upcoming_changes_stats/0/name" resolves to that value.
def value_at_json_pointer(data, pointer)
return data if pointer.blank?
pointer
.to_s
.split("/")
.reject(&:empty?)
.reduce(data) do |node, segment|
key = segment.match?(/\A\d+\z/) ? segment.to_i : segment
node&.dig(key)
end
end
def format_json_value(value)
formatted = JSON.pretty_generate(value)
formatted.length > 1200 ? "#{formatted[0...1200]}\n... <truncated>" : formatted
rescue JSON::GeneratorError, TypeError
value.inspect
end
def schema_for_json_value(value)
case value
when Hash
if value.empty?
{ type: "object", additionalProperties: true }
else
{
type: "object",
additionalProperties: false,
properties: value.transform_values { |nested_value| schema_for_json_value(nested_value) },
required: value.keys,
}
end
when Array
item = value.compact.first
schema = { type: "array" }
schema[:items] = schema_for_json_value(item) if item
schema
when String
{ type: "string" }
when Integer
{ type: "integer" }
when Numeric
{ type: "number" }
when TrueClass, FalseClass
{ type: "boolean" }
when NilClass
{ type: "null" }
else
{}
end
end
def json_pointer_leaf(pointer)
pointer.to_s.split("/").reject(&:empty?).last
end
def schema_validation_issue(validation_result)
pointer = validation_result["data_pointer"].presence || "root"
case validation_result["type"]
when "required"
missing_keys = validation_result.dig("details", "missing_keys") || []
"Missing required #{"property".pluralize(missing_keys.size)} #{missing_keys.join(", ")} at #{pointer}"
when "schema"
"Unexpected property at #{pointer}"
else
validation_result["error"] || "Schema mismatch at #{pointer}"
end
end
def schema_validation_suggested_fix(validation_result, params)
schema_pointer = validation_result["schema_pointer"].presence || "root schema"
data_pointer = validation_result["data_pointer"].presence
value = value_at_json_pointer(params, data_pointer)
case validation_result["type"]
when "required"
missing_keys = validation_result.dig("details", "missing_keys") || []
"Add #{missing_keys.join(", ")} to the response/request, or remove it from `required` at #{schema_pointer}."
when "schema"
property_name = json_pointer_leaf(data_pointer)
snippet = { property_name => schema_for_json_value(value) }
"If this response/request field is intentional, add this entry to the parent schema's `properties` object:\n#{format_json_value(snippet).indent(6)}\n If the field is always present, also add #{property_name.inspect} to the parent schema's `required` array."
when "array", "object", "string", "integer", "number", "boolean", "null"
"Update the payload to match the documented `type`, or replace the schema at #{schema_pointer} with:\n#{format_json_value(schema_for_json_value(value)).indent(6)}"
when "enum"
"Return one of the documented enum values, or add this value to the enum at #{schema_pointer}."
else
"Compare the value at the data path with the schema at #{schema_pointer}."
end
end
def format_schema_validation_result(validation_result, params, index)
pointer = validation_result["data_pointer"].presence
parent_pointer = pointer&.sub(%r{/[^/]+\z}, "")
lines = [
"#{index}. #{schema_validation_issue(validation_result)}",
" Error: #{validation_result["error"]}",
" Data path: #{pointer || "root"}",
" Schema path: #{validation_result["schema_pointer"].presence || "root"}",
" Value:",
format_json_value(value_at_json_pointer(params, pointer)).indent(5),
]
if parent_pointer.present? && parent_pointer != pointer
lines.concat(
[
" Parent path: #{parent_pointer}",
" Parent value:",
format_json_value(value_at_json_pointer(params, parent_pointer)).indent(5),
],
)
end
if validation_result["details"].present?
lines.concat([" Details:", format_json_value(validation_result["details"]).indent(5)])
end
lines.concat(
[" Suggested fix: #{schema_validation_suggested_fix(validation_result, params)}"],
)
lines.join("\n")
end
def schema_validation_failure_message(validation_results, params)
formatted_results =
validation_results
.map
.with_index(1) do |validation_result, index|
format_schema_validation_result(validation_result, params, index)
end
<<~MESSAGE
JSON schema validation failed with #{validation_results.size} #{"error".pluralize(validation_results.size)}:
#{formatted_results.join("\n\n")}
MESSAGE
end
def expect_schema_valid(schemer, params)
validation_results = schemer.validate(params).to_a
if validation_results.any?
raise RSpec::Expectations::ExpectationNotMetError,
schema_validation_failure_message(validation_results, params)
end
end
describe "response status" do
it "returns expected response status" do
expect(response.status).to eq(expected_response_status)
end
end
describe "request body" do
it "matches the documented request schema" do |example|
if expected_request_schema
schemer = JSONSchemer.schema(expected_request_schema)
expect_schema_valid(schemer, params)
end
end
end
describe "response body" do
let(:json_response) { JSON.parse(response.body) }
it "matches the documented response schema" do |example|
if expected_response_schema
schemer = JSONSchemer.schema(expected_response_schema)
expect_schema_valid(schemer, json_response)
end
end
end
end