attio 0.3.0 → 0.5.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.
- checksums.yaml +4 -4
- data/.gitignore +2 -0
- data/CHANGELOG.md +83 -2
- data/CLAUDE.md +35 -4
- data/Gemfile.lock +1 -1
- data/META_IMPLEMENTATION_PLAN.md +205 -0
- data/README.md +361 -37
- data/lib/attio/circuit_breaker.rb +299 -0
- data/lib/attio/client.rb +11 -10
- data/lib/attio/connection_pool.rb +190 -35
- data/lib/attio/enhanced_client.rb +257 -0
- data/lib/attio/http_client.rb +54 -3
- data/lib/attio/observability.rb +424 -0
- data/lib/attio/resources/attributes.rb +244 -0
- data/lib/attio/resources/base.rb +53 -0
- data/lib/attio/resources/bulk.rb +1 -1
- data/lib/attio/resources/lists.rb +195 -0
- data/lib/attio/resources/meta.rb +103 -42
- data/lib/attio/resources/objects.rb +104 -0
- data/lib/attio/resources/records.rb +97 -2
- data/lib/attio/resources/workspaces.rb +11 -2
- data/lib/attio/version.rb +1 -1
- data/lib/attio/webhooks.rb +220 -0
- data/lib/attio.rb +9 -1
- metadata +6 -1
@@ -0,0 +1,424 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "logger"
|
5
|
+
|
6
|
+
module Attio
|
7
|
+
# Observability and instrumentation for Attio client
|
8
|
+
#
|
9
|
+
# @example Basic usage
|
10
|
+
# client.instrumentation = Attio::Observability.new(
|
11
|
+
# logger: Rails.logger,
|
12
|
+
# metrics_backend: :datadog
|
13
|
+
# )
|
14
|
+
module Observability
|
15
|
+
# Base instrumentation class
|
16
|
+
class Instrumentation
|
17
|
+
attr_reader :logger, :metrics, :traces
|
18
|
+
|
19
|
+
def initialize(logger: nil, metrics_backend: nil, trace_backend: nil)
|
20
|
+
@logger = logger || Logger.new($stdout)
|
21
|
+
@metrics = Metrics.for(metrics_backend) if metrics_backend
|
22
|
+
@traces = Traces.for(trace_backend) if trace_backend
|
23
|
+
@enabled = true
|
24
|
+
end
|
25
|
+
|
26
|
+
# Record an API call
|
27
|
+
#
|
28
|
+
# @param method [Symbol] HTTP method
|
29
|
+
# @param path [String] API path
|
30
|
+
# @param duration [Float] Call duration in seconds
|
31
|
+
# @param status [Integer] HTTP status code
|
32
|
+
# @param error [Exception] Error if any
|
33
|
+
def record_api_call(method:, path:, duration:, status: nil, error: nil)
|
34
|
+
return unless @enabled
|
35
|
+
|
36
|
+
log_api_call(method, path, duration, status, error)
|
37
|
+
record_api_metrics(method, path, duration, error) if @metrics
|
38
|
+
record_api_trace(method, path, status, error) if @traces
|
39
|
+
end
|
40
|
+
|
41
|
+
# Record rate limit information
|
42
|
+
#
|
43
|
+
# @param remaining [Integer] Requests remaining
|
44
|
+
# @param limit [Integer] Rate limit
|
45
|
+
# @param reset_at [Time] When limit resets
|
46
|
+
def record_rate_limit(remaining:, limit:, reset_at:)
|
47
|
+
return unless @enabled
|
48
|
+
|
49
|
+
utilization = 1.0 - (remaining.to_f / limit)
|
50
|
+
|
51
|
+
@logger.debug(format_log_entry(
|
52
|
+
event: "rate_limit",
|
53
|
+
remaining: remaining,
|
54
|
+
limit: limit,
|
55
|
+
utilization: utilization.round(3),
|
56
|
+
reset_in: (reset_at - Time.now).to_i
|
57
|
+
))
|
58
|
+
|
59
|
+
@metrics&.gauge("attio.rate_limit.remaining", remaining)
|
60
|
+
@metrics&.gauge("attio.rate_limit.utilization", utilization)
|
61
|
+
end
|
62
|
+
|
63
|
+
# Record cache hit/miss
|
64
|
+
#
|
65
|
+
# @param key [String] Cache key
|
66
|
+
# @param hit [Boolean] Whether it was a hit
|
67
|
+
def record_cache(key:, hit:)
|
68
|
+
return unless @enabled
|
69
|
+
|
70
|
+
@logger.debug(format_log_entry(
|
71
|
+
event: "cache",
|
72
|
+
key: key,
|
73
|
+
hit: hit
|
74
|
+
))
|
75
|
+
|
76
|
+
@metrics&.increment("attio.cache.#{hit ? 'hits' : 'misses'}")
|
77
|
+
end
|
78
|
+
|
79
|
+
# Record circuit breaker state change
|
80
|
+
#
|
81
|
+
# @param endpoint [String] Endpoint name
|
82
|
+
# @param old_state [Symbol] Previous state
|
83
|
+
# @param new_state [Symbol] New state
|
84
|
+
def record_circuit_breaker(endpoint:, old_state:, new_state:)
|
85
|
+
return unless @enabled
|
86
|
+
|
87
|
+
@logger.warn(format_log_entry(
|
88
|
+
event: "circuit_breaker",
|
89
|
+
endpoint: endpoint,
|
90
|
+
old_state: old_state,
|
91
|
+
new_state: new_state
|
92
|
+
))
|
93
|
+
|
94
|
+
@metrics&.increment("attio.circuit_breaker.transitions", tags: {
|
95
|
+
from: old_state,
|
96
|
+
to: new_state,
|
97
|
+
})
|
98
|
+
end
|
99
|
+
|
100
|
+
# Record connection pool stats
|
101
|
+
#
|
102
|
+
# @param stats [Hash] Pool statistics
|
103
|
+
def record_pool_stats(stats)
|
104
|
+
return unless @enabled
|
105
|
+
|
106
|
+
@metrics&.gauge("attio.pool.size", stats[:size])
|
107
|
+
@metrics&.gauge("attio.pool.available", stats[:available])
|
108
|
+
@metrics&.gauge("attio.pool.allocated", stats[:allocated])
|
109
|
+
@metrics&.gauge("attio.pool.utilization", stats[:allocated].to_f / stats[:size])
|
110
|
+
end
|
111
|
+
|
112
|
+
# Disable instrumentation
|
113
|
+
def disable!
|
114
|
+
@enabled = false
|
115
|
+
end
|
116
|
+
|
117
|
+
# Enable instrumentation
|
118
|
+
def enable!
|
119
|
+
@enabled = true
|
120
|
+
end
|
121
|
+
|
122
|
+
private def format_log_entry(**fields)
|
123
|
+
fields[:timestamp] = Time.now.iso8601
|
124
|
+
fields[:service] = "attio-ruby"
|
125
|
+
JSON.generate(fields)
|
126
|
+
end
|
127
|
+
|
128
|
+
private def sanitize_path(path)
|
129
|
+
# Remove IDs from paths for metric aggregation
|
130
|
+
path.gsub(%r{/[a-f0-9-]{36}}, "/:id") # UUIDs
|
131
|
+
.gsub(%r{/[a-zA-Z]+-\d+-[a-zA-Z]+}, "/:id") # IDs like abc-123-def
|
132
|
+
.gsub(%r{/\d+}, "/:id") # Numeric IDs
|
133
|
+
end
|
134
|
+
|
135
|
+
private def log_api_call(method, path, duration, status, error)
|
136
|
+
@logger.info(format_log_entry(
|
137
|
+
event: "api_call",
|
138
|
+
method: method,
|
139
|
+
path: path,
|
140
|
+
duration_ms: (duration * 1000).round(2),
|
141
|
+
status: status,
|
142
|
+
error: error&.class&.name
|
143
|
+
))
|
144
|
+
end
|
145
|
+
|
146
|
+
private def record_api_metrics(method, path, duration, error)
|
147
|
+
@metrics.increment("attio.api.calls", tags: { method: method, path: sanitize_path(path) })
|
148
|
+
@metrics.histogram("attio.api.duration", duration * 1000, tags: { method: method })
|
149
|
+
|
150
|
+
return unless error
|
151
|
+
|
152
|
+
@metrics.increment("attio.api.errors", tags: {
|
153
|
+
method: method,
|
154
|
+
error_class: error.class.name,
|
155
|
+
})
|
156
|
+
end
|
157
|
+
|
158
|
+
private def record_api_trace(method, path, status, error)
|
159
|
+
@traces.span("attio.api.call") do |span|
|
160
|
+
span.set_attribute("http.method", method.to_s)
|
161
|
+
span.set_attribute("http.path", path)
|
162
|
+
span.set_attribute("http.status_code", status) if status
|
163
|
+
span.set_attribute("error", true) if error
|
164
|
+
end
|
165
|
+
end
|
166
|
+
end
|
167
|
+
|
168
|
+
# Metrics backends
|
169
|
+
module Metrics
|
170
|
+
def self.for(backend)
|
171
|
+
case backend
|
172
|
+
when :datadog then Datadog.new
|
173
|
+
when :statsd then StatsD.new
|
174
|
+
when :prometheus then Prometheus.new
|
175
|
+
when :memory then Memory.new
|
176
|
+
else
|
177
|
+
raise ArgumentError, "Unknown metrics backend: #{backend}"
|
178
|
+
end
|
179
|
+
end
|
180
|
+
|
181
|
+
# In-memory metrics for testing
|
182
|
+
class Memory
|
183
|
+
attr_reader :counters, :gauges, :histograms
|
184
|
+
|
185
|
+
def initialize
|
186
|
+
@counters = Hash.new(0)
|
187
|
+
@gauges = {}
|
188
|
+
@histograms = Hash.new { |h, k| h[k] = [] }
|
189
|
+
end
|
190
|
+
|
191
|
+
def increment(metric, tags: {})
|
192
|
+
key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
|
193
|
+
@counters[key] += 1
|
194
|
+
end
|
195
|
+
|
196
|
+
def gauge(metric, value, tags: {})
|
197
|
+
key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
|
198
|
+
@gauges[key] = value
|
199
|
+
end
|
200
|
+
|
201
|
+
def histogram(metric, value, tags: {})
|
202
|
+
key = tags.empty? ? "#{metric}:" : "#{metric}:#{format_tags(tags)}"
|
203
|
+
@histograms[key] << value
|
204
|
+
end
|
205
|
+
|
206
|
+
def reset!
|
207
|
+
@counters.clear
|
208
|
+
@gauges.clear
|
209
|
+
@histograms.clear
|
210
|
+
end
|
211
|
+
|
212
|
+
private def format_tags(tags)
|
213
|
+
# Format tags in a consistent way that works across Ruby versions
|
214
|
+
sorted = tags.sort.to_h
|
215
|
+
content = sorted.map { |k, v| ":#{k}=>#{v.inspect}" }.join(", ")
|
216
|
+
"{#{content}}"
|
217
|
+
end
|
218
|
+
end
|
219
|
+
|
220
|
+
# StatsD metrics
|
221
|
+
class StatsD
|
222
|
+
def initialize
|
223
|
+
require "statsd-ruby"
|
224
|
+
@client = ::Statsd.new("localhost", 8125)
|
225
|
+
rescue LoadError
|
226
|
+
raise "Please add 'statsd-ruby' to your Gemfile"
|
227
|
+
end
|
228
|
+
|
229
|
+
def increment(metric, tags: {})
|
230
|
+
@client.increment(metric, tags: format_tags(tags))
|
231
|
+
end
|
232
|
+
|
233
|
+
def gauge(metric, value, tags: {})
|
234
|
+
@client.gauge(metric, value, tags: format_tags(tags))
|
235
|
+
end
|
236
|
+
|
237
|
+
def histogram(metric, value, tags: {})
|
238
|
+
@client.histogram(metric, value, tags: format_tags(tags))
|
239
|
+
end
|
240
|
+
|
241
|
+
private def format_tags(tags)
|
242
|
+
tags.map { |k, v| "#{k}:#{v}" }
|
243
|
+
end
|
244
|
+
end
|
245
|
+
|
246
|
+
# Datadog metrics
|
247
|
+
class Datadog
|
248
|
+
def initialize
|
249
|
+
require "datadog/statsd"
|
250
|
+
@client = ::Datadog::Statsd.new("localhost", 8125)
|
251
|
+
rescue LoadError
|
252
|
+
raise "Please add 'dogstatsd-ruby' to your Gemfile"
|
253
|
+
end
|
254
|
+
|
255
|
+
def increment(metric, tags: {})
|
256
|
+
@client.increment(metric, tags: format_tags(tags))
|
257
|
+
end
|
258
|
+
|
259
|
+
def gauge(metric, value, tags: {})
|
260
|
+
@client.gauge(metric, value, tags: format_tags(tags))
|
261
|
+
end
|
262
|
+
|
263
|
+
def histogram(metric, value, tags: {})
|
264
|
+
@client.histogram(metric, value, tags: format_tags(tags))
|
265
|
+
end
|
266
|
+
|
267
|
+
private def format_tags(tags)
|
268
|
+
tags.map { |k, v| "#{k}:#{v}" }
|
269
|
+
end
|
270
|
+
end
|
271
|
+
|
272
|
+
# Prometheus metrics
|
273
|
+
class Prometheus
|
274
|
+
def initialize
|
275
|
+
require "prometheus/client"
|
276
|
+
@registry = ::Prometheus::Client.registry
|
277
|
+
@counters = {}
|
278
|
+
@gauges = {}
|
279
|
+
@histograms = {}
|
280
|
+
rescue LoadError
|
281
|
+
raise "Please add 'prometheus-client' to your Gemfile"
|
282
|
+
end
|
283
|
+
|
284
|
+
def increment(metric, tags: {})
|
285
|
+
counter = @counters[metric] ||= @registry.counter(
|
286
|
+
metric.to_sym,
|
287
|
+
docstring: "Counter for #{metric}",
|
288
|
+
labels: tags.keys
|
289
|
+
)
|
290
|
+
counter.increment(labels: tags)
|
291
|
+
end
|
292
|
+
|
293
|
+
def gauge(metric, value, tags: {})
|
294
|
+
gauge = @gauges[metric] ||= @registry.gauge(
|
295
|
+
metric.to_sym,
|
296
|
+
docstring: "Gauge for #{metric}",
|
297
|
+
labels: tags.keys
|
298
|
+
)
|
299
|
+
gauge.set(value, labels: tags)
|
300
|
+
end
|
301
|
+
|
302
|
+
def histogram(metric, value, tags: {})
|
303
|
+
histogram = @histograms[metric] ||= @registry.histogram(
|
304
|
+
metric.to_sym,
|
305
|
+
docstring: "Histogram for #{metric}",
|
306
|
+
labels: tags.keys
|
307
|
+
)
|
308
|
+
histogram.observe(value, labels: tags)
|
309
|
+
end
|
310
|
+
end
|
311
|
+
end
|
312
|
+
|
313
|
+
# Tracing backends
|
314
|
+
module Traces
|
315
|
+
def self.for(backend)
|
316
|
+
case backend
|
317
|
+
when :opentelemetry then OpenTelemetry.new
|
318
|
+
when :datadog then DatadogAPM.new
|
319
|
+
when :memory then Memory.new
|
320
|
+
else
|
321
|
+
raise ArgumentError, "Unknown trace backend: #{backend}"
|
322
|
+
end
|
323
|
+
end
|
324
|
+
|
325
|
+
# OpenTelemetry tracing
|
326
|
+
class OpenTelemetry
|
327
|
+
def initialize
|
328
|
+
require "opentelemetry-sdk"
|
329
|
+
@tracer = ::OpenTelemetry.tracer_provider.tracer("attio-ruby")
|
330
|
+
rescue LoadError
|
331
|
+
raise "Please add 'opentelemetry-sdk' to your Gemfile"
|
332
|
+
end
|
333
|
+
|
334
|
+
def span(name, &block)
|
335
|
+
@tracer.in_span(name, &block)
|
336
|
+
end
|
337
|
+
end
|
338
|
+
|
339
|
+
# Datadog APM tracing
|
340
|
+
class DatadogAPM
|
341
|
+
def initialize
|
342
|
+
require "datadog"
|
343
|
+
@tracer = ::Datadog::Tracing
|
344
|
+
rescue LoadError
|
345
|
+
raise "Please add 'datadog' to your Gemfile"
|
346
|
+
end
|
347
|
+
|
348
|
+
def span(name)
|
349
|
+
@tracer.trace(name) do |span|
|
350
|
+
yield(span) if block_given?
|
351
|
+
end
|
352
|
+
end
|
353
|
+
end
|
354
|
+
|
355
|
+
# In-memory tracing for testing
|
356
|
+
class Memory
|
357
|
+
attr_reader :spans
|
358
|
+
|
359
|
+
def initialize
|
360
|
+
@spans = []
|
361
|
+
end
|
362
|
+
|
363
|
+
def span(name)
|
364
|
+
span = Span.new(name)
|
365
|
+
@spans << span
|
366
|
+
yield(span) if block_given?
|
367
|
+
span
|
368
|
+
end
|
369
|
+
|
370
|
+
def reset!
|
371
|
+
@spans.clear
|
372
|
+
end
|
373
|
+
|
374
|
+
class Span
|
375
|
+
attr_reader :name, :attributes
|
376
|
+
|
377
|
+
def initialize(name)
|
378
|
+
@name = name
|
379
|
+
@attributes = {}
|
380
|
+
end
|
381
|
+
|
382
|
+
def set_attribute(key, value)
|
383
|
+
@attributes[key] = value
|
384
|
+
end
|
385
|
+
end
|
386
|
+
end
|
387
|
+
end
|
388
|
+
|
389
|
+
# Middleware for automatic instrumentation
|
390
|
+
class Middleware
|
391
|
+
def initialize(app, instrumentation)
|
392
|
+
@app = app
|
393
|
+
@instrumentation = instrumentation
|
394
|
+
end
|
395
|
+
|
396
|
+
def call(env)
|
397
|
+
start_time = Time.now
|
398
|
+
method = env[:method]
|
399
|
+
path = env[:url].path
|
400
|
+
|
401
|
+
begin
|
402
|
+
response = @app.call(env)
|
403
|
+
|
404
|
+
@instrumentation.record_api_call(
|
405
|
+
method: method,
|
406
|
+
path: path,
|
407
|
+
duration: Time.now - start_time,
|
408
|
+
status: response.status
|
409
|
+
)
|
410
|
+
|
411
|
+
response
|
412
|
+
rescue StandardError => e
|
413
|
+
@instrumentation.record_api_call(
|
414
|
+
method: method,
|
415
|
+
path: path,
|
416
|
+
duration: Time.now - start_time,
|
417
|
+
error: e
|
418
|
+
)
|
419
|
+
raise
|
420
|
+
end
|
421
|
+
end
|
422
|
+
end
|
423
|
+
end
|
424
|
+
end
|
@@ -9,6 +9,20 @@ module Attio
|
|
9
9
|
#
|
10
10
|
# @example Listing attributes
|
11
11
|
# client.attributes.list(object: "people")
|
12
|
+
#
|
13
|
+
# @example Creating a custom attribute
|
14
|
+
# client.attributes.create(
|
15
|
+
# object: "deals",
|
16
|
+
# data: {
|
17
|
+
# title: "Deal Stage",
|
18
|
+
# api_slug: "deal_stage",
|
19
|
+
# type: "select",
|
20
|
+
# options: [
|
21
|
+
# { title: "Lead", value: "lead" },
|
22
|
+
# { title: "Qualified", value: "qualified" }
|
23
|
+
# ]
|
24
|
+
# }
|
25
|
+
# )
|
12
26
|
class Attributes < Base
|
13
27
|
def list(object:, **params)
|
14
28
|
validate_object!(object)
|
@@ -21,6 +35,228 @@ module Attio
|
|
21
35
|
request(:get, "objects/#{object}/attributes/#{id_or_slug}")
|
22
36
|
end
|
23
37
|
|
38
|
+
# Create a custom attribute for an object
|
39
|
+
#
|
40
|
+
# @param object [String] The object type or slug
|
41
|
+
# @param data [Hash] The attribute configuration
|
42
|
+
# @option data [String] :title The display title of the attribute
|
43
|
+
# @option data [String] :api_slug The API slug for the attribute
|
44
|
+
# @option data [String] :type The attribute type (text, number, select, date, etc.)
|
45
|
+
# @option data [String] :description Optional description
|
46
|
+
# @option data [Boolean] :is_required Whether the attribute is required
|
47
|
+
# @option data [Boolean] :is_unique Whether the attribute must be unique
|
48
|
+
# @option data [Boolean] :is_multiselect For select types, whether multiple values are allowed
|
49
|
+
# @option data [Array<Hash>] :options For select types, the available options
|
50
|
+
# @return [Hash] The created attribute
|
51
|
+
# @example Create a select attribute
|
52
|
+
# client.attributes.create(
|
53
|
+
# object: "trips",
|
54
|
+
# data: {
|
55
|
+
# title: "Status",
|
56
|
+
# api_slug: "status",
|
57
|
+
# type: "select",
|
58
|
+
# options: [
|
59
|
+
# { title: "Pending", value: "pending" },
|
60
|
+
# { title: "Active", value: "active" }
|
61
|
+
# ]
|
62
|
+
# }
|
63
|
+
# )
|
64
|
+
def create(object:, data:)
|
65
|
+
validate_object!(object)
|
66
|
+
validate_required_hash!(data, "Attribute data")
|
67
|
+
|
68
|
+
# Wrap data in the expected format
|
69
|
+
payload = { data: data }
|
70
|
+
|
71
|
+
request(:post, "objects/#{object}/attributes", payload)
|
72
|
+
end
|
73
|
+
|
74
|
+
# Update an existing attribute
|
75
|
+
#
|
76
|
+
# @param object [String] The object type or slug
|
77
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
78
|
+
# @param data [Hash] The attribute configuration updates
|
79
|
+
# @option data [String] :title The display title of the attribute
|
80
|
+
# @option data [String] :api_slug The API slug for the attribute
|
81
|
+
# @option data [String] :description Optional description
|
82
|
+
# @option data [Boolean] :is_required Whether the attribute is required
|
83
|
+
# @option data [Boolean] :is_unique Whether the attribute must be unique
|
84
|
+
# @option data [Boolean] :is_multiselect For select types, whether multiple values are allowed
|
85
|
+
# @return [Hash] The updated attribute
|
86
|
+
# @example Update an attribute's title
|
87
|
+
# client.attributes.update(
|
88
|
+
# object: "contacts",
|
89
|
+
# id_or_slug: "status",
|
90
|
+
# data: {
|
91
|
+
# title: "Contact Status",
|
92
|
+
# description: "The current status of the contact"
|
93
|
+
# }
|
94
|
+
# )
|
95
|
+
def update(object:, id_or_slug:, data:)
|
96
|
+
validate_object!(object)
|
97
|
+
validate_id_or_slug!(id_or_slug)
|
98
|
+
validate_required_hash!(data, "Attribute data")
|
99
|
+
|
100
|
+
# Wrap data in the expected format
|
101
|
+
payload = { data: data }
|
102
|
+
|
103
|
+
request(:patch, "objects/#{object}/attributes/#{id_or_slug}", payload)
|
104
|
+
end
|
105
|
+
|
106
|
+
# List options for a select attribute
|
107
|
+
#
|
108
|
+
# @param object [String] The object type or slug
|
109
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
110
|
+
# @param params [Hash] Additional query parameters
|
111
|
+
# @option params [Integer] :limit Number of results to return
|
112
|
+
# @option params [Integer] :offset Number of results to skip
|
113
|
+
# @return [Hash] The list of attribute options
|
114
|
+
# @example List options for a select attribute
|
115
|
+
# client.attributes.list_options(
|
116
|
+
# object: "deals",
|
117
|
+
# id_or_slug: "deal_stage"
|
118
|
+
# )
|
119
|
+
def list_options(object:, id_or_slug:, **params)
|
120
|
+
validate_object!(object)
|
121
|
+
validate_id_or_slug!(id_or_slug)
|
122
|
+
request(:get, "objects/#{object}/attributes/#{id_or_slug}/options", params)
|
123
|
+
end
|
124
|
+
|
125
|
+
# Create a new option for a select attribute
|
126
|
+
#
|
127
|
+
# @param object [String] The object type or slug
|
128
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
129
|
+
# @param data [Hash] The option configuration
|
130
|
+
# @option data [String] :title The display title of the option
|
131
|
+
# @option data [String] :value The value of the option
|
132
|
+
# @option data [String] :color Optional color for the option
|
133
|
+
# @option data [Integer] :order Optional order for the option
|
134
|
+
# @return [Hash] The created option
|
135
|
+
# @example Create a new option
|
136
|
+
# client.attributes.create_option(
|
137
|
+
# object: "deals",
|
138
|
+
# id_or_slug: "deal_stage",
|
139
|
+
# data: {
|
140
|
+
# title: "Negotiation",
|
141
|
+
# value: "negotiation",
|
142
|
+
# color: "blue"
|
143
|
+
# }
|
144
|
+
# )
|
145
|
+
def create_option(object:, id_or_slug:, data:)
|
146
|
+
validate_object!(object)
|
147
|
+
validate_id_or_slug!(id_or_slug)
|
148
|
+
validate_required_hash!(data, "Option data")
|
149
|
+
|
150
|
+
request(:post, "objects/#{object}/attributes/#{id_or_slug}/options", data)
|
151
|
+
end
|
152
|
+
|
153
|
+
# Update an option for a select attribute
|
154
|
+
#
|
155
|
+
# @param object [String] The object type or slug
|
156
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
157
|
+
# @param option [String] The option ID or value
|
158
|
+
# @param data [Hash] The option configuration updates
|
159
|
+
# @option data [String] :title The display title of the option
|
160
|
+
# @option data [String] :value The value of the option
|
161
|
+
# @option data [String] :color Optional color for the option
|
162
|
+
# @option data [Integer] :order Optional order for the option
|
163
|
+
# @return [Hash] The updated option
|
164
|
+
# @example Update an option's title
|
165
|
+
# client.attributes.update_option(
|
166
|
+
# object: "deals",
|
167
|
+
# id_or_slug: "deal_stage",
|
168
|
+
# option: "negotiation",
|
169
|
+
# data: {
|
170
|
+
# title: "In Negotiation",
|
171
|
+
# color: "orange"
|
172
|
+
# }
|
173
|
+
# )
|
174
|
+
def update_option(object:, id_or_slug:, option:, data:)
|
175
|
+
validate_object!(object)
|
176
|
+
validate_id_or_slug!(id_or_slug)
|
177
|
+
validate_option_id!(option)
|
178
|
+
validate_required_hash!(data, "Option data")
|
179
|
+
|
180
|
+
request(:patch, "objects/#{object}/attributes/#{id_or_slug}/options/#{option}", data)
|
181
|
+
end
|
182
|
+
|
183
|
+
# List statuses for a status attribute
|
184
|
+
#
|
185
|
+
# @param object [String] The object type or slug
|
186
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
187
|
+
# @param params [Hash] Additional query parameters
|
188
|
+
# @option params [Integer] :limit Number of results to return
|
189
|
+
# @option params [Integer] :offset Number of results to skip
|
190
|
+
# @return [Hash] The list of attribute statuses
|
191
|
+
# @example List statuses for a status attribute
|
192
|
+
# client.attributes.list_statuses(
|
193
|
+
# object: "deals",
|
194
|
+
# id_or_slug: "deal_status"
|
195
|
+
# )
|
196
|
+
def list_statuses(object:, id_or_slug:, **params)
|
197
|
+
validate_object!(object)
|
198
|
+
validate_id_or_slug!(id_or_slug)
|
199
|
+
request(:get, "objects/#{object}/attributes/#{id_or_slug}/statuses", params)
|
200
|
+
end
|
201
|
+
|
202
|
+
# Create a new status for a status attribute
|
203
|
+
#
|
204
|
+
# @param object [String] The object type or slug
|
205
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
206
|
+
# @param data [Hash] The status configuration
|
207
|
+
# @option data [String] :title The display title of the status
|
208
|
+
# @option data [String] :value The value of the status
|
209
|
+
# @option data [String] :color Optional color for the status
|
210
|
+
# @option data [Integer] :order Optional order for the status
|
211
|
+
# @return [Hash] The created status
|
212
|
+
# @example Create a new status
|
213
|
+
# client.attributes.create_status(
|
214
|
+
# object: "deals",
|
215
|
+
# id_or_slug: "deal_status",
|
216
|
+
# data: {
|
217
|
+
# title: "Under Review",
|
218
|
+
# value: "under_review",
|
219
|
+
# color: "yellow"
|
220
|
+
# }
|
221
|
+
# )
|
222
|
+
def create_status(object:, id_or_slug:, data:)
|
223
|
+
validate_object!(object)
|
224
|
+
validate_id_or_slug!(id_or_slug)
|
225
|
+
validate_required_hash!(data, "Status data")
|
226
|
+
|
227
|
+
request(:post, "objects/#{object}/attributes/#{id_or_slug}/statuses", data)
|
228
|
+
end
|
229
|
+
|
230
|
+
# Update a status for a status attribute
|
231
|
+
#
|
232
|
+
# @param object [String] The object type or slug
|
233
|
+
# @param id_or_slug [String] The attribute ID or API slug
|
234
|
+
# @param status [String] The status ID or value
|
235
|
+
# @param data [Hash] The status configuration updates
|
236
|
+
# @option data [String] :title The display title of the status
|
237
|
+
# @option data [String] :value The value of the status
|
238
|
+
# @option data [String] :color Optional color for the status
|
239
|
+
# @option data [Integer] :order Optional order for the status
|
240
|
+
# @return [Hash] The updated status
|
241
|
+
# @example Update a status's title
|
242
|
+
# client.attributes.update_status(
|
243
|
+
# object: "deals",
|
244
|
+
# id_or_slug: "deal_status",
|
245
|
+
# status: "under_review",
|
246
|
+
# data: {
|
247
|
+
# title: "Pending Review",
|
248
|
+
# color: "orange"
|
249
|
+
# }
|
250
|
+
# )
|
251
|
+
def update_status(object:, id_or_slug:, status:, data:)
|
252
|
+
validate_object!(object)
|
253
|
+
validate_id_or_slug!(id_or_slug)
|
254
|
+
validate_status_id!(status)
|
255
|
+
validate_required_hash!(data, "Status data")
|
256
|
+
|
257
|
+
request(:patch, "objects/#{object}/attributes/#{id_or_slug}/statuses/#{status}", data)
|
258
|
+
end
|
259
|
+
|
24
260
|
private def validate_object!(object)
|
25
261
|
raise ArgumentError, "Object type is required" if object.nil? || object.to_s.strip.empty?
|
26
262
|
end
|
@@ -28,6 +264,14 @@ module Attio
|
|
28
264
|
private def validate_id_or_slug!(id_or_slug)
|
29
265
|
raise ArgumentError, "Attribute ID or slug is required" if id_or_slug.nil? || id_or_slug.to_s.strip.empty?
|
30
266
|
end
|
267
|
+
|
268
|
+
private def validate_option_id!(option)
|
269
|
+
raise ArgumentError, "Option ID is required" if option.nil? || option.to_s.strip.empty?
|
270
|
+
end
|
271
|
+
|
272
|
+
private def validate_status_id!(status)
|
273
|
+
raise ArgumentError, "Status ID is required" if status.nil? || status.to_s.strip.empty?
|
274
|
+
end
|
31
275
|
end
|
32
276
|
end
|
33
277
|
end
|