Search code examples
jsoncrystal-lang

Correctly moving around types with JSON::PullParser


In a Crystal shard I am creating, data needs to be pulled from different API endpoints. Collection endpoints will respond with a array like the one below:

json = %({
  "_embedded": [
    {"id":"item_1"},
    {"id":"item_2"}
  ]
})

To interpret the array and cast it to an array of objects, I prepared the following converter:

struct ListConverter(T)
  def self.from_json(pull : JSON::PullParser)
    items = Array(T).new
    pull.read_array do
      items.push(Item.from_json(pull.read_raw))
    end
    items
  end
end

There are two abstract structs. One for the items in the array and another for the list itself, which includes Enumerable:

abstract struct Base
  include JSON::Serializable
end

abstract struct List(T) < Base
  include Enumerable(T)

  @[JSON::Field(key: "_embedded", converter: ListConverter(Item))]
  getter items : Array(T)

  def each(&block : T -> _)
  end
end

Finally, the implementation:

struct Item < Base
  getter id : String?
end

struct ItemList < List(Item)
end

list = ItemList.from_json(json)

This all works fine, except for one thing. The list converter needs to be passed the exact item type:

@[JSON::Field(key: "_embedded", converter: ListConverter(Item))]

I would like to be able to do this, but of course, that does not work because T is not defined at runtime (I think):

@[JSON::Field(key: "_embedded", converter: ListConverter(T))]

So now I will have to define the following lines in every struct inheriting from List:

@[JSON::Field(key: "_embedded", converter: ListConverter(Item))]
getter items : Array(T)

What would be the best approach to avoid unnecessary duplication?


Solution

  • After a brisk walk, I came up with a solution that works. By using a macro, T can be captured and the macro can be used to pass the converter to the JSON::Field annotation:

    abstract struct List(T) < Base
      include Enumerable(T)
    
      macro list_converter
        ListConverter({{ T.id }})
      end
    
      @[JSON::Field(key: "_embedded", converter: list_converter)]
      getter items : Array(T)
    
      def each(&block : T -> _)
      end
    end
    

    Not sure if this is the right/best approach, but it works as expected.