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.
- 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
|