Merge pull request #156 from travis-ci/rkh-v3

v3 API (initial setup)
This commit is contained in:
Konstantin Haase 2015-01-21 17:12:37 +01:00
commit b2ba9383eb
37 changed files with 1111 additions and 40 deletions

2
.gitignore vendored
View File

@ -3,3 +3,5 @@ config/travis.yml
log/
vendor
config/skylight.yml
.coverage
.coverage/

View File

@ -10,6 +10,7 @@ gem 'travis-support', github: 'travis-ci/travis-support'
gem 'travis-config', '~> 0.1.0'
gem 'travis-sidekiqs', github: 'travis-ci/travis-sidekiqs', require: nil, ref: 'cde9741'
gem 'travis-yaml', github: 'travis-ci/travis-yaml'
gem 'mustermann', github: 'rkh/mustermann'
gem 'sinatra'
gem 'sinatra-contrib', require: nil #github: 'sinatra/sinatra-contrib', require: nil
@ -27,7 +28,8 @@ gem 'pry'
gem 'metriks', '0.9.9.6'
gem 'metriks-librato_metrics', github: 'eric/metriks-librato_metrics'
gem 'micro_migrations'
gem 'skylight'
gem 'simplecov'
gem 'skylight', '~> 0.6.0.beta.1'
group :test do
gem 'rspec', '~> 2.13'

View File

@ -20,6 +20,13 @@ GIT
rack-contrib (1.2.0)
rack (>= 0.9.1)
GIT
remote: git://github.com/rkh/mustermann.git
revision: 01df7fe671838d6caadac278d9704b5cd3c7b999
specs:
mustermann (0.4.0)
tool (~> 0.2)
GIT
remote: git://github.com/rkh/yard-sinatra.git
revision: 00774d355123617ff0faa7e0ebd54c4cdcfcdf93
@ -43,7 +50,7 @@ GIT
GIT
remote: git://github.com/travis-ci/travis-core.git
revision: cbfb6da8a1c6f4cc80c40b260d4d2708e3acec03
revision: a0aa1e2f79d45a4c59c1046a5435fe598eda2d2a
specs:
travis-core (0.0.1)
actionmailer (~> 3.2.19)
@ -89,7 +96,6 @@ PATH
remote: .
specs:
travis-api (0.0.1)
backports (~> 2.5)
memcachier
mustermann (~> 0.4)
pg (~> 0.13.2)
@ -106,12 +112,12 @@ PATH
GEM
remote: https://rubygems.org/
specs:
actionmailer (3.2.21)
actionpack (= 3.2.21)
actionmailer (3.2.19)
actionpack (= 3.2.19)
mail (~> 2.5.4)
actionpack (3.2.21)
activemodel (= 3.2.21)
activesupport (= 3.2.21)
actionpack (3.2.19)
activemodel (= 3.2.19)
activesupport (= 3.2.19)
builder (~> 3.0.0)
erubis (~> 2.7.0)
journey (~> 1.0.4)
@ -121,15 +127,15 @@ GEM
sprockets (~> 2.2.1)
active_model_serializers (0.9.0)
activemodel (>= 3.2)
activemodel (3.2.21)
activesupport (= 3.2.21)
activemodel (3.2.19)
activesupport (= 3.2.19)
builder (~> 3.0.0)
activerecord (3.2.21)
activemodel (= 3.2.21)
activesupport (= 3.2.21)
activerecord (3.2.19)
activemodel (= 3.2.19)
activesupport (= 3.2.19)
arel (~> 3.0.2)
tzinfo (~> 0.3.29)
activesupport (3.2.21)
activesupport (3.2.19)
i18n (~> 0.6, >= 0.6.4)
multi_json (~> 1.0)
addressable (2.3.6)
@ -140,7 +146,7 @@ GEM
descendants_tracker (~> 0.0.4)
ice_nine (~> 0.11.0)
thread_safe (~> 0.3, >= 0.3.1)
backports (2.8.2)
backports (3.6.4)
builder (3.0.4)
bunny (0.8.0)
celluloid (0.12.0)
@ -172,10 +178,11 @@ GEM
descendants_tracker (0.0.4)
thread_safe (~> 0.3, >= 0.3.1)
diff-lcs (1.2.5)
docile (1.1.5)
dotenv (0.7.0)
equalizer (0.0.9)
erubis (2.7.0)
eventmachine (1.0.3)
eventmachine (1.0.4)
factory_girl (2.4.2)
activesupport
faraday (0.9.1)
@ -184,7 +191,7 @@ GEM
foreman (0.64.0)
dotenv (~> 0.7.0)
thor (>= 0.13.6)
gh (0.13.2)
gh (0.13.3)
addressable
backports
faraday (~> 0.8)
@ -195,10 +202,10 @@ GEM
hike (1.2.3)
hitimes (1.2.2)
httpclient (2.3.4.1)
i18n (0.7.0)
ice_nine (0.11.1)
i18n (0.6.11)
ice_nine (0.11.0)
journey (1.0.4)
json (1.8.2)
json (1.8.1)
kgio (2.9.2)
listen (1.0.3)
rb-fsevent (>= 0.9.3)
@ -222,8 +229,6 @@ GEM
metaclass (~> 0.0.1)
multi_json (1.10.1)
multipart-post (2.0.0)
mustermann (0.4.0)
tool (~> 0.2)
net-http-persistent (2.9.4)
net-http-pipeline (1.0.1)
pg (0.13.2)
@ -244,11 +249,11 @@ GEM
rack
rack-ssl (1.3.4)
rack
rack-test (0.6.3)
rack-test (0.6.2)
rack (>= 1.0)
railties (3.2.21)
actionpack (= 3.2.21)
activesupport (= 3.2.21)
railties (3.2.19)
actionpack (= 3.2.19)
activesupport (= 3.2.19)
rack-ssl (~> 1.3.2)
rake (>= 0.8.7)
rdoc (~> 3.4)
@ -263,7 +268,7 @@ GEM
rdoc (3.12.2)
json (~> 1.4)
redcarpet (2.3.0)
redis (3.2.0)
redis (3.1.0)
redis-namespace (1.5.1)
redis (~> 3.0, >= 3.0.4)
rerun (0.8.2)
@ -291,6 +296,11 @@ GEM
simple_states (1.0.1)
activesupport
hashr (~> 0.0.10)
simplecov (0.9.1)
docile (~> 1.1.0)
multi_json (~> 1.0)
simplecov-html (~> 0.8.0)
simplecov-html (0.8.0)
sinatra (1.4.5)
rack (~> 1.4)
rack-protection (~> 1.4)
@ -305,7 +315,7 @@ GEM
skylight (0.6.0.beta.1)
activesupport (>= 3.0.0)
slop (3.6.0)
sprockets (2.2.3)
sprockets (2.2.2)
hike (~> 1.2)
multi_json (~> 1.0)
rack (~> 1.0)
@ -335,7 +345,7 @@ GEM
raindrops (~> 0.7)
useragent (0.10.0)
uuidtools (2.1.5)
virtus (1.0.4)
virtus (1.0.3)
axiom-types (~> 0.1)
coercible (~> 1.0)
descendants_tracker (~> 0.0, >= 0.0.3)
@ -357,6 +367,7 @@ DEPENDENCIES
metriks-librato_metrics!
micro_migrations
mocha (~> 0.12)
mustermann!
pry
rack-attack
rack-cache!
@ -367,9 +378,10 @@ DEPENDENCIES
rspec (~> 2.13)
s3!
sentry-raven!
simplecov
sinatra
sinatra-contrib
skylight
skylight (~> 0.6.0.beta.1)
travis-api!
travis-config (~> 0.1.0)
travis-core!

View File

@ -3,7 +3,6 @@ require 'travis'
require 'travis/model'
require 'travis/support/amqp'
require 'travis/states_cache'
require 'backports'
require 'rack'
require 'rack/protection'
require 'rack/contrib'
@ -22,6 +21,7 @@ require 'travis/support/log_subscriber/active_record_metrics'
require 'fileutils'
require 'travis/api/instruments'
require 'travis/api/v2/http'
require 'travis/api/v3'
require 'travis/api/app/stack_instrumentation'
# Rack class implementing the HTTP API.
@ -115,11 +115,17 @@ module Travis::Api
env['travis.global_prefix'] = env['SCRIPT_NAME']
end
use Travis::Api::App::Middleware::ScopeCheck
use Travis::Api::App::Middleware::Logging
use Travis::Api::App::Middleware::Metriks
use Travis::Api::App::Middleware::Rewrite
use Travis::Api::App::Middleware::ScopeCheck
use Travis::Api::App::Middleware::UserAgentTracker
use Travis::Api::App::Middleware::Metriks
# if this is a v3 API request, ignore everything after
use Travis::API::V3::OptIn
# rewrite should come after V3 hook
use Travis::Api::App::Middleware::Rewrite
SettingsEndpoint.subclass :env_vars
if Travis.config.endpoints.ssh_key
@ -195,8 +201,8 @@ module Travis::Api
end
def self.load_endpoints
Backports.require_relative_dir 'app/middleware'
Backports.require_relative_dir 'app/endpoint'
Dir.glob("#{__dir__}/app/middleware/*.rb").each { |f| require f[%r[(?<=lib/).+(?=\.rb$)]] }
Dir.glob("#{__dir__}/app/endpoint/*.rb").each { |f| require f[%r[(?<=lib/).+(?=\.rb$)]] }
end
def self.setup_endpoints

View File

@ -17,7 +17,7 @@ class Travis::Api::App
# Landing point. Redirects web browsers to [API documentation](#/docs/).
get '/' do
pass if settings.disable_root_endpoint?
redirect to('/docs/') if request.preferred_type('application/json', 'text/html') == 'text/html'
redirect to('/docs/') if request.preferred_type('application/json', 'application/json-home', 'text/html') == 'text/html'
{ 'hello' => 'world' }
end

View File

@ -3,6 +3,6 @@ require 'travis/api/app'
class Travis::Api::App
# Namespace for Sinatra extensions.
module Extensions
Backports.require_relative_dir 'extensions'
Dir.glob("#{__dir__}/extensions/*.rb").each { |f| require f[%r[(?<=lib/).+(?=\.rb$)]] }
end
end

View File

@ -3,6 +3,6 @@ require 'travis/api/app'
class Travis::Api::App
# Namespace for helpers.
module Helpers
Backports.require_relative_dir 'helpers'
Dir.glob("#{__dir__}/helpers/*.rb").each { |f| require f[%r[(?<=lib/).+(?=\.rb$)]] }
end
end

View File

@ -1,4 +1,5 @@
require 'travis/api/app'
require 'travis/api/app/helpers/accept'
class Travis::Api::App
module Helpers

27
lib/travis/api/v3.rb Normal file
View File

@ -0,0 +1,27 @@
module Travis
module API
module V3
V3 = self
def load_dir(dir, recursive: true)
Dir.glob("#{dir}/*.rb").each { |f| require f[%r[(?<=lib/).+(?=\.rb$)]] }
Dir.glob("#{dir}/*").each { |dir| load_dir(dir) } if recursive
end
def response(payload, headers = {}, content_type: 'application/json'.freeze, status: 200)
payload = JSON.pretty_generate(payload) unless payload.is_a? String
headers = { 'Content-Type'.freeze => content_type, 'Content-Length'.freeze => payload.bytesize.to_s }.merge!(headers)
[status, headers, [payload] ]
end
extend self
load_dir("#{__dir__}/v3")
ClientError = Error .create(status: 400)
NotFound = ClientError .create(:resource, status: 404, template: '%s not found (or insufficient access)')
EnitityMissing = NotFound .create(type: 'not_found')
WrongCredentials = ClientError .create('access denied', status: 403)
WrongParams = ClientError .create('wrong parameters')
end
end
end

View File

@ -0,0 +1,15 @@
module Travis::API::V3
module AccessControl
REGISTER = {}
def self.new(env)
type, payload = env['HTTP_AUTHORIZATION'.freeze].to_s.split(" ", 2)
payload &&= payload.unpack(?m.freeze).first if type == 'basic'.freeze
payload &&= type == 'token'.freeze ? payload.gsub(/^"(.+)"$/, '\1'.freeze) : payload.split(?:.freeze)
modes = REGISTER.fetch(type, [])
access_control = modes.inject(nil) { |current, mode| current || mode.for_request(type, payload, env) }
raise WrongCredentials unless access_control
access_control
end
end
end

View File

@ -0,0 +1,12 @@
require 'travis/api/v3/access_control/generic'
module Travis::API::V3
class AccessControl::Anonymous < AccessControl::Generic
# use when Authorization header is not set
auth_type(nil)
def self.for_request(*)
new
end
end
end

View File

@ -0,0 +1,25 @@
require 'travis/api/v3/access_control/generic'
module Travis::API::V3
class AccessControl::Application < AccessControl::Generic
attr_reader :application_name, :config, :user
def initialize(application_name, user: nil)
@application_name = application_name
@config = Travis.config.applications[application_name]
@user = user
raise ArgumentError, 'unknown application %p' % application_name unless config
raise ArgumentError, 'cannot use %p without a user' % application_name if config.requires_user and not user
end
protected
def logged_in?
!!user
end
def full_access?
config.full_access
end
end
end

View File

@ -0,0 +1,48 @@
module Travis::API::V3
class AccessControl::Generic
def self.for_request(type, payload, env)
end
def self.auth_type(*list)
list.each { |e| (AccessControl::REGISTER[e] ||= []) << self }
end
def visible?(object)
full_access? or dispatch(object)
end
protected
def repository_visible?(repository)
return true if unrestricted_api? and not repository.private?
private_repository_visible?(repository)
end
def private_repository_visible?(repository)
false
end
def full_access?
false
end
def logged_in?
false
end
def public_api?
!Travis.config.private_api
end
def unrestricted_api?
full_access? or logged_in? or public_api?
end
private
def dispatch(object, method = caller_locations.first.base_label)
method = object.class.name.underscore + ?_.freeze + method
send(method, object) if respond_to?(method, true)
end
end
end

View File

@ -0,0 +1,27 @@
require 'travis/api/app/access_token'
require 'travis/api/v3/access_control/user'
module Travis::API::V3
# Using v2 API tokens to access v3 API.
# Allows us to later introduce a new way of storing tokens with more capabilities without API users having to know.
class AccessControl::LegacyToken < AccessControl::User
auth_type('token', 'basic')
def self.for_request(type, payload, env)
payload = paylad.first if payload.is_a? Array
token = Travis::Api::App::AccessToken.find_by_token(payload)
new(token) if token
end
def initialize(token)
@token = token
super(token.user)
end
protected
def permission?(action, id)
super if @token.scopes.include? :private
end
end
end

View File

@ -0,0 +1,19 @@
require 'travis/api/v3/access_control/generic'
module Travis::API::V3
class AccessControl::Scoped < AccessControl::Generic
attr_accessor :unscoped, :owner_name, :name
def initialize(scope, unscoped)
@owner_name, @name = scope.split(?/.freeze, 2)
@unscoped = unscoped
end
protected
def private_repository_visible?(repository)
return false if name and repository.name != name
unscoped.visible?(repository) if repository.owner_name == owner_name
end
end
end

View File

@ -0,0 +1,58 @@
require 'travis/api/v3/access_control/generic'
require 'travis/api/app/access_token'
require 'digest/sha1'
require 'openssl'
module Travis::API::V3
# Support signed requests to not expose the secret to an untrusted environment.
class AccessControl::Signature < AccessControl::Generic
auth_type('signature')
def self.for_request(type, payload, env)
*args, signature = payload
options = Hash[args.map { |a| a.split(?=.freeze, 2) }]
challenge = ""
if github_id = options[?u.freeze]
return unless user = ::User.find_by_github_id(github_id)
end
if application = options[?a.freeze]
return unless Travis.config.applications and app_config = Travis.config.applications[application]
end
if c = options[?c.freeze]
challenge << env['REQUEST_METHOD'.freeze] << "\n".freeze if c.include?(?m.freeze)
challenge << env['SCRIPT_NAME'.freeze] << env['PATH_INFO'.freeze] << "\n" if c.include?(?p.freeze)
end
challenge << app_config[:secret] if app_config and user
challenge << args.join(?:.freeze)
if app_config
control = AccessControl::Application.new(application, user: user)
secrets = user ? secrets_for(user) : [app_config[:secret]]
else
control = AccessControl::User.new(user)
secrets = secrets_for(user)
end
if scope = options[?s.freeze]
control &&= AccessControl::Scoped.new(scope, control)
end
control if secrets.any? { |secret| signed(challenge, secret) == signature }
end
def self.secrets_for(user)
[
Travis::Api::App::AccessToken.new(user: user, app_id: 1), # generated from github token
Travis::Api::App::AccessToken.new(user: user, app_id: 0) # used by web
]
end
def self.signed(challenge, secret)
OpenSSL::HMAC.hexdigest('sha256'.freeze, secret, challenge)
end
end
end

View File

@ -0,0 +1,24 @@
require 'travis/api/v3/access_control/generic'
module Travis::API::V3
class AccessControl::User < AccessControl::Generic
attr_reader :user, :permissions
def initialize(user)
@user = user
@permissions = user.permissions.where(user_id: user.id)
super()
end
protected
def private_repository_visible?(repository)
permission?(:pull, repository)
end
def permission?(type, id)
id = id.id if id.is_a? ::Repository
permissions.where(type => true, :repository_id => id).any?
end
end
end

View File

@ -0,0 +1,38 @@
require 'rack/utils'
module Travis::API::V3
class Error < StandardError
def self.create(default_message = nil, **options)
options[:default_message] = default_message if default_message
Class.new(self) { options.each { |key, value| define_singleton_method(key) { value } } }
end
def self.status
500
end
def self.type
@type ||= name[/[^:]+$/].underscore
end
def self.template
'%s'.freeze
end
def self.default_message
@default_message ||= Rack::Utils::HTTP_STATUS_CODES.fetch(status, 'unknown error'.freeze).downcase
end
attr_accessor :status, :type, :payload
def initialize(message = self.class.default_message, status: self.class.status, type: self.class.type, **payload)
if message.is_a? Symbol
payload[:resource_type] ||= message
message = self.class.template % message
end
self.status, self.type, self.payload = status, type, payload
super(message)
end
end
end

View File

@ -0,0 +1,64 @@
module Travis::API::V3
class OptIn
attr_reader :legacy_stack, :prefix, :router, :accept, :version_header
def initialize(legacy_stack, prefix: '/v3', router: Router.new, accept: 'application/vnd.travis-ci.3+', version_header: 'Travis-API-Version')
@legacy_stack = legacy_stack
@prefix = prefix
@router = router
@accept = accept
@version_header = "HTTP_#{version_header.upcase.gsub(/\W/, '_')}"
end
def call(env)
return redirect(env) if redirect?(env)
if matched = matching_env(env)
result = @router.call(matched)
result, missing = nil, result if cascade?(*result)
end
result = result || legacy_stack.call(env)
pick(result, missing)
end
def pick(result, missing)
return result if missing.nil?
return result if result[0] != 404
missing
end
def redirect?(env)
env['PATH_INFO'.freeze] == prefix
end
def redirect(env)
[307, {'Location'.freeze => env['SCRIPT_NAME'.freeze] + prefix + ?/.freeze, 'Conent-Type'.freeze => 'text/plain'.freeze}, []]
end
def cascade?(status, headers, body)
status % 100 == 4 and headers['X-Cascade'.freeze] == 'pass'.freeze
end
def matching_env(env)
for_v3 = from_prefix(env) || from_accept(env) || from_version_header(env)
for_v3 == true ? env : for_v3
end
def from_prefix(env)
return unless prefix and env['PATH_INFO'.freeze].start_with?(prefix + ?/.freeze)
env.merge({
'SCRIPT_NAME'.freeze => env['SCRIPT_NAME'.freeze] + prefix,
'PATH_INFO'.freeze => env['PATH_INFO'.freeze][prefix.size..-1]
})
end
def from_accept(env)
env['HTTP_ACCEPT'.freeze].include?(accept) if accept and env.include?('HTTP_ACCEPT'.freeze)
end
def from_version_header(env)
env[version_header] == '3'.freeze if version_header and env.include?(version_header)
end
end
end

View File

@ -0,0 +1,14 @@
module Travis::API::V3
module Renderer
extend self
def [](key)
return key if key.respond_to? :render
const_get(key.to_s.camelize)
end
def format_date(date)
date && date.strftime('%Y-%m-%dT%H:%M:%SZ')
end
end
end

View File

@ -0,0 +1,14 @@
module Travis::API::V3
module Renderer::Error
extend self
def render(error)
{
:@type => 'error'.freeze,
:error_type => error.type,
:error_message => error.message,
**error.payload
}
end
end
end

View File

@ -0,0 +1,33 @@
module Travis::API::V3
module Renderer::Repository
DIRECT_ATTRIBUTES = %i[id name slug description github_language private]
extend self
def render(repository)
{ :@type => 'repository'.freeze, **direct_attributes(repository), **nested_resources(repository) }
end
def direct_attributes(repository)
DIRECT_ATTRIBUTES.map { |a| [a, repository.public_send(a)] }.to_h
end
def nested_resources(repository)
{
owner: {
:@type => repository.owner_type.downcase,
:id => repository.owner_id,
:login => repository.owner_name
},
last_build: {
:@type => 'build'.freeze,
:id => repository.last_build_id,
:number => repository.last_build_number,
:state => repository.last_build_state.to_s,
:duration => repository.last_build_duration,
:started_at => Renderer.format_date(repository.last_build_started_at),
:finished_at => Renderer.format_date(repository.last_build_finished_at),
}
}
end
end
end

View File

@ -0,0 +1,28 @@
module Travis::API::V3
class Result
attr_accessor :type, :resource
def initialize(type, resource = [])
@type, @resource = type, resource
end
def respond_to_missing?(method, *)
super or method.to_sym == type.to_sym
end
def <<(value)
resource << value
self
end
def render
Renderer[type].render(resource)
end
def method_missing(method, *args)
return super unless method.to_sym == type.to_sym
raise ArgumentError, 'wrong number of arguments (1 for 0)'.freeze if args.any?
resource
end
end
end

View File

@ -0,0 +1,49 @@
module Travis::API::V3
class Router
include Travis::API::V3
attr_accessor :routes
def initialize(routes = Routes)
@routes = routes
routes.draw_routes
end
def call(env)
return service_index(env) if env['PATH_INFO'.freeze] == ?/.freeze
access_control = AccessControl.new(env)
factory, params = routes.factory_for(env['REQUEST_METHOD'.freeze], env['PATH_INFO'.freeze])
env_params = params(env)
raise NotFound unless factory
service = factory.new(access_control, env_params.merge(params))
result = service.run
render(result, env_params)
rescue Error => error
result = Result.new(:error, error)
V3.response(result.render, 'X-Cascade'.freeze => 'pass'.freeze, status: error.status)
end
def render(result, env_params)
V3.response(result.render)
end
def service_index(env)
ServiceIndex.for(env, routes).render(env)
end
def params(env)
request = Rack::Request.new(env)
params = request.params
media_type = request.media_type
if media_type == 'application/json'.freeze or media_type == 'text/json'.freeze
request.body.rewind
json_params = env['travis.input.json'.freeze] ||= JSON.load(request.body)
params.merge! json_params if json_params.is_a? Hash
end
params
end
end
end

View File

@ -0,0 +1,11 @@
module Travis::API::V3
module Routes
require 'travis/api/v3/routes/dsl'
extend DSL
resource :repository do
route '/repo/:id'
get :find_repository
end
end
end

View File

@ -0,0 +1,63 @@
require 'travis/api/v3/routes/resource'
module Travis::API::V3
module Routes::DSL
def routes
@routes ||= {}
end
def resources
@resources ||= []
end
def current_resource
@current_resource ||= nil
end
def resource(type, &block)
resource = Routes::Resource.new(type)
with_resource(resource, &block)
resources << resource
end
def with_resource(resource)
resource_was, @current_resource = current_resource, resource
yield
ensure
@current_resource = resource_was
end
def route(value)
current_resource.route = value
end
def get(*args)
current_resource.add_service('GET'.freeze, *args)
end
def post(*args)
current_resource.add_service('POST'.freeze, *args)
end
def draw_routes
resources.each do |resource|
prefix = resource.route
resource.services.each do |(request_method, sub_route), service|
route = sub_route ? prefix + sub_route : prefix
routes[route] ||= {}
routes[route][request_method] = Services[service]
end
end
self.routes.replace(routes)
end
def factory_for(request_method, path)
routes.each do |route, method_map|
next unless params = route.params(path)
raise MethodNotAllowed unless factory = method_map[request_method]
return [factory, params]
end
nil # nothing matched
end
end
end

View File

@ -0,0 +1,21 @@
require 'mustermann'
module Travis::API::V3
class Routes::Resource
attr_accessor :identifier, :route, :services
def initialize(identifier)
@identifier = identifier
@services = {}
end
def add_service(request_method, service, sub_route = nil)
sub_route &&= Mustermann.new(sub_route)
services[[request_method, sub_route]] = service
end
def route=(value)
@route = value ? Mustermann.new(value) : value
end
end
end

View File

@ -0,0 +1,33 @@
module Travis::API::V3
class Service
def self.required_params
@required_params ||= []
end
def self.params(*list, optional: false)
@params ||= []
list.each do |param|
param = param.to_s
define_method(param) { params[param] }
required_params << param unless optional
@params << param
end
@params
end
attr_accessor :access_control, :params
def initialize(access_control, params)
@access_control = access_control
@params = params
end
def required_params?
required_params.all? { |param| params.include? param }
end
def required_params
self.class.required_params
end
end
end

View File

@ -0,0 +1,90 @@
module Travis::API::V3
class ServiceIndex
ALLOW_POST = ['application/json', 'application/x-www-form-urlencoded', 'multipart/form-data']
@index_cache = {}
def self.for(env, routes)
access_factory = AccessControl.new(env).class
prefix = env['SCRIPT_NAME'.freeze]
@index_cache[[access_factory, routes, prefix]] ||= new(access_factory, routes, prefix)
end
attr_reader :access_factory, :routes, :json_home_response, :json_response, :prefix
def initialize(access_factory, routes, prefix)
@prefix = prefix || ''
@access_factory, @routes = access_factory, routes
@json_response = V3.response(render_json, content_type: 'application/json'.freeze)
@json_home_response = V3.response(render_json_home, content_type: 'application/json-home'.freeze)
end
def render(env)
json_home?(env) ? json_home_response : json_response
end
def render_json
resources = { }
routes.resources.each do |resource|
resources[resource.identifier] ||= {}
resource.services.each do |(request_method, sub_route), service|
service &&= service.to_s.sub(/_#{resource.identifier}$/, ''.freeze)
list = resources[resource.identifier][service] ||= []
pattern = sub_route ? resource.route + sub_route : resource.route
pattern.to_templates.each do |template|
list << { 'request-method'.freeze => request_method, 'uri-template'.freeze => prefix + template }
end
end
end
{ resources: resources }
end
def render_json_home
relations = {}
routes.resources.each do |resource|
resource.services.each do |(request_method, sub_route), service|
service &&= service.to_s.sub(/_#{resource.identifier}$/, ''.freeze)
pattern = sub_route ? resource.route + sub_route : resource.route
relation = "http://schema.travis-ci.com/rel/#{resource.identifier}/#{service}"
pattern.to_templates.each do |template|
relations[relation] ||= {}
relations[relation][template] ||= { allow: [], vars: template.scan(/{\+?([^}]+)}/).flatten }
relations[relation][template][:allow] << request_method
end
end
end
nested_relations = {}
relations.delete_if do |relation, request_map|
next if request_map.size < 2
common_vars = request_map.values.map { |e| e[:vars] }.inject(:&)
request_map.each do |template, payload|
special_vars = payload[:vars] - common_vars
schema = special_vars.any? ? "#{relation}/by_#{special_vars.join(?_)}" : relation
nested_relations[schema] = { template => payload }
end
end
relations.merge! nested_relations
resources = relations.map do |relation, payload|
template, payload = payload.first
hints = { 'allow' => payload[:allow] }
hints['accept-post'] = ALLOW_POST if payload[:allow].include? 'POST'
hints['accept-patch'] = ALLOW_POST if payload[:allow].include? 'PATCH'
hints['accept-put'] = ALLOW_POST if payload[:allow].include? 'PUT'
hints['representations'] = ['application/json', 'application/vnd.travis-ci.3+json']
[relation, {
'href-template' => prefix + template,
'href-vars' => Hash[payload[:vars].map { |var| [var, "http://schema.travis-ci.com/param/#{var}"] }],
'hints' => hints
}]
end
{ resources: Hash[resources] }
end
def json_home?(env)
env['HTTP_ACCEPT'.freeze] == 'application/json-home'.freeze
end
end
end

View File

@ -0,0 +1,8 @@
module Travis::API::V3
module Services
def self.[](key)
return key if key.respond_to? :new
const_get(key.to_s.camelize)
end
end
end

View File

@ -0,0 +1,22 @@
module Travis::API::V3
class Services::FindRepository < Service
params :id, :github_id, :slug, optional: true
def run
raise NotFound, :repository unless repository and access_control.visible? repository
Result.new(:repository, repository)
end
def repository
raise EntityMissing, :repository if defined?(@repository) and @repository.nil?
@repository ||= find_repository
end
def find_repository
return ::Repository.find_by_id(id) if id
return ::Repository.find_by_github_id(github_id) if github_id
return ::Repository.by_slug(slug).first if slug
raise WrongParams
end
end
end

View File

@ -1,5 +1,7 @@
ENV['RACK_ENV'] = ENV['RAILS_ENV'] = ENV['ENV'] = 'test'
require 'support/coverage'
require 'rspec'
require 'database_cleaner'
require 'sinatra/test_helpers'

7
spec/support/coverage.rb Normal file
View File

@ -0,0 +1,7 @@
require 'simplecov'
SimpleCov.start do
coverage_dir '.coverage'
add_filter "/spec/"
add_group "v3", "lib/travis/api/v3"
end

14
spec/v3/result_spec.rb Normal file
View File

@ -0,0 +1,14 @@
require 'spec_helper'
describe Travis::API::V3::Result do
subject(:result) { described_class.new(:example) }
example { expect(result.type) .to be == :example }
example { expect(result.resource) .to be == [] }
example { expect(result.example) .to be == [] }
example do
result << 42
expect(result.example).to include(42)
end
end

View File

@ -0,0 +1,44 @@
require 'spec_helper'
describe Travis::API::V3::ServiceIndex do
let(:headers) {{ }}
let(:path) { '/' }
let(:json) { JSON.load(response.body) }
let(:response) { get(path, {}, headers) }
describe "custom json entry point" do
let(:expected_resources) {{
"repository" => {
"find" => [{"request-method"=>"GET", "uri-template"=>"#{path}repo/{id}"}]
}
}}
describe 'with /v3 prefix' do
let(:path) { '/v3/' }
specify(:resources) { expect(json['resources']).to be == expected_resources }
end
describe 'with Accept header' do
let(:headers) { { 'HTTP_ACCEPT' => 'application/vnd.travis-ci.3+json' } }
specify(:resources) { expect(json['resources']).to be == expected_resources }
end
describe 'with Travis-API-Version header' do
let(:headers) { { 'HTTP_TRAVIS_API_VERSION' => '3' } }
specify(:resources) { expect(json['resources']).to be == expected_resources }
end
end
describe "json-home document" do
describe 'with /v3 prefix' do
let(:headers) { { 'HTTP_ACCEPT' => 'application/json-home' } }
let(:path) { '/v3/' }
specify(:resources) { expect(json['resources']).to include("http://schema.travis-ci.com/rel/repository/find") }
end
describe 'with Travis-API-Version header' do
let(:headers) { { 'HTTP_ACCEPT' => 'application/json-home', 'HTTP_TRAVIS_API_VERSION' => '3' } }
specify(:resources) { expect(json['resources']).to include("http://schema.travis-ci.com/rel/repository/find") }
end
end
end

View File

@ -0,0 +1,211 @@
require 'spec_helper'
describe Travis::API::V3::Services::FindRepository do
let(:repo) { Repository.by_slug('svenfuchs/minimal').first }
describe "public repository" do
before { get("/v3/repo/#{repo.id}") }
example { expect(last_response).to be_ok }
example { expect(JSON.load(body)).to be == {
"@type" => "repository",
"id" => repo.id,
"name" => "minimal",
"slug" => "svenfuchs/minimal",
"description" => nil,
"github_language" => nil,
"private" => false,
"owner" => {
"@type" => "user",
"id" => repo.owner_id,
"login" => "svenfuchs" },
"last_build" => {
"@type" => "build",
"id" => repo.last_build_id,
"number" => "2",
"state" => "passed",
"duration" => nil,
"started_at" => "2010-11-12T12:30:00Z",
"finished_at" => "2010-11-12T12:30:20Z"}
}}
end
describe "missing repository" do
before { get("/v3/repo/999999999999999") }
example { expect(last_response).to be_not_found }
example { expect(JSON.load(body)).to be == {
"@type" => "error",
"error_type" => "not_found",
"error_message" => "repository not found (or insufficient access)",
"resource_type" => "repository"
}}
end
describe "public repository, private API" do
before { Travis.config.private_api = true }
before { get("/v3/repo/#{repo.id}") }
after { Travis.config.private_api = true }
example { expect(last_response).to be_not_found }
example { expect(JSON.load(body)).to be == {
"@type" => "error",
"error_type" => "not_found",
"error_message" => "repository not found (or insufficient access)",
"resource_type" => "repository"
}}
end
describe "private repository, not authenticated" do
before { repo.update_attribute(:private, true) }
before { get("/v3/repo/#{repo.id}") }
before { repo.update_attribute(:private, false) }
example { expect(last_response).to be_not_found }
example { expect(JSON.load(body)).to be == {
"@type" => "error",
"error_type" => "not_found",
"error_message" => "repository not found (or insufficient access)",
"resource_type" => "repository"
}}
end
describe "private repository, private API, authenticated as user with access" do
let(:token) { Travis::Api::App::AccessToken.create(user: repo.owner, app_id: 1) }
let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }}
before { Permission.create(repository: repo, user: repo.owner, pull: true) }
before { repo.update_attribute(:private, true) }
before { get("/v3/repo/#{repo.id}", {}, headers) }
before { repo.update_attribute(:private, false) }
example { expect(last_response).to be_ok }
example { expect(JSON.load(body)).to be == {
"@type" => "repository",
"id" => repo.id,
"name" => "minimal",
"slug" => "svenfuchs/minimal",
"description" => nil,
"github_language" => nil,
"private" => true,
"owner" => {
"@type" => "user",
"id" => repo.owner_id,
"login" => "svenfuchs" },
"last_build" => {
"@type" => "build",
"id" => repo.last_build_id,
"number" => "2",
"state" => "passed",
"duration" => nil,
"started_at" => "2010-11-12T12:30:00Z",
"finished_at" => "2010-11-12T12:30:20Z"}
}}
end
describe "private repository, private API, authenticated as user without access" do
let(:token) { Travis::Api::App::AccessToken.create(user: User.find(2), app_id: 1) }
let(:headers) {{ 'HTTP_AUTHORIZATION' => "token #{token}" }}
before { repo.update_attribute(:private, true) }
before { get("/v3/repo/#{repo.id}", {}, headers) }
before { repo.update_attribute(:private, false) }
example { expect(last_response).to be_not_found }
example { expect(JSON.load(body)).to be == {
"@type" => "error",
"error_type" => "not_found",
"error_message" => "repository not found (or insufficient access)",
"resource_type" => "repository"
}}
end
describe "private repository, authenticated as internal application with full access" do
let(:app_name) { 'travis-example' }
let(:app_secret) { '12345678' }
let(:sign_opts) { "a=#{app_name}" }
let(:signature) { OpenSSL::HMAC.hexdigest('sha256', app_secret, sign_opts) }
let(:headers) {{ 'HTTP_AUTHORIZATION' => "signature #{sign_opts}:#{signature}" }}
before { Travis.config.applications = { app_name => { full_access: true, secret: app_secret }}}
before { repo.update_attribute(:private, true) }
before { get("/v3/repo/#{repo.id}", {}, headers) }
before { repo.update_attribute(:private, false) }
example { expect(last_response).to be_ok }
example { expect(JSON.load(body)).to be == {
"@type" => "repository",
"id" => repo.id,
"name" => "minimal",
"slug" => "svenfuchs/minimal",
"description" => nil,
"github_language" => nil,
"private" => true,
"owner" => {
"@type" => "user",
"id" => repo.owner_id,
"login" => "svenfuchs" },
"last_build" => {
"@type" => "build",
"id" => repo.last_build_id,
"number" => "2",
"state" => "passed",
"duration" => nil,
"started_at" => "2010-11-12T12:30:00Z",
"finished_at" => "2010-11-12T12:30:20Z"}
}}
end
describe "private repository, authenticated as internal application with full access, but scoped to a different org" do
let(:app_name) { 'travis-example' }
let(:app_secret) { '12345678' }
let(:sign_opts) { "a=#{app_name}:s=travis-pro" }
let(:signature) { OpenSSL::HMAC.hexdigest('sha256', app_secret, sign_opts) }
let(:headers) {{ 'HTTP_AUTHORIZATION' => "signature #{sign_opts}:#{signature}" }}
before { Travis.config.applications = { app_name => { full_access: true, secret: app_secret }}}
before { repo.update_attribute(:private, true) }
before { get("/v3/repo/#{repo.id}", {}, headers) }
before { repo.update_attribute(:private, false) }
example { expect(last_response).to be_not_found }
example { expect(JSON.load(body)).to be == {
"@type" => "error",
"error_type" => "not_found",
"error_message" => "repository not found (or insufficient access)",
"resource_type" => "repository"
}}
end
describe "private repository, authenticated as internal application with full access, scoped to the right org" do
let(:app_name) { 'travis-example' }
let(:app_secret) { '12345678' }
let(:sign_opts) { "a=#{app_name}:s=#{repo.owner_name}" }
let(:signature) { OpenSSL::HMAC.hexdigest('sha256', app_secret, sign_opts) }
let(:headers) {{ 'HTTP_AUTHORIZATION' => "signature #{sign_opts}:#{signature}" }}
before { Travis.config.applications = { app_name => { full_access: true, secret: app_secret }}}
before { repo.update_attribute(:private, true) }
before { get("/v3/repo/#{repo.id}", {}, headers) }
before { repo.update_attribute(:private, false) }
example { expect(last_response).to be_ok }
example { expect(JSON.load(body)).to be == {
"@type" => "repository",
"id" => repo.id,
"name" => "minimal",
"slug" => "svenfuchs/minimal",
"description" => nil,
"github_language" => nil,
"private" => true,
"owner" => {
"@type" => "user",
"id" => repo.owner_id,
"login" => "svenfuchs" },
"last_build" => {
"@type" => "build",
"id" => repo.last_build_id,
"number" => "2",
"state" => "passed",
"duration" => nil,
"started_at" => "2010-11-12T12:30:00Z",
"finished_at" => "2010-11-12T12:30:20Z"}
}}
end
end

View File

@ -73,6 +73,7 @@ Gem::Specification.new do |s|
"config/database.yml",
"config/puma-config.rb",
"config/unicorn.rb",
"lib/conditional_skylight.rb",
"lib/tasks/build_update_branch.rake",
"lib/tasks/build_update_pull_request_data.rake",
"lib/tasks/encrypt_all_data.rake",
@ -160,6 +161,29 @@ Gem::Specification.new do |s|
"lib/travis/api/v2/http/ssl_key.rb",
"lib/travis/api/v2/http/user.rb",
"lib/travis/api/v2/http/validation_error.rb",
"lib/travis/api/v3.rb",
"lib/travis/api/v3/access_control.rb",
"lib/travis/api/v3/access_control/anonymous.rb",
"lib/travis/api/v3/access_control/application.rb",
"lib/travis/api/v3/access_control/generic.rb",
"lib/travis/api/v3/access_control/legacy_token.rb",
"lib/travis/api/v3/access_control/scoped.rb",
"lib/travis/api/v3/access_control/signature.rb",
"lib/travis/api/v3/access_control/user.rb",
"lib/travis/api/v3/error.rb",
"lib/travis/api/v3/opt_in.rb",
"lib/travis/api/v3/renderer.rb",
"lib/travis/api/v3/renderer/error.rb",
"lib/travis/api/v3/renderer/repository.rb",
"lib/travis/api/v3/result.rb",
"lib/travis/api/v3/router.rb",
"lib/travis/api/v3/routes.rb",
"lib/travis/api/v3/routes/dsl.rb",
"lib/travis/api/v3/routes/resource.rb",
"lib/travis/api/v3/service.rb",
"lib/travis/api/v3/service_index.rb",
"lib/travis/api/v3/services.rb",
"lib/travis/api/v3/services/find_repository.rb",
"lib/travis/private_key.rb",
"public/favicon.ico",
"public/images/result/canceled.png",
@ -202,6 +226,7 @@ Gem::Specification.new do |s|
"spec/integration/v2_spec.backup.rb",
"spec/integration/version_spec.rb",
"spec/spec_helper.rb",
"spec/support/coverage.rb",
"spec/support/formats.rb",
"spec/support/matchers.rb",
"spec/unit/access_token_spec.rb",
@ -253,6 +278,9 @@ Gem::Specification.new do |s|
"spec/unit/middleware/user_agent_tracker_spec.rb",
"spec/unit/responders/json_spec.rb",
"spec/unit/responders/service_spec.rb",
"spec/v3/result_spec.rb",
"spec/v3/service_index_spec.rb",
"spec/v3/services/find_repository_spec.rb",
"tmp/.gitkeep",
"travis-api.gemspec"
]
@ -260,7 +288,6 @@ Gem::Specification.new do |s|
s.add_dependency 'travis-support'
s.add_dependency 'travis-core'
s.add_dependency 'backports', '~> 2.5'
s.add_dependency 'pg', '~> 0.13.2'
s.add_dependency 'thin', '~> 1.4'
s.add_dependency 'sinatra', '~> 1.3'