strawberry
strawberry copied to clipboard
Proposal: Custom fields
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?
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 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.
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",
]
-
@PaginatedField()
this is mostly a personal preference, but I don't like using classes as decorators (mostly because it looks odd) -
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",
]
?
@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.
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
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.
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
[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)
Hello, what's the status of this proposal?
@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!
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?
@strawberry-graphql/core what do you think? Any objections to this feature?
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"
Closing this issue because we now have field extensions