spokes 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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