diff --git a/spec/lucky/url_helpers_spec.cr b/spec/lucky/url_helpers_spec.cr new file mode 100644 index 000000000..e9593efc2 --- /dev/null +++ b/spec/lucky/url_helpers_spec.cr @@ -0,0 +1,178 @@ +require "../spec_helper" + +include ContextHelper + +describe Lucky::UrlHelpers do + describe "#current_page?" do + context "given a string" do + it "tests if a path matches the request path or not" do + view_for("/").current_page?("/").should be_true + view_for("/action").current_page?("/gum").should be_false + view_for("/action").current_page?("/action").should be_true + view_for("/action").current_page?("/action/").should be_true + view_for("/action/").current_page?("/action").should be_true + view_for("/action/").current_page?("/action/").should be_true + end + + it "tests if the path of a url matches request path or not" do + view_for("/") + .current_page?("https://example.com/") + .should be_true + view_for("/action") + .current_page?("https://example.com/action") + .should be_true + view_for("/action", host_with_port: "example.io") + .current_page?("https://example.com/action") + .should be_false + view_for("/action", host_with_port: "example.com:3000") + .current_page?("https://example.com/action") + .should be_false + view_for("/action", host_with_port: "example.com:3000") + .current_page?("https://example.com:3000/action") + .should be_true + view_for("/action", host_with_port: "example.com:3000") + .current_page?("http://example.com:3000/action") + .should be_true + end + + it "only tests positive for get and head requests" do + view_for("/get", "GET").current_page?("/get").should be_true + view_for("/head", "HEAD").current_page?("/head").should be_true + view_for("/post", "POST").current_page?("/post").should be_false + view_for("/put", "PUT").current_page?("/put").should be_false + view_for("/patch", "PATCH").current_page?("/patch").should be_false + view_for("/delete", "DELETE").current_page?("/delete").should be_false + end + + it "ignores query parameters by default" do + view_for("/action?order=desc&page=1").current_page?("/action") + .should be_true + view_for("/action").current_page?("/action?order=desc&page=1") + .should be_true + view_for("/action?order=desc&page=1").current_page?("/action/123") + .should be_false + end + + it "deals with escaped characters in query params" do + view_for("/pages?description=Some%20d%C3%A9scription") + .current_page?("/pages?description=Some déscription", check_query_params: true) + .should be_true + view_for("/pages?description=Some%20d%C3%A9scription") + .current_page?("/pages?description=Some%20d%C3%A9scription", check_query_params: true) + .should be_true + end + + it "checks query params if explicitly required" do + view_for("/action?order=desc&page=1") + .current_page?("/action?order=desc&page=1", check_query_params: true) + .should be_true + view_for("/action") + .current_page?("/action", check_query_params: true) + .should be_true + view_for("/action") + .current_page?("/action?order=desc&page=1", check_query_params: true) + .should be_false + view_for("/action?order=desc&page=1") + .current_page?("/action", check_query_params: true) + .should be_false + end + + it "does not care about the order of query params" do + view_for("/action?order=desc&page=1") + .current_page?("/action?order=desc&page=1", check_query_params: true) + .should be_true + view_for("/action?order=desc&page=1") + .current_page?("/action?page=1&order=desc", check_query_params: true) + .should be_true + end + + it "ignores anchors" do + view_for("/pages/123").current_page?("/pages/123#section") + .should be_true + view_for("/pages/123#section").current_page?("/pages/123") + .should be_true + view_for("/pages/123#section").current_page?("/pages/123#section") + .should be_true + view_for("/pages/123") + .current_page?("/pages/123#section", check_query_params: true) + .should be_true + end + end + + context "given a browser action" do + it "tests if the path matches or not" do + view_for("/pages/123").current_page?(Pages::Show.with(123)) + .should be_true + view_for("/pages/123").current_page?(Pages::Show.with(12)) + .should be_false + view_for("/pages").current_page?(Pages::Index) + .should be_true + view_for("/pages") + .current_page?(Pages::Index.with(page: 2)) + .should be_true + view_for("/pages?page=2") + .current_page?(Pages::Index) + .should be_true + end + + it "checks query params if explicitly required" do + view_for("/pages") + .current_page?(Pages::Index, check_query_params: true) + .should be_true + view_for("/pages?page=2") + .current_page?(Pages::Index.with(page: 2), check_query_params: true) + .should be_true + view_for("/pages") + .current_page?(Pages::Index.with(page: 2), check_query_params: true) + .should be_false + view_for("/pages?page=2") + .current_page?(Pages::Index, check_query_params: true) + .should be_false + end + + it "ignores anchors" do + view_for("/pages/123") + .current_page?(Pages::Show.with(123, anchor: "section")) + .should be_true + view_for("/pages/123#section") + .current_page?(Pages::Show.with(123)) + .should be_true + view_for("/pages/123#section") + .current_page?(Pages::Show.with(123, anchor: "section")) + .should be_true + view_for("/pages/123") + .current_page?(Pages::Show.with(123, anchor: "section"), check_query_params: true) + .should be_true + end + end + end +end + +private def view_for( + path : String, + method : String = "GET", + host_with_port : String = "example.com" +) + request = HTTP::Request.new(method, path) + request.headers["Host"] = host_with_port + TestPage.new(build_context(path: path, request: request)) +end + +private class TestPage + include Lucky::HTMLPage + include Lucky::UrlHelpers +end + +class Pages::Index < TestAction + param page : Int32 = 1 + + get "/pages" do + plain_text "I'm just a list of pages" + end +end + +class Pages::Show < TestAction + get "/pages/:id" do + plain_text "I'm just a page" + end +end diff --git a/spec/support/context_helper.cr b/spec/support/context_helper.cr index 2d44402ee..5275c2ee2 100644 --- a/spec/support/context_helper.cr +++ b/spec/support/context_helper.cr @@ -1,7 +1,12 @@ module ContextHelper extend self - private def build_request(method = "GET", body = "", content_type = "", fixed_length : Bool = false) : HTTP::Request + private def build_request( + method = "GET", + body = "", + content_type = "", + fixed_length : Bool = false + ) : HTTP::Request headers = HTTP::Headers.new headers.add("Content-Type", content_type) if fixed_length @@ -10,7 +15,10 @@ module ContextHelper HTTP::Request.new(method, "/", body: body, headers: headers) end - def build_context(path = "/", request : HTTP::Request? = nil) : HTTP::Server::Context + def build_context( + path = "/", + request : HTTP::Request? = nil + ) : HTTP::Server::Context build_context_with_io(IO::Memory.new, path: path, request: request) end @@ -26,7 +34,11 @@ module ContextHelper ) end - private def build_context_with_io(io : IO, path = "/", request = nil) : HTTP::Server::Context + private def build_context_with_io( + io : IO, + path = "/", + request = nil + ) : HTTP::Server::Context request = request || HTTP::Request.new("GET", path) response = HTTP::Server::Response.new(io) HTTP::Server::Context.new request, response diff --git a/src/lucky/page_helpers/url_helpers.cr b/src/lucky/page_helpers/url_helpers.cr new file mode 100644 index 000000000..5f6a91e6e --- /dev/null +++ b/src/lucky/page_helpers/url_helpers.cr @@ -0,0 +1,91 @@ +module Lucky::UrlHelpers + # Tests if the given path matches the current request path. + # + # ``` + # # Let's say we are visiting https://example.com/shop/products?order=desc&page=1 + # current_page?("/shop/checkout") + # # => false + # current_page?("/shop/products") + # # => true + # current_page?("/shop/products/") + # # => true + # current_page?("/shop/products?order=desc&page=1") + # # => true + # current_page?("/shop/products", check_query_params: true) + # # => false + # current_page?("/shop/products?order=desc&page=1", check_query_params: true) + # # => true + # current_page?("https://example.com/shop/products") + # # => true + # current_page?("https://example.io/shop/products") + # # => false + # current_page?("https://example.com/shop/products", check_query_params: true) + # # => false + # current_page?("https://example.com/shop/products?order=desc&page=1") + # # => true + # ``` + def current_page?( + value : String, + check_query_params : Bool = false + ) + request = @context.request + + return false unless {"GET", "HEAD"}.includes?(request.method) + + uri = URI.parse(value) + request_uri = URI.parse(request.resource) + path = uri.path + resource = request_uri.path + + unless path == "/" + path = path.chomp("/") + resource = resource.chomp("/") + end + + if check_query_params + path += comparable_query_params(uri.query_params) + resource += comparable_query_params(request_uri.query_params) + end + + if value.match(/^\w+:\/\//) + host_with_port = uri.port ? "#{uri.host}:#{uri.port}" : uri.host + "#{host_with_port}#{path}" == "#{request.host_with_port}#{resource}" + else + path == resource + end + end + + # Tests if the given path matches the current request path. + # + # ``` + # # Visiting https://example.com/pages/123 + # current_page?(Pages::Show.with(123)) + # # => true + # current_page?(Posts::Show.with(123)) + # # => false + # # Visiting https://example.com/pages + # current_page?(Pages::Index) + # # => true + # current_page?(Blog::Index) + # # => false + # # Visiting https://example.com/pages?page=2 + # current_page?(Pages::Index.with) + # # => true + # current_page?(Pages::Index.with(page: 2)) + # # => true + # current_page?(Pages::Index.with, check_query_params: true) + # # => false + # current_page?(Pages::Index.with(page: 2), check_query_params: true) + # # => true + # ``` + def current_page?( + action : Lucky::Action.class | Lucky::RouteHelper, + check_query_params : Bool = false + ) + current_page?(action.path, check_query_params) + end + + private def comparable_query_params(query_params : HTTP::Params) : String + URI.decode(query_params.map(&.join).sort!.join) + end +end