diff --git a/Gemfile b/Gemfile index d92fda3e..26dabac1 100644 --- a/Gemfile +++ b/Gemfile @@ -6,6 +6,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 diff --git a/Gemfile.lock b/Gemfile.lock index 933e755e..6d9c30fc 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 @@ -215,8 +222,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) @@ -350,6 +355,7 @@ DEPENDENCIES metriks-librato_metrics! micro_migrations mocha (~> 0.12) + mustermann! pry rack-attack rack-cache! diff --git a/lib/travis/api/v3.rb b/lib/travis/api/v3.rb index 3cc17fa0..3d640a5e 100644 --- a/lib/travis/api/v3.rb +++ b/lib/travis/api/v3.rb @@ -1,11 +1,19 @@ 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) + [200, headers, [payload] ] + end + extend self load_dir("#{__dir__}/v3") end diff --git a/lib/travis/api/v3/access_control/generic.rb b/lib/travis/api/v3/access_control/generic.rb index db159ebc..e9117468 100644 --- a/lib/travis/api/v3/access_control/generic.rb +++ b/lib/travis/api/v3/access_control/generic.rb @@ -31,7 +31,7 @@ module Travis::API::V3 end def public_api? - Travis.config.public_api + !Travis.config.private_api end def unrestricted_api? @@ -42,7 +42,7 @@ module Travis::API::V3 def dispatch(object, method = caller_locations.first.base_label) method = object.class.name.underscore + ?_.freeze + method - public_send(method) if respond_to?(method) + send(method, object) if respond_to?(method, true) end end end diff --git a/lib/travis/api/v3/opt_in.rb b/lib/travis/api/v3/opt_in.rb index c95bcd0c..8f43942f 100644 --- a/lib/travis/api/v3/opt_in.rb +++ b/lib/travis/api/v3/opt_in.rb @@ -2,7 +2,7 @@ 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') + 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 @@ -29,11 +29,11 @@ module Travis::API::V3 end def redirect?(env) - env['PATH_INFO'.freeze] + ?/.freeze == prefix + env['PATH_INFO'.freeze] == prefix end def redirect(env) - [307, {'Location'.freeze => env['SCRIPT_NAME'.freeze] + prefix, 'Conent-Type'.freeze => 'text/plain'.freeze}, []] + [307, {'Location'.freeze => env['SCRIPT_NAME'.freeze] + prefix + ?/.freeze, 'Conent-Type'.freeze => 'text/plain'.freeze}, []] end def cascade?(status, headers, body) @@ -46,7 +46,7 @@ module Travis::API::V3 end def from_prefix(env) - return unless prefix and env['PATH_INFO'.freeze].start_with?(prefix) + 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] 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/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 index 44cd799d..71e0b8fa 100644 --- a/lib/travis/api/v3/result.rb +++ b/lib/travis/api/v3/result.rb @@ -15,6 +15,10 @@ module Travis::API::V3 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? diff --git a/lib/travis/api/v3/router.rb b/lib/travis/api/v3/router.rb index 5b51d5e0..d522410e 100644 --- a/lib/travis/api/v3/router.rb +++ b/lib/travis/api/v3/router.rb @@ -1,29 +1,41 @@ module Travis::API::V3 class Router - not_found = '{"error":{"message":"not found"}}'.freeze - headers = { 'Content-Type'.freeze => 'application/json'.freeze, 'X-Cascade'.freeze => 'pass'.freeze, 'Content-Length'.freeze => not_found.bytesize } - NOT_FOUND = [ 404, headers, not_found ] + include Travis::API::V3 - attr_accessor :routs, :not_found + not_found = '{"error":{"message":"not found"}}'.freeze + headers = { 'Content-Type'.freeze => 'application/json'.freeze, 'X-Cascade'.freeze => 'pass'.freeze, 'Content-Length'.freeze => not_found.bytesize.to_s } + NOT_FOUND = [ 404, headers, [not_found] ] + + attr_accessor :routes, :not_found def initialize(routes = Routes, not_found: NOT_FOUND) @routes = routes @not_found = not_found + 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]) + factory, params = routes.factory_for(env['REQUEST_METHOD'.freeze], env['PATH_INFO'.freeze]) env_params = params(env) if factory service = factory.new(access_control, env_params.merge(params)) result = service.run render(result, env_params) else - not_found + NOT_FOUND end 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 diff --git a/lib/travis/api/v3/routes/dsl.rb b/lib/travis/api/v3/routes/dsl.rb index ca594929..fff4ff39 100644 --- a/lib/travis/api/v3/routes/dsl.rb +++ b/lib/travis/api/v3/routes/dsl.rb @@ -1,8 +1,11 @@ require 'travis/api/v3/routes/resource' -require 'mustermann' module Travis::API::V3 module Routes::DSL + def routes + @routes ||= {} + end + def resources @resources ||= [] end @@ -25,14 +28,36 @@ module Travis::API::V3 end def route(value) - current_resource.route = Mustermann.new(value) + current_resource.route = value end def get(*args) current_resource.add_service('GET'.freeze, *args) end - def factory_for(method, path) + 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 index 45b26841..2805f9a9 100644 --- a/lib/travis/api/v3/routes/resource.rb +++ b/lib/travis/api/v3/routes/resource.rb @@ -1,3 +1,5 @@ +require 'mustermann' + module Travis::API::V3 class Routes::Resource attr_accessor :identifier, :route, :services @@ -7,9 +9,13 @@ module Travis::API::V3 @services = {} end - def add_service(request_method, service, sub_route = '') - services[request_method] ||= {} - services[request_method][sub_route] = service + 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 index 0f0df724..df782046 100644 --- a/lib/travis/api/v3/service.rb +++ b/lib/travis/api/v3/service.rb @@ -1,6 +1,33 @@ module Travis::API::V3 class Service - def self.params(*) + 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 index d83758cf..7bc77689 100644 --- a/lib/travis/api/v3/services.rb +++ b/lib/travis/api/v3/services.rb @@ -1,4 +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