moco-ruby 0.1.1 → 1.0.0.alpha

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,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Main client class for interacting with the MOCO API
5
+ # Provides dynamic access to all API endpoints through method_missing
6
+ class Client
7
+ attr_reader :connection
8
+
9
+ def initialize(subdomain:, api_key:)
10
+ @connection = Connection.new(self, subdomain, api_key)
11
+ @collections = {}
12
+ end
13
+
14
+ # Dynamically handle entity collection access (e.g., client.projects)
15
+ def method_missing(name, *args, &)
16
+ # Check if the method name corresponds to a known plural entity type
17
+ if collection_name?(name)
18
+ # Return a CollectionProxy directly for chainable queries
19
+ # Cache it so subsequent calls return the same proxy instance
20
+ @collections[name] ||= CollectionProxy.new(
21
+ self,
22
+ name.to_s, # Pass the plural name (e.g., "projects") as the path hint
23
+ ActiveSupport::Inflector.classify(name.to_s) # Get class name (e.g., "Project")
24
+ )
25
+ else
26
+ # Delegate to superclass for non-collection methods
27
+ super
28
+ end
29
+ end
30
+
31
+ def respond_to_missing?(name, include_private = false)
32
+ collection_name?(name) || super
33
+ end
34
+
35
+ # Check if the method name looks like a collection name (plural)
36
+ def collection_name?(name)
37
+ name.to_s == ActiveSupport::Inflector.pluralize(name.to_s)
38
+ end
39
+
40
+ # Delegate HTTP methods to connection
41
+ %i[get post put patch delete].each do |method|
42
+ define_method(method) do |path, params = {}|
43
+ connection.send(method, path, params)
44
+ end
45
+ end
46
+ end
47
+ end
@@ -0,0 +1,190 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Provides ActiveRecord-style query interface for MOCO entities
5
+ class CollectionProxy
6
+ include Enumerable
7
+ attr_reader :client, :entity_class_name, :filters, :limit_value
8
+
9
+ def initialize(client, path_or_entity_name, entity_class_name)
10
+ @client = client
11
+ @entity_class_name = entity_class_name
12
+ @entity_class = load_entity_class # Load and store the class
13
+ @base_path = determine_base_path(path_or_entity_name)
14
+ @filters = {} # Store query filters
15
+ @limit_value = nil # Store limit for methods like first/find_by
16
+ @loaded = false # Flag to track if data has been fetched
17
+ @records = [] # Cache for fetched records
18
+ end
19
+
20
+ def load_entity_class
21
+ # Ensure ActiveSupport::Inflector is available if not already loaded globally
22
+ require "active_support/inflector" unless defined?(ActiveSupport::Inflector)
23
+
24
+ entity_file_name = ActiveSupport::Inflector.underscore(entity_class_name)
25
+ entity_file_path = "entities/#{entity_file_name}" # Path relative to lib/moco/
26
+ begin
27
+ # Use require_relative from the current file's directory
28
+ require_relative entity_file_path
29
+ MOCO.const_get(entity_class_name)
30
+ rescue LoadError
31
+ warn "Warning: Could not load entity file at #{entity_file_path}. Using BaseEntity."
32
+ MOCO::BaseEntity # Fallback
33
+ rescue NameError
34
+ warn "Warning: Could not find entity class #{entity_class_name}. Using BaseEntity."
35
+ MOCO::BaseEntity # Fallback
36
+ end
37
+ end
38
+
39
+ # Removed method_missing and respond_to_missing? as they are not
40
+ # currently used for building nested paths in this implementation.
41
+
42
+ # --- Chainable Methods ---
43
+
44
+ # Adds filters to the query. Returns self for chaining.
45
+ def where(conditions = {})
46
+ # TODO: Implement proper merging/handling of existing filters if called multiple times
47
+ @filters.merge!(conditions)
48
+ self # Return self to allow chaining like client.projects.where(active: true).where(...)
49
+ end
50
+
51
+ # Sets a limit on the number of records to fetch. Returns self.
52
+ def limit(value)
53
+ @limit_value = value
54
+ self
55
+ end
56
+
57
+ # --- Methods Triggering API Call ---
58
+
59
+ # Fetches all records matching the current filters.
60
+ # Caches the result.
61
+ def all
62
+ load_records unless loaded?
63
+ @records
64
+ end
65
+
66
+ # Fetches a specific record by ID. Does not use current filters or limit.
67
+ def find(id)
68
+ # Ensure entity_class is loaded and valid before calling new
69
+ klass = entity_class
70
+ return nil unless klass && klass <= MOCO::BaseEntity
71
+
72
+ # Directly fetch by ID, bypassing stored filters/limit
73
+ response = client.get("#{@base_path}/#{id}")
74
+ # wrap_response now returns an array even for single results
75
+ result_array = wrap_response(response)
76
+ # Return the single entity or nil if not found (or if response was not a hash)
77
+ result_array.first
78
+ end
79
+
80
+ # NOTE: The duplicated 'where' method definition below is removed.
81
+ # The correct 'where' method is defined earlier in the class.
82
+
83
+ # Fetches the first record matching the current filters.
84
+ def first
85
+ limit(1).load_records unless loaded? && @limit_value == 1
86
+ @records.first
87
+ end
88
+
89
+ # Finds the first record matching the given attributes.
90
+ def find_by(conditions)
91
+ where(conditions).first
92
+ end
93
+
94
+ # Executes the query and yields each record.
95
+ def each(&)
96
+ load_records unless loaded?
97
+ @records.each(&)
98
+ end
99
+
100
+ # --- Persistence Methods (Pass-through) ---
101
+ # These don't typically belong on the relation/proxy but are kept for now.
102
+ # Consider moving them or ensuring they operate correctly in this context.
103
+
104
+ def create(attributes)
105
+ klass = entity_class
106
+ return nil unless klass && klass <= MOCO::BaseEntity
107
+
108
+ klass.new(client, client.post(@base_path, attributes))
109
+ end
110
+
111
+ def update(id, attributes)
112
+ klass = entity_class
113
+ return nil unless klass && klass <= MOCO::BaseEntity
114
+
115
+ klass.new(client, client.put("#{@base_path}/#{id}", attributes))
116
+ end
117
+
118
+ def delete(id)
119
+ client.delete("#{@base_path}/#{id}")
120
+ end
121
+
122
+ # --- Internal Methods ---
123
+
124
+ # Executes the API request based on current filters and limit.
125
+ # Populates @records and sets @loaded flag.
126
+ # Needs to be public for methods like first, each, find_by to call it.
127
+ def load_records
128
+ query_params = @filters.dup
129
+ query_params[:limit] = @limit_value if @limit_value
130
+ # MOCO API might use 'per_page' instead of 'limit' for pagination control
131
+ # Adjust if necessary based on API docs. Assuming 'limit' works for now.
132
+
133
+ response = client.get(@base_path, query_params)
134
+ @records = wrap_response(response) # wrap_response should return an Array here
135
+ @loaded = true
136
+ @records # Return the loaded records
137
+ end
138
+
139
+ protected
140
+
141
+ # Flag indicating if records have been loaded from the API.
142
+ def loaded?
143
+ @loaded
144
+ end
145
+
146
+ # Returns the loaded entity class constant.
147
+ attr_reader :entity_class
148
+
149
+ # Determines the base API path for the entity.
150
+ # Uses entity_path method if defined, otherwise uses the pluralized name.
151
+ def determine_base_path(path_or_entity_name)
152
+ klass = entity_class
153
+ # Check if the class itself responds to entity_path (class method)
154
+ return klass.entity_path if klass.respond_to?(:entity_path)
155
+
156
+ # Check if instances respond to entity_path (instance method)
157
+ # Need a dummy instance if the class is valid
158
+ if klass && klass <= MOCO::BaseEntity && klass.instance_methods.include?(:entity_path)
159
+ # We can't reliably call instance methods here without data.
160
+ # This indicates entity_path should likely be a class method.
161
+ # Falling back to default path generation.
162
+ warn "Warning: entity_path is defined as an instance method on #{klass.name}. It should ideally be a class method. Falling back to default path."
163
+ end
164
+
165
+ # Fallback: Use the pluralized/tableized version of the entity name or the provided path.
166
+ ActiveSupport::Inflector.tableize(path_or_entity_name.to_s)
167
+ end
168
+
169
+ private
170
+
171
+ # Wraps the raw API response (Hash or Array of Hashes) into entity objects.
172
+ def wrap_response(response_body)
173
+ klass = entity_class
174
+ # Ensure we have a valid class derived from BaseEntity
175
+ return [] unless klass && klass <= MOCO::BaseEntity # Return empty array if class invalid
176
+
177
+ if response_body.is_a?(Array)
178
+ # Convert array of hashes to array of entity objects
179
+ response_body.map { |item_hash| klass.new(client, item_hash) if item_hash.is_a?(Hash) }.compact
180
+ elsif response_body.is_a?(Hash)
181
+ # Wrap single hash response in an array for consistency internally
182
+ [klass.new(client, response_body)]
183
+ else
184
+ # Handle unexpected response types (like the String error we saw)
185
+ warn "Warning: Unexpected API response type received in wrap_response: #{response_body.class}. Expected Hash or Array."
186
+ response_body # Return the unexpected body as is
187
+ end
188
+ end
189
+ end
190
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "faraday"
4
+ require "json"
5
+
6
+ module MOCO
7
+ # Handles HTTP communication with the MOCO API
8
+ # Responsible for building API requests and converting responses to entity objects
9
+ class Connection
10
+ attr_reader :client, :subdomain, :api_key
11
+
12
+ def initialize(client, subdomain, api_key)
13
+ @client = client
14
+ @subdomain = subdomain
15
+ @api_key = api_key
16
+ @conn = Faraday.new do |f|
17
+ f.request :json
18
+ f.response :json
19
+ f.request :authorization, "Token", "token=#{@api_key}"
20
+ f.url_prefix = "https://#{@subdomain}.mocoapp.com/api/v1"
21
+ end
22
+ end
23
+
24
+ # Define methods for HTTP verbs (get, post, put, patch, delete)
25
+ # These methods send the request and return the raw parsed JSON response body.
26
+ %w[get post put patch delete].each do |http_method|
27
+ define_method(http_method) do |path, params = {}|
28
+ response = @conn.send(http_method, path, params)
29
+
30
+ # Raise an error for non-successful responses
31
+ unless response.success?
32
+ # Attempt to parse error details from the body, otherwise use status/reason
33
+ error_details = response.body.is_a?(Hash) ? response.body["message"] : response.body
34
+ # Explicitly pass nil for original_error, and response for the third argument
35
+ # raise MOCO::Error.new("MOCO API Error: #{response.status} #{response.reason_phrase}. Details: #{error_details}",
36
+ # nil, response)
37
+ # Use RuntimeError for now
38
+ raise "MOCO API Error: #{response.status} #{response.reason_phrase}. Details: #{error_details}"
39
+ end
40
+
41
+ response.body
42
+ rescue Faraday::Error => e
43
+ # Wrap Faraday errors - pass e as the second argument (original_error)
44
+ # raise MOCO::Error.new("Faraday Connection Error: #{e.message}", e)
45
+ # Use RuntimeError for now
46
+ raise "Faraday Connection Error: #{e.message}"
47
+ end
48
+ end
49
+
50
+ # Define a custom error class for MOCO API errors
51
+ # Temporarily commented out
52
+ # class Error < StandardError
53
+ # attr_reader :original_error, :response
54
+ #
55
+ # def initialize(message, original_error = nil, response = nil)
56
+ # super(message)
57
+ # @original_error = original_error
58
+ # @response = response
59
+ # end
60
+ # end
61
+ end
62
+ end
@@ -0,0 +1,96 @@
1
+ # frozen_string_literal: true
2
+
3
+ module MOCO
4
+ # Represents a MOCO activity (time entry)
5
+ # Provides methods for activity-specific operations and associations
6
+ class Activity < BaseEntity
7
+ # Instance methods for activity-specific operations
8
+ def start_timer
9
+ client.patch("activities/#{id}/start_timer")
10
+ self
11
+ end
12
+
13
+ def stop_timer
14
+ client.patch("activities/#{id}/stop_timer")
15
+ self
16
+ end
17
+
18
+ # Class methods for bulk operations
19
+ def self.disregard(client, reason:, activity_ids:, company_id:, project_id: nil)
20
+ payload = {
21
+ reason:,
22
+ activity_ids:,
23
+ company_id:
24
+ }
25
+ payload[:project_id] = project_id if project_id
26
+ client.post("activities/disregard", payload)
27
+ end
28
+
29
+ def self.bulk_create(client, activities)
30
+ api_entities = activities.map do |activity|
31
+ activity.to_h.except(:id, :project, :user, :customer).tap do |h|
32
+ h[:project_id] = activity.project.id if activity.project
33
+ h[:task_id] = activity.task.id if activity.task
34
+ end
35
+ end
36
+ client.post("activities/bulk", { activities: api_entities })
37
+ end
38
+
39
+ # Associations
40
+ # Fetches the associated Project object.
41
+ def project
42
+ # Check if the project attribute is a hash (contains ID) or already an object
43
+ project_data = attributes[:project]
44
+ return @project if defined?(@project) # Return memoized object if already fetched
45
+
46
+ @project = if project_data.is_a?(Hash) && project_data[:id]
47
+ client.projects.find(project_data[:id])
48
+ elsif project_data.is_a?(MOCO::Project) # If it was already processed into an object
49
+ project_data
50
+ else
51
+ nil # No project associated or data missing
52
+ end
53
+ end
54
+
55
+ # Fetches the associated Task object.
56
+ def task
57
+ task_data = attributes[:task]
58
+ return @task if defined?(@task)
59
+
60
+ @task = if task_data.is_a?(Hash) && task_data[:id]
61
+ client.tasks.find(task_data[:id])
62
+ elsif task_data.is_a?(MOCO::Task)
63
+ task_data
64
+ end
65
+ end
66
+
67
+ # Fetches the associated User object.
68
+ def user
69
+ user_data = attributes[:user]
70
+ return @user if defined?(@user)
71
+
72
+ @user = if user_data.is_a?(Hash) && user_data[:id]
73
+ client.users.find(user_data[:id])
74
+ elsif user_data.is_a?(MOCO::User)
75
+ user_data
76
+ end
77
+ end
78
+
79
+ # Fetches the associated Customer (Company) object.
80
+ def customer
81
+ customer_data = attributes[:customer]
82
+ return @customer if defined?(@customer)
83
+
84
+ @customer = if customer_data.is_a?(Hash) && customer_data[:id]
85
+ # Customer association points to the 'companies' collection
86
+ client.companies.find(customer_data[:id])
87
+ elsif customer_data.is_a?(MOCO::Company)
88
+ customer_data
89
+ end
90
+ end
91
+
92
+ def to_s
93
+ "#{date} - #{hours}h - #{project&.name} - #{task&.name} - #{description}"
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,303 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "active_support/inflector" # Ensure ActiveSupport::Inflector is available
4
+
5
+ module MOCO
6
+ # Base class for all MOCO API entities
7
+ class BaseEntity
8
+ attr_reader :client, :attributes
9
+
10
+ # Initializes an entity instance from raw API response data (Hash).
11
+ # Recursively processes nested hashes and arrays, converting known
12
+ # entity structures into corresponding MOCO::Entity instances.
13
+ def initialize(client, response_data)
14
+ @client = client
15
+
16
+ # Ensure response_data is a Hash before proceeding
17
+ unless response_data.is_a?(Hash)
18
+ raise ArgumentError, "BaseEntity must be initialized with a Hash, got: #{response_data.class}"
19
+ end
20
+
21
+ # Process the top-level hash: transform keys and process nested values.
22
+ @attributes = response_data.transform_keys(&:to_sym)
23
+ .each_with_object({}) do |(k, v), acc|
24
+ # Use process_value only for the *values* within the hash
25
+ acc[k] = process_value(v, k)
26
+ end
27
+
28
+ # Define attribute methods based on the processed attributes hash
29
+ define_attribute_methods
30
+ end
31
+
32
+ # Returns the entity's ID.
33
+ def id
34
+ attributes[:id] || attributes["id"]
35
+ end
36
+
37
+ # Compares two entities based on class and ID.
38
+ def ==(other)
39
+ self.class == other.class && !id.nil? && id == other.id
40
+ end
41
+
42
+ # Converts the entity to a Hash, recursively converting nested entities.
43
+ def to_h
44
+ attributes.transform_values do |value|
45
+ case value
46
+ when BaseEntity then value.to_h
47
+ when Array then value.map { |item| item.is_a?(BaseEntity) ? item.to_h : item }
48
+ else value
49
+ end
50
+ end
51
+ end
52
+
53
+ # Converts the entity to a JSON string.
54
+ def to_json(*options)
55
+ to_h.to_json(*options)
56
+ end
57
+
58
+ # Provides a string representation of the entity.
59
+ def inspect
60
+ "#<#{self.class.name}:#{object_id} @attributes=#{@attributes.inspect}>"
61
+ end
62
+
63
+ # Saves changes to the entity back to the API.
64
+ # Returns self on success for method chaining.
65
+ def save
66
+ return self if id.nil? # Can't save without an ID
67
+
68
+ # Determine the collection name from the class name
69
+ collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
70
+
71
+ # Check if the client responds to the collection method
72
+ if client.respond_to?(collection_name)
73
+ # Use the collection proxy to update the entity
74
+ updated_data = client.send(collection_name).update(id, attributes)
75
+
76
+ # Update local attributes with the response data
77
+ @attributes = updated_data.attributes if updated_data
78
+ else
79
+ warn "Warning: Client does not respond to collection '#{collection_name}' for saving entity."
80
+ end
81
+
82
+ self # Return self for method chaining
83
+ end
84
+
85
+ # Updates attributes and saves the entity in one step.
86
+ # Returns self on success for method chaining.
87
+ def update(new_attributes)
88
+ # Update attributes
89
+ new_attributes.each do |key, value|
90
+ send("#{key}=", value) if respond_to?("#{key}=")
91
+ end
92
+
93
+ # Save changes
94
+ save
95
+ end
96
+
97
+ # Deletes the entity from the API.
98
+ # Returns true on success, false on failure.
99
+ def destroy
100
+ return false if id.nil? # Can't destroy without an ID
101
+
102
+ # Determine the collection name from the class name
103
+ collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
104
+
105
+ # Check if the client responds to the collection method
106
+ if client.respond_to?(collection_name)
107
+ # Use the collection proxy to delete the entity
108
+ client.send(collection_name).delete(id)
109
+ true
110
+ else
111
+ warn "Warning: Client does not respond to collection '#{collection_name}' for destroying entity."
112
+ false
113
+ end
114
+ end
115
+
116
+ # Reloads the entity from the API.
117
+ # Returns self on success for method chaining.
118
+ def reload
119
+ return self if id.nil? # Can't reload without an ID
120
+
121
+ # Determine the collection name from the class name
122
+ collection_name = ActiveSupport::Inflector.tableize(self.class.name.split("::").last).to_sym
123
+
124
+ # Check if the client responds to the collection method
125
+ if client.respond_to?(collection_name)
126
+ # Use the collection proxy to find the entity
127
+ reloaded = client.send(collection_name).find(id)
128
+
129
+ # Update attributes with the reloaded data
130
+ @attributes = reloaded.attributes if reloaded
131
+ else
132
+ warn "Warning: Client does not respond to collection '#{collection_name}' for reloading entity."
133
+ end
134
+
135
+ self # Return self for method chaining
136
+ end
137
+
138
+ # Helper method to fetch associated objects based on data in attributes.
139
+ # Uses memoization to avoid repeated API calls.
140
+ # association_name: Symbol representing the association (e.g., :project, :customer).
141
+ # target_class_name_override: String specifying the target class if it differs
142
+ # from the classified association name (e.g., "Company" for :customer).
143
+ def association(association_name, target_class_name_override = nil)
144
+ # Initialize cache if it doesn't exist
145
+ @_association_cache ||= {}
146
+ # Return cached object if available
147
+ return @_association_cache[association_name] if @_association_cache.key?(association_name)
148
+
149
+ association_data = attributes[association_name]
150
+
151
+ # If data is already a BaseEntity object (processed during initialization), use it directly.
152
+ return @_association_cache[association_name] = association_data if association_data.is_a?(MOCO::BaseEntity)
153
+
154
+ # If data is a hash containing an ID, fetch the object.
155
+ if association_data.is_a?(Hash) && association_data[:id]
156
+ assoc_id = association_data[:id]
157
+ target_class_name = target_class_name_override || ActiveSupport::Inflector.classify(association_name.to_s)
158
+ collection_name = ActiveSupport::Inflector.tableize(target_class_name).to_sym # e.g., "Project" -> :projects
159
+
160
+ # Check if the client responds to the collection method (e.g., client.projects)
161
+ if client.respond_to?(collection_name)
162
+ # Fetch the object using the appropriate collection proxy
163
+ fetched_object = client.send(collection_name).find(assoc_id)
164
+ return @_association_cache[association_name] = fetched_object
165
+ else
166
+ warn "Warning: Client does not respond to collection '#{collection_name}' for association '#{association_name}'."
167
+ return @_association_cache[association_name] = nil
168
+ end
169
+ end
170
+
171
+ # If data is not an object or a hash with an ID, return nil.
172
+ @_association_cache[association_name] = nil
173
+ end
174
+
175
+ # Helper method to fetch associated collections based on a foreign key.
176
+ # Uses memoization to avoid repeated API calls.
177
+ # association_name: Symbol representing the association (e.g., :tasks, :activities).
178
+ # foreign_key: Symbol representing the foreign key to use (e.g., :project_id).
179
+ # target_class_name_override: String specifying the target class if it differs
180
+ # from the classified association name (e.g., "Task" for :tasks).
181
+ # nested: Boolean indicating if this is a nested resource (e.g., project.tasks)
182
+ def has_many(association_name, foreign_key = nil, target_class_name_override = nil, nested = false)
183
+ # Initialize cache if it doesn't exist
184
+ @_association_cache ||= {}
185
+ # Return cached collection if available
186
+ return @_association_cache[association_name] if @_association_cache.key?(association_name)
187
+
188
+ # If the association data is already in attributes and is an array of entities, use it directly
189
+ association_data = attributes[association_name]
190
+ if association_data.is_a?(Array) && association_data.all? { |item| item.is_a?(MOCO::BaseEntity) }
191
+ return @_association_cache[association_name] = association_data
192
+ end
193
+
194
+ # Otherwise, fetch the collection from the API
195
+ target_class_name = target_class_name_override || ActiveSupport::Inflector.classify(association_name.to_s)
196
+ collection_name = ActiveSupport::Inflector.tableize(target_class_name).to_sym # e.g., "Task" -> :tasks
197
+
198
+ # Determine the foreign key to use
199
+ fk = foreign_key || :"#{ActiveSupport::Inflector.underscore(self.class.name.split("::").last)}_id"
200
+
201
+ # Check if this is a nested resource
202
+ if nested
203
+ # For nested resources, create a NestedCollectionProxy
204
+ require_relative "../nested_collection_proxy"
205
+ @_association_cache[association_name] = MOCO::NestedCollectionProxy.new(
206
+ client,
207
+ self,
208
+ collection_name,
209
+ target_class_name
210
+ )
211
+ elsif client.respond_to?(collection_name)
212
+ # For regular resources, use the standard collection proxy with a filter
213
+ @_association_cache[association_name] = client.send(collection_name).where(fk => id)
214
+ else
215
+ warn "Warning: Client does not respond to collection '#{collection_name}' for association '#{association_name}'."
216
+ @_association_cache[association_name] = []
217
+ end
218
+ end
219
+
220
+ private
221
+
222
+ # Defines getter and setter methods for each key in the @attributes hash.
223
+ def define_attribute_methods
224
+ attributes.each_key do |key|
225
+ # Skip if the key is nil or a method with this name already exists.
226
+ next if key.nil? || respond_to?(key)
227
+
228
+ define_singleton_method(key) { attributes[key] }
229
+ define_singleton_method("#{key}=") { |v| attributes[key] = process_value(v, key) } # Process assigned value too
230
+ end
231
+ end
232
+
233
+ # Recursively processes a value from the API response.
234
+ # - Hashes representing known entities are converted to Entity instances.
235
+ # - Other Hashes have their values processed recursively.
236
+ # - Arrays have their items processed recursively.
237
+ # - Primitives are returned as is.
238
+ # key_hint: The key under which this value was found in its parent Hash.
239
+ def process_value(value, key_hint = nil)
240
+ case value
241
+ when Hash
242
+ # Check if this hash represents a known MOCO entity class
243
+ klass = entity_class_for(value, key_hint)
244
+ if klass
245
+ # If yes, create an instance of that class (recursive initialize)
246
+ klass.new(client, value)
247
+ else
248
+ # If no, treat as a generic hash: process its values recursively
249
+ value.transform_keys(&:to_sym)
250
+ .each_with_object({}) do |(k, v), acc|
251
+ # Pass the key 'k' as a hint for nested processing
252
+ acc[k] = process_value(v, k)
253
+ end
254
+ end
255
+ when Array
256
+ # Recursively process each item in the array.
257
+ # Pass a singularized key_hint if available (e.g., :tasks -> :task)
258
+ singular_hint = key_hint ? ActiveSupport::Inflector.singularize(key_hint.to_s).to_sym : nil
259
+ value.map { |item| process_value(item, singular_hint) }
260
+ else
261
+ # Return primitive values (String, Integer, Boolean, nil, etc.) as is
262
+ value
263
+ end
264
+ end
265
+
266
+ # Determines the specific MOCO::Entity class for a given hash, if possible.
267
+ # Returns the class constant or nil if no specific entity type is identified.
268
+ # data: The hash to analyze.
269
+ # key_hint: The key under which this hash was found in its parent.
270
+ def entity_class_for(data, key_hint = nil)
271
+ # data is always a Hash when called from process_value
272
+ type_name = data[:type] || data["type"]
273
+
274
+ # Infer type from the key_hint if :type attribute is missing
275
+ if type_name.nil? && key_hint
276
+ # Special case: map :customer key to Company class
277
+ type_name = if key_hint == :customer
278
+ "Company"
279
+ else
280
+ # General case: singularize the key hint (e.g., :tasks -> "task")
281
+ ActiveSupport::Inflector.singularize(key_hint.to_s)
282
+ end
283
+ end
284
+
285
+ # If no type name could be determined, it's not a known entity structure
286
+ return nil unless type_name
287
+
288
+ # Convert type name (e.g., "project", "user", "company", "Task") to class name
289
+ # If type_name is already classified (like "Company" from the special case),
290
+ # classify won't hurt it.
291
+ class_name = ActiveSupport::Inflector.classify(type_name)
292
+
293
+ # Check if the class exists within the MOCO module and is a BaseEntity subclass
294
+ if MOCO.const_defined?(class_name, false)
295
+ klass = MOCO.const_get(class_name)
296
+ return klass if klass.is_a?(Class) && klass <= MOCO::BaseEntity
297
+ end
298
+
299
+ # Fallback: No matching entity class found
300
+ nil
301
+ end
302
+ end
303
+ end