From 4218f517df4dc0861f488dd66e69a803afc31ac1 Mon Sep 17 00:00:00 2001 From: fumimowdan Date: Mon, 9 Oct 2023 15:03:49 +0100 Subject: [PATCH] Use pseudo random urn generator Urn represents a pseudo random Uniform Resource Name (URN) generator. Invoking the method `next` returns a unique URN with a fixed prefix and a random alphanumeric suffix. --- app/models/application.rb | 1 + app/models/urn.rb | 123 ++++++++++++++---- app/services/submit_form.rb | 2 +- config/initializers/urn.rb | 7 + .../20231009110217_add_application_indexes.rb | 2 +- spec/factories/applications.rb | 2 +- spec/models/urn_spec.rb | 6 +- spec/services/submit_form_spec.rb | 2 +- 8 files changed, 115 insertions(+), 30 deletions(-) create mode 100644 config/initializers/urn.rb diff --git a/app/models/application.rb b/app/models/application.rb index 79e54f6e..05be63be 100644 --- a/app/models/application.rb +++ b/app/models/application.rb @@ -67,4 +67,5 @@ def mark_as_qa! validates(:subject, presence: true) validates(:visa_type, presence: true) validates(:applicant, presence: true) + validates(:urn, uniqueness: true) end diff --git a/app/models/urn.rb b/app/models/urn.rb index 56330c1c..2160c548 100644 --- a/app/models/urn.rb +++ b/app/models/urn.rb @@ -1,38 +1,115 @@ # frozen_string_literal: true -# Urn represents a Uniform Resource Name (URN) generator. -# It generates a URN with a fixed prefix and a random alphanumeric suffix. +# Urn represents a pseudo random Uniform Resource Name (URN) generator. +# Invoking the method `next` returns a unique URN with a fixed prefix +# and a random alphanumeric suffix. # +# Urn.configure do |c| +# c.max_suffix = 11 +# c.seeds = { teacher: ENV['TEACHER_URN_SEED'] } +# c.urns = ->(route) { Application.where(application_route: route).pluck(:urn) } +# end # # Example: # -# Urn.generate('teacher') # => "IRPTE12345" -# Urn.generate('teacher') # => "IRPTE12345" -# Urn.generate('salaried_trainee') # => "IRPST12345" +# Urn.next('teacher') # => "IRPTE12345" +# Urn.next('teacher') # => "IRPTE12345" +# Urn.next('salaried_trainee') # => "IRPST12345" # class Urn - attr_reader :value - attr_writer :suffix + class NoUrnAvailableError < StandardError; end - def self.generate(applicant_type) - code = applicant_type_code(applicant_type) - PREFIX + code + Array.new(LENGTH) { CHARSET.sample }.join + class Config + def initialize + @default_prefix = "IRP" + @default_max_suffix = 99_999 + @default_codes = { + teacher: "TE", + salaried_trainee: "ST", + }.with_indifferent_access + @default_urns = ->(_) { [] } + end + + attr_writer :prefix, :codes, :max_suffix, :seeds, :urns, :padding_size + + def prefix + @prefix || @default_prefix + end + + def codes + (@codes || @default_codes).with_indifferent_access + end + + def max_suffix + @max_suffix || @default_max_suffix + end + + def padding_size + @padding_size || max_suffix.to_s.size + end + + def seeds + (@seeds || {}).with_indifferent_access + end + + def urns + @urns || @default_urns + end end - CHARSET = %w[0 1 2 3 4 5 6 7 8 9].freeze - PREFIX = "IRP" - LENGTH = 5 - private_constant :CHARSET, :PREFIX, :LENGTH + class << self + def configure + yield(config) + end + + def config + return @config if @config.present? + + @config = Config.new + end + + def next(route) + routes[route].next + rescue KeyError + raise(ArgumentError, "Invalid route: #{route}, must be one of #{config.codes.keys}") + end + + private + + def routes + @routes ||= Hash.new do |hash, route| + hash[route] = urn_enumerator( + config.codes.fetch(route), + config.seeds.fetch(route, Random.new_seed), + config.urns.call(route), + ) + end + end + + def urns(code, seed) + Array + .new(config.max_suffix) { formatter(code, _1) } + .drop(1) + .shuffle!(random: Random.new(seed)) + end + + def formatter(code, suffix) + [config.prefix, code, sprintf("%0#{config.padding_size}d", suffix)].join + end + + def available_urns(code, seed, used_urns) + urns(code, seed) - used_urns + end + + def urn_enumerator(code, seed, used_urns) + list = available_urns(code, seed, used_urns) + error_msg = "you have exhausted urn for code #{code} you need to increase the size of the suffix" + + Enumerator.new do |yielder| + list.each { yielder << _1 } - def self.applicant_type_code(applicant_type) - case applicant_type - when "teacher" - "TE" - when "salaried_trainee" - "ST" - else - raise(ArgumentError, "Invalid applicant type: #{applicant_type}") + raise(NoUrnAvailableError, error_msg) + end end end - private_methods :applicant_type_code end diff --git a/app/services/submit_form.rb b/app/services/submit_form.rb index 28bdbaff..a6ccb4d5 100644 --- a/app/services/submit_form.rb +++ b/app/services/submit_form.rb @@ -88,7 +88,7 @@ def create_application date_of_entry: form.date_of_entry, start_date: form.start_date, subject: SubjectStep.new(form).answer.formatted_value, - urn: Urn.generate(form.application_route), + urn: Urn.next(form.application_route), visa_type: form.visa_type, ) end diff --git a/config/initializers/urn.rb b/config/initializers/urn.rb new file mode 100644 index 00000000..f9658c35 --- /dev/null +++ b/config/initializers/urn.rb @@ -0,0 +1,7 @@ +require Rails.root.join("app/models/urn") + +Urn.configure do |c| + c.prefix = "IRP" + c.max_suffix = 99_999 + c.urns = ->(route) { Application.where(application_route: route).pluck(:urn) } +end diff --git a/db/migrate/20231009110217_add_application_indexes.rb b/db/migrate/20231009110217_add_application_indexes.rb index a1d241ad..cbaa98bd 100644 --- a/db/migrate/20231009110217_add_application_indexes.rb +++ b/db/migrate/20231009110217_add_application_indexes.rb @@ -1,6 +1,6 @@ class AddApplicationIndexes < ActiveRecord::Migration[7.0] def change - drop_table :urns + drop_table :urns # rubocop:disable Rails/ReversibleMigration add_index :applications, :urn, unique: true add_index :applications, :application_route end diff --git a/spec/factories/applications.rb b/spec/factories/applications.rb index 49d86172..712bf90b 100644 --- a/spec/factories/applications.rb +++ b/spec/factories/applications.rb @@ -31,7 +31,7 @@ visa_type { VisaStep::VALID_ANSWERS_OPTIONS.reject { _1 == "Other" }.sample } date_of_entry { Time.zone.today } start_date { 1.month.from_now.to_date } - urn { Urn.generate(application_route) } + urn { Urn.next(application_route) } factory :teacher_application do application_route { "teacher" } diff --git a/spec/models/urn_spec.rb b/spec/models/urn_spec.rb index 4df75ded..366389b0 100644 --- a/spec/models/urn_spec.rb +++ b/spec/models/urn_spec.rb @@ -3,9 +3,9 @@ require "rails_helper" RSpec.describe Urn do - subject(:urn) { described_class.generate(applicant_type) } + subject(:urn) { described_class.next(applicant_type) } - describe ".generate" do + describe ".next" do context 'when applicant type is "teacher"' do let(:applicant_type) { "teacher" } @@ -32,7 +32,7 @@ let(:applicant_type) { "invalid_type" } it "raises an ArgumentError" do - expect { urn }.to raise_error(ArgumentError, "Invalid applicant type: invalid_type") + expect { urn }.to raise_error(ArgumentError, 'Invalid route: invalid_type, must be one of ["teacher", "salaried_trainee"]') end end end diff --git a/spec/services/submit_form_spec.rb b/spec/services/submit_form_spec.rb index 8606e43f..c7faac40 100644 --- a/spec/services/submit_form_spec.rb +++ b/spec/services/submit_form_spec.rb @@ -136,7 +136,7 @@ context "applicant email" do before do - allow(Urn).to receive(:generate).and_return(urn) + allow(Urn).to receive(:next).and_return(urn) end let(:urn) { "SOMEURN" }