0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[Ruby] HTTP の QUERY メソッドを Rack で実装する

0
Last updated at Posted at 2026-06-26

はじめに

新しい HTTP メソッド QUERY が、RFC 10008 (Proposed Standard) として公開されました。

これを知るきっかけになったのが X の以下のポストです。

早速この QUERY メソッドを Python で実装した記事を見つけました。

この記事に感化され Ruby でも実装してみようと思います。

準備

必要な Gem のインストール

Puma と Rack をインストールします。

Gemfile
source 'https://rubygems.org'

# Active Support コア拡張のメソッドを利用したい。
gem 'activesupport', require: false

gem 'puma'
gem 'rack'
$ bundle install

Puma の設定

Puma 用の設定を記載した Ruby ファイル puma.rb を用意します。Puma は QUERY メソッドをサポートしていないため、supported_http_methods で許可する HTTP メソッドとして明示的に指定する必要があります。

puma.rb
port ENV.fetch('PORT', 9292)

# `QUERY` を追加する。
supported_http_methods %w(HEAD GET POST PUT DELETE OPTIONS TRACE PATCH QUERY)

Rack アプリケーションの用意

QUERY メソッド検証用の Rack アプリケーション QueryApp を用意します。

要件

  • [RFC 10008] QUERY はセーフ (読み取り専用) かつべき等。
  • [RFC 10008] クエリを URL ではなくリクエストボディに含める。
    • [RFC 10008] Content-Type が必須。
    • 欠落や構文エラーは 400 Bad Request。
    • [RFC 10008] 非対応メディアタイプは 415 Unsupported Media Type。
    • 構文は妥当だが処理不能 (クエリオブジェクトでない) 場合は 422 Unprocessable Entity。
  • [RFC 10008] 成功 (200) レスポンスの Content-Location に「同等の結果を GET で返すリソース」を含める。
  • [RFC 10008] レスポンスはキャッシュ可能。
    • [PFC 10008] キャッシュキーにリクエストボディを含める必要がある。
    • 正規化したクエリからバリデータ (ETag) を計算する。
  • レスポンスのメディアタイプを Accept で要求されたが非対応の場合は 406 Not Acceptable を返す。
  • If-None-Match による条件付きリクエストに対応し、一致時は 304 Not Modified を返す。
  • 非対応メソッドには Allow ヘッダ付きで 405 Method Not Allowed を返す。
  • [PFC 10008] Accept-Query で QUERY を受け付ける。そして受信可能な MIME メディアタイプを通知する。

コード

config.ru
require 'json'
require 'uri'
require 'digest'
require 'rack/utils'
require 'active_support/core_ext/object/blank'

DATASET = [
  { 'id' => 1, 'name' => '志那都',       'style' => '1A', 'price' => 8_500 },
  { 'id' => 2, 'name' => 'メルフォーレ', 'style' => '2A', 'price' => 2_490 },
  { 'id' => 3, 'name' => 'カルレダ',     'style' => '4A', 'price' => 18_900 },
  { 'id' => 4, 'name' => 'ホーネット',   'style' => '2A', 'price' => 2_490 },
  { 'id' => 5, 'name' => 'キョウト',     'style' => '1A', 'price' => 49_900 }
].freeze

class QueryApp
  RESOURCE_PATH = '/search'
  ALLOW_METHODS = 'GET, QUERY, OPTIONS'
  SUPPORTED_QUERY_TYPES = %w(application/json).freeze
  ACCEPT_QUERY = SUPPORTED_QUERY_TYPES.join(', ').freeze
  ACCEPTABLE_RESPONSE_TYPES = ['application/json', 'application/*', '*/*'].freeze

  Response = Data.define(:status, :headers, :body) do
    def to_a = [status, headers, body]
  end

  def call(env) = dispatch(env).to_a

  private

  def dispatch(env)
    return not_found unless env['PATH_INFO'] == RESOURCE_PATH

    case env['REQUEST_METHOD']
    when 'QUERY'
      handle_query(env)
    when 'GET'
      handle_get(env)
    when 'OPTIONS'
      handle_options
    else
      method_not_allowed
    end
  end

  def handle_query(env)
    content_type = env['CONTENT_TYPE'].to_s

    return bad_request('Content-Type is required') if content_type.blank?

    media_type = content_type.split(';').first.to_s.strip.downcase
    return unsupported_media_type unless SUPPORTED_QUERY_TYPES.include?(media_type)

    return not_acceptable unless acceptable?(env['HTTP_ACCEPT'].to_s)

    request_body = env['rack.input'].read.to_s
    conditions =
      begin
        parse_json_object(request_body)
      rescue JSON::ParserError
        return bad_request('Request body is not valid JSON')
      end

    return unprocessable_entity('Request content must be a JSON object') unless conditions

    effective_conditions = no_transform?(env) ? conditions : normalize(conditions)
    etag = weak_etag(effective_conditions)
    content_location = build_content_location(effective_conditions)

    cache_headers = {
      'content-location' => content_location,
      'cache-control' => 'max-age=60',
      'etag' => etag,
      'vary' => 'Accept',
      'accept-query' => ACCEPT_QUERY,
    }

    return Response.new(status: 304, headers: cache_headers, body: []) if env['HTTP_IF_NONE_MATCH'].to_s.strip == etag

    results = run_query(conditions)
    payload = JSON.generate('query' => conditions, 'count' => results.size, 'results' => results)

    headers = { 'content-type' => 'application/json' }.merge(cache_headers)
    Response.new(status: 200, headers: headers, body: [payload])
  end

  def handle_get(env)
    params = Rack::Utils.parse_nested_query(env['QUERY_STRING'])

    conditions = {}
    conditions['q'] = params['q'] unless params['q'].blank?
    conditions['style'] = params['style'] unless params['style'].blank?
    conditions['max_price'] = Integer(params['max_price']) unless params['max_price'].blank?

    results = run_query(conditions)

    Response.new(
      status: 200,
      headers: { 'content-type' => 'application/json' },
      body: [JSON.generate('query' => conditions, 'count' => results.size, 'results' => results)]
    )
  rescue ArgumentError
    bad_request('max_price must be an integer')
  end

  def handle_options
    headers = {
      'allow' => ALLOW_METHODS,
      'accept-query' => ACCEPT_QUERY
    }

    Response.new(status: 204, headers: headers, body: [])
  end

  def run_query(conditions)
    q = conditions['q']
    style = conditions['style']
    max_price = conditions['max_price']

    DATASET.select do |row|
      (q.blank? || row['name'].include?(q.to_s)) &&
      (style.blank? || row['style'] == style) &&
      (max_price.nil? || row['price'] <= max_price.to_i)
    end
  end

  def parse_json_object(body)
    return {} if body.empty?

    json = JSON.parse(body)
    return nil unless json.is_a?(Hash)

    json
  end

  def build_content_location(conditions)
    query_string = URI.encode_www_form(conditions)
    query_string.empty? ? RESOURCE_PATH : "#{RESOURCE_PATH}?#{query_string}"
  end

  def weak_etag(conditions) = %(W/"#{Digest::SHA256.hexdigest(JSON.generate(conditions))[0, 16]}")

  def no_transform?(env)
    env['HTTP_CACHE_CONTROL'].to_s.split(',').map(&:strip).include?('no-transform')
  end

  def normalize(conditions) = conditions.reject { |_k, v| v.blank? }.sort.to_h

  def not_found
    Response.new(
      status: 404,
      headers: { 'content-type' => 'application/json' },
      body: [JSON.generate('error' => 'Not Found')]
    )
  end

  def method_not_allowed
    Response.new(
      status: 405,
      headers: { 'content-type' => 'application/json', 'allow' => ALLOW_METHODS, 'accept-query' => ACCEPT_QUERY },
      body: [JSON.generate('error' => 'Method Not Allowed', 'allow' => %w[GET QUERY OPTIONS])]
    )
  end

  def unsupported_media_type
    Response.new(
      status: 415,
      headers: { 'content-type' => 'application/json', 'accept-query' => ACCEPT_QUERY },
      body: [JSON.generate('error' => 'Unsupported Media Type', 'supported' => SUPPORTED_QUERY_TYPES)]
    )
  end

  def acceptable?(accept)
    return true if accept.blank?

    accept.split(',').map { it.split(';').first.strip.downcase }.any? do |type|
      ACCEPTABLE_RESPONSE_TYPES.include?(type)
    end
  end

  def not_acceptable
    Response.new(
      status: 406,
      headers: { 'content-type' => 'application/json' },
      body: [JSON.generate('error' => 'Not Acceptable', 'supported' => SUPPORTED_QUERY_TYPES)]
    )
  end

  def unprocessable_entity(message)
    Response.new(
      status: 422,
      headers: { 'content-type' => 'application/json' },
      body: [JSON.generate('error' => message)]
    )
  end

  def bad_request(message)
    Response.new(
      status: 400,
      headers: { 'content-type' => 'application/json' },
      body: [JSON.generate('error' => message)]
    )
  end
end

run(QueryApp.new)

検証

Puma を使用して QueryApp を起動します。

$ bundle exec puma -C puma.rb config.ru
require 'net/http'
require 'uri'

def show(title, response)
  puts("=== #{title} ===")
  puts("status: #{response.code}")
  %w(content-location etag accept-query allow cache-control).each do |header|
    puts("#{header}: #{response[header]}") if response[header]
  end
  puts("body: #{response.body}")
  puts
end

http = Net::HTTP.new('localhost', 9292)

# 1. [RFC 10008] JSON ボディを含む正常な QUERY の場合は 200。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'application/json' })
req.body = '{"style":"2A","max_price":3000}'
show('1. QUERY (200 + Content-Location/ETag/Accept-Query)', http.request(req))

# 2. [RFC 10008] Content-Type が欠落している場合は 400。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search')
req.body = '{}'
show('2. Missing Content-Type -> 400', http.request(req))

# 3. [RFC 10008] 非対応の MIME メディアタイプの場合は 415。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'text/plain' })
req.body = 'x'
show('3. Unsupported media type -> 415', http.request(req))

# 4. QueryApp は POST 非対応なので 405 (Allow ヘッダを含む)。
req = Net::HTTP::Post.new('/search', { 'Content-Type' => 'application/json' })
req.body = '{}'
show('4. POST -> 405 + Allow', http.request(req))

# 5. [RFC 10008] Content-Location への GET が同等の結果を返すこと。
show('5. GET equivalent (Content-Location)', http.get('/search?max_price=3000&style=2A'))

# 6. 非対応の MIME メディアタイプを Accept で指定する場合は 406。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'application/json', 'Accept' => 'text/html' })
req.body = '{}'
show('6. Accept text/html -> 406', http.request(req))

# 7. 不正な構文の JSON の場合は 400。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'application/json' })
req.body = 'not json'
show('7. Malformed JSON -> 400', http.request(req))

# 8. 構文は妥当だがオブジェクトでない JSON の場合は 422。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'application/json' })
req.body = '[1, 2, 3]'
show('8. Valid JSON but not an object -> 422', http.request(req))

# 9. If-None-Match を付けて再送する場合 304。
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'application/json' })
req.body = '{"style":"2A","max_price":3000}'
first = http.request(req)
etag = first['etag']
req = Net::HTTPGenericRequest.new('QUERY', true, true, '/search', { 'Content-Type' => 'application/json', 'If-None-Match' => etag })
req.body = '{"style":"2A","max_price":3000}'
show("9. If-None-Match (#{etag}) -> 304", http.request(req))
=== 1. QUERY (200 + Content-Location/ETag/Accept-Query) ===
status: 200
content-location: /search?max_price=3000&style=2A
etag: W/"0f1f4e90e0d4f662"
accept-query: application/json
cache-control: max-age=60
body: {"query":{"style":"2A","max_price":3000},"count":2,"results":[{"id":2,"name":"メルフォーレ","style":"2A","price":2490},{"id":4,"name":"ホーネット","style":"2A","price":2490}]}

=== 2. Missing Content-Type -> 400 ===
status: 400
body: {"error":"Content-Type is required"}

=== 3. Unsupported media type -> 415 ===
status: 415
accept-query: application/json
body: {"error":"Unsupported Media Type","supported":["application/json"]}

=== 4. POST -> 405 + Allow ===
status: 405
accept-query: application/json
allow: GET, QUERY, OPTIONS
body: {"error":"Method Not Allowed","allow":["GET","QUERY","OPTIONS"]}

=== 5. GET equivalent (Content-Location) ===
status: 200
body: {"query":{"style":"2A","max_price":3000},"count":2,"results":[{"id":2,"name":"メルフォーレ","style":"2A","price":2490},{"id":4,"name":"ホーネット","style":"2A","price":2490}]}

=== 6. Accept text/html -> 406 ===
status: 406
body: {"error":"Not Acceptable","supported":["application/json"]}

=== 7. Malformed JSON -> 400 ===
status: 400
body: {"error":"Request body is not valid JSON"}

=== 8. Valid JSON but not an object -> 422 ===
status: 422
body: {"error":"Request content must be a JSON object"}

=== 9. If-None-Match (W/"0f1f4e90e0d4f662") -> 304 ===
status: 304
content-location: /search?max_price=3000&style=2A
etag: W/"0f1f4e90e0d4f662"
accept-query: application/json
cache-control: max-age=60
body:

バージョン情報

言語・Gem バージョン
Ruby 4.0.5
Puma 8.0.2
Rack 3.2.6

参考

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?