stitches 3.0.0
Sign up to get free protection for your applications and to get access to all the features.
- checksums.yaml +7 -0
- data/.gitignore +10 -0
- data/.ruby-gemset +1 -0
- data/.ruby-version +1 -0
- data/Gemfile +3 -0
- data/Gemfile.lock +126 -0
- data/README.md +220 -0
- data/Rakefile +13 -0
- data/lib/stitches.rb +3 -0
- data/lib/stitches/api_generator.rb +97 -0
- data/lib/stitches/api_key.rb +59 -0
- data/lib/stitches/api_version_constraint.rb +32 -0
- data/lib/stitches/configuration.rb +77 -0
- data/lib/stitches/error.rb +9 -0
- data/lib/stitches/errors.rb +101 -0
- data/lib/stitches/generator_files/app/controllers/api.rb +2 -0
- data/lib/stitches/generator_files/app/controllers/api/api_controller.rb +19 -0
- data/lib/stitches/generator_files/app/controllers/api/v1.rb +2 -0
- data/lib/stitches/generator_files/app/controllers/api/v1/pings_controller.rb +20 -0
- data/lib/stitches/generator_files/app/controllers/api/v2.rb +2 -0
- data/lib/stitches/generator_files/app/controllers/api/v2/pings_controller.rb +20 -0
- data/lib/stitches/generator_files/app/models/api_client.rb +2 -0
- data/lib/stitches/generator_files/config/initializers/stitches.rb +14 -0
- data/lib/stitches/generator_files/db/migrate/create_api_clients.rb +11 -0
- data/lib/stitches/generator_files/db/migrate/enable_uuid_ossp_extension.rb +5 -0
- data/lib/stitches/generator_files/lib/tasks/generate_api_key.rake +10 -0
- data/lib/stitches/generator_files/spec/acceptance/ping_v1_spec.rb +46 -0
- data/lib/stitches/generator_files/spec/features/api_spec.rb +96 -0
- data/lib/stitches/railtie.rb +9 -0
- data/lib/stitches/render_timestamps_in_iso8601_in_json.rb +9 -0
- data/lib/stitches/spec.rb +4 -0
- data/lib/stitches/spec/api_clients.rb +5 -0
- data/lib/stitches/spec/be_iso_8601_utc_encoded.rb +10 -0
- data/lib/stitches/spec/have_api_error.rb +50 -0
- data/lib/stitches/spec/test_headers.rb +51 -0
- data/lib/stitches/valid_mime_type.rb +32 -0
- data/lib/stitches/version.rb +3 -0
- data/lib/stitches/whitelisting_middleware.rb +29 -0
- data/lib/stitches_norailtie.rb +17 -0
- data/spec/api_key_spec.rb +200 -0
- data/spec/api_version_constraint_spec.rb +33 -0
- data/spec/configuration_spec.rb +105 -0
- data/spec/errors_spec.rb +99 -0
- data/spec/spec/have_api_error_spec.rb +78 -0
- data/spec/spec_helper.rb +10 -0
- data/spec/valid_mime_type_spec.rb +166 -0
- data/stitches.gemspec +24 -0
- 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,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,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
|