spokes 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
@@ -0,0 +1,43 @@
1
+ module Spokes
2
+ module Middleware
3
+ class RequestID
4
+ PATTERN = /^[\w\\-_\\.\d]+$/
5
+
6
+ def initialize(app, service_name:)
7
+ raise "invalid name: #{service_name}" unless service_name =~ PATTERN
8
+
9
+ @app = app
10
+ @service_name = service_name
11
+ end
12
+
13
+ def call(env)
14
+ id = env['action_dispatch.request_id'] || SecureRandom.uuid
15
+ request_ids = extract_request_ids(env).insert(0, @service_name + ':' + id)
16
+
17
+ # make ID of the request accessible to consumers down the stack
18
+ env['REQUEST_ID'] = request_ids[0]
19
+
20
+ # Extract request IDs from incoming headers as well. Can be used for
21
+ # identifying a request across a number of components in SOA.
22
+ env['REQUEST_IDS'] = request_ids
23
+
24
+ Thread.current[:request_chain] = env['REQUEST_IDS']
25
+
26
+ @app.call(env)
27
+ end
28
+
29
+ private
30
+
31
+ def extract_request_ids(env)
32
+ request_ids = raw_request_ids(env)
33
+ request_ids.map!(&:strip)
34
+ request_ids
35
+ end
36
+
37
+ def raw_request_ids(_env)
38
+ %w[HTTP_REQUEST_CHAIN].each_with_object([]) do |key, _request_ids|
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,65 @@
1
+ module Spokes
2
+ module Middleware
3
+ # Validates inbound and sets outbound Service-Name HTTP headers.
4
+ #
5
+ # Usage:
6
+ #
7
+ # class Application < Rails::Application
8
+ # config.middleware.use Spokes::Middleware::ServiceName
9
+ # end
10
+ #
11
+ class ServiceName
12
+ include Middleware::Concerns::BadRequest
13
+ include Middleware::Concerns::HeaderValidation
14
+
15
+ PATTERN = /^[\w\\-_\\.\d]+$/
16
+ HEADER_NAME = 'Service-Name'.freeze
17
+
18
+ def initialize(app, service_name:, exclude_paths: [])
19
+ raise "invalid name: #{service_name}" unless service_name =~ PATTERN
20
+ @app = app
21
+ @service_name = service_name
22
+ @exclude_paths = path_to_regex(exclude_paths)
23
+ end
24
+
25
+ def call(env)
26
+ begin
27
+ unless exclude?(env['PATH_INFO'].chomp('/'))
28
+ validate_header_presence(env: env, header_name: HEADER_NAME)
29
+ validate_header_pattern(env: env, header_name: HEADER_NAME, pattern: PATTERN)
30
+ end
31
+ rescue Middleware::Concerns::HeaderValidation::NotValid => e
32
+ return bad_request(e.message)
33
+ end
34
+
35
+ status, headers, body = @app.call(env)
36
+ headers[HEADER_NAME] = @service_name
37
+ [status, headers, body]
38
+ end
39
+
40
+ private
41
+
42
+ def exclude?(path)
43
+ @exclude_paths.each do |regex|
44
+ return true if regex.match?(path)
45
+ end
46
+ false
47
+ end
48
+
49
+ # build out regular expression to match exclude path
50
+ def path_to_regex(exclude_paths)
51
+ reg_ex_path = []
52
+ exclude_paths.each do |path|
53
+ path_parts = path.split('/')
54
+ regex_paths = path_parts.inject do |out, p|
55
+ val = p
56
+ val = '(\\S*)' if p.include?(':')
57
+ out + '[/]' + val
58
+ end
59
+ reg_ex_path.push(Regexp.new(regex_paths))
60
+ end
61
+ reg_ex_path
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,3 @@
1
+ module Spokes
2
+ VERSION = '0.1.1'.freeze
3
+ end
@@ -0,0 +1,3 @@
1
+ # "2015-07-04":
2
+ # default: true
3
+ # description: My first version.
@@ -0,0 +1,71 @@
1
+ require 'active_support'
2
+ require 'active_support/concern'
3
+ require 'active_support/core_ext'
4
+ require_relative 'railtie' if defined?(Rails)
5
+
6
+ module Spokes
7
+ module Versioning
8
+ # Minor versioning mix-in for controllers.
9
+ #
10
+ # Usage:
11
+ #
12
+ # # app/controllers/my_controller.rb
13
+ # class MyController
14
+ # include MinorVersioning
15
+ #
16
+ # def index
17
+ # logger.info(minor_version)
18
+ # end
19
+ # end
20
+ #
21
+ module MinorVersioning
22
+ extend ActiveSupport::Concern
23
+
24
+ API_VERSION = 'API-Version'.freeze
25
+
26
+ included do
27
+ include MinorVersioning
28
+ after_filter :set_minor_version_response_header
29
+ end
30
+
31
+ def set_minor_version_response_header
32
+ response.headers[API_VERSION] = minor_version
33
+ end
34
+
35
+ def minor_version
36
+ @minor_version ||= begin
37
+ chosen_version = request.headers[API_VERSION]
38
+ return chosen_version if valid_minor_version?(chosen_version)
39
+ default_minor_version
40
+ end
41
+ end
42
+
43
+ private
44
+
45
+ def default_minor_version
46
+ @default_minor_version ||= begin
47
+ default = find_default_version
48
+ raise('No version marked as default in the configuration.') if default.nil?
49
+ default
50
+ end
51
+ end
52
+
53
+ def find_default_version
54
+ all_minor_versions.each do |version, info|
55
+ return version if info[:default]
56
+ end
57
+ nil
58
+ end
59
+
60
+ def valid_minor_version?(version)
61
+ version.present? && all_minor_versions.keys.include?(version)
62
+ end
63
+
64
+ def all_minor_versions
65
+ has_versions = Rails.application.config.respond_to?(:minor_versions)
66
+ raise('config/minor_versions.yml doesn\'t exist.') unless has_versions
67
+ Rails.application.config.minor_versions
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,22 @@
1
+ # encoding: utf-8
2
+
3
+ require 'rails'
4
+
5
+ module Spokes
6
+ class Railtie < Rails::Railtie
7
+ initializer('spokes_versioning.load_minor_versions_file') do |app|
8
+ config_file = Rails.root.join('config', 'minor_versions.yml')
9
+ if File.exist?(config_file)
10
+ minor_versions = YAML.load_file(config_file)
11
+ app.config.minor_versions = {}
12
+ minor_versions.map do |key, val|
13
+ app.config.minor_versions[key] = val.symbolize_keys
14
+ end
15
+ end
16
+ end
17
+
18
+ rake_tasks do
19
+ load 'spokes/versioning/tasks/minor_versioning.rake'
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,14 @@
1
+ # encoding: utf-8
2
+
3
+ namespace :spokes do
4
+ namespace :versioning do
5
+ MINOR_VERSION_YML = File.join(File.dirname(__FILE__), '../config/minor_versions.yml').to_s.freeze
6
+
7
+ desc 'Sets up minor versioning yml'
8
+ task setup: :environment do
9
+ raise 'Minor version currently only supports Rails Applications' unless defined?(Rails)
10
+ next if File.exist?("#{Rails.root}/config/minor_versions.yml")
11
+ FileUtils.cp(MINOR_VERSION_YML, "#{Rails.root}/config", verbose: true)
12
+ end
13
+ end
14
+ end
Binary file
data/script/cibuild ADDED
@@ -0,0 +1,12 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # script/cibuild: Setup environment for CI to run tests. This is primarily
4
+ # designed to run on the continuous integration server.
5
+
6
+ set -e
7
+
8
+ # cd to project root
9
+ cd "$(dirname "$0")/.."
10
+
11
+ # run tests
12
+ script/test
data/script/postbuild ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # script/postbuild: Cleanup environment after CI. This is primarily
4
+ # designed to run on the continuous integration server.
5
+
6
+ set -e
7
+
8
+ PROJECT_NAME='spokes'
9
+
10
+ [[ "${PROJECT_NAME:-}" ]] || (echo "PROJECT_NAME is required." && exit 1)
11
+
12
+ # cd to project root
13
+ cd "$(dirname "$0")/.."
14
+
15
+ docker stop `docker ps -a -q -f status=exited` &> /dev/null || true &> /dev/null
16
+ docker rm -v `docker ps -a -q -f status=exited` &> /dev/null || true &> /dev/null
17
+ docker rmi `docker images --filter 'dangling=true' -q --no-trunc` &> /dev/null || true &> /dev/null
data/script/test ADDED
@@ -0,0 +1,23 @@
1
+ #!/usr/bin/env bash
2
+
3
+ # script/test: Run test suite for application. Optionally pass in a path to an
4
+ # individual test file to run a single test.
5
+
6
+ set -e
7
+ set -u
8
+
9
+ export RAILS_ENV="test" RACK_ENV="test"
10
+ PROJECT_NAME='spokes'
11
+
12
+ [[ "${PROJECT_NAME:-}" ]] || (echo "PROJECT_NAME is required." && exit 1)
13
+
14
+ # cd to project root
15
+ cd "$(dirname "$0")/.."
16
+
17
+ # Build deploy Docker image
18
+ docker build --tag=$PROJECT_NAME .
19
+
20
+ printf "\n===> Running tests ...\n"
21
+ date "+%H:%M:%S"
22
+
23
+ docker run --rm $PROJECT_NAME bundle exec rspec
@@ -0,0 +1,111 @@
1
+ require 'spec_helper'
2
+
3
+ describe Spokes::Config::Env do
4
+ after do
5
+ %w[HOMER_SIMPSON TEST_STRING_VAR TEST_INT_VAR TEST_FLOAT_VAR TEST_ARRAY_VAR TEST_SYMBOL_VAR TEST_BOOL_VAR].each do |w|
6
+ ENV.delete(w)
7
+ end
8
+ end
9
+
10
+ describe '#manditory' do
11
+ let(:env_loader) do
12
+ lambda do
13
+ Spokes::Config::Env.load do
14
+ mandatory :homer_simpson, string
15
+ end
16
+ end
17
+ end
18
+
19
+ it 'raises error if variable is not set' do
20
+ expect(env_loader).to raise_error(KeyError)
21
+ end
22
+
23
+ it 'exposes variable once loaded' do
24
+ ENV['HOMER_SIMPSON'] = 'doh'
25
+ expect(env_loader.call.homer_simpson).to eq('doh')
26
+ end
27
+ end
28
+
29
+ describe '#optional' do
30
+ let(:env_loader) do
31
+ lambda do
32
+ Spokes::Config::Env.load do
33
+ optional :homer_simpson, string
34
+ end
35
+ end
36
+ end
37
+
38
+ it 'exposes variable once loaded' do
39
+ ENV['HOMER_SIMPSON'] = 'doh'
40
+ expect(env_loader.call.homer_simpson).to eq('doh')
41
+ end
42
+
43
+ it 'value is nil when variable is not set' do
44
+ expect(env_loader.call.homer_simpson).to eq(nil)
45
+ end
46
+ end
47
+
48
+ describe '#default' do
49
+ let(:env_loader) do
50
+ lambda do
51
+ Spokes::Config::Env.load do
52
+ default :homer_simpson, 'derp', string
53
+ end
54
+ end
55
+ end
56
+
57
+ it 'exposes variable once loaded' do
58
+ ENV['HOMER_SIMPSON'] = 'doh'
59
+ expect(env_loader.call.homer_simpson).to eq('doh')
60
+ end
61
+
62
+ it 'uses default value when varible is not set' do
63
+ expect(env_loader.call.homer_simpson).to eq('derp')
64
+ end
65
+ end
66
+
67
+ describe 'type casting' do
68
+ let(:env_loader) do
69
+ lambda do
70
+ Spokes::Config::Env.load do
71
+ optional :test_bool_var, bool
72
+ optional :test_float_var, float
73
+ optional :test_string_var, string
74
+ optional :test_symbol_var, symbol
75
+ optional :test_array_var, array
76
+ optional :test_int_var, int
77
+ end
78
+ end
79
+ end
80
+
81
+ it 'does nothing to strings' do
82
+ ENV['TEST_STRING_VAR'] = 'stringy'
83
+ expect(env_loader.call.test_string_var).to eq('stringy')
84
+ end
85
+
86
+ it 'casts to integer' do
87
+ ENV['TEST_INT_VAR'] = '123'
88
+ expect(env_loader.call.test_int_var).to eq(123)
89
+ end
90
+
91
+ it 'casts to float' do
92
+ ENV['TEST_FLOAT_VAR'] = '1.23'
93
+ expect(env_loader.call.test_float_var).to eq(1.23)
94
+ end
95
+
96
+ it 'casts to array' do
97
+ ENV['TEST_ARRAY_VAR'] = 'hello,world'
98
+ expect(env_loader.call.test_array_var).to eq(%w[hello world])
99
+ end
100
+
101
+ it 'casts to symbol' do
102
+ ENV['TEST_SYMBOL_VAR'] = 'howdy'
103
+ expect(env_loader.call.test_symbol_var).to eq(:howdy)
104
+ end
105
+
106
+ it 'casts to boolean' do
107
+ ENV['TEST_BOOL_VAR'] = 'true'
108
+ expect(env_loader.call.test_bool_var).to eq(true)
109
+ end
110
+ end
111
+ end
@@ -0,0 +1,29 @@
1
+ require 'spec_helper'
2
+
3
+ describe Spokes::Middleware::CORS, modules: true, middleware: true do
4
+ let(:app) { proc { [200, {}, ['hi']] } }
5
+ let(:stack) { Spokes::Middleware::CORS.new(app) }
6
+ let(:request) { Rack::MockRequest.new(stack) }
7
+
8
+ it 'does not do anything when the Origin header is not present' do
9
+ response = request.get('/')
10
+ expect(response.status).to eq(200)
11
+ expect(response.body).to eq('hi')
12
+ expect(response.headers['Access-Control-Allow-Origin']).to eq(nil)
13
+ end
14
+
15
+ it 'intercepts OPTION requests to render a stub (preflight request)' do
16
+ response = request.options('/', 'Origin' => 'http://localhost', 'HTTP_ORIGIN' => 'http://localhost')
17
+ expect(response.status).to eq(200)
18
+ expect(response.body).to eq('')
19
+ expect(response.headers['Access-Control-Allow-Methods']).to eq('GET, POST, PUT, PATCH, DELETE, OPTIONS')
20
+ expect(response.headers['Access-Control-Allow-Origin']).to eq('http://localhost')
21
+ end
22
+
23
+ it 'delegates other calls, adding the CORS headers to the response' do
24
+ response = request.get('/', 'Origin' => 'http://localhost', 'HTTP_ORIGIN' => 'http://localhost')
25
+ expect(response.status).to eq(200)
26
+ expect(response.body).to eq('hi')
27
+ expect(response.headers['Access-Control-Allow-Origin']).to eq('http://localhost')
28
+ end
29
+ end
@@ -0,0 +1,107 @@
1
+ require 'spec_helper'
2
+
3
+ describe Spokes::Middleware::Health, modules: true, middleware: true do
4
+ def env(url = '/', *args)
5
+ Rack::MockRequest.env_for(url, *args)
6
+ end
7
+
8
+ let(:base_app) do
9
+ lambda do |_env|
10
+ [200, { 'Content-Type' => 'text/plain' }, ['Oi!']]
11
+ end
12
+ end
13
+
14
+ let(:app) { Rack::Lint.new Spokes::Middleware::Health.new(base_app, health_options) }
15
+ let(:health_options) { {} }
16
+ let(:status) { subject[0] }
17
+ let(:body) do
18
+ str = ''
19
+ subject[2].each do |s|
20
+ str += s
21
+ end
22
+ str
23
+ end
24
+
25
+ describe 'with default options' do
26
+ let(:health_options) { {} }
27
+
28
+ describe '/' do
29
+ subject { app.call env('/') }
30
+ it { expect(status).to eq(200) }
31
+ it { expect(body).to eq('Oi!') }
32
+ end
33
+
34
+ describe '/status' do
35
+ subject { app.call env('/status') }
36
+ it { expect(status).to eq(200) }
37
+ it { expect(body).to eq('OK') }
38
+ end
39
+ end
40
+
41
+ describe 'as json' do
42
+ let(:health_options) { {} }
43
+
44
+ describe '/' do
45
+ subject { app.call env('/', 'Content-Type' => 'application/json') }
46
+ it { expect(status).to eq(200) }
47
+ it { expect(body).to eq('Oi!') }
48
+ end
49
+
50
+ describe '/status' do
51
+ subject { app.call env('/status', 'CONTENT_TYPE' => 'application/json') }
52
+ it { expect(status).to eq(200) }
53
+ it { expect(JSON.parse(body)['status']).to eq('OK') }
54
+ end
55
+ end
56
+
57
+ describe 'with :fail_if' do
58
+ subject { app.call env('/status') }
59
+
60
+ describe '== lambda { true }' do
61
+ let(:health_options) { { fail_if: -> { true } } }
62
+
63
+ it { expect(status).to eq(503) }
64
+ it { expect(body).to eq('FAIL') }
65
+ end
66
+
67
+ describe '== lambda { false }' do
68
+ let(:health_options) { { fail_if: -> { false } } }
69
+ it { expect(status).to eq(200) }
70
+ it { expect(body).to eq('OK') }
71
+ end
72
+ end
73
+
74
+ describe 'with :status_code' do
75
+ let(:status_proc) { ->(healthy) { healthy ? 202 : 404 } }
76
+ subject { app.call env('/status') }
77
+
78
+ context 'healthy' do
79
+ let(:health_options) { { fail_if: -> { false }, status_code: status_proc } }
80
+ it { expect(status).to eq(202) }
81
+ it { expect(body).to eq('OK') }
82
+ end
83
+
84
+ context 'fail' do
85
+ let(:health_options) { { fail_if: -> { true }, status_code: status_proc } }
86
+ it { expect(status).to eq(404) }
87
+ it { expect(body).to eq('FAIL') }
88
+ end
89
+ end
90
+
91
+ describe 'with :simple' do
92
+ let(:body_proc) { ->(healthy) { healthy ? 'GOOD' : 'BAD' } }
93
+ subject { app.call env('/status') }
94
+
95
+ context 'healthy' do
96
+ let(:health_options) { { fail_if: -> { false }, simple: body_proc } }
97
+ it { expect(status).to eq(200) }
98
+ it { expect(body).to eq('GOOD') }
99
+ end
100
+
101
+ context 'fail' do
102
+ let(:health_options) { { fail_if: -> { true }, simple: body_proc } }
103
+ it { expect(status).to eq(503) }
104
+ it { expect(body).to eq('BAD') }
105
+ end
106
+ end
107
+ end