thatch-health / grape_sorbet

Sorbet signatures and Tapioca DSL compiler for Grape.
MIT License
4 stars 0 forks source link

Consider adding support for generic-powered `Grape::TypedEntity` #15

Open iMacTia opened 3 weeks ago

iMacTia commented 3 weeks ago

Problem summary

Sharing this little "hack" that we recently implemented in our codebase. There's only that much we can achieve by adding signatures to Grape::Entity, especially because of the awful signature of expose that makes use the splat (*) operator for its arguments.

The other "issue" we wanted to address what that there's no way to know what the object in a serialiser is, especially in a big codebase with tens of people working on it. It would be great if Grape::Entity could be defined as generic (like Grape::Entity[ObjectType], but sorbet doesn't allow you to re-define an existing class as generic without forcing you to define the ObjectType on ALL the existing entities. This was just not viable for us due to the huge amount of existing entities.

Solution

In order to achieve the goals above and make this process "opt-in", we therefore introduced a Grape::TypedEntity wrapper. The current version, which I'm sure can be improved, looks like this (it's split into an .rb and .rbi file due to sorbet's limits):

# grape-entity.rbi
class Grape::TypedEntity
  sig { returns(ObjectType) }
  attr_reader :object

  sig { returns(T::Hash[Symbol, T.anything]) }
  attr_reader :options

  sig { params(object: ObjectType, options: T::Hash[Symbol, T.anything]).void }
  def initialize(object, options = {}); end
end

# typed_entity.rb
module Grape
  class TypedEntity < Grape::Entity
    extend T::Sig
    extend T::Generic

    ObjectType = type_member
    ObjectTypeTemplate = type_template

    sig do
      params(
        attr_name: Symbol,
        as: T.nilable(
          T.any(
            Symbol,
            T.proc.params(object: ObjectTypeTemplate, opts: T::Hash[Symbol, T.anything]).returns(T.anything)
          )
        ),
        proc: T.nilable(
          T.proc.params(object: ObjectTypeTemplate, opts: T::Hash[Symbol, T.anything]).returns(T.anything)
        ),
        using: T.nilable(T.any(String, T.class_of(Grape::Entity))),
        documentation: T.nilable(T::Hash[Symbol, T.anything]),
        override: T.nilable(T::Boolean),
        default: T.nilable(T.anything),
        format_with: T.nilable(Symbol),
        if: T.nilable(
          T.any(
            Symbol,
            T::Hash[Symbol, Symbol],
            T.proc.params(object: ObjectTypeTemplate, opts: T::Hash[Symbol, T.anything]).returns(T::Boolean)
          )
        ),
        unless: T.nilable(
          T.any(
            Symbol,
            T::Hash[Symbol, Symbol],
            T.proc.params(object: ObjectTypeTemplate, opts: T::Hash[Symbol, T.anything]).returns(T::Boolean)
          )
        ),
        merge: T.nilable(
          T.any(T::Boolean, T.proc.params(key: Symbol, old_val: T.anything, new_val: T.anything).returns(T.anything))
        ),
        expose_nil: T.nilable(T::Boolean),
        safe: T.nilable(T::Boolean),
        block: T.nilable(T.proc.bind(T.self_type).void)
      ).void
    end
    def self.expose(
      attr_name, as: nil, proc: nil, using: nil, documentation: nil, override: nil, default: nil,
      format_with: nil, if: nil, unless: nil, merge: nil, expose_nil: nil, safe: nil, &block
    )
      options = {
        as: as,
        proc: proc,
        using: using,
        documentation: documentation,
        override: override,
        default: default,
        format_with: format_with,
        if: binding.local_variable_get(:if),
        unless: binding.local_variable_get(:unless),
        merge: merge,
        expose_nil: expose_nil,
        safe: safe
      }.compact
      super(attr_name, options, &block)
    end
  end
end

Usage

When defining a TypedEntity you need to specify the two generic members:

class UserDTO < Grape::TypedEntity
  ObjectType = type_member { { fixed: ::User } }
  ObjectTypeTemplate = type_template { { fixed: ::User } }

  expose :email, as: :contact # now sorbet knows exactly what options you can pass and their type
  expose :full_name

  def full_name
    # Here sorbet knows that `object` is of type `User`, so it will raise an error
    # if you try to access a method that does not exist.
    "#{object.first_name} #{object.last_name}"
  end
end

Known issues

The signature for expose is not perfect yet. Options that take a proc will not type-check object and options correctly (this is a sorbet limitations on procs). Moreover, the expose method sig only accepts a block without parameters (for nesting), so as should be used for defining complex fields

olivier-thatch commented 3 weeks ago

Thanks @iMacTia! This is great. I had something very similar in mind, down to the Grape::TypedEntity name :)

Having to declare the generic type twice is annoying, but probably inevitable due to how Grape is designed and Sorbet's own limitations with generics.

If we're going to introduce this generic class, I would also seize the opportunity to replace the expose method with 3 different aliases, to address the issue that was discussed on the Sorbet Slack:

I will try to take a stab at this next week.

iMacTia commented 3 weeks ago

That sounds great, I like the idea of the aliases and it seems perfectly fine to "enforce" them because Grape::TypedEntity is opt-in anyway 😄

Keep me posted and please do let me know when it's ready to test as I'm sure I could help with that