Année d'aventure avec graphène-python

Salut tout le monde, je suis un développeur python. L'année dernière, j'ai travaillé avec graphene-python + django ORM et pendant ce temps, j'ai essayé de créer une sorte d'outil pour rendre le travail avec le graphène plus pratique. En conséquence, j'ai obtenu une petite base de code de graphene-framework
et un ensemble de règles que je voudrais partager.

Qu'est-ce que le graphène-python?
Selon graphene-python.org , alors:
Graphene-Python est une bibliothèque pour créer facilement des API GraphQL à l'aide de Python. Sa tâche principale est de fournir une API simple mais en même temps extensible pour faciliter la vie des programmeurs.
Sa tâche principale est de fournir une API simple mais en même temps extensible pour faciliter la vie des programmeurs.
Oui, en réalité le graphène est simple et extensible, mais il me semble trop simple pour des applications de grande taille et à croissance rapide. Une courte documentation (j'ai utilisé le code source à la place - il est beaucoup plus détaillé), ainsi que le manque de normes pour écrire du code ne font pas de cette bibliothèque le meilleur choix pour votre prochaine API.
Quoi qu'il en soit, j'ai décidé de l'utiliser dans le projet et j'ai rencontré un certain nombre de problèmes, heureusement, après avoir résolu la plupart d'entre eux (grâce aux riches fonctionnalités non documentées du graphène). Certaines de mes solutions sont purement architecturales et peuvent être utilisées immédiatement, sans mon framework. Cependant, les autres nécessitent encore une base de code.
Cet article n'est pas de la documentation, mais en un sens une brève description du chemin que j'ai suivi et des problèmes que j'ai résolus d'une manière ou d'une autre avec une brève justification de mon choix. Dans cette partie, j'ai prêté attention aux mutations et aux choses qui leur sont liées.
Le but de cet article est d'obtenir des commentaires significatifs , donc j'attendrai les critiques dans les commentaires!
Remarque: avant de poursuivre la lecture de l'article, je vous recommande fortement de vous familiariser avec GraphQL.
Mutations
La plupart des discussions sur GraphQL se concentrent sur l'obtention de données, mais toute plateforme qui se respecte nécessite également un moyen de modifier les données stockées sur le serveur.
Commençons par les mutations.
Considérez le code suivant:
class UpdatePostMutation(graphene.Mutation): class Arguments: post_id = graphene.ID(required=True) title = graphene.String(required=True) content = graphene.String(required=True) image_urls = graphene.List(graphene.String, required=False) allow_comments = graphene.Boolean(required=True) contact_email = graphene.String(required=True) ok = graphene.Boolean(required=True) errors = graphene.List(graphene.String, required=True) def mutate(_, info, post_id, title, content, image_urls, allow_comments, contact_email): errors = [] try: post = get_post_by_id(post_id) except PostNotFound: return UpdatePostMutation(ok=False, errors=['post_not_found']) if not info.context.user.is_authenticated: errors.append('not_authenticated') if len(title) < TITLE_MIN_LENGTH: errors.append('title_too_short') if not is_email(contact_email): errors.append('contact_email_not_valid') if post.owner != info.context.user: errors.append('not_post_owner') if Post.objects.filter(title=title).exists(): errors.append('title_already_taken') if not errors: post = Utils.update_post(post, title, content, image_urls, allow_comments, contact_email) return UpdatePostMutation(ok=bool(errors), errors=errors)
UpdatePostMutation
modifie la publication avec l' id
donné, en utilisant les données transférées et renvoie des erreurs si certaines conditions ne sont pas remplies.
Il suffit de regarder ce code, car il devient visible sa non-extensibilité et sa non-prise en charge en raison de:
- Trop d'arguments de la fonction
mutate
, dont le nombre peut augmenter même si l'on veut ajouter plus de champs à éditer. - Pour que les mutations se ressemblent du côté client, elles doivent renvoyer des
errors
et ok
, afin que leur statut et ce qu'il soit toujours possible de comprendre. - Recherchez et récupérez un objet dans la fonction
mutate
. La fonction de mutation fonctionne avec le jeûne, et si elle n'est pas là, alors la mutation ne devrait pas se produire. - Vérification des autorisations dans une mutation. La mutation ne devrait pas se produire si l'utilisateur n'a pas le droit de le faire (éditer un article).
- Un premier argument inutile (une racine qui est toujours
None
pour les champs de niveau supérieur, qui est notre mutation). - Un ensemble d'erreurs imprévisible: si vous n'avez pas de code source ou de documentation, vous ne saurez pas quelles erreurs cette mutation peut renvoyer, car elles ne sont pas reflétées dans le schéma.
- Il y a trop de vérifications d'erreur de modèle qui sont effectuées directement dans la méthode de
mutate
, ce qui implique de modifier les données, plutôt qu'une variété de vérifications. Le mutate
idéal devrait consister en une ligne - un appel à la fonction de post-édition.
En bref, mutate
devrait modifier les données , plutôt que de s'occuper de tâches tierces telles que l'accès aux objets et la validation des entrées. Notre objectif est d'arriver à quelque chose comme:
def mutate(post, info, input): post = Utils.update_post(post, **input) return UpdatePostMutation(post=post)
Voyons maintenant les points ci-dessus.
Types personnalisés
Le champ email
est transmis sous la forme d'une chaîne, alors qu'il s'agit d'une chaîne d'un format spécifique . Chaque fois que l'API reçoit un e-mail, elle doit vérifier son exactitude. La meilleure solution serait donc de créer un type personnalisé.
class Email(graphene.String):
Cela peut sembler évident, mais mérite d'être mentionné.
Types d'entrée
Utilisez des types d'entrée pour vos mutations. Même s'ils ne sont pas susceptibles d'être réutilisés ailleurs. Grâce aux types d'entrée, les requêtes deviennent plus petites, ce qui les rend plus faciles à lire et plus rapides à écrire.
class UpdatePostInput(graphene.InputObjectType): title = graphene.String(required=True) content = graphene.String(required=True) image_urls = graphene.List(graphene.String, required=False) allow_comments = graphene.Boolean(required=True) contact_email = graphene.String(required=True)
À:
mutation( $post_id: ID!, $title: String!, $content: String!, $image_urls: String!, $allow_comments: Boolean!, $contact_email: Email! ) { updatePost( post_id: $post_id, title: $title, content: $content, image_urls: $image_urls, allow_comments: $allow_comments, contact_email: $contact_email, ) { ok } }
Après:
mutation($id: ID!, $input: UpdatePostInput!) { updatePost(id: $id, input: $input) { ok } }
Le code de mutation se transforme en:
class UpdatePostMutation(graphene.Mutation): class Arguments: input = UpdatePostInput(required=True) id = graphene.ID(required=True) ok = graphene.Boolean(required=True) errors = graphene.List(graphene.String, required=True) def mutate(_, info, input, id):
Classe de mutation de base
Comme mentionné au paragraphe 2, les mutations doivent renvoyer des errors
et ok
pour que leur statut et les causes puissent toujours être compris . C'est assez simple, nous créons une classe de base:
class MutationPayload(graphene.ObjectType): ok = graphene.Boolean(required=True) errors = graphene.List(graphene.String, required=True) query = graphene.Field('main.schema.Query', required=True) def resolve_ok(self, info): return len(self.errors or []) == 0 def resolve_errors(self, info): return self.errors or [] def resolve_query(self, info): return {}
Quelques notes:
Ceci est très pratique lorsque le client met à jour certaines données une fois la mutation terminée et ne souhaite pas demander au contributeur de renvoyer l'ensemble complet. Moins vous écrivez de code, plus il est facile à maintenir. J'ai pris cette idée d'ici .
Avec la classe de mutation de base, le code se transforme en:
class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput(required=True) id = graphene.ID(required=True) def mutate(_, info, input, id):
Mutations racinaires
Notre demande de mutation ressemble maintenant à ceci:
mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok } }
Contenir toutes les mutations à l'échelle mondiale n'est pas une bonne pratique. Voici quelques raisons:
- Avec le nombre croissant de mutations, il devient de plus en plus difficile de trouver la mutation dont vous avez besoin.
- En raison d'un espace de noms, il est nécessaire d'inclure «le nom de son module» dans le nom de la mutation, par exemple
update
Post
. - Vous devez transmettre
id
comme argument à la mutation.
Je suggère d'utiliser des mutations racinaires . Leur objectif est de résoudre ces problèmes en séparant les mutations dans des étendues distinctes et en libérant les mutations de la logique d'accès aux objets et des droits d'accès à ceux-ci.
La nouvelle demande ressemble à ceci:
mutation($id: ID!, $input: PostUpdateInput!) { post(id: $id) { update(input: $input) { ok } } }
Les arguments de la demande restent les mêmes. Maintenant, la fonction de changement est "appelée" à l'intérieur de la post
, ce qui permet d'implémenter la logique suivante:
- Si
id
pas transmis à la post
, il renvoie {}
. Cela vous permet de continuer à effectuer des mutations à l'intérieur. Utilisé pour les mutations qui ne nécessitent pas d'élément racine (par exemple, pour créer des objets). - Si
id
est passé, l'élément correspondant est récupéré. - Si l'objet n'est pas trouvé,
None
renvoyé et cela termine la demande, la mutation n'est pas appelée. - Si l'objet est trouvé, vérifiez les droits de l'utilisateur pour le manipuler.
- Si l'utilisateur ne dispose pas de droits,
None
renvoyé et la demande se termine, la mutation n'est pas appelée. - Si l'utilisateur a des droits, l'objet trouvé est renvoyé et la mutation le reçoit en tant que racine - le premier argument.
Ainsi, le code de mutation se transforme en:
class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput() def mutate(post, info, input): if post is None: return None errors = [] if not info.context.user.is_authenticated: errors.append('not_authenticated') if len(title) < TITLE_MIN_LENGTH: errors.append('title_too_short') if Post.objects.filter(title=title).exists(): errors.append('title_already_taken') if not errors: post = Utils.update_post(post, **input.__dict__) return UpdatePostMutation(errors=errors)
- La racine de la mutation - le premier argument - est maintenant un objet de type
Post
, sur lequel la mutation est effectuée. - Contrôle d'autorisation déplacé vers le code de mutation racine.
Code de mutation racine:
class PostMutationRoot(MutationRoot): class Meta: model = Post has_permission = lambda post, user: post.owner == user update = UpdatePostMutation.Field()
Interface d'erreur
Pour rendre un ensemble d'erreurs prévisibles, elles doivent être reflétées dans la conception.
- Étant donné que les mutations peuvent renvoyer plusieurs erreurs, les erreurs doivent être une liste.
- Étant donné que les erreurs sont représentées par différents types, une
Union
spécifique Union
erreurs doit exister pour une mutation particulière. - Pour que les erreurs restent similaires, elles doivent implémenter l'interface, appelons-la
ErrorInterface
. Laissez-le contenir deux champs: ok
et message
.
Ainsi, les erreurs doivent être de type [SomeMutationErrorsUnion]!
. Tous les sous-types de SomeMutationErrorsUnion
doivent implémenter ErrorInterface
.
Nous obtenons:
class NotAuthenticated(graphene.ObjectType): message = graphene.String(required=True, default_value='not_authenticated') class Meta: interfaces = [ErrorInterface, ] class TitleTooShort(graphene.ObjectType): message = graphene.String(required=True, default_value='title_too_short') class Meta: interfaces = [ErrorInterface, ] class TitleAlreadyTaken(graphene.ObjectType): message = graphene.String(required=True, default_value='title_already_taken') class Meta: interfaces = [ErrorInterface, ] class UpdatePostMutationErrors(graphene.Union): class Meta: types = [NotAuthenticated, TitleIsTooShort, TitleAlreadyTaken, ]
Cela semble bon, mais il y a trop de code. Nous utilisons la métaclasse pour générer ces erreurs à la volée:
class PostErrors(metaclass=ErrorMetaclass): errors = [ 'not_authenticated', 'title_too_short', 'title_already_taken', ] class UpdatePostMutationErrors(graphene.Union): class Meta: types = [PostErrors.not_authenticated, PostErrors.title_too_short, PostErrors.title_already_taken, ]
Ajoutez la déclaration des erreurs retournées à la mutation:
class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput() errors = graphene.List(UpdatePostMutationErrors, required=True) def mutate(post, info, input):
Vérifier les erreurs
Il me semble que la méthode de mutate
ne devrait pas se préoccuper d'autre chose que de muter les données . Pour ce faire, vous devez vérifier les erreurs dans leur code pour cette fonction.
En omettant l'implémentation, voici le résultat:
class UpdatePostMutation(DefaultMutation): class Arguments: input = UpdatePostInput() class Meta: root_required = True authentication_required = True
Avant de démarrer la fonction mutate
, chaque vérificateur (le deuxième élément des membres du tableau de checks
) est appelé. Si True
est renvoyé, l'erreur correspondante est trouvée. Si aucune erreur n'est trouvée, la fonction mutate
est appelée.
Je vais vous expliquer:
- Les fonctions de vérification prennent les mêmes arguments que les fonctions de
mutate
. - Les fonctions de validation doivent retourner
True
si une erreur est trouvée. - Les vérifications d'autorisation et la présence de l'élément racine sont assez générales et sont répertoriées dans les indicateurs
Meta
. authentication_required
ajoute une vérification d'autorisation si True
.root_required
ajoute une root_required
" root is not None
".UpdatePostMutationErrors
n'est plus requis. Une union d'erreurs possibles est créée à la volée en fonction des classes d'erreurs du tableau de checks
.
Génériques
DefaultMutation
utilisée dans la dernière section ajoute une méthode pre_mutate
, qui vous permet de modifier les arguments d'entrée avant de rechercher des erreurs et, en conséquence, d'invoquer la mutation.
Il existe également un kit de démarrage générique qui raccourcit le code et facilite la vie.
Remarque: actuellement, le code générique est spécifique à django ORM
Createmutation
Nécessite l'un des create_function
model
ou create_function
. Par défaut, create_function
ressemble à ceci:
model._default_manager.create(**data, owner=user)
Cela peut sembler dangereux, mais n'oubliez pas qu'il existe une vérification de type intégrée dans graphql, ainsi que des vérifications dans les mutations.
Il fournit également une méthode post_mutate
qui est appelée après create_function
avec des arguments (instance_created, user)
, dont le résultat sera retourné au client.
Updatemutation
Vous permet de définir update_function
. Par défaut:
def default_update_function(instance, user=None, **data): instance.__dict__.update(data) instance.save() return instance
root_required
est True
par défaut.
Il fournit également une méthode post_mutate
qui est appelée après update_function
avec des arguments (instance_updated, user)
, dont le résultat sera retourné au client.
Et c'est ce dont nous avons besoin!
Code final:
class UpdatePostMutation(UpdateMutation): class Arguments: input = UpdatePostInput() class Meta: checks = [ ( PostErrors.title_too_short, lambda post, input: len(input.title) < TITLE_MIN_LENGTH ), ( PostErrors.title_already_taken, lambda post, input: Post.objects.filter(title=input.title).exists() ), ]
DeleteMutation
Vous permet de définir delete_function
. Par défaut:
def default_delete_function(instance, user=None, **data): instance.delete()
Conclusion
Cet article ne considère qu'un aspect, bien qu'il soit à mon avis le plus complexe. J'ai quelques réflexions sur les résolveurs et les types, ainsi que sur les choses générales en graphène-python.
Il est difficile pour moi de m'appeler un développeur expérimenté, donc je serai très heureux de tout commentaire, ainsi que des suggestions.
Le code source peut être trouvé ici .