diff --git a/.gitignore b/.gitignore index 68072775..ec837dcd 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,5 @@ config/travis.yml log/ vendor config/skylight.yml +.coverage +.coverage/ diff --git a/Gemfile b/Gemfile index 7a2f174e..4c6c11ca 100644 --- a/Gemfile +++ b/Gemfile @@ -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' diff --git a/Gemfile.lock b/Gemfile.lock index 3f38a26c..80283b3f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -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! diff --git a/lib/travis/api/app.rb b/lib/travis/api/app.rb index 0f828551..42cc85f2 100644 --- a/lib/travis/api/app.rb +++ b/lib/travis/api/app.rb @@ -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 diff --git a/lib/travis/api/app/endpoint/home.rb b/lib/travis/api/app/endpoint/home.rb index bb3b3eb7..545aea41 100644 --- a/lib/travis/api/app/endpoint/home.rb +++ b/lib/travis/api/app/endpoint/home.rb @@ -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 diff --git a/lib/travis/api/app/extensions.rb b/lib/travis/api/app/extensions.rb index dd189114..d7faa8fe 100644 --- a/lib/travis/api/app/extensions.rb +++ b/lib/travis/api/app/extensions.rb @@ -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 diff --git a/lib/travis/api/app/helpers.rb b/lib/travis/api/app/helpers.rb index a009b025..e09bbd8f 100644 --- a/lib/travis/api/app/helpers.rb +++ b/lib/travis/api/app/helpers.rb @@ -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 diff --git a/lib/travis/api/app/helpers/respond_with.rb b/lib/travis/api/app/helpers/respond_with.rb index 746152fb..08182992 100644 --- a/lib/travis/api/app/helpers/respond_with.rb +++ b/lib/travis/api/app/helpers/respond_with.rb @@ -1,4 +1,5 @@ require 'travis/api/app' +require 'travis/api/app/helpers/accept' class Travis::Api::App module Helpers diff --git a/lib/travis/api/v3.rb b/lib/travis/api/v3.rb new file mode 100644 index 00000000..bd63d030 --- /dev/null +++ b/lib/travis/api/v3.rb @@ -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 diff --git a/lib/travis/api/v3/access_control.rb b/lib/travis/api/v3/access_control.rb new file mode 100644 index 00000000..37500817 --- /dev/null +++ b/lib/travis/api/v3/access_control.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/anonymous.rb b/lib/travis/api/v3/access_control/anonymous.rb new file mode 100644 index 00000000..98175a80 --- /dev/null +++ b/lib/travis/api/v3/access_control/anonymous.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/application.rb b/lib/travis/api/v3/access_control/application.rb new file mode 100644 index 00000000..47ff63c8 --- /dev/null +++ b/lib/travis/api/v3/access_control/application.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/generic.rb b/lib/travis/api/v3/access_control/generic.rb new file mode 100644 index 00000000..e9117468 --- /dev/null +++ b/lib/travis/api/v3/access_control/generic.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/legacy_token.rb b/lib/travis/api/v3/access_control/legacy_token.rb new file mode 100644 index 00000000..f07d936c --- /dev/null +++ b/lib/travis/api/v3/access_control/legacy_token.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/scoped.rb b/lib/travis/api/v3/access_control/scoped.rb new file mode 100644 index 00000000..5bf1df90 --- /dev/null +++ b/lib/travis/api/v3/access_control/scoped.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/signature.rb b/lib/travis/api/v3/access_control/signature.rb new file mode 100644 index 00000000..b07baa05 --- /dev/null +++ b/lib/travis/api/v3/access_control/signature.rb @@ -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 diff --git a/lib/travis/api/v3/access_control/user.rb b/lib/travis/api/v3/access_control/user.rb new file mode 100644 index 00000000..d6881a1f --- /dev/null +++ b/lib/travis/api/v3/access_control/user.rb @@ -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 diff --git a/lib/travis/api/v3/error.rb b/lib/travis/api/v3/error.rb new file mode 100644 index 00000000..fe098a9b --- /dev/null +++ b/lib/travis/api/v3/error.rb @@ -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 diff --git a/lib/travis/api/v3/opt_in.rb b/lib/travis/api/v3/opt_in.rb new file mode 100644 index 00000000..8f43942f --- /dev/null +++ b/lib/travis/api/v3/opt_in.rb @@ -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 diff --git a/lib/travis/api/v3/renderer.rb b/lib/travis/api/v3/renderer.rb new file mode 100644 index 00000000..caad5bad --- /dev/null +++ b/lib/travis/api/v3/renderer.rb @@ -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 diff --git a/lib/travis/api/v3/renderer/error.rb b/lib/travis/api/v3/renderer/error.rb new file mode 100644 index 00000000..d913e139 --- /dev/null +++ b/lib/travis/api/v3/renderer/error.rb @@ -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 diff --git a/lib/travis/api/v3/renderer/repository.rb b/lib/travis/api/v3/renderer/repository.rb new file mode 100644 index 00000000..f618071c --- /dev/null +++ b/lib/travis/api/v3/renderer/repository.rb @@ -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 diff --git a/lib/travis/api/v3/result.rb b/lib/travis/api/v3/result.rb new file mode 100644 index 00000000..71e0b8fa --- /dev/null +++ b/lib/travis/api/v3/result.rb @@ -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 diff --git a/lib/travis/api/v3/router.rb b/lib/travis/api/v3/router.rb new file mode 100644 index 00000000..a0fb3678 --- /dev/null +++ b/lib/travis/api/v3/router.rb @@ -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 diff --git a/lib/travis/api/v3/routes.rb b/lib/travis/api/v3/routes.rb new file mode 100644 index 00000000..519bffaa --- /dev/null +++ b/lib/travis/api/v3/routes.rb @@ -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 diff --git a/lib/travis/api/v3/routes/dsl.rb b/lib/travis/api/v3/routes/dsl.rb new file mode 100644 index 00000000..fff4ff39 --- /dev/null +++ b/lib/travis/api/v3/routes/dsl.rb @@ -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 diff --git a/lib/travis/api/v3/routes/resource.rb b/lib/travis/api/v3/routes/resource.rb new file mode 100644 index 00000000..2805f9a9 --- /dev/null +++ b/lib/travis/api/v3/routes/resource.rb @@ -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 diff --git a/lib/travis/api/v3/service.rb b/lib/travis/api/v3/service.rb new file mode 100644 index 00000000..df782046 --- /dev/null +++ b/lib/travis/api/v3/service.rb @@ -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 diff --git a/lib/travis/api/v3/service_index.rb b/lib/travis/api/v3/service_index.rb new file mode 100644 index 00000000..f3a44c67 --- /dev/null +++ b/lib/travis/api/v3/service_index.rb @@ -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 diff --git a/lib/travis/api/v3/services.rb b/lib/travis/api/v3/services.rb new file mode 100644 index 00000000..7bc77689 --- /dev/null +++ b/lib/travis/api/v3/services.rb @@ -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 diff --git a/lib/travis/api/v3/services/find_repository.rb b/lib/travis/api/v3/services/find_repository.rb new file mode 100644 index 00000000..5db62e60 --- /dev/null +++ b/lib/travis/api/v3/services/find_repository.rb @@ -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 diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e41dbdf2..ced5e7af 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -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' diff --git a/spec/support/coverage.rb b/spec/support/coverage.rb new file mode 100644 index 00000000..f5b93400 --- /dev/null +++ b/spec/support/coverage.rb @@ -0,0 +1,7 @@ +require 'simplecov' + +SimpleCov.start do + coverage_dir '.coverage' + add_filter "/spec/" + add_group "v3", "lib/travis/api/v3" +end diff --git a/spec/v3/result_spec.rb b/spec/v3/result_spec.rb new file mode 100644 index 00000000..b66945b3 --- /dev/null +++ b/spec/v3/result_spec.rb @@ -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 diff --git a/spec/v3/service_index_spec.rb b/spec/v3/service_index_spec.rb new file mode 100644 index 00000000..8faa4b24 --- /dev/null +++ b/spec/v3/service_index_spec.rb @@ -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 diff --git a/spec/v3/services/find_repository_spec.rb b/spec/v3/services/find_repository_spec.rb new file mode 100644 index 00000000..af0ce98a --- /dev/null +++ b/spec/v3/services/find_repository_spec.rb @@ -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 \ No newline at end of file diff --git a/travis-api.gemspec b/travis-api.gemspec index 66811d13..d4e1c079 100644 --- a/travis-api.gemspec +++ b/travis-api.gemspec @@ -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'