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

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

Open
iMacTia opened this issue Aug 22, 2024 · 2 comments
Open

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

iMacTia opened this issue Aug 22, 2024 · 2 comments

Comments

@iMacTia
Copy link

iMacTia commented Aug 22, 2024

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
Copy link
Contributor

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:

  • expose: default one, same as what you have here but without the block argument to force the use of one of the following when passing a block
  • expose_nested: block variant for nested exposure (block takes no argument and is evaluated in the class context)
  • expose_runtime: block variant for runtime exposure (block takes 1 or 2 arguments and is evaluated in the instance context)

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

@iMacTia
Copy link
Author

iMacTia commented Aug 23, 2024

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

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

No branches or pull requests

2 participants