Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/nack.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,5 +4,6 @@
require_relative "nack/error"
require_relative "nack/headers"
require_relative "nack/mime"
require_relative "nack/query_parser"
require_relative "nack/spec"
require_relative "nack/utils"
98 changes: 98 additions & 0 deletions lib/nack/query_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
# frozen_string_literal: true

module Nack
class QueryParser
def initialize(param_limit: 4096, depth_limit: 32)
@param_limit = param_limit
@depth_limit = depth_limit
freeze
end

def parse_query(string)
params = []
each_param(string) { |name, value| params << [name, value] }
params
end

def parse_nested_query(string)
params = {}
each_param(string) do |name, value|
keys = split_name(name)
raise ParamError, "too deeply nested: #{name.inspect}" if keys.size > @depth_limit
store(params, keys, value, name)
end
params
end

def build_query(params)
params.map { |name, value| build_param(Utils.escape(name), value) }.join("&")
end

def build_nested_query(params)
join(params.map { |name, value| build_nested_param(Utils.escape(name), value) })
end

private

def each_param(string)
pairs = string.b.split("&", @param_limit + 1)
raise ParamError, "too many parameters" if pairs.size > @param_limit
pairs.each do |pair|
next if pair.empty?
name, value = pair.split("=", 2)
yield Utils.unescape(name), value ? Utils.unescape(value) : +""
end
end

def split_name(name)
start = name.index("[")
return [name] unless start && /\A[^\[\]]+(?:\[[^\[\]]*\])*\z/.match?(name)
inner = name[start + 1...-1]
inner.empty? ? [name[0, start], ""] : [name[0, start], *inner.split("][", -1)]
end

def store(container, keys, value, name)
key, *rest = keys
if rest.empty?
return container << value if container.is_a?(Array)
raise ParamError, "conflicting shapes: #{name.inspect}" if container[key] in Array | Hash
return container[key] = value
end
type = rest.first.empty? ? Array : Hash
if container.is_a?(Array)
container << type.new unless container.last.instance_of?(type) && !occupied?(container.last, rest)
child = container.last
else
child = container.fetch(key) { container[key] = type.new }
raise ParamError, "conflicting shapes: #{name.inspect}" unless child.instance_of?(type)
end
store(child, rest, value, name)
end

def occupied?(container, keys)
case container
when Array then false
when Hash
key, *rest = keys
container.key?(key) && (rest.empty? || occupied?(container[key], rest))
else true
end
end

def build_param(escaped_name, value)
"#{escaped_name}=#{Utils.escape(value)}"
end

def build_nested_param(escaped_name, value)
case value
when Array then join(value.map { build_nested_param("#{escaped_name}%5B%5D", it) })
when Hash then join(value.map { |key, child| build_nested_param("#{escaped_name}%5B#{Utils.escape(key)}%5D", child) })
else build_param(escaped_name, value)
end
end

def join(parts)
parts.reject(&:empty?).join("&")
end
end
end
72 changes: 72 additions & 0 deletions test/spec_query_parser.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
# frozen_string_literal: true

require_relative "helper"

describe Nack::QueryParser do
let(:parser) { Nack::QueryParser.new }

it "is shareable across Ractors" do
Ractor.shareable?(parser).must_equal true
end

it "parses a query per WHATWG" do
parser.parse_query("a=1&b=2&a=3").must_equal [["a", "1"], ["b", "2"], ["a", "3"]]
parser.parse_query("&").must_equal []
parser.parse_query("a;b").must_equal [["a;b", ""]]
parser.parse_query("a==").must_equal [["a", "="]]
parser.parse_query("a").must_equal [["a", ""]]
parser.parse_query("+=+").must_equal [[" ", " "]]
parser.parse_query("\xff").must_equal [["\uFFFD", ""]]
end

it "raises ParamError beyond the parameter limit" do
limited = Nack::QueryParser.new(param_limit: 1)
limited.parse_query("a").must_equal [["a", ""]]
-> { limited.parse_query("a&b") }.must_raise Nack::ParamError
end

it "parses a nested query" do
parser.parse_nested_query("a=1&a=2").must_equal({"a" => "2"})
parser.parse_nested_query("a[]=1&a[]=2").must_equal({"a" => ["1", "2"]})
parser.parse_nested_query("a[b]=1").must_equal({"a" => {"b" => "1"}})
parser.parse_nested_query("a[b][]=1").must_equal({"a" => {"b" => ["1"]}})
end

it "keeps a malformed nested name flat" do
parser.parse_nested_query("[]&]a[]&a[[]&a[]]").must_equal({"[]" => "", "]a[]" => "", "a[[]" => "", "a[]]" => ""})
end

it "raises ParamError beyond the depth limit" do
limited = Nack::QueryParser.new(depth_limit: 1)
limited.parse_nested_query("a").must_equal({"a" => ""})
-> { limited.parse_nested_query("a[b]") }.must_raise Nack::ParamError
end

it "reuses the last array element unless occupied" do
parser.parse_nested_query("a[][]=1&a[][]=2").must_equal({"a" => [["1", "2"]]})
parser.parse_nested_query("a[][b][c]=1&a[][b][d]=2&a[][b][c]=3").must_equal({"a" => [{"b" => {"c" => "1", "d" => "2"}}, {"b" => {"c" => "3"}}]})
parser.parse_nested_query("a[][b]=1&a[][b][c]=2").must_equal({"a" => [{"b" => "1"}, {"b" => {"c" => "2"}}]})
parser.parse_nested_query("a[][]=1&a[][b]=2").must_equal({"a" => [["1"], {"b" => "2"}]})
end

it "raises ParamError on conflicting shapes" do
["a[]&a", "a[b]&a", "a&a[]", "a&a[b]", "a[]&a[b]", "a[b]&a[]"].each do |query|
-> { parser.parse_nested_query(query) }.must_raise Nack::ParamError
end
end

it "builds a query per WHATWG" do
parser.build_query([["a", "1"], ["b", "2"], ["a", "3"]]).must_equal "a=1&b=2&a=3"
parser.build_query([["a", ""]]).must_equal "a="
parser.build_query([[" ", " "]]).must_equal "+=+"
end

it "builds a nested query" do
params = {"a" => ["1", "2"], "b" => {"c" => "3"}, "d" => [{"e" => "4", "f" => "5"}, {"e" => "6"}]}
query = parser.build_nested_query(params)
query.must_equal "a%5B%5D=1&a%5B%5D=2&b%5Bc%5D=3&d%5B%5D%5Be%5D=4&d%5B%5D%5Bf%5D=5&d%5B%5D%5Be%5D=6"
parser.parse_nested_query(query).must_equal params
parser.build_nested_query({" " => {" " => " "}}).must_equal "+%5B+%5D=+"
parser.build_nested_query({"a" => [], "b" => {}}).must_equal ""
end
end
Loading