Rspecのファイルアップロードテスト時にcommitteeでCommittee::InvalidRequestが発生する事象への対処方法

# 発生しているエラー

```
     Committee::InvalidRequest:
       #/paths/~1contracts/post/requestBody/content/multipart~1form-data/schema/properties/original_file expected string, but received ActionDispatch::Http::UploadedFile: #<ActionDispatch::Http::UploadedFile:0x00007fa77e1f36a0>
```

# 何がしたいのか

`multipart/form-data` 形式でファイルアップロードを受け取るようなAPIを定義している。
APIはOpenAPI3.0の仕様に基づいて設計していて、例えば以下のようになる。

```yaml
requestBody:
  original_file:
    image/png:
      schema:
        type: string
        format: binary
```

[Swagger](https://swagger.io/docs/specification/describing-request-body/file-upload/)のドキュメントにもある。

このAPIに対して `committee` gem を使ってRspecを書きたい。

# テストの内容

```ruby:some_controller_spec.rb
# formのパラメータ
let(:file_upload_form) {
  {
    article_id: 1_000,
    name: 'This is good article',
    # set real existing file
    original_file: fixture_file_upload(
      Rails.root.join('sample_files/sample.docx'),
      'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
    )
  }
}
```

Requestとして使うパラメータは上記のように定義していて、アップロードするファイルはテスト用に用意したDocxファイル。
これを以下のようにPOSTしてAPIのフォーマットを `assert_schema_conform` メソッドで検証している。

```ruby:some_controller_spec.rb
it 'uploads file' do
  post api_v1_images_path,
         headers: authenticated_header(user),
         params: file_upload_form

  expect(response).to have_http_status(:success)
  assert_schema_conform
end
```

# エラーの原因

APIのRequestの仕様が `type: string` なのに、テストでは `ActionDispatch` のオブジェクトが設定されているので、期待しているのと違う、っていうエラーが発生している。
しかし、ファイルアップロードのテストなのでStringでは内部の処理がうごかないので、変えることもできない。これは困った。

という状況

# エラーの発生箇所

エラーは以下の `OpenAPIParser` の処理で発生していう。
[lib/openapi_parser/schema_validators/string_validator.rb#L11]
(https://github.com/ota42y/openapi_parser/blob/44c640cc103bbbb9e8029e41a8889e8fd9350902/lib/openapi_parser/schema_validators/string_validator.rb#L11)

```ruby:lib/openapi_parser/schema_validators/string_validator.rb

    def coerce_and_validate(value, schema, **_keyword_args)
      return OpenAPIParser::ValidateError.build_error_result(value, schema) unless value.kind_of?(String)

      # ... 以下省略 ...

    end
```

単純に `value`が `String` クラスかどうかをみていて、`String`じゃない場合はErrorになっている。今回はここが `ActionDispatch` のオブジェクトなので `value.kind_of?(String)` が偽となりエラーが発生してしまう。  


# `type: string`であっても`format: binary`の場合は`String`クラス以外も許す

ファイルのように、`format: binay`の場合はエラーをRaiseしないように条件を変えてやれば良い。そのためのパッチを作成する。
バッチとして該当のメソッド `coerce_and_validate(value, schema, **_keyword_args)` を定義した任意のModuleを作成する。

```ruby
module StringValidatorPatch
  def coerce_and_validate(value, schema, **keyword_args)
    # この処理を変更
    # https://github.com/ota42y/openapi_parser/blob/61874f0190a86c09bdfb78de5f51cfb6ae16068b/lib/openapi_parser/schema_validators/string_validator.rb#L11
    if !value.is_a?(String) && schema.format != 'binary'
      return OpenAPIParser::ValidateError.build_error_result(value, schema)
    end
    # --- ここまで

    value, err = check_enum_include(value, schema)
    return [nil, err] if err

    value, err = pattern_validate(value, schema)
    return [nil, err] if err

    unless @datetime_coerce_class.nil?
      value, err = coerce_date_time(value, schema)
      return [nil, err] if err
    end

    value, err = validate_max_min_length(value, schema)
    return [nil, err] if err

    value, err = validate_email_format(value, schema)
    return [nil, err] if err

    value, err = validate_uuid_format(value, schema)
    return [nil, err] if err

    [value, nil]
  end
end
```

このように、変更したいメソッドだけを再定義したModuleを定義する。このModuleをパッチを当てたいクラス、今回は`OpenAPIParser::SchemaValidator::StringValidator`クラスを再オープンし、`Module#prepend`を使ってメソッドを上書きする。
[Module#prependのリファレンス](https://docs.ruby-lang.org/ja/latest/method/Module/i/prepend.html)


```ruby
class OpenAPIParser::SchemaValidator::StringValidator
  prepend StringValidatorPatch
end
```

これで `Committee::InvalidRequest` エラーが発生しなくなる

# 影響を最低限に抑える

パッチを当てることで`Committee::InvalidRequest`エラーを回避することはできるが、グローバルなスコープでパッチを当ててしまうと全体に影響が出てしまう。
この変更はファイルアップロードを伴うテストの時だけ有効にしたい。
そのためRspecの必要なコンテキストでだけ反映されるように `context 'some context' do ... end` の中で定義することを考える。

```ruby:some_controller_spec.rb
RSpec.describe SomeController, type: :request do
  context 'some context' do

    # context内でパッチを当てる
    module StringValidatorPatch
      def coerce_and_validate(value, schema, **keyword_args)
        if !value.is_a?(String) && schema.format != 'binary'
          return OpenAPIParser::ValidateError.build_error_result(value, schema)
        end
        # (以下略)
      end
    end

    class OpenAPIParser::SchemaValidator::StringValidator
      prepend StringValidatorPatch
    end
    # ここまでパッチ

    it 'uploads file' do
      post api_v1_images_path,
             headers: authenticated_header(user),
             params: file_upload_form

      expect(response).to have_http_status(:success)
      assert_schema_conform
    end
  end
end
```

しかし、ブロックのスコープは定数を分離したり名前空間を設定したりしないので、ブロック内でクラス定義などの処理は避けたい
Rubocopの [Lint/ConstantDefinitionInBlock](https://docs.rubocop.org/rubocop/cops_lint.html#lintconstantdefinitioninblock)にもひっかかってしまう。
Rspecで同様の定数定義を行う場合は、`stub_const()` を使って定義する。

# `stub_const()` を使って必要な箇所にパッチを適用する

クラスの再オープンを`class`キーワードを使わずに行うには [Class.new](https://docs.ruby-lang.org/ja/latest/method/Class/s/new.html)を使う。
ブロックを渡すことで、クラス定義も行える


```ruby
Foo = Class.new {|c|
  def hello; 'hello'; end
}

puts Foo.new.hello # => 'hello'
```

これを使って、パッチを当てたクラスを定義する。パッチ用の`StringValidatorPatch`moduleは `spec/support`ディレクトリに `string_validator_patch.rb` として保存し、読み込まれるようにしておく。

```ruby
patched = Class.new(OpenAPIParser::SchemaValidator::StringValidator) do |klass|
  klass.prepend StringValidatorPatch
end

stub_const('OpenAPIParser::SchemaValidator::StringValidator', patched)

```

`Class.new()` の引数にクラスを渡すと親クラスとして扱われるので、上記の場合は `patched` が `OpenAPIParser::SchemaValidator::StringValidator` の子クラスとなる。
それを `stub_const()` を使って定数定義をしてやれば、パッチを当てたクラスを利用することができる。

この処理を必要に応じて `before` や `let` で実装する。

# まとめ

- `Committee::InvalidRequest` エラーを解消するには、Validatorクラスにパッチを当てる
- パッチを当てるには当該メソッドだけを実装したModuleを定義し、`prepend`を使って反映する
- RSpecでは`Class.new()`と`stub_const()` を使って局所的にパッチをあてる


```ruby:spec/support/string_validator_patch.rb
module StringValidatorPatch
  def coerce_and_validate(value, schema, **keyword_args)
    # この処理を変更
    # https://github.com/ota42y/openapi_parser/blob/61874f0190a86c09bdfb78de5f51cfb6ae16068b/lib/openapi_parser/schema_validators/string_validator.rb#L11
    if !value.is_a?(String) && schema.format != 'binary'
      return OpenAPIParser::ValidateError.build_error_result(value, schema)
    end
    # --- ここまで

    value, err = check_enum_include(value, schema)
    return [nil, err] if err

    value, err = pattern_validate(value, schema)
    return [nil, err] if err

    unless @datetime_coerce_class.nil?
      value, err = coerce_date_time(value, schema)
      return [nil, err] if err
    end

    value, err = validate_max_min_length(value, schema)
    return [nil, err] if err

    value, err = validate_email_format(value, schema)
    return [nil, err] if err

    value, err = validate_uuid_format(value, schema)
    return [nil, err] if err

    [value, nil]
  end
end
```


```ruby:some_controller_spec.rb
RSpec.describe SomeController, type: :request do
  context 'some context' do
    let(:file_upload_form) {
      {
        article_id: 1_000,
        name: 'This is good article',
        original_file: fixture_file_upload(
          Rails.root.join('sample_files/sample.docx'),
          'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
        )
      }
    }

    before do
      # context内でパッチを当てる
      patched = Class.new(OpenAPIParser::SchemaValidator::StringValidator) do |klass|
        klass.prepend StringValidatorPatch
      end
      stub_const('OpenAPIParser::SchemaValidator::StringValidator', patched)
    end

    it 'uploads file' do
      post api_v1_images_path,
           headers: authenticated_header(user),
           params: file_upload_form

      expect(response).to have_http_status(:success)
      assert_schema_conform
    end
  end
end
```