Browse files

initial commit

  • Loading branch information...
0 parents commit f3023491591b772eeded8990017954135a96dd6a @rkh rkh committed Mar 5, 2012
4 .gitignore
@@ -0,0 +1,4 @@
+*.gem
+.bundle
+Gemfile.lock
+pkg/*
4 Gemfile
@@ -0,0 +1,4 @@
+source "http://rubygems.org"
+
+# Specify your gem's dependencies in gh.gemspec
+gemspec
25 README.md
@@ -0,0 +1,25 @@
+**This is work in progress and not yet usable!**
+
+Goal of this library is to ease usage of the Github API as part of large, tightly integrated, distributed projects, such as [Travis CI](http://travis-ci.org). It was born out due to issues we ran into with all existing Github libraries and the Github API itself.
+
+With that in mind, this library follows the following goals:
+
+* Implement features in separate layers, make layers as independend of each other as possible
+* Higher level layers should not worry about when to send requests
+* It should only send requests to Github when necessary
+* It should be able to fetch data from Github asynchronously (i.e. HTTP requests to Travis should not be bound to HTTP requests to Github, if possible)
+* It should be able to deal with events and hooks well (i.e. update cached entities with hook data)
+* It should not have intransparent magic (i.e. implicit, undocumented requirements on fields we get from Github)
+* It should shield against possible changes to the Github API or at least complain about those changes if it can't deal with it.
+
+Most of this is not yet implemented!
+
+The lower level APIs support a Rack-like stacking API:
+
+``` ruby
+api = GH::Stack.build do
+ use GH::Cache, cache: Rails.cache
+ use GH::Normalizer
+ use GH::Remote, username: "admin", password: "admin"
+end
+```
3 Rakefile
@@ -0,0 +1,3 @@
+require 'bundler/gem_tasks'
+require 'rspec/core/rake_task'
+RSpec::Core::RakeTask.new(:default)
23 gh.gemspec
@@ -0,0 +1,23 @@
+# -*- encoding: utf-8 -*-
+$:.push File.expand_path("../lib", __FILE__)
+require "gh/version"
+
+Gem::Specification.new do |s|
+ s.name = "gh"
+ s.version = GH::VERSION
+ s.authors = ["Konstantin Haase"]
+ s.email = ["konstantin.mailinglists@googlemail.com"]
+ s.homepage = ""
+ s.summary = %q{layered github client}
+ s.description = %q{multi-layer client for the github api v3}
+
+ s.files = `git ls-files`.split("\n")
+ s.test_files = `git ls-files -- {test,spec,features}/*`.split("\n")
+ s.executables = `git ls-files -- bin/*`.split("\n").map{ |f| File.basename(f) }
+ s.require_paths = ["lib"]
+
+ s.add_development_dependency "rspec"
+ s.add_runtime_dependency 'faraday', '~> 0.7'
+ s.add_runtime_dependency 'backports', '~> 2.3'
+ s.add_runtime_dependency 'multi_json', '~> 1.0'
+end
12 lib/gh.rb
@@ -0,0 +1,12 @@
+require 'gh/version'
+require 'backports'
+require 'forwardable'
+
+module GH
+ autoload :Cache, 'gh/cache'
+ autoload :Normalizer, 'gh/normalizer'
+ autoload :Remote, 'gh/remote'
+ autoload :Response, 'gh/response'
+ autoload :Stack, 'gh/stack'
+ autoload :Wrapper, 'gh/wrapper'
+end
51 lib/gh/cache.rb
@@ -0,0 +1,51 @@
+require 'gh'
+require 'thread'
+
+module GH
+ # Public: This class deals with HTTP requests to Github. It is the base Wrapper you always want to use.
+ # Note that it is usually used implicitely by other wrapper classes if not specified.
+ class Cache < Wrapper
+ # Public: Get/set cache to use. Compatible with Rails/ActiveSupport cache.
+ attr_accessor :cache
+
+ # Internal: Simple in-memory cache basically implementing a copying GC.
+ class SimpleCache
+ # Internal: Initializes a new SimpleCache.
+ #
+ # size - Number of objects to hold in cache.
+ def initialize(size = 2048)
+ @old, @new, @size, @mutex = {}, {}, size/2, Mutex.new
+ end
+
+ # Internal: Tries to fetch a value from the cache and if it doesn't exist, generates it from the
+ # block given.
+ def fetch(key)
+ @mutex.lock { @old, @new = @new, {} if @new.size > @size } if @new.size > @size
+ @new[key] ||= @old[key] || yield
+ end
+ end
+
+ # Public: Initializes a new Cache instance.
+ #
+ # backend - Backend to wrap (defaults to Remote)
+ # options - Configuration options:
+ # :cache - Cache to be used.
+ def initialize(*)
+ super
+ self.cache ||= Rails.cache if defined? Rails.cache
+ self.cache ||= ActiveSupport::Cache.lookup_store if defined? ActiveSupport::Cache.lookup_store
+ self.cache ||= SimpleCache.new
+ end
+
+ # Public: Retrieves resources from Github and caches response for future access.
+ #
+ # Examples
+ #
+ # Github::Cache.new['users/rkh'] # => { ... }
+ #
+ # Returns the Response.
+ def [](key)
+ cache.fetch(path_for(key)) { super }
+ end
+ end
+end
98 lib/gh/faraday.rb
@@ -0,0 +1,98 @@
+# Copyright (c) 2009 rick olson, zack hobson
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy of
+# this software and associated documentation files (the "Software"), to deal in
+# the Software without restriction, including without limitation the rights to
+# use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+# of the Software, and to permit persons to whom the Software is furnished to do
+# so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+require 'faraday'
+
+if Faraday::VERSION < '0.8.0'
+ $stderr.puts "please update faraday"
+
+ # https://github.com/technoweenie/faraday/blob/master/lib/faraday/request/basic_authentication.rb
+ require 'base64'
+
+ module Faraday
+ class Request::BasicAuthentication < Faraday::Middleware
+ def initialize(app, login, pass)
+ super(app)
+ @header_value = "Basic #{Base64.encode64([login, pass].join(':')).gsub("\n", '')}"
+ end
+
+ def call(env)
+ unless env[:request_headers]['Authorization']
+ env[:request_headers]['Authorization'] = @header_value
+ end
+ @app.call(env)
+ end
+ end
+ end
+
+ # https://github.com/technoweenie/faraday/blob/master/lib/faraday/request/retry.rb
+ module Faraday
+ class Request::Retry < Faraday::Middleware
+ def initialize(app, retries = 2)
+ @retries = retries
+ super(app)
+ end
+
+ def call(env)
+ retries = @retries
+ begin
+ @app.call(env)
+ rescue StandardError, Timeout::Error
+ if retries > 0
+ retries -= 1
+ retry
+ end
+ raise
+ end
+ end
+ end
+ end
+
+ # https://github.com/technoweenie/faraday/blob/master/lib/faraday/request/token_authentication.rb
+ module Faraday
+ class Request::TokenAuthentication < Faraday::Middleware
+ def initialize(app, token, options={})
+ super(app)
+
+ values = ["token=#{token.to_s.inspect}"]
+ options.each do |key, value|
+ values << "#{key}=#{value.to_s.inspect}"
+ end
+ comma = ",\n#{' ' * ('Authorization: Token '.size)}"
+ @header_value = "Token #{values * comma}"
+ end
+
+ def call(env)
+ unless env[:request_headers]['Authorization']
+ env[:request_headers]['Authorization'] = @header_value
+ end
+ @app.call(env)
+ end
+ end
+ end
+
+ # https://github.com/technoweenie/faraday/blob/master/lib/faraday/request.rb
+ Faraday::Request.register_lookup_modules \
+ :url_encoded => :UrlEncoded,
+ :multipart => :Multipart,
+ :retry => :Retry,
+ :basic_auth => :BasicAuthentication,
+ :token_auth => :TokenAuthentication
+end
98 lib/gh/normalizer.rb
@@ -0,0 +1,98 @@
+require 'gh'
+require 'time'
+
+module GH
+ # Public: A Wrapper class that deals with normalizing Github responses.
+ class Normalizer < Wrapper
+ # Public: Fetches resource from Github and normalizes the response.
+ #
+ # Returns normalized Response.
+ def [](key)
+ normalize super
+ end
+
+ private
+
+ def set_link(hash, type, href)
+ links = hash["_links"] ||= {}
+ links[type] = {"href" => href}
+ end
+
+ def normalize_response(response)
+ response = response.dup
+ response.data = normalize response.data
+ response
+ end
+
+ def normalize_hash(hash)
+ corrected = {}
+
+ hash.each_pair do |key, value|
+ key = normalize_key(key, value)
+ next if normalize_url(corrected, key, value)
+ next if normalize_time(corrected, key, value)
+ corrected[key] = normalize(value)
+ end
+
+ normalize_user(corrected)
+ corrected
+ end
+
+ def normalize_time(hash, key, value)
+ hash['date'] = Time.at(value).xmlschema if key == 'timestamp'
+ end
+
+ def normalize_user(hash)
+ hash['owner'] ||= hash.delete('user') if hash['created_at'] and hash['user']
+ hash['author'] ||= hash.delete('user') if hash['committed_at'] and hash['user']
+
+ hash['committer'] ||= hash['author'] if hash['author']
+ hash['author'] ||= hash['committer'] if hash['committer']
+ end
+
+ def normalize_url(hash, key, value)
+ case key
+ when "blog"
+ set_link(hash. key, value)
+ when "url"
+ type = Addressable::URI.parse(value).host == api_host.host ? "self" : "html"
+ set_link(hash, type, value)
+ when /^(.+)_url$/
+ set_link(hash, $1, value)
+ end
+ end
+
+ def normalize_key(key, value = nil)
+ case key
+ when 'gravatar_url' then 'avatar_url'
+ when 'org' then 'organization'
+ when 'orgs' then 'organizations'
+ when 'username' then 'login'
+ when 'repo' then 'repository'
+ when 'repos' then normalize_key('repositories', value)
+ when /^repos?_(.*)$/ then "repository_#{$1}"
+ when /^(.*)_repo$/ then "#{$1}_repository"
+ when /^(.*)_repos$/ then "#{$1}_repositories"
+ when 'commit', 'commit_id' then value =~ /^\w{40}$/ ? 'sha' : key
+ when 'comments' then Number === value ? 'comment_count' : key
+ when 'forks' then Number === value ? 'fork_count' : key
+ when 'repositories' then Number === value ? 'repository_count' : key
+ when /^(.*)s_count$/ then "#{$1}_count"
+ else key
+ end
+ end
+
+ def normalize_array(array)
+ array.map { |e| normalize(e) }
+ end
+
+ def normalize(object)
+ case object
+ when Hash then normalize_hash(object)
+ when Array then normalize_array(object)
+ when Response then normalize_response(object)
+ else object
+ end
+ end
+ end
+end
60 lib/gh/remote.rb
@@ -0,0 +1,60 @@
+require 'gh'
+require 'gh/faraday'
+
+module GH
+ # Public: This class deals with HTTP requests to Github. It is the base Wrapper you always want to use.
+ # Note that it is usually used implicitely by other wrapper classes if not specified.
+ class Remote < Wrapper
+ attr_reader :api_host, :connection, :headers
+
+ # Public: Generates a new Rempte instance.
+ #
+ # api_host - HTTP host to send requests to, has to include schema (https or http)
+ # options - Hash with configuration options:
+ # :token - OAuth token to use (optional).
+ # :username - Github user used for login (optional).
+ # :password - Github password used for login (optional).
+ # :origin - Value of the origin request header (optional).
+ # :headers - HTTP headers to be send on every request (optional).
+ # :adapter - HTTP library to use for making requests (optional, default: :net_http)
+ #
+ # It is highly recommended to set origin, but not to set headers.
+ # If you set the username, you should also set the password.
+ def initialize(api_host = 'https://api.github.com', options = {})
+ api_host, options = normalize_options(api_host, options)
+ token, username, password = options.values_at :token, :username, :password
+
+ api_host = api_host.api_host if api_host.respond_to? :api_host
+ @api_host = Addressable::URI.parse(api_host)
+ @headers = options[:headers].try(:dup) || {
+ "Origin" => options[:origin] || "http://example.org",
+ "Accept" => "application/vnd.github.v3.raw+json," \
+ "application/vnd.github.beta.raw+json;q=0.5," \
+ "application/json;q=0.1",
+ "Accept-Charset" => "utf-8"
+ }
+
+ @connection = Faraday.new(:url => api_host) do |builder|
+ builder.request(:token_auth, token) if token
+ builder.request(:basic_auth, username, password) if username and password
+ builder.request(:retry)
+ builder.response(:raise_error)
+ builder.adapter(options[:adapter] || :net_http)
+ end
+ end
+
+ # Public: Retrieves resources from Github.
+ #
+ # Examples
+ #
+ # Github::Remote.new['users/rkh'] # => { ... }
+ #
+ # Raises Faraday::Error::ResourceNotFound if the resource returns status 404.
+ # Raises Faraday::Error::ClientError if the resource returns a status between 400 and 599.
+ # Returns the Response.
+ def [](key)
+ response = connection.get(path_for(key), headers)
+ Response.new(response.headers, response.body)
+ end
+ end
+end
58 lib/gh/response.rb
@@ -0,0 +1,58 @@
+require 'gh'
+require 'multi_json'
+
+module GH
+ # Public: Class wrapping low level Github responses.
+ #
+ # Delegates safe methods to the parsed body (expected to be an Array or Hash).
+ class Response
+ # Internal: Content-Type header value expected from Github
+ CONTENT_TYPE = "application/json; charset=utf-8"
+
+ include Enumerable
+ attr_accessor :headers, :data, :body
+
+ # subset of safe methods that both Array and Hash implement
+ extend Forwardable
+ def_delegators(:@data, :[], :assoc, :each, :empty?, :flatten, :include?, :index, :inspect, :length,
+ :pretty_print, :pretty_print_cycle, :rassoc, :select, :size, :to_a, :values_at)
+
+ # Internal: Initializes a new instance.
+ #
+ # headers - HTTP headers as a Hash
+ # body - HTTP body as a String
+ def initialize(headers, body)
+ @headers = Hash[headers.map { |k,v| [k.downcase, v] }]
+ raise ArgumentError, "unexpected Content-Type #{content_type}" unless content_type == CONTENT_TYPE
+
+ @body = body.to_str
+ @body = @body.encode("utf-8") if @body.respond_to? :encode
+ @data = MultiJson.decode(@body)
+ end
+
+ # Public: Duplicates the instance. Will also duplicate some instance variables to behave as expected.
+ #
+ # Returns new Response instance.
+ def dup
+ super.dup!
+ end
+
+ # Public: Returns the Response body as a String.
+ def to_s
+ @body.dup
+ end
+
+ protected
+
+ def dup!
+ @headers, @data, @body = @headers.dup, @data.dup, @body.dup
+ self
+ end
+
+ private
+
+ def content_type
+ headers['content-type']
+ end
+ end
+end
52 lib/gh/stack.rb
@@ -0,0 +1,52 @@
+require 'gh'
+
+module GH
+ # Public: Exposes DSL for stacking wrappers.
+ #
+ # Examples
+ #
+ # api = GH::Stack.build do
+ # use GH::Cache, cache: Rails.cache
+ # use GH::Normalizer
+ # use GH::Remote, username: "admin", password: "admin"
+ # end
+ class Stack
+ # Public: Generates a new wrapper stack from the given block.
+ #
+ # options - Hash of options that will be passed to all layers upon initialization.
+ #
+ # Returns top most Wrapper instance.
+ def self.build(options, &block)
+ new(&block).build(options)
+ end
+
+ # Public: Generates a new Stack instance.
+ #
+ # options - Hash of options that will be passed to all layers upon initialization.
+ #
+ # Can be used for easly stacking layers.
+ def initialize(options = {}, &block)
+ @options, @stack = {}, []
+ block.try(:instance_eval, self)
+ end
+
+ # Public: Adds a new layer to the stack.
+ #
+ # Layer will be wrapped by layers already on the stack.
+ def use(klass, options = {})
+ stack << [klass, options]
+ self
+ end
+
+ # Public: Generates wrapper instances for stack configuration.
+ #
+ # options - Hash of options that will be passed to all layers upon initialization.
+ #
+ # Returns top most Wrapper instance.
+ def build(options)
+ stack.reverse.inject(nil) do |backend, (klass, opts)|
+ klass.new backend, @options.merge(opts).merge(options)
+ end
+ end
+ end
+end
4 lib/gh/version.rb
@@ -0,0 +1,4 @@
+module GH
+ # Public: Library version.
+ VERSION = "0.0.1"
+end
65 lib/gh/wrapper.rb
@@ -0,0 +1,65 @@
+require 'gh'
+require 'addressable/uri'
+
+module GH
+ # Public: Simple base class for low level layers.
+ # Handy if you want to manipulate resources coming in from Github.
+ #
+ # Examples
+ #
+ # class IndifferentAccess
+ # def [](key) super.tap { |r| r.data.with_indifferent_access! } end
+ # end
+ #
+ # gh = IndifferentAccess.new
+ # gh['users/rkh'][:name] # => "Konstantin Haase"
+ #
+ # # easy to use in the low level stack
+ # gh = Github.build do
+ # use GH::Cache
+ # use IndifferentAccess
+ # use GH::Normalizer
+ # end
+ class Wrapper
+ extend Forwardable
+
+ # Public: Returns the URI used for sending out web request.
+ def_delegator :@backend, :api_host
+
+ # Public: Retrieves resources from Github.
+ #
+ # By default, this method is delegated to the nex layer on the stack.
+ def_delegator :@backend, :[]
+
+ # Internal: Get/set default layer to wrap when creating a new instance.
+ def self.wraps(klass = nil)
+ @wraps = klass if klass
+ @wraps || Remote
+ end
+
+ # Public: Initialize a new Wrapper.
+ #
+ # backend - layer to be wrapped
+ # options - config options
+ def initialize(backend = nil, options = {})
+ backend, options = normalize_options(backend, options)
+ @backend = Wrapper === backend ? backend : self.class.wraps.new(backend, options)
+ options.each_pair { |key, value| public_send("#{key}=", value) if respond_to? "#{key}=" }
+ end
+
+ private
+
+ def normalize_options(backend, options)
+ backend, options = nil, backend if options.nil? and Hash === backend
+ options ||= {}
+ backend ||= options[:backend] || options[:api_url] || 'https://api.github.com'
+ [backend, options]
+ end
+
+ def path_for(key)
+ uri = Addressable::URI.parse(key)
+ raise ArgumentError, "URI out of scope: #{key}" if uri.host and uri.host != api_host.host
+ uri.request_uri
+ end
+ end
+end
6 spec/cache_spec.rb
@@ -0,0 +1,6 @@
+require 'spec_helper'
+
+describe GH::Cache do
+ it 'send HTTP requests for uncached resources'
+ it 'uses the cache for subsequent requests'
+end
49 spec/normalizer_spec.rb
@@ -0,0 +1,49 @@
+require 'spec_helper'
+
+describe GH::Normalizer do
+ it 'leaves unknown fields in place'
+ it 'works for deeply nested fields'
+ it 'works for lists'
+
+ context 'date fields' do
+ it 'generates date from timestamp'
+ end
+
+ context 'renaming' do
+ it 'renames gravatar_url to avatar_url'
+ it 'renames org to organization'
+ it 'renames orgs to organizations'
+ it 'renames username to login'
+ it 'renames repo to repository'
+ it 'renames repos to repositories'
+ it 'renames repo_ prefix to repository_'
+ it 'renames repos_ prefix to repository_'
+ it 'renames _repo suffix to _repository'
+ it 'renames _repos prefix to _repositories'
+ it 'renames commit to sha if value is a sha'
+ it 'renames commit_id to sha if value is a sha'
+ it 'renames comments to comment_count if content is a number'
+ it 'renames repositories to repository_count if content is a number'
+ it 'renames repos to repository_count if content is a number'
+ it 'renames forks to fork_count if content is a number'
+ it 'renames user to owner if appropriate'
+ it 'renames user to author if appropriate'
+ it 'leaves user in place if owner exists'
+ it 'leaves user in place if author exists'
+ it 'leaves user in place if no indication what kind of user'
+ it 'copies author to committer'
+ it 'copies committer to author'
+ it 'does not override committer or author if both exist'
+ end
+
+ context 'links' do
+ it 'generates link entries from link headers'
+ it 'generates link headers from link entries'
+ it 'does not discard existing link entires'
+ it 'does not discard existing link headers'
+ it 'identifies _url prefix as link'
+ it 'identifies blog as link'
+ it 'detects html urls in url field'
+ it 'detects self urls in url field'
+ end
+end
8 spec/remote_spec.rb
@@ -0,0 +1,8 @@
+require 'spec_helper'
+
+describe GH::Remote do
+ it 'loads resources from github'
+ it 'sets headers correctly'
+ it 'raises an exception for missing resources'
+ it 'parses the body'
+end
5 spec/response_spec.rb
@@ -0,0 +1,5 @@
+require 'spec_helper'
+
+describe GH::Response do
+ it 'is specified'
+end
1 spec/spec_helper.rb
@@ -0,0 +1 @@
+require 'gh'
5 spec/stack_spec.rb
@@ -0,0 +1,5 @@
+require 'spec_helper'
+
+describe GH::Stack do
+ it 'is specified'
+end
5 spec/wrapper_spec.rb
@@ -0,0 +1,5 @@
+require 'spec_helper'
+
+describe GH::Wrapper do
+ it 'is specified'
+end

0 comments on commit f302349

Please sign in to comment.