diff --git a/README.md b/README.md index dc647be7..ae09bf6f 100644 --- a/README.md +++ b/README.md @@ -305,6 +305,59 @@ describe Committee::Middleware::Stub do end ``` +### Excepting specific parameters from validation + +When testing error responses (like 401 Unauthorized or 400 Bad Request), you may want to intentionally omit required parameters. Use the `except` option with `assert_request_schema_confirm` to skip validation for those specific parameters while still validating the rest of the request: + +```ruby +it "returns 401 when authorization header is missing" do + get "/resources" # Intentionally omit authorization header + + assert_request_schema_confirm(except: { headers: ['authorization'] }) + assert_response_schema_confirm(401) +end + +it "returns 422 when required body param is missing" do + post "/orders", JSON.generate({ quantity: 1 }), { "CONTENT_TYPE" => "application/json" } + # 'item_id' is required but intentionally omitted to test the error response + + assert_request_schema_confirm(except: { body: ['item_id'] }) + assert_response_schema_confirm(422) +end +``` + +The `except` option accepts a hash with the following keys, each taking an array of parameter names: + +| Key | Target | Supported content types | Example | +|---|---|---|---| +| `headers` | HTTP request headers | — | `headers: ['authorization', 'content-type']` | +| `query` | Query string parameters | — | `query: ['page', 'limit']` | +| `body` | Request body parameters | `application/json` (including `+json` variants such as `application/vnd.api+json`), `application/x-www-form-urlencoded`, `multipart/form-data` | `body: ['required_field']` | + +Multiple parameter types can be excepted at once: + +```ruby +assert_request_schema_confirm( + except: { + headers: ['authorization'], + query: ['page'], + body: ['required_field'] + } +) +``` + +Dummy values are injected **only when the parameter is absent** (nil) in the actual request. Parameters that already carry a value are left untouched. After validation completes (whether it raises or not), all injected values are automatically removed and the request is restored to its original state. + +The dummy value for each excepted parameter is chosen as follows: + +1. First value of `enum` if the parameter has an enum constraint +2. Otherwise, determined by the declared `type`: + - `integer`, `number`, `boolean`, `array` — zero value; native types for JSON bodies (`0`, `0.0`, `false`, `[]`), string-encoded for query/header/form (`"0"`, `"true"`, `["0"]`) + - `object` — `{}` for JSON bodies only; falls back to `"dummy-{name}"` for query/header/form + - `string` type with a recognized format (`date-time`, `date`, `email`, `uuid`) gets a format-aware string + - Everything else falls back to `"dummy-{name}"` + +> **Note:** Type-aware dummy values require OpenAPI 3. For Hyper-Schema and OpenAPI 2, `"dummy-{name}"` is always used. ## Tips diff --git a/lib/committee/test/except_parameter.rb b/lib/committee/test/except_parameter.rb new file mode 100644 index 00000000..37998aab --- /dev/null +++ b/lib/committee/test/except_parameter.rb @@ -0,0 +1,416 @@ +# frozen_string_literal: true + +module Committee + module Test + # Handles temporary parameter exclusion during schema validation. + # Allows testing error responses without validation failures for intentionally missing parameters. + class ExceptParameter + FORMAT_DUMMIES = { 'date-time': '2000-01-01T00:00:00Z', date: '2000-01-01', email: 'dummy@example.com', uuid: '00000000-0000-0000-0000-000000000000' }.freeze + + # @param [Rack::Request] request The request object + # @param [Hash] committee_options Committee options hash + def initialize(request, committee_options) + @request = request + @committee_options = committee_options + @handlers = {} + end + + # Apply dummy values to excepted parameters + # @param [Hash] except Hash of parameter types to parameter names + # (e.g., { headers: ['authorization'], query: ['page'] }) + # @return [void] + def apply(except) + except.each do |param_type, param_names| + handler = (@handlers[param_type] ||= handler_for(param_type)) + handler&.apply(param_names) + end + end + + # Restore original parameter values + # @return [void] + def restore + @handlers.each_value(&:restore) + end + + private + + def handler_for(param_type) + case param_type + when :headers then HeaderHandler.new(@request, @committee_options) + when :query then QueryHandler.new(@request, @committee_options) + when :body then BodyHandler.new(@request, @committee_options) + end + end + + # Shared helpers for looking up OpenAPI3 parameter schemas and generating + # type/format/enum-aware dummy values encoded as HTTP strings. + # + # Included by BaseHashParameterHandler, HeaderHandler, and BodyHandler. + module StringDummyLookup + private + + # Resolve the OpenAPI3 operation object for the current request. + # Returns nil for non-OpenAPI3 schemas or any lookup failure. + def resolve_operation + schema = Committee::Middleware::Base.get_schema(@committee_options) + return nil unless schema.is_a?(Committee::Drivers::OpenAPI3::Schema) + + path = @request.path_info + if (prefix = @committee_options[:prefix]) + path = path.gsub(Regexp.new("\\A#{Regexp.escape(prefix)}"), '') + end + + schema.operation_object(path, @request.request_method.downcase) + end + + # Find the OpenAPI3 schema object for a parameter by name and location. + # Returns nil for non-OpenAPI3 schemas or any lookup failure. + # + # 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. + def find_parameter_schema(key, location) + operation = resolve_operation + return nil unless operation + + op_object = operation.request_operation.operation_object + # Merge operation-level and path item-level parameters; operation level first + # so that overrides are respected when both define the same parameter. + params = Array(op_object&.parameters) + Array(op_object&.parent&.parameters) + params.find { |p| p.name&.casecmp?(key.to_s) && p.in == location }&.schema + rescue StandardError + nil + end + + # Return a type-appropriate dummy value encoded as a String (or Array of strings + # for array-typed query/path params). The value must be coerceable to the + # expected schema type by openapi_parser's coerce_value option. + def string_dummy_for_schema(key, param_schema) + return "dummy-#{key}" unless param_schema + return param_schema.enum.first.to_s if param_schema.enum&.any? + + case param_schema.type + when 'integer', 'number' then '0' + when 'boolean' then 'true' + when 'array' then ['0'] + when 'string' then string_format_dummy(key, param_schema.format) + else "dummy-#{key}" + end + end + + # Return a string that satisfies common OpenAPI3 string format constraints. + def string_format_dummy(key, format) + FORMAT_DUMMIES.fetch(format&.to_sym, "dummy-#{key}") + end + end + + # Base handler for parameters stored in hash-like structures. + # Subclasses implement #get_storage and optionally #dummy_value_for. + class BaseHashParameterHandler + include StringDummyLookup + + # @param [Rack::Request] request The request object + # @param [Hash] committee_options Committee options hash + def initialize(request, committee_options) + @request = request + @committee_options = committee_options + @original_values = {} + end + + # Apply dummy values to parameters + # @param [Array] param_names Parameter names to except + # @return [void] + def apply(param_names) + storage = get_storage + return unless storage + + param_names.each do |param_name| + key = param_name.to_s + @original_values[key] = storage[key] + storage[key] ||= dummy_value_for(key) + end + end + + # Restore original parameter values + # @return [void] + def restore + storage = get_storage + return unless storage + + @original_values.each do |key, value| + value.nil? ? storage.delete(key) : storage[key] = value + end + end + + private + + # Override in subclasses to specify the storage location + # @return [Hash, nil] + def get_storage + raise NotImplementedError, "#{self.class} must implement #get_storage" + end + + def dummy_value_for(key) + "dummy-#{key}" + end + end + + # Handler for request headers + class HeaderHandler + include StringDummyLookup + + # In Rack/CGI, Content-Type and Content-Length are stored without the + # HTTP_ prefix. All other headers use the HTTP_ prefix convention. + SPECIAL_RACK_HEADERS = { 'content-type' => 'CONTENT_TYPE', 'content-length' => 'CONTENT_LENGTH', }.freeze + private_constant :SPECIAL_RACK_HEADERS + + # @param [Rack::Request] request The request object + # @param [Hash] committee_options Committee options hash + def initialize(request, committee_options) + @request = request + @committee_options = committee_options + @original_values = {} + end + + # Apply dummy values to header parameters + # @param [Array] param_names Header names to except + # @return [void] + def apply(param_names) + param_names.each do |param_name| + key = rack_header_key(param_name.to_s) + @original_values[key] = @request.env[key] + @request.env[key] ||= string_dummy_for_schema(param_name.to_s, find_parameter_schema(param_name.to_s, 'header')) + end + end + + # Restore original header values + # @return [void] + def restore + @original_values.each do |key, value| + value.nil? ? @request.env.delete(key) : @request.env[key] = value + end + end + + private + + def rack_header_key(name) + SPECIAL_RACK_HEADERS[name.downcase] || "HTTP_#{name.upcase.tr('-', '_')}" + end + end + + # Handler for query parameters + class QueryHandler < BaseHashParameterHandler + private + + def get_storage + # Calling request.GET ensures Rack parses the query string into + # rack.request.query_hash before we inject dummy values, so that + # any existing query parameters are preserved. + @request.GET + end + + def dummy_value_for(key) + string_dummy_for_schema(key, find_parameter_schema(key, 'query')) + end + end + + # Handler for request body parameters + # + # Supports three content types: + # - application/json (and variants): replaces rack.input stream so that + # request_unpack re-parses the modified body during validation. + # - application/x-www-form-urlencoded / multipart/form-data: pre-populates + # rack.request.form_hash, which Rack returns directly from request.POST + # without re-parsing the raw body. + # - Other content types (e.g. binary): no-op, as RequestUnpacker does not + # extract named parameters from them. + class BodyHandler + include StringDummyLookup + + # @param [Rack::Request] request The request object + # @param [Hash] committee_options Committee options hash + def initialize(request, committee_options) + @request = request + @committee_options = committee_options + @original_body = nil + @original_form_values = nil + end + + # Apply dummy values to body parameters based on content type. + # @param [Array] param_names Body parameter names to except + # @return [void] + def apply(param_names) + if json_content_type? + apply_json(param_names) + elsif form_content_type? + apply_form(param_names) + end + # Other content types: no-op + end + + # Restore original body content + # @return [void] + def restore + restore_json if @original_body + restore_form if @original_form_values + end + + private + + def json_content_type? + mt = @request.media_type + mt.nil? || mt.match?(%r{application/(?:.*\+)?json}) + end + + def form_content_type? + mt = @request.media_type + mt == 'application/x-www-form-urlencoded' || mt&.start_with?('multipart/form-data') + end + + # --- JSON body handling --- + # + # Replaces rack.input with a modified JSON body. Also clears any stale + # rack.request.form_hash / rack.request.form_pairs caches so that Rack + # does not return outdated form data if POST is called after the swap. + + def apply_json(param_names) + original_body = read_body + body_hash = parse_body(original_body) + + # Non-Hash bodies (arrays, scalars) cannot have named fields injected. + # Skip injection and let the schema validator report the type mismatch. + return unless body_hash.is_a?(Hash) + + # Commit state only after successful parse so that restore_json is not + # triggered unnecessarily when parse_body raises (e.g. invalid JSON). + @original_body = original_body + @saved_form_hash = @request.env.delete('rack.request.form_hash') + @saved_form_pairs = @request.env.delete('rack.request.form_pairs') + + param_names.each do |param_name| + key = param_name.to_s + body_hash[key] = dummy_value_for(key) if body_hash[key].nil? + end + + replace_body(JSON.generate(body_hash)) + end + + def restore_json + replace_body(@original_body) + @request.env.delete(body_hash_key) + @request.env['rack.request.form_hash'] = @saved_form_hash if @saved_form_hash + @request.env['rack.request.form_pairs'] = @saved_form_pairs if @saved_form_pairs + end + + # --- Form body handling --- + # + # Calls request.POST to trigger Rack's form parsing (populating + # rack.request.form_hash from rack.input), then injects dummy values + # for missing params into the live hash. In Rack 3.x, request.POST + # returns rack.request.form_hash directly on subsequent calls, so the + # injected values are visible during validation. + + def apply_form(param_names) + @request.body&.rewind + form_hash = @request.POST + @original_form_values = {} + param_names.each do |param_name| + key = param_name.to_s + @original_form_values[key] = form_hash[key] + form_hash[key] ||= form_dummy_for(key) + end + rescue StandardError + # If form parsing fails (e.g. malformed body), skip remaining injection. + # Use ||= so that any originals already saved in the loop are preserved + # and can be correctly restored by restore_form. + @original_form_values ||= {} + end + + def restore_form + form_hash = @request.env['rack.request.form_hash'] + return unless form_hash + + @original_form_values.each do |key, value| + value.nil? ? form_hash.delete(key) : form_hash[key] = value + end + end + + # --- Dummy value helpers --- + + def body_hash_key + @committee_options.fetch(:request_body_hash_key, 'committee.request_body_hash') + end + + def read_body + return '' unless @request.body + + @request.body.rewind + body = @request.body.read + @request.body.rewind + body || '' + end + + def parse_body(body_str) + return {} if body_str.nil? || body_str.empty? + + JSON.parse(body_str) + end + + def replace_body(body_str) + @request.env['rack.input'] = StringIO.new(body_str) + end + + # Returns a native-typed dummy value for JSON bodies. + def dummy_value_for(key) + prop_schema = body_param_schema(key) + return "dummy-#{key}" unless prop_schema + + return prop_schema.enum.first if prop_schema.enum&.any? + + case prop_schema.type + when 'integer' then 0 + when 'number' then 0.0 + when 'boolean' then false + when 'array' then [] + when 'object' then {} + when 'string' then FORMAT_DUMMIES.fetch(prop_schema.format&.to_sym, "dummy-#{key}") + else "dummy-#{key}" + end + end + + # Returns a string-encoded dummy value for form bodies. + # Form data is always transmitted as strings; openapi_parser's coerce_value + # handles conversion to the declared type during validation. + def form_dummy_for(key) + prop_schema = body_param_schema(key) + return "dummy-#{key}" unless prop_schema + + return prop_schema.enum.first.to_s if prop_schema.enum&.any? + + case prop_schema.type + when 'integer', 'number' then '0' + when 'boolean' then 'true' + when 'array' then ['0'] + when 'string' then FORMAT_DUMMIES.fetch(prop_schema.format&.to_sym, "dummy-#{key}").to_s + else "dummy-#{key}" + end + end + + def body_param_schema(key) + operation = resolve_operation + return nil unless operation + + request_body = operation.request_operation.operation_object&.request_body + return nil unless request_body + + content = request_body.content + return nil unless content + + content[@request.media_type]&.schema&.properties&.[](key) + rescue StandardError + nil + end + end + end + end +end diff --git a/lib/committee/test/methods.rb b/lib/committee/test/methods.rb index cfc6250c..9b06fc98 100644 --- a/lib/committee/test/methods.rb +++ b/lib/committee/test/methods.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require_relative 'except_parameter' + module Committee module Test module Methods @@ -8,13 +10,19 @@ def assert_schema_conform(expected_status = nil) assert_response_schema_confirm(expected_status) end - def assert_request_schema_confirm + def assert_request_schema_confirm(except: {}) unless schema_validator.link_exist? request = "`#{request_object.request_method} #{request_object.path_info}` undefined in schema (prefix: #{committee_options[:prefix].inspect})." raise Committee::InvalidRequest.new(request) end - schema_validator.request_validate(request_object) + if except.empty? + schema_validator.request_validate(request_object) + else + with_except_params(except) do + schema_validator.request_validate(request_object) + end + end end def assert_response_schema_confirm(expected_status = nil) @@ -79,6 +87,23 @@ def schema_coverage def old_behavior committee_options.fetch(:old_assert_behavior, false) end + + private + + # Temporarily adds dummy values for excepted parameters during validation + # @see ExceptParameter + def with_except_params(except) + return yield if except.empty? + + except_handler = ExceptParameter.new(request_object, committee_options) + + begin + except_handler.apply(except) + yield + ensure + except_handler.restore + end + end end end end diff --git a/test/data/openapi3/normal.yaml b/test/data/openapi3/normal.yaml index 57e6034c..f31013bd 100644 --- a/test/data/openapi3/normal.yaml +++ b/test/data/openapi3/normal.yaml @@ -671,6 +671,131 @@ paths: '204': description: no content + /test_except_validation: + get: + description: test endpoint for except option validation + parameters: + - name: required_param_a + in: query + required: true + schema: + type: string + - name: required_param_b + in: query + required: true + schema: + type: string + responses: + '204': + description: no content + + /get_endpoint_with_required_integer_query: + get: + description: endpoint with required integer query param + parameters: + - name: count + in: query + required: true + schema: + type: integer + responses: + '204': + description: no content + + /test_except_body_with_constraints: + post: + description: test endpoint for except body params with format and enum constraints + requestBody: + content: + application/json: + schema: + type: object + required: + - status + - created_at + properties: + status: + type: string + enum: + - active + - inactive + created_at: + type: string + format: date-time + responses: + '204': + description: no content + + /test_except_body_params: + post: + description: test endpoint for except body params option + requestBody: + content: + application/json: + schema: + type: object + required: + - required_string + - required_integer + properties: + required_string: + type: string + required_integer: + type: integer + responses: + '204': + description: no content + + /test_except_form_params: + post: + description: test endpoint for except body params with form encoding + requestBody: + content: + application/x-www-form-urlencoded: + schema: + type: object + required: + - required_string + - required_integer + properties: + required_string: + type: string + required_integer: + type: integer + responses: + '204': + description: no content + + /test_except_content_type_header: + post: + description: test endpoint for except Content-Type header + parameters: + - name: Content-Type + in: header + required: true + schema: + type: string + responses: + '204': + description: no content + + /test_except_vnd_json_body: + post: + description: test endpoint that accepts application/vnd.api+json body + requestBody: + content: + application/vnd.api+json: + schema: + type: object + required: + - required_string + properties: + required_string: + type: string + responses: + '204': + description: no content + /ref-sample: get: responses: @@ -748,6 +873,44 @@ paths: date: 2020-05-12 date-time: 2020-05-12T00:00:00.00Z + /test_path_level_required_integer: + parameters: + - name: count + in: query + required: true + schema: + type: integer + get: + description: endpoint with required integer query param defined at PATH ITEM level + responses: + '204': + description: no content + + /test_multi_content_type_body: + post: + description: endpoint with same field name but different types per content type + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [status] + properties: + status: + type: string + format: email + application/x-www-form-urlencoded: + schema: + type: object + required: [status] + properties: + status: + type: integer + responses: + '204': + description: no content + components: schemas: nested_array: diff --git a/test/test/methods_test.rb b/test/test/methods_test.rb index 9155662d..eebc4f32 100644 --- a/test/test/methods_test.rb +++ b/test/test/methods_test.rb @@ -169,6 +169,225 @@ def response_data end assert_match(/`GET \/undefined` undefined in schema/i, e.message) end + + describe "with except option" do + it "passes validation when required query parameter is excepted" do + @app = new_rack_app + # Missing required 'data' parameter, but except it + get "/get_endpoint_with_required_parameter" + assert_request_schema_confirm(except: { query: ['data'] }) + end + + it "still validates other parameters when some are excepted" do + @app = new_rack_app + # Missing required 'data' parameter, but except it + # This test verifies that the except option works + get "/get_endpoint_with_required_parameter" + assert_request_schema_confirm(except: { query: ['data'] }) + end + + it "works without except option (backward compatibility)" do + @app = new_rack_app + get "/characters" + assert_request_schema_confirm + end + + it "supports multiple parameter types" do + @app = new_rack_app + # Can except headers, query, and body parameters + get "/get_endpoint_with_required_parameter" + assert_request_schema_confirm(except: { headers: ['authorization'], query: ['data'] }) + end + + it "supports multiple parameters in each type" do + @app = new_rack_app + # Can except multiple parameters in each type simultaneously + get "/get_endpoint_with_required_parameter" + assert_request_schema_confirm(except: { headers: ['content-type', 'authorization', 'accept'], query: ['data', 'page', 'limit'] }) + end + + it "raises error when non-excepted required parameter is missing" do + @app = new_rack_app + # Except only 'required_param_a', but 'required_param_b' is also required and missing + # This should raise an error for 'required_param_b' + get "/test_except_validation" + + e = assert_raises(Committee::InvalidRequest) do + assert_request_schema_confirm(except: { query: ['required_param_a'] }) + end + # Verify error is about the non-excepted missing parameter + assert_match(/required_param_b/i, e.message) + end + + describe "with body params" do + it "passes validation when required string body param is excepted" do + @app = new_rack_app + post "/test_except_body_params", JSON.generate({ "required_integer" => 1 }), { "CONTENT_TYPE" => "application/json" } + assert_request_schema_confirm(except: { body: ['required_string'] }) + end + + it "passes validation when required integer body param is excepted" do + @app = new_rack_app + post "/test_except_body_params", JSON.generate({ "required_string" => "foo" }), { "CONTENT_TYPE" => "application/json" } + assert_request_schema_confirm(except: { body: ['required_integer'] }) + end + + it "passes validation when all required body params are excepted" do + @app = new_rack_app + post "/test_except_body_params", nil, { "CONTENT_TYPE" => "application/json" } + assert_request_schema_confirm(except: { body: ['required_string', 'required_integer'] }) + end + + it "raises error when non-excepted required body param is missing" do + @app = new_rack_app + post "/test_except_body_params", nil, { "CONTENT_TYPE" => "application/json" } + e = assert_raises(Committee::InvalidRequest) do + assert_request_schema_confirm(except: { body: ['required_string'] }) + end + assert_match(/required_integer/i, e.message) + end + + describe "non-string types and format constraints" do + it "passes validation when required integer query param is excepted" do + @app = new_rack_app + get "/get_endpoint_with_required_integer_query" + assert_request_schema_confirm(except: { query: ['count'] }) + end + + it "passes validation when required integer header is excepted" do + @app = new_rack_app + get "/header" + assert_request_schema_confirm(except: { headers: ['integer'] }) + end + + it "passes validation when required enum body param is excepted" do + @app = new_rack_app + post "/test_except_body_with_constraints", JSON.generate({ "created_at" => "2024-01-01T00:00:00Z" }), { "CONTENT_TYPE" => "application/json" } + assert_request_schema_confirm(except: { body: ['status'] }) + end + + it "passes validation when required date-time format body param is excepted" do + @app = new_rack_app + post "/test_except_body_with_constraints", JSON.generate({ "status" => "active" }), { "CONTENT_TYPE" => "application/json" } + assert_request_schema_confirm(except: { body: ['created_at'] }) + end + end + + describe "with form-encoded body params" do + it "passes validation when required string form param is excepted" do + @app = new_rack_app + post "/test_except_form_params", "required_integer=1", { "CONTENT_TYPE" => "application/x-www-form-urlencoded" } + assert_request_schema_confirm(except: { body: ['required_string'] }) + end + + it "passes validation when required integer form param is excepted" do + @app = new_rack_app + post "/test_except_form_params", "required_string=foo", { "CONTENT_TYPE" => "application/x-www-form-urlencoded" } + assert_request_schema_confirm(except: { body: ['required_integer'] }) + end + + it "passes validation when all required form params are excepted" do + @app = new_rack_app + post "/test_except_form_params", "", { "CONTENT_TYPE" => "application/x-www-form-urlencoded" } + assert_request_schema_confirm(except: { body: ['required_string', 'required_integer'] }) + end + + it "raises error when non-excepted required form param is missing" do + @app = new_rack_app + post "/test_except_form_params", "", { "CONTENT_TYPE" => "application/x-www-form-urlencoded" } + e = assert_raises(Committee::InvalidRequest) do + assert_request_schema_confirm(except: { body: ['required_string'] }) + end + assert_match(/required_integer/i, e.message) + end + end + + describe "with special rack headers" do + it "passes validation when required Content-Type header is excepted" do + @app = new_rack_app + post "/test_except_content_type_header", nil + assert_request_schema_confirm(except: { headers: ['Content-Type'] }) + end + end + + describe "does not overwrite existing values" do + it "leaves an existing query param value unchanged when it is excepted" do + @app = new_rack_app + get "/get_endpoint_with_required_parameter", "data" => "existing_value" + before_value = last_request.GET["data"] + assert_request_schema_confirm(except: { query: ['data'] }) + assert_equal before_value, last_request.GET["data"] + end + end + + describe "with vnd.api+json content type" do + it "treats application/vnd.api+json body as JSON and injects dummy values" do + @app = new_rack_app + post "/test_except_vnd_json_body", JSON.generate({}), { "CONTENT_TYPE" => "application/vnd.api+json" } + assert_request_schema_confirm(except: { body: ['required_string'] }) + end + end + + describe "with schema_path option" do + before do + @committee_options = { schema_path: open_api_3_schema_path } + end + + it "passes validation when required integer query param is excepted" do + @app = new_rack_app + get "/get_endpoint_with_required_integer_query" + assert_request_schema_confirm(except: { query: ['count'] }) + end + end + + describe "with path item-level parameters" do + it "passes validation when required integer query param declared at path level is excepted" do + @app = new_rack_app + get "/test_path_level_required_integer" + assert_request_schema_confirm(except: { query: ['count'] }) + end + end + + describe "with multiple content-type body" do + it "uses the correct schema for form-urlencoded when excepting a body param" do + @app = new_rack_app + post "/test_multi_content_type_body", "", "CONTENT_TYPE" => "application/x-www-form-urlencoded" + assert_request_schema_confirm(except: { body: ['status'] }) + end + end + + describe "with non-hash JSON body" do + it "raises BadRequest when JSON body is an array" do + @app = new_rack_app + post "/test_except_body_params", "[1,2,3]", "CONTENT_TYPE" => "application/json" + assert_raises(Committee::BadRequest) do + assert_request_schema_confirm(except: { body: ['required_string'] }) + end + end + end + + describe "error recovery" do + it "restores partially-applied params when JSON body parsing raises mid-apply" do + # Scenario: HeaderHandler injects a dummy header, then BodyHandler fails + # to parse an invalid JSON body (JSON::ParserError). With apply() outside + # the ensure block the injected header would not be restored. This test + # verifies that all side-effects from apply() are rolled back even when + # apply() itself raises. + @app = new_rack_app + post "/test_except_body_params", 'invalid-json', { "CONTENT_TYPE" => "application/json" } + + assert_nil last_request.env['HTTP_AUTHORIZATION'] + + assert_raises(JSON::ParserError) do + assert_request_schema_confirm(except: { headers: ['authorization'], body: ['required_string'] }) + end + + assert_nil last_request.env['HTTP_AUTHORIZATION'] + end + end + + end + end end describe "#assert_response_schema_confirm" do