spokes 0.1.1

Sign up to get free protection for your applications and to get access to all the features.
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