diff --git a/CHANGELOG.md b/CHANGELOG.md index 9fa8d8f..ef034b3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 4.1.0 + +* Add `seed` parameter to `GovukSchemas::RandomExample` to make the random behaviour deterministic. Given the same seed, the same randomised outputs will be returned ([#56](https://github.com/alphagov/govuk_schemas/pull/56)). + # 4.0.1 * Bump the required Ruby version to >= 2.6.x. diff --git a/govuk_schemas.gemspec b/govuk_schemas.gemspec index 06f696a..f774c41 100644 --- a/govuk_schemas.gemspec +++ b/govuk_schemas.gemspec @@ -1,5 +1,3 @@ -# coding: utf-8 - lib = File.expand_path("lib", __dir__) $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib) require "govuk_schemas/version" diff --git a/lib/govuk_schemas.rb b/lib/govuk_schemas.rb index 967bb34..c13b91a 100644 --- a/lib/govuk_schemas.rb +++ b/lib/govuk_schemas.rb @@ -1,6 +1,5 @@ require "govuk_schemas/version" require "govuk_schemas/schema" -require "govuk_schemas/utils" require "govuk_schemas/random_example" require "govuk_schemas/document_types" require "govuk_schemas/example" diff --git a/lib/govuk_schemas/random.rb b/lib/govuk_schemas/random.rb deleted file mode 100644 index 8ae8db2..0000000 --- a/lib/govuk_schemas/random.rb +++ /dev/null @@ -1,101 +0,0 @@ -require "securerandom" - -module GovukSchemas - # @private - module Random - class << self - WORDS = %w[Lorem ipsum dolor sit amet consectetur adipiscing elit. Ut suscipit at mauris non bibendum. Ut ac massa est. Aenean tempor imperdiet leo vel interdum. Nam sagittis cursus sem ultricies scelerisque. Quisque porttitor risus vel risus finibus eu sollicitudin nisl aliquet. Sed sed lectus ac dolor molestie interdum. Nam molestie pellentesque purus ac vestibulum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Suspendisse non tempor eros. Mauris eu orci hendrerit volutpat lorem in tristique libero. Duis a nibh nibh.].freeze - - def string_for_type(type) - if type == "date-time" - time - elsif type == "uri" - uri - else - raise "Unknown attribute type `#{type}`" - end - end - - def time - seconds_ago = rand(-5000000..4999999) - (Time.now + seconds_ago).iso8601 - end - - # TODO: make this more random with query string, optional anchor. - def uri - "http://example.com#{base_path}#{anchor}" - end - - def base_path - "/" + rand(1..5).times.map { SecureRandom.uuid }.join("/") - end - - def govuk_subdomain_url - subdomain = rand(2..4).times.map { - ("a".."z").to_a.sample(rand(3..8)).join - }.join(".") - "https://#{subdomain}.gov.uk#{base_path}" - end - - def string(minimum_chars = nil, maximum_chars = nil) - minimum_chars ||= 0 - maximum_chars ||= 100 - WORDS.sample(rand(minimum_chars..maximum_chars)).join(" ") - end - - def bool - rand(2) == 1 - end - - def anchor - "##{SecureRandom.hex}" - end - - def random_identifier(separator:) - Utils.parameterize(WORDS.sample(rand(1..10)).join("-")).gsub("-", separator) - end - - def string_for_regex(pattern) - case pattern.to_s - when "^(placeholder|placeholder_.+)$" - ["placeholder", "placeholder_#{WORDS.sample}"].sample - when "^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" - SecureRandom.uuid - when "^/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?$" - base_path - when "^[1-9][0-9]{3}[-/](0[1-9]|1[0-2])[-/](0[1-9]|[12][0-9]|3[0-1])$" - Date.today.iso8601 - when "^[1-9][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[0-1])$" - Date.today.iso8601 - when "^#.+$" - anchor - when "[a-z-]" - random_identifier(separator: "-") - when "^[a-z_]+$" - random_identifier(separator: "_") - when "^/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?(\\?([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?(#([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?$" - base_path - when "^https://([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[A-Za-z0-9])?\\.)+campaign\\.gov\\.uk(/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?(\\?([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?(#([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?)?$" - govuk_subdomain_url - when "^https://([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[A-Za-z0-9])?\\.)*gov\\.uk(/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?(\\?([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?(#([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?)?$" - govuk_subdomain_url - when '[a-z0-9\-_]' - "#{SecureRandom.hex}-#{SecureRandom.hex}" - else - raise <<-DOC - Don't know how to generate random string for pattern #{pattern.inspect} - - This propably means you've introduced a new regex in govuk-content-schemas. - Because it's very hard to generate a valid string from a regex alone, - we have to specify a method to generate random data for each regex in - the schemas. - - To fix this: - - - Add your regex to `lib/govuk_schemas/random.rb` - DOC - end - end - end - end -end diff --git a/lib/govuk_schemas/random_content_generator.rb b/lib/govuk_schemas/random_content_generator.rb new file mode 100644 index 0000000..c2d90a9 --- /dev/null +++ b/lib/govuk_schemas/random_content_generator.rb @@ -0,0 +1,126 @@ +module GovukSchemas + # @private + class RandomContentGenerator + WORDS = %w[Lorem ipsum dolor sit amet consectetur adipiscing elit. Ut suscipit at mauris non bibendum. Ut ac massa est. Aenean tempor imperdiet leo vel interdum. Nam sagittis cursus sem ultricies scelerisque. Quisque porttitor risus vel risus finibus eu sollicitudin nisl aliquet. Sed sed lectus ac dolor molestie interdum. Nam molestie pellentesque purus ac vestibulum. Pellentesque habitant morbi tristique senectus et netus et malesuada fames ac turpis egestas. Suspendisse non tempor eros. Mauris eu orci hendrerit volutpat lorem in tristique libero. Duis a nibh nibh.].freeze + + def initialize(random: Random.new) + @random = random + end + + def string_for_type(type) + if type == "date-time" + time + elsif type == "uri" + uri + else + raise "Unknown attribute type `#{type}`" + end + end + + def time + arbitrary_time = Time.new(2012, 2, 1) + (arbitrary_time + @random.rand(0..500_000_000)).iso8601 + end + + # TODO: make this more random with query string, optional anchor. + def uri + "http://example.com#{base_path}#{anchor}" + end + + def base_path + "/" + @random.rand(1..5).times.map { uuid }.join("/") + end + + def govuk_subdomain_url + subdomain = @random.rand(2..4).times.map { + ("a".."z").to_a.sample(@random.rand(3..8), random: @random).join + }.join(".") + "https://#{subdomain}.gov.uk#{base_path}" + end + + def string(minimum_chars = nil, maximum_chars = nil) + minimum_chars ||= 0 + maximum_chars ||= 100 + WORDS.sample(@random.rand(minimum_chars..maximum_chars), random: @random).join(" ") + end + + def bool + @random.rand(2) == 1 + end + + def anchor + "##{hex}" + end + + def random_identifier(separator:) + WORDS.sample(@random.rand(1..10), random: @random) + .join("-") + .gsub(/[^a-z0-9\-_]+/i, "-") + .gsub("-", separator) + end + + def uuid + # matches uuid regex e.g. e058aad7-ce86-5181-8801-4ddcb3c8f27c + # /^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/ + "#{hex(8)}-#{hex(4)}-1#{hex(3)}-a#{hex(3)}-#{hex(12)}" + end + + def hex(length = 10) + length.times.map { bool ? random_letter : random_number }.join("") + end + + def string_for_regex(pattern) + case pattern.to_s + when "^(placeholder|placeholder_.+)$" + ["placeholder", "placeholder_#{WORDS.sample(random: @random)}"].sample(random: @random) + when "^[a-f0-9]{8}-[a-f0-9]{4}-[1-5][a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$" + uuid + when "^/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?$" + base_path + when "^[1-9][0-9]{3}[-/](0[1-9]|1[0-2])[-/](0[1-9]|[12][0-9]|3[0-1])$" + Date.today.iso8601 + when "^[1-9][0-9]{3}-(0[1-9]|1[0-2])-(0[1-9]|[12][0-9]|3[0-1])$" + Date.today.iso8601 + when "^#.+$" + anchor + when "[a-z-]" + random_identifier(separator: "-") + when "^[a-z_]+$" + random_identifier(separator: "_") + when "^/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?(\\?([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?(#([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?$" + base_path + when "^https://([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[A-Za-z0-9])?\\.)+campaign\\.gov\\.uk(/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?(\\?([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?(#([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?)?$" + govuk_subdomain_url + when "^https://([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[A-Za-z0-9])?\\.)*gov\\.uk(/(([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})+(/([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)*)?(\\?([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?(#([a-zA-Z0-9._~!$&'()*+,;=:@-]|%[0-9a-fA-F]{2})*)?)?$" + govuk_subdomain_url + when '[a-z0-9\-_]' + "#{hex}-#{hex}" + else + raise <<-DOC + Don't know how to generate random string for pattern #{pattern.inspect} + + This propably means you've introduced a new regex in govuk-content-schemas. + Because it's very hard to generate a valid string from a regex alone, + we have to specify a method to generate random data for each regex in + the schemas. + + To fix this: + + - Add your regex to `lib/govuk_schemas/random.rb` + DOC + end + end + + private + + def random_letter + letters = ("a".."f").to_a + letters[@random.rand(0..letters.count - 1)] + end + + def random_number + numbers = ("0".."9").to_a + numbers[@random.rand(0..numbers.count - 1)] + end + end +end diff --git a/lib/govuk_schemas/random_example.rb b/lib/govuk_schemas/random_example.rb index ef9f283..7529a5c 100644 --- a/lib/govuk_schemas/random_example.rb +++ b/lib/govuk_schemas/random_example.rb @@ -1,5 +1,4 @@ -require "govuk_schemas/random" -require "govuk_schemas/random_item_generator" +require "govuk_schemas/random_schema_generator" require "json-schema" require "json" @@ -24,11 +23,17 @@ class RandomExample # schema = GovukSchemas::Schema.find(frontend_schema: "detailed_guide") # GovukSchemas::RandomExample.new(schema: schema).payload # + # Example with seed (for consistent results): + # + # schema = GovukSchemas::Schema.find(frontend_schema: "detailed_guide") + # GovukSchemas::RandomExample.new(schema: schema, seed: 777).payload + # GovukSchemas::RandomExample.new(schema: schema, seed: 777).payload # returns same as above + # # @param [Hash] schema A JSON schema. # @return [GovukSchemas::RandomExample] - def initialize(schema:) + def initialize(schema:, seed: nil) @schema = schema - @random_generator = RandomItemGenerator.new(schema: schema) + @random_generator = RandomSchemaGenerator.new(schema: schema, seed: seed) end # Returns a new `GovukSchemas::RandomExample` object. diff --git a/lib/govuk_schemas/random_item_generator.rb b/lib/govuk_schemas/random_schema_generator.rb similarity index 74% rename from lib/govuk_schemas/random_item_generator.rb rename to lib/govuk_schemas/random_schema_generator.rb index 447e46b..223022f 100644 --- a/lib/govuk_schemas/random_item_generator.rb +++ b/lib/govuk_schemas/random_schema_generator.rb @@ -1,7 +1,7 @@ -require "govuk_schemas/random" +require "govuk_schemas/random_content_generator" module GovukSchemas - # The RandomItemGenerator takes a JSON schema and outputs a random hash that + # The RandomSchemaGenerator takes a JSON schema and outputs a random hash that # is valid against said schema. # # The "randomness" here is quote relative, it's particularly tailored to the @@ -9,9 +9,11 @@ module GovukSchemas # hundred characters to keep the resulting items small. # # @private - class RandomItemGenerator - def initialize(schema:) + class RandomSchemaGenerator + def initialize(schema:, seed: nil) @schema = schema + @random = Random.new(seed || rand) + @generator = RandomContentGenerator.new(random: @random) end def payload @@ -45,24 +47,24 @@ def generate_value(props) end # Make sure that we choose a type when there are more than one specified. - type = Array(type).sample + type = Array(type).sample(random: @random) if props["anyOf"] - generate_value(props["anyOf"].sample) + generate_value(props["anyOf"].sample(random: @random)) elsif props["oneOf"] && type != "object" # FIXME: Generating valid data for a `oneOf` schema is quite interesting. # According to the JSON Schema spec a `oneOf` schema is only valid if # the data is valid against *only one* of the clauses. To do this # properly, we'd have to verify that the data generated below doesn't # validate against the other schemas in `props['oneOf']`. - generate_value(props["oneOf"].sample) + generate_value(props["oneOf"].sample(random: @random)) elsif props["allOf"] props["allOf"].each_with_object({}) do |subschema, hash| val = generate_value(subschema) hash.merge(val) end elsif props["enum"] - props["enum"].sample + props["enum"].sample(random: @random) elsif type == "null" nil elsif type == "object" @@ -70,11 +72,11 @@ def generate_value(props) elsif type == "array" generate_random_array(props) elsif type == "boolean" - Random.bool + @generator.bool elsif type == "integer" min = props["minimum"] || 0 max = props["maximum"] || 10 - rand(min..max) + @random.rand(min..max) elsif type == "string" generate_random_string(props) else @@ -85,27 +87,27 @@ def generate_value(props) def generate_random_object(subschema) document = {} - one_of_sample = subschema.fetch("oneOf", []).sample || {} + one_of_sample = subschema.fetch("oneOf", []).sample(random: @random) || {} (subschema["properties"] || {}).each do |attribute_name, attribute_properties| # TODO: When the schema contains `subschema['minProperties']` we always # populate all of the keys in the hash. This isn't quite random, but I # haven't found a nice way yet to ensure there's at least n elements in # the hash. - should_generate_value = Random.bool \ + should_generate_value = @generator.bool \ || subschema["required"].to_a.include?(attribute_name) \ || (one_of_sample["required"] || {}).to_a.include?(attribute_name) \ || (one_of_sample["properties"] || {}).keys.include?(attribute_name) \ || subschema["minProperties"] \ - if should_generate_value - one_of_properties = (one_of_sample["properties"] || {})[attribute_name] - document[attribute_name] = if one_of_properties - generate_value(one_of_properties) - else - generate_value(attribute_properties) - end - end + next unless should_generate_value + + one_of_properties = (one_of_sample["properties"] || {})[attribute_name] + document[attribute_name] = if one_of_properties + generate_value(one_of_properties) + else + generate_value(attribute_properties) + end end document @@ -114,7 +116,7 @@ def generate_random_object(subschema) def generate_random_array(props) min = props["minItems"] || 0 max = props["maxItems"] || 10 - num_items = rand(min..max) + num_items = @random.rand(min..max) num_items.times.map do # sometimes arrays don't have `items` specified, not sure if this is a bug @@ -124,11 +126,11 @@ def generate_random_array(props) def generate_random_string(props) if props["format"] - Random.string_for_type(props["format"]) + @generator.string_for_type(props["format"]) elsif props["pattern"] - Random.string_for_regex(props["pattern"]) + @generator.string_for_regex(props["pattern"]) else - Random.string(props["minLength"], props["maxLength"]) + @generator.string(props["minLength"], props["maxLength"]) end end diff --git a/lib/govuk_schemas/utils.rb b/lib/govuk_schemas/utils.rb deleted file mode 100644 index dcc6038..0000000 --- a/lib/govuk_schemas/utils.rb +++ /dev/null @@ -1,16 +0,0 @@ -module GovukSchemas - # @private - module Utils - def self.stringify_keys(hash) - new_hash = {} - hash.each do |k, v| - new_hash[k.to_s] = v - end - new_hash - end - - def self.parameterize(string) - string.gsub(/[^a-z0-9\-_]+/i, "-") - end - end -end diff --git a/lib/govuk_schemas/version.rb b/lib/govuk_schemas/version.rb index 5e28c2a..8183ad1 100644 --- a/lib/govuk_schemas/version.rb +++ b/lib/govuk_schemas/version.rb @@ -1,4 +1,4 @@ module GovukSchemas # @private - VERSION = "4.0.1".freeze + VERSION = "4.1.0".freeze end diff --git a/spec/lib/random_spec.rb b/spec/lib/random_content_generator_spec.rb similarity index 50% rename from spec/lib/random_spec.rb rename to spec/lib/random_content_generator_spec.rb index 7d01412..0419fcf 100644 --- a/spec/lib/random_spec.rb +++ b/spec/lib/random_content_generator_spec.rb @@ -1,9 +1,9 @@ require "spec_helper" -RSpec.describe GovukSchemas::Random do +RSpec.describe GovukSchemas::RandomContentGenerator do describe ".random_identifier" do it "generates a string" do - string = GovukSchemas::Random.random_identifier(separator: "_") + string = GovukSchemas::RandomContentGenerator.new.random_identifier(separator: "_") expect(string).to be_a(String) end diff --git a/spec/lib/random_example_spec.rb b/spec/lib/random_example_spec.rb index 080e8ce..e1becf9 100644 --- a/spec/lib/random_example_spec.rb +++ b/spec/lib/random_example_spec.rb @@ -25,6 +25,13 @@ end end + it "returns the same output if a seed is detected" do + schema = GovukSchemas::Schema.random_schema(schema_type: "frontend") + first_payload = GovukSchemas::RandomExample.new(schema: schema, seed: 777).payload + second_payload = GovukSchemas::RandomExample.new(schema: schema, seed: 777).payload + expect(first_payload).to eql(second_payload) + end + it "can customise the payload" do schema = GovukSchemas::Schema.random_schema(schema_type: "frontend") diff --git a/spec/lib/random_item_generator_spec.rb b/spec/lib/random_schema_generator_spec.rb similarity index 85% rename from spec/lib/random_item_generator_spec.rb rename to spec/lib/random_schema_generator_spec.rb index ce2fde0..823df46 100644 --- a/spec/lib/random_item_generator_spec.rb +++ b/spec/lib/random_schema_generator_spec.rb @@ -1,6 +1,6 @@ require "spec_helper" -RSpec.describe GovukSchemas::RandomItemGenerator do +RSpec.describe GovukSchemas::RandomSchemaGenerator do describe "#payload" do it "generates an object with a required property" do schema = { @@ -13,7 +13,7 @@ }, } - generator = GovukSchemas::RandomItemGenerator.new(schema: schema) + generator = GovukSchemas::RandomSchemaGenerator.new(schema: schema) expect(generator.payload.keys).to include("my_field") end @@ -34,7 +34,7 @@ }, } - generator = GovukSchemas::RandomItemGenerator.new(schema: schema) + generator = GovukSchemas::RandomSchemaGenerator.new(schema: schema) expect(generator.payload.keys).to include("my_field") end @@ -59,7 +59,7 @@ }, } - generator = GovukSchemas::RandomItemGenerator.new(schema: schema) + generator = GovukSchemas::RandomSchemaGenerator.new(schema: schema) expect(generator.payload.keys).to include("my_field") end @@ -87,7 +87,7 @@ ], } - generator = GovukSchemas::RandomItemGenerator.new(schema: schema) + generator = GovukSchemas::RandomSchemaGenerator.new(schema: schema) expect(generator.payload["my_enum"]).to eq("a") expect(generator.payload.keys).to include("my_field")