Search code examples
crystal-lang

Use PullParser to deserialize Range in Crystal


I'm trying to convert some json { "range": {"start": 1, "stop": 10} } into a Range object equivalent to Range.new(1,10).

It seems that if I want to do this in my Foo struct I'll need a custom converter (see below) which uses the JSON::PullParser to consume each token. I tried things like the below to see if I could understand how the pull parser is supposed to be used. But it looks like it expects everything to be a string and chokes on the first Int it finds. So the following isn't helpful but illustrates what I'm confused about:

require "json"

module RangeConverter
  def self.from_json(pull : JSON::PullParser)
    pull.read_object do |key, key_location|
      puts key # => puts `start` then chokes on the `int`
               # Expected String but was Int at 1:22
    end
    Range.new(1,2)
  end
end

  
struct Foo
  include JSON::Serializable
    
  @[JSON::Field(converter: RangeConverter)]
  property range : Range(Int32, Int32)
end

Foo.from_json %({"range": {"start": 1, "stop": 10}})

The only way I was able to figure this out was to just read the raw json string and work with it directly but it feels like I'm side-stepping the parser because I don't understand it. The following works:

require "json"

module RangeConverter
  def self.from_json(pull : JSON::PullParser)
    h = Hash(String, Int32).from_json(pull.read_raw)
    Range.new(h["start"],h["stop"])
  end
end

  
struct Foo
  include JSON::Serializable
    
  @[JSON::Field(converter: RangeConverter)]
  property range : Range(Int32, Int32)
end

Foo.from_json %({"range": {"start": 1, "stop": 10}})

So how am I actually supposed to be using the Parser here?


Solution

  • Your latter option isn't bad at all. It just reuses the implementation from a Hash but it's fully workable and composable. The only downside is it needs to allocate and then discard that Hash.

    Based on this sample I deduce that you're expected to call .begin_object? first. But actually that's just a nicety for error detection. The main thing is that you're also supposed to explicitly read ("consume") the values, based on this sample. In the code below this is represented with Int32.new(pull).

    require "json"
    
    module RangeConverter
      def self.from_json(pull : JSON::PullParser)
        start = stop = nil
        unless pull.kind.begin_object?
          raise JSON::ParseException.new("Unexpected pull kind: #{pull.kind}", *pull.location)
        end
        pull.read_object do |key, key_location|
          case key
          when "start"
            start = Int32.new(pull)
          when "stop"
            stop = Int32.new(pull)
          else
            raise JSON::ParseException.new("Unexpected key: #{key}", *key_location)
          end
        end
        raise JSON::ParseException.new("No start", *pull.location) unless start
        raise JSON::ParseException.new("No stop", *pull.location) unless stop
        Range.new(start, stop)
      end
    end
    
      
    struct Foo
      include JSON::Serializable
        
      @[JSON::Field(converter: RangeConverter)]
      property range : Range(Int32, Int32)
    end
    
    p Foo.from_json %({"range": {"start": 1, "stop": 10}})