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

Custom field API discussion #2168

Closed
patrick91 opened this issue Sep 13, 2022 · 18 comments
Closed

Custom field API discussion #2168

patrick91 opened this issue Sep 13, 2022 · 18 comments

Comments

@patrick91
Copy link
Member

We've been discussing the API for custom fields quite a bit on discord, I'll summarize some of the thoughts here.

Goal

We want to allow users of Strawberry to leverage code to change how fields work and what they return.

Some use cases:

  • Relay pagination
  • Authenticated only fields (they would return None if users are not logged in)

Worries

My personal worry is this might make it harder to understand how the final schema will look like, but I might be worrying too much 😊

How it should work

In #920 we have a proposal that uses a field class to update information about the field. I was also thinking we could try to leverage python's dynamic features to do the same thing. Let's see both approaches:

API 1

class PaginatedField(StrawberryField):
    def get_arguments(self) -> dict[str, type]:
        arguments = super().get_arguments()
        if "first" not in arguments:
            arguments["first"] = int
        return arguments

    def get_result(
        self, source: Any, info: Any, arguments: dict[str, Any]
    ) -> Awaitable[Any] |  Any:
        first = arguments.pop("first")
        result = super().get_result(arguments, source, info)

        return result[:first]

@strawberry.type
class Query:
    @PaginatedField()
    def books() -> list[str]:
        return [
            "Pride and Prejudice",
            "Sense and Sensibility",
            "Persuasion",
            "Mansfield Park",
        ]

API 2

def paginated(function: Callable[[...], T]):
    @strawberry.wraps(function)
    def paginated_function(root: Any, info: Any, first: int, **kwargs) -> T:
        # strawberry.wraps will change the original function
        # to always accept root and info
        result = function(root, info=info, **kwargs)
        return result[:first]

    return paginated_function

@strawberry.type
class Query:
    @strawberry.field
    @paginated
    def books() -> list[str]:
        return [
            "Pride and Prejudice",
            "Sense and Sensibility",
            "Persuasion",
            "Mansfield Park",
        ]

I'm not 100% sure if this API is doable, but it might be worth trying


I haven't added any pro and cons for both APIs mostly because I want to hear other people's opinion first 😊

@nrbnlulu
Copy link
Member

nrbnlulu commented Sep 13, 2022

API 1 - Extending StrawberryField:

Pros Cons
more understand able API, no need for rocket science to know how strawberry calls it decorating with a class is awkward
allows to modify everything you can in one place type cannot be determined statically
allows inheriting other custom fields and modifying upon them
opens a door for new contributors

API 2 - Magic decorators :

Pros Cons
looks nicer user wise can't override type (easily )
can't override name
can't override arguments type
ordering the decorators can get cumbersome
How would you deal with basic fields?

API 3 - Extension classes in conjunction with API 1:

class BaseExtension:

  @classmethod
  def override_name(cls, field) -> str:
    return field.graphql_name

  @classmethod
  def override_type(cls, field) -> StrawberryAnnotation:
     return field.type_annotation

  @classmethod
  def resolve(cls, previous, root, info, args, kwargs)  -> Any:
      return previous

class NoUnderScore(BaseExtension):
   
    @classmethod
     def override_name(cls, field):
        return field.python_name.strip("_")

@strawberry.type
class A:
   _a: int =  strawberry.field(extensions=[NoUnderScore])

Extension would be called in StrawberryField.__post__init__() by order and their benefit is that they don't change the field's signature.
If a want to use API 1 he would be discouraged by the docs but it would be possible.

Pros Cons
type is not changed magically called by strawberry
somewhat more user-friendly than API 1 ot 2 may be causing issues if used with a customized field but idk
allows validating the use gave the right type to override

I vote for 3

@patrick91
Copy link
Member Author

For 2:

can't override type (easily )

my idea was to use the decorated function to update the return type:

@strawberry.type
class Paginated(T):
    items: T
    has_next: bool

def paginated(function: Callable[[...], T]):
    @strawberry.wraps(function)
    def paginated_function(root: Any, info: Any, first: int, **kwargs) -> Paginated[T]:
        # strawberry.wraps will change the original function
        # to always accept root and info
        result = function(root, info=info, **kwargs)
        return Paginated(items=result[:first], has_next=True)

    return paginated_function

can't override name

we could find a way to do this, but... do you want it? you can also still pass a custom name to strawberry.field

ordering the decorators

Yes, the requirement here is that you need to use the decorators after using @strawberry.field, other than that standard python rules apply.

How would you deal with basic fields?

Good question, I'm not sure yet

@nrbnlulu
Copy link
Member

can't override name

we could find a way to do this, but... do you want it? you can also still pass a custom name to strawberry.field

I personally don't but there was someone on discord... https://discord.com/channels/689806334337482765/1012409889437515867/1012411651330424872

@nrbnlulu
Copy link
Member

nrbnlulu commented Sep 13, 2022

can't override type (easily )

my idea was to use the decorated function to update the return type:

what if the user gave Optional and you want to provide Optional as well it would be Optional[Optional[SomeType]]
even if you will have something like

... -> Paginated[T].resolve_type()

this would a. lose the type hints. b. cause the user to mess up with native typing stuff instead the nice API of StrawberryType

@nrbnlulu
Copy link
Member

another thing to note here is that we should probably provide a configuration for a default field class and that is a +1 for API 1 | 3

@patrick91
Copy link
Member Author

can't override type (easily )

my idea was to use the decorated function to update the return type:

what if the user gave Optional and you want to provide Optional as well it would be Optional[Optional[SomeType]] even if you will have something like

... -> Paginated[T].resolve_type()

this would a. lose the type hints. b. cause the user to mess up with native typing stuff instead the nice API of StrawberryType

Optional[Optional[T]] is the same as Optional[T]`

the type resolution would be done in strawberry.wraps and use all the current logic we have :)

another thing to note here is that we should probably provide a configuration for a default field class and that is a +1 for API 1 | 3

why?

@nrbnlulu
Copy link
Member

nrbnlulu commented Sep 13, 2022

can't override type (easily )

my idea was to use the decorated function to update the return type:

what if the user gave Optional and you want to provide Optional as well it would be Optional[Optional[SomeType]] even if you will have something like

... -> Paginated[T].resolve_type()

this would a. lose the type hints. b. cause the user to mess up with native typing stuff instead the nice API of StrawberryType

Optional[Optional[T]] is the same as Optional[T]`

I can't think of a use cases but:

  • make field required is not possible
  • returning a list is also an issue if user already provides list I think List[T] if T is a List[foo]?

the type resolution would be done in strawberry.wraps and use all the current logic we have :)

another thing to note here is that we should probably provide a configuration for a default field class and that is a +1 for API 1 | 3

why?

if a user wants to use a custom field all over the schema.
use cases I can think of:

  • auth field that returns a union of authorization errors and the original type.
  • cached fields

and basically this can replace resolve on schema extensions. currently schema extensions does not work for subscriptions and that will also solve this.

@jkimbo jkimbo mentioned this issue Oct 12, 2022
20 tasks
@jkimbo
Copy link
Member

jkimbo commented Oct 12, 2022

I think we should go for the Field Extension classes that @nrbnlulu suggested. I would modify the API slightly to something like this:

class UpperCaseExtension(FieldExtension):
	def apply(self, field):
		assert field.type is str  # check that field type is a string

	def resolve(self, next, source, info, arguments):
		result = next(source, info, arguments)
		return result.upper()

@strawberry.type
class A:
   a: str =  strawberry.field(extensions=[UpperCaseExtension])

A more complicated example:

class PaginatedExtension(FieldExtension):
	def apply(self, field):
		assert field.type is list
		if "first" not in field.arguments:
			field.arguments["first"] = int
		
	def resolve(self, next, source, info, arguments):
		first = arguments.pop("first")
		result = next(source, info, arguments)
		return result[:first]

@strawberry.type
class Query:
	@strawberry.field(extensions=[PaginatedExtension])
	def books() -> List[str]:
		return [
			"Pride and Prejudice",
			"Sense and Sensibility",
			"Persuasion",
			"Mansfield Park",
		]

I think this API allows you to easily modify field arguments, return types and executions while being easy to apply multiple extensions. It also pairs nicely with our existing Schema Extensions (where could actually deprecate the usage of resolve and replace it with the ability to define default field extensions).

Any objections @patrick91 @nrbnlulu ?

@patrick91
Copy link
Member Author

I think that looks good, if I want to add description to argument will it look like this?

class PaginatedExtension(FieldExtension):
	def apply(self, field):
		assert field.type is list
		if "first" not in field.arguments:
			field.arguments["first"] = Annotated[int, strawberry.argument(description="ABC")

?

where could actually deprecate the usage of resolve and replace it with the ability to define default field extensions

I think this might not work in extensions like the ones for tracing

@jkimbo
Copy link
Member

jkimbo commented Oct 12, 2022

I think that looks good, if I want to add description to argument will it look like this?

Yes I guess so. Though it might be worth making this work:

field.arguments["first"] = strawberry.argument(int, description="ABC")

Or we could add a helper function:

field.arguments["first"] = self.create_argument(int, description="ABC")

where could actually deprecate the usage of resolve and replace it with the ability to define default field extensions

I think this might not work in extensions like the ones for tracing

Good point. We can shelve this idea for now then. It's not essential.

@patrick91
Copy link
Member Author

I think that looks good, if I want to add description to argument will it look like this?

Yes I guess so. Though it might be worth making this work:

field.arguments["first"] = strawberry.argument(int, description="ABC")

Or we could add a helper function:

field.arguments["first"] = self.create_argument(int, description="ABC")

Yes, I'd love to have either! I think I'm in favour of the second option for the time being. The reason is that if we allow the first one, we should also make this use case work:

def a_resolver(a: strawberry.argument(int, description="ABC")) -> str:
    return str(a)

but unfortunately this is not allowed by any type checkers (and might never be) :(

So it might be easier to have a dedicated function (which might be just an alias to using Annoated) for the time being 😊

@nrbnlulu
Copy link
Member

Where should we start working on this? My PR is getting older and older :)

@jkimbo
Copy link
Member

jkimbo commented Oct 14, 2022

@nrbnlulu I think the first step is to add support for field extensions that can implement the resolve method only. That will already provide some benefit and is small enough to review in one go.

Basic requirements:

  • be able to pass a list of field extensions to a field
  • a field extension can modify the return value of a field using the resolve method
  • the FieldExtension.resolve method can modify the source value and arguments for a resolver
  • multiple extensions compose in a sensible way
  • the FieldExtension.resolve method can be async (this might be quite tricky to implement)
  • docs on how to implement a field extension

I think it's worth starting a new PR from scratch and please keep it focussed on just these changes so that's it's easier to review.

Does that sound like a good approach?

@nrbnlulu
Copy link
Member

nrbnlulu commented Oct 19, 2022

@jkimbo I like the idea though I think the code base needs re-factorization and it would be harder to do after adding more features. so it is either to go with my big PR with the changes needed, or create a new PR just for refactoring and then start working on this one.

@jkimbo
Copy link
Member

jkimbo commented Oct 19, 2022

@nrbnlulu any refactoring should definitely be in it's own PR because it becomes complicated to review both new features and refactoring at the same time. I'm happy to spend some time working on a first pass of field extensions (as I described above) if you want to carry on with the refactoring? I would also highly recommend splitting out your refactoring work into multiple changes if possible so that it's easier to review.

@nrbnlulu
Copy link
Member

@jkimbo O.K, currently I have no time but if I will hopefully I'll start working on splitting things up there.

@erikwrede
Copy link
Member

#2567 implementing this is ready for review now. The Permissions refactor PR using extensions shows an example on how to use it. Please check it out and let me know if that works for you ☺️

@erikwrede
Copy link
Member

Released in https://github.com/strawberry-graphql/strawberry/releases/tag/0.162.0 🥳
Related old PR that this supersedes: #473

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

No branches or pull requests

4 participants