stitches 3.0.0

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