virtuous 0.0.1

Sign up to get free protection for your applications and to get access to all the features.
Files changed (52) hide show
  1. checksums.yaml +7 -0
  2. data/.github/workflows/test.yml +26 -0
  3. data/.gitignore +5 -0
  4. data/.reek.yml +36 -0
  5. data/.rubocop.yml +87 -0
  6. data/.ruby-version +1 -0
  7. data/.yardopts +1 -0
  8. data/CHANGELOG.md +18 -0
  9. data/Gemfile +17 -0
  10. data/LICENSE +21 -0
  11. data/README.md +54 -0
  12. data/Rakefile +24 -0
  13. data/lib/virtuous/client/contact.rb +220 -0
  14. data/lib/virtuous/client/contact_address.rb +78 -0
  15. data/lib/virtuous/client/gift.rb +394 -0
  16. data/lib/virtuous/client/gift_designation.rb +59 -0
  17. data/lib/virtuous/client/individual.rb +125 -0
  18. data/lib/virtuous/client/recurring_gift.rb +86 -0
  19. data/lib/virtuous/client.rb +272 -0
  20. data/lib/virtuous/error.rb +54 -0
  21. data/lib/virtuous/helpers/hash_helper.rb +28 -0
  22. data/lib/virtuous/helpers/string_helper.rb +31 -0
  23. data/lib/virtuous/parse_oj.rb +24 -0
  24. data/lib/virtuous/version.rb +5 -0
  25. data/lib/virtuous.rb +12 -0
  26. data/logo/virtuous.svg +1 -0
  27. data/spec/spec_helper.rb +25 -0
  28. data/spec/support/client_factory.rb +10 -0
  29. data/spec/support/fixtures/contact.json +112 -0
  30. data/spec/support/fixtures/contact_address.json +20 -0
  31. data/spec/support/fixtures/contact_addresses.json +42 -0
  32. data/spec/support/fixtures/contact_gifts.json +80 -0
  33. data/spec/support/fixtures/gift.json +55 -0
  34. data/spec/support/fixtures/gift_designation_query_options.json +2701 -0
  35. data/spec/support/fixtures/gift_designations.json +175 -0
  36. data/spec/support/fixtures/gifts.json +112 -0
  37. data/spec/support/fixtures/import.json +0 -0
  38. data/spec/support/fixtures/individual.json +46 -0
  39. data/spec/support/fixtures/recurring_gift.json +26 -0
  40. data/spec/support/fixtures_helper.rb +5 -0
  41. data/spec/support/virtuous_mock.rb +101 -0
  42. data/spec/virtuous/client_spec.rb +270 -0
  43. data/spec/virtuous/error_spec.rb +74 -0
  44. data/spec/virtuous/resources/contact_address_spec.rb +75 -0
  45. data/spec/virtuous/resources/contact_spec.rb +137 -0
  46. data/spec/virtuous/resources/gift_designation_spec.rb +70 -0
  47. data/spec/virtuous/resources/gift_spec.rb +249 -0
  48. data/spec/virtuous/resources/individual_spec.rb +95 -0
  49. data/spec/virtuous/resources/recurring_gift_spec.rb +67 -0
  50. data/spec/virtuous_spec.rb +7 -0
  51. data/virtuous.gemspec +25 -0
  52. metadata +121 -0
@@ -0,0 +1,272 @@
1
+ require 'faraday'
2
+
3
+ Dir[File.expand_path('client/*.rb', __dir__)].sort.each { |f| require f }
4
+
5
+ module Virtuous
6
+ ##
7
+ # An API client for Virtuous.
8
+ # See {initialize} for a full list of supported configuration options.
9
+ #
10
+ # ### Authentication
11
+ #
12
+ # #### Api key auth
13
+ #
14
+ # To generate an api key you need to visit the
15
+ # [virtuous connect dashboard](https://connect.virtuoussoftware.com/).
16
+ # Then you can use the key by setting the `api_key` param while creating the client or
17
+ # by setting the `VIRTUOUS_KEY` environment variable beforehand.
18
+ #
19
+ # client = Virtuous::Client.new(
20
+ # api_key: api_key,
21
+ # # ...
22
+ # )
23
+ #
24
+ # #### Oauth
25
+ #
26
+ # First, an access token needs to be fetched by providing a user's email and password.
27
+ # This will return an access token that lasts for 15 days, and a refresh token that should be
28
+ # stored and used to create clients in the future.
29
+ # The client will use the expiry date of the access token to automatically determine when a
30
+ # new one needs to be fetched.
31
+ #
32
+ # client = Virtuous::Client.new
33
+ # client.authenticate(email, password)
34
+ # user.update(
35
+ # access_token: client.access_token, refresh_token: client.refresh_token,
36
+ # token_expiration: client.expires_at
37
+ # )
38
+ #
39
+ # # Afterwards
40
+ #
41
+ # client = Virtuous::Client.new(
42
+ # access_token: user.access_token, refresh_token: user.refresh_token,
43
+ # expires_at: user.token_expiration
44
+ # )
45
+ #
46
+ # # Use client
47
+ #
48
+ # if client.refreshed
49
+ # # Update values if they changed
50
+ # user.update(
51
+ # access_token: client.access_token, refresh_token: client.refresh_token,
52
+ # token_expiration: client.expires_at
53
+ # )
54
+ # end
55
+ #
56
+ # #### Two-Factor Authentication
57
+ #
58
+ # client = Virtuous::Client.new
59
+ # response = client.authenticate(email, password)
60
+ # if response[:requires_otp]
61
+ # # Prompt user for OTP
62
+ # client.authenticate(email, password, otp)
63
+ # end
64
+ #
65
+ # Check resource modules to see available client methods:
66
+ #
67
+ # - {Contact}
68
+ # - {Individual}
69
+ # - {Gift}
70
+ # - {GiftDesignation}
71
+ class Client
72
+ include Contact
73
+ include ContactAddress
74
+ include Individual
75
+ include Gift
76
+ include RecurringGift
77
+ include GiftDesignation
78
+
79
+ ##
80
+ # Access token used for OAuth authentication.
81
+ attr_reader :access_token
82
+ ##
83
+ # Token used to refresh the access token when it has expired.
84
+ attr_reader :refresh_token
85
+ ##
86
+ # Expiration date of the access token.
87
+ attr_reader :expires_at
88
+ ##
89
+ # True if the access token has been refreshed.
90
+ attr_reader :refreshed
91
+
92
+ ##
93
+ # @option config [String] :base_url The base url to use for API calls.
94
+ # Default: `https://api.virtuoussoftware.com`.
95
+ # @option config [String] :api_key The key for the API.
96
+ # @option config [String] :access_token The OAuth access token.
97
+ # @option config [String] :refresh_token The OAuth refresh token.
98
+ # @option config [Time] :expires_at The expiration date of the access token.
99
+ # @option config [Hash] :ssl_options SSL options to use with Faraday.
100
+ # @option config [Logger] :logger Logger object for Faraday.
101
+ # @option config [Symbol] :adapter Faraday adapter to use. Default: `Faraday.default_adapter`.
102
+ def initialize(**config)
103
+ read_config(config)
104
+
105
+ @refreshed = false
106
+ end
107
+
108
+ ##
109
+ # @!method get(path, body = {})
110
+ # Makes a `GET` request to the path.
111
+ #
112
+ # @param path [String] The path to send the request.
113
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
114
+ #
115
+ # @return [Object] The body of the response.
116
+
117
+ ##
118
+ # @!method post(path, body = {})
119
+ # Makes a `POST` request to the path.
120
+ #
121
+ # @param path [String] The path to send the request.
122
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
123
+ #
124
+ # @return [Object] The body of the response.
125
+
126
+ ##
127
+ # @!method delete(path, body = {})
128
+ # Makes a `DELETE` request to the path.
129
+ #
130
+ # @param path [String] The path to send the request.
131
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
132
+ #
133
+ # @return [Object] The body of the response.
134
+
135
+ ##
136
+ # @!method patch(path, body = {})
137
+ # Makes a `PATCH` request to the path.
138
+ #
139
+ # @param path [String] The path to send the request.
140
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
141
+ #
142
+ # @return [Object] The body of the response.
143
+
144
+ ##
145
+ # @!method put(path, body = {})
146
+ # Makes a `PUT` request to the path.
147
+ #
148
+ # @param path [String] The path to send the request.
149
+ # @param body [Hash] Hash of URI query unencoded key/value pairs.
150
+ #
151
+ # @return [Object] The body of the response.
152
+ [:get, :post, :delete, :patch, :put].each do |http_method|
153
+ define_method(http_method) do |path, body = {}|
154
+ connection.public_send(http_method, path, body).body
155
+ end
156
+ end
157
+
158
+ ##
159
+ # Send a request to get an access token using the email and password of a user.
160
+ #
161
+ # @param email [String] The email of the user.
162
+ # @param password [String] The password of the user.
163
+ # @param otp [Integer] The One Time Password of the two-factor authentication flow.
164
+ #
165
+ # @return [Hash] If the authentication was a success, it contains the OAuth tokens.
166
+ # If two factor is required, it will include the `:requires_otp` key.
167
+ # @example Output if authentication is a success
168
+ # {
169
+ # access_token: '<access_token>',
170
+ # refresh_token: '<refresh_token>',
171
+ # expires_at: Time
172
+ # }
173
+ #
174
+ # @example Output if Two-Factor Authentication is required
175
+ # {
176
+ # requires_otp: true
177
+ # }
178
+ def authenticate(email, password, otp = nil)
179
+ data = { grant_type: 'password', username: email, password: password }
180
+ data[:otp] = otp unless otp.nil?
181
+ get_access_token(data)
182
+ end
183
+
184
+ private
185
+
186
+ def encode(string)
187
+ # CGI.escape changes spaces to '+', and we need '%20' instead.
188
+ CGI.escape(string).gsub('+', '%20')
189
+ end
190
+
191
+ def read_config(config)
192
+ [
193
+ :base_url, :api_key, :access_token, :refresh_token, :expires_at, :ssl_options, :logger,
194
+ :adapter
195
+ ].each do |attribute|
196
+ instance_variable_set("@#{attribute}", config[attribute])
197
+ end
198
+
199
+ @api_key ||= ENV.fetch('VIRTUOUS_KEY', nil)
200
+ @base_url ||= 'https://api.virtuoussoftware.com'
201
+ @adapter ||= Faraday.default_adapter
202
+ end
203
+
204
+ def use_access_token?
205
+ !@access_token.nil? || !@refresh_token.nil?
206
+ end
207
+
208
+ def get_access_token(body)
209
+ response = unauthorized_connection.post('/Token', URI.encode_www_form(body))
210
+
211
+ return { requires_otp: true } if response.env.status == 202
212
+
213
+ self.oauth_tokens = response.body
214
+
215
+ { access_token: @access_token, refresh_token: @refresh_token, expires_at: @expires_at }
216
+ end
217
+
218
+ def oauth_tokens=(values)
219
+ @access_token = values['access_token']
220
+ @expires_at = Time.parse(values['.expires'])
221
+ @refresh_token = values['refresh_token']
222
+ @refreshed = true
223
+ end
224
+
225
+ def check_token_expiration
226
+ if !@refresh_token.nil? &&
227
+ ((!@expires_at.nil? && @expires_at < Time.now) || @access_token.nil?)
228
+ get_access_token({ grant_type: 'refresh_token', refresh_token: @refresh_token })
229
+ end
230
+ end
231
+
232
+ def parse(data)
233
+ data = JSON.parse(data) if data.is_a?(String)
234
+
235
+ HashHelper.deep_transform_keys(data) { |key| StringHelper.underscore(key).to_sym }
236
+ end
237
+
238
+ def format(data)
239
+ HashHelper.deep_transform_keys(data) { |key| StringHelper.camelize(key.to_s) }
240
+ end
241
+
242
+ def bearer_token
243
+ if @api_key.nil?
244
+ check_token_expiration
245
+ return @access_token
246
+ end
247
+
248
+ @api_key
249
+ end
250
+
251
+ def connection
252
+ unauthorized_connection do |conn|
253
+ conn.request :authorization, 'Bearer', -> { bearer_token }
254
+ end
255
+ end
256
+
257
+ def unauthorized_connection
258
+ options = { url: @base_url }
259
+
260
+ options[:ssl] = @ssl_options unless @ssl_options.nil?
261
+
262
+ Faraday.new(options) do |conn|
263
+ conn.request :json
264
+ conn.response :oj
265
+ conn.response :logger, @logger if @logger
266
+ conn.use FaradayMiddleware::VirtuousErrorHandler
267
+ conn.adapter @adapter
268
+ yield(conn) if block_given?
269
+ end
270
+ end
271
+ end
272
+ end
@@ -0,0 +1,54 @@
1
+ module Virtuous # :nodoc: all
2
+ class Error < StandardError; end
3
+ class BadGateway < Error; end
4
+ class BadRequest < Error; end
5
+ class CloudflareError < Error; end
6
+ class Forbidden < Error; end
7
+ class GatewayTimeout < Error; end
8
+ class InternalServerError < Error; end
9
+ class NotFound < Error; end
10
+ class ServiceUnavailable < Error; end
11
+ class Unauthorized < Error; end
12
+ end
13
+
14
+ require 'faraday'
15
+ module FaradayMiddleware
16
+ class VirtuousErrorHandler < Faraday::Middleware
17
+ ERROR_STATUSES = (400..600).freeze
18
+
19
+ ##
20
+ # Throws an exception for responses with an HTTP error code.
21
+ def on_complete(env)
22
+ message = error_message(env)
23
+
24
+ case env[:status]
25
+ when 400
26
+ raise Virtuous::BadRequest, message
27
+ when 401
28
+ raise Virtuous::Unauthorized, message
29
+ when 403
30
+ raise Virtuous::Forbidden, message
31
+ when 404
32
+ raise Virtuous::NotFound, message
33
+ when 500
34
+ raise Virtuous::InternalServerError, message
35
+ when 502
36
+ raise Virtuous::BadGateway, message
37
+ when 503
38
+ raise Virtuous::ServiceUnavailable, message
39
+ when 504
40
+ raise Virtuous::GatewayTimeout, message
41
+ when 520
42
+ raise Virtuous::CloudflareError, message
43
+ when ERROR_STATUSES
44
+ raise Virtuous::Error, message
45
+ end
46
+ end
47
+
48
+ private
49
+
50
+ def error_message(env)
51
+ "#{env[:status]}: #{env[:url]} #{env[:body]}"
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ module Virtuous
2
+ ##
3
+ # Helper methods for hashes
4
+ class HashHelper
5
+ ##
6
+ # Recieves a block which is called on all of the keys of the hash,
7
+ # including the keys nested hashes inside.
8
+ # @param hash [Hash] the hash to call the block upon.
9
+ # @return [Hash] A new hash with the transformed keys.
10
+ def self.deep_transform_keys(hash, &transform)
11
+ new_hash = {}
12
+ hash.each_key do |key|
13
+ value = hash[key]
14
+ new_key = transform.call(key)
15
+ new_hash[new_key] = if value.is_a?(Array)
16
+ value.map do |item|
17
+ item.is_a?(Hash) ? deep_transform_keys(item, &transform) : item
18
+ end
19
+ elsif value.is_a?(Hash)
20
+ deep_transform_keys(value, &transform)
21
+ else
22
+ value
23
+ end
24
+ end
25
+ new_hash
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,31 @@
1
+ module Virtuous
2
+ ##
3
+ # Helper methods for strings
4
+ class StringHelper
5
+ class << self
6
+ ##
7
+ # Transform a String from `CamelCase` to `snake_case`.
8
+ # @param string [String] the string to transform.
9
+ # @return [String] the `snake_case` string.
10
+ def underscore(string)
11
+ string.gsub('::', '/')
12
+ .gsub(/([A-Z]+)([A-Z][a-z])/, '\1_\2')
13
+ .gsub(/([a-z\d])([A-Z])/, '\1_\2')
14
+ .tr('-', '_')
15
+ .downcase
16
+ end
17
+
18
+ ##
19
+ # Transform a String from `snake_case` to `CamelCase`.
20
+ # @param string [String] the string to transform.
21
+ # @return [String] the `CamelCase` string.
22
+ def camelize(string)
23
+ string = string.sub(/^(?:(?=\b|[A-Z_])|\w)/, &:downcase)
24
+
25
+ string.gsub(%r{(?:_|(/))([a-z\d]*)}) do
26
+ "#{::Regexp.last_match(1)}#{::Regexp.last_match(2).capitalize}"
27
+ end.gsub('/', '::')
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,24 @@
1
+ require 'oj'
2
+
3
+ module FaradayMiddleware
4
+ class ParseOj < Faraday::Middleware
5
+ ##
6
+ # Parses JSON responses.
7
+ def on_complete(env)
8
+ body = env[:body]
9
+ env[:body] = if empty_body?(body.strip)
10
+ nil
11
+ else
12
+ Oj.load(body, mode: :compat)
13
+ end
14
+ end
15
+
16
+ private
17
+
18
+ def empty_body?(body)
19
+ body.empty? && body == ''
20
+ end
21
+ end
22
+ end
23
+
24
+ Faraday::Response.register_middleware(oj: FaradayMiddleware::ParseOj)
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Virtuous
4
+ VERSION = '0.0.1'
5
+ end
data/lib/virtuous.rb ADDED
@@ -0,0 +1,12 @@
1
+ require_relative 'virtuous/version'
2
+ require_relative 'virtuous/error'
3
+ require_relative 'virtuous/parse_oj'
4
+ Dir[File.expand_path('virtuous/helpers/*.rb', __dir__)].sort.each { |f| require f }
5
+ require_relative 'virtuous/client'
6
+
7
+ ##
8
+ # Virtuous module documentation.
9
+ #
10
+ # Go to Client to see the documentation of the client.
11
+ module Virtuous
12
+ end
data/logo/virtuous.svg ADDED
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 372.4 66.52"><defs><style>.cls-1{fill:#00a3e0;}.cls-2{fill:#f0f8ff;}</style></defs><g id="Layer_2" data-name="Layer 2"><g id="Logo_Variations" data-name="Logo Variations"><path class="cls-1" d="M27.76,45.66V66.52C3,45.5,0,53.55,0,27.6V6.71C24.79,27.75,27.76,19.69,27.76,45.66Z"></path><path class="cls-1" d="M59.82,6.71V27.6c0,25.95-3,17.9-27.77,38.92V45.66C32.05,19.69,35,27.75,59.82,6.71Z"></path><path class="cls-2" d="M132.58,16,116.29,56.86h-9.91L90.06,16h9.28a4,4,0,0,1,2.32.68A3.52,3.52,0,0,1,103,18.48l7.52,20.68c.37.92.66,1.84.93,2.76a24.29,24.29,0,0,1,.92-2.81l7.7-20.7a3.63,3.63,0,0,1,1.31-1.73,3.86,3.86,0,0,1,2.26-.64Z"></path><path class="cls-2" d="M148.83,16V53.35a3.51,3.51,0,0,1-3.5,3.47h-8V19.48a3.5,3.5,0,0,1,3.5-3.49Z"></path><path class="cls-2" d="M148.83,3.48V10h-8a3.49,3.49,0,0,1-3.5-3.48V0h8A3.51,3.51,0,0,1,148.83,3.48Z"></path><path class="cls-2" d="M371.48,39.05a10.08,10.08,0,0,0-2.4-3.17,13.27,13.27,0,0,0-3.32-2.14c-1.17-.52-2.38-1-3.59-1.41s-2.38-.78-3.53-1.13a20,20,0,0,1-2.91-1.09,6.21,6.21,0,0,1-1.84-1.28,1.8,1.8,0,0,1-.53-1.38,2.59,2.59,0,0,1,1.17-2.23,6.23,6.23,0,0,1,3.75-1,10.31,10.31,0,0,1,2.77.34,16.81,16.81,0,0,1,2.19.79l1.79.85a3.76,3.76,0,0,0,3.51.09,3.42,3.42,0,0,0,1.14-1.19l2.6-4-.6-.59a17.69,17.69,0,0,0-5.85-3.67,22.26,22.26,0,0,0-14.43-.38,14.9,14.9,0,0,0-4.93,2.76,11.66,11.66,0,0,0-3.05,4.11,12.24,12.24,0,0,0-1,4.95,11.64,11.64,0,0,0,.93,4.8,10.26,10.26,0,0,0,2.4,3.39,13,13,0,0,0,3.34,2.24,32.52,32.52,0,0,0,3.69,1.46c1.23.41,2.42.78,3.57,1.13a20.08,20.08,0,0,1,2.92,1.09,6.29,6.29,0,0,1,1.85,1.29,2.12,2.12,0,0,1,.53,1.52,3.34,3.34,0,0,1-.27,1.36,2.87,2.87,0,0,1-.85,1.1,5.13,5.13,0,0,1-1.64.85,8.17,8.17,0,0,1-2.55.34,9.4,9.4,0,0,1-3.16-.43,15.06,15.06,0,0,1-2.25-1c-.67-.37-1.25-.71-1.75-1a4.19,4.19,0,0,0-4.19-.16,4.06,4.06,0,0,0-1.34,1.33L341,51.85l.64.57a17,17,0,0,0,2.76,2A21.58,21.58,0,0,0,347.84,56,23.21,23.21,0,0,0,351.68,57a23.84,23.84,0,0,0,11.07-.64A15.11,15.11,0,0,0,368,53.5a12.32,12.32,0,0,0,3.3-4.43,13.94,13.94,0,0,0,1.11-5.56A10,10,0,0,0,371.48,39.05Z"></path><path class="cls-2" d="M236.88,19.47V38.74c0,3.35-.65,6.43-1.66,7.8a5.57,5.57,0,0,1-4.82,2.13,5.64,5.64,0,0,1-4.9-2.14c-1-1.44-1.66-4.46-1.66-7.79l0-19.27A3.49,3.49,0,0,0,220.3,16h-8V39.81a21.12,21.12,0,0,0,1.24,6.58,16.85,16.85,0,0,0,3.69,5.91A16,16,0,0,0,223,56.08a21.52,21.52,0,0,0,14.76,0,16,16,0,0,0,5.66-3.78,16.81,16.81,0,0,0,3.64-5.91,21.65,21.65,0,0,0,1.24-6.57V16h-8A3.48,3.48,0,0,0,236.88,19.47Z"></path><path class="cls-2" d="M155.31,30.28V56.82h8a3.49,3.49,0,0,0,3.49-3.48V34.62c0-3.35.65-6.42,1.64-7.8a5.62,5.62,0,0,1,4.83-2.13h1.4a3.5,3.5,0,0,0,3.5-3.47V16h-8.54a16.05,16.05,0,0,0-5.82,1,12.78,12.78,0,0,0-4.61,3,13.51,13.51,0,0,0-2.87,4.73,17.07,17.07,0,0,0-1,5,2.94,2.94,0,0,0,0,.42"></path><path class="cls-2" d="M268,27.44c1.37-1.92,3.44-2.85,6.35-2.85s5.06.92,6.45,2.87,2.16,5,2.16,8.95-.73,6.91-2.16,8.9-3.48,2.84-6.45,2.84-5-.92-6.36-2.82-2.14-5-2.14-8.92S266.62,29.45,268,27.44Zm-12.42.26a24.86,24.86,0,0,0-1.43,8.63A24.86,24.86,0,0,0,255.57,45a19,19,0,0,0,4.12,6.7,17.91,17.91,0,0,0,6.46,4.25,24.17,24.17,0,0,0,16.6,0,18,18,0,0,0,6.46-4.25A19.12,19.12,0,0,0,293.3,45a24.64,24.64,0,0,0,1.44-8.63,24.48,24.48,0,0,0-1.45-8.64,18.79,18.79,0,0,0-4.13-6.58,18.39,18.39,0,0,0-6.46-4.21,24.28,24.28,0,0,0-16.6,0,18.06,18.06,0,0,0-10.53,10.8Z"></path><path class="cls-2" d="M312,19.47V38.74c0,3.35.65,6.43,1.65,7.8a6.61,6.61,0,0,0,9.73,0c1-1.44,1.66-4.46,1.66-7.79l0-19.27a3.49,3.49,0,0,1,3.5-3.47h8V39.81a21.11,21.11,0,0,1-1.23,6.58,16.85,16.85,0,0,1-3.69,5.91,15.91,15.91,0,0,1-5.74,3.78,21.49,21.49,0,0,1-14.75,0,16,16,0,0,1-5.67-3.78,16.81,16.81,0,0,1-3.64-5.91,21.06,21.06,0,0,1-1.24-6.57V16h8A3.48,3.48,0,0,1,312,19.47Z"></path><path class="cls-2" d="M195.22,24.7h7.46A2.75,2.75,0,0,0,205.44,22V16H195.22V8.87a2.77,2.77,0,0,0-2.77-2.76h-8.66V45.66c0,3.61,1,6.5,3,8.59s4.89,3.17,8.53,3.17a18.6,18.6,0,0,0,5.83-.91,15.17,15.17,0,0,0,5.05-2.76l.69-.56-3.28-5.3,0-.07a4.23,4.23,0,0,0-.76-.82,2.26,2.26,0,0,0-2.56-.07c-.21.13-.45.25-.72.39a8.1,8.1,0,0,1-.92.38,3.2,3.2,0,0,1-1.11.14,2,2,0,0,1-1.66-.67,3.11,3.11,0,0,1-.64-2.14Z"></path></g></g></svg>
@@ -0,0 +1,25 @@
1
+ $LOAD_PATH.unshift(File.join(__FILE__, '..', '..', 'lib'))
2
+
3
+ SPEC_DIR = File.dirname(__FILE__)
4
+ FIXTURES_DIR = File.join(SPEC_DIR, 'support', 'fixtures')
5
+
6
+ require 'virtuous'
7
+ require 'webmock/rspec'
8
+ require_relative 'support/fixtures_helper'
9
+ require_relative 'support/virtuous_mock'
10
+ require_relative 'support/client_factory'
11
+
12
+ RSpec.configure do |config|
13
+ config.before :suite do
14
+ WebMock.disable_net_connect!
15
+ end
16
+
17
+ config.before :each do
18
+ stub_request(:any, /virtuoussoftware/).to_rack(VirtuousMock)
19
+ end
20
+
21
+ config.run_all_when_everything_filtered = true
22
+ config.filter_run :focus
23
+
24
+ config.order = 'random'
25
+ end
@@ -0,0 +1,10 @@
1
+ RSpec.shared_context 'resource specs' do
2
+ let(:attrs) do
3
+ {
4
+ api_key: 'test_api_key',
5
+ base_url: 'http://api.virtuoussoftware.com'
6
+ }
7
+ end
8
+
9
+ let(:client) { Virtuous::Client.new(**attrs) }
10
+ end
@@ -0,0 +1,112 @@
1
+ {
2
+ "id": 1,
3
+ "contactType": "Organization",
4
+ "isPrivate": true,
5
+ "name": "Test Contact",
6
+ "informalName": "Test Contact",
7
+ "formalContactName": "Test Contact",
8
+ "alternateContactName": "Test Contact",
9
+ "preferredSalutationName": "Test Contact",
10
+ "preferredAddresseeName": "Test Contact",
11
+ "description": null,
12
+ "website": null,
13
+ "maritalStatus": null,
14
+ "anniversaryMonth": null,
15
+ "anniversaryDay": null,
16
+ "anniversaryYear": null,
17
+ "mergedIntoContactId": null,
18
+ "address": {
19
+ "id": 800,
20
+ "label": "Test address",
21
+ "address1": "123 test street",
22
+ "address2": null,
23
+ "city": null,
24
+ "state": "",
25
+ "postal": null,
26
+ "country": "",
27
+ "isPrimary": true,
28
+ "canBePrimary": false,
29
+ "startMonth": 1,
30
+ "startDay": 1,
31
+ "endMonth": 12,
32
+ "endDay": 31,
33
+ "createDateTimeUtc": "2023-11-30T14:20:56.34",
34
+ "createdByUser": "Test User",
35
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.3533333",
36
+ "modifiedByUser": "Test User"
37
+ },
38
+ "giftAskAmount": "$1,200",
39
+ "giftAskType": null,
40
+ "lifeToDateGiving": "$0",
41
+ "yearToDateGiving": "$0",
42
+ "lastGiftAmount": "$0",
43
+ "lastGiftDate": "Unavailable",
44
+ "contactIndividuals": [
45
+ {
46
+ "id": 2,
47
+ "contactId": 1,
48
+ "prefix": "Mr",
49
+ "prefix2": null,
50
+ "firstName": "Test",
51
+ "middleName": null,
52
+ "lastName": "Individual",
53
+ "preMarriageName": null,
54
+ "suffix": null,
55
+ "nickname": null,
56
+ "gender": "Male",
57
+ "isPrimary": true,
58
+ "canBePrimary": false,
59
+ "isSecondary": false,
60
+ "canBeSecondary": false,
61
+ "birthMonth": 9,
62
+ "birthDay": 4,
63
+ "birthYear": 1998,
64
+ "birthDate": "9/4/1998",
65
+ "approximateAge": 25,
66
+ "isDeceased": false,
67
+ "deceasedDate": "",
68
+ "passion": null,
69
+ "avatarUrl": null,
70
+ "contactMethods": [
71
+ {
72
+ "id": 2081,
73
+ "type": "Work Email",
74
+ "value": "email@test.com",
75
+ "isOptedIn": false,
76
+ "isPrimary": true,
77
+ "canBePrimary": false,
78
+ "createDateTimeUtc": "2023-11-30T14:20:56.3233333",
79
+ "createdByUser": "Test User",
80
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.37",
81
+ "modifiedByUser": "Test User"
82
+ }
83
+ ],
84
+ "createDateTimeUtc": "2023-11-30T14:20:56.2933333",
85
+ "createdByUser": "Test User",
86
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.3533333",
87
+ "modifiedByUser": "Test User",
88
+ "customFields": [],
89
+ "customCollections": []
90
+ }
91
+ ],
92
+ "contactGiftsUrl": "/api/Gift/ByContact/1",
93
+ "contactPassthroughGiftsUrl": "/api/Gift/Passthrough/ByContact/1",
94
+ "contactPlannedGiftsUrl": "/api/PlannedGift/ByContact/1",
95
+ "contactRecurringGiftsUrl": "/api/RecurringGift/ByContact/1",
96
+ "contactImportantNotesUrl": "/api/ContactNote/Important/ByContact/1",
97
+ "contactNotesUrl": "/api/ContactNote/ByContact/1",
98
+ "contactTagsUrl": "/api/ContactTag/ByContact/1",
99
+ "contactRelationshipsUrl": "/api/Relationship/ByContact/1",
100
+ "primaryAvatarUrl": null,
101
+ "contactReferences": [],
102
+ "originSegmentId": null,
103
+ "originSegment": null,
104
+ "createDateTimeUtc": "2023-11-30T14:20:56.277",
105
+ "createdByUser": "Test User",
106
+ "modifiedDateTimeUtc": "2023-11-30T14:20:56.353",
107
+ "modifiedByUser": "Test User",
108
+ "tags": [],
109
+ "organizationGroups": [],
110
+ "customFields": [],
111
+ "customCollections": []
112
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "id": 1,
3
+ "label": "Home address",
4
+ "address1": "324 Frank Island",
5
+ "address2": "Apt. 366",
6
+ "city": "Antonioborough",
7
+ "state": "MA",
8
+ "postal": "27516",
9
+ "country": "United States",
10
+ "is_primary": true,
11
+ "can_be_primary": false,
12
+ "start_month": 1,
13
+ "start_day": 1,
14
+ "end_month": 12,
15
+ "end_day": 31,
16
+ "create_date_time_utc": "2023-11-30T14:20:56.34",
17
+ "created_by_user": "Test User",
18
+ "modified_date_time_utc": "2023-12-27T15:33:09.9",
19
+ "modified_by_user": "Test User"
20
+ }
@@ -0,0 +1,42 @@
1
+ [
2
+ {
3
+ "id": 1,
4
+ "label": "Home address",
5
+ "address1": "324 Frank Island",
6
+ "address2": "Apt. 366",
7
+ "city": "Antonioborough",
8
+ "state": "MA",
9
+ "postal": "27516",
10
+ "country": "United States",
11
+ "is_primary": true,
12
+ "can_be_primary": false,
13
+ "start_month": 1,
14
+ "start_day": 1,
15
+ "end_month": 12,
16
+ "end_day": 31,
17
+ "create_date_time_utc": "2023-11-30T14:20:56.34",
18
+ "created_by_user": "Test User",
19
+ "modified_date_time_utc": "2023-12-27T15:33:09.9",
20
+ "modified_by_user": "Test User"
21
+ },
22
+ {
23
+ "id": 2,
24
+ "label": "Home address",
25
+ "address1": "324 Frank Island",
26
+ "address2": "Apt. 366",
27
+ "city": "Antonioborough",
28
+ "state": "MA",
29
+ "postal": "27516",
30
+ "country": "United States",
31
+ "is_primary": false,
32
+ "can_be_primary": true,
33
+ "start_month": 1,
34
+ "start_day": 1,
35
+ "end_month": 12,
36
+ "end_day": 31,
37
+ "create_date_time_utc": "2023-12-27T15:35:02.447",
38
+ "created_by_user": "Test User",
39
+ "modified_date_time_utc": "2023-12-27T15:35:02.967",
40
+ "modified_by_user": "Test User"
41
+ }
42
+ ]