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,33 @@
1
+ require 'spec_helper.rb'
2
+
3
+ describe Stitches::ApiVersionConstraint do
4
+ let(:version) { 2 }
5
+ let(:request) { double("request", headers: headers) }
6
+
7
+ subject(:constraint) { described_class.new(version) }
8
+
9
+ context "no accept header" do
10
+ let(:headers) { {} }
11
+ it "doesn't match" do
12
+ expect(constraint.matches?(request)).to eq(false)
13
+ end
14
+ end
15
+ context "accept header missing version" do
16
+ let(:headers) { { accept: "application/json" } }
17
+ it "doesn't match" do
18
+ expect(constraint.matches?(request)).to eq(false)
19
+ end
20
+ end
21
+ context "accept header has wrong version" do
22
+ let(:headers) { { accept: "application/json; version=1" } }
23
+ it "doesn't match" do
24
+ expect(constraint.matches?(request)).to eq(false)
25
+ end
26
+ end
27
+ context "accept header has correct version" do
28
+ let(:headers) { { accept: "application/json; version=2" } }
29
+ it "matcheds" do
30
+ expect(constraint.matches?(request)).to eq(true)
31
+ end
32
+ end
33
+ end
@@ -0,0 +1,105 @@
1
+ require 'spec_helper.rb'
2
+
3
+ describe Stitches::Configuration do
4
+ before do
5
+ Stitches.configuration.reset_to_defaults!
6
+ end
7
+
8
+ describe "global configuration" do
9
+ let(:whitelist_regexp) { %r{foo} }
10
+ let(:custom_http_auth_scheme) { "Blah" }
11
+ let(:env_var_to_hold_api_client_primary_key) { "FOOBAR" }
12
+
13
+ it "can be configured globally" do
14
+ Stitches.configure do |config|
15
+ config.whitelist_regexp = whitelist_regexp
16
+ config.custom_http_auth_scheme = custom_http_auth_scheme
17
+ config.env_var_to_hold_api_client_primary_key = env_var_to_hold_api_client_primary_key
18
+ end
19
+
20
+ expect(Stitches.configuration.whitelist_regexp).to eq(whitelist_regexp)
21
+ expect(Stitches.configuration.custom_http_auth_scheme).to eq(custom_http_auth_scheme)
22
+ expect(Stitches.configuration.env_var_to_hold_api_client_primary_key).to eq(env_var_to_hold_api_client_primary_key)
23
+ end
24
+
25
+ it "defaults to nil for whitelist_regexp" do
26
+ expect(Stitches.configuration.whitelist_regexp).to be_nil
27
+ end
28
+
29
+ it "sets a default for env_var_to_hold_api_client_primary_key" do
30
+ expect(Stitches.configuration.env_var_to_hold_api_client_primary_key).to eq("STITCHES_API_CLIENT_ID")
31
+ end
32
+
33
+ it "blows up if you try to use custom_http_auth_scheme without having set it" do
34
+ expect {
35
+ Stitches.configuration.custom_http_auth_scheme
36
+ }.to raise_error(/you must set a value for custom_http_auth_scheme/i)
37
+ end
38
+ end
39
+ describe "whitelist_regexp" do
40
+ let(:config) { Stitches::Configuration.new }
41
+ it "must be a regexp" do
42
+ expect {
43
+ config.whitelist_regexp = "foo"
44
+ }.to raise_error(/whitelist_regexp must be a Regexp/i)
45
+ end
46
+ it "may be nil" do
47
+ expect {
48
+ config.whitelist_regexp = nil
49
+ }.not_to raise_error
50
+ end
51
+ it "may be a regexp" do
52
+ expect {
53
+ config.whitelist_regexp = /foo/
54
+ }.not_to raise_error
55
+ end
56
+ end
57
+
58
+ describe "custom_http_auth_scheme" do
59
+ let(:config) { Stitches::Configuration.new }
60
+ it "must be a string" do
61
+ expect {
62
+ config.custom_http_auth_scheme = 42
63
+ }.to raise_error(/custom_http_auth_scheme must be a String/i)
64
+ end
65
+ it "may not be nil" do
66
+ expect {
67
+ config.custom_http_auth_scheme = nil
68
+ }.to raise_error(/custom_http_auth_scheme may not be blank/i)
69
+ end
70
+ it "may not be a blank string" do
71
+ expect {
72
+ config.custom_http_auth_scheme = " "
73
+ }.to raise_error(/custom_http_auth_scheme may not be blank/i)
74
+ end
75
+ it "may be a String" do
76
+ expect {
77
+ config.custom_http_auth_scheme = "Foobar"
78
+ }.not_to raise_error
79
+ end
80
+ end
81
+
82
+ describe "env_var_to_hold_api_client_primary_key" do
83
+ let(:config) { Stitches::Configuration.new }
84
+ it "must be a string" do
85
+ expect {
86
+ config.env_var_to_hold_api_client_primary_key = 42
87
+ }.to raise_error(/env_var_to_hold_api_client_primary_key must be a String/i)
88
+ end
89
+ it "may not be nil" do
90
+ expect {
91
+ config.env_var_to_hold_api_client_primary_key = nil
92
+ }.to raise_error(/env_var_to_hold_api_client_primary_key may not be blank/i)
93
+ end
94
+ it "may not be a blank string" do
95
+ expect {
96
+ config.env_var_to_hold_api_client_primary_key = " "
97
+ }.to raise_error(/env_var_to_hold_api_client_primary_key may not be blank/i)
98
+ end
99
+ it "may be a String" do
100
+ expect {
101
+ config.env_var_to_hold_api_client_primary_key = "Foobar"
102
+ }.not_to raise_error
103
+ end
104
+ end
105
+ end
@@ -0,0 +1,99 @@
1
+ require 'spec_helper.rb'
2
+
3
+ class MyFakeError < StandardError
4
+ end
5
+
6
+ class FakePersonHolder
7
+ include ActiveModel::Validations
8
+ attr_accessor :name, :person
9
+
10
+ validates_presence_of :name
11
+
12
+ def valid?
13
+ # doing this because we can't use validates_associated on a non-AR object, and
14
+ # our logic doesn't depend on validates_associated, per se
15
+ super.tap {
16
+ unless person.valid?
17
+ errors.add(:person,"is not valid")
18
+ end
19
+ }
20
+ end
21
+ end
22
+
23
+ class FakePerson
24
+ include ActiveModel::Validations
25
+
26
+ attr_accessor :first_name, :last_name, :age
27
+
28
+ validates_each :first_name, :last_name do |record, attr, value|
29
+ record.errors.add attr, 'starts with z.' if value.to_s[0] == ?z
30
+ end
31
+
32
+ validates_numericality_of :age
33
+ validates_presence_of :first_name
34
+ end
35
+
36
+ describe Stitches::Errors do
37
+ it "can be created from an exception" do
38
+ exception = MyFakeError.new("OH NOES!")
39
+ errors = Stitches::Errors.from_exception(exception)
40
+
41
+ expect(errors.size).to eq(1)
42
+ expect(errors.first.code).to eq("my_fake")
43
+ expect(errors.first.message).to eq("OH NOES!")
44
+ end
45
+
46
+ it "renders useful JSON" do
47
+ errors = Stitches::Errors.new([
48
+ Stitches::Error.new(code: "not_found", message: "Was not found, yo"),
49
+ Stitches::Error.new(code: "and_you_should_feel_bad", message: "And you should feel bad about even asking"),
50
+ ])
51
+
52
+ expect(errors.to_json).to eq(
53
+ [
54
+ {
55
+ "code" => "not_found",
56
+ "message" => "Was not found, yo",
57
+ },
58
+ {
59
+ "code" => "and_you_should_feel_bad",
60
+ "message" => "And you should feel bad about even asking",
61
+ }
62
+ ].to_json
63
+ )
64
+ end
65
+
66
+ context "creation from an active record object" do
67
+ let(:object) { FakePerson.new.tap { |person|
68
+ person.age = "asdfasdf"
69
+ person.last_name = "zjohnson"
70
+ }}
71
+ it "sets reasonable messages for the fields of the object" do
72
+ object.valid?
73
+ errors = Stitches::Errors.from_active_record_object(object)
74
+ errors_hash = JSON.parse(errors.to_json).sort_by {|_| _["code"] }
75
+ expect(errors_hash[0]["code"]).to eq("age_invalid")
76
+ expect(errors_hash[0]["message"]).to eq("Age is not a number")
77
+ expect(errors_hash[1]["code"]).to eq("first_name_invalid")
78
+ expect(errors_hash[1]["message"]).to eq("First name can't be blank")
79
+ expect(errors_hash[2]["code"]).to eq("last_name_invalid")
80
+ expect(errors_hash[2]["message"]).to eq("Last name starts with z.")
81
+ end
82
+
83
+ it "digs one level deep into the object for associated active-records" do
84
+ holder = FakePersonHolder.new
85
+ holder.name = nil
86
+ holder.person = object
87
+
88
+ holder.valid?
89
+
90
+ errors = Stitches::Errors.from_active_record_object(holder)
91
+ errors_hash = JSON.parse(errors.to_json).sort_by {|_| _["code"] }
92
+ expect(errors_hash[0]["code"]).to eq("name_invalid")
93
+ expect(errors_hash[0]["message"]).to eq("Name can't be blank")
94
+ expect(errors_hash[1]["code"]).to eq("person_invalid")
95
+ expect(errors_hash[1]["message"]).to eq("Age is not a number, First name can't be blank, Last name starts with z.")
96
+ end
97
+ end
98
+ end
99
+
@@ -0,0 +1,78 @@
1
+ require 'spec_helper.rb'
2
+ require 'stitches/spec'
3
+
4
+ describe "have_api_error" do
5
+ let(:errors) {
6
+ [
7
+ { code: "foo", message: "bar" },
8
+ { code: "baz", message: "quux" }
9
+ ]
10
+ }
11
+ let(:response) {
12
+ double(
13
+ response_code: response_code,
14
+ body: { errors: errors }.to_json)
15
+ }
16
+ context "missing required arguments from expectation" do
17
+ let(:response_code) { 422 }
18
+ it "blows up with a decent message" do
19
+ expect {
20
+ expect(response).to have_api_error(message: errors.first[:message])
21
+ }.to raise_error(/key not found: :code/)
22
+ expect {
23
+ expect(response).to have_api_error(code: errors.first[:code])
24
+ }.to raise_error(/key not found: :message/)
25
+ end
26
+ end
27
+ context "no expected status specified" do
28
+ context "status is 422" do
29
+ let(:response_code) { 422 }
30
+ context "an error in the expectation exists" do
31
+ it "indicates there is an error" do
32
+ expect(response).to have_api_error(errors.first)
33
+ end
34
+ it "indicates there is an error for another error in the errors list" do
35
+ expect(response).to have_api_error(errors.second)
36
+ end
37
+ it "indicates there is an error via regexp" do
38
+ expect(response).to have_api_error(code: errors.second[:code], message: /^.*$/)
39
+ end
40
+ end
41
+ context "no error from the expectation exists" do
42
+ it "indicates there is no error" do
43
+ expect(response).not_to have_api_error(code: "blah", message: "crud")
44
+ end
45
+ end
46
+ end
47
+ context "status is 200" do
48
+ let(:response_code) { 200 }
49
+ it "indicates there is no error" do
50
+ expect(response).not_to have_api_error(errors.first)
51
+ end
52
+ end
53
+ context "status is e.g. 404" do
54
+ let(:response_code) { 404 }
55
+ it "indicates there is no error" do
56
+ expect(response).not_to have_api_error(errors.first)
57
+ end
58
+ end
59
+ end
60
+ context "expected status is specified" do
61
+ context "status is the expected status" do
62
+ let(:response_code) { 404 }
63
+ it "indicates there is an error" do
64
+ expect(response).to have_api_error(status: 404,
65
+ code: errors.first[:code],
66
+ message: errors.first[:message])
67
+ end
68
+ end
69
+ context "status is not the expected status" do
70
+ let(:response_code) { 422 }
71
+ it "indicates there is no error" do
72
+ expect(response).not_to have_api_error(status: 404,
73
+ code: errors.first[:code],
74
+ message: errors.first[:bar])
75
+ end
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,10 @@
1
+ GEM_ROOT = File.expand_path(File.join(File.dirname(__FILE__),'..'))
2
+ Dir["#{GEM_ROOT}/spec/support/**/*.rb"].sort.each {|f| require f}
3
+
4
+ require 'rails/all'
5
+ require 'stitches'
6
+
7
+ RSpec.configure do |config|
8
+ config.order = "random"
9
+ end
10
+ I18n.enforce_available_locales = false # situps
@@ -0,0 +1,166 @@
1
+ require 'spec_helper.rb'
2
+
3
+ describe Stitches::ValidMimeType do
4
+ let(:app) { double("rack app") }
5
+
6
+ before do
7
+ allow(app).to receive(:call).with(env)
8
+ end
9
+
10
+ subject(:middleware) { described_class.new(app, namespace: "/api") }
11
+
12
+ shared_examples "an unacceptable response" do
13
+ it "returns a 406" do
14
+ expect(@response.status).to eq(406)
15
+ end
16
+ it "stops the call chain preventing anything from happening" do
17
+ expect(app).not_to have_received(:call)
18
+ end
19
+ it "sends a reasonable message" do
20
+ expect(@response.body.first).to match(/didn't have the right mime type or version number. We only accept application\/json/)
21
+ end
22
+ end
23
+
24
+ describe "#call" do
25
+ context "not in namespace" do
26
+ context "not in whitelist" do
27
+ let(:env) {
28
+ {
29
+ "PATH_INFO" => "/index/home",
30
+ }
31
+ }
32
+
33
+ before do
34
+ @response = middleware.call(env)
35
+ end
36
+
37
+ it_behaves_like "an unacceptable response"
38
+ end
39
+ context "whitelisting" do
40
+ let(:env) {
41
+ {
42
+ "PATH_INFO" => "/index/home",
43
+ }
44
+ }
45
+
46
+ context "whitelist is explicit in middleware usage" do
47
+ before do
48
+ @response = middleware.call(env)
49
+ end
50
+
51
+ context "passes the whitelist" do
52
+ subject(:middleware) { described_class.new(app, except: %r{\A/resque\/.*\Z}) }
53
+ let(:env) {
54
+ {
55
+ "PATH_INFO" => "/resque/overview"
56
+ }
57
+ }
58
+ it "calls through to the rest of the chain" do
59
+ expect(app).to have_received(:call).with(env)
60
+ end
61
+ end
62
+
63
+ context "fails the whitelist" do
64
+ subject(:middleware) { described_class.new(app, except: %r{\A/resque\/.*\Z}) }
65
+ let(:env) {
66
+ {
67
+ "PATH_INFO" => "//resque/overview" # subtle
68
+ }
69
+ }
70
+ it_behaves_like "an unacceptable response"
71
+ end
72
+ context "except: is not given a regexp" do
73
+ let(:env) {
74
+ {
75
+ "PATH_INFO" => "//resque/overview"
76
+ }
77
+ }
78
+ it "blows up" do
79
+ expect {
80
+ described_class.new(app, except: "/resque")
81
+ }.to raise_error(/must be a Regexp/i)
82
+ end
83
+ end
84
+ end
85
+ context "whitelist is implicit from the configuration" do
86
+
87
+ before do
88
+ Stitches.configuration.whitelist_regexp = %r{\A/resque/.*\Z}
89
+ @response = middleware.call(env)
90
+ end
91
+
92
+ context "passes the whitelist" do
93
+ subject(:middleware) { described_class.new(app) }
94
+ let(:env) {
95
+ {
96
+ "PATH_INFO" => "/resque/overview"
97
+ }
98
+ }
99
+ it "calls through to the rest of the chain" do
100
+ expect(app).to have_received(:call).with(env)
101
+ end
102
+ end
103
+
104
+ context "fails the whitelist" do
105
+ subject(:middleware) { described_class.new(app) }
106
+ let(:env) {
107
+ {
108
+ "PATH_INFO" => "//resque/overview" # subtle
109
+ }
110
+ }
111
+ it_behaves_like "an unacceptable response"
112
+ end
113
+ end
114
+ end
115
+ end
116
+
117
+ context "valid header" do
118
+ let(:env) {
119
+ {
120
+ "PATH_INFO" => "/api/ping",
121
+ "HTTP_ACCEPT" => "application/json; version=99",
122
+ }
123
+ }
124
+
125
+ before do
126
+ @response = middleware.call(env)
127
+ end
128
+ it "calls through to the rest of the chain" do
129
+ expect(app).to have_received(:call).with(env)
130
+ end
131
+ end
132
+
133
+ context "unacceptable responses" do
134
+ before do
135
+ @response = middleware.call(env)
136
+ @response.finish
137
+ end
138
+ context "no header" do
139
+ let(:env) {
140
+ {
141
+ "PATH_INFO" => "/api/ping",
142
+ }
143
+ }
144
+ it_behaves_like "an unacceptable response"
145
+ end
146
+ context "bad mime type" do
147
+ let(:env) {
148
+ {
149
+ "PATH_INFO" => "/api/ping",
150
+ "HTTP_ACCEPT" => "application/json; version=bleorgh",
151
+ }
152
+ }
153
+ it_behaves_like "an unacceptable response"
154
+ end
155
+ context "bad version" do
156
+ let(:env) {
157
+ {
158
+ "PATH_INFO" => "/api/ping",
159
+ "HTTP_ACCEPT" => "application/xml; version=1",
160
+ }
161
+ }
162
+ it_behaves_like "an unacceptable response"
163
+ end
164
+ end
165
+ end
166
+ end