lib/govuk_schemas/random_example.rb



require "govuk_schemas/random_schema_generator"
require "json-schema"
require "json"

module GovukSchemas
  # Generate random content based on a schema.
  #
  # ## Limitations
  #
  # - The gem doesn't support `patternProperties` yet. On GOV.UK we [use this in
  # the expanded frontend
  # links](https://github.com/alphagov/publishing-api/blob/a8039d430e44c86c3f54a69569f07ad48a4fc912/content_schemas/formats/shared/definitions/frontend_links.jsonnet#L118-L121).
  # - It's complicated to generate random data for `oneOf` properties. 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 make
  # sure that the data generated below doesn't validate against the other
  # schemas properties.
  class RandomExample
    # Returns a new `GovukSchemas::RandomExample` object.
    #
    # For example:
    #
    #     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.
    # @param [Integer, nil] seed    A random number seed for deterministic results
    # @return [GovukSchemas::RandomExample]
    def initialize(schema:, seed: nil)
      @schema = schema
      @random_generator = RandomSchemaGenerator.new(schema:, seed:)
    end

    # Returns a new `GovukSchemas::RandomExample` object.
    #
    # Example without block:
    #
    #      GovukSchemas::RandomExample.for_schema(frontend_schema: "detailed_guide")
    #      # => {"base_path"=>"/e42dd28e", "title"=>"dolor est...", "publishing_app"=>"elit"...}
    #
    # Example with block:
    #
    #      GovukSchemas::RandomExample.for_schema(frontend_schema: "detailed_guide") do |payload|
    #        payload.merge('base_path' => "Test base path")
    #      end
    #      # => {"base_path"=>"Test base path", "title"=>"dolor est...", "publishing_app"=>"elit"...}
    #
    # @param schema_key_value [Hash]
    # @param [Block] the base payload is passed inton the block, with the block result then becoming
    #   the new payload. The new payload is then validated. (optional)
    # @return [GovukSchemas::RandomExample]
    def self.for_schema(schema_key_value, &block)
      schema = GovukSchemas::Schema.find(schema_key_value)
      GovukSchemas::RandomExample.new(schema:).payload(&block)
    end

    # Return a content item merged with a hash and with the excluded fields removed.
    # If the resulting content item isn't valid against the schema an error will be raised.
    #
    # Example without block:
    #
    #      generator.payload
    #      # => {"base_path"=>"/e42dd28e", "title"=>"dolor est...", "publishing_app"=>"elit"...}
    #
    # Example with block:
    #
    #      generator.payload do |payload|
    #        payload.merge('base_path' => "Test base path")
    #      end
    #      # => {"base_path"=>"Test base path", "title"=>"dolor est...", "publishing_app"=>"elit"...}
    #
    # @param [Block] the base payload is passed inton the block, with the block result then becoming
    #   the new payload. The new payload is then validated. (optional)
    # @return [Hash] A content item
    # @raise [GovukSchemas::InvalidContentGenerated]
    def payload(&block)
      payload = @random_generator.payload

      return customise_payload(payload, &block) if block

      errors = validation_errors_for(payload)
      raise InvalidContentGenerated, error_message(payload, errors) if errors.any?

      payload
    end

  private

    def customise_payload(payload)
      # Use Marshal to create a deep dup of the payload so the original can be mutated
      original_payload = Marshal.load(Marshal.dump(payload))
      customised_payload = yield(payload)
      customised_errors = validation_errors_for(customised_payload)

      if customised_errors.any?
        # Check if the original payload had errors and report those over
        # any from customisation. This is not done prior to generating the
        # customised payload because validation is time expensive and we
        # want to avoid it if possible.
        original_errors = validation_errors_for(original_payload)
        errors = original_errors.any? ? original_errors : customised_errors
        payload = original_errors.any? ? original_payload : customised_payload
        message = error_message(payload, errors, customised: original_errors.empty?)

        raise InvalidContentGenerated, message
      end

      customised_payload
    end

    def validation_errors_for(item)
      JSON::Validator.fully_validate(@schema, item, errors_as_objects: true)
    end

    def error_message(item, errors, customised: false)
      details = <<~ERR
        Generated payload:
        --------------------------

        #{JSON.pretty_generate([item])}

        Validation errors:
        --------------------------

        #{JSON.pretty_generate(errors)}
      ERR

      if customised
        <<~ERR
          The content item you are trying to generate is invalid against the schema.
          The item was valid before being customised.

          #{details}
        ERR
      else
        <<~ERR
          An invalid content item was generated.

          This probably means there's a bug in the generator that causes it to output
          invalid values. Below you'll find the generated payload, the validation errors
          and the schema that was used.

          #{details}
        ERR
      end
    end
  end
end