Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

A proposal for a non-magical, but stricter context #67

Closed
brianhempel opened this issue Sep 23, 2014 · 32 comments
Closed

A proposal for a non-magical, but stricter context #67

brianhempel opened this issue Sep 23, 2014 · 32 comments
Labels

Comments

@brianhempel
Copy link
Contributor

In #46 we decided to use an OpenStruct for the context. It removed the magic of creating methods when things were set on the context. It seemed wrong to magically add methods to the interactor based on the calling parameters.

However, now we have problem that every time you want the given email, you have to say context.email. This is a break in the syntax-shortening advantage of encapsulation.

What if:

class AuthenticateUser
  include Interactor

  def call
    if user = User.authenticate(context.email, context.password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

Instead became:

class AuthenticateUser
  include Interactor

  context_attributes :email, :password
  context_attributes :user, :token
  context_attribute :message

  def call
    if user = User.authenticate(email, password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

The context itself is no longer an OpenStruct. Accessing or setting an undeclared attribute on the context will raise NoMethodError.

There are several advantages to this approach:

  1. Accessing the context in code is easy: user instead of context.user.
  2. It documents what attributes may be used/created on the context.
  3. Typos fail fast inside interactors and inside code that calls interactors. AuthenticateUser.perform(eamil: "[email protected]") can explode with an ArgumentError.

An organizer would gather all the context_attribute of its constituents and pre-populate the context will all the appropriate methods so that the application can call the organizer with arguments needed by any of the interactors.

@laserlemon laserlemon added this to the v3.1.0 milestone Sep 23, 2014
@ecielam
Copy link

ecielam commented Sep 23, 2014

+1

context.user feels excessively verbose, and I didn't like it at all.

@laserlemon
Copy link
Collaborator

I love the idea and I think it may kill two birds with one stone.

I like the explicit context.user access because it's clear where the user object is coming from. However, a feature like you're suggesting could sit on top of that to provide the convenience methods that we used to have, only this time without the "magic" because it's clear where these values are coming from.

There's also a persistent (and justified) gripe that it's hard to glean what an interactor receives in its context. This helps to address that concern as well. The syntax that comes to mind for me is:

class AuthenticateUser
  include Interactor

  receives :email, :password
  provides :user, :message

  def call
    if user = User.authenticate(email, password)
      context.user = user
      context.token = user.secret_token
    else
      context.fail!(message: "authenticate_user.failure")
    end
  end
end

The receives and provides methods could actually be aliases of each other. The only purpose is documentation and to provide shortcut access to the values at the specified context keys.

In the future, this could be extended with options such as :required.

I wouldn't take any measures to ensure that a given context doesn't contain extra keys because that's perfectly legitimate. In the short term, I think these methods would simply provide convenience access to the context.

Thoughts?

@brianhempel
Copy link
Contributor Author

Most (all?) of the interactors I've written receive and provide different things, so I'm in favor of that syntax. In the future it could be used to only add the provides from the returned context.

Every time I've used OpenStruct I've either not liked it, or regretted it, so I'm in favor of being more strict about what's in the context. But until we've got a good example that argues either way, we'll just have to disagree philosophically. 😄

I do agree that shortcut access to the context with receives and provides is a win, regardless of whether or not we use them to make interactors stricter.

@laserlemon
Copy link
Collaborator

In the future it could be used to only add the provides from the returned context.

Could you elaborate on this? I don't understand.

@brianhempel
Copy link
Contributor Author

We could make it so arguments aren't added to the context unless they're part of provides:

returned_context = AuthenticateUser.call(email: "[email protected]", password: "password")
returned_context.attribute_names # => [:user, :token, :message]

Arguments not added to the context can still be accessed with methods inside the interactor, they're just stored on some sort of local_arguments object instead of on the context.

@laserlemon
Copy link
Collaborator

I don't think there's any need for something like local_arguments since interactors are just plain ol' Ruby objects. You can use instance variables or attr_accessors.

@ersatzryan
Copy link
Contributor

I actually like this idea more than I initially thought I would. It prevents a nil smell that we get from using and OpenStruct.

@ersatzryan
Copy link
Contributor

would this need to be a major version bump?

@laserlemon
Copy link
Collaborator

@ersatzryan Could you explain the nil smell it prevents? Would you expect that calling the email method directly would raise an error if not set?

@laserlemon
Copy link
Collaborator

My thought is that this would start as a backwards-compatible change. The receives (or similar) method would simply create the shortcut method(s) to the context and serve as documentation of what the interactor expects to receive. In my mind, it doesn't say that that key is necessarily required. Although, that could be added as an option:

receives :email, :password, required: true

I'm still not clear on what the default behavior should be for missing context keys/values. Should it fail? Should it raise an error?

@ersatzryan
Copy link
Contributor

since we return a context from call and that context is an OpenStruct anytime you call a method that has not been set it doesn't result in an error only in nil this makes it so when you have work with a context object you have to be paranoid with lots of nil tests context.user && context.user.name etc.

As opposed to having an object that throws errors when you call methods that do not exist.

Tracking down an "Undefined Method for Nil::Class" 5 levels past where that nil was introduced is much harder than a custom error from the context or even a NoMethod just on the context.

@ersatzryan
Copy link
Contributor

Since we return the context from call wouldn't needing to define what methods it has on it be not backwards compatible?

If I currently have something like

class FooController < ApplicationController
  def create
    result = FooCreator.call(foo_params: params)

    if result.foo
       redirect_to result.foo
    else
      render :new
    end
  end

class FooCreator
  include Interactor

  def call
     foo = Foo.new(context.foo_params)

     if foo.save
       context.foo = foo
     else
       fail!
     end
end

would it not bomb in the controller if it were upgraded to version that has new context not based on OpenStruct

@laserlemon
Copy link
Collaborator

I've been thinking more about the internal interface (inside the interactor itself) but it's a good point that these changes would also apply to how the interactor is accessed from the controller as well.

I'm not very concerned currently with unexpected nil values in the controller because the interactor should have clear expectation of what context keys will be set in the case of interactor success or failure.

We'd probably want to make sure that the ||= context assignment still works:

before do
  context.user ||= User.find(context.user_id)
end

@ryansch
Copy link

ryansch commented Oct 8, 2014

I'd be a fan of documenting the inputs and outputs of an interactor.

As far as required parameters go, we're using this syntax now:

required_parameters or: [:order_id, :order]
required_parameters :something_else

I use a before hook to ensure that both order_id and order are available in the context by filling one from the other.

Edit: I haven't yet gone so far as to write methods on to the interactor for those parameters but we've been talking about it as we upgrade from the old 'v3' branch to the released version 3.

@laserlemon laserlemon removed this from the v3.1.0 milestone Oct 13, 2014
@laserlemon
Copy link
Collaborator

LightService has a nice syntax for a similar feature documented here. The functionality differs from what we're talking about here but the macro methods are similar and might get the creative juices flowing for naming.

@ersatzryan
Copy link
Contributor

I like the macro methods of expects and promises

@ersatzryan
Copy link
Contributor

or perhaps expect and provides

@laserlemon
Copy link
Collaborator

I've used the "provide" terminology in the specs before.

@wyattjoh
Copy link

Any suggestions for the issue of ensuring required attributes are passed in while this feature is still being developed?

@zavan
Copy link

zavan commented Nov 25, 2014

@wyattjoh What about being able to set default values to context attributes?
Or should this be handled exclusively in the models that the Interactor handle?

@zavan
Copy link

zavan commented Nov 25, 2014

Maybe in the future, the context object can evolve into something more complex and complete, like trailblazer's contracts (https://github.com/apotonick/trailblazer), thus allowing the validations to be removed from the model too...

@bradstewart
Copy link

I just started using this gem (thanks, it's great), and I really like where this is headed. As a quick and dirty "solution" to some of these issues, I've been using delegate--no more typing context every time you want to touch something, and it documents what I expect to be on the context.

E.g. delegate :user, :email, to: :context.

@quidproquo
Copy link

Love the concept behind this gem, though, it would be nice to have context be any type of object. Simply delegate method missing on the context to the passed-in object itself. I feel like forcing the developer to break up parameters into key/value pairs reduces flexibility.

@mjbellantoni
Copy link

👍 to this proposal. I've been using this library (thank you! btw) for about a month and I almost immediately added an equivalent to context_attributes. I'm beginning to use organizers and pretty quickly wished something like requires/provides existed.

@jonstokes
Copy link

What about something that looks like this:

class CreateContact
  include Interactor

  expects :email, :first_name       # these are required, otherwise error
  provides :contact_id              # this may or may not be populated by the interactor
  allows :last_name, :phone_number  # these can be supplied, but if not default is nil

  allows :category do                # this can be supplied, or the block provides a default
    ContactCategories.default
  end

  allows :country, default: 'USA'     # this can be supplied, default declared inline
end

These could just start out as convenience methods, per Steve's suggestion, so that they don't break backwards compatibility.

@jonstokes
Copy link

FYI, all, if you're following this issue, I'm working on a PR that attempts to address it. You can find it here: #82

@greyblake
Copy link

+1 for this proposal!

@BattleBrisket
Copy link

👍 Is this still in the cards?

@andydust
Copy link

andydust commented Aug 2, 2016

👍

@berfarah
Copy link

berfarah commented Feb 7, 2017

Huh... Great minds think alike, I guess!

I worked on something like this. Would welcome feedback on the features:
https://github.com/berfarah/interactor-schema

@jonstokes
Copy link

@berfarah I made a gem for this, as well. Check it out, here: https://github.com/jonstokes/troupe

@laserlemon
Copy link
Collaborator

Closing for a few reasons:

  1. Change Interactor#call to receive a partial context via keyword arguments #123 will address explicit inputs.
  2. @jonstokes graciously wrote troupe.
  3. There's no universally agreed-upon behavior for what should happen if what should be provided is not provided.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Projects
None yet
Development

No branches or pull requests