Skip to content

Add except option for testing error responses#465

Open
sadahiro-ono wants to merge 2 commits intointeragent:masterfrom
sadahiro-ono:add-except-option
Open

Add except option for testing error responses#465
sadahiro-ono wants to merge 2 commits intointeragent:masterfrom
sadahiro-ono:add-except-option

Conversation

@sadahiro-ono
Copy link

@sadahiro-ono sadahiro-ono commented Feb 9, 2026

Summary

Add except option to assert_request_schema_confirm to support testing error responses where required parameters are intentionally omitted.

Background

When writing tests for error responses (e.g. 401 Unauthorized, 400 Bad Request), it is common to intentionally omit required parameters to trigger the error. However, assert_request_schema_confirm would fail on the missing parameters before the response could be validated — making it impossible to verify both the request schema and the error response in the same test.

# Previously: had to choose between validating the request or testing the error
get "/resources"  # intentionally no Authorization header
assert_request_schema_confirm  # => raises InvalidRequest: missing required header
assert_response_schema_confirm(401)

Usage

# Except specific parameters from request validation
assert_request_schema_confirm(except: { headers: ['authorization'] })
assert_response_schema_confirm(401)

# Multiple parameter types at once
assert_request_schema_confirm(
  except: {
    headers: ['authorization'],
    query:   ['page'],
    body:    ['required_field']
  }
)

Changes

New file: lib/committee/test/except_parameter.rb

Introduces ExceptParameter and its handler classes that temporarily inject dummy values for excepted parameters and restore the original state after validation.

Handler classes:

Class Target Storage
HeaderHandler HTTP request headers request.env directly
QueryHandler Query string parameters request.GET (rack.request.query_hash)
BodyHandler Request body rack.input (JSON) or rack.request.form_hash (form)

Key design decisions:

  • StringDummyLookup module — shared by all handlers, provides resolve_operation for OpenAPI3 schema lookup and type/format/enum-aware dummy value generation.
    • resolve_operation calls Committee::Middleware::Base.get_schema(@committee_options) instead of accessing @committee_options[:schema] directly, so the schema_path: option is respected.
  • find_parameter_schema searches both operation-level and path item-level parameters (OpenAPI 3 allows parameters to be declared on the path item and shared across operations). Operation-level parameters take precedence per the OpenAPI spec.
  • BodyHandler dispatches on Content-Type: treats application/json and +json variants (e.g. application/vnd.api+json) as JSON (replaces rack.input); calls request.POST to force Rack to parse the form body first, then injects into the live rack.request.form_hash for application/x-www-form-urlencoded / multipart/form-data; no-op for other content types (e.g. binary). This logic mirrors request_unpacker.rb exactly.
  • HeaderHandler handles the CGI special cases where Content-TypeCONTENT_TYPE and Content-LengthCONTENT_LENGTH (no HTTP_ prefix)
  • Dummy values are injected only when the parameter is absent (nil). Parameters that already carry a value are left untouched.
  • Dummy value selection: enum first value takes priority; otherwise determined by typeinteger/number/boolean/array get a zero value (native types for JSON bodies, string-encoded for query/header/form); object gets {} for JSON bodies only and falls back to "dummy-{name}" for other parameter types; string with a recognized format (date-time, date, email, uuid) gets a format-aware string; everything else falls back to "dummy-{name}". Format-aware strings and type-based zero values are parallel branches of the same case statement, not sequential priorities.
  • For JSON bodies, native Ruby types are used (0, 0.0, false, []; object{}); for query/header/form, string-encoded values are used ("0", "true") since coerce_value handles the conversion (object type is not string-encodable and falls back to "dummy-{name}")
  • rescue StandardError in schema lookup methods (find_parameter_schema, body_param_schema) prevents unexpected failures in openapi_parser internals from breaking test assertions; falls back to "dummy-{name}"
  • apply_json guards against non-Hash JSON bodies (arrays, scalars): returns early so that the schema validator can report the type mismatch itself, rather than raising a TypeError during named-field injection.
  • apply_json also parses the body before committing any side-effects (@original_body, form cache deletion) so that a JSON::ParserError does not trigger a spurious restore_json call.
  • apply_form uses ||= in its rescue clause to preserve any per-parameter originals already saved before the error, ensuring restore_form can undo partial injections.
  • body_param_schema looks up the schema using the exact Content-Type of the request (content[@request.media_type]) rather than scanning all declared content types. This avoids returning the wrong schema when the request body does not match any declared media type.
  • Type-aware dummy values require OpenAPI 3. Hyper-Schema and OpenAPI 2 fall back to "dummy-{name}"

Modified: lib/committee/test/methods.rb

  • Added except: {} keyword argument to assert_request_schema_confirm
  • Added private with_except_params helper; both apply and yield are inside the begin block under an ensure clause so that any partial dummy-value injection is always rolled back — even when apply itself raises (e.g. JSON::ParserError from an invalid request body)

Modified: test/data/openapi3/normal.yaml

Added test endpoints:

  • /get_endpoint_with_required_parameter — required string query param
  • /test_except_validation — two required query params (to verify partial except)
  • /get_endpoint_with_required_integer_query — required integer query param
  • /test_except_body_params — required string + integer JSON body params
  • /test_except_body_with_constraints — enum and date-time format constraints
  • /test_except_form_params — required form-encoded body params
  • /test_except_content_type_header — required Content-Type header
  • /test_except_vnd_json_bodyapplication/vnd.api+json body (verify +json variant handled as JSON)
  • /test_path_level_required_integer — path item-level required integer param (verify path item params are found by find_parameter_schema)
  • /test_multi_content_type_body — multiple content types in one endpoint (verify exact Content-Type match in body_param_schema)

Modified: test/test/methods_test.rb

Added 26 test cases covering:

  • Basic except for query, header, body parameters
  • Multiple parameter types excepted simultaneously
  • Partial except: non-excepted required parameters still raise errors
  • Type-aware dummies: integer query, integer header, enum body, date-time body
  • Form-encoded body: string and integer form params
  • Special rack headers: Content-TypeCONTENT_TYPE mapping
  • Does not overwrite existing parameter values (inject only when nil)
  • application/vnd.api+json body treated as JSON
  • Backward compatibility: assert_request_schema_confirm without except unchanged
  • Error recovery: injected params are fully restored even when apply raises mid-way (e.g. invalid JSON body)
  • Path item-level parameter lookup: params declared on path item (not operation) are correctly resolved
  • Exact Content-Type schema lookup: schema is resolved from the actual request media type, not by scanning all declared types
  • Non-Hash JSON body (e.g. array): injection is skipped and validator reports the type mismatch

Modified: README.md

Documented the except option with usage examples, parameter type table (including supported content types for body), and dummy value priority rules.

@ydah
Copy link
Member

ydah commented Feb 25, 2026

It looks like there are some trial-and-error commits here. Could you squash them into a single commit?

Add `except` option to `assert_request_schema_confirm` to support
testing error responses where required parameters are intentionally
omitted.

When testing error responses (e.g. 401 Unauthorized), required
parameters are often intentionally absent. Previously this caused
`assert_request_schema_confirm` to raise before the response could
be validated.

The `except` option temporarily injects dummy values for the specified
parameters during request validation and restores the original state
afterwards via an `ensure` clause.

  assert_request_schema_confirm(except: { headers: ['authorization'] })
  assert_response_schema_confirm(401)

Supported parameter types: headers, query, body (JSON,
application/x-www-form-urlencoded, multipart/form-data).

Dummy values are type/format/enum-aware for OpenAPI 3; OpenAPI 2 and
Hyper-Schema fall back to "dummy-{name}".
@sadahiro-ono
Copy link
Author

@ydah

It looks like there are some trial-and-error commits here. Could you squash them into a single commit?

Thank you for the feedback! I've squashed all the trial-and-error commits into a
single commit.

Copy link
Member

@geemus geemus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

lgtm.

@ydah did you have any other questions or concerns?

# Resolve the OpenAPI3 operation object for the current request.
# Returns nil for non-OpenAPI3 schemas or any lookup failure.
def resolve_operation
schema = @committee_options[:schema]
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ask) Since resolve_operation only references @committee_options[:schema], if schema_path is specified in committee_options during testing, won't the schema retrieval always result in nil?
As a result, the dummy value falls back to “dummy-*”. In this state, even if the required typed parameter is excluded, won't the request validation fail because the type of the inserted fallback value is incorrect?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes. Fixed by changing @committee_options[:schema] to Committee::Middleware::Base.get_schema(@committee_options), which handles both schema: and schema_path: options consistently with how the rest of the test infrastructure resolves schemas.

operation = resolve_operation
return nil unless operation

params = operation.request_operation.operation_object&.parameters
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ask) The parameter schema search only references operation_object.parameters and does not include parameters declared at the path item level.
In specifications that share query/header parameters in this way, except cannot find the schema and inserts the string fallback value. Therefore, wouldn't explicitly excepting a typed required parameter cause an InvalidRequest?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Fixed by merging both operation-level and path item-level parameters in the lookup:

params = Array(op_object&.parameters) + Array(op_object&.parent&.parameters)

request_body = operation.request_operation.operation_object&.request_body
return nil unless request_body

request_body.content&.each_value do |media_type|
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ask) body_param_schema scans all schemas within requestBody.content and returns the first matching property name without considering the actual request's media type. If an endpoint supports multiple body Content-Types and fields with the same name but different types exist, wouldn't except insert dummy values from the incorrect schema, potentially causing validation to fail for the actual Content-Type?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Correct. Fixed by using a direct hash lookup on the exact Content-Type:

content[@request.media_type]&.schema&.properties&.[](key)

- resolve_operation: use Base.get_schema instead of @committee_options[:schema]
  directly, so schema_path: option is respected
- find_parameter_schema: include path item-level parameters in addition to
  operation-level parameters
- apply_json: guard against non-Hash JSON bodies to avoid TypeError
- body_param_schema: use exact Content-Type match instead of scanning all
  content types, removing the unnecessary fallback

Add tests for all four fixes in methods_test.rb, and add two new test
endpoints to test/data/openapi3/normal.yaml.
@sadahiro-ono
Copy link
Author

@ydah @geemus
All three issues have been addressed. Could you take another look?

@sadahiro-ono sadahiro-ono requested review from geemus and ydah March 3, 2026 02:28
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants