simple_oauth2 0.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 (64) hide show
  1. checksums.yaml +7 -0
  2. data/.codeclimate.yml +25 -0
  3. data/.coveralls.yml +1 -0
  4. data/.gitignore +26 -0
  5. data/.hound.yml +4 -0
  6. data/.rubocop.yml +5 -0
  7. data/.rubocop_todo.yml +12 -0
  8. data/.travis.yml +31 -0
  9. data/Gemfile +18 -0
  10. data/LICENSE +21 -0
  11. data/README.md +11 -0
  12. data/Rakefile +11 -0
  13. data/gemfiles/nobrainer.rb +15 -0
  14. data/lib/simple_oauth2/configuration/class_accessors.rb +36 -0
  15. data/lib/simple_oauth2/configuration/constants.rb +36 -0
  16. data/lib/simple_oauth2/configuration.rb +169 -0
  17. data/lib/simple_oauth2/generators/authorization.rb +64 -0
  18. data/lib/simple_oauth2/generators/base.rb +31 -0
  19. data/lib/simple_oauth2/generators/token.rb +71 -0
  20. data/lib/simple_oauth2/helpers.rb +40 -0
  21. data/lib/simple_oauth2/mixins/nobrainer/access_grant.rb +62 -0
  22. data/lib/simple_oauth2/mixins/nobrainer/access_token.rb +98 -0
  23. data/lib/simple_oauth2/mixins/nobrainer/client.rb +43 -0
  24. data/lib/simple_oauth2/resource/bearer.rb +20 -0
  25. data/lib/simple_oauth2/responses.rb +62 -0
  26. data/lib/simple_oauth2/scopes.rb +59 -0
  27. data/lib/simple_oauth2/strategies/authorization_code.rb +22 -0
  28. data/lib/simple_oauth2/strategies/base.rb +61 -0
  29. data/lib/simple_oauth2/strategies/client_credentials.rb +21 -0
  30. data/lib/simple_oauth2/strategies/code.rb +25 -0
  31. data/lib/simple_oauth2/strategies/password.rb +21 -0
  32. data/lib/simple_oauth2/strategies/refresh_token.rb +53 -0
  33. data/lib/simple_oauth2/strategies/token.rb +24 -0
  34. data/lib/simple_oauth2/uniq_token.rb +20 -0
  35. data/lib/simple_oauth2/version.rb +26 -0
  36. data/lib/simple_oauth2.rb +62 -0
  37. data/logo.png +0 -0
  38. data/simple_oauth2.gemspec +22 -0
  39. data/spec/configuration/config_spec.rb +181 -0
  40. data/spec/configuration/version_spec.rb +11 -0
  41. data/spec/dummy/endpoints/authorization.rb +15 -0
  42. data/spec/dummy/endpoints/custom_authorization.rb +21 -0
  43. data/spec/dummy/endpoints/custom_token.rb +21 -0
  44. data/spec/dummy/endpoints/status.rb +51 -0
  45. data/spec/dummy/endpoints/token.rb +22 -0
  46. data/spec/dummy/orm/nobrainer/app/config/db.rb +8 -0
  47. data/spec/dummy/orm/nobrainer/app/models/access_grant.rb +3 -0
  48. data/spec/dummy/orm/nobrainer/app/models/access_token.rb +3 -0
  49. data/spec/dummy/orm/nobrainer/app/models/client.rb +3 -0
  50. data/spec/dummy/orm/nobrainer/app/models/user.rb +11 -0
  51. data/spec/dummy/orm/nobrainer/app/twitter.rb +51 -0
  52. data/spec/dummy/orm/nobrainer/config.ru +37 -0
  53. data/spec/dummy/simple_oauth2_config.rb +7 -0
  54. data/spec/requests/flows/authorization_code_spec.rb +177 -0
  55. data/spec/requests/flows/client_credentials_spec.rb +163 -0
  56. data/spec/requests/flows/code_spec.rb +98 -0
  57. data/spec/requests/flows/password_spec.rb +183 -0
  58. data/spec/requests/flows/refresh_token_spec.rb +282 -0
  59. data/spec/requests/flows/token_spec.rb +113 -0
  60. data/spec/requests/protected_resources_spec.rb +65 -0
  61. data/spec/requests/revoke_token_spec.rb +90 -0
  62. data/spec/spec_helper.rb +51 -0
  63. data/spec/support/helper.rb +11 -0
  64. metadata +125 -0
@@ -0,0 +1,62 @@
1
+ module Simple
2
+ module OAuth2
3
+ module NoBrainer
4
+ # Includes all the required API, associations, validations and callbacks
5
+ module AccessGrant
6
+ extend ActiveSupport::Concern
7
+
8
+ included do # rubocop:disable Metrics/BlockLength
9
+ include ::NoBrainer::Document
10
+ include ::NoBrainer::Document::Timestamps
11
+
12
+ belongs_to :client, class_name: Simple::OAuth2.config.client_class_name,
13
+ foreign_key: :client_id, primary_key: :id
14
+ belongs_to :resource_owner, class_name: Simple::OAuth2.config.resource_owner_class_name,
15
+ foreign_key: :resource_owner_id, primary_key: :id
16
+
17
+ before_save { self.updated_at = Time.now }
18
+ before_validation :setup_expiration, if: :new_record?
19
+
20
+ field :resource_owner_id, type: String, index: true, required: true
21
+ field :client_id, type: String, index: true, required: true
22
+
23
+ field :token,
24
+ type: String,
25
+ required: true,
26
+ uniq: true,
27
+ index: true,
28
+ default: -> { Simple::OAuth2.config.token_generator.generate }
29
+
30
+ field :redirect_uri, type: String, required: true
31
+ field :scopes, type: String
32
+
33
+ field :revoked_at, type: Time
34
+ field :expires_at, type: Time, required: true
35
+ field :created_at, type: Time, required: true, default: -> { Time.now }
36
+ field :updated_at, type: Time, required: true, default: -> { Time.now }
37
+
38
+ class << self
39
+ def create_for(client, resource_owner, redirect_uri, scopes = nil)
40
+ create(
41
+ client_id: client.id,
42
+ resource_owner_id: resource_owner.id,
43
+ redirect_uri: redirect_uri,
44
+ scopes: scopes
45
+ )
46
+ end
47
+
48
+ def authenticate(token)
49
+ where(token: token.to_s).first
50
+ end
51
+ end
52
+
53
+ private
54
+
55
+ def setup_expiration
56
+ self.expires_at = Time.now.utc + Simple::OAuth2.config.authorization_code_lifetime if expires_at.nil?
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,98 @@
1
+ module Simple
2
+ module OAuth2
3
+ module NoBrainer
4
+ # Includes all the required API, associations, validations and callbacks
5
+ module AccessToken
6
+ extend ActiveSupport::Concern
7
+
8
+ included do # rubocop:disable Metrics/BlockLength
9
+ include ::NoBrainer::Document
10
+ include ::NoBrainer::Document::Timestamps
11
+
12
+ before_save { self.updated_at = Time.now }
13
+ before_validation :setup_expiration, if: :new_record?
14
+
15
+ belongs_to :client, class_name: Simple::OAuth2.config.client_class_name,
16
+ foreign_key: :client_id, primary_key: :id
17
+ belongs_to :resource_owner, class_name: Simple::OAuth2.config.resource_owner_class_name,
18
+ foreign_key: :resource_owner_id, primary_key: :id
19
+
20
+ field :resource_owner_id, type: String, index: true, required: true
21
+ field :client_id, type: String, index: true, required: true
22
+ field :token,
23
+ type: String,
24
+ index: true,
25
+ required: true,
26
+ uniq: true,
27
+ default: -> { Simple::OAuth2.config.token_generator.generate }
28
+ field :refresh_token,
29
+ type: String,
30
+ index: true,
31
+ uniq: true,
32
+ default: -> do
33
+ if Simple::OAuth2.config.issue_refresh_token
34
+ Simple::OAuth2.config.token_generator.generate
35
+ else
36
+ ''
37
+ end
38
+ end
39
+
40
+ field :scopes, type: String
41
+
42
+ field :revoked_at, type: Time
43
+ field :expires_at, type: Time, required: true
44
+ field :created_at, type: Time, required: true, default: -> { Time.now }
45
+ field :updated_at, type: Time, required: true, default: -> { Time.now }
46
+
47
+ class << self
48
+ def create_for(client, resource_owner, scopes = nil)
49
+ create(
50
+ client_id: client.id,
51
+ resource_owner_id: resource_owner.id,
52
+ scopes: scopes
53
+ )
54
+ end
55
+
56
+ def authenticate(token, token_type_hint = nil)
57
+ return if token.blank?
58
+
59
+ if token_type_hint == 'refresh_token'
60
+ where(refresh_token: token).first
61
+ else
62
+ where(token: token).first
63
+ end
64
+ end
65
+ end
66
+
67
+ def expired?
68
+ expires_at && Time.now.utc > expires_at
69
+ end
70
+
71
+ def revoked?
72
+ revoked_at && revoked_at <= Time.now.utc
73
+ end
74
+
75
+ def revoke!(revoked_at = Time.now.utc)
76
+ update!(revoked_at: revoked_at)
77
+ end
78
+
79
+ def to_bearer_token
80
+ {
81
+ access_token: token,
82
+ expires_in: expires_at && Simple::OAuth2.config.access_token_lifetime.to_i,
83
+ refresh_token: refresh_token,
84
+ scope: scopes
85
+ }
86
+ end
87
+
88
+ private
89
+
90
+ def setup_expiration
91
+ expires_in = Simple::OAuth2.config.access_token_lifetime.to_i
92
+ self.expires_at = Time.now.utc + expires_in if expires_at.nil? && !expires_in.nil?
93
+ end
94
+ end
95
+ end
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,43 @@
1
+ module Simple
2
+ module OAuth2
3
+ module NoBrainer
4
+ # Includes all the required API, associations, validations and callbacks
5
+ module Client
6
+ extend ActiveSupport::Concern
7
+
8
+ included do
9
+ include ::NoBrainer::Document
10
+ include ::NoBrainer::Document::Timestamps
11
+
12
+ before_save { self.updated_at = Time.now }
13
+
14
+ has_many :access_tokens, class_name: Simple::OAuth2.config.access_token_class_name, foreign_key: :client_id
15
+ has_many :access_grants, class_name: Simple::OAuth2.config.access_grant_class_name, foreign_key: :client_id
16
+
17
+ field :name, type: String, required: true
18
+ field :redirect_uri, type: String, required: true
19
+
20
+ field :key,
21
+ type: String,
22
+ required: true,
23
+ index: true,
24
+ uniq: true,
25
+ default: -> { Simple::OAuth2.config.token_generator.generate }
26
+ field :secret,
27
+ type: String,
28
+ required: true,
29
+ index: true,
30
+ uniq: true,
31
+ default: -> { Simple::OAuth2.config.token_generator.generate }
32
+
33
+ field :created_at, type: Time, required: true, default: -> { Time.now }
34
+ field :updated_at, type: Time, required: true, default: -> { Time.now }
35
+
36
+ def self.authenticate(key)
37
+ where(key: key.to_s).first
38
+ end
39
+ end
40
+ end
41
+ end
42
+ end
43
+ end
@@ -0,0 +1,20 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Resource
4
+ # OAuth2 middleware Protected Resource Endpoint
5
+ class Bearer
6
+ def initialize(app)
7
+ @app = app
8
+ end
9
+
10
+ # See https://github.com/nov/rack-oauth2/wiki/Server-Resource-Endpoint
11
+ def call(env)
12
+ app = Rack::OAuth2::Server::Resource::Bearer.new(@app, Simple::OAuth2.config.realm) do |req|
13
+ Simple::OAuth2.config.token_authenticator.call(req)
14
+ end
15
+ app.call(env)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,62 @@
1
+ module Simple
2
+ module OAuth2
3
+ # Processes Rack Responses and contains helper methods
4
+ #
5
+ # @return [Object] Rack response
6
+ #
7
+ # @example
8
+ # rack_response = [
9
+ # 200,
10
+ # { 'Content-Type' => 'application/json' },
11
+ # Rack::BodyProxy.new(Rack::Response.new('200'.to_json))
12
+ # ]
13
+ # response = Simple::OAuth2::Responses.new(rack_response)
14
+ #
15
+ # response.status #=> 200
16
+ # response.headers #=> {}
17
+ # response.body #=> '200'
18
+ # response #=> <Simple::OAuth2::Responses:0x007fc9f32080b8 @response=[
19
+ # 200,
20
+ # {},
21
+ # <Rack::BodyProxy:0x007fc9f3208108
22
+ # @block=nil,
23
+ # @body= <Rack::Response:0x007fc9f3208388
24
+ # @block=nil,
25
+ # @body=["\"200\""],
26
+ # @header={"Content-Length"=>"5"},
27
+ # @length=5,
28
+ # @status=200
29
+ # >,
30
+ # @closed=false
31
+ # >
32
+ # ]
33
+ #
34
+ class Responses
35
+ # Simple::OAuth2 response class
36
+ #
37
+ # @param response [Array] raw Rack::Response object
38
+ #
39
+ def initialize(response)
40
+ @response = response
41
+ end
42
+
43
+ # Response status
44
+ def status
45
+ @response[0]
46
+ end
47
+
48
+ # Response headers
49
+ def headers
50
+ @response[1]
51
+ end
52
+
53
+ # Response JSON-parsed body
54
+ def body
55
+ response_body = @response[2].body.first
56
+ return {} if response_body.nil? || response_body.empty?
57
+
58
+ JSON.parse(response_body)
59
+ end
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,59 @@
1
+ module Simple
2
+ module OAuth2
3
+ # Scopes helper for scopes validation
4
+ class Scopes
5
+ # Checks if requested scopes are valid
6
+ #
7
+ # @param access_scopes [Array] scopes of AccessToken class
8
+ # @param scopes [Array<String, Symbol>] array, symbol, string of any object that responds to `to_a`
9
+ #
10
+ def self.valid?(access_scopes, scopes)
11
+ new(access_scopes, scopes).valid?
12
+ end
13
+
14
+ # Helper class initializer
15
+ #
16
+ # @param access_scopes [Array] scopes of AccessToken class
17
+ # @param scopes [Array<String, Symbol>] array, symbol, string of any object that responds to `to_a`
18
+ #
19
+ def initialize(access_scopes, scopes = [])
20
+ @scopes = to_array(scopes)
21
+ @access_scopes = to_array(access_scopes)
22
+ end
23
+
24
+ # Checks if requested scopes (passed and processed on initialization) are presented in the AccessToken
25
+ #
26
+ # @return [Boolean] true if requested scopes are empty or present in access_scopes
27
+ #
28
+ def valid?
29
+ @scopes.empty? || present_in_access_token?
30
+ end
31
+
32
+ private
33
+
34
+ # Checks if scopes present in access_scopes
35
+ #
36
+ # @return [Boolean] true if requested scopes present in access_scopes
37
+ #
38
+ def present_in_access_token?
39
+ Set.new(@access_scopes) >= Set.new(@scopes)
40
+ end
41
+
42
+ # Converts scopes set to the array
43
+ #
44
+ # @param scopes [Array<String, Symbol>, #to_a]
45
+ # string, symbol, array or object that responds to `to_a`
46
+ # @return [Array<String>] array of scopes
47
+ #
48
+ def to_array(scopes)
49
+ collection = if scopes.is_a?(Array) || scopes.respond_to?(:to_a)
50
+ scopes.to_a
51
+ elsif scopes.is_a?(String) || scopes.is_a?(Symbol)
52
+ scopes.split(',')
53
+ end
54
+
55
+ collection.map(&:to_s)
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,22 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Authorization Code strategy class
5
+ # Processes request and respond with Access Token
6
+ class AuthorizationCode < Base
7
+ class << self
8
+ # Processes Authorization Code request
9
+ def process(request)
10
+ client = token_verify_client!(request)
11
+
12
+ code = authenticate_access_grant(request) || request.invalid_grant!
13
+ code.redirect_uri == request.redirect_uri || request.invalid_grant!
14
+
15
+ token = config.access_token_class.create_for(client, code.resource_owner, code.scopes)
16
+ expose_to_bearer_token(token)
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,61 @@
1
+ module Simple
2
+ module OAuth2
3
+ # Simple::OAuth2 strategies namespace
4
+ module Strategies
5
+ # Base Strategies class.
6
+ # Contains common functionality for all the descendants
7
+ class Base
8
+ class << self
9
+ # Authenticates Client from the request
10
+ def authenticate_client(request)
11
+ config.client_class.authenticate(request.client_id)
12
+ end
13
+
14
+ # Authenticates Resource Owner from the request
15
+ def authenticate_resource_owner(client, request)
16
+ config.resource_owner_class.oauth_authenticate(
17
+ client,
18
+ request.params['username'],
19
+ request.params['password']
20
+ )
21
+ end
22
+
23
+ # Authenticates Access Grant from the request
24
+ def authenticate_access_grant(request)
25
+ config.access_grant_class.authenticate(request.code)
26
+ end
27
+
28
+ # Exposes token object to Bearer token.
29
+ #
30
+ # @param token [AccessToken] any object that responds to `to_bearer_token`
31
+ # @return [Rack::OAuth2::AccessToken::Bearer] bearer token instance
32
+ #
33
+ def expose_to_bearer_token(token)
34
+ Rack::OAuth2::AccessToken::Bearer.new(token.to_bearer_token)
35
+ end
36
+
37
+ # Token endpoint, check client for exact matching verifier
38
+ def token_verify_client!(request)
39
+ client = authenticate_client(request) || request.invalid_client!
40
+ client.secret == request.client_secret || request.invalid_client!
41
+ client
42
+ end
43
+
44
+ # Authorization endpoint, check client and redirect_uri for exact matching verifier
45
+ def authorization_verify_client!(request, response)
46
+ client = authenticate_client(request) || request.bad_request!
47
+ response.redirect_uri = request.verify_redirect_uri!(client.redirect_uri)
48
+ client
49
+ end
50
+
51
+ private
52
+
53
+ # Short getter for Simple::OAuth2 configuration.
54
+ def config
55
+ Simple::OAuth2.config
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+ end
@@ -0,0 +1,21 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # ClientCredentials strategy class.
5
+ # Processes request and respond with Access Token
6
+ class ClientCredentials < Base
7
+ class << self
8
+ # Processes ClientCredentials request
9
+ def process(request)
10
+ client = authenticate_client(request) || request.invalid_client!
11
+
12
+ resource_owner = authenticate_resource_owner(client, request) || request.invalid_grant!
13
+
14
+ token = config.access_token_class.create_for(client, resource_owner, request.scope.join(','))
15
+ expose_to_bearer_token(token)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,25 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Code strategy class.
5
+ # Processes request and respond with Code
6
+ class Code < Base
7
+ class << self
8
+ # Processes Code request
9
+ def process(request, response)
10
+ client = authorization_verify_client!(request, response)
11
+
12
+ authorization_code = config.access_grant_class.create_for(
13
+ client,
14
+ config.resource_owner_authenticator.call(request),
15
+ response.redirect_uri,
16
+ request.scope.join(',')
17
+ )
18
+
19
+ response.code = authorization_code.token
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,21 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Resource Owner Password Credentials strategy class
5
+ # Processes request and respond with Access Token
6
+ class Password < Base
7
+ class << self
8
+ # Processes Password request
9
+ def process(request)
10
+ client = token_verify_client!(request)
11
+
12
+ resource_owner = authenticate_resource_owner(client, request) || request.invalid_grant!
13
+
14
+ token = config.access_token_class.create_for(client, resource_owner, request.scope.join(','))
15
+ expose_to_bearer_token(token)
16
+ end
17
+ end
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,53 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Refresh Token strategy class
5
+ # Processes request and respond with Access Token
6
+ class RefreshToken < Base
7
+ class << self
8
+ # Processes Refresh Token request
9
+ def process(request)
10
+ client = token_verify_client!(request)
11
+ refresh_token = verify_refresh_token!(request, client.id)
12
+
13
+ token = config.access_token_class.create_for(
14
+ client, refresh_token.resource_owner, request.scope.join(',')
15
+ )
16
+ run_callback_on_refresh_token(refresh_token) if config.on_refresh_runnable?
17
+
18
+ expose_to_bearer_token(token)
19
+ end
20
+
21
+ private
22
+
23
+ # Check refresh token and client id for exact matching verifier
24
+ def verify_refresh_token!(request, client_id)
25
+ refresh_token = config.access_token_class.authenticate(request.refresh_token, 'refresh_token')
26
+ refresh_token || request.invalid_grant!
27
+ refresh_token.client_id == client_id || request.unauthorized_client!
28
+
29
+ refresh_token
30
+ end
31
+
32
+ # Invokes custom callback on Access Token refresh.
33
+ # If callback is a proc, then call it with token.
34
+ # If access token responds to callback value (symbol for example), then call it from the token.
35
+ #
36
+ # @param access_token [Object] Access Token instance
37
+ #
38
+ def run_callback_on_refresh_token(access_token)
39
+ callback = config.on_refresh
40
+
41
+ if callback.respond_to?(:call)
42
+ callback.call(access_token)
43
+ elsif access_token.respond_to?(callback)
44
+ access_token.send(callback)
45
+ else
46
+ raise(ArgumentError, ":on_refresh is not a block and Access Token class doesn't respond to #{callback}!")
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,24 @@
1
+ module Simple
2
+ module OAuth2
3
+ module Strategies
4
+ # Token strategy class
5
+ # Processes request and respond with Access Token
6
+ class Token < Base
7
+ class << self
8
+ # Processes Token request
9
+ def process(request, response)
10
+ client = authorization_verify_client!(request, response)
11
+
12
+ access_token = config.access_token_class.create_for(
13
+ client,
14
+ config.resource_owner_authenticator.call(request),
15
+ request.scope.join(',')
16
+ )
17
+
18
+ response.access_token = expose_to_bearer_token(access_token)
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -0,0 +1,20 @@
1
+ module Simple
2
+ module OAuth2
3
+ # OAuth2 helper for generation of unique token values.
4
+ # Can process custom payload and options
5
+ module UniqToken
6
+ # Generates unique token value
7
+ #
8
+ # @param _payload [Hash]
9
+ # payload
10
+ # @param options [Hash]
11
+ # options for generator
12
+ #
13
+ # @return [String]
14
+ # unique token value
15
+ def self.generate(_payload = {}, options = {})
16
+ SecureRandom.hex(options.delete(:size) || 32)
17
+ end
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,26 @@
1
+ module Simple
2
+ # Semantic versioning
3
+ module OAuth2
4
+ # Simple::OAuth2 version
5
+ # @return [Gem::Version] version of the gem
6
+ #
7
+ def self.gem_version
8
+ Gem::Version.new VERSION::STRING
9
+ end
10
+
11
+ # Simple::OAuth2 semantic versioning module
12
+ # Contains detailed info about gem version
13
+ module VERSION
14
+ # Level changes for implementation level detail changes, such as small bug fixes
15
+ PATCH = 0
16
+ # Level changes for any backwards compatible API changes, such as new functionality/features
17
+ MINOR = 0
18
+ # Level changes for backwards incompatible API changes,
19
+ # such as changes that will break existing users code if they update
20
+ MAJOR = 0
21
+
22
+ # Full gem version string
23
+ STRING = [MAJOR, MINOR, PATCH].join('.')
24
+ end
25
+ end
26
+ end