Collection+JSON Ruby Client

Posted on 30 Nov 2015 by Eric Oestrich

At REST Fest this year I did a small Collection+JSON ruby client to work against a hypermedia server. The full source code is hosted on github.

Client classes

class CollectionJSONMiddleware < Faraday::Middleware
  def initialize(app)
    @app = app
  end

  def call(env)
    env[:request_headers]["Accept"] = "application/vnd.collection+json"
    @app.call(env)
  end
end

A small middleware class to shove the correct accept header in.

class Client
  def initialize(url)
    @url = url
  end

  def get(url)
    response = connection.get(url)
    json = JSON.parse(response.body)
    Collection.new(json)
  end

  def delete(item)
    response = connection.delete(item.href)
    json = JSON.parse(response.body)
    Collection.new(json)
  end

  def update(item, template)
    response = connection.put(item.href) do |req|
      req.headers["Content-Type"] = "application/vnd.collection+json"
      req.body = template.to_json
    end
    json = JSON.parse(response.body)
    Collection.new(json)
  end

  def add_template(collection, template)
    response = connection.post(collection.href) do |req|
      req.headers["Content-Type"] = "application/vnd.collection+json"
      req.body = template.to_json
    end
    json = JSON.parse(response.body)
    Collection.new(json)
  end

  private

  def connection
    @connection ||= Faraday.new(@url) do |conn|
      conn.use CollectionJSONMiddleware
      conn.adapter Faraday.default_adapter
    end
  end
end

Model Classes

These are Collection+JSON classes that are not specific to the underlying data at all.

class Collection
  attr_reader :version, :href, :links, :items, :queries, :template

  def initialize(json)
    collection = json.fetch("collection")

    @version = collection.fetch("version", nil)
    @href = collection.fetch("href", nil)
    @links = collection.fetch("links", []).map { |link| Link.new(link) }
    @items = collection.fetch("items", []).map { |item| Item.new(item) }
    @queries = collection.fetch("queries", []).map do |query|
      Query.new(query)
    end
    template = collection.fetch("template", nil)
    @template = Template.new(template) if template
  end

  def query(rel)
    queries.detect do |query|
      query.rel == rel
    end
  end
end

class Query
  attr_reader :rel, :href, :prompt, :data

  def initialize(attributes)
    @rel = attributes.fetch("rel")
    @href = attributes.fetch("href")
    @prompt = attributes.fetch("prompt")
    @data = attributes.fetch("data", []).map do |data|
      DataItem.new(data)
    end
  end

  def set(data, value)
    @values ||= Faraday::Utils::ParamsHash.new
    @values[data.name] = value
  end

  def to_url
    uri = URI.parse(href)
    uri.query = @values.to_query
    uri.to_s
  end
end

class Template
  attr_reader :prompt, :rel, :data

  def initialize(attributes)
    @prompt = attributes.fetch("prompt", nil)
    @data = attributes.fetch("data", []).map do |data|
      DataItem.new(data)
    end
  end

  def set(data, value)
    @values ||= {}
    @values[data.name] = value
  end

  def to_json
    {
      :template => {
        :data => data.map do |data|
          {
            :name => data.name,
            :value => @values[data.name],
          }
        end
      },
    }.to_json
  end
end

class Link
  def initialize(attributes)
    @attributes = attributes
  end

  def to_s
    "#{rel}: #{href}"
  end

  def [](key)
    @attributes[key]
  end

  def rel
    @attributes.fetch("rel")
  end

  def href
    @attributes.fetch("href")
  end
end

class Item
  attr_reader :href, :data, :links

  def initialize(attributes)
    @href = attributes.fetch("href")
    @data = attributes.fetch("data", []).map do |data|
      DataItem.new(data)
    end
    @links = attributes.fetch("links", []).map { |link| Link.new(link) }
  end

  def attribute(name)
    data.detect { |attribute| attribute.name == name }
  end
end

class DataItem
  def initialize(attributes)
    @attributes = attributes
  end

  def to_s
    "#{name}: #{value}"
  end

  def [](key)
    @attributes[key]
  end

  def name
    @attributes.fetch("name")
  end

  def value
    @attributes.fetch("value", nil)
  end

  def prompt
    @attributes.fetch("prompt", nil)
  end
end

Putting it together

Here is a class that lets you perform general collection+json actions on the command line. It is slightly specific for the app at REST Fest, but there isn't that much specific.

class CommandLine
  attr_reader :collection

  def run
    @collection ||= client.get("/")

    puts "What do you want to do?"
    puts "  - Refresh Items (refresh)"
    puts "  - Items (items)"
    puts "  - Queries (queries)"
    puts "  - Template '#{collection.template.prompt}' (template)" if collection.template
    puts "  - Delete (delete)"
    puts "  - Edit (edit)"
    puts "  - Exit (exit)"

    choice = gets.chomp
    puts

    case choice
    when "refresh"
      refresh
    when "items"
      print_items(collection.items)
    when "queries"
      queries
    when "template"
      template
    when "delete"
      delete
    when "edit"
      edit
    when "exit"
      return
    end

    run
  end

  def refresh
    @collection = client.get(collection.href)
  end

  def queries
    queries = collection.queries.map do |query|
      "#{query.prompt} (#{query.rel})"
    end
    puts queries

    puts "Choose a query"
    query = gets.chomp

    search_query = collection.query(query)

    unless search_query
      puts "Query not found"
      return
    end

    puts

    if search_query.data.all? { |data| data.value.empty? }
      edit = "yes"
    else
      search_query.data.each do |data|
        puts "#{data.prompt}: #{data.value}"
      end

      puts "Edit? (yes/no)"
      edit = gets.chomp
      puts
    end

    case edit
    when "yes"
      search_query.data.each do |data|
        if data.value.present?
          puts "#{data.prompt} (#{data.value}):"
        else
          puts "#{data.prompt}:"
        end
        search_query.set(data, gets.chomp)
      end
      puts
    when "no"
      search_query.data.each do |data|
        search_query.set(data, data.value)
      end
    end

    collection = client.get(search_query.to_url)

    print_items(collection.items)
  end

  def template
    template = collection.template
    puts template.prompt
    template.data.each do |data|
      if data.value.present?
        puts "#{data.prompt} (#{data.value}):"
      else
        puts "#{data.prompt}:"
      end
      template.set(data, gets.chomp)
    end
    puts

    @collection = client.add_template(collection, template)
  end

  def delete
    item = choose_item
    @collection = client.delete(item)
  end

  def edit
    item = choose_item

    @collection = client.get(item.href)

    item = collection.items.first
    template = collection.template

    item.data.each do |data|
      next unless template.data.any? { |td| td.name == data.name }
      if data.value.present?
        puts "#{data.prompt} (#{data.value}):"
      else
        puts "#{data.prompt}:"
      end
      template.set(data, gets.chomp)
    end

    @collection = client.update(item, template)
  end

  private

  def client
    @client ||= Client.new("http://hyper-hackery.herokuapp.com/")
  end

  def choose_item
    print_items(collection.items, true)

    puts "Choose an item"
    item_number = gets.chomp.to_i

    collection.items[item_number]
  end

  def print_items(items, include_numbers = false)
    puts "Returned items"
    items.each_with_index do |item, index|
      value = item.attribute("completed").value
      finished = value == "true" || value == true ? "(X)" : "( )"
      number = " (#{index})" if include_numbers
      puts "  - #{finished}#{number} #{item.attribute("title").value}"
    end
    puts
  end
end

CommandLine.new.run

Here is a sample run:

ruby perform_search.rb
What do you want to do?
  - Refresh Items (refresh)
  - Items (items)
  - Queries (queries)
  - Template 'Add ToDo' (template)
  - Delete (delete)
  - Edit (edit)
  - Exit (exit)
items

Returned items
  - (X) one more test again
  - ( ) danny boy
  - ( ) fishing
  - ( ) goofing around
  - ( ) one more simple test
  - (X) one more minor test
  - ( ) update these docs
  - ( ) wheee
  - ( ) asdasd
  - ( ) something to test
  - (X) update cj parser
  - ( ) trying to test
  - ( ) one more simple test
  - (X) additional stuff
  - ( ) ssssss
  - (X) more stuff
  - ( ) one more simple test

What do you want to do?
  - Refresh Items (refresh)
  - Items (items)
  - Queries (queries)
  - Template 'Add ToDo' (template)
  - Delete (delete)
  - Edit (edit)
  - Exit (exit)
queries

Active ToDos (active collection)
Completed ToDos (completed collection)
Search ToDos (search)
Choose a query
search

Title:
one

Returned items
  - (X) one more test again
  - ( ) one more simple test
  - (X) one more minor test
  - ( ) one more simple test
  - ( ) one more simple test

What do you want to do?
  - Refresh Items (refresh)
  - Items (items)
  - Queries (queries)
  - Template 'Add ToDo' (template)
  - Delete (delete)
  - Edit (edit)
  - Exit (exit)
exit
comments powered by Disqus
Eric Oestrich
I am:
All posts
Creative Commons License
This site's content is licensed under a Creative Commons Attribution-ShareAlike 4.0 International License unless otherwise specified. Code on this site is licensed under the MIT License unless otherwise specified.