Skip to content

Commit f31d8b8

Browse files
committed
Publish post "URL slug conflict validation"
1 parent f6984e6 commit f31d8b8

File tree

1 file changed

+135
-0
lines changed

1 file changed

+135
-0
lines changed
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
---
2+
title: URL slug conflict validation
3+
date: 2019-08-29 22:00:00 +0200
4+
categories: rails urls
5+
---
6+
7+
The username you choose on GitHub gives you a personal profile page under `github.com/<username>`. But a username like `about` would not be valid because `github.com/about` is the About page and therefore reserved.
8+
9+
There is a similar problem when user-defined slugs/names are used in URLs. When you create an [App Search](https://www.elastic.co/products/app-search) engine the name is used as part of a URL like `/as/engines/<engine-name>/documents`. `new` as an engine name wouldn't work because `/as/engines/new` is the URL for the page where you create a new engine.
10+
11+
In this blog post I'm going to explore solutions for this problem.
12+
13+
## The manual solution
14+
15+
A simple way to guard against conflicting URLs is to validate with a list of disallowed slugs/names.
16+
17+
## The automatic solution
18+
19+
`config/routes.rb` already defines the possible routes so here's how an automatic solution could look like:
20+
21+
```ruby
22+
# frozen_string_literal: true
23+
24+
require "bundler/inline"
25+
26+
gemfile(true) do
27+
source "https://rubygems.org"
28+
29+
gem "rails", "6.0.0"
30+
gem "sqlite3"
31+
gem "byebug"
32+
end
33+
34+
require "action_controller/railtie"
35+
require "active_record"
36+
require "minitest/autorun"
37+
38+
ActiveRecord::Base.establish_connection(adapter: "sqlite3", database: ":memory:")
39+
ActiveRecord::Base.logger = Logger.new(STDOUT)
40+
ActiveRecord::Schema.define do
41+
create_table :accounts do |t|
42+
end
43+
44+
create_table :engines do |t|
45+
t.belongs_to :account, null: false
46+
t.string :name, null: false
47+
48+
t.index [:account_id, :name], unique: true
49+
end
50+
end
51+
52+
class TestRailsApp < Rails::Application
53+
config.root = __dir__
54+
config.hosts << "example.org"
55+
config.session_store :cookie_store, key: "cookie_store_key"
56+
secrets.secret_key_base = "secret_key_base"
57+
58+
config.logger = Logger.new($stdout)
59+
Rails.logger = config.logger
60+
61+
routes.draw do
62+
root to: "home#index"
63+
64+
namespace :app_search, path: "as" do
65+
resources :engines, param: :name
66+
end
67+
end
68+
end
69+
70+
module AppSearch
71+
class EnginesController < ActionController::Base
72+
# Define actions here.
73+
end
74+
end
75+
76+
class Account < ActiveRecord::Base
77+
has_many :engines
78+
end
79+
80+
class Engine < ActiveRecord::Base
81+
belongs_to :account
82+
83+
validates :name, format: { with: /\A[a-z][a-z0-9_\-]*\z/ }
84+
validate :validate_name_results_in_recognized_path
85+
86+
private
87+
88+
def validate_name_results_in_recognized_path
89+
return if name_results_in_recognized_path?
90+
91+
errors.add(:name, :inclusion, value: name)
92+
end
93+
94+
def name_results_in_recognized_path?
95+
path = Rails.application.routes.recognize_path("/as/engines/#{name}")
96+
path[:controller] == "app_search/engines" && path[:action] == "show"
97+
rescue ActionController::RoutingError
98+
false
99+
end
100+
end
101+
102+
class EngineTest < Minitest::Test
103+
def test_success
104+
account = Account.create!
105+
engine = account.engines.create!(name: "product-listing")
106+
107+
assert_equal "product-listing", engine.name
108+
end
109+
110+
def test_uniqueness
111+
account = Account.create!
112+
account.engines.create!(name: "product-listing")
113+
114+
assert_raises('ActiveRecord::RecordNotUnique') do
115+
account.engines.create!(name: "product-listing")
116+
end
117+
end
118+
119+
def test_url_conflict
120+
account = Account.create!
121+
engine = account.engines.new(name: "new")
122+
123+
assert engine.invalid?
124+
end
125+
126+
def test_name_with_slashes
127+
account = Account.create!
128+
engine = account.engines.new(name: "no/slash")
129+
130+
assert engine.invalid?
131+
end
132+
end
133+
```
134+
135+
The downside is that `Rails.application.routes.recognize_path` is not a publicly documented method meaning it can't fully be relied on. But with a few tests, as shown in bottom of the snippet, you should be able to notice when that particular API breaks.

0 commit comments

Comments
 (0)