tasker-rb 0.1.1

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 (78) hide show
  1. checksums.yaml +7 -0
  2. data/DEVELOPMENT.md +548 -0
  3. data/README.md +87 -0
  4. data/ext/tasker_core/Cargo.lock +4720 -0
  5. data/ext/tasker_core/Cargo.toml +76 -0
  6. data/ext/tasker_core/extconf.rb +38 -0
  7. data/ext/tasker_core/src/CLAUDE.md +7 -0
  8. data/ext/tasker_core/src/bootstrap.rs +320 -0
  9. data/ext/tasker_core/src/bridge.rs +400 -0
  10. data/ext/tasker_core/src/client_ffi.rs +173 -0
  11. data/ext/tasker_core/src/conversions.rs +131 -0
  12. data/ext/tasker_core/src/diagnostics.rs +57 -0
  13. data/ext/tasker_core/src/event_handler.rs +179 -0
  14. data/ext/tasker_core/src/event_publisher_ffi.rs +239 -0
  15. data/ext/tasker_core/src/ffi_logging.rs +245 -0
  16. data/ext/tasker_core/src/global_event_system.rs +16 -0
  17. data/ext/tasker_core/src/in_process_event_ffi.rs +319 -0
  18. data/ext/tasker_core/src/lib.rs +41 -0
  19. data/ext/tasker_core/src/observability_ffi.rs +339 -0
  20. data/lib/tasker_core/batch_processing/batch_aggregation_scenario.rb +85 -0
  21. data/lib/tasker_core/batch_processing/batch_worker_context.rb +238 -0
  22. data/lib/tasker_core/bootstrap.rb +394 -0
  23. data/lib/tasker_core/domain_events/base_publisher.rb +220 -0
  24. data/lib/tasker_core/domain_events/base_subscriber.rb +178 -0
  25. data/lib/tasker_core/domain_events/publisher_registry.rb +253 -0
  26. data/lib/tasker_core/domain_events/subscriber_registry.rb +152 -0
  27. data/lib/tasker_core/domain_events.rb +43 -0
  28. data/lib/tasker_core/errors/CLAUDE.md +7 -0
  29. data/lib/tasker_core/errors/common.rb +305 -0
  30. data/lib/tasker_core/errors/error_classifier.rb +61 -0
  31. data/lib/tasker_core/errors.rb +4 -0
  32. data/lib/tasker_core/event_bridge.rb +330 -0
  33. data/lib/tasker_core/handlers.rb +159 -0
  34. data/lib/tasker_core/internal.rb +31 -0
  35. data/lib/tasker_core/logger.rb +234 -0
  36. data/lib/tasker_core/models.rb +337 -0
  37. data/lib/tasker_core/observability/types.rb +158 -0
  38. data/lib/tasker_core/observability.rb +292 -0
  39. data/lib/tasker_core/registry/handler_registry.rb +453 -0
  40. data/lib/tasker_core/registry/resolver_chain.rb +258 -0
  41. data/lib/tasker_core/registry/resolvers/base_resolver.rb +90 -0
  42. data/lib/tasker_core/registry/resolvers/class_constant_resolver.rb +156 -0
  43. data/lib/tasker_core/registry/resolvers/explicit_mapping_resolver.rb +146 -0
  44. data/lib/tasker_core/registry/resolvers/method_dispatch_wrapper.rb +144 -0
  45. data/lib/tasker_core/registry/resolvers/registry_resolver.rb +229 -0
  46. data/lib/tasker_core/registry/resolvers.rb +42 -0
  47. data/lib/tasker_core/registry.rb +12 -0
  48. data/lib/tasker_core/step_handler/api.rb +48 -0
  49. data/lib/tasker_core/step_handler/base.rb +354 -0
  50. data/lib/tasker_core/step_handler/batchable.rb +50 -0
  51. data/lib/tasker_core/step_handler/decision.rb +53 -0
  52. data/lib/tasker_core/step_handler/mixins/api.rb +452 -0
  53. data/lib/tasker_core/step_handler/mixins/batchable.rb +465 -0
  54. data/lib/tasker_core/step_handler/mixins/decision.rb +252 -0
  55. data/lib/tasker_core/step_handler/mixins.rb +66 -0
  56. data/lib/tasker_core/subscriber.rb +212 -0
  57. data/lib/tasker_core/task_handler/base.rb +254 -0
  58. data/lib/tasker_core/tasker_rb.so +0 -0
  59. data/lib/tasker_core/template_discovery.rb +181 -0
  60. data/lib/tasker_core/tracing.rb +166 -0
  61. data/lib/tasker_core/types/batch_processing_outcome.rb +301 -0
  62. data/lib/tasker_core/types/client_types.rb +145 -0
  63. data/lib/tasker_core/types/decision_point_outcome.rb +177 -0
  64. data/lib/tasker_core/types/error_types.rb +72 -0
  65. data/lib/tasker_core/types/simple_message.rb +151 -0
  66. data/lib/tasker_core/types/step_context.rb +328 -0
  67. data/lib/tasker_core/types/step_handler_call_result.rb +307 -0
  68. data/lib/tasker_core/types/step_message.rb +112 -0
  69. data/lib/tasker_core/types/step_types.rb +207 -0
  70. data/lib/tasker_core/types/task_template.rb +240 -0
  71. data/lib/tasker_core/types/task_types.rb +148 -0
  72. data/lib/tasker_core/types.rb +132 -0
  73. data/lib/tasker_core/version.rb +13 -0
  74. data/lib/tasker_core/worker/CLAUDE.md +7 -0
  75. data/lib/tasker_core/worker/event_poller.rb +224 -0
  76. data/lib/tasker_core/worker/in_process_domain_event_poller.rb +271 -0
  77. data/lib/tasker_core.rb +160 -0
  78. metadata +322 -0
@@ -0,0 +1,452 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'faraday'
4
+
5
+ module TaskerCore
6
+ module StepHandler
7
+ module Mixins
8
+ # API mixin that provides HTTP functionality for step handlers.
9
+ # Use this mixin with Base to add HTTP capabilities to any handler.
10
+ #
11
+ # ## TAS-112: Composition Pattern
12
+ #
13
+ # This module follows the composition-over-inheritance pattern. Instead of
14
+ # inheriting from a specialized API handler class, include this mixin in
15
+ # your Base handler.
16
+ #
17
+ # ## Usage
18
+ #
19
+ # ```ruby
20
+ # class MyApiHandler < TaskerCore::StepHandler::Base
21
+ # include TaskerCore::StepHandler::Mixins::API
22
+ #
23
+ # def call(context)
24
+ # response = get('/users', params: { limit: 10 })
25
+ # success(result: response.body)
26
+ # end
27
+ # end
28
+ # ```
29
+ #
30
+ # ## Key Features
31
+ #
32
+ # - Faraday HTTP client with full configuration support
33
+ # - Automatic error classification (RetryableError vs PermanentError)
34
+ # - Retry-After header support for server-requested backoff
35
+ # - SSL configuration, headers, query parameters
36
+ module API
37
+ # Hook called when module is included
38
+ def self.included(base)
39
+ base.extend(ClassMethods)
40
+ end
41
+
42
+ # Class methods added to including class
43
+ module ClassMethods
44
+ # No class methods needed for now
45
+ end
46
+
47
+ # Override capabilities to include API-specific features
48
+ def capabilities
49
+ super + %w[http_client error_classification retry_headers faraday_connection]
50
+ end
51
+
52
+ # Enhanced configuration schema for API handlers
53
+ def config_schema
54
+ super.merge({ properties: get_merged_api_config(super[:properties]) })
55
+ end
56
+
57
+ # ========================================================================
58
+ # HTTP CONNECTION
59
+ # ========================================================================
60
+
61
+ # Access to the configured Faraday connection
62
+ # @return [Faraday::Connection] Configured HTTP connection
63
+ def connection
64
+ @connection ||= build_faraday_connection
65
+ end
66
+
67
+ # Allow connection customization with block
68
+ # @yield [connection] Faraday connection for customization
69
+ def configure_connection(&)
70
+ @connection = build_faraday_connection(&)
71
+ end
72
+
73
+ # ========================================================================
74
+ # CONVENIENCE HTTP METHODS
75
+ # ========================================================================
76
+
77
+ # Perform HTTP GET request with automatic error classification
78
+ # @param path [String] API endpoint path
79
+ # @param params [Hash] Query parameters
80
+ # @param headers [Hash] Additional headers
81
+ # @return [Faraday::Response] Raw response object
82
+ def get(path, params: {}, headers: {})
83
+ response = connection.get(path, params, headers)
84
+ process_response(response)
85
+ response
86
+ end
87
+
88
+ # Perform HTTP POST request with automatic error classification
89
+ # @param path [String] API endpoint path
90
+ # @param data [Hash] Request body data
91
+ # @param headers [Hash] Additional headers
92
+ # @return [Faraday::Response] Raw response object
93
+ def post(path, data: {}, headers: {})
94
+ response = connection.post(path, data, headers)
95
+ process_response(response)
96
+ response
97
+ end
98
+
99
+ # Perform HTTP PUT request with automatic error classification
100
+ # @param path [String] API endpoint path
101
+ # @param data [Hash] Request body data
102
+ # @param headers [Hash] Additional headers
103
+ # @return [Faraday::Response] Raw response object
104
+ def put(path, data: {}, headers: {})
105
+ response = connection.put(path, data, headers)
106
+ process_response(response)
107
+ response
108
+ end
109
+
110
+ # Perform HTTP DELETE request with automatic error classification
111
+ # @param path [String] API endpoint path
112
+ # @param params [Hash] Query parameters
113
+ # @param headers [Hash] Additional headers
114
+ # @return [Faraday::Response] Raw response object
115
+ def delete(path, params: {}, headers: {})
116
+ response = connection.delete(path, params, headers)
117
+ process_response(response)
118
+ response
119
+ end
120
+
121
+ # ========================================================================
122
+ # SPECIALIZED HTTP METHODS
123
+ # ========================================================================
124
+
125
+ # Upload file via multipart form data
126
+ # @param path [String] Upload endpoint path
127
+ # @param file_path [String] Path to file to upload
128
+ # @param field_name [String] Form field name for file
129
+ # @param additional_fields [Hash] Additional form fields
130
+ # @return [Hash] Response data
131
+ def api_upload_file(path, file_path, field_name: 'file', additional_fields: {})
132
+ unless File.exist?(file_path)
133
+ raise TaskerCore::PermanentError.new(
134
+ "File not found: #{file_path}",
135
+ error_code: 'FILE_NOT_FOUND',
136
+ error_category: 'validation'
137
+ )
138
+ end
139
+
140
+ payload = additional_fields.merge({
141
+ field_name => Faraday::Multipart::FilePart.new(file_path,
142
+ File.open(file_path).content_type || 'application/octet-stream')
143
+ })
144
+
145
+ http_request(:post, path, multipart: payload)
146
+ end
147
+
148
+ # Perform paginated request that handles cursor/offset pagination
149
+ # @param path [String] API endpoint path
150
+ # @param method [Symbol] HTTP method (:get, :post)
151
+ # @param pagination_key [String] Key for pagination parameter
152
+ # @param limit_key [String] Key for limit parameter
153
+ # @param max_pages [Integer] Maximum pages to fetch (safety limit)
154
+ # @yield [page_data] Block to process each page of data
155
+ # @return [Array] All collected results
156
+ def api_paginated_request(path, method: :get, pagination_key: 'cursor',
157
+ limit_key: 'limit', max_pages: 100)
158
+ results = []
159
+ pagination_value = nil
160
+ page_count = 0
161
+
162
+ loop do
163
+ page_count += 1
164
+ if page_count > max_pages
165
+ logger.warn("Pagination limit reached (#{max_pages} pages) for #{path}")
166
+ break
167
+ end
168
+
169
+ params = { limit_key => @config.dig(:pagination, :page_size) || 100 }
170
+ params[pagination_key] = pagination_value if pagination_value
171
+
172
+ response = http_request(method, path, params: params)
173
+ page_data = response[:data] || response['data'] || []
174
+
175
+ # Process page with block if provided
176
+ if block_given?
177
+ yield(page_data)
178
+ else
179
+ results.concat(Array(page_data))
180
+ end
181
+
182
+ # Check for next page
183
+ pagination_value = extract_pagination_cursor(response, pagination_key)
184
+ break unless pagination_value && page_data.any?
185
+ end
186
+
187
+ results
188
+ end
189
+
190
+ # ========================================================================
191
+ # RESULT HELPERS
192
+ # ========================================================================
193
+
194
+ # Return a successful API response result
195
+ #
196
+ # @param data [Object] Response data
197
+ # @param status [Integer] HTTP status code
198
+ # @param headers [Hash] Response headers
199
+ # @param execution_time_ms [Integer] Execution time in milliseconds
200
+ # @param metadata [Hash] Additional metadata
201
+ # @return [StepHandlerCallResult] Success result
202
+ def api_success(data:, status: 200, headers: {}, execution_time_ms: nil, metadata: {})
203
+ result = {
204
+ 'data' => data,
205
+ 'status' => status,
206
+ 'headers' => headers
207
+ }
208
+ result['execution_time_ms'] = execution_time_ms if execution_time_ms
209
+
210
+ success(result: result, metadata: metadata.merge(api_call: true))
211
+ end
212
+
213
+ # Return a failed API response result
214
+ #
215
+ # @param message [String] Error message
216
+ # @param status [Integer] HTTP status code
217
+ # @param error_type [String] Error type classification
218
+ # @param headers [Hash] Response headers
219
+ # @param execution_time_ms [Integer] Execution time in milliseconds
220
+ # @param metadata [Hash] Additional metadata
221
+ # @return [StepHandlerCallResult] Error result
222
+ def api_failure(message:, status: nil, error_type: nil, headers: {}, execution_time_ms: nil, metadata: {})
223
+ # Classify error type based on status code if not provided
224
+ classified_error_type = error_type || classify_error_type(status)
225
+ retryable = retryable_status?(status)
226
+
227
+ failure(
228
+ message: message,
229
+ error_type: classified_error_type,
230
+ retryable: retryable,
231
+ metadata: metadata.merge(
232
+ api_call: true,
233
+ status: status,
234
+ headers: headers,
235
+ execution_time_ms: execution_time_ms
236
+ ).compact
237
+ )
238
+ end
239
+
240
+ # ========================================================================
241
+ # RESPONSE PROCESSING
242
+ # ========================================================================
243
+
244
+ # Process HTTP response and classify errors
245
+ # @param response [Faraday::Response] HTTP response to process
246
+ def process_response(response)
247
+ return response if response.success?
248
+
249
+ case response.status
250
+ when 400, 401, 403, 404, 422
251
+ # Client errors - permanent failures (don't retry)
252
+ raise TaskerCore::PermanentError.new(
253
+ "HTTP #{response.status}: #{response.reason_phrase}",
254
+ error_code: "HTTP_#{response.status}",
255
+ error_category: classify_client_error(response.status),
256
+ context: {
257
+ status: response.status,
258
+ body: response.body,
259
+ headers: response.headers.to_h
260
+ }
261
+ )
262
+ when 429
263
+ # Rate limiting - retryable with server-suggested backoff
264
+ retry_after = extract_retry_after_header(response.headers)
265
+ raise TaskerCore::RetryableError.new(
266
+ "Rate limited: #{response.reason_phrase}",
267
+ retry_after: retry_after,
268
+ error_category: 'rate_limit',
269
+ context: {
270
+ status: response.status,
271
+ retry_after: retry_after,
272
+ body: response.body,
273
+ headers: response.headers.to_h
274
+ }
275
+ )
276
+ when 503
277
+ # Service unavailable - retryable with server-suggested backoff
278
+ retry_after = extract_retry_after_header(response.headers)
279
+ raise TaskerCore::RetryableError.new(
280
+ "Service unavailable: #{response.reason_phrase}",
281
+ retry_after: retry_after,
282
+ error_category: 'service_unavailable',
283
+ context: {
284
+ status: response.status,
285
+ retry_after: retry_after,
286
+ body: response.body,
287
+ headers: response.headers.to_h
288
+ }
289
+ )
290
+ when 500..599
291
+ # Other server errors - retryable without forced backoff
292
+ raise TaskerCore::RetryableError.new(
293
+ "Server error: HTTP #{response.status} #{response.reason_phrase}",
294
+ error_category: 'server_error',
295
+ context: {
296
+ status: response.status,
297
+ body: response.body,
298
+ headers: response.headers.to_h
299
+ }
300
+ )
301
+ else
302
+ # Unknown status codes - treat as retryable for safety
303
+ raise TaskerCore::RetryableError.new(
304
+ "Unknown HTTP status: #{response.status} #{response.reason_phrase}",
305
+ error_category: 'unknown',
306
+ context: {
307
+ status: response.status,
308
+ body: response.body,
309
+ headers: response.headers.to_h
310
+ }
311
+ )
312
+ end
313
+ end
314
+
315
+ private
316
+
317
+ # Classify error type based on HTTP status code
318
+ def classify_error_type(status)
319
+ case status
320
+ when 400..499 then 'PermanentError'
321
+ when 500..599 then 'RetryableError'
322
+ else 'UnexpectedError'
323
+ end
324
+ end
325
+
326
+ # Determine if status code indicates retryable error
327
+ def retryable_status?(status)
328
+ return false if status.nil?
329
+
330
+ status >= 500 || status == 429
331
+ end
332
+
333
+ def get_merged_api_config(config)
334
+ ::TaskerCore::ConfigSchemas::API_CONFIG_SCHEMA.dup.merge(config)
335
+ end
336
+
337
+ # Build Faraday connection with configuration
338
+ def build_faraday_connection
339
+ base_url = config[:url] || config['url']
340
+
341
+ Faraday.new(base_url) do |conn|
342
+ # Apply configuration
343
+ apply_connection_config(conn)
344
+
345
+ # Apply custom configuration block if provided
346
+ yield(conn) if block_given?
347
+
348
+ # Default adapter (must be last)
349
+ conn.adapter Faraday.default_adapter unless conn.builder.handlers.any? { |h| h.klass < Faraday::Adapter }
350
+ end
351
+ end
352
+
353
+ # Apply configuration to Faraday connection
354
+ def apply_connection_config(conn)
355
+ # Get API timeouts from configuration
356
+ api_timeouts = TaskerCore::Config.instance.api_timeouts
357
+
358
+ # Timeouts - use config values or TaskerCore configuration defaults
359
+ conn.options.timeout = config[:timeout] || config['timeout'] || api_timeouts[:timeout]
360
+ conn.options.open_timeout = config[:open_timeout] || config['open_timeout'] || api_timeouts[:open_timeout]
361
+
362
+ # SSL configuration
363
+ if (ssl_config = config[:ssl] || config['ssl'])
364
+ conn.ssl.merge!(ssl_config.transform_keys(&:to_sym))
365
+ end
366
+
367
+ # Headers
368
+ if (headers = config[:headers] || config['headers'])
369
+ headers.each { |key, value| conn.headers[key] = value }
370
+ end
371
+
372
+ # Query parameters
373
+ if (params = config[:params] || config['params'])
374
+ conn.params.merge!(params)
375
+ end
376
+
377
+ # Authentication
378
+ apply_authentication(conn)
379
+
380
+ # Request/response middleware
381
+ conn.request :json
382
+ conn.response :json
383
+
384
+ # Logging (only in debug mode)
385
+ return unless logger.level <= Logger::DEBUG
386
+
387
+ conn.response :logger, logger, { headers: false, bodies: false }
388
+ end
389
+
390
+ # Apply authentication to connection
391
+ def apply_authentication(conn)
392
+ auth_config = config[:auth] || config['auth'] || {}
393
+
394
+ case auth_config[:type] || auth_config['type']
395
+ when 'bearer'
396
+ token = auth_config[:token] || auth_config['token']
397
+ conn.request :authorization, 'Bearer', token if token
398
+ when 'basic'
399
+ username = auth_config[:username] || auth_config['username']
400
+ password = auth_config[:password] || auth_config['password']
401
+ conn.request :authorization, :basic, username, password if username && password
402
+ when 'api_key'
403
+ token = auth_config[:token] || auth_config['token']
404
+ header = auth_config[:api_key_header] || auth_config['api_key_header'] || 'X-API-Key'
405
+ conn.headers[header] = token if token
406
+ end
407
+ end
408
+
409
+ # Extract retry-after header value
410
+ def extract_retry_after_header(headers)
411
+ TaskerCore::ErrorClassification.extract_retry_after(headers)
412
+ end
413
+
414
+ # Extract pagination cursor from response
415
+ def extract_pagination_cursor(response, pagination_key)
416
+ data = response[:data] || response['data']
417
+
418
+ # Check for cursor in response metadata
419
+ meta = response[:meta] || response['meta'] || response[:pagination] || response['pagination']
420
+ return meta[pagination_key] || meta[pagination_key.to_s] if meta
421
+
422
+ # Check for cursor in response body
423
+ return data[pagination_key] || data[pagination_key.to_s] if data.is_a?(Hash)
424
+
425
+ # Check for next URL in headers (Link header)
426
+ if (headers = response[:headers] || response['headers'])
427
+ link_header = headers['Link'] || headers['link']
428
+ return extract_param_from_link_header(link_header, pagination_key) if link_header&.include?('rel="next"')
429
+ end
430
+
431
+ nil
432
+ end
433
+
434
+ # Simple link header parsing for pagination
435
+ def extract_param_from_link_header(link_header, param_key)
436
+ match = link_header.match(/[?&]#{param_key}=([^&>]+)/)
437
+ match ? match[1] : nil
438
+ end
439
+
440
+ # Classify client error category by status code
441
+ def classify_client_error(status_code)
442
+ case status_code
443
+ when 400, 422 then 'validation'
444
+ when 401, 403 then 'authorization'
445
+ when 404 then 'not_found'
446
+ else 'client_error'
447
+ end
448
+ end
449
+ end
450
+ end
451
+ end
452
+ end