stitches 3.0.0

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.
Files changed (48) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.ruby-gemset +1 -0
  4. data/.ruby-version +1 -0
  5. data/Gemfile +3 -0
  6. data/Gemfile.lock +126 -0
  7. data/README.md +220 -0
  8. data/Rakefile +13 -0
  9. data/lib/stitches.rb +3 -0
  10. data/lib/stitches/api_generator.rb +97 -0
  11. data/lib/stitches/api_key.rb +59 -0
  12. data/lib/stitches/api_version_constraint.rb +32 -0
  13. data/lib/stitches/configuration.rb +77 -0
  14. data/lib/stitches/error.rb +9 -0
  15. data/lib/stitches/errors.rb +101 -0
  16. data/lib/stitches/generator_files/app/controllers/api.rb +2 -0
  17. data/lib/stitches/generator_files/app/controllers/api/api_controller.rb +19 -0
  18. data/lib/stitches/generator_files/app/controllers/api/v1.rb +2 -0
  19. data/lib/stitches/generator_files/app/controllers/api/v1/pings_controller.rb +20 -0
  20. data/lib/stitches/generator_files/app/controllers/api/v2.rb +2 -0
  21. data/lib/stitches/generator_files/app/controllers/api/v2/pings_controller.rb +20 -0
  22. data/lib/stitches/generator_files/app/models/api_client.rb +2 -0
  23. data/lib/stitches/generator_files/config/initializers/stitches.rb +14 -0
  24. data/lib/stitches/generator_files/db/migrate/create_api_clients.rb +11 -0
  25. data/lib/stitches/generator_files/db/migrate/enable_uuid_ossp_extension.rb +5 -0
  26. data/lib/stitches/generator_files/lib/tasks/generate_api_key.rake +10 -0
  27. data/lib/stitches/generator_files/spec/acceptance/ping_v1_spec.rb +46 -0
  28. data/lib/stitches/generator_files/spec/features/api_spec.rb +96 -0
  29. data/lib/stitches/railtie.rb +9 -0
  30. data/lib/stitches/render_timestamps_in_iso8601_in_json.rb +9 -0
  31. data/lib/stitches/spec.rb +4 -0
  32. data/lib/stitches/spec/api_clients.rb +5 -0
  33. data/lib/stitches/spec/be_iso_8601_utc_encoded.rb +10 -0
  34. data/lib/stitches/spec/have_api_error.rb +50 -0
  35. data/lib/stitches/spec/test_headers.rb +51 -0
  36. data/lib/stitches/valid_mime_type.rb +32 -0
  37. data/lib/stitches/version.rb +3 -0
  38. data/lib/stitches/whitelisting_middleware.rb +29 -0
  39. data/lib/stitches_norailtie.rb +17 -0
  40. data/spec/api_key_spec.rb +200 -0
  41. data/spec/api_version_constraint_spec.rb +33 -0
  42. data/spec/configuration_spec.rb +105 -0
  43. data/spec/errors_spec.rb +99 -0
  44. data/spec/spec/have_api_error_spec.rb +78 -0
  45. data/spec/spec_helper.rb +10 -0
  46. data/spec/valid_mime_type_spec.rb +166 -0
  47. data/stitches.gemspec +24 -0
  48. metadata +168 -0
@@ -0,0 +1,96 @@
1
+ require 'spec_helper.rb'
2
+
3
+ feature "general API stuff" do
4
+ scenario "good request" do
5
+ headers = TestHeaders.new
6
+ post "/api/ping", {}.to_json, headers.headers
7
+
8
+ expect(response.response_code).to eq(201)
9
+ expect(JSON.parse(response.body)["ping"]["status"]).to eq("ok")
10
+ end
11
+
12
+ scenario "good request to v2" do
13
+ headers = TestHeaders.new(version: 2)
14
+ post "/api/ping", {}.to_json, headers.headers
15
+
16
+ expect(response.response_code).to eq(201)
17
+ expect(JSON.parse(response.body)["ping"]["status_v2"]).to eq("ok")
18
+ end
19
+
20
+ scenario "good request with custom status" do
21
+ headers = TestHeaders.new
22
+ post "/api/ping", {status: 200}.to_json, headers.headers
23
+
24
+ expect(response.response_code).to eq(200)
25
+ expect(JSON.parse(response.body)["ping"]["status"]).to eq("ok")
26
+ end
27
+
28
+ scenario "request with user error" do
29
+ headers = TestHeaders.new
30
+ post "/api/ping", {error: "OH NOES!"}.to_json, headers.headers
31
+
32
+ expect(response).to have_api_error(code: "test", message: "OH NOES!")
33
+ end
34
+
35
+ scenario "no auth header given" do
36
+ headers = TestHeaders.new(api_client: nil)
37
+ post "/api/ping", {}.to_json, headers.headers
38
+
39
+ expect(response).to have_auth_error
40
+ end
41
+
42
+ scenario "weird auth header given" do
43
+ headers = TestHeaders.new(api_client: ["Basic","foo:bar"])
44
+ post "/api/ping", {}.to_json, headers.headers
45
+
46
+ expect(response).to have_auth_error
47
+ end
48
+
49
+ scenario "bad key given" do
50
+ headers = TestHeaders.new(api_client: "foobar")
51
+ post "/api/ping", {}.to_json, headers.headers
52
+
53
+ expect(response).to have_auth_error
54
+ end
55
+
56
+ scenario "no version" do
57
+ headers = TestHeaders.new(version: nil)
58
+ post "/api/ping", {}.to_json, headers.headers
59
+
60
+ expect(response.response_code).to eq(406)
61
+ end
62
+
63
+ scenario "version we don't support" do
64
+ headers = TestHeaders.new(version: 999)
65
+ expect {
66
+ post "/api/ping", {}.to_json, headers.headers
67
+ }.to raise_error(ActionController::RoutingError)
68
+ end
69
+
70
+ scenario "wrong mime type" do
71
+ headers = TestHeaders.new(mime_type: "application/foobar")
72
+ post "/api/ping", {}.to_json, headers.headers
73
+
74
+ expect(response.response_code).to eq(406)
75
+ end
76
+
77
+ RSpec::Matchers.define :have_auth_error do
78
+ match do |response|
79
+ correct_code,correct_header = evaluate_response(response)
80
+ correct_code && correct_header
81
+ end
82
+
83
+ failure_message_for_should do
84
+ correct_code,_ = evaluate_response(response)
85
+ if correct_code
86
+ "Expected WWW-Authenticate header to be 'CustomKeyAuth realm=#{Rails.application.class.parent.to_s}', but was #{response['WWW-Authenticate']}"
87
+ else
88
+ "Expected response to be 401, but was #{response.response_code}"
89
+ end
90
+ end
91
+
92
+ def evaluate_response(response)
93
+ [response.response_code == 401, response.headers["WWW-Authenticate"] == "CustomKeyAuth realm=#{Rails.application.class.parent.to_s}" ]
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,9 @@
1
+ require 'stitches/api_key'
2
+ require 'stitches/valid_mime_type'
3
+
4
+ module Stitches
5
+ class Railtie < Rails::Railtie
6
+ config.app_middleware.use "Stitches::ApiKey"
7
+ config.app_middleware.use "Stitches::ValidMimeType"
8
+ end
9
+ end
@@ -0,0 +1,9 @@
1
+ require 'active_support/time_with_zone'
2
+
3
+ class ActiveSupport::TimeWithZone
4
+ # We want dates to be a) in UTC and b) in ISO8601 always
5
+ def as_json(options = {})
6
+ utc.iso8601
7
+ end
8
+ end
9
+
@@ -0,0 +1,4 @@
1
+ require 'stitches/spec/be_iso_8601_utc_encoded'
2
+ require 'stitches/spec/have_api_error'
3
+ require 'stitches/spec/api_clients'
4
+ require 'stitches/spec/test_headers'
@@ -0,0 +1,5 @@
1
+ module ApiClients
2
+ def api_client(name: "test")
3
+ ::ApiClient.where(name: name).first or ::ApiClient.create!(name: name).reload
4
+ end
5
+ end
@@ -0,0 +1,10 @@
1
+ RSpec::Matchers.define :be_iso8601_utc_encoded do
2
+ match do |string|
3
+ string =~ /^\d\d\d\d-\d\d-\d\dT\d\d:\d\d:\d\d[Zz]$/
4
+ end
5
+
6
+ failure_message_for_should do |string|
7
+ "'#{string}' doesn't look like a UTC IS8601-encoded date"
8
+ end
9
+ end
10
+
@@ -0,0 +1,50 @@
1
+ # Expects a web response to have an error in our given format
2
+ RSpec::Matchers.define :have_api_error do |expected_fields|
3
+ match do |response|
4
+ if response.response_code == (expected_fields[:status] || 422)
5
+ error = extract_error(response,expected_fields)
6
+ if error
7
+ expected_code = expected_fields.fetch(:code)
8
+ expected_message = expected_fields.fetch(:message)
9
+ error["code"] == expected_code && message_matches(error["message"],expected_message)
10
+ else
11
+ false
12
+ end
13
+ else
14
+ false
15
+ end
16
+ end
17
+
18
+ def extract_error(response,expected_fields)
19
+ parsed_response = JSON.parse(response.body)
20
+ parsed_response["errors"].detect { |error|
21
+ error["code"] == expected_fields.fetch(:code)
22
+ }
23
+ end
24
+
25
+ def message_matches(message,expected_message)
26
+ if expected_message.kind_of?(Regexp)
27
+ message =~ expected_message
28
+ else
29
+ message == expected_message
30
+ end
31
+ end
32
+
33
+ failure_message do |response|
34
+ if response.response_code != (expected_fields[:status] || 422)
35
+ "HTTP status was #{response.response_code} and not 422"
36
+ else
37
+ error = extract_error(response,expected_fields)
38
+ if error
39
+ if error["code"] != expected_fields.fetch(:code)
40
+ "Expected code to be '#{expected_fields[:code]}', but was '#{error['code']}'"
41
+ else
42
+ "Expected message to be '#{expected_fields[:message]}', but was '#{error['message']}'"
43
+ end
44
+ else
45
+ "Could not find an error for code #{expected_fields[:code]} from #{response.body}"
46
+ end
47
+ end
48
+ end
49
+ end
50
+
@@ -0,0 +1,51 @@
1
+ class TestHeaders
2
+ include ApiClients
3
+ def initialize(options={})
4
+ full_mimetype = mime_type(options)
5
+ @headers = {
6
+ "Accept" => full_mimetype,
7
+ "Content-Type" => full_mimetype,
8
+ }.tap { |headers|
9
+ set_authorization_header(headers,options)
10
+ }
11
+ end
12
+
13
+ def headers
14
+ @headers
15
+ end
16
+
17
+ private
18
+
19
+ def mime_type(options)
20
+ version_number = if options.key?(:version)
21
+ options.delete(:version)
22
+ else
23
+ "1"
24
+ end
25
+ version = "; version=#{version_number}" if version_number
26
+
27
+ mime_type = if options.key?(:mime_type)
28
+ options.delete(:mime_type)
29
+ else
30
+ "application/json"
31
+ end
32
+
33
+ "#{mime_type}#{version}"
34
+ end
35
+
36
+ def set_authorization_header(headers,options)
37
+ api_client_key = if options.key?(:api_client)
38
+ options.delete(:api_client).try(:key)
39
+ else
40
+ api_client.key
41
+ end
42
+ if api_client_key
43
+ if api_client_key.kind_of?(Array)
44
+ headers["Authorization"] = api_client_key.join(" ")
45
+ else
46
+ headers["Authorization"] = "CustomKeyAuth key=#{api_client_key}"
47
+ end
48
+ end
49
+ end
50
+
51
+ end
@@ -0,0 +1,32 @@
1
+ require_relative 'whitelisting_middleware'
2
+ module Stitches
3
+ # A middleware that requires all API calls to be for versioned JSON. This means that the Accept
4
+ # header (available to Rack apps as HTTP_ACCEPT) should be like so:
5
+ #
6
+ # application/json; version=1
7
+ #
8
+ # This just checks that you've specified some numeric version. ApiVersionConstraint should be used
9
+ # to "lock down" the versions you accept.
10
+ class ValidMimeType < Stitches::WhitelistingMiddleware
11
+
12
+ protected
13
+
14
+ def do_call(env)
15
+ accept = String(env["HTTP_ACCEPT"])
16
+ if accept =~ %r{application/json} && accept =~ %r{version=\d+}
17
+ @app.call(env)
18
+ else
19
+ NotAcceptableResponse.new(accept)
20
+ end
21
+ end
22
+
23
+ private
24
+
25
+ class NotAcceptableResponse < Rack::Response
26
+ def initialize(accept_header)
27
+ super("Not Acceptable - '#{accept_header}' didn't have the right mime type or version number. We only accept application/json with a version", 406)
28
+ end
29
+ end
30
+
31
+ end
32
+ end
@@ -0,0 +1,3 @@
1
+ module Stitches
2
+ VERSION = '3.0.0'
3
+ end
@@ -0,0 +1,29 @@
1
+ module Stitches
2
+ # A middleware that will skip its behavior if the path matches a white-listed URL
3
+ class WhitelistingMiddleware
4
+ def initialize(app, options={})
5
+
6
+ @app = app
7
+ @configuration = options[:configuration] || Stitches.configuration
8
+ @except = options[:except] || @configuration.whitelist_regexp
9
+
10
+ unless @except.nil? || @except.is_a?(Regexp)
11
+ raise ":except must be a Regexp"
12
+ end
13
+ end
14
+ def call(env)
15
+ if @except && @except.match(env["PATH_INFO"])
16
+ @app.call(env)
17
+ else
18
+ do_call(env)
19
+ end
20
+ end
21
+
22
+ protected
23
+
24
+ def do_call(env)
25
+ raise 'subclass must implement'
26
+ end
27
+
28
+ end
29
+ end
@@ -0,0 +1,17 @@
1
+ module Stitches
2
+ def self.configure(&block)
3
+ block.(configuration)
4
+ end
5
+
6
+ def self.configuration
7
+ @configuration ||= Configuration.new
8
+ end
9
+ end
10
+ require 'stitches/configuration'
11
+ require 'stitches/render_timestamps_in_iso8601_in_json'
12
+ require 'stitches/error'
13
+ require 'stitches/errors'
14
+ require 'stitches/api_generator'
15
+ require 'stitches/api_version_constraint'
16
+ require 'stitches/api_key'
17
+ require 'stitches/valid_mime_type'
@@ -0,0 +1,200 @@
1
+ require 'spec_helper.rb'
2
+
3
+ module MyApp
4
+ class Application
5
+ end
6
+ end
7
+
8
+ unless defined? ApiClient
9
+ class ApiClient
10
+ end
11
+ end
12
+
13
+ describe Stitches::ApiKey do
14
+ let(:app) { double("rack app") }
15
+ let(:api_clients) {
16
+ [
17
+ double(ApiClient, id: 42)
18
+ ]
19
+ }
20
+
21
+ before do
22
+ Stitches.configuration.reset_to_defaults!
23
+ Stitches.configuration.custom_http_auth_scheme = 'MyAwesomeInternalScheme'
24
+ fake_rails_app = MyApp::Application.new
25
+ allow(Rails).to receive(:application).and_return(fake_rails_app)
26
+ allow(app).to receive(:call).with(env)
27
+ allow(ApiClient).to receive(:where).and_return(api_clients)
28
+ end
29
+
30
+ subject(:middleware) { described_class.new(app, namespace: "/api") }
31
+
32
+ shared_examples "an unauthorized response" do
33
+ it "returns a 401" do
34
+ expect(@response.status).to eq(401)
35
+ end
36
+ it "sets the proper header" do
37
+ expect(@response.headers["WWW-Authenticate"]).to eq("MyAwesomeInternalScheme realm=MyApp")
38
+ end
39
+ it "stops the call chain preventing anything from happening" do
40
+ expect(app).not_to have_received(:call)
41
+ end
42
+ it "sends a reasonable message" do
43
+ expect(@response.body).to eq([expected_body])
44
+ end
45
+ end
46
+
47
+ describe "#call" do
48
+ context "not in namespace" do
49
+ context "not whitelisted" do
50
+ let(:env) {
51
+ {
52
+ "PATH_INFO" => "/index/apifoolingyou/home",
53
+ }
54
+ }
55
+
56
+ before do
57
+ @response = middleware.call(env)
58
+ end
59
+
60
+ it_behaves_like "an unauthorized response" do
61
+ let(:expected_body) { "Unauthorized - no authorization header" }
62
+ end
63
+ end
64
+ context "whitelisting" do
65
+ context "whitelist is explicit in middleware usage" do
66
+ before do
67
+ @response = middleware.call(env)
68
+ end
69
+ context "passes the whitelist" do
70
+ subject(:middleware) { described_class.new(app, except: %r{\A/resque\/.*\Z}) }
71
+ let(:env) {
72
+ {
73
+ "PATH_INFO" => "/resque/overview"
74
+ }
75
+ }
76
+ it "calls through to the rest of the chain" do
77
+ expect(app).to have_received(:call).with(env)
78
+ end
79
+ end
80
+
81
+ context "fails the whitelist" do
82
+ subject(:middleware) { described_class.new(app, except: %r{\A/resque\/.*\Z}) }
83
+ let(:env) {
84
+ {
85
+ "PATH_INFO" => "//resque/overview" # subtle
86
+ }
87
+ }
88
+ it_behaves_like "an unauthorized response" do
89
+ let(:expected_body) { "Unauthorized - no authorization header" }
90
+ end
91
+ end
92
+ context "except: is not given a regexp" do
93
+ let(:env) {
94
+ {
95
+ "PATH_INFO" => "//resque/overview" # subtle
96
+ }
97
+ }
98
+ it "blows up" do
99
+ expect {
100
+ described_class.new(app, except: "/resque")
101
+ }.to raise_error(/must be a Regexp/i)
102
+ end
103
+ end
104
+ end
105
+ context "whitelist is implicit from the configuration" do
106
+
107
+ before do
108
+ Stitches.configuration.whitelist_regexp = %r{\A/resque/.*\Z}
109
+ @response = middleware.call(env)
110
+ end
111
+
112
+ context "passes the whitelist" do
113
+ subject(:middleware) { described_class.new(app) }
114
+ let(:env) {
115
+ {
116
+ "PATH_INFO" => "/resque/overview"
117
+ }
118
+ }
119
+ it "calls through to the rest of the chain" do
120
+ expect(app).to have_received(:call).with(env)
121
+ end
122
+ end
123
+
124
+ context "fails the whitelist" do
125
+ subject(:middleware) { described_class.new(app) }
126
+ let(:env) {
127
+ {
128
+ "PATH_INFO" => "//resque/overview" # subtle
129
+ }
130
+ }
131
+ it_behaves_like "an unauthorized response" do
132
+ let(:expected_body) { "Unauthorized - no authorization header" }
133
+ end
134
+ end
135
+ end
136
+ end
137
+ end
138
+
139
+ context "valid key" do
140
+ let(:env) {
141
+ {
142
+ "PATH_INFO" => "/api/ping",
143
+ "HTTP_AUTHORIZATION" => "MyAwesomeInternalScheme key=foobar",
144
+ }
145
+ }
146
+
147
+ before do
148
+ @response = middleware.call(env)
149
+ end
150
+ it "calls through to the rest of the chain" do
151
+ expect(app).to have_received(:call).with(env)
152
+ end
153
+
154
+ it "sets the api_client's ID in the environment" do
155
+ expect(env[Stitches.configuration.env_var_to_hold_api_client_primary_key]).to eq(api_clients.first.id)
156
+ end
157
+ end
158
+
159
+ context "unauthorized responses" do
160
+ before do
161
+ @response = middleware.call(env)
162
+ @response.finish
163
+ end
164
+ context "invalid key" do
165
+ let(:env) {
166
+ {
167
+ "PATH_INFO" => "/api/ping",
168
+ "HTTP_AUTHORIZATION" => "MyAwesomeInternalScheme key=foobar",
169
+ }
170
+ }
171
+ let(:api_clients) { [] }
172
+
173
+ it_behaves_like "an unauthorized response" do
174
+ let(:expected_body) { "Unauthorized - key invalid" }
175
+ end
176
+ end
177
+ context "bad authorization type" do
178
+ let(:env) {
179
+ {
180
+ "PATH_INFO" => "/api/ping",
181
+ "HTTP_AUTHORIZATION" => "foobar",
182
+ }
183
+ }
184
+ it_behaves_like "an unauthorized response" do
185
+ let(:expected_body) { "Unauthorized - bad authorization type" }
186
+ end
187
+ end
188
+ context "no auth header" do
189
+ let(:env) {
190
+ {
191
+ "PATH_INFO" => "/api/ping",
192
+ }
193
+ }
194
+ it_behaves_like "an unauthorized response" do
195
+ let(:expected_body) { "Unauthorized - no authorization header" }
196
+ end
197
+ end
198
+ end
199
+ end
200
+ end