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,59 @@
|
|
1
|
+
require_relative 'whitelisting_middleware'
|
2
|
+
|
3
|
+
module Stitches
|
4
|
+
# A middleware that requires an API key for certain transactions, and makes its id available
|
5
|
+
# in the enviornment for controllers.
|
6
|
+
#
|
7
|
+
# This follows http://www.ietf.org/rfc/rfc2617.txt for use of custom authorization methods, namely
|
8
|
+
# the specification of an API key.
|
9
|
+
#
|
10
|
+
# Apps are expected to set the Authorization header (available to Rack apps as the environment
|
11
|
+
# variable HTTP_AUTHORIZATION) to
|
12
|
+
#
|
13
|
+
# MyInternalRealm key=<<api key>>
|
14
|
+
#
|
15
|
+
# where MyInternalRealm is the value returned by Stitches.configuration.custom_http_auth_scheme and
|
16
|
+
# <<api key>> is the UUID provided to the caller. It's expected that there is an entry
|
17
|
+
# in the API_CLIENTS table with this value for "key".
|
18
|
+
#
|
19
|
+
# If that is the case, env[Stitches.configuration.env_var_to_hold_api_client_primary_key] will be the primary key of the
|
20
|
+
# ApiClient that it maps to.
|
21
|
+
class ApiKey < Stitches::WhitelistingMiddleware
|
22
|
+
|
23
|
+
def initialize(app,options = {})
|
24
|
+
super(app,options)
|
25
|
+
@realm = Rails.application.class.parent.to_s
|
26
|
+
end
|
27
|
+
|
28
|
+
protected
|
29
|
+
|
30
|
+
def do_call(env)
|
31
|
+
authorization = env["HTTP_AUTHORIZATION"]
|
32
|
+
if authorization
|
33
|
+
if authorization =~ /#{@configuration.custom_http_auth_scheme}\s+key=(.*)\s*$/
|
34
|
+
key = $1
|
35
|
+
client = ::ApiClient.where(key: key).first
|
36
|
+
if client.present?
|
37
|
+
env[@configuration.env_var_to_hold_api_client_primary_key] = client.id
|
38
|
+
@app.call(env)
|
39
|
+
else
|
40
|
+
UnauthorizedResponse.new("key invalid",@realm,@configuration.custom_http_auth_scheme)
|
41
|
+
end
|
42
|
+
else
|
43
|
+
UnauthorizedResponse.new("bad authorization type",@realm,@configuration.custom_http_auth_scheme)
|
44
|
+
end
|
45
|
+
else
|
46
|
+
UnauthorizedResponse.new("no authorization header",@realm,@configuration.custom_http_auth_scheme)
|
47
|
+
end
|
48
|
+
end
|
49
|
+
|
50
|
+
private
|
51
|
+
|
52
|
+
class UnauthorizedResponse < Rack::Response
|
53
|
+
def initialize(reason,realm,custom_http_auth_scheme)
|
54
|
+
super("Unauthorized - #{reason}", 401, { "WWW-Authenticate" => "#{custom_http_auth_scheme} realm=#{realm}" })
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
end
|
59
|
+
end
|
@@ -0,0 +1,32 @@
|
|
1
|
+
module Stitches
|
2
|
+
# A routing constraint to route versioned requests to the right controller.
|
3
|
+
# This allows you to organize your code around version numbers without requiring that clients
|
4
|
+
# put version numbers in their URLs. It's expected that you've set up ValidMimeType
|
5
|
+
# as a middleware to ensure these numbers exist
|
6
|
+
#
|
7
|
+
# Example
|
8
|
+
#
|
9
|
+
# namespace :api do
|
10
|
+
# scope module: :v1, constraints: Stitches::ApiVersionConstraint.new(1) do
|
11
|
+
# resource 'ping', only: [ :create ]
|
12
|
+
# end
|
13
|
+
# scope module: :v2, constraints: Stitches::ApiVersionConstraint.new(2) do
|
14
|
+
# resource 'ping', only: [ :create ]
|
15
|
+
# end
|
16
|
+
# end
|
17
|
+
#
|
18
|
+
# This will route requests with ;version=1 to +Api::V1::PingsController+, while those
|
19
|
+
# with ;version=2 will go to +Api::V2::PingsController+.
|
20
|
+
#
|
21
|
+
class ApiVersionConstraint
|
22
|
+
def initialize(version)
|
23
|
+
@version = version
|
24
|
+
end
|
25
|
+
|
26
|
+
def matches?(request)
|
27
|
+
request.headers.fetch(:accept).include?("version=#{@version}")
|
28
|
+
rescue KeyError
|
29
|
+
false
|
30
|
+
end
|
31
|
+
end
|
32
|
+
end
|
@@ -0,0 +1,77 @@
|
|
1
|
+
module Stitches
|
2
|
+
end
|
3
|
+
|
4
|
+
class Stitches::Configuration
|
5
|
+
|
6
|
+
def initialize
|
7
|
+
reset_to_defaults!
|
8
|
+
end
|
9
|
+
|
10
|
+
# Mainly for testing, this resets all configuration to the default value
|
11
|
+
def reset_to_defaults!
|
12
|
+
@whitelist_regexp = nil
|
13
|
+
@custom_http_auth_scheme = UnsetString.new("custom_http_auth_scheme")
|
14
|
+
@env_var_to_hold_api_client_primary_key = NonNullString.new("env_var_to_hold_api_client_primary_key","STITCHES_API_CLIENT_ID")
|
15
|
+
end
|
16
|
+
|
17
|
+
# A RegExp that whitelists URLS around the mime type and api key requirements.
|
18
|
+
# nil means that ever request must have a proper mime type and api key.
|
19
|
+
attr_reader :whitelist_regexp
|
20
|
+
def whitelist_regexp=(new_whitelist_regexp)
|
21
|
+
unless new_whitelist_regexp.nil? || new_whitelist_regexp.is_a?(Regexp)
|
22
|
+
raise "whitelist_regexp must be a Regexp, not a #{new_whitelist_regexp.class}"
|
23
|
+
end
|
24
|
+
@whitelist_regexp = new_whitelist_regexp
|
25
|
+
end
|
26
|
+
|
27
|
+
# The name of your custom http auth scheme. This must be set, and has no default
|
28
|
+
def custom_http_auth_scheme
|
29
|
+
@custom_http_auth_scheme.to_s
|
30
|
+
end
|
31
|
+
|
32
|
+
def custom_http_auth_scheme=(new_custom_http_auth_scheme)
|
33
|
+
@custom_http_auth_scheme = NonNullString.new("custom_http_auth_scheme",new_custom_http_auth_scheme)
|
34
|
+
end
|
35
|
+
|
36
|
+
# The name of the environment variable that the ApiKey middleware should use to
|
37
|
+
# place the primary key of the authenticated ApiKey. For example, if a user provides
|
38
|
+
# the api key 1234-1234-1234-1234, and that maps to the primary key 42 in your database,
|
39
|
+
# the environment will contain "42" in the key provided here.
|
40
|
+
def env_var_to_hold_api_client_primary_key
|
41
|
+
@env_var_to_hold_api_client_primary_key.to_s
|
42
|
+
end
|
43
|
+
|
44
|
+
def env_var_to_hold_api_client_primary_key=(new_env_var_to_hold_api_client_primary_key)
|
45
|
+
@env_var_to_hold_api_client_primary_key = NonNullString.new("env_var_to_hold_api_client_primary_key",new_env_var_to_hold_api_client_primary_key)
|
46
|
+
end
|
47
|
+
|
48
|
+
private
|
49
|
+
|
50
|
+
class NonNullString
|
51
|
+
def initialize(name,string)
|
52
|
+
unless string.nil? || string.is_a?(String)
|
53
|
+
raise "#{name} must be a String, not a #{string.class}"
|
54
|
+
end
|
55
|
+
if String(string).strip.length == 0
|
56
|
+
raise "#{name} may not be blank"
|
57
|
+
end
|
58
|
+
@string = string
|
59
|
+
end
|
60
|
+
|
61
|
+
def to_s
|
62
|
+
@string
|
63
|
+
end
|
64
|
+
alias :to_str :to_s
|
65
|
+
end
|
66
|
+
|
67
|
+
class UnsetString
|
68
|
+
def initialize(name)
|
69
|
+
@name = name
|
70
|
+
end
|
71
|
+
|
72
|
+
def to_s
|
73
|
+
raise "You must set a value for #{@name} "
|
74
|
+
end
|
75
|
+
alias :to_str :to_s
|
76
|
+
end
|
77
|
+
end
|
@@ -0,0 +1,101 @@
|
|
1
|
+
module Stitches
|
2
|
+
# A container for error messages you intend to send as a response to an API request.
|
3
|
+
# The canonical error format is a list of all errors that occured, with each error consistent of a code
|
4
|
+
# (useful for programmatic logic based on error condition) and a message (useful for displaying to
|
5
|
+
# either a log or a human, as documented by the API).
|
6
|
+
#
|
7
|
+
# The general usage of this is:
|
8
|
+
#
|
9
|
+
# type.json do
|
10
|
+
# render json: {
|
11
|
+
# errors: Stitches::Errors.new([
|
12
|
+
# Stitches::Error.new(code: "name_required", message: "The name is required")
|
13
|
+
# Stitches::Error.new(code: "numeric_age", message: "The age should be a number")
|
14
|
+
# ])
|
15
|
+
# },
|
16
|
+
# status: 404
|
17
|
+
# end
|
18
|
+
#
|
19
|
+
# More likely, you will create these from an exception or from an ActiveRecord::Base.
|
20
|
+
#
|
21
|
+
# == Exceptions
|
22
|
+
#
|
23
|
+
# If you create exceptions for the various known errors in your app, you can rely
|
24
|
+
# on the logic in +from_exception+ to generate your code and message.
|
25
|
+
#
|
26
|
+
# rescue BadPaymentTypeError => ex
|
27
|
+
# Stitches::Errors.from_exception(ex)
|
28
|
+
# end
|
29
|
+
#
|
30
|
+
# This will create an errors array with one element, which is an error with code "bad_payment_type"
|
31
|
+
# and the message of whatever the exception message was.
|
32
|
+
#
|
33
|
+
# So, by judicious use and naming of your exceptions, you could do something like this in your controller:
|
34
|
+
#
|
35
|
+
# rescue_from MyAppSpecificExceptionBase do |ex|
|
36
|
+
# render json: { errors: Stitches::Errors.from_exception(ex) }, status: 400
|
37
|
+
# end
|
38
|
+
#
|
39
|
+
# And the codes will match the hierarchy of exceptions inheriting from +MyAppSpecificExceptionBase+.
|
40
|
+
#
|
41
|
+
# == ActiveRecord
|
42
|
+
#
|
43
|
+
# You can also create errors from an ActiveRecord object:
|
44
|
+
#
|
45
|
+
# person = Person.create(params)
|
46
|
+
# if person.valid?
|
47
|
+
# render json: { person: person }, status: 201
|
48
|
+
# else
|
49
|
+
# render json: { errors: Stitches::Errors.from_active_record_object(person)
|
50
|
+
# end
|
51
|
+
#
|
52
|
+
# This will create one error for each field of the main object. The code will be "field_invalid" and
|
53
|
+
# the message will a comma-joined list of what's wrong with that field, e.g. "Amount can't be blank, Amount must be a number".
|
54
|
+
#
|
55
|
+
# Remember, for APIs, you don't want to send bad user data directly to the API, so this mechanism isn't designed for form fields and
|
56
|
+
# all the other things Rails gives you. It's for the API client to be able to tell the programmer what went wrong.
|
57
|
+
class Errors
|
58
|
+
include Enumerable
|
59
|
+
|
60
|
+
def self.from_exception(exception)
|
61
|
+
code = exception.class.name.underscore.gsub(/_error$/,'')
|
62
|
+
self.new([
|
63
|
+
Error.new(
|
64
|
+
code: code,
|
65
|
+
message: exception.message
|
66
|
+
)
|
67
|
+
])
|
68
|
+
end
|
69
|
+
|
70
|
+
def self.from_active_record_object(object)
|
71
|
+
errors = object.errors.to_hash.map { |field,errors|
|
72
|
+
code = "#{field}_invalid".parameterize
|
73
|
+
message = if object.send(field).respond_to?(:errors)
|
74
|
+
object.send(field).errors.full_messages.sort.join(', ')
|
75
|
+
else
|
76
|
+
object.errors.full_messages_for(field).sort.join(', ')
|
77
|
+
end
|
78
|
+
Stitches::Error.new(code: "#{field}_invalid".parameterize, message: message)
|
79
|
+
}
|
80
|
+
self.new(errors)
|
81
|
+
end
|
82
|
+
|
83
|
+
def initialize(individual_errors)
|
84
|
+
@individual_errors = individual_errors
|
85
|
+
end
|
86
|
+
|
87
|
+
def size
|
88
|
+
@individual_errors.size
|
89
|
+
end
|
90
|
+
|
91
|
+
def each
|
92
|
+
if block_given?
|
93
|
+
@individual_errors.each do |error|
|
94
|
+
yield error
|
95
|
+
end
|
96
|
+
else
|
97
|
+
@individual_errors.each
|
98
|
+
end
|
99
|
+
end
|
100
|
+
end
|
101
|
+
end
|
@@ -0,0 +1,19 @@
|
|
1
|
+
class Api::ApiController < ActionController::Base
|
2
|
+
rescue_from ActiveRecord::RecordNotFound do |exception|
|
3
|
+
respond_to do |type|
|
4
|
+
type.json { render json: { errors: Stitches::Errors.new([ Stitches::Error.new(code: "not_found", message: exception.message) ]) }, status: 404 }
|
5
|
+
type.all { render :nothing => true, :status => 404 }
|
6
|
+
end
|
7
|
+
end
|
8
|
+
|
9
|
+
def current_user
|
10
|
+
api_client
|
11
|
+
end
|
12
|
+
|
13
|
+
protected
|
14
|
+
|
15
|
+
def api_client
|
16
|
+
@api_client ||= ::ApiClient.find(request.env[Stitches.configuration.env_var_to_hold_api_client_primary_key])
|
17
|
+
end
|
18
|
+
|
19
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class Api::V1::PingsController < Api::ApiController
|
2
|
+
|
3
|
+
def create
|
4
|
+
respond_to do |format|
|
5
|
+
format.json do
|
6
|
+
if ping_params[:error]
|
7
|
+
render json: { errors: Stitches::Errors.new([ Stitches::Error.new(code: "test", message: ping_params[:error]) ])} , status: 422
|
8
|
+
else
|
9
|
+
render json: { ping: { status: "ok" } }, status: (ping_params[:status] || "201").to_i
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def ping_params
|
18
|
+
params.permit(:error, :status)
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,20 @@
|
|
1
|
+
class Api::V2::PingsController < Api::ApiController
|
2
|
+
|
3
|
+
def create
|
4
|
+
respond_to do |format|
|
5
|
+
format.json do
|
6
|
+
if ping_params[:error]
|
7
|
+
render json: { errors: Stitches::Errors.new([ Stitches::Error.new(code: "test", message: ping_params[:error]) ])} , status: 422
|
8
|
+
else
|
9
|
+
render json: { ping: { status_v2: "ok" } }, status: (ping_params[:status] || "201").to_i
|
10
|
+
end
|
11
|
+
end
|
12
|
+
end
|
13
|
+
end
|
14
|
+
|
15
|
+
private
|
16
|
+
|
17
|
+
def ping_params
|
18
|
+
params.permit(:error, :status)
|
19
|
+
end
|
20
|
+
end
|
@@ -0,0 +1,14 @@
|
|
1
|
+
require 'stitches'
|
2
|
+
|
3
|
+
Stitches.configure do |configuration|
|
4
|
+
# Regexp of urls that do not require ApiKeys or valid, versioned mime types
|
5
|
+
configuration.whitelist_regexp = %r{\A/(resque|docs|assets)(\Z|/.*\Z)}
|
6
|
+
|
7
|
+
# Name of the custom Authorization scheme. See http://www.ietf.org/rfc/rfc2617.txt for details,
|
8
|
+
# but generally should be a string with no spaces or special characters.
|
9
|
+
configuration.custom_http_auth_scheme = "CustomKeyAuth"
|
10
|
+
|
11
|
+
# Env var that gets the primary key of the authenticated ApiKey
|
12
|
+
# for access in your controllers, so they don't need to re-parse the header
|
13
|
+
# configuration.env_var_to_hold_api_client_primary_key = "YOUR_ENV_VAR"
|
14
|
+
end
|
@@ -0,0 +1,11 @@
|
|
1
|
+
class CreateApiClients < ActiveRecord::Migration
|
2
|
+
def change
|
3
|
+
create_table :api_clients do |t|
|
4
|
+
t.string :name, null: false
|
5
|
+
t.column :key, "uuid default uuid_generate_v4()", null: false
|
6
|
+
t.column :created_at, "timestamp with time zone default now()", null: false
|
7
|
+
end
|
8
|
+
add_index :api_clients, [:name], unique: true
|
9
|
+
add_index :api_clients, [:key], unique: true
|
10
|
+
end
|
11
|
+
end
|
@@ -0,0 +1,10 @@
|
|
1
|
+
desc "Generates a new API Key. Requires a name, e.g. rake generate_api_key[YOUR_APP_NAME_HERE]"
|
2
|
+
task :generate_api_key, [:name] => :environment do |t, args|
|
3
|
+
fail "You must provide a name" unless args.name
|
4
|
+
api_client = ::ApiClient.create!(name: args.name)
|
5
|
+
api_client.reload
|
6
|
+
puts "Your key is #{api_client.key}"
|
7
|
+
puts
|
8
|
+
puts "You can test it via curl:"
|
9
|
+
puts "curl -v -X POST -H 'Accept: application/json; version=1' -H 'Content-type: application/json; version=1' -H 'Authorization: CustomKeyAuth key=#{api_client.key}' https://your_app.herokuapp.com/api/ping"
|
10
|
+
end
|
@@ -0,0 +1,46 @@
|
|
1
|
+
require 'spec_helper'
|
2
|
+
require 'rspec_api_documentation/dsl'
|
3
|
+
|
4
|
+
resource "Ping (V1)" do
|
5
|
+
include ApiClients
|
6
|
+
header "Accept", "application/json; version=1"
|
7
|
+
header "Content-Type", "application/json; version=1"
|
8
|
+
|
9
|
+
post "/api/ping" do
|
10
|
+
response_field :ping, "The name of the ping", "Type" => "Object"
|
11
|
+
response_field :status, "The status of the ping", scope: "ping", "Type" => "String"
|
12
|
+
example "ping the server to validate your client's happy path" do
|
13
|
+
|
14
|
+
header "Authorization", "CustomKeyAuth key=#{api_client.key}"
|
15
|
+
do_request
|
16
|
+
|
17
|
+
result = JSON.parse(response_body)
|
18
|
+
expect(result).to eq({ "ping" => { "status" => "ok" }})
|
19
|
+
|
20
|
+
status.should == 201
|
21
|
+
|
22
|
+
end
|
23
|
+
end
|
24
|
+
post "/api/ping" do
|
25
|
+
parameter :error, "If set, will return an error instead of ok", "Type" => "Object"
|
26
|
+
|
27
|
+
response_field :errors, "Array of errors", "Type" => "Array"
|
28
|
+
response_field :code, "Programmer key describing the error (useful for logic)", scope: "errors", "Type" => "String"
|
29
|
+
response_field :message, "Human-readable error message", scope: "errors", "Type" => "String"
|
30
|
+
|
31
|
+
let(:error) { "OH NOES!" }
|
32
|
+
let(:raw_post) { params.to_json }
|
33
|
+
|
34
|
+
example "ping the server to validate your client's error handling" do
|
35
|
+
|
36
|
+
header "Authorization", "CustomKeyAuth key=#{api_client.key}"
|
37
|
+
do_request
|
38
|
+
|
39
|
+
result = JSON.parse(response_body)
|
40
|
+
expect(result).to eq({ "errors" => [ { "code" => "test", "message" => "OH NOES!" }]})
|
41
|
+
|
42
|
+
status.should == 422
|
43
|
+
|
44
|
+
end
|
45
|
+
end
|
46
|
+
end
|