# frozen_string_literal: true
require_relative "../api_resource"
module Attio
# Represents a custom attribute on an Attio object
# Attributes define the schema for data stored on records
class Attribute < APIResource
api_operations :list, :retrieve, :create, :update
# Attribute types
TYPES = %w[
text
number
checkbox
date
timestamp
rating
currency
status
select
multiselect
email
phone
url
user
record_reference
location
].freeze
# Type configurations
TYPE_CONFIGS = {
"text" => {supports_default: true, supports_required: true},
"number" => {supports_default: true, supports_required: true, supports_unique: true},
"checkbox" => {supports_default: true},
"date" => {supports_default: true, supports_required: true},
"timestamp" => {supports_default: true, supports_required: true},
"rating" => {supports_default: true, max_value: 5},
"currency" => {supports_default: true, supports_required: true},
"status" => {requires_options: true},
"select" => {requires_options: true, supports_default: true},
"multiselect" => {requires_options: true},
"email" => {supports_unique: true, supports_required: true},
"phone" => {supports_required: true},
"url" => {supports_required: true},
"user" => {supports_required: true},
"record_reference" => {requires_target_object: true, supports_required: true},
"location" => {supports_required: true}
}.freeze
# API endpoint path for attributes
# @return [String] The API path
def self.resource_path
"attributes"
end
# Define known attributes with proper accessors
attr_attio :name, :description, :is_required, :is_unique,
:is_default_value_enabled, :default_value, :options
# Read-only attributes
attr_reader :api_slug, :type, :attio_object_id, :object_api_slug,
:parent_object_id, :created_by_actor, :is_archived, :archived_at,
:title
def initialize(attributes = {}, opts = {})
super
normalized_attrs = normalize_attributes(attributes)
@api_slug = normalized_attrs[:api_slug]
@type = normalized_attrs[:type]
@attio_object_id = normalized_attrs[:object_id]
@object_api_slug = normalized_attrs[:object_api_slug]
@parent_object_id = normalized_attrs[:parent_object_id]
@created_by_actor = normalized_attrs[:created_by_actor]
@is_archived = normalized_attrs[:is_archived] || false
@archived_at = parse_timestamp(normalized_attrs[:archived_at])
@title = normalized_attrs[:title]
end
# Archive this attribute
def archive(**opts)
raise InvalidRequestError, "Cannot archive an attribute without an ID" unless persisted?
response = self.class.send(:execute_request, :POST, "#{resource_path}/archive", {}, opts)
update_from(response[:data] || response)
self
end
# Unarchive this attribute
def unarchive(**opts)
raise InvalidRequestError, "Cannot unarchive an attribute without an ID" unless persisted?
response = self.class.send(:execute_request, :POST, "#{resource_path}/unarchive", {}, opts)
update_from(response[:data] || response)
self
end
def archived?
@is_archived == true
end
def required?
is_required == true
end
def unique?
is_unique == true
end
def has_default?
is_default_value_enabled == true
end
# Convert attribute to hash representation
# @return [Hash] Attribute data as a hash
def to_h
super.merge(
api_slug: api_slug,
name: name,
description: description,
type: type,
is_required: is_required,
is_unique: is_unique,
is_default_value_enabled: is_default_value_enabled,
default_value: default_value,
options: options,
object_id: attio_object_id,
object_api_slug: object_api_slug,
parent_object_id: parent_object_id,
created_by_actor: created_by_actor,
is_archived: is_archived,
archived_at: archived_at&.iso8601
).compact
end
def resource_path
raise InvalidRequestError, "Cannot generate path without an ID" unless persisted?
attribute_id = Util::IdExtractor.extract_for_resource(id, :attribute)
"#{self.class.resource_path}/#{attribute_id}"
end
# Override save to handle nested ID
def save(**)
raise InvalidRequestError, "Cannot save an attribute without an ID" unless persisted?
return self unless changed?
# Pass the full ID (including object context) to update method
self.class.update(id, changed_attributes, **)
end
class << self
# Override retrieve to handle object-scoped attributes
def retrieve(id, **opts)
# Extract simple ID if it's a nested hash
attribute_id = Util::IdExtractor.extract_for_resource(id, :attribute)
validate_id!(attribute_id)
# For attributes, we need the object context - check if it's in the nested ID
if id.is_a?(Hash) && id["object_id"]
object_id = id["object_id"]
response = execute_request(:GET, "objects/#{object_id}/attributes/#{attribute_id}", {}, opts)
else
# Fall back to regular attributes endpoint
response = execute_request(:GET, "#{resource_path}/#{attribute_id}", {}, opts)
end
new(response["data"] || response, opts)
end
# Override update to handle object-scoped attributes
def update(id, params = {}, **opts)
# Extract simple ID if it's a nested hash
attribute_id = Util::IdExtractor.extract_for_resource(id, :attribute)
validate_id!(attribute_id)
# For attributes, we need the object context
if id.is_a?(Hash) && id["object_id"]
object_id = id["object_id"]
prepared_params = prepare_params_for_update(params)
response = execute_request(:PATCH, "objects/#{object_id}/attributes/#{attribute_id}", prepared_params, opts)
else
# Fall back to regular attributes endpoint
prepared_params = prepare_params_for_update(params)
response = execute_request(:PATCH, "#{resource_path}/#{attribute_id}", prepared_params, opts)
end
new(response["data"] || response, opts)
end
# Override create to handle validation and object parameter
def prepare_params_for_create(params)
validate_type!(params[:type])
validate_type_config!(params)
# Generate api_slug from name if not provided
api_slug = params[:api_slug] || params[:name].downcase.gsub(/[^a-z0-9]+/, "_")
{
data: {
title: params[:name] || params[:title],
api_slug: api_slug,
type: params[:type],
description: params[:description],
is_required: params[:is_required] || false,
is_unique: params[:is_unique] || false,
is_multiselect: params[:is_multiselect] || false,
default_value: params[:default_value],
config: params[:config] || {}
}.compact
}
end
# Override update params preparation
def prepare_params_for_update(params)
# Only certain fields can be updated
updateable_fields = %i[
name
title
description
is_required
is_unique
default_value
options
]
update_params = params.slice(*updateable_fields)
update_params[:options] = prepare_options(update_params[:options]) if update_params[:options]
# Wrap in data for API
{
data: update_params
}
end
# Override list to handle object-specific attributes
def list(params = {}, **opts)
if params[:object]
object = params.delete(:object)
validate_object_identifier!(object)
response = execute_request(:GET, "objects/#{object}/attributes", params, opts)
APIResource::ListObject.new(response, self, params.merge(object: object), opts)
else
raise ArgumentError, "Attributes must be listed for a specific object. Use Attribute.for_object(object_slug) or pass object: parameter"
end
end
# Override create to handle object-specific attributes
def create(params = {}, **opts)
object = params[:object]
validate_object_identifier!(object)
prepared_params = prepare_params_for_create(params)
response = execute_request(:POST, "objects/#{object}/attributes", prepared_params, opts)
new(response["data"] || response, opts)
end
# List attributes for a specific object
def for_object(object, params = {}, **)
list(params.merge(object: object), **)
end
private
def validate_object_identifier!(object)
raise ArgumentError, "Object identifier is required" if object.nil? || object.to_s.empty?
end
def validate_type!(type)
raise ArgumentError, "Attribute type is required" if type.nil? || type.to_s.empty?
unless TYPES.include?(type.to_s)
raise ArgumentError, "Invalid attribute type: #{type}. Valid types: #{TYPES.join(", ")}"
end
end
def validate_type_config!(params)
type = params[:type]
config = TYPE_CONFIGS[type.to_s]
return unless config
# Check required options
if config[:requires_options]
options = params[:options]
if options.nil? || (options.is_a?(Array) && options.empty?)
raise ArgumentError, "Attribute type '#{type}' requires options"
end
end
# Check required target object
if config[:requires_target_object]
target = params[:target_object]
if target.nil? || target.to_s.empty?
raise ArgumentError, "Attribute type '#{type}' requires target_object"
end
end
# Validate unsupported features
if params[:is_unique] && !config[:supports_unique]
raise ArgumentError, "Attribute type '#{type}' does not support unique constraint"
end
if params[:is_required] && !config[:supports_required]
raise ArgumentError, "Attribute type '#{type}' does not support required constraint"
end
if params[:is_default_value_enabled] && !config[:supports_default]
raise ArgumentError, "Attribute type '#{type}' does not support default values"
end
end
def prepare_options(options)
return nil unless options
case options
when Array
options.map do |opt|
case opt
when String
{title: opt}
when Hash
opt
else
{title: opt.to_s}
end
end
else
options
end
end
end
end
end