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 @@ -10,4 +10,5 @@
require_relative "nack/request"
require_relative "nack/response"
require_relative "nack/spec"
require_relative "nack/url_map"
require_relative "nack/utils"
50 changes: 50 additions & 0 deletions lib/nack/url_map.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
# frozen_string_literal: true

module Nack
class URLMap
Mount = Data.define(:host, :prefix, :subtree, :app)
private_constant :Mount

def initialize(mapping)
@mounts = mapping.map { |reference, app| mount(reference, app) }
.sort_by { [it.host ? 0 : 1, -it.prefix.bytesize] }.freeze
end

def call(request)
script_name = request.script_name
path_info = request.path_info
if script_name.start_with?("/") || path_info.start_with?("/")
mount = @mounts.find do
(path_info.start_with?(it.subtree) || path_info == it.prefix) && (it.host.nil? || it.host.casecmp(request.host) == 0)
end
end
return [404, [["content-type", "text/plain"]], "Not Found"] unless mount
prefix = mount.prefix
request.script_name = script_name.empty? ? prefix : script_name + prefix
request.path_info = path_info.byteslice(prefix.bytesize, path_info.bytesize)
mount.app.call(request)
ensure
request.script_name = script_name
request.path_info = path_info
end

private

def mount(reference, app)
host, path = split_reference(reference)
prefix = path.chomp("/").b.freeze
Mount.new(host, prefix, (prefix + "/").freeze, app)
end

def split_reference(reference)
unless reference.start_with?("//")
raise ArgumentError, "reference must start with /: #{reference.inspect}" unless reference.start_with?("/")
return [nil, reference]
end
host, path = reference.delete_prefix("//").split("/", 2)
match = Request::AUTHORITY.match(host)
raise ArgumentError, "reference must name a host and no port: #{reference.inspect}" unless match && match[1] == host
[host.b.freeze, "/#{path}"]
end
end
end
56 changes: 56 additions & 0 deletions test/spec_url_map.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# frozen_string_literal: true

require_relative "helper"

describe Nack::URLMap do
def request(path:, authority: nil)
Nack::Request.new(Struct.new(:scheme, :authority, :path).new(nil, authority && IO::Buffer.for(authority), path && IO::Buffer.for(path)))
end

def app(name)
->(request) { [name, request.script_name, request.path_info] }
end

it "rejects a reference without a leading slash or host, or with a port" do
["a", "///a", "//h:/", "//h:1"].each { |reference| -> { Nack::URLMap.new(reference => app(:a)) }.must_raise ArgumentError }
end

it "matches no prefix against authority-form and asterisk-form" do
map = Nack::URLMap.new("/" => app(:root))
[request(path: nil), request(path: "*")].each { map.call(it).first.must_equal 404 }
end

it "moves the longest prefix ending at a segment boundary from path_info to script_name" do
map = Nack::URLMap.new("/" => app(:root), "/a" => app(:a), "/a/b/" => app(:ab))
map.call(request(path: "/a/b/c")).must_equal [:ab, "/a/b", "/c"]
map.call(request(path: "/a/bc")).must_equal [:a, "/a", "/bc"]
map.call(request(path: "/a")).must_equal [:a, "/a", ""]
map.call(request(path: "/b")).must_equal [:root, "", "/b"]
end

it "prefers a mount for the host, ignoring case and port, over a longer prefix" do
map = Nack::URLMap.new("/a/b" => app(:ab), "//h/a" => app(:h))
map.call(request(authority: "H:1", path: "/a/b")).must_equal [:h, "/a", "/b"]
map.call(request(authority: "i", path: "/a/b")).must_equal [:ab, "/a/b", ""]
map.call(request(path: "/a/b")).must_equal [:ab, "/a/b", ""]
Nack::URLMap.new("//[::1]" => app(:h)).call(request(authority: "[::1]:1", path: "/")).must_equal [:h, "", "/"]
Nack::URLMap.new("//é" => app(:h)).call(request(authority: "é", path: "/")).must_equal [:h, "", "/"]
end

it "responds 404 when no prefix matches" do
Nack::URLMap.new("/a" => app(:a)).call(request(path: "/b")).must_equal [404, [["content-type", "text/plain"]], "Not Found"]
end

it "appends to script_name when nested, matching after an outer mount took the whole path" do
Nack::URLMap.new("/a" => Nack::URLMap.new("/b" => app(:b))).call(request(path: "/a/b/c")).must_equal [:b, "/a/b", "/c"]
Nack::URLMap.new("/a" => Nack::URLMap.new("/" => app(:root))).call(request(path: "/a")).must_equal [:root, "/a", ""]
end

it "restores script_name and path_info after the mounted app returns or raises" do
req = request(path: "/a/b")
Nack::URLMap.new("/a" => app(:a)).call(req)
[req.script_name, req.path_info].must_equal ["", "/a/b"]
-> { Nack::URLMap.new("/a" => ->(_) { raise }).call(req) }.must_raise RuntimeError
[req.script_name, req.path_info].must_equal ["", "/a/b"]
end
end
Loading