Custom resource attributes
ryanwjackson opened this issue · 9 comments
Is your feature request related to a problem? Please describe.
NetSuite (and other ledgers) support customer fields/attributes on resources. Right now, you must specify each attribute per resource (and in operation validations).
Thoughts/Ideas
-
ResourceAttributeSet
Right now we store all attributes in aResourceAttributeSet
object. We could modify resources to have an.attributes
instance method instead of attributes being directly accessible. Then we can have that set accept any key/value pair. -
Add a
custom_attributes: {}
keyword to resource initializer -
Add a way to specify custom attributes when calling
LedgerSync.register_resource
(or somewhere when instantiating LedgerSync)
This would require devs to know and specify their custom attributes. NetSuite does provide a dynamic schema/metadata through their API. I don't know that this holds for other ledgers that offer this level of customization. In theory, they may just return custom attributes as part of the record's response body and we may not have a formal way to detect. So perhaps being intentional and having devs define these attributes makes sense.
- Add raw body to resource
Ideally, resources will remain adaptor independent. But there is data loss (even now) for all the attributes we aren't defining. You can access this data through the Response
object body, but it's pretty inconvenient. Perhaps we can add an adaptor_body
attribute to resources or something?
Appendix
For all of these we would have to determine how to handle operation validations, which right now require that you define a validation for every attribute.
I've been investigating how this works, and I want to enumerate some options I have come up with. For clarity, we use the term attribute
(not property
, field
, etc.). Some ledgers use different lingo or may even use them interchangeably.
First, let's discuss where/how we use attributes today:
- They are explicitly defined on each resource with a type.
- They must be included in the validation section of each operation for that resource.
Challenges:
- The enforced validation and type definition ensures we are intentional about each attribute value.
- Different ledgers will have different attributes and/or the implementation using the lib may required different fields.
- How do we validate when an attribute value is set, but that attribute is not used in the operation (e.g. you set
subsidiary
but QBO does not use this field)?
Thoughts:
- At a minimum, we need a way to store the raw output of the API against the resource so all fields are accessible, explicitly defined or not.
Options
Option 1
Modify ResourceAttributeSet
to support custom attributes so that we could do something like the following:
resource = LedgerSync::Customer.new
resource.attributes # returns a `ResourceAttributeSet`
resource.attributes.name # returns the name of the customer
resource.name # raises MethodNotDefined error, because we will move it to `attributes`
resource.custom_attributes # a `ResourceAttributeSet` of user- or lib- defined attributes
We can allow operations access to both sets so they can perform the necessary validations.
Option 2
Create a CustomAttribute
resource and use it via references_many
:
resource.custom_attributes # returns an array of CustomAttributes
Operations could validate on these with the current pattern. One benefit of this approach is that NetSuite allows you to manage custom attributes (fields as they call them) via the API (e.g. create custom field, edit custom field, etc.). Since in this approach they would be resources, we could build operations around them as well. This may be required anyway, given that when you onboard a customer you may need to create custom attributes for your integration to work.
Option 3
We could turn ResourceAttributeSet
into a catch-all, similar to FactoryBot. Essentially you could do the following:
resource.attributes.name = 'Ryan'
resource.attributes.foo = :bar
resource.attributes.whatever = 123
We would not be able to declare every attribute on the resource or in operation validations. There is flexibility in this approach, but I admit that it seems very "monkey-patchy." I recently tried to patch FactoryBot for this lib, and it was a nightmare. To achieve the catch-all nature, you must override method_missing
, explicitly allowing the methods you want defined and then being clever about setting/getting the rest.
Open Questions
- How do we determine if an attribute is custom or not when pulling a record from the NS REST API?
- Ensure we can edit the values of customer attributes on a given resource. I assume you can, but I haven't tested yet.
I feel like I'm leaning towards #1 or #2. I don't really want developers to have to write code for the custom attributes. If there's a good way to manage this through configuration, that could be nice. For example, if we had a CustomAttribute
model, we could do something like
attr = CustomAttribute.new
attr.type = :int
attr.value = 2
resource.custom_attributes << attr
And then write that to the ledger using their API
Let's talk about this tomorrow
Random thoughts for discussion:
- There also may be a way to make this part of the
config.rb
of an Adaptor
If devs will be using different custom attributes, then the operations need to be able to account for that. For example, a Create
needs to know you want to save that CustomAttribute
. Or perhaps we can also make the operation.perform
method take a block:
operation.perform do |adaptor:, resource:|
# Do something here.
end
One challenge there is that each adaptor has different ways of interacting with the ledger. For example, QBO is Faraday requests and Stripe is ruby objects.
From sync:
- consumer of library can provide custom serializer for operation
CustomAttributeCollection
andCustomAttribute
,type
is optional
After some more work on this, I see a couple solutions that are worth discussing:
Solution 1
customer = LedgerSync::Customer.new(
name: 'Test Name',
foo: 'bar',
custom_attributes: {
LedgerSync::ResourceAttribute::Custom.new(
name: :foo,
type: Type::String
)
}
)
customer.name # => 'Test Name'
customer.foo # => 'bar'
customer.custom_attributes # => [
<LedgerSync::ResourceAttribute::Custom @name="foo">
]
class CustomLedgerSerializer < LedgerSync::NetSuite::Customer::LedgerSerializer
attribute ledger_attribute: 'custom_foo_field_in_ledger',
resource_attribute: :foo
end
op = LedgerSync::NetSuite::Customer::Create.new(
adaptor: adaptor,
ledger_deserializer: CustomLedgerSerializer,
ledger_serializer: CustomLedgerSerializer,
resource: customer
)
op.perform
Solution 2
class CustomCustomer < LedgerSync::Customer
attribute :foo, type: Type::String
end
customer = CustomCustomer.new(
name: 'Test Name',
foo: 'bar'
)
customer.name # => 'Test Name'
customer.foo # => 'bar'
customer.custom_attributes # NotDefinedError
class CustomLedgerSerializer < LedgerSync::NetSuite::Customer::LedgerSerializer
attribute ledger_attribute: 'custom_foo_field_in_ledger',
resource_attribute: :foo
end
op = LedgerSync::NetSuite::Customer::Create.new(
adaptor: adaptor,
ledger_deserializer: CustomLedgerSerializer,
ledger_serializer: CustomLedgerSerializer,
resource: customer
)
op.perform
Other
Operation validation contracts are hard-coded into the operation. We can get make this more dynamic by allowing it to be passed in on operation initialization. This will at least give you the ability validate at the operation level if you want to.
What I like about Solution 2 is that we don't mix concepts of attributes. I find the LedgerSync::Customer.new(name: 'test', custom_attributes: [...])
to be confusing because name
is a value of an attribute whereas custom_attributes
is something "special"/functional.
I like 2nd option. It's clean. If user needs to write custom serializer to handle the logic, defining custom resource should not be an issue.
As per live discussions, we will go with Solution 2.