· 5 years ago · Sep 15, 2020, 12:46 PM
1app/controllers/foo/bar/company_redirects_controller.rb (контроллер для создания редиректов на active_scaffold)
2# frozen_string_literal: true
3module Foo
4 module Bar
5 module Admin
6 class CompanyRedirectsController < Foo::Bar::Admin::BaseController
7 COLUMNS = %i(virtual_company company_id code from_url to_url).freeze
8 PER_PAGE = 50
9
10 active_scaffold :company_redirect do |config|
11 config.columns = COLUMNS
12 COLUMNS.each do |column|
13 config.columns[column].sort = false
14 config.columns[column].label = I18n.t(
15 "controllers.apress.companies.admin.company_redirects.columns_labels.#{column}"
16 )
17 end
18
19 # define actions for company redirects
20 config.actions = [:list, :create, :update, :delete]
21
22 # define headers for list and create forms
23 config.list.label = I18n.t('controllers.apress.companies.admin.company_redirects.headers.list')
24 config.create.label = I18n.t('controllers.apress.companies.admin.company_redirects.headers.create')
25
26 # define fields to render on new/edit forms and list
27 config.list.columns.exclude :company_id
28 config.create.columns.exclude :virtual_company
29
30 # define input for company_id field on new/edit forms
31 config.columns[:company_id].form_ui = :input
32 config.columns[:company_id].number = false
33 config.columns[:company_id].options = {}
34
35 # define select with available values for code field on new/edit forms
36 config.columns[:code].form_ui = :select
37 config.columns[:code].options = {options: ::CompanyRedirect::COMPANY_REDIRECT_CODES}
38
39 # define sorting and redirects per page on list
40 config.list.sorting = {updated_at: :desc}
41 config.list.per_page = PER_PAGE
42
43 # define associations to preload for virtual_company column
44 config.columns[:virtual_company].includes = [:company]
45 end
46
47 def conditions_for_collection
48 conditions = []
49 placeholders = {}
50
51 if filtered_by?(:company_id)
52 conditions << '(company_redirects.company_id = :company_id)'
53 placeholders[:company_id] = get_filter(:company_id).to_i
54 end
55
56 [conditions.join(' AND '), placeholders]
57 end
58 end
59 end
60 end
61end
62
63
64app/helpers/foo/bar/admin/company_redirects_helper.rb (колонка для active_scaffold)
65# frozen_string_literal: true
66module Foo
67 module Bar
68 module Admin
69 module CompanyRedirectsHelper
70 def company_redirect_virtual_company_column(record, column)
71 company = record.company
72
73 "#{company.name} (#{company.id})"
74 end
75 end
76 end
77 end
78end
79
80
81app/controllers/concerns/foo/bar/redirect.rb (логика редиректа)
82module Foo
83 module Bar
84 module Redirect
85 private
86
87 def company_site_redirect
88 from_url = parse_from_url
89 data_hash = CompanyRedirect.get(company.id, parse_from_url)
90 return if data_hash.blank?
91
92 redirect_to(redirect_path(from_url, data_hash['to_url']), status: data_hash['code'])
93 end
94
95 def parse_from_url
96 request.path
97 end
98
99 def redirect_path(from_url, to_url)
100 request.fullpath.sub(from_url, to_url)
101 end
102 end
103 end
104end
105
106app/controllers/foo/bar/base_controller.rb (подключение логики редиректов в базовый класс)
107
108include Foo::Bar::Redirect
109before_filter :company_site_redirect
110
111
112
113ассоциация
114has_many :company_redirects, dependent: :destroy, class_name: '::CompanyRedirect', inverse_of: :company
115
116
117app/models/foo/bar/company_redirect.rb (модель редиректа)
118# frozen_string_literal: true
119module Foo
120 module Bar
121 class CompanyRedirect < ActiveRecord::Base
122 include Apress::Companies::Redirect::RedisStorage
123
124 REDIRECT_URL_MAX_LENGTH = 1024
125 COMPANY_REDIRECT_CODES = [301, 302, 307, 308].freeze
126
127 belongs_to :company, class_name: '::Company', inverse_of: :company_redirects
128
129 before_validation :normalize_redirect_urls, if: :need_normalize_redirect_urls?
130
131 validates :company_id, presence: true
132 validates :code, presence: true
133 validates :from_url, presence: true, length: {maximum: REDIRECT_URL_MAX_LENGTH}
134 validates :to_url, presence: true, length: {maximum: REDIRECT_URL_MAX_LENGTH}
135
136 validate :check_cyclic_redirect
137
138 def to_label
139 "Редирект с '#{from_url}' на '#{to_url}'"
140 end
141
142 private
143
144 def create_or_update
145 super
146 rescue ActiveRecord::RecordNotUnique => e
147 raise e unless e.message.include?('uniq_index_company_redirects_on_company_id_and_from_url')
148
149 errors.add(:base, :not_unique)
150
151 false
152 end
153
154 def normalize_redirect_urls
155 self.from_url = from_url.to_s.strip.downcase
156 self.to_url = to_url.to_s.strip.downcase
157 end
158
159 def need_normalize_redirect_urls?
160 from_url_changed? || to_url_changed?
161 end
162
163 def check_cyclic_redirect
164 cyclic = from_url == to_url
165 cyclic ||= self.class.connection.select_all(<<-SQL).to_a.present?
166 SELECT 1 FROM company_redirects WHERE company_id = #{company_id.to_i} AND to_url = '#{from_url}'
167 UNION ALL
168 SELECT 1 FROM company_redirects WHERE company_id = #{company_id.to_i} AND from_url = '#{to_url}'
169 SQL
170
171 errors.add(:base, :cyclic_redirect) if cyclic
172 end
173
174 ActiveSupport.run_load_hooks(:'apress/companies/company_redirect', self)
175 end
176 end
177end
178
179
180app/models/concerns/foo/bar/redirect/redis_storage.rb (класс для записи/удаления редиректов из редиса)
181# frozen_string_literal: true
182require 'class_logger'
183module Foo
184 module Bar
185 module Redirect
186 module RedisStorage
187 extend ActiveSupport::Concern
188 include ClassLogger
189
190 MAX_BATCH_SIZE = 1000
191 KEY_NAMESPACE = 'company_site_redirects'.freeze
192 KEYS_SET = 'company_site_redirects_keys'.freeze
193
194 module ClassMethods
195 # Public: перезаливает данные из таблицы в редис
196 #
197 # Example:
198 #
199 # CompanyRedirect.reload_redis_data
200 # # => 2
201 #
202 # Returns Fixnum, число добавленных записей
203 def reload_redis_data
204 logger.info('Clear old redirects of CompanyRedirect from redis')
205 clear_redis
206 upload_redis
207 end
208
209 def get(company_id, from_url)
210 ::Redis.current.hgetall(redis_key(company_id, from_url))
211 end
212
213 def set(company_id, from_url, *args)
214 key = redis_key(company_id, from_url)
215 ::Redis.current.hmset(key, args)
216 ::Redis.current.sadd(KEYS_SET, key)
217 end
218
219 private
220
221 def redis_key(company_id, from_url)
222 "#{KEY_NAMESPACE}:#{company_id}:#{from_url}"
223 end
224
225 # Public: Записывает все записи из модели CompanyRedirect в редис
226 #
227 # Example:
228 #
229 # CompanyRedirect.upload_redis
230 # # => 2
231 #
232 # Returns Fixnum
233 def upload_redis
234 logger.info("Cloning CompanyRedirect to redis")
235
236 processed = 0
237
238 CompanyRedirect.find_in_batches do |batch|
239 ::Redis.current.pipelined { batch.each(&:set) }
240
241 processed += batch.size
242 logger.info("#{processed} rows processed")
243 end
244
245 logger.info("Complete.")
246
247 processed
248 end
249
250 # Public: Удаляет все значения в редисе, в пространстве имен модели редиректа
251 #
252 # Example:
253 #
254 # CompanyRedirect.clear_redis
255 # # => 2
256 #
257 # Returns Fixnum
258 def clear_redis
259 cursor = 0
260
261 loop do
262 results = ::Redis.current.sscan(KEYS_SET, cursor, count: MAX_BATCH_SIZE)
263 keys = results[1]
264 cursor = results[0].to_i
265
266 ::Redis.current.del(keys) if keys.present?
267
268 break if cursor.zero?
269 end
270
271 ::Redis.current.del(KEYS_SET)
272 end
273 end
274
275 def set
276 self.class.set(company_id, from_url, 'code', code, 'to_url', to_url)
277 end
278 end
279 end
280 end
281end
282
283
284локали
285ru:
286 activerecord:
287 errors:
288 models:
289 company_redirect:
290 attributes:
291 base:
292 cyclic_redirect: 'обнаружен циклический редирект'
293 not_unique: 'у компании уже есть редирект с такого URL'
294
295 controllers:
296 foo:
297 bar:
298 admin:
299 company_redirects:
300 headers:
301 list: 'Редиректы компаний'
302 create: 'Создание редиректа'
303 columns_labels:
304 virtual_company: 'Название компании (ID)'
305 code: 'Код ответа'
306 from_url: 'Старый URL'
307 to_url: 'Новый URL'
308 company_id: 'ID Компании'
309
310
311роуты
312asc_resources :company_redirects
313
314миграция
315class CreateCompanyRedirects < ActiveRecord::Migration
316 def up
317 execute <<-SQL
318 CREATE TYPE company_redirect_codes AS ENUM ('301', '302', '307', '308');
319 SQL
320
321 create_table :company_redirects do |t|
322 t.integer :company_id, null: false
323 t.column :code, :company_redirect_codes, null: false
324 t.string :from_url, limit: 1024, null: false
325 t.string :to_url, limit: 1024, null: false
326
327 t.timestamps
328 end
329
330 execute <<-SQL
331 ALTER TABLE company_redirects ADD CONSTRAINT company_redirects_company_id_fk
332 FOREIGN KEY (company_id) REFERENCES companies(id) ON DELETE CASCADE;
333 SQL
334
335 add_index :company_redirects,
336 [:company_id, :from_url],
337 unique: true,
338 name: 'uniq_index_company_redirects_on_company_id_and_from_url'
339
340 add_index :company_redirects, [:company_id, :to_url]
341 end
342
343 def down
344 drop_table :company_redirects
345
346 execute <<-SQL
347 DROP TYPE company_redirect_codes;
348 SQL
349 end
350end
351
352
353регулярный таск
354desc 'Перазаливка редиректов'
355 task reload_redirects_into_redis: :environment do
356 CompanyRedirect.reload_redis_data
357 end
358end
359
360
361
362фабрика для тестов
363# frozen_string_literal: true
364
365FactoryGirl.define do
366 factory :company_redirect do
367 association :company
368 code 301
369 sequence(:from_url) { |n| "/from/url#{n}" }
370 sequence(:to_url) { |n| "/to/url#{n}" }
371 end
372end
373
374
375ТЕСТЫ
376# frozen_string_literal: true
377require 'spec_helper'
378
379describe Foo::Bar::Redirect do
380 class TestRedirectController < ApplicationController; end
381
382 describe TestRedirectController, type: :controller do
383 controller do
384 include Apress::Companies::Redirect
385
386 before_filter :company_site_redirect
387
388 def show
389 render json: {status: :ok}
390 end
391
392 private
393
394 def company
395 Company.find(params.require(:id))
396 end
397 end
398
399 let!(:company_redirect) { create :company_redirect }
400 let(:region) { build :region }
401
402 before do
403 allow(controller).to receive(:current_region).and_return(region)
404
405 # в тестовом окружении как-то странно работает request.path и request.fullpath
406 # возвращают не только путь, но и протокол, и домен
407 # пришлось их застабить
408 allow(controller.request).to receive(:fullpath).and_return(from_url)
409 allow(controller.request).to receive(:path).and_return(from_url)
410 end
411
412 context 'when path has not "firms/#id"' do
413 let(:from_url) { company_redirect.from_url }
414 let(:to_url) { company_redirect.to_url }
415
416 context 'when company has redirect' do
417 before do
418 CompanyRedirect.reload_redis_data
419 get :show, id: company_redirect.company_id
420 end
421
422 it do
423 expect(response.status).to eq 301
424 expect(response).to redirect_to(to_url)
425 end
426 end
427
428 context 'when company has not redirect in redis' do
429 before { get :show, id: company_redirect.company_id }
430
431 it do
432 expect(response.status).to eq 200
433 expect(response).to_not redirect_to(to_url)
434 end
435 end
436 end
437
438 context 'when path has "firms/#id"' do
439 let(:from_url) { "/firms/#{company_redirect.company_id}#{company_redirect.from_url}" }
440 let(:to_url) { "/firms/#{company_redirect.company_id}#{company_redirect.to_url}" }
441
442 context 'when company has redirect' do
443 before do
444 CompanyRedirect.reload_redis_data
445 get :show, id: company_redirect.company_id
446 end
447
448 it do
449 expect(response.status).to eq 301
450 expect(response).to redirect_to(to_url)
451 end
452 end
453
454 context 'when company has not redirect in redis' do
455 before { get :show, id: company_redirect.company_id }
456
457 it do
458 expect(response.status).to eq 200
459 expect(response).to_not redirect_to(to_url)
460 end
461 end
462 end
463 end
464end
465
466
467# frozen_string_literal: true
468require 'spec_helper'
469
470RSpec.describe Foo::Bar::Admin::CompanyRedirectsHelper, type: :helper do
471 describe '#company_redirect_virtual_company_column' do
472 let(:company) { create :company }
473 let(:column) { double('ActiveScaffold::DataStructures::Column') }
474 let(:record) { create :company_redirect, company: company }
475
476 let(:expected_data) { "#{company.name} (#{company.id})" }
477
478 it { expect(helper.company_redirect_virtual_company_column(record, column)).to eq expected_data }
479 end
480end
481
482# frozen_string_literal: true
483require 'spec_helper'
484
485describe CompanyRedirect do
486 describe 'validations' do
487 let(:company_redirect) { build :company_redirect }
488
489 context 'when valid attributes' do
490 it do
491 expect(company_redirect.valid?).to eq true
492 expect(company_redirect.save).to eq true
493 expect(company_redirect.errors.messages).to eq Hash.new
494 end
495 end
496
497 context 'when invalid company_id' do
498 it do
499 company_redirect.company_id = nil
500
501 expect(company_redirect.valid?).to eq false
502 expect(company_redirect.save).to eq false
503 expect(company_redirect.errors.messages[:company_id]).to eq ['не может быть пустым']
504 end
505 end
506
507 context 'when invalid code' do
508 it do
509 company_redirect.code = nil
510
511 expect(company_redirect.valid?).to eq false
512 expect(company_redirect.save).to eq false
513 expect(company_redirect.errors.messages[:code]).to eq ['не может быть пустым']
514 end
515 end
516
517 context 'when invalid from_url' do
518 context 'when empty from_url' do
519 it do
520 company_redirect.from_url = nil
521
522 expect(company_redirect.valid?).to eq false
523 expect(company_redirect.save).to eq false
524 expect(company_redirect.errors.messages[:from_url]).to eq ['не может быть пустым']
525 end
526 end
527
528 context 'when too long from_url' do
529 let(:max_length) { described_class::REDIRECT_URL_MAX_LENGTH }
530
531 it do
532 company_redirect.from_url = 'a' * (max_length + 1)
533
534 expect(company_redirect.valid?).to eq false
535 expect(company_redirect.save).to eq false
536 expect(company_redirect.errors.messages[:from_url]).
537 to eq ["слишком большой длины (не может быть больше чем #{max_length} символа)"]
538 end
539 end
540 end
541
542 context 'when invalid to_url' do
543 context 'when empty to_url' do
544 it do
545 company_redirect.to_url = nil
546
547 expect(company_redirect.valid?).to eq false
548 expect(company_redirect.save).to eq false
549 expect(company_redirect.errors.messages[:to_url]).to eq ['не может быть пустым']
550 end
551 end
552
553 context 'when too long to_url' do
554 let(:max_length) { described_class::REDIRECT_URL_MAX_LENGTH }
555
556 it do
557 company_redirect.to_url = 'a' * (max_length + 1)
558
559 expect(company_redirect.valid?).to eq false
560 expect(company_redirect.save).to eq false
561 expect(company_redirect.errors.messages[:to_url]).
562 to eq ["слишком большой длины (не может быть больше чем #{max_length} символа)"]
563 end
564 end
565 end
566
567 context 'when cyclic redirect' do
568 context 'when from_url exists in to_url' do
569 let!(:existed_redirect) do
570 create :company_redirect, from_url: company_redirect.to_url, company: company_redirect.company
571 end
572
573 it do
574 expect(company_redirect.valid?).to eq false
575 expect(company_redirect.save).to eq false
576 expect(company_redirect.errors.messages[:base]).
577 to eq [I18n.t('activerecord.errors.models.company_redirect.attributes.base.cyclic_redirect')]
578 end
579 end
580
581 context 'when to_url exists in from_url' do
582 let!(:existed_redirect) do
583 create :company_redirect, to_url: company_redirect.from_url, company: company_redirect.company
584 end
585
586 it do
587 expect(company_redirect.valid?).to eq false
588 expect(company_redirect.save).to eq false
589 expect(company_redirect.errors.messages[:base]).
590 to eq [I18n.t('activerecord.errors.models.company_redirect.attributes.base.cyclic_redirect')]
591 end
592 end
593
594 context 'when from_url is equal to to_url' do
595 let(:company_redirect) { build :company_redirect, from_url: '/url', to_url: '/url' }
596
597 it do
598 expect(company_redirect.valid?).to eq false
599 expect(company_redirect.save).to eq false
600 expect(company_redirect.errors.messages[:base]).
601 to eq [I18n.t('activerecord.errors.models.company_redirect.attributes.base.cyclic_redirect')]
602 end
603 end
604 end
605 end
606
607 describe '#create_or_update' do
608 let(:company_redirect) { build :company_redirect }
609
610 context 'when not unique from_url' do
611 context 'when redirect present for another company' do
612 let(:another_company) { create :company }
613 let!(:existed_redirect) do
614 create :company_redirect, from_url: company_redirect.from_url, company: another_company
615 end
616
617 it do
618 expect(company_redirect.save).to eq true
619 expect(company_redirect.errors.messages).to eq Hash.new
620 end
621 end
622
623 context 'when redirect present for company' do
624 let!(:existed_redirect) do
625 create :company_redirect, from_url: company_redirect.from_url, company: company_redirect.company
626 end
627
628 it do
629 expect(company_redirect.save).to eq false
630 expect(company_redirect.errors.messages[:base]).
631 to eq [I18n.t('activerecord.errors.models.company_redirect.attributes.base.not_unique')]
632 end
633 end
634 end
635
636 context 'when not unique to_url' do
637 context 'when redirect present for another company' do
638 let(:another_company) { create :company }
639 let!(:existed_redirect) do
640 create :company_redirect, to_url: company_redirect.to_url, company: another_company
641 end
642
643 it do
644 expect(company_redirect.save).to eq true
645 expect(company_redirect.errors.messages).to eq Hash.new
646 end
647 end
648
649 context 'when redirect present for company' do
650 let!(:existed_redirect) do
651 create :company_redirect, to_url: company_redirect.to_url, company: company_redirect.company
652 end
653
654 it do
655 expect(company_redirect.save).to eq true
656 expect(company_redirect.errors.messages).to eq Hash.new
657 end
658 end
659 end
660 end
661
662 describe '#save' do
663 context 'when need normalization for redirect urls' do
664 context 'when redirect urls without whitespaces' do
665 let(:from_url) { '/from' }
666 let(:to_url) { '/to' }
667 let(:company_redirect) { build :company_redirect, from_url: from_url, to_url: to_url }
668
669 it do
670 expect(company_redirect).to receive(:need_normalize_redirect_urls?).and_call_original
671 expect(company_redirect).to receive(:normalize_redirect_urls).and_call_original
672
673 expect(company_redirect.save).to eq true
674 expect(company_redirect.errors.messages).to eq Hash.new
675
676 company_redirect.reload
677
678 expect(company_redirect.from_url).to eq from_url
679 expect(company_redirect.to_url).to eq to_url
680 end
681 end
682
683 context 'when redirect urls with whitespaces' do
684 let(:from_url) { ' /from ' }
685 let(:to_url) { ' /to ' }
686 let(:company_redirect) { build :company_redirect, from_url: from_url, to_url: to_url }
687
688 it do
689 expect(company_redirect).to receive(:need_normalize_redirect_urls?).and_call_original
690 expect(company_redirect).to receive(:normalize_redirect_urls).and_call_original
691
692 expect(company_redirect.save).to eq true
693 expect(company_redirect.errors.messages).to eq Hash.new
694
695 company_redirect.reload
696
697 expect(company_redirect.from_url).to eq from_url.strip
698 expect(company_redirect.to_url).to eq to_url.strip
699 end
700 end
701
702 context 'when redirect urls with upcase letters' do
703 let(:from_url) { ' /FROM ' }
704 let(:to_url) { ' /TO ' }
705 let(:company_redirect) { build :company_redirect, from_url: from_url, to_url: to_url }
706
707 it do
708 expect(company_redirect).to receive(:need_normalize_redirect_urls?).and_call_original
709 expect(company_redirect).to receive(:normalize_redirect_urls).and_call_original
710
711 expect(company_redirect.save).to eq true
712 expect(company_redirect.errors.messages).to eq Hash.new
713
714 company_redirect.reload
715
716 expect(company_redirect.from_url).to eq from_url.strip.downcase
717 expect(company_redirect.to_url).to eq to_url.strip.downcase
718 end
719 end
720 end
721 end
722
723 describe '#to_label' do
724 let(:redirect) { create :company_redirect, from_url: '/from', to_url: '/to' }
725
726 it { expect(redirect.to_label).to eq "Редирект с '#{redirect.from_url}' на '#{redirect.to_url}'" }
727 end
728end
729
730
731# frozen_string_literal: true
732require 'spec_helper'
733
734describe Foo::Bar::Redirect::RedisStorage do
735 let!(:company_redirect) { create :company_redirect }
736
737 let(:cached_data) { CompanyRedirect.get(company_redirect.company_id, company_redirect.from_url) }
738 let(:expected_data) { {'code' => company_redirect.code.to_s, 'to_url' => company_redirect.to_url} }
739
740 describe 'reload_redis_data' do
741 context 'when storage was empty' do
742 it do
743 CompanyRedirect.reload_redis_data
744
745 expect(cached_data).to eq expected_data
746 expect(Redis.current.smembers('company_site_redirects_keys')).to_not be_empty
747 end
748 end
749
750 context 'when storage has another_dates' do
751 let(:tmp_redirect) { build :company_redirect, from_url: '/from_drop', to_url: '/to_drop' }
752 it do
753 tmp_redirect.set
754 CompanyRedirect.reload_redis_data
755
756 expect(cached_data).to eq expected_data
757 expect(CompanyRedirect.get(tmp_redirect.company_id, tmp_redirect.from_url)).to be_empty
758 expect(Redis.current.smembers('company_site_redirects_keys').count).to eq(1)
759 end
760 end
761 end
762end
763