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.
- checksums.yaml +4 -4
- data/.rubocop.yml +10 -3
- data/CHANGELOG.md +79 -6
- data/Gemfile +2 -1
- data/Gemfile.lock +71 -23
- data/README.md +199 -55
- data/examples/v2_api_example.rb +73 -0
- data/lib/moco/client.rb +47 -0
- data/lib/moco/collection_proxy.rb +190 -0
- data/lib/moco/connection.rb +62 -0
- data/lib/moco/entities/activity.rb +96 -0
- data/lib/moco/entities/base_entity.rb +303 -0
- data/lib/moco/entities/company.rb +28 -0
- data/lib/moco/entities/deal.rb +24 -0
- data/lib/moco/entities/expense.rb +29 -0
- data/lib/moco/entities/holiday.rb +25 -0
- data/lib/moco/entities/invoice.rb +53 -0
- data/lib/moco/entities/planning_entry.rb +26 -0
- data/lib/moco/entities/presence.rb +30 -0
- data/lib/moco/entities/project.rb +39 -0
- data/lib/moco/entities/schedule.rb +26 -0
- data/lib/moco/entities/task.rb +20 -0
- data/lib/moco/entities/user.rb +33 -0
- data/lib/moco/entities/web_hook.rb +27 -0
- data/lib/moco/entities.rb +13 -4
- data/lib/moco/entity_collection.rb +59 -0
- data/lib/moco/helpers.rb +13 -0
- data/lib/moco/nested_collection_proxy.rb +40 -0
- data/lib/moco/sync.rb +74 -19
- data/lib/moco/version.rb +1 -1
- data/lib/moco.rb +26 -2
- data/mocurl.rb +51 -34
- data/sync_activity.rb +4 -4
- metadata +44 -10
- data/lib/moco/api.rb +0 -129
data/lib/moco/client.rb
ADDED
@@ -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
|