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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA1:
3
+ metadata.gz: b2871efe83be8086d0e0117a1e6f941776a364e8
4
+ data.tar.gz: 5505a9ff74b657dea7d197c109ecb10e6af57f81
5
+ SHA512:
6
+ metadata.gz: 91e66451fc8794a775362eeda928c5889391294c7c3c954e50ffdaeef2b349759440fd46b956d8276797ecdcfb6c3ccc74093a1d860e0b890b533741d844ae9b
7
+ data.tar.gz: 3180c2e60f220d3b281460fa0396c32ef8b9350760469ab4020a878715a076e4eb03c329116fa8e9208ff3b420574ad421cc463c1cdbae36d451c5793d0eb7dd
data/.gitignore ADDED
@@ -0,0 +1,17 @@
1
+ # See https://help.github.com/articles/ignoring-files for more about ignoring files.
2
+ #
3
+ # If you find yourself ignoring temporary files generated by your text editor
4
+ # or operating system, you probably want to add a global ignore instead:
5
+ # git config --global core.excludesfile '~/.gitignore_global'
6
+
7
+ # Ignore bundler config.
8
+ .DS_Store
9
+ /.bundle
10
+ /pkg
11
+
12
+ # Ignore all logfiles and tempfiles.
13
+ /log/*
14
+ /tmp
15
+ /coverage
16
+ /.sass-cache
17
+ Gemfile.lock
data/.rubocop.yml ADDED
@@ -0,0 +1,12 @@
1
+ AllCops:
2
+ Excludes:
3
+ - spec/**/*
4
+ Documentation:
5
+ Enabled: false
6
+ LineLength:
7
+ Max: 120
8
+ Metrics/MethodLength:
9
+ CountComments: false
10
+ Max: 80
11
+ Metrics/AbcSize:
12
+ Max: 50
data/.ruby-gemset ADDED
@@ -0,0 +1 @@
1
+ spokes
data/.ruby-version ADDED
@@ -0,0 +1 @@
1
+ 2.2.3
data/Gemfile ADDED
@@ -0,0 +1,2 @@
1
+ source 'https://rubygems.org'
2
+ gemspec
data/README.md ADDED
@@ -0,0 +1,137 @@
1
+
2
+ <img src="https://raw.githubusercontent.com/khaight/spokes/master/logo/spokes_logo.png" alt="Spokes Logo"/>
3
+
4
+ A set of utilities for helping creating apis
5
+
6
+ - [Configuration](#configuration)
7
+ - [Middleware](#middleware)
8
+ - [Health](#health)
9
+ - [CORS](#cors)
10
+ - [Service Name](#service_name)
11
+ - [Versioning](#versioning)
12
+ - [Minor Versioning](#minor-versioning)
13
+
14
+ ## Configuration
15
+
16
+ Provides an easy to use set of configuration methods to manage environment variables.
17
+
18
+ Example:
19
+
20
+ ```
21
+ # config/application.rb
22
+
23
+ Spokes::Config::Env.load do
24
+ mandatory :homer, string
25
+ default :krusty, :clown, symbol
26
+ optional :duffman, boolean
27
+ end
28
+ ```
29
+
30
+ The above example will look for the following environment variables when booting your application as well as try to perform type coercion:
31
+
32
+ ```
33
+ ENV['HOMER'] # will raise KeyError if variable does not exist; will 'cast' value to a string if exists
34
+
35
+ ENV['KRUSTY'] # will try to 'cast' value to a symbol if exists; otherwise will populate with the default value :clown
36
+
37
+ ENV['DUFFMAN'] # will try to 'cast' value to a boolean if exists and will do nothing otherwise
38
+ ```
39
+
40
+ ## Middleware
41
+
42
+ ### Health
43
+
44
+ Provides a `/status` endpoint on your API.
45
+
46
+ #### Installation
47
+
48
+ Add the following to your Rails project:
49
+
50
+ ```ruby
51
+ # config/application.rb
52
+ class Application < Rails::Application
53
+ config.middleware.use Spokes::Middleware::Health
54
+ end
55
+ ```
56
+
57
+ #### Configuration Arguments
58
+
59
+ | name | description |
60
+ | -------------- | ----------- |
61
+ | `fail_if` | Mechanism for putting the service into a "failing" state |
62
+ | `content_type` | Establishes content types returned for the different representations of the health response. Requires two keys: `simple` and `details` |
63
+ | `details` | Override the body content returned in details view |
64
+ | `status_code` | Override the body content returned in simple view |
65
+ | `headers` | Override the headers in health responses. Takes `Content-Type` header value as a parameter. |
66
+
67
+ ### CORS
68
+
69
+ Provides CORS HTTP access control headers in all responses.
70
+
71
+ #### Installation
72
+
73
+ Add the following to your Rails project:
74
+
75
+ ```ruby
76
+ # config/application.rb
77
+ class Application < Rails::Application
78
+ config.middleware.use Spokes::Middleware::CORS
79
+ end
80
+ ```
81
+
82
+
83
+ #### Installation
84
+
85
+ Add the following to your Rails project:
86
+
87
+ ```ruby
88
+ # config/application.rb
89
+ class Application < Rails::Application
90
+ config.middleware.use Spokes::Middleware::OrganizationId
91
+ end
92
+ ```
93
+
94
+ ### Service Name
95
+
96
+ Requires and validates `Service-Name` header in all requests. Appends the current service's name to all outbound
97
+ responses.
98
+
99
+ #### Installation
100
+
101
+ Add the following to your Rails project:
102
+
103
+ ```ruby
104
+ # config/application.rb
105
+ class Application < Rails::Application
106
+ config.middleware.use Spokes::Middleware::ServiceName
107
+ end
108
+ ```
109
+
110
+ ## Versioning
111
+
112
+ ### Minor Versioning
113
+
114
+ Parses the `API-Version` HTTP header and makes it available in controllers via the `minor_version` helper method.
115
+ This concern also adds the `API-Version` header to all outgoing responses.
116
+
117
+ #### Installation
118
+
119
+ 1. Add the following to your Rails project:
120
+
121
+ ```ruby
122
+ # app/controllers/application_controller.rb
123
+ class ApplicationController < ActionController::Base
124
+ include Spokes::Versioning::MinorVersioning
125
+ end
126
+ ```
127
+ 2. Execute the following in your Rails project's directory:
128
+
129
+ ```bash
130
+ $ bundle exec rake spokes:versioning:setup
131
+ ```
132
+ 3. You'll now have a file created at `config/minor_versions.yml`. Edit this file as needed to set up the default
133
+ version for your API. Any subsequent versions will be listed there as well.
134
+
135
+ ## Contributing
136
+
137
+ Bug reports and pull requests are welcome on GitHub at https://github.com/khaight/spokes. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [Contributor Covenant](http://contributor-covenant.org) code of conduct.
data/Rakefile ADDED
@@ -0,0 +1 @@
1
+ require 'bundler/gem_tasks'
data/lib/spokes.rb ADDED
@@ -0,0 +1,13 @@
1
+ require_relative 'spokes/middleware/concerns/bad_request.rb'
2
+ require_relative 'spokes/middleware/concerns/header_validation.rb'
3
+
4
+ require_relative 'spokes/middleware/cors.rb'
5
+ require_relative 'spokes/middleware/health.rb'
6
+ require_relative 'spokes/middleware/service_name.rb'
7
+ require_relative 'spokes/middleware/request_id.rb'
8
+
9
+ require_relative 'spokes/config/env.rb'
10
+ require_relative 'spokes/versioning/minor_versioning.rb'
11
+
12
+ module Spokes
13
+ end
@@ -0,0 +1,65 @@
1
+ module Spokes
2
+ module Config
3
+ class Env
4
+ def self.load(&blk)
5
+ new(&blk)
6
+ end
7
+
8
+ def initialize(&blk)
9
+ instance_eval(&blk)
10
+ end
11
+
12
+ def mandatory(name, method = nil)
13
+ value = cast(ENV.fetch(name.to_s.upcase), method)
14
+ create(name, value)
15
+ end
16
+
17
+ def optional(name, method = nil)
18
+ value = cast(ENV[name.to_s.upcase], method)
19
+ create(name, value)
20
+ end
21
+
22
+ def default(name, default, method = nil)
23
+ value = cast(ENV.fetch(name.to_s.upcase, default), method)
24
+ create(name, value)
25
+ end
26
+
27
+ def array
28
+ ->(v) { v.nil? ? [] : v.split(',') }
29
+ end
30
+
31
+ def int
32
+ ->(v) { v.to_i }
33
+ end
34
+
35
+ def float
36
+ ->(v) { v.to_f }
37
+ end
38
+
39
+ def bool
40
+ ->(v) { v.to_s == 'true' }
41
+ end
42
+
43
+ def string
44
+ nil
45
+ end
46
+
47
+ def symbol
48
+ ->(v) { v.nil? ? nil : v.to_sym }
49
+ end
50
+
51
+ private
52
+
53
+ def cast(value, method)
54
+ method ? method.call(value) : value
55
+ end
56
+
57
+ def create(name, value)
58
+ instance_variable_set(:"@#{name}", value)
59
+ instance_eval "def #{name}; @#{name} end", __FILE__, __LINE__
60
+ return unless value.is_a?(TrueClass) || value.is_a?(FalseClass) || value.is_a?(NilClass)
61
+ instance_eval "def #{name}?; !!@#{name} end", __FILE__, __LINE__
62
+ end
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,22 @@
1
+ require 'multi_json'
2
+
3
+ module Spokes
4
+ module Middleware
5
+ module Concerns
6
+ module BadRequest
7
+ def bad_request(errors)
8
+ errors = [errors] unless errors.is_a?(Array)
9
+ [400, bad_request_headers, [bad_request_body(errors)]]
10
+ end
11
+
12
+ def bad_request_headers
13
+ { 'Content-Type' => 'application/json; charset=utf-8' }
14
+ end
15
+
16
+ def bad_request_body(errors)
17
+ MultiJson.dump(errors: errors)
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,23 @@
1
+ module Spokes
2
+ module Middleware
3
+ module Concerns
4
+ module HeaderValidation
5
+ class NotValid < StandardError; end
6
+
7
+ def validate_header_presence(env:, header_name:, message: 'is required')
8
+ value = env[env_header_name(header_name)]
9
+ raise NotValid, "#{header_name} #{message}" if value.nil? || value.empty?
10
+ end
11
+
12
+ def validate_header_pattern(env:, header_name:, pattern:, message: 'is invalid')
13
+ value = env[env_header_name(header_name)]
14
+ raise NotValid, "#{header_name} #{message}" unless value =~ pattern
15
+ end
16
+
17
+ def env_header_name(name)
18
+ "HTTP_#{name.upcase.tr('-', '_')}"
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
@@ -0,0 +1,71 @@
1
+ module Spokes
2
+ module Middleware
3
+ # Provides CORS HTTP access control.
4
+ #
5
+ # Usage:
6
+ #
7
+ # class Application < Rails::Application
8
+ # config.middleware.use Spokes::Middleware::CORS
9
+ # end
10
+ #
11
+ # Example response:
12
+ #
13
+ # $ curl -v -L http://localhost:3000/ -H "Origin: http://elsewhere" -X OPTIONS
14
+ # > OPTIONS / HTTP/1.1
15
+ # > User-Agent: curl/7.37.1
16
+ # > Host: localhost:3000
17
+ # > Accept: */*
18
+ # > Origin: http://elsewhere
19
+ # >
20
+ # < HTTP/1.1 200 OK
21
+ # < Access-Control-Allow-Origin: http://elsewhere
22
+ # < Access-Control-Allow-Methods: GET, POST, PUT, PATCH, DELETE, OPTIONS
23
+ # < Access-Control-Allow-Headers: *, Content-Type, Accept, AUTHORIZATION, Cache-Control
24
+ # < Access-Control-Allow-Credentials: true
25
+ # < Access-Control-Max-Age: 1728000
26
+ # < Access-Control-Expose-Headers: Cache-Control, Content-Language, Content-Type, Expires, Last-Modified, Pragma
27
+ # < Cache-Control: no-cache
28
+ # < X-Request-Id: 1d388184-5dd6-4150-bf47-1729f33794ec
29
+ # < X-Runtime: 0.001269
30
+ # < Transfer-Encoding: chunked
31
+ #
32
+ class CORS
33
+ ALLOW_METHODS =
34
+ %w[GET POST PUT PATCH DELETE OPTIONS].freeze
35
+ ALLOW_HEADERS =
36
+ %w[* Content-Type Accept AUTHORIZATION Cache-Control].freeze
37
+ EXPOSE_HEADERS =
38
+ %w[Cache-Control Content-Language Content-Type Expires Last-Modified Pragma].freeze
39
+
40
+ def initialize(app)
41
+ @app = app
42
+ end
43
+
44
+ def call(env)
45
+ # preflight request: render a stub 200 with the CORS headers
46
+ if cors_request?(env) && env['REQUEST_METHOD'] == 'OPTIONS'
47
+ [200, cors_headers(env), ['']]
48
+ else
49
+ status, headers, response = @app.call(env)
50
+ headers.merge!(cors_headers(env)) if cors_request?(env)
51
+ [status, headers, response]
52
+ end
53
+ end
54
+
55
+ def cors_request?(env)
56
+ env.key?('HTTP_ORIGIN')
57
+ end
58
+
59
+ def cors_headers(env)
60
+ {
61
+ 'Access-Control-Allow-Origin' => env['HTTP_ORIGIN'],
62
+ 'Access-Control-Allow-Methods' => ALLOW_METHODS.join(', '),
63
+ 'Access-Control-Allow-Headers' => ALLOW_HEADERS.join(', '),
64
+ 'Access-Control-Allow-Credentials' => 'true',
65
+ 'Access-Control-Max-Age' => '1728000',
66
+ 'Access-Control-Expose-Headers' => EXPOSE_HEADERS.join(', ')
67
+ }
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,79 @@
1
+ module Spokes
2
+ module Middleware
3
+ # Provides `/status` route.
4
+ #
5
+ # Example setup:
6
+ #
7
+ # class Application < Rails::Application
8
+ # config.middleware.use Spokes::Middleware::Health
9
+ # end
10
+ #
11
+ # Example responses:
12
+ #
13
+ # $ curl -v -L http://localhost:3000/status
14
+ # > GET /status HTTP/1.1
15
+ # > User-Agent: curl/7.37.1
16
+ # > Host: localhost:3000
17
+ # > Accept: */*
18
+ # >
19
+ # < HTTP/1.1 200 OK
20
+ # < Content-Type: text/plain
21
+ # < Cache-Control: must-revalidate,no-cache,no-store
22
+ # < X-Request-Id: 8916fc91-b773-4002-9a9a-59870894715c
23
+ # < X-Runtime: 0.001055
24
+ # < Transfer-Encoding: chunked
25
+ # <
26
+ # OK
27
+ #
28
+ # $ curl -v -L http://localhost:3000/status -H "Content-Type: application/json"
29
+ # > GET /status HTTP/1.1
30
+ # > User-Agent: curl/7.37.1
31
+ # > Host: localhost:3000
32
+ # > Accept: */*
33
+ # > Content-Type: application/json
34
+ # >
35
+ # < HTTP/1.1 200 OK
36
+ # < Content-Type: application/json
37
+ # < Cache-Control: must-revalidate,no-cache,no-store
38
+ # < X-Request-Id: 0a598c48-ca37-4b61-9677-20d8a8a4d637
39
+ # < X-Runtime: 0.001252
40
+ # < Transfer-Encoding: chunked
41
+ # <
42
+ # {"status":"OK"}
43
+ #
44
+ class Health
45
+ PATH = '/status'.freeze
46
+
47
+ def initialize(app, options = {})
48
+ @app = app
49
+ @options = {
50
+ content_type: { simple: 'text/plain', details: 'application/json' },
51
+ fail_if: -> { false },
52
+ simple: ->(healthy) { healthy ? 'OK' : 'FAIL' },
53
+ details: ->(healthy) { healthy ? { 'status': 'OK' }.to_json : { 'status': 'FAIL' }.to_json },
54
+ status_code: ->(healthy) { healthy ? 200 : 503 },
55
+ headers: lambda do |content_type|
56
+ { 'Content-Type' => content_type, 'Cache-Control' => 'must-revalidate,no-cache,no-store' }
57
+ end
58
+ }.merge(options)
59
+ end
60
+
61
+ def call(env)
62
+ return @app.call(env) unless env['REQUEST_METHOD'] == 'GET' && env['PATH_INFO'] == PATH
63
+ healthy = !@options[:fail_if].call
64
+ status = get_status(env, healthy)
65
+ [status[:status], status[:headers], status[:body]]
66
+ end
67
+
68
+ private
69
+
70
+ def get_status(env, healthy)
71
+ d = env['CONTENT_TYPE'] == @options[:content_type][:details]
72
+ body = [@options[(d ? :details : :simple)].call(healthy)]
73
+ status = @options[:status_code].call(healthy)
74
+ headers = @options[:headers].call(d ? @options[:content_type][:details] : @options[:content_type][:simple])
75
+ { body: body, status: status, headers: headers }
76
+ end
77
+ end
78
+ end
79
+ end