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