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