Skip to content

Commit

Permalink
Add uniqueness constraint on applications.urn
Browse files Browse the repository at this point in the history
Urn.call returns a unique urn for the application route.
URN suffix is generated from a UUID and guaranties uniqueness and randomness
  • Loading branch information
fumimowdan committed Sep 22, 2023
1 parent 5381713 commit 0403ef7
Show file tree
Hide file tree
Showing 10 changed files with 131 additions and 44 deletions.
4 changes: 4 additions & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,10 @@ Rails/HasManyOrHasOneDependent:
Exclude:
- 'app/models/application.rb'

Rails/UniqueValidationWithoutIndex:
Exclude:
- 'app/models/application.rb'

Performance/MethodObjectAsBlock:
Exclude:
- 'app/models/summary.rb'
Expand Down
1 change: 1 addition & 0 deletions app/models/application.rb
Original file line number Diff line number Diff line change
Expand Up @@ -67,4 +67,5 @@ def mark_as_qa!
validates(:subject, presence: true)
validates(:visa_type, presence: true)
validates(:applicant, presence: true)
validates(:urn, presence: true, uniqueness: true)
end
116 changes: 90 additions & 26 deletions app/models/urn.rb
Original file line number Diff line number Diff line change
@@ -1,38 +1,102 @@
# 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.
# It generates a URN with a fixed prefix and suffix created from UUID
# converted to based 8 to only contain digits.
#
# Suffix Algo base 8:
# generate a UUID
# remove any '-' chars
# convert to a binary string
# split in groups of 3 bits, each group is a one char in base 8; from "000" to "111"
# convert each group to a char in base 10; from "0" to "7" => CHARSET
# map each char to the CHARSET
#
# Example:
# original idea: https://www.fastruby.io/blog/ruby/uuid/friendlier-uuid-urls-in-ruby.html
#
# Urn.generate('teacher') # => "IRPTE12345"
# Urn.generate('teacher') # => "IRPTE12345"
# Urn.generate('salaried_trainee') # => "IRPST12345"
# Example:
#
# Urn.call("teacher", base: 8) => "IRPTE1534743322003655447026533317435036675213160"
# Urn.call("teacher", base: 16) => "IRPTEA8B252DB88114FCB991BF6763262CD04"
# Urn.call("teacher", base: 32) => "IRPTE13MVE7PCVHKP5S5BQDC47KK2IQ"
# Urn.call("teacher", base: 64) => "IRPTEBJ_8UdgBaxHZo9QRxawrn

class Urn
attr_reader :value
attr_writer :suffix
# rubocop:disable Layout/MultilineArrayLineBreaks
CHARSET = %w[
0 1 2 3 4 5 6 7 8 9
A B C D E F G H I J K L M N O P Q R S T U V W X Y Z
a b c d e f g h i j k l m n o p q r s t u v w x y z
- _
].freeze
# rubocop:enable Layout/MultilineArrayLineBreaks

def self.generate(applicant_type)
code = applicant_type_code(applicant_type)
PREFIX + code + Array.new(LENGTH) { CHARSET.sample }.join
PREFIX = "IRP"
TEACHER_ROUTE = "teacher"
TRAINEE_ROUTE = "salaried_trainee"
ROUTE_MAPPING = {
TEACHER_ROUTE => "TE",
TRAINEE_ROUTE => "ST",

}.freeze
ACCEPTED_BASES = [8, 16, 32, 64].freeze

def self.call(...)
service = new(...)
service.generate_suffix
service.urn
end

CHARSET = %w[0 1 2 3 4 5 6 7 8 9].freeze
PREFIX = "IRP"
LENGTH = 5
private_constant :CHARSET, :PREFIX, :LENGTH

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}")
end
end
private_methods :applicant_type_code
def initialize(route, prefix: PREFIX, route_mapping: ROUTE_MAPPING, base: ACCEPTED_BASES.last)
@base = base
raise(ArgumentError, "base must be one of #{ACCEPTED_BASES}") unless ACCEPTED_BASES.include?(base)

@prefix = prefix
@code = route_mapping.fetch(route)
@bit_group_size = (base - 1).to_s(2).size
end

def urn
[prefix, code, suffix].join
end

def generate_suffix
@suffix = (
method(:binary_coded_decimal) >>
method(:add_padding) >>
method(:to_base) >>
method(:charset_encode)
).call(compact_uuid)
end

private

attr_reader :prefix, :code, :suffix, :base, :bit_group_size

def compact_uuid
SecureRandom.uuid.tr("-", "")
end

def binary_coded_decimal(uuid)
uuid.chars.map { |c| c.hex.to_s(2).rjust(4, "0") }.join
end

def add_padding(str)
char_offset = str.size % bit_group_size
return str if char_offset.zero?

padding = "0" * (bit_group_size - char_offset)
padding + str
end

def to_base(str)
regex = %r(.{#{bit_group_size}})
str
.scan(regex)
.map { |x| x.to_i(2) }
end

def charset_encode(str)
str.map { |x| CHARSET.fetch(x) }
end
end
16 changes: 11 additions & 5 deletions app/services/submit_form.rb
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,9 @@ def initialize(form, ip_address)
@form = form
@ip_address = ip_address
@success = false
@counter = 0
end
attr_reader :form, :ip_address, :application
attr_reader :form, :ip_address, :application, :counter

delegate :errors, to: :form

Expand All @@ -30,6 +31,9 @@ def submit_form!
create_application_records
send_applicant_email
@success = true
rescue StandardError => e
Sentry.capture_exception(e)
errors.add(:base, :technical_error, message: "We are unable to submit your application at the moment. Please try again later")
end

private
Expand Down Expand Up @@ -75,20 +79,20 @@ def create_applicant
city: form.city,
postcode: form.postcode,
},
school: @school,
school: school,
)
end

def create_application
@application = Application.create!(
applicant: @applicant,
applicant: applicant,
application_date: Date.current.to_s,
application_route: form.application_route,
application_progress: ApplicationProgress.new,
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.call(form.application_route),
visa_type: form.visa_type,
)
end
Expand All @@ -100,10 +104,12 @@ def delete_form
def send_applicant_email
GovukNotifyMailer
.with(
email: @applicant.email_address,
email: applicant.email_address,
urn: application.urn,
)
.application_submission
.deliver_later
end

attr_reader :school, :applicant
end
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
class AddUniquenessToApplicationUrn < ActiveRecord::Migration[7.0]
def up
drop_view :duplicate_applications
add_index(:applications, :urn)
create_view :duplicate_applications, version: 3
end

def down; end
end
3 changes: 2 additions & 1 deletion db/schema.rb

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion spec/factories/applications.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.call(application_route) }

factory :teacher_application do
application_route { "teacher" }
Expand Down
2 changes: 2 additions & 0 deletions spec/models/application_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,8 @@
it { expect(application).to validate_presence_of(:subject) }
it { expect(application).to validate_presence_of(:visa_type) }
it { expect(application).to validate_presence_of(:applicant) }
it { expect(application).to validate_presence_of(:urn) }
it { expect(application).to validate_uniqueness_of(:urn) }
end
end

Expand Down
20 changes: 10 additions & 10 deletions spec/models/urn_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -3,36 +3,36 @@
require "rails_helper"

RSpec.describe Urn do
subject(:urn) { described_class.generate(applicant_type) }
describe ".call" do
subject(:urn) { described_class.call(application_route, base: 8) }

describe ".generate" do
context 'when applicant type is "teacher"' do
let(:applicant_type) { "teacher" }
let(:application_route) { "teacher" }

it "generates a URN with the correct prefix and suffix" do
expect(urn).to match(/^IRPTE[0-9]{5}$/)
expect(urn).to match(/^IRPTE[0-7]{43}$/)
end

it "generates a Urn with a suffix of only characters in the CHARSET" do
charset = %w[0 1 2 3 4 5 6 7 8 9]
charset = %w[0 1 2 3 4 5 6 7]

expect(urn[5..9].chars).to all(be_in(charset))
expect(urn[5..47].chars).to all(be_in(charset))
end
end

context 'when applicant type is "salaried_trainee"' do
let(:applicant_type) { "salaried_trainee" }
let(:application_route) { "salaried_trainee" }

it "generates a URN with the correct prefix and suffix" do
expect(urn).to match(/^IRPST[0-9]{5}$/)
expect(urn).to match(/^IRPST[0-7]{43}$/)
end
end

context "when an invalid applicant type is provided" do
let(:applicant_type) { "invalid_type" }
let(:application_route) { "invalid_type" }

it "raises an ArgumentError" do
expect { urn }.to raise_error(ArgumentError, "Invalid applicant type: invalid_type")
expect { urn }.to raise_error(KeyError, 'key not found: "invalid_type"')
end
end
end
Expand Down
2 changes: 1 addition & 1 deletion spec/services/submit_form_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,7 @@

context "applicant email" do
before do
allow(Urn).to receive(:generate).and_return(urn)
allow(Urn).to receive(:call).and_return(urn)
end

let(:urn) { "SOMEURN" }
Expand Down

0 comments on commit 0403ef7

Please sign in to comment.