diff --git a/Gemfile b/Gemfile index 41685cd1..d300a1ac 100644 --- a/Gemfile +++ b/Gemfile @@ -28,8 +28,19 @@ group :assets do gem 'guard' end +group :development, :test do + gem 'rake', '~> 0.9.2' +end + group :development do gem 'foreman' gem 'rerun' gem 'rb-fsevent', '~> 0.9.1' end + +group :test do + gem 'rspec', '~> 2.11' + gem 'factory_girl', '~> 2.4.0' + gem 'mocha', '~> 0.12' +end + diff --git a/Gemfile.lock b/Gemfile.lock index 1963ff72..0c5ed9ff 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -60,7 +60,7 @@ GIT GIT remote: git://github.com/travis-ci/travis-core.git - revision: ddbb703d196834b7f82c6a86eff4d40b27ce588b + revision: 60f38e45ce1d739894839ef74f405348fb5f8481 branch: sf-travis-api specs: travis-core (0.0.1) @@ -149,10 +149,13 @@ GEM debugger-linecache (1.1.2) debugger-ruby_core_source (>= 1.1.1) debugger-ruby_core_source (1.1.3) + diff-lcs (1.1.3) erubis (2.7.0) eventmachine (1.0.0) execjs (1.4.0) multi_json (~> 1.0) + factory_girl (2.4.2) + activesupport faraday (0.8.4) multipart-post (~> 1.1) foreman (0.60.0) @@ -173,11 +176,14 @@ GEM i18n (>= 0.4.0) mime-types (~> 1.16) treetop (~> 1.4.8) + metaclass (0.0.1) metriks (0.9.9.1) atomic (~> 1.0) avl_tree (~> 1.1.2) hitimes (~> 1.1) mime-types (1.19) + mocha (0.12.6) + metaclass (~> 0.0.1) multi_json (1.3.6) multipart-post (1.1.5) net-http-persistent (2.7) @@ -221,6 +227,14 @@ GEM rerun (0.7.1) listen rollout (1.1.0) + rspec (2.11.0) + rspec-core (~> 2.11.0) + rspec-expectations (~> 2.11.0) + rspec-mocks (~> 2.11.0) + rspec-core (2.11.1) + rspec-expectations (2.11.3) + diff-lcs (~> 1.1.3) + rspec-mocks (2.11.3) sass (3.2.1) signature (0.1.4) simple_states (0.1.1) @@ -265,17 +279,21 @@ DEPENDENCIES coffee-script compass debugger + factory_girl (~> 2.4.0) foreman gh! guard hubble! + mocha (~> 0.12) newrelic_rpm (~> 3.3.0) pg (~> 0.13.2) rack-contrib! + rake (~> 0.9.2) rake-pipeline! rake-pipeline-web-filters! rb-fsevent (~> 0.9.1) rerun + rspec (~> 2.11) sinatra sinatra-contrib tilt diff --git a/config.ru b/config.ru index 86f23ef5..e043dd62 100644 --- a/config.ru +++ b/config.ru @@ -1,68 +1,7 @@ -require 'travis' -require 'travis/api/app' +# Make sure we set that before everything +ENV['RACK_ENV'] ||= ENV['RAILS_ENV'] || ENV['ENV'] +ENV['RAILS_ENV'] = ENV['RACK_ENV'] -env, api_endpoint, client_endpoint, run_api, watch, deflate = ENV.values_at('RACK_ENV', 'API_ENDPOINT', 'CLIENT_ENDPOINT', 'RUN_API', 'WATCH', 'DEFLATE') - -env ||= "development" -api_endpoint ||= "https://api.#{Travis.config.host}" unless run_api and run_api != '0' -run_api ||= api_endpoint.to_s.start_with? '/' -api_endpoint ||= "/api" if run_api and run_api != '0' -client_endpoint ||= "/" -watch ||= false #env == "development" -deflate ||= env == "production" - -c = proc do |value| - case value - when nil, false, '0' then "\e[31m0\e[0m" - when true, '1' then "\e[32m1\e[0m" - else "\e[33m\"#{value}\"\e[0m" - end -end - -$stderr.puts "RACK_ENV = #{c[env]}", - "API_ENDPOINT = #{c[api_endpoint]}", - "CLIENT_ENDPOINT = #{c[client_endpoint]}", - "RUN_API = #{c[run_api]}", - "WATCH = #{c[watch]}", - "DEFLATE = #{c[deflate]}" - -class EndpointSetter < Struct.new(:app, :endpoint) - DEFAULT_ENDPOINT = 'https://api.travis-ci.org' - - def call(env) - status, headers, body = app.call(env) - if endpoint != DEFAULT_ENDPOINT and headers.any? { |k,v| k.downcase == 'content-type' and v.start_with? 'text/html' } - headers.delete 'Content-Length' - body, old = [], body - old.each { |s| body << s.gsub(DEFAULT_ENDPOINT, endpoint) } - old.close if old.respond_to? :close - end - [status, headers, body] - end -end - -use Rack::SSL if env == 'production' -use Rack::Deflater if deflate and deflate != '0' - -app = proc do |env| - Rack::File.new(nil).tap { |f| f.path = 'public/index.html' }.serving(env) -end - -if run_api and run_api != '0' - map api_endpoint.gsub(/:\d+/, '') do - run Travis::Api::App.new - end -end - -map client_endpoint do - use EndpointSetter, api_endpoint - - if watch and watch != '0' - require 'rake-pipeline' - require 'rake-pipeline/middleware' - use Rake::Pipeline::Middleware, 'AssetFile' - run app - else - run Rack::Cascade.new([Rack::File.new('public'), app]) - end -end +$: << 'lib' +require 'travis/web' +run Travis::Web::App.new diff --git a/lib/travis/web.rb b/lib/travis/web.rb new file mode 100644 index 00000000..b20f4545 --- /dev/null +++ b/lib/travis/web.rb @@ -0,0 +1,5 @@ +module Travis + module Web + autoload :App, 'travis/web/app' + end +end diff --git a/lib/travis/web/app.rb b/lib/travis/web/app.rb new file mode 100644 index 00000000..f4b0b220 --- /dev/null +++ b/lib/travis/web/app.rb @@ -0,0 +1,48 @@ +require 'rack' +require 'rack/protection/path_traversal' + +module Travis::Web + class App + autoload :Api, 'travis/web/app/api' + autoload :Config, 'travis/web/app/config' + autoload :Files, 'travis/web/app/files' + autoload :Filter, 'travis/web/app/filter' + autoload :Terminal, 'travis/web/app/terminal' + autoload :Version, 'travis/web/app/version' + + Rack.autoload :SSL, 'rack/ssl' + Rack.autoload :Deflater, 'rack/deflater' + + include Terminal + + attr_accessor :app + + def initialize + config = Config.new + announce(config) + + @app = Rack::Builder.app do + use Rack::SSL if config.production? + use Rack::Protection::PathTraversal + use Rack::Deflater if config.deflate? + + # TODO this doesn't work, how can i extract this to a separate file/class + # use Travis::Web::App::Api, config if config.run_api? + if config.run_api? + require 'travis/api/app' + map config.api_endpoint do + run Travis::Api::App.new + end + end + + use Travis::Web::App::Version, config + use Travis::Web::App::Filter, config + run Travis::Web::App::Files.new + end + end + + def call(env) + app.call(env) + end + end +end diff --git a/lib/travis/web/app/api.rb b/lib/travis/web/app/api.rb new file mode 100644 index 00000000..eec7efb1 --- /dev/null +++ b/lib/travis/web/app/api.rb @@ -0,0 +1,32 @@ +require 'travis/api/app' + +class Travis::Web::App + class Api + attr_reader :app, :api, :config + + def initialize(app, config) + @app = app + @api = Travis::Api::App.new + @config = config + end + + def call(env) + path = env['PATH_INFO'] + if matches?(path) + api.call(env.merge('PATH_INFO' => api_path(path))) + else + app.call(env) + end + end + + def matches?(path) + # TODO there's a redirect through /auth/post_message which doesn't have the /api + # prefix. is that safe_redirect in travis-api? not sure how to solve this + path.starts_with?(config.api_endpoint) || path.starts_with?('/auth') + end + + def api_path(path) + path.sub(/^#{config.api_endpoint}/, '') + end + end +end diff --git a/lib/travis/web/app/config.rb b/lib/travis/web/app/config.rb new file mode 100644 index 00000000..25b3b903 --- /dev/null +++ b/lib/travis/web/app/config.rb @@ -0,0 +1,71 @@ +class Travis::Web::App + class Config + OPTIONS = %w(ENV API_ENDPOINT CLIENT_ENDPOINT RUN_API WATCH DEFLATE) + + def keys + @keys ||= OPTIONS.map(&:downcase) + end + + def each + keys.each do |key| + yield key, send(key) + end + end + + def env + config.fetch(:env, 'development') + end + + def production? + env == 'production' + end + + def run_api? + !!config.fetch(:run_api, config[:api_endpoint].to_s.start_with?('/')) + end + + def api_endpoint + config.fetch(:api_endpoint, run_api? ? '/api' : "https://api.travis-ci.org").gsub(/:\d+/, '') + end + + def client_endpoint + config.fetch(:client_endpoint, '/') + end + + def deflate? + !!config.fetch(:deflate, production?) + end + + def watch? + !!config.fetch(:watch, false) + end + + alias run_api run_api? + alias deflate deflate? + alias watch watch? + + def version + production? ? @version ||= read_version : read_version + end + + private + + def config + @config ||= Hash[*OPTIONS.map do |key| + [key.downcase.to_sym, cast(ENV[key])] if ENV.key?(key) + end.compact.flatten] + end + + def cast(value) + case value + when '1', 'true' then true + when '0', 'false' then false + else value + end + end + + def read_version + File.read('public/version').chomp + end + end +end diff --git a/lib/travis/web/app/files.rb b/lib/travis/web/app/files.rb new file mode 100644 index 00000000..2d8f5374 --- /dev/null +++ b/lib/travis/web/app/files.rb @@ -0,0 +1,17 @@ +class Travis::Web::App + class Files < Rack::Cascade + def initialize + super([public_dir, index]) + end + + def public_dir + Rack::File.new('public') + end + + def index + proc do |env| + Rack::File.new(nil).tap { |f| f.path = 'public/index.html' }.serving(env) + end + end + end +end diff --git a/lib/travis/web/app/filter.rb b/lib/travis/web/app/filter.rb new file mode 100644 index 00000000..d10ec329 --- /dev/null +++ b/lib/travis/web/app/filter.rb @@ -0,0 +1,34 @@ +class Travis::Web::App + class Filter + autoload :Endpoint, 'travis/web/app/filter/endpoint' + autoload :Version, 'travis/web/app/filter/version' + + attr_reader :app, :config, :filters + + def initialize(app, config) + @app = app + @config = config + @filters = [Endpoint.new(config), Version.new(config)] + end + + def call(env) + status, headers, body = app.call(env) + headers, body = filter(headers, body) if content_type?(headers, 'text/html') + [status, headers, body] + end + + private + + def filter(headers, body) + headers.delete 'Content-Length' # why don't we just set this to the new length? + filtered = [] + body.each { |s| filtered << filters.inject(s) { |s, filter| filter.apply(s) } } + body.close if body.respond_to?(:close) + [headers, filtered] + end + + def content_type?(headers, type) + headers.any? { |key, value| key.downcase == 'content-type' and value.start_with?(type) } + end + end +end diff --git a/lib/travis/web/app/filter/endpoint.rb b/lib/travis/web/app/filter/endpoint.rb new file mode 100644 index 00000000..ce3b540f --- /dev/null +++ b/lib/travis/web/app/filter/endpoint.rb @@ -0,0 +1,15 @@ +class Travis::Web::App::Filter + class Endpoint + DEFAULT_ENDPOINT = 'https://api.travis-ci.org' + + attr_reader :config + + def initialize(config) + @config = config + end + + def apply(string) + string.gsub(DEFAULT_ENDPOINT, config.api_endpoint) + end + end +end diff --git a/lib/travis/web/app/filter/version.rb b/lib/travis/web/app/filter/version.rb new file mode 100644 index 00000000..a0146332 --- /dev/null +++ b/lib/travis/web/app/filter/version.rb @@ -0,0 +1,16 @@ +class Travis::Web::App::Filter + class Version + ASSET_DIRS = %r(/(stylesheets|javascripts)/) + + attr_reader :config + + def initialize(config) + @config = config + end + + def apply(string) + string.gsub(ASSET_DIRS) { |match| "/#{config.version}/#{$1}/" } + end + end +end + diff --git a/lib/travis/web/app/terminal.rb b/lib/travis/web/app/terminal.rb new file mode 100644 index 00000000..5920cf0d --- /dev/null +++ b/lib/travis/web/app/terminal.rb @@ -0,0 +1,17 @@ +class Travis::Web::App + module Terminal + def announce(config) + config.each do |key, value| + $stderr.puts("#{key.upcase.rjust(15)} = #{colorize(config.send(key))}") + end + end + + def colorize(value) + case value + when nil, false, '0' then "\e[31m0\e[0m" + when true, '1' then "\e[32m1\e[0m" + else "\e[33m#{value}\e[0m" + end + end + end +end diff --git a/lib/travis/web/app/version.rb b/lib/travis/web/app/version.rb new file mode 100644 index 00000000..fbedbd62 --- /dev/null +++ b/lib/travis/web/app/version.rb @@ -0,0 +1,35 @@ +class Travis::Web::App + class Version + attr_reader :app, :config + + def initialize(app, config) + @app = app + @config = config + end + + def call(env) + path = env['PATH_INFO'] + if pass?(path) + app.call(env) + elsif versioned?(path) + app.call(env.merge('PATH_INFO' => strip_version(path))) + else + [404, { 'Content-Type' => 'text/html', 'Content-Length' => '9' }, ['not found']] + end + end + + private + + def pass?(path) + ['/', '/index.html', 'current'].include?(path) + end + + def versioned?(path) + path.starts_with?("/#{config.version}/") + end + + def strip_version(path) + path.sub(%r(/#{config.version}/), '') + end + end +end diff --git a/public/version b/public/version new file mode 100644 index 00000000..d00491fd --- /dev/null +++ b/public/version @@ -0,0 +1 @@ +1 diff --git a/spec/app/config_spec.rb b/spec/app/config_spec.rb new file mode 100644 index 00000000..2dd3fc8a --- /dev/null +++ b/spec/app/config_spec.rb @@ -0,0 +1,113 @@ +require 'spec_helper' + +describe Travis::Web::App::Config do + let(:config) { Travis::Web::App::Config.new } + + before :each do + @env = ENV.clone + ENV.clear + end + + after :each do + ENV.replace(@env) + end + + describe 'env' do + it 'given ENV=foo it returns foo' do + ENV['ENV'] = 'foo' + config.env.should == 'foo' + end + + it 'defaults to development' do + config.env.should == 'development' + end + end + + describe 'run_api?' do + it 'given RUN_API=1 it returns true' do + ENV['RUN_API'] = '1' + config.run_api?.should be_true + end + + it 'given RUN_API=0 it returns false' do + ENV['RUN_API'] = '0' + config.run_api?.should be_false + end + + it 'defaults to true if api_endpoint is local' do + ENV['API_ENDPOINT'] = '/api' + config.run_api?.should be_true + end + + it 'defaults to false if api_endpoint is not local' do + ENV['API_ENDPOINT'] = 'https://api.travis-ci.com' + config.run_api?.should be_false + end + end + + describe 'api_endpoint' do + it 'given API_ENDPOINT=https://api.travis-ci.com it returns the given url' do + ENV['API_ENDPOINT'] = 'https://api.travis-ci.com' + config.api_endpoint.should == 'https://api.travis-ci.com' + end + + it 'defaults to /api if run_api? is true' do + config.stubs(:run_api?).returns(true) + config.api_endpoint.should == '/api' + end + + it 'defaults to https://api.travis-ci.org if run_api? is false' do + config.stubs(:run_api?).returns(false) + config.api_endpoint.should == 'https://api.travis-ci.org' + end + end + + describe 'client_endpoint' do + it 'given CLIENT_ENDPOINT=/client it returns the given url' do + ENV['CLIENT_ENDPOINT'] = '/client' + config.client_endpoint.should == '/client' + end + + it 'defaults to /' do + config.client_endpoint.should == '/' + end + end + + describe 'deflate?' do + it 'given DEFLATE=1 it returns true' do + ENV['DEFLATE'] = '1' + config.deflate.should be_true + end + + it 'given DEFLATE=0 it returns false' do + ENV['DEFLATE'] = '0' + config.deflate.should be_false + end + + it 'defaults to true if env is production' do + config.stubs(:env).returns('production') + config.deflate.should be_true + end + + it 'defaults to false if env is not production' do + config.stubs(:env).returns('development') + config.deflate.should be_false + end + end + + describe 'watch?' do + it 'given WATCH=1 it returns true' do + ENV['WATCH'] = '1' + config.watch?.should be_true + end + + it 'given WATCH=0 it returns false' do + ENV['WATCH'] = '0' + config.watch?.should be_false + end + + it 'defaults to false' do + config.watch?.should be_false + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 00000000..e5a335fc --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,19 @@ +# ENV['RACK_ENV'] = ENV['RAILS_ENV'] = ENV['ENV'] = 'test' + +require 'rspec' +require 'travis/web' +require 'sinatra/test_helpers' + +# require 'logger' +# require 'gh' +# require 'multi_json' + +RSpec.configure do |config| + config.mock_framework = :mocha + config.expect_with :rspec, :stdlib + # config.include TestHelpers + + # config.before :each do + # set_app Travis::Web::App.new + # end +end