spokes 0.1.1
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +17 -0
- data/.rubocop.yml +12 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +2 -0
- data/README.md +137 -0
- data/Rakefile +1 -0
- data/lib/spokes.rb +13 -0
- data/lib/spokes/config/env.rb +65 -0
- data/lib/spokes/middleware/concerns/bad_request.rb +22 -0
- data/lib/spokes/middleware/concerns/header_validation.rb +23 -0
- data/lib/spokes/middleware/cors.rb +71 -0
- data/lib/spokes/middleware/health.rb +79 -0
- data/lib/spokes/middleware/request_id.rb +43 -0
- data/lib/spokes/middleware/service_name.rb +65 -0
- data/lib/spokes/version.rb +3 -0
- data/lib/spokes/versioning/config/minor_versions.yml +3 -0
- data/lib/spokes/versioning/minor_versioning.rb +71 -0
- data/lib/spokes/versioning/railtie.rb +22 -0
- data/lib/spokes/versioning/tasks/minor_versioning.rake +14 -0
- data/logo/spokes_logo.png +0 -0
- data/script/cibuild +12 -0
- data/script/postbuild +17 -0
- data/script/test +23 -0
- data/spec/config/env_spec.rb +111 -0
- data/spec/middleware/cors_spec.rb +29 -0
- data/spec/middleware/health_spec.rb +107 -0
- data/spec/middleware/request_id_spec.rb +19 -0
- data/spec/middleware/service_name_spec.rb +60 -0
- data/spec/rails_helper.rb +3 -0
- data/spec/spec_helper.rb +25 -0
- data/spec/versioning/minor_versioning_spec.rb +45 -0
- data/spokes.gemspec +29 -0
- metadata +255 -0
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
data/.ruby-gemset
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
spokes
|
data/.ruby-version
ADDED
@@ -0,0 +1 @@
|
|
1
|
+
2.2.3
|
data/Gemfile
ADDED
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
|