Skip to content

Commit

Permalink
initial commit
Browse files Browse the repository at this point in the history
  • Loading branch information
rkh committed Mar 5, 2012
0 parents commit f302349
Show file tree
Hide file tree
Showing 21 changed files with 636 additions and 0 deletions.
4 changes: 4 additions & 0 deletions .gitignore
@@ -0,0 +1,4 @@
*.gem
.bundle
Gemfile.lock
pkg/*
4 changes: 4 additions & 0 deletions Gemfile
@@ -0,0 +1,4 @@
source "http://rubygems.org"

# Specify your gem's dependencies in gh.gemspec
gemspec
25 changes: 25 additions & 0 deletions 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 changes: 3 additions & 0 deletions Rakefile
@@ -0,0 +1,3 @@
require 'bundler/gem_tasks'
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:default)
23 changes: 23 additions & 0 deletions 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 changes: 12 additions & 0 deletions 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 changes: 51 additions & 0 deletions 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 changes: 98 additions & 0 deletions 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 changes: 98 additions & 0 deletions 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 changes: 60 additions & 0 deletions 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

0 comments on commit f302349

Please sign in to comment.