strawberry icon indicating copy to clipboard operation
strawberry copied to clipboard

Proposal: Custom fields

Open jkimbo opened this issue 3 years ago • 13 comments

Objective

Allow developers to create custom fields to encapsulate common resolver logic, allow modification of field arguments and return types.

Motivation

At the moment the only way to define a field is to use the strawberry.field function. There is no ability to modify arguments or types programmatically so defining common patterns in a schema means duplicating logic throughout the code base.

@BryceBeagle work on refactoring StrawberryField into a proper class has now provided us with a stable foundation on which to provide an API for developers to extend the default StrawberryField class to define their own custom fields.

Benefit

Custom fields will allow developers to create fields to abstract away common patterns in their schema and make it more maintainable. Strawberry can also provide common custom fields to help with things like pagination, permissions and validation.

API design

from strawberry import StrawberryField

class MyCustomField(StrawberryField):
    def get_type(self) -> Type:
		type_ = super().get_type()
        # Modify type
		return type_

    def get_arguments(self) -> Dict[str, Type]:
		arguments = super().get_arguments()
        # Modify arguments
		return arguments

    def get_result(self, source: Any, info: Info, arguments: Dict[str, Any]) -> Union[Awaitable[Any], Any]:
		result = super().get_result(source, info, arguments)
        # Modify result
		return result

Examples

Simple upper case field

class UpperCaseField(StrawberryField):
	def get_type(self) -> Type:
		type_ = super().get_type()
		# Make sure that this field is only used on str fields
		assert type_ is str
		return type_

	def get_result(
		self, source: Any, info: Any, arguments: Dict[str, Any]
	) -> Union[Awaitable[Any], Any]:
		result = super().get_result(arguments, source, info)
		return cast(str, result).upper()


@strawberry.type
class Query:
	name: str = UpperCaseField(default="Jonathan")

	@UpperCaseField()
	def alt_name() -> str:
		return "jkimbo"

schema = strawberry.Schema(query=Query)

result = schema.execute_sync("{ name, altName }", root_value=Query())

assert result.data == {"name": "JONATHAN", "altName": "JKIMBO"}

Auth required field

class AuthenticationRequiredField(StrawberryField):
	def get_type(self) -> Type:
		type_ = super().get_type()
		# Make sure the return type is optional
		return Optional[type_]

	def get_result(
		self, source: Any, info: Any, arguments: Dict[str, Any]
	) -> Union[Awaitable[Any], Any]:
		# Check authentication
		if not info.context["is_authenticated"]:
			return None

		return super().get_result(arguments, source, info)

@strawberry.type
class Query:
	@AuthenticationRequiredField()
	def get_user(self, id: strawberry.ID) -> User:
		return User(id=id)

Pagination field

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]
	) -> Union[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",
		]

schema = strawberry.Schema(query=Query)
result = schema.execute_sync("{ books(first: 2) }")
assert result.data["books"] == ["Pride and Prejudice", "Sense and Sensibility"]

Alternatives

Allowing the usage of decorators (#473) would allow you to encapsulate resolver logic but it wouldn’t let you modify the arguments for a field or the return type. Custom fields allow both of those things at the expense of being more verbose and not as obvious. Decorators are still possibly a good thing to support for the more limited use cases.

Questions

  • Should we treat mutations and subscriptions differently?
  • Should we provide examples/helpers on how to test custom fields?
  • How can you compose multiple custom fields?

jkimbo avatar May 07 '21 08:05 jkimbo

I like this concept. I'm already using this approach in strawberry-graphql-django package. I'm pretty sure other users may want to do the same :)

Field composition

Would it make sense to use class inheritance in a such way that arguments, types and results of super class are all accessed through super object? This would allow composition and inheritance from multiple classes like this.

class PaginationMixin:
    def get_arguments(self):
        return {
            'pagination': PaginationInput
        } | super().get_arguments()
    
    def get_result(self, source, info, pagination, **kwargs):
        result = super().get_result(source, info, **kwargs)
        return apply_pagination(result, pagination)
        
 class FilteringMixin:
    def get_arguments(self):
        return {
            'filtering': FilteringInput
        } | super().get_arguments()
    
    def get_result(self, source, info, filtering, **kwargs):
        result = super().get_result(source, info, **kwargs)
        return apply_filtering(result, filtering)
 
 class CustomField(
    FilteringMixing,
    PaginationMixing,
    StrawberryField,
):
    pass

I'm not sure which one, process_arguments or get_arguments, would be better name.

kwargs of get_result

You had argument called arguments in first example but other examples had kwargs. Which one would you propose?

Currenly kwargs is passed as an dict to get_result method. Would it make sense to change kwargs to **kwargs? After that it would be a bit cleaner to "pop" arguments from kwargs before passing rest of the kwargs to get_result of inherited class.

def get_result(self, my_argument, **kwargs):
    return super().get_result(**kwargs)

# vs

def get_result(self, kwargs):
    my_argument = kwargs.pop('my_argument')
    return super().get_result(**kwargs)

post_init

In Django integration I'm missing some kind of post_init method which is called as soon as type and arguments have been resolved. We need to check there that output and input types are both from the same model like this:

class DjangoField:
    def post_init(self):
        assert self.arguments['input']._django_model == self.type._django_model

It's possible that types are still forward references (strings) when types and arguments are processed which means that it is not be possible to do validation there until forward references have been resolved.

la4de avatar May 17 '21 19:05 la4de

@la4de very sorry I haven't replied to you! I think I must have just done it in my head and not actually written it down 🤦

Would it make sense to use class inheritance in a such way that arguments, types and results of super class are all accessed through super object? This would allow composition and inheritance from multiple classes like this.

Using inheritance to combine different custom fields definitely makes sense but I was thinking of more adhoc uses of multiple custom fields. For example if I wanted to combine the AuthenticationRequired field and the UpperCaseField I would like to just do this:

@strawberry.type
class Query:
	@UpperCaseField()
	@AuthenticationRequiredField()
	def get_username(self, id: strawberry.ID) -> str:
		return "jkimbo"

rather than making a new class just for this specific combination of fields.

I think the above should be possible to implement and should be part of the implementation.

I'm not sure which one, process_arguments or get_arguments, would be better name.

I don't have a strong opinion on get_ vs process_. I'll probably go with get_ because it's a bit more obvious.

You had argument called arguments in first example but other examples had kwargs. Which one would you propose?

Good spot. I think it's better to call it arguments because they are technically the GraphQL field arguments and I don't want to confuse them up with the function kwargs. I've updated the examples.

Currenly kwargs is passed as an dict to get_result method. Would it make sense to change kwargs to **kwargs? After that it would be a bit cleaner to "pop" arguments from kwargs before passing rest of the kwargs to get_result of inherited class.

I think passing the arguments as a dict rather than expanding them is better because it means that arguments won't interfere with other args to the functions. Not sure what you mean by it being easier to "pop" arguments from kwargs. Can you expand on that?

In Django integration I'm missing some kind of post_init method which is called as soon as type and arguments have been resolved. We need to check there that output and input types are both from the same model like this:

I like this idea. I'll try and incorporate it into the implementation.

jkimbo avatar Jul 28 '21 15:07 jkimbo

I mostly like this, the only two things I don't like are, from this example:

@strawberry.type
class Query:
	@PaginatedField()
	def books() -> List[str]:
		return [
			"Pride and Prejudice",
			"Sense and Sensibility",
			"Persuasion",
			"Mansfield Park",
		]
  1. @PaginatedField() this is mostly a personal preference, but I don't like using classes as decorators (mostly because it looks odd)
  2. List[str] we are changing the field type, but we still use the old type in the method (which is fine); my only concern on this is that we are hiding the new field type

Have you considered an API like this:

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

?

patrick91 avatar Jul 28 '21 17:07 patrick91

@PaginatedField() this is mostly a personal preference, but I don't like using classes as decorators (mostly because it looks odd)

Yeah I agree that aesthetically lower case functions look better as decorators but I didn't want to have to define a wrapper function for every custom field. Any suggestions on how to avoid it?

Another alternative would be:

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

What do you think?

List[str] we are changing the field type, but we still use the old type in the method (which is fine); my only concern on this is that we are hiding the new field type

That is an interesting alternative API but wouldn't that require more mypy plugin magic and extra IDE integration? Also I'm not sure it's as obvious that you're potentially modifying the arguments to the field and the resolver logic when you're just wrapping the return type.

jkimbo avatar Jul 28 '21 18:07 jkimbo

don't really like this option

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

maybe we can do what asgi ref does with sync_to_async? :D

https://github.com/django/asgiref/blob/main/asgiref/sync.py#L511-L512

patrick91 avatar Jul 28 '21 18:07 patrick91

maybe we can do what asgi ref does with sync_to_async? :D

Sure thats fine. I think the built in custom fields we should expose as lower case but people are free to define their own custom fields with any kind of naming convention.

jkimbo avatar Jul 28 '21 18:07 jkimbo

maybe we can do what asgi ref does with sync_to_async? :D

Sure thats fine. I think the built in custom fields we should expose as lower case but people are free to define their own custom fields with any kind of naming convention.

Yes, that works for me :D

patrick91 avatar Jul 28 '21 18:07 patrick91

[I] don't like using classes as decorators (mostly because it looks odd)

maybe we can do what asgi ref does with sync_to_async

This is also something the Python standard library does. Easiest example is @property (although it's implemented in C, not pure Python)

BryceBeagle avatar Jul 28 '21 23:07 BryceBeagle

Hello, what's the status of this proposal?

Ambro17 avatar Dec 26 '21 14:12 Ambro17

@Ambro17 unfortunately the PR that I created is pretty out of date at this point and needs updating. I still think the proposal is a good idea though. I might be able to find time to update it but if anyone else wants to pick this up that would be very helpful!

jkimbo avatar Dec 27 '21 19:12 jkimbo

I can do it when i have some free time, but i'd like to know if it's still regarded as a proposal subject to approval or a feature that still needs to be added. In other words, given a working implementation of this, is there a chance is not merged because it's deemed not useful?

Ambro17 avatar Dec 27 '21 19:12 Ambro17

@strawberry-graphql/core what do you think? Any objections to this feature?

jkimbo avatar Dec 27 '21 20:12 jkimbo

In #2100 @jkimbo proposed this API for composing fields:

@composeFields(fields=[])

How about something like what asgi applications does?:

class StrawberryField:
    def __init__(self, child=None)
        self.child = child

@strawberry.type
class Query:
    @UpperCaseField(AuthenticationRequiredField())
    def get_username(self, id: strawberry.ID) -> str:
        return "jkimbo"

nrbnlulu avatar Aug 21 '22 10:08 nrbnlulu

Closing this issue because we now have field extensions

jkimbo avatar May 09 '23 07:05 jkimbo