moco-ruby 0.1.2 → 1.0.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,73 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "bundler/setup"
5
+ require "moco"
6
+ require "yaml"
7
+
8
+ # Load configuration from config.yml
9
+ config = YAML.load_file(File.join(File.dirname(__FILE__), "..", "config.yml"))
10
+ instance = config["instances"].first
11
+
12
+ # Initialize client
13
+ moco = MOCO::Client.new(
14
+ subdomain: instance["subdomain"],
15
+ api_key: instance["api_key"]
16
+ )
17
+
18
+ puts "Connected to MOCO instance: #{instance["subdomain"]}"
19
+
20
+ # Get all active projects
21
+ puts "\nActive Projects:"
22
+ projects = moco.projects.where(active: "true")
23
+ projects.each do |project|
24
+ puts "- #{project.id}: #{project.name} (#{project.customer&.name})"
25
+ end
26
+
27
+ # Get a specific project
28
+ if projects.any?
29
+ project = projects.first
30
+ puts "\nProject Details for #{project.name}:"
31
+ puts " Customer: #{project.customer&.name}"
32
+
33
+ # Get tasks for the project
34
+ puts " Tasks:"
35
+ project.tasks.each do |task|
36
+ puts " - #{task.name} (#{task.billable ? "Billable" : "Non-billable"})"
37
+ end
38
+
39
+ # Get recent activities for the project
40
+ puts "\nRecent Activities for #{project.name}:"
41
+ activities = project.activities
42
+ activities.each do |activity|
43
+ puts " - #{activity.date}: #{activity.hours}h - #{activity.description} (#{activity.user&.full_name})"
44
+ end
45
+
46
+ # Demonstrate chaining (commented out to avoid modifying data)
47
+ # project.archive.assign_to_group(123).unarchive
48
+ end
49
+
50
+ # Get users
51
+ puts "\nUsers:"
52
+ users = moco.users.all
53
+ users.each do |user|
54
+ puts "- #{user.id}: #{user.full_name}"
55
+ end
56
+
57
+ # Dynamic access to any collection
58
+ puts "\nDemonstrating dynamic collection access:"
59
+ collections = %w[companies deals invoices expenses schedules presences holidays planning_entries]
60
+ collections.each do |collection|
61
+ if moco.respond_to?(collection)
62
+ count = begin
63
+ moco.send(collection).count
64
+ rescue StandardError
65
+ 0
66
+ end
67
+ puts "- #{collection}: #{count} items"
68
+ else
69
+ puts "- #{collection}: not available"
70
+ end
71
+ end
72
+
73
+ puts "\nExample completed successfully!"
@@ -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:, debug: false)
10
+ @connection = Connection.new(self, subdomain, api_key, debug: debug)
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,200 @@
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
+ # Modifies the base path to fetch assigned resources. Returns self.
58
+ def assigned
59
+ # Ensure this is only called once or handle idempotency if needed
60
+ @base_path += "/assigned"
61
+ self
62
+ end
63
+
64
+ # --- Methods Triggering API Call ---
65
+
66
+ # Fetches all records matching the current filters.
67
+ # Caches the result.
68
+ def all
69
+ load_records unless loaded?
70
+ @records
71
+ end
72
+
73
+ # Fetches a specific record by ID. Does not use current filters or limit.
74
+ def find(id)
75
+ # Ensure entity_class is loaded and valid before calling new
76
+ klass = entity_class
77
+ return nil unless klass && klass <= MOCO::BaseEntity
78
+
79
+ # Directly fetch by ID, bypassing stored filters/limit
80
+ response = client.get("#{@base_path}/#{id}")
81
+ # wrap_response now returns an array even for single results
82
+ result_array = wrap_response(response)
83
+ # Return the single entity or nil if not found (or if response was not a hash)
84
+ result_array.first
85
+ end
86
+
87
+ # NOTE: The duplicated 'where' method definition below is removed.
88
+ # The correct 'where' method is defined earlier in the class.
89
+
90
+ # Fetches the first record matching the current filters.
91
+ def first
92
+ limit(1).load_records unless loaded? && @limit_value == 1
93
+ @records.first
94
+ end
95
+
96
+ # Finds the first record matching the given attributes.
97
+ def find_by(conditions)
98
+ where(conditions).first
99
+ end
100
+
101
+ # Executes the query and yields each record.
102
+ def each(&)
103
+ load_records unless loaded?
104
+ @records.each(&)
105
+ end
106
+
107
+ # --- Persistence Methods (Pass-through) ---
108
+ # These don't typically belong on the relation/proxy but are kept for now.
109
+ # Consider moving them or ensuring they operate correctly in this context.
110
+
111
+ def create(attributes)
112
+ klass = entity_class
113
+ return nil unless klass && klass <= MOCO::BaseEntity
114
+
115
+ klass.new(client, client.post(@base_path, attributes))
116
+ end
117
+
118
+ def update(id, attributes)
119
+ klass = entity_class
120
+ return nil unless klass && klass <= MOCO::BaseEntity
121
+
122
+ klass.new(client, client.put("#{@base_path}/#{id}", attributes))
123
+ end
124
+
125
+ def delete(id)
126
+ client.delete("#{@base_path}/#{id}")
127
+ end
128
+
129
+ # --- Internal Methods ---
130
+
131
+ # Executes the API request based on current filters and limit.
132
+ # Populates @records and sets @loaded flag.
133
+ # Needs to be public for methods like first, each, find_by to call it.
134
+ def load_records
135
+ query_params = @filters.dup
136
+ query_params[:limit] = @limit_value if @limit_value
137
+ # MOCO API might use 'per_page' instead of 'limit' for pagination control
138
+ # Adjust if necessary based on API docs. Assuming 'limit' works for now.
139
+
140
+ # Filter out nil values before sending to avoid empty params like ?from&to=
141
+ query_params.compact!
142
+
143
+ response = client.get(@base_path, query_params)
144
+ @records = wrap_response(response) # wrap_response should return an Array here
145
+ @loaded = true
146
+ @records # Return the loaded records
147
+ end
148
+
149
+ protected
150
+
151
+ # Flag indicating if records have been loaded from the API.
152
+ def loaded?
153
+ @loaded
154
+ end
155
+
156
+ # Returns the loaded entity class constant.
157
+ attr_reader :entity_class
158
+
159
+ # Determines the base API path for the entity.
160
+ # Uses entity_path method if defined, otherwise uses the pluralized name.
161
+ def determine_base_path(path_or_entity_name)
162
+ klass = entity_class
163
+ # Check if the class itself responds to entity_path (class method)
164
+ return klass.entity_path if klass.respond_to?(:entity_path)
165
+
166
+ # Check if instances respond to entity_path (instance method)
167
+ # Need a dummy instance if the class is valid
168
+ if klass && klass <= MOCO::BaseEntity && klass.instance_methods.include?(:entity_path)
169
+ # We can't reliably call instance methods here without data.
170
+ # This indicates entity_path should likely be a class method.
171
+ # Falling back to default path generation.
172
+ 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."
173
+ end
174
+
175
+ # Fallback: Use the pluralized/tableized version of the entity name or the provided path.
176
+ ActiveSupport::Inflector.tableize(path_or_entity_name.to_s)
177
+ end
178
+
179
+ private
180
+
181
+ # Wraps the raw API response (Hash or Array of Hashes) into entity objects.
182
+ def wrap_response(response_body)
183
+ klass = entity_class
184
+ # Ensure we have a valid class derived from BaseEntity
185
+ return [] unless klass && klass <= MOCO::BaseEntity # Return empty array if class invalid
186
+
187
+ if response_body.is_a?(Array)
188
+ # Convert array of hashes to array of entity objects
189
+ response_body.map { |item_hash| klass.new(client, item_hash) if item_hash.is_a?(Hash) }.compact
190
+ elsif response_body.is_a?(Hash)
191
+ # Wrap single hash response in an array for consistency internally
192
+ [klass.new(client, response_body)]
193
+ else
194
+ # Handle unexpected response types (like the String error we saw)
195
+ warn "Warning: Unexpected API response type received in wrap_response: #{response_body.class}. Expected Hash or Array."
196
+ response_body # Return the unexpected body as is
197
+ end
198
+ end
199
+ end
200
+ end
@@ -0,0 +1,68 @@
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, :debug
11
+
12
+ def initialize(client, subdomain, api_key, debug: false)
13
+ @client = client
14
+ @subdomain = subdomain
15
+ @api_key = api_key
16
+ @debug = debug
17
+ @conn = Faraday.new do |f|
18
+ f.request :json
19
+ f.response :json
20
+ f.request :authorization, "Token", "token=#{@api_key}"
21
+ f.url_prefix = "https://#{@subdomain}.mocoapp.com/api/v1"
22
+ end
23
+ end
24
+
25
+ # Define methods for HTTP verbs (get, post, put, patch, delete)
26
+ # These methods send the request and return the raw parsed JSON response body.
27
+ %w[get post put patch delete].each do |http_method|
28
+ define_method(http_method) do |path, params = {}|
29
+ # Log URL if debug is enabled
30
+ if @debug
31
+ full_url = @conn.build_url(path, params).to_s
32
+ warn "[DEBUG] Fetching URL: #{http_method.upcase} #{full_url}"
33
+ end
34
+ response = @conn.send(http_method, path, params)
35
+
36
+ # Raise an error for non-successful responses
37
+ unless response.success?
38
+ # Attempt to parse error details from the body, otherwise use status/reason
39
+ error_details = response.body.is_a?(Hash) ? response.body["message"] : response.body
40
+ # Explicitly pass nil for original_error, and response for the third argument
41
+ # raise MOCO::Error.new("MOCO API Error: #{response.status} #{response.reason_phrase}. Details: #{error_details}",
42
+ # nil, response)
43
+ # Use RuntimeError for now
44
+ raise "MOCO API Error: #{response.status} #{response.reason_phrase}. Details: #{error_details}"
45
+ end
46
+
47
+ response.body
48
+ rescue Faraday::Error => e
49
+ # Wrap Faraday errors - pass e as the second argument (original_error)
50
+ # raise MOCO::Error.new("Faraday Connection Error: #{e.message}", e)
51
+ # Use RuntimeError for now
52
+ raise "Faraday Connection Error: #{e.message}"
53
+ end
54
+ end
55
+
56
+ # Define a custom error class for MOCO API errors
57
+ # Temporarily commented out
58
+ # class Error < StandardError
59
+ # attr_reader :original_error, :response
60
+ #
61
+ # def initialize(message, original_error = nil, response = nil)
62
+ # super(message)
63
+ # @original_error = original_error
64
+ # @response = response
65
+ # end
66
+ # end
67
+ end
68
+ end
@@ -0,0 +1,101 @@
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
+ # Access the remote_id attribute
93
+ def remote_id
94
+ attributes[:remote_id]
95
+ end
96
+
97
+ def to_s
98
+ "#{attributes[:date]} - #{attributes[:hours]}h - #{project&.name} - #{task&.name} - #{attributes[:description]}"
99
+ end
100
+ end
101
+ end