graphiti-api / graphiti

Stylish Graph APIs
https://www.graphiti.dev/
MIT License
971 stars 139 forks source link

Custom Persistence with Sideposting? #23

Closed derekcannon closed 6 years ago

derekcannon commented 6 years ago

I've read over the examples for both custom persistence and sideposting. It seems like to add a service call in the mix, you would define a create(attributes) method in the resource. If you're sideposting, I suppose you'd define multiple create(attributes) on the various resources.

Assuming that's all correct, what if your service object coordinates multiple models being posted together, none of which can be saved until the service object touches all of them?

A more concrete example, let's say you have an Order and an Address and need to geocode to find the timezone, then apply that timezone to a time on the order:

# app/services/some_service.rb
class SomeService
  def initialize(order, address)
    # assigning variables, etc...
  end

  def create
    address.geocode
    order.some_time = time_in_new_timezone(order.some_time_without_zone, address.timezone)
    address.save && order.save
  end

  private

  def time_in_new_timezone(time, zone)
    # some logic
  end
end

# app/resources/order_resource.rb
class OrderResource < ApplicationResource
  type :orders
  model Order

  def create(attributes)
    order = Order.new(attributes)
    # address = ??? (no access to the params for address here)
    SomeService.new(order, address).create
    order
  end
end

I know there are other ways to approach this particular example, but it's just a simple example representing a much more complex service.

richmolj commented 6 years ago

Hi Derek - great question. I think I have an answer for you, the documentation just hasn't caught up yet. In your example, you could do something like this:

class AddressResource < JsonapiCompliable::Resource
  # ... code ...
  def create(attributes, parent) # note the optional second arg here
    address = Address.new(attributes)
    if parent.is_a?(Order)
      address.geocode
        .time_in_new_timezone(parent.some_time, address.timezone)
    end
    address.save
    address
  end
end

Note the check on parent here - since multiple objects could (in theory) sidepost an address, we should verify it's an Order doing the sideposting.

Another option would be the after_save hooks within the parent. I haven't written a how-to yet, but you can see examples in the tests. One way:

# OrderResource
belongs_to :address,
  foreign_key: :order_id,
  scope: -> { Address.all },
  resource: AddressResource do
    after_save only: [:create, update] do |order, addresses|
      # addresses is always an array regardless of has-many/belongs-to
      address = addresses.first
      address.geocode(order.some_time, address.timezone)
      address.save
    end

For both of these examples, you'll already be within a transaction. Make sure to define #transaction in your adapter if not using activerecord.

Does either of those work for you?

richmolj commented 6 years ago

And by the way, as you noted earlier - another option is to simply post a CompoundOrder object, rather than sideposting the address. In this case you'd have a CompoundOrder resource and the attributes could be whatever structure you want, and you could do whatever you want within the relevant resource methods. I think this is less than ideal, but it's what a lot of RESTful folks did/do before jsonapi introduced sideposting, so no worse than the status quo as a last resort.

derekcannon commented 6 years ago

Thanks for the quick reply! Having access to the parent is definitely helpful. I want to avoid callbacks as much as possible; it's gotten us into a lot of trouble in our past API.

I am thinking at this point the compound object would be best. Ideally, I'd like to define a central place where things are happening to an order, so it's easy to see what effects are taking place.

Another option, for future consideration, might be the ability to specify which resource handles the create/update for a relationship. Something like:

# app/resources/order_resource.rb
class OrderResource < ApplicationResource
  type :orders
  model Order

  has_one :address,
          resource: AddressResource,
          persisted_by: :self, # something to specify OrderResource is handling address params
          scope: -> { Address.all },
          foreign_key: :address_id
end

This way, AddressResource can operate as normal in any situation and not know about the custom requirements for OrderResource, while OrderResource is smart enough to handle address params in a certain way. What do you think?

richmolj commented 6 years ago

I'm definitely open to more options/injectables around persistence. That said, I'm not sure I agree with that abstraction - I like the premise of an OrderResource persists an Order, an AddressResource persists an Address, and a CompoundOrderResource is persisted by a CompoundOrderService that coordinates multiple resources. Cross-pollinating these sounds like we'd end up with some confusing "who does what where" issues, and some "unexpected side-effect" issues similar to what you've seen before with callbacks.

I do think there is probably something to the idea of a JsonapiCompliable::CompoundResource concept, but I'd like to hear more about these scenarios and problems with the existing paradigm before digging too deep. Maybe you could start down the CompoundOrderResource route and let me know any problems you run into?