Skip to content

Commit b913fc6

Browse files
committed
Add wildcard url matcher service
Add a new service which should try to find the correct wildcard url for a given parent_page. It will traverse the page tree and will try to match all page_layouts with a wildcard_url. The first match wins and will be returned. The page and params are stored in the service itself and can be received later in the controller.
1 parent e8fcda3 commit b913fc6

2 files changed

Lines changed: 286 additions & 0 deletions

File tree

Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# frozen_string_literal: true
2+
3+
module Alchemy
4+
class WildcardUrlMatcher
5+
attr_reader :page, :params
6+
7+
def initialize(parent_page = Current.language.root_page)
8+
@parent_page = parent_page
9+
@params = ActionController::Parameters.new
10+
@page = nil
11+
end
12+
13+
def call(urlname)
14+
@page = find_page(urlname)
15+
self
16+
end
17+
18+
private
19+
20+
# Walks the page tree level by level, matching URL segments against page slugs or wildcard URLs.
21+
#
22+
# @return [Alchemy::Page, nil] the matched page or nil if no match
23+
def find_page(urlname)
24+
return if urlname.blank? || wildcard_layout_names.empty?
25+
26+
segments = urlname.split("/")
27+
current_parent = @parent_page
28+
path_prefix = nil
29+
page = nil
30+
31+
until segments.empty?
32+
children = current_parent.children.contentpages
33+
34+
# Try an exact slug match first
35+
expected_urlname = [path_prefix, segments.first].compact.join("/")
36+
page = children.find_by(urlname: expected_urlname)
37+
if page
38+
segments = segments.drop(1)
39+
current_parent = page
40+
path_prefix = expected_urlname
41+
next
42+
end
43+
44+
# Try children with wildcard_url attributes
45+
children.where(page_layout: wildcard_layout_names).each do |child|
46+
page_definition = PageDefinition.get(child.page_layout)
47+
pattern = page_definition.wildcard_url.pattern
48+
49+
# a wildcard url pattern can have multiple segments, e.g. ":year/:slug"
50+
segment_count = pattern.count("/") + 1
51+
candidate_url = segments.first(segment_count).join("/")
52+
53+
next unless url_matches_pattern?(candidate_url, pattern)
54+
55+
extracted_parameters = extract_params_from_url(candidate_url, pattern)
56+
next unless params_match_constraints?(extracted_parameters, page_definition.wildcard_url.params)
57+
58+
@params.merge!(extracted_parameters)
59+
60+
# prepare the data for the next segment loop
61+
path_prefix = [path_prefix, child.slug].compact.join("/")
62+
segments = segments.drop(segment_count)
63+
current_parent = child
64+
page = child
65+
break
66+
end
67+
return unless page
68+
end
69+
70+
page
71+
end
72+
73+
# Checks if a URL matches a pattern's structure.
74+
# Verifies segment count and static segment equality.
75+
#
76+
# @param url [String] the URL segment(s) to check, e.g. "2024/my-post"
77+
# @param pattern [String] the wildcard pattern, e.g. ":year/:slug"
78+
# @return [Boolean]
79+
def url_matches_pattern?(url, pattern)
80+
url_segments = url.split("/")
81+
pattern_segments = pattern.split("/")
82+
return false unless url_segments.size == pattern_segments.size
83+
84+
pattern_segments.each_with_index.all? do |segment, index|
85+
segment.start_with?(":") || segment == url_segments[index]
86+
end
87+
end
88+
89+
# Extracts named parameters from a URL that matches a pattern.
90+
#
91+
# @param url [String] the URL segment(s) to extract from, e.g. "2024/my-post"
92+
# @param pattern [String] the wildcard pattern, e.g. ":year/:slug"
93+
# @return [Hash<Symbol, String>] extracted parameter names and values
94+
def extract_params_from_url(url, pattern)
95+
url_segments = url.split("/")
96+
pattern.split("/").each_with_index.each_with_object({}) do |(segment, index), params|
97+
params[segment[1..].to_sym] = url_segments[index] if segment.start_with?(":")
98+
end
99+
end
100+
101+
# Validates extracted params against wildcard_url params from the page definition.
102+
# Params can be a simple string or a hash mapping param names to types.
103+
#
104+
# @param params [Hash<Symbol, String>] extracted params
105+
# @param constraints [String, Hash, nil] param definitions
106+
# @return [Boolean]
107+
def params_match_constraints?(params, constraints)
108+
return true if constraints.blank?
109+
110+
format_matchers = Alchemy.config.format_matchers
111+
normalize_constraints(constraints, params).all? do |key, type|
112+
if type.is_a?(Regexp)
113+
params[key]&.match?(type)
114+
else
115+
next true unless format_matchers.respond_to?(type.to_sym)
116+
117+
params[key]&.match?(format_matchers.public_send(type.to_sym))
118+
end
119+
end
120+
end
121+
122+
# Normalizes constraints into a hash mapping param names to type strings.
123+
# A simple string applies to all params, e.g. params: "integer"
124+
#
125+
# @param constraints [String, Hash] param definitions
126+
# @param params [Hash<Symbol, String>] extracted params
127+
# @return [Hash<Symbol, String>]
128+
def normalize_constraints(constraints, params)
129+
if constraints.is_a?(String)
130+
params.keys.to_h { |key| [key, constraints] }
131+
else
132+
constraints.transform_keys(&:to_sym)
133+
end
134+
end
135+
136+
# Returns the names of all page layouts that have a wildcard_url defined.
137+
#
138+
# @return [Array<String>] layout names with wildcard URLs
139+
def wildcard_layout_names
140+
@_wildcard_layout_names ||= PageDefinition.all.select { |d| d.wildcard_url&.present? }.map(&:name)
141+
end
142+
end
143+
end
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
# frozen_string_literal: true
2+
3+
require "rails_helper"
4+
5+
module Alchemy
6+
RSpec.describe WildcardUrlMatcher do
7+
let(:language) { create(:alchemy_language) }
8+
let!(:language_root) do
9+
create(:alchemy_page, :language_root, language: language)
10+
end
11+
12+
before do
13+
PageDefinition.reset!
14+
Current.language = language
15+
end
16+
17+
def create_page(name:, layout: "standard", parent: language_root)
18+
create(
19+
:alchemy_page, :public,
20+
name: name,
21+
page_layout: layout,
22+
parent: parent,
23+
language: language
24+
)
25+
end
26+
27+
# Page tree used by most tests:
28+
#
29+
# Products (standard) -> /products
30+
# +-- Product Details (product_detail) -> /products/:id (integer constraint)
31+
# | +-- Comments (standard) -> /products/:id/comments
32+
# +-- Product By Slug (product_by_slug) -> /products/:slug (no constraint)
33+
#
34+
# Blog (standard) -> /blog
35+
# +-- Blog Post (blog_post) -> /blog/:year/:slug (integer + string)
36+
#
37+
# Users (standard) -> /users
38+
# +-- User Profile (user_profile) -> /users/:uuid/profile (uuid constraint)
39+
40+
let!(:products_page) { create_page(name: "Products") }
41+
let!(:product_detail_page) { create_page(name: "Product Details", layout: "product_detail", parent: products_page) }
42+
let!(:comments_page) { create_page(name: "Comments", parent: product_detail_page) }
43+
let!(:product_by_slug_page) { create_page(name: "Product By Slug", layout: "product_by_slug", parent: products_page) }
44+
45+
let!(:blog_page) { create_page(name: "Blog") }
46+
let!(:blog_post_page) { create_page(name: "Blog Post", layout: "blog_post", parent: blog_page) }
47+
48+
let!(:users_page) { create_page(name: "Users") }
49+
let!(:user_profile_page) { create_page(name: "User Profile", layout: "user_profile", parent: users_page) }
50+
51+
it "defaults to Current.language.root_page" do
52+
matcher = described_class.new.call("products/123")
53+
expect(matcher.page).to eq(product_detail_page)
54+
expect(matcher.params[:id]).to eq("123")
55+
end
56+
57+
it "returns nil for a blank path" do
58+
expect(described_class.new(language_root).call("").page).to be_nil
59+
end
60+
61+
it "returns nil for a path with no matching prefix" do
62+
expect(described_class.new(language_root).call("other/123").page).to be_nil
63+
end
64+
65+
context "single-segment pattern with integer constraint" do
66+
it "matches /products/123" do
67+
matcher = described_class.new(language_root).call("products/123")
68+
expect(matcher.page).to eq(product_detail_page)
69+
expect(matcher.params[:id]).to eq("123")
70+
end
71+
72+
it "rejects non-integer and falls through to unconstrained sibling" do
73+
matcher = described_class.new(language_root).call("products/some-slug")
74+
expect(matcher.page).to eq(product_by_slug_page)
75+
expect(matcher.params[:slug]).to eq("some-slug")
76+
end
77+
end
78+
79+
context "multi-segment pattern" do
80+
it "matches /blog/2024/my-post and extracts both params" do
81+
matcher = described_class.new(language_root).call("blog/2024/my-post")
82+
expect(matcher.page).to eq(blog_post_page)
83+
expect(matcher.params[:year]).to eq("2024")
84+
expect(matcher.params[:slug]).to eq("my-post")
85+
end
86+
87+
it "does not match with wrong segment count" do
88+
expect(described_class.new(language_root).call("blog/2024").page).to be_nil
89+
end
90+
end
91+
92+
context "pattern with static segments" do
93+
let(:uuid) { "550e8400-e29b-41d4-a716-446655440000" }
94+
95+
it "matches /users/:uuid/profile" do
96+
matcher = described_class.new(language_root).call("users/#{uuid}/profile")
97+
expect(matcher.page).to eq(user_profile_page)
98+
expect(matcher.params[:uuid]).to eq(uuid)
99+
end
100+
101+
it "does not match without the trailing static segment" do
102+
expect(described_class.new(language_root).call("users/#{uuid}").page).to be_nil
103+
end
104+
end
105+
106+
context "hierarchical patterns (grandchild under pattern page)" do
107+
it "matches /products/42/comments through the pattern parent" do
108+
matcher = described_class.new(language_root).call("products/42/comments")
109+
expect(matcher.page).to eq(comments_page)
110+
expect(matcher.params[:id]).to eq("42")
111+
end
112+
end
113+
114+
context "with competing sibling patterns of different segment counts" do
115+
let!(:shared_page) { create_page(name: "Shared") }
116+
let!(:multi_segment_page) { create_page(name: "Multi Segment", layout: "blog_post", parent: shared_page) }
117+
let!(:single_segment_page) { create_page(name: "Single Segment", layout: "product_by_slug", parent: shared_page) }
118+
let!(:child_of_single) { create_page(name: "My Post", parent: single_segment_page) }
119+
120+
it "matches the first pattern sibling even when the other could also match via its child" do
121+
matcher = described_class.new(language_root).call("shared/2024/my-post")
122+
expect(matcher.page).to eq(multi_segment_page)
123+
expect(matcher.params[:year]).to eq("2024")
124+
expect(matcher.params[:slug]).to eq("my-post")
125+
end
126+
end
127+
128+
context "with a regex constraint" do
129+
let!(:warehouse_page) { create_page(name: "Warehouse") }
130+
let!(:sku_page) { create_page(name: "SKU Lookup", layout: "product_by_sku", parent: warehouse_page) }
131+
132+
it "matches when the value satisfies the regex" do
133+
matcher = described_class.new(language_root).call("warehouse/AB-1234")
134+
expect(matcher.page).to eq(sku_page)
135+
expect(matcher.params[:sku]).to eq("AB-1234")
136+
end
137+
138+
it "does not match when the value violates the regex" do
139+
expect(described_class.new(language_root).call("warehouse/invalid").page).to be_nil
140+
end
141+
end
142+
end
143+
end

0 commit comments

Comments
 (0)