require 'markaby' require 'json' require 'nokogiri' require 'rack/accept_media_types' require 'addressable/template' # Rack::Stereoscope - bringing a new dimension to your RESTful API # # Stereoscope is inspired by the idea that software should be explorable. Put # stereoscope in front of your RESTful API, and you get an interactive, # explorable HTML interface to your API for free. Use it to manually test your # API from a browser. Use it to make your API self-documenting. Use it to # quickly prototype new API features and get a visual feel for the data # structures. # # Stereoscope is designed to be unobtrusive. It will not interpose itself unless # the request asks for HTML (i.e. it comes from a browser). If the request # requests no explicit content type; or if it requests a content-type other than # HTML, Stereoscope stays out of the way. # # This middleware is especially well-suited to presenting APIs that are heavily # hyperlinked (and if your API doesn't have hyperlinks, why # not?[1]). Stereoscope does it's best to recognize URLs and make them # clickable. What's more, Stereoscope supports URI Templates[2]. If your data # includes URL templates such as the following: # # http://example.org/{foo}?bar={bar} # # Stereoscope will render a form which enables the user to experiment with # different expansions of the URI template. # # Limitations: # * Currently only supports JSON data # * Only link-ifies fully-qualified URLs; relative URLs are not supported # * Read-only exploration; no support for POSTs, PUTs, or DELETEs. # # [1] http://www.theamazingrando.com/blog/?p=107 # [2] http://bitworking.org/projects/URI-Templates/ module Rack class Stereoscope def initialize(app) @app = app end def call(env) request = Rack::Request.new(env) if Rack::AcceptMediaTypes.new(env['HTTP_ACCEPT']).include?('text/html') status, headers, body = @app.call(env) if request.path == '/__stereoscope_expand_template__' expand_template(request) else present_data(request, status, headers, body) end else @app.call(env) end end def present_data(request, status, headers, body) response = Rack::Response.new("", status, headers) response.write(build_page(body, request, response)) response['Content-Type'] = 'text/html' response.finish end def expand_template(request) template = Addressable::Template.new(request['__template__']) url = template.expand(request.params) response = Rack::Response.new response.redirect(url.to_s) response.finish end def build_page(content, request, response) this = self mab = Markaby::Builder.new mab.html do head do title request.path end body do h1 "#{response.status} #{request.url}" if !content.to_s.empty? h2 "Response:" case response.content_type when 'application/json' then div do this.data_to_html(JSON.parse(content.join), mab) end when 'text/plain' then p content.join else text Nokogiri::HTML(content.join).css('body').inner_html end else p "(No content)" end h2 "Raw:" tt do raw_content = case response.content_type when 'application/json' JSON.pretty_generate(JSON.parse(content.join)) else content.join end pre raw_content end end end mab.to_s end def data_to_html(data, builder) this = self case data when Hash builder.dl do data.each_pair do |key, value| dt do this.data_to_html(key, builder) end dd do this.data_to_html(value, builder) end end end when Array if tabular?(data) table_to_html(data, builder) else list_to_html(data, builder) end when String if url?(data) if url_template?(data) template_to_html(data, builder) else url_to_html(data, builder) end else builder.div do data.split("\n").each do |line| builder.span line builder.br end end end else builder.span do data end end end def url?(text) Addressable::URI.parse(text.to_s).ip_based? end def url_template?(text) !Addressable::Template.new(text.to_s).variables.empty? end def tabular?(data) data.kind_of?(Array) && data.all?{|e| e.kind_of?(Hash)} && data[1..-1].all?{|e| e.keys == data.first.keys} end def url_to_html(url, builder) builder.a(url.to_s, :href => url.to_s) end def template_to_html(text, builder) template = Addressable::Template.new(text) builder.div(:class => 'url-template-form') do p text form(:method => 'GET', :action => '/__stereoscope_expand_template__') do input(:type => 'hidden', :name => '__template__', :value => text) template.variables.each do |variable| div(:class => 'url-template-variable') do label do text "#{variable}: " input(:type => 'text', :name => variable) end end end input(:type => 'submit') end end end def list_to_html(data, builder) this = self builder.ol do data.each do |value| li do this.data_to_html(value, builder) end end end end def table_to_html(data, builder) this = self builder.table do headers = data.first.keys thead do headers.each do |header| th do this.data_to_html(header, builder) end end end tbody do data.each do |row| tr do row.each do |key, value| td do this.data_to_html(value, builder) end end end end end end end end end