はじめに
新しい 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 |