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,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,9 @@
1
+ module Stitches
2
+ class Error
3
+ attr_reader :code, :message
4
+ def initialize(code:, message:)
5
+ @code = code
6
+ @message = message
7
+ end
8
+ end
9
+ 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,2 @@
1
+ module Api
2
+ 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,2 @@
1
+ module Api::V1
2
+ 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,2 @@
1
+ module Api::V2
2
+ 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,2 @@
1
+ class ApiClient < ActiveRecord::Base
2
+ 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,5 @@
1
+ class EnableUuidOsspExtension < ActiveRecord::Migration
2
+ def change
3
+ enable_extension 'uuid-ossp'
4
+ end
5
+ 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