From f40e845b619968c7a4d1f9458246f6bd7fd51953 Mon Sep 17 00:00:00 2001 From: himura467 Date: Tue, 10 Nov 2026 17:32:35 +0900 Subject: [PATCH] feat: add Nack::URLMap --- lib/nack.rb | 1 + lib/nack/url_map.rb | 50 +++++++++++++++++++++++++++++++++++++++ test/spec_url_map.rb | 56 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+) create mode 100644 lib/nack/url_map.rb create mode 100644 test/spec_url_map.rb diff --git a/lib/nack.rb b/lib/nack.rb index b4b8ff1..cd0fd14 100644 --- a/lib/nack.rb +++ b/lib/nack.rb @@ -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" diff --git a/lib/nack/url_map.rb b/lib/nack/url_map.rb new file mode 100644 index 0000000..ad9a2d1 --- /dev/null +++ b/lib/nack/url_map.rb @@ -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 diff --git a/test/spec_url_map.rb b/test/spec_url_map.rb new file mode 100644 index 0000000..cf2c287 --- /dev/null +++ b/test/spec_url_map.rb @@ -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