data_nexus 0.2.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.
@@ -0,0 +1,176 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+ require 'faraday/retry'
5
+ require 'json'
6
+
7
+ module DataNexus
8
+ # HTTP connection wrapper using Faraday
9
+ #
10
+ # Handles authentication, request/response processing, and error handling.
11
+ #
12
+ class Connection
13
+ # @return [Configuration] The connection configuration
14
+ attr_reader :config
15
+
16
+ # Initialize a new Connection
17
+ #
18
+ # @param config [Configuration] The configuration object
19
+ def initialize(config)
20
+ @config = config
21
+ end
22
+
23
+ # Perform a GET request
24
+ #
25
+ # @param path [String] The API endpoint path
26
+ # @param params [Hash] Query parameters
27
+ # @return [Hash] Parsed JSON response
28
+ def get(path, params = {})
29
+ request(:get, path, params)
30
+ end
31
+
32
+ # Perform a POST request
33
+ #
34
+ # @param path [String] The API endpoint path
35
+ # @param body [Hash] Request body
36
+ # @return [Hash] Parsed JSON response
37
+ def post(path, body = {})
38
+ request(:post, path, body)
39
+ end
40
+
41
+ # Perform a PATCH request
42
+ #
43
+ # @param path [String] The API endpoint path
44
+ # @param body [Hash] Request body
45
+ # @return [Hash] Parsed JSON response
46
+ def patch(path, body = {})
47
+ request(:patch, path, body)
48
+ end
49
+
50
+ # Perform a DELETE request
51
+ #
52
+ # @param path [String] The API endpoint path
53
+ # @return [Hash] Parsed JSON response (empty on 204 No Content)
54
+ def delete(path)
55
+ request(:delete, path)
56
+ end
57
+
58
+ private
59
+
60
+ # Build the Faraday connection
61
+ #
62
+ # @return [Faraday::Connection]
63
+ def faraday
64
+ @faraday ||= Faraday.new(url: config.base_url, ssl: ssl_options) do |conn|
65
+ configure_request(conn)
66
+ configure_headers(conn)
67
+ configure_timeouts(conn)
68
+
69
+ conn.response :raise_error
70
+ conn.adapter Faraday.default_adapter
71
+ end
72
+ end
73
+
74
+ def ssl_options
75
+ { verify: config.ssl_verify }
76
+ end
77
+
78
+ def configure_request(conn)
79
+ conn.request :json
80
+ conn.request :retry, max: 2, interval: 0.5, backoff_factor: 2,
81
+ exceptions: [Faraday::TimeoutError, Faraday::ConnectionFailed]
82
+ end
83
+
84
+ def configure_headers(conn)
85
+ conn.headers['Authorization'] = "apikey #{config.api_key}"
86
+ conn.headers['Content-Type'] = 'application/json'
87
+ conn.headers['Accept'] = 'application/json'
88
+ conn.headers['User-Agent'] = "data-nexus-ruby/#{DataNexus::VERSION}"
89
+ end
90
+
91
+ def configure_timeouts(conn)
92
+ conn.options.timeout = config.timeout
93
+ conn.options.open_timeout = config.open_timeout
94
+ end
95
+
96
+ # Perform an HTTP request
97
+ #
98
+ # @param method [Symbol] HTTP method (:get, :post, :patch)
99
+ # @param path [String] The API endpoint path
100
+ # @param params_or_body [Hash] Query params (GET) or body (POST/PATCH)
101
+ # @return [Hash] Parsed JSON response
102
+ def request(method, path, params_or_body = {})
103
+ response = faraday.public_send(method, path, params_or_body)
104
+ parse_response(response)
105
+ rescue Faraday::TimeoutError => e
106
+ raise TimeoutError, "Request timed out: #{e.message}"
107
+ rescue Faraday::ConnectionFailed => e
108
+ raise ConnectionError, "Connection failed: #{e.message}"
109
+ rescue Faraday::ClientError, Faraday::ServerError => e
110
+ handle_error_response(e)
111
+ end
112
+
113
+ # Parse the response body as JSON
114
+ #
115
+ # @param response [Faraday::Response]
116
+ # @return [Hash]
117
+ def parse_response(response)
118
+ return {} if response.body.nil? || response.body.empty?
119
+
120
+ JSON.parse(response.body, symbolize_names: true)
121
+ rescue JSON::ParserError
122
+ { raw_body: response.body }
123
+ end
124
+
125
+ # Handle error responses and raise appropriate exceptions
126
+ #
127
+ # @param error [Faraday::Error]
128
+ # @raise [APIError] The appropriate error subclass
129
+ def handle_error_response(error)
130
+ status, body, message = extract_error_details(error)
131
+ error_class = HTTP_STATUS_ERRORS[status] || APIError
132
+
133
+ raise_api_error(error_class, message, status, body, error.response)
134
+ end
135
+
136
+ def extract_error_details(error)
137
+ status = error.response&.dig(:status)
138
+ body = parse_error_body(error.response&.dig(:body))
139
+ message = extract_error_message(body, error.message)
140
+
141
+ [status, body, message]
142
+ end
143
+
144
+ def raise_api_error(error_class, message, status, body, response)
145
+ if error_class == RateLimitError
146
+ retry_after = response&.dig(:headers, 'retry-after')&.to_i
147
+ raise error_class.new(message, status: status, response_body: body, retry_after: retry_after)
148
+ end
149
+
150
+ raise error_class.new(message, status: status, response_body: body)
151
+ end
152
+
153
+ # Parse error response body
154
+ #
155
+ # @param body [String, nil]
156
+ # @return [Hash, nil]
157
+ def parse_error_body(body)
158
+ return nil if body.nil? || body.empty?
159
+
160
+ JSON.parse(body, symbolize_names: true)
161
+ rescue JSON::ParserError
162
+ { raw_body: body }
163
+ end
164
+
165
+ # Extract a human-readable error message
166
+ #
167
+ # @param body [Hash, nil]
168
+ # @param default [String]
169
+ # @return [String]
170
+ def extract_error_message(body, default)
171
+ return default unless body.is_a?(Hash)
172
+
173
+ body[:error] || body[:message] || body[:errors]&.first || default
174
+ end
175
+ end
176
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataNexus
4
+ # Base error class for all DataNexus errors
5
+ class Error < StandardError
6
+ # @return [Integer, nil] HTTP status code if applicable
7
+ attr_reader :status
8
+
9
+ # @return [Hash, nil] Response body if available
10
+ attr_reader :response_body
11
+
12
+ # Initialize a new Error
13
+ #
14
+ # @param message [String] Error message
15
+ # @param status [Integer, nil] HTTP status code
16
+ # @param response_body [Hash, nil] Parsed response body
17
+ def initialize(message = nil, status: nil, response_body: nil)
18
+ @status = status
19
+ @response_body = response_body
20
+ super(message)
21
+ end
22
+ end
23
+
24
+ # Raised when the API key is missing or invalid
25
+ class ConfigurationError < Error; end
26
+
27
+ # Base class for HTTP errors returned by the API
28
+ class APIError < Error; end
29
+
30
+ # Raised when authentication fails (401)
31
+ class AuthenticationError < APIError; end
32
+
33
+ # Raised when the request is forbidden (403)
34
+ class ForbiddenError < APIError; end
35
+
36
+ # Raised when a resource is not found (404)
37
+ class NotFoundError < APIError; end
38
+
39
+ # Raised when the request is invalid (400)
40
+ class BadRequestError < APIError; end
41
+
42
+ # Raised when validation fails (422)
43
+ class UnprocessableEntityError < APIError; end
44
+
45
+ # Raised when rate limit is exceeded (429)
46
+ class RateLimitError < APIError
47
+ # @return [Integer, nil] Seconds until rate limit resets
48
+ attr_reader :retry_after
49
+
50
+ def initialize(message = nil, status: nil, response_body: nil, retry_after: nil)
51
+ @retry_after = retry_after
52
+ super(message, status: status, response_body: response_body)
53
+ end
54
+ end
55
+
56
+ # Raised when there's a server error (500, 502, 503, 504)
57
+ class ServerError < APIError; end
58
+
59
+ # Raised when there's a network/connection error
60
+ class ConnectionError < Error; end
61
+
62
+ # Raised when a request times out
63
+ class TimeoutError < ConnectionError; end
64
+
65
+ # Maps HTTP status codes to error classes
66
+ HTTP_STATUS_ERRORS = {
67
+ 400 => BadRequestError,
68
+ 401 => AuthenticationError,
69
+ 403 => ForbiddenError,
70
+ 404 => NotFoundError,
71
+ 422 => UnprocessableEntityError,
72
+ 429 => RateLimitError,
73
+ 500 => ServerError,
74
+ 502 => ServerError,
75
+ 503 => ServerError,
76
+ 504 => ServerError
77
+ }.freeze
78
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataNexus
4
+ module Resources
5
+ # Resource for managing member consents
6
+ #
7
+ # Provides methods for creating, finding, updating, and deleting
8
+ # consents for a specific member.
9
+ #
10
+ # @example Create a consent
11
+ # client.programs("uuid").members("member-id").consents.create(
12
+ # consent: {
13
+ # category: "sms",
14
+ # member_response: true,
15
+ # consent_details: { sms_phone_number: "+15558675309" }
16
+ # }
17
+ # )
18
+ #
19
+ # @example Find a consent
20
+ # consent = client.programs("uuid").members("member-id").consents.find(123)
21
+ #
22
+ # @example Update a consent
23
+ # client.programs("uuid").members("member-id").consents.update(123,
24
+ # consent: { member_response: false }
25
+ # )
26
+ #
27
+ # @example Delete a consent
28
+ # client.programs("uuid").members("member-id").consents.delete(123)
29
+ #
30
+ class MemberConsents
31
+ # @return [Connection] The HTTP connection
32
+ attr_reader :connection
33
+
34
+ # @return [String] The member ID
35
+ attr_reader :member_id
36
+
37
+ # @return [String] The program UUID
38
+ attr_reader :program_id
39
+
40
+ # Initialize a new MemberConsents resource
41
+ #
42
+ # @param connection [Connection] The HTTP connection
43
+ # @param member_id [String] The member ID
44
+ # @param program_id [String] The program UUID
45
+ def initialize(connection, member_id, program_id)
46
+ @connection = connection
47
+ @member_id = member_id
48
+ @program_id = program_id
49
+ end
50
+
51
+ # Create a new consent for the member
52
+ #
53
+ # @param consent [Hash] The consent attributes
54
+ # @option consent [String] :category The consent category (required)
55
+ # @option consent [Hash] :consent_details Additional consent details (required, can be empty)
56
+ # @option consent [String] :consented_at The datetime of consent (optional)
57
+ # @option consent [Boolean] :member_response Whether member agreed (optional, default: false)
58
+ # @return [Hash] Response containing :data with the created consent
59
+ #
60
+ # @example
61
+ # response = client.programs("uuid").members("member-id").consents.create(
62
+ # consent: {
63
+ # category: "sms",
64
+ # member_response: true,
65
+ # consent_details: { sms_phone_number: "+15558675309" }
66
+ # }
67
+ # )
68
+ # created_consent = response[:data]
69
+ def create(consent:)
70
+ consent_with_program = consent.merge(program_id: program_id)
71
+ body = { consent: consent_with_program }
72
+ connection.post(base_path, body)
73
+ end
74
+
75
+ # Find a specific consent by ID
76
+ #
77
+ # @param consent_id [Integer, String] The consent ID
78
+ # @return [Hash] The consent data
79
+ #
80
+ # @example
81
+ # consent = client.programs("uuid").members("member-id").consents.find(123)
82
+ # puts consent[:category]
83
+ def find(consent_id)
84
+ response = connection.get("#{base_path}/#{consent_id}")
85
+ response[:data]
86
+ end
87
+
88
+ # Update a consent's attributes
89
+ #
90
+ # @param consent_id [Integer, String] The consent ID
91
+ # @param consent [Hash] The consent attributes to update
92
+ # @return [Hash] Response containing :data with the updated consent
93
+ #
94
+ # @example
95
+ # response = client.programs("uuid").members("member-id").consents.update(123,
96
+ # consent: { member_response: false }
97
+ # )
98
+ # updated_consent = response[:data]
99
+ def update(consent_id, consent:)
100
+ body = { consent: consent }
101
+ connection.patch("#{base_path}/#{consent_id}", body)
102
+ end
103
+
104
+ # Delete a consent
105
+ #
106
+ # @param consent_id [Integer, String] The consent ID
107
+ # @return [Hash] Empty hash on success (204 No Content)
108
+ #
109
+ # @example
110
+ # client.programs("uuid").members("member-id").consents.delete(123)
111
+ def delete(consent_id)
112
+ connection.delete("#{base_path}/#{consent_id}")
113
+ end
114
+
115
+ private
116
+
117
+ # Base path for member consents endpoints
118
+ #
119
+ # @return [String]
120
+ def base_path
121
+ "/api/members/#{member_id}/consents"
122
+ end
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataNexus
4
+ module Resources
5
+ # Resource for managing member enrollments
6
+ #
7
+ # Provides methods for creating, finding, updating, and deleting
8
+ # enrollments for a specific member.
9
+ #
10
+ # @example Create an enrollment
11
+ # client.programs("program-uuid").members("member-id").enrollments.create(
12
+ # enrollment: {
13
+ # enrolled_at: "2024-01-01T00:00:00Z",
14
+ # expires_at: "2025-01-01T00:00:00Z"
15
+ # }
16
+ # )
17
+ #
18
+ # @example Find an enrollment
19
+ # enrollment = client.programs("program-uuid").members("member-id").enrollments.find(123)
20
+ #
21
+ # @example Update an enrollment
22
+ # client.programs("program-uuid").members("member-id").enrollments.update(123,
23
+ # enrollment: { expires_at: "2026-01-01T00:00:00Z" }
24
+ # )
25
+ #
26
+ # @example Delete an enrollment
27
+ # client.programs("program-uuid").members("member-id").enrollments.delete(123)
28
+ #
29
+ class MemberEnrollments
30
+ # @return [Connection] The HTTP connection
31
+ attr_reader :connection
32
+
33
+ # @return [String] The member ID
34
+ attr_reader :member_id
35
+
36
+ # @return [String] The program UUID
37
+ attr_reader :program_id
38
+
39
+ # Initialize a new MemberEnrollments resource
40
+ #
41
+ # @param connection [Connection] The HTTP connection
42
+ # @param member_id [String] The member ID
43
+ # @param program_id [String] The program UUID
44
+ def initialize(connection, member_id, program_id)
45
+ @connection = connection
46
+ @member_id = member_id
47
+ @program_id = program_id
48
+ end
49
+
50
+ # Create a new enrollment for the member
51
+ #
52
+ # @param enrollment [Hash] The enrollment attributes
53
+ # @option enrollment [String] :enrolled_at The datetime of enrollment (required)
54
+ # @option enrollment [String] :expires_at The datetime when enrollment expires (optional)
55
+ # @return [Hash] Response containing :data with the created enrollment
56
+ #
57
+ # @note The program_id is automatically injected from the resource chain
58
+ #
59
+ # @example
60
+ # response = client.programs("program-uuid").members("member-id").enrollments.create(
61
+ # enrollment: {
62
+ # enrolled_at: "2024-01-01T00:00:00Z"
63
+ # }
64
+ # )
65
+ # created_enrollment = response[:data]
66
+ def create(enrollment:)
67
+ enrollment_with_program = enrollment.merge(program_id: program_id)
68
+ body = { enrollment: enrollment_with_program }
69
+ connection.post(base_path, body)
70
+ end
71
+
72
+ # Find a specific enrollment by ID
73
+ #
74
+ # @param enrollment_id [Integer, String] The enrollment ID
75
+ # @return [Hash] The enrollment data
76
+ #
77
+ # @example
78
+ # enrollment = client.programs("program-uuid").members("member-id").enrollments.find(123)
79
+ # puts enrollment[:program_id]
80
+ def find(enrollment_id)
81
+ response = connection.get("#{base_path}/#{enrollment_id}")
82
+ response[:data]
83
+ end
84
+
85
+ # Update an enrollment's attributes
86
+ #
87
+ # @param enrollment_id [Integer, String] The enrollment ID
88
+ # @param enrollment [Hash] The enrollment attributes to update
89
+ # @return [Hash] Response containing :data with the updated enrollment
90
+ #
91
+ # @example
92
+ # response = client.programs("program-uuid").members("member-id").enrollments.update(123,
93
+ # enrollment: { expires_at: "2026-01-01T00:00:00Z" }
94
+ # )
95
+ # updated_enrollment = response[:data]
96
+ def update(enrollment_id, enrollment:)
97
+ body = { enrollment: enrollment }
98
+ connection.patch("#{base_path}/#{enrollment_id}", body)
99
+ end
100
+
101
+ # Delete an enrollment
102
+ #
103
+ # @param enrollment_id [Integer, String] The enrollment ID
104
+ # @return [Hash] Empty hash on success (204 No Content)
105
+ #
106
+ # @example
107
+ # client.programs("program-uuid").members("member-id").enrollments.delete(123)
108
+ def delete(enrollment_id)
109
+ connection.delete("#{base_path}/#{enrollment_id}")
110
+ end
111
+
112
+ private
113
+
114
+ # Base path for member enrollments endpoints
115
+ #
116
+ # @return [String]
117
+ def base_path
118
+ "/api/members/#{member_id}/enrollments"
119
+ end
120
+ end
121
+ end
122
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DataNexus
4
+ module Resources
5
+ # Resource for managing members at the top level
6
+ #
7
+ # Provides methods for listing, finding, and updating members
8
+ # without requiring a program scope.
9
+ #
10
+ # @example List members with filters
11
+ # client.members.list(
12
+ # first_name: "george",
13
+ # born_on: "1976-07-04"
14
+ # )
15
+ #
16
+ # @example Paginate through members
17
+ # collection = client.members.list(first: 50)
18
+ # collection.each_page { |page| process(page.data) }
19
+ #
20
+ # @example Find a specific member
21
+ # member = client.members.find("member-id")
22
+ #
23
+ class Members
24
+ # @return [Connection] The HTTP connection
25
+ attr_reader :connection
26
+
27
+ # Initialize a new Members resource
28
+ #
29
+ # @param connection [Connection] The HTTP connection
30
+ def initialize(connection)
31
+ @connection = connection
32
+ end
33
+
34
+ # List members with optional filters
35
+ #
36
+ # @param after [String, nil] Cursor for next group of records
37
+ # @param before [String, nil] Cursor for previous group of records
38
+ # @param first [Integer, nil] Number of records to fetch after cursor
39
+ # @param last [Integer, nil] Number of records to fetch before cursor
40
+ # @param born_on [String, nil] Filter by date of birth (YYYY-MM-DD)
41
+ # @param first_name [String, nil] Filter by first name
42
+ # @param last_name [String, nil] Filter by last name
43
+ # @param program_id [String, nil] Filter by program UUID (members eligible for program)
44
+ # @param updated_since [String, nil] Filter by update time (ISO 8601 datetime)
45
+ #
46
+ # @return [Collection] Paginated collection of members
47
+ #
48
+ # @example Basic listing
49
+ # collection = client.members.list
50
+ # collection.data.each { |m| puts m[:first_name] }
51
+ #
52
+ # @example With filters
53
+ # collection = client.members.list(
54
+ # born_on: "1976-07-04",
55
+ # first_name: "george"
56
+ # )
57
+ #
58
+ # @example With pagination
59
+ # collection = client.members.list(first: 50, after: "cursor")
60
+ #
61
+ # @example Filter by program eligibility
62
+ # collection = client.members.list(program_id: "uuid")
63
+ #
64
+ # @example Filter by update time
65
+ # collection = client.members.list(updated_since: "2024-01-01T00:00:00Z")
66
+ def list(**params)
67
+ allowed_params = %i[
68
+ after before first last
69
+ born_on first_name last_name
70
+ program_id updated_since
71
+ ]
72
+
73
+ query_params = params.slice(*allowed_params).compact
74
+ response = connection.get(base_path, query_params)
75
+ Collection.new(response, resource: self, params: query_params)
76
+ end
77
+
78
+ # Find a specific member by ID
79
+ #
80
+ # @param member_id [String] The member ID
81
+ # @return [Hash] The member data
82
+ #
83
+ # @example
84
+ # member = client.members.find("member-id")
85
+ # puts member[:first_name]
86
+ def find(member_id)
87
+ response = connection.get("#{base_path}/#{member_id}")
88
+ response[:data]
89
+ end
90
+
91
+ # Update a member's attributes
92
+ #
93
+ # @param member_id [String] The member ID
94
+ # @param member [Hash] The member attributes to update
95
+ # @return [Hash] Response containing :data with the updated member
96
+ #
97
+ # @example
98
+ # response = client.members.update("member-id",
99
+ # member: { phone_number: "+15551234567" }
100
+ # )
101
+ # updated_member = response[:data]
102
+ def update(member_id, member:)
103
+ body = { member: member }
104
+ connection.patch("#{base_path}/#{member_id}", body)
105
+ end
106
+
107
+ private
108
+
109
+ # Base path for members endpoints
110
+ #
111
+ # @return [String]
112
+ def base_path
113
+ '/api/members'
114
+ end
115
+ end
116
+ end
117
+ end