kgiszczak / shale

Shale is a Ruby object mapper and serializer for JSON, YAML, TOML, CSV and XML. It allows you to parse JSON, YAML, TOML, CSV and XML data and convert it into Ruby data structures, as well as serialize data structures into JSON, YAML, TOML, CSV or XML.
https://shalerb.org/
MIT License
618 stars 19 forks source link

Allow more JSON Schema keywords #28

Closed bkjohnson closed 8 months ago

bkjohnson commented 9 months ago

I've been working on trying to use more keywords from the JSON Schema dialect, such as minimum, maximum, and required. I wasn't able to get shale's JSON mapping to work which makes sense if it's just for serializing/deserializing, so as of now I've thought of three possible approaches

1. Custom classes for every possibility

I think this is what developers would have to do right now to use more of the dialect. To have an integer with a minimum value of 1 might look like this:

  class IntegerMinimumOneType < Shale::Type::Integer
  end

  class IntegerMinimumOneJSONType < Shale::Schema::JSONGenerator::Base
    def as_type
      { "type" => "integer", "minimum" => 1 }
    end
  end

  Shale::Schema::JSONGenerator.register_json_type(IntegerMinimumOneType, IntegerMinimumOneJSONType)

  class PersonMapper < Shale::Mapper
    model Person
    attribute :age, IntegerMinimumOneType
  end

This would be fine for common things, like PositiveInteger, but this would get increasingly difficult to manage if the schema needed multiple validations for properties, such as a minimum, maximum, and required all at once.

2. Instantiated types for more flexibility

This isn't possible with shale right now, but I have a local branch where I've gotten it to work and would be happy to make a PR if this is a direction you'd like to go in. To achieve the same schema as above the API could look like this:

  class BoundedIntegerType < Shale::Type::Integer
    attr_reader :min, :max

    def initialize(min = nil, max = nil)
      @min = min
      @max = max
    end
  end

  class BoundedIntegerJSONType < Shale::Schema::JSONGenerator::Base
    def as_type
      { "type" => "integer", "minimum" => instance.min, "maximum": instance.max }.compact
    end
  end

  Shale::Schema::JSONGenerator.register_json_type(BoundedIntegerType, BoundedIntegerJSONType)

  class PersonMapper < Shale::Mapper
    model Person
    attribute :age, BoundedIntegerType.new(1) # minimum of 1, no maximum
  end

The responsibility is still on the consumer of the gem to create the types needed for their schema, but having a generator that can receive an instance of a class can allow for a lot of flexibility.

3. Modify the API to include more options, similar to collection

Another option to allow more of the dialect to be used is to allow the attribute API to receive all the possible keywords, either individually or through some sort of hash.

  class PersonMapper < Shale::Mapper
    model Person
    attribute :age, Shale::Type::Integer, minimum: 1
  end

This might involve some more work on the shale side of things to ensure that the provided keywords are compatible with the type. For example this should be considered invalid since maxItems is for arrays:

    attribute :age, Shale::Type::Integer, max_items: 5

Apologies if I've overlooked something, but based on reading the docs and trying things out I think right now Option 1 is all I can do, so I wanted to explore Option 2 since it seemed less cumbersome. It also seemed like implementing Option 3 would add a lot more complexity to the gem. Should I go ahead and make a PR for this, or do you have guidance on how to achieve this another way?

kgiszczak commented 9 months ago

Hey, I'd happily accept a PR from you. Option 3 is something I'd like to see, although with some modifications. Since this change relates to Schema I'd prefer extra options be defined on schema definition instead an attribute. This way we can have different options per schema. It will also be more consistent, since not every format support schemas.

This is an example I have in mind:

class Person < Shale::Mapper
  attribute :id, Shale::Type::Integer
  attribute :first_name, Shale::Type::String

  json do
    map 'id', to: :id, minimum: 1
    map 'first_name', to: :first_name, maximum: 5
  end

  xml do
    map_element 'Id', to: :id, minimum: 10
    map_element 'FirstName', to: :first_name, maximum: 20
  end
end