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 +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
|