Año de aventuras con grafeno-pitón

Hola a todos, soy un desarrollador de Python. El año pasado trabajé con graphene-python + django ORM y durante este tiempo intenté crear algún tipo de herramienta para hacer que trabajar con grafeno sea más conveniente. Como resultado, obtuve una pequeña base de código de graphene-framework
y un conjunto de algunas reglas, que me gustaría compartir.

¿Qué es el grafeno-pitón?
De acuerdo con graphene-python.org , entonces:
Graphene-Python es una biblioteca para crear fácilmente API GraphQL usando Python. Su tarea principal es proporcionar una API simple pero al mismo tiempo extensible para facilitar la vida de los programadores.
Su tarea principal es proporcionar una API simple pero al mismo tiempo extensible para facilitar la vida de los programadores.
Sí, en realidad el grafeno es simple y extensible, pero me parece demasiado simple para aplicaciones grandes y de rápido crecimiento. Documentación breve (usé el código fuente en su lugar, es mucho más detallado), así como la falta de estándares para escribir código hace que esta biblioteca no sea la mejor opción para su próxima API.
Sea como fuere, decidí usarlo en el proyecto y encontré una serie de problemas, afortunadamente, al haber resuelto la mayoría de ellos (gracias a las características indocumentadas de grafeno). Algunas de mis soluciones son puramente arquitectónicas y se pueden usar de forma inmediata, sin mi marco. Sin embargo, el resto de ellos aún requieren algo de código base.
Este artículo no es documentación, sino una descripción breve del camino que tomé y los problemas que resolví de una forma u otra con una breve justificación de mi elección. En esta parte, presté atención a las mutaciones y cosas relacionadas con ellas.
El propósito de este artículo es obtener comentarios significativos , ¡así que esperaré las críticas en los comentarios!
Nota: antes de continuar leyendo el artículo, le recomiendo que se familiarice con GraphQL.
Mutaciones
La mayoría de las discusiones sobre GraphQL se centran en obtener datos, pero cualquier plataforma respetuosa también requiere una forma de modificar los datos almacenados en el servidor.
Comencemos con las mutaciones.
Considere el siguiente código:
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
cambia la publicación con la id
dada, usando los datos transferidos y devuelve errores si no se cumplen algunas condiciones.
Uno solo necesita mirar este código, ya que se hace visible su no extensibilidad e incompatibilidad debido a:
- Demasiados argumentos de la función de
mutate
, cuyo número puede aumentar incluso si queremos agregar más campos para editar. - Para que las mutaciones se vean iguales en el lado del cliente, deben devolver
errors
y ok
, de modo que su estado y lo que siempre es posible entender. - Buscar y recuperar un objeto en función
mutate
. La función de mutación funciona con el ayuno, y si no está allí, entonces la mutación no debería ocurrir. - Comprobando permisos en una mutación. La mutación no debería ocurrir si el usuario no tiene el derecho de hacer esto (edite alguna publicación).
- Un primer argumento inútil (una raíz que siempre es
None
para los campos de nivel superior, que es nuestra mutación). - Un conjunto impredecible de errores: si no tiene el código fuente o la documentación, no sabrá qué errores puede devolver esta mutación, ya que no se reflejan en el esquema.
- Hay demasiadas verificaciones de errores de plantilla que se llevan a cabo directamente en el método de
mutate
, lo que implica cambiar los datos, en lugar de una variedad de verificaciones. La mutate
ideal debe consistir en una línea: una llamada a la función de edición posterior.
En resumen, mutate
debería modificar los datos , en lugar de ocuparse de tareas de terceros, como acceder a objetos y validar la entrada. Nuestro objetivo es llegar a algo como:
def mutate(post, info, input): post = Utils.update_post(post, **input) return UpdatePostMutation(post=post)
Ahora echemos un vistazo a los puntos anteriores.
Tipos personalizados
El campo de email
se pasa como una cadena, mientras que es una cadena de un formato específico . Cada vez que la API recibe un correo electrónico, debe verificar su corrección. Entonces, la mejor solución sería crear un tipo personalizado.
class Email(graphene.String):
Esto puede parecer obvio, pero vale la pena mencionarlo.
Tipos de entrada
Use tipos de entrada para sus mutaciones. Incluso si no están sujetos a reutilización en otros lugares. Gracias a los tipos de entrada, las consultas se hacen más pequeñas, lo que las hace más fáciles de leer y más rápidas de escribir.
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)
Para:
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 } }
Después:
mutation($id: ID!, $input: UpdatePostInput!) { updatePost(id: $id, input: $input) { ok } }
El código de mutación cambia a:
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):
Clase de mutación base
Como se mencionó en el párrafo 2, las mutaciones deben devolver errors
y estar ok
para que su estado y sus causas siempre se puedan entender . Es bastante simple, creamos una clase 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 {}
Algunas notas
Esto es muy conveniente cuando el cliente actualiza algunos datos después de que se completa la mutación y no quiere pedirle al patrocinador que devuelva todo este conjunto. Cuanto menos código escriba, más fácil será mantenerlo. Tomé esta idea de aquí .
Con la clase de mutación base, el código se convierte en:
class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput(required=True) id = graphene.ID(required=True) def mutate(_, info, input, id):
Mutaciones de la raíz
Nuestra solicitud de mutación ahora se ve así:
mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok } }
Contener todas las mutaciones en un ámbito global no es una buena práctica. Aquí hay algunas razones por las cuales:
- Con el creciente número de mutaciones, se hace cada vez más difícil encontrar la mutación que necesita.
- Debido a un espacio de nombres, es necesario incluir "el nombre de su módulo" en el nombre de la mutación, por ejemplo
update
Post
. - Debe pasar
id
como argumento de la mutación.
Sugiero usar mutaciones de raíz . Su objetivo es resolver estos problemas separando las mutaciones en ámbitos separados y liberando las mutaciones de la lógica de acceso a los objetos y los derechos de acceso a ellos.
La nueva solicitud se ve así:
mutation($id: ID!, $input: PostUpdateInput!) { post(id: $id) { update(input: $input) { ok } } }
Los argumentos de la solicitud siguen siendo los mismos. Ahora la función de cambio se "llama" dentro de la post
, lo que permite implementar la siguiente lógica:
- Si la
id
no se pasa a la post
, devuelve {}
. Esto le permite continuar realizando mutaciones dentro. Se usa para mutaciones que no requieren un elemento raíz (por ejemplo, para crear objetos). - Si se pasa la
id
, se recupera el elemento correspondiente. - Si no se encuentra el objeto,
None
devuelve None
y esto completa la solicitud, no se llama a la mutación. - Si se encuentra el objeto, verifique los derechos del usuario para manipularlo.
- Si el usuario no tiene derechos,
None
devuelve None
y se completa la solicitud, no se llama a la mutación. - Si el usuario tiene derechos, el objeto encontrado se devuelve y la mutación lo recibe como raíz: el primer argumento.
Por lo tanto, el código de mutación cambia a:
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 raíz de la mutación, el primer argumento, ahora es un objeto de tipo
Post
, sobre el cual se realiza la mutación. - La verificación de autorización se movió al código de mutación raíz.
Código de mutación de la raíz:
class PostMutationRoot(MutationRoot): class Meta: model = Post has_permission = lambda post, user: post.owner == user update = UpdatePostMutation.Field()
Interfaz de error
Para que un conjunto de errores sea predecible, deben reflejarse en el diseño.
- Como las mutaciones pueden devolver varios errores, los errores deben ser una lista.
- Dado que los errores están representados por diferentes tipos, debe existir una
Union
errores específica para una mutación particular. - Para que los errores sigan siendo similares entre sí, deben implementar la interfaz, llamémosla
ErrorInterface
. Deje que contenga dos campos: ok
y message
.
¡Por lo tanto, los errores deben ser del tipo [SomeMutationErrorsUnion]!
. Todos los subtipos de SomeMutationErrorsUnion
deben implementar ErrorInterface
.
Obtenemos:
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, ]
Se ve bien, pero hay demasiado código. Usamos la metaclase para generar estos errores sobre la marcha:
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, ]
Agregue la declaración de los errores devueltos a la mutación:
class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput() errors = graphene.List(UpdatePostMutationErrors, required=True) def mutate(post, info, input):
Verificar errores
Me parece que al método de mutate
no debería importarle nada más que mutar los datos . Para lograr esto, debe verificar si hay errores en su código para esta función.
Omitiendo la implementación, aquí está el resultado:
class UpdatePostMutation(DefaultMutation): class Arguments: input = UpdatePostInput() class Meta: root_required = True authentication_required = True
Antes de iniciar la función mutate
, se llama a cada verificador (el segundo elemento de los miembros de la matriz de checks
). Si se devuelve True
encuentra el error correspondiente. Si no se encuentran errores, se llama a la función mutate
.
Explicaré:
- Las funciones de verificación toman los mismos argumentos que las funciones de
mutate
. - Las funciones de validación deben devolver
True
si se encuentra un error. - Las verificaciones de autorización y la presencia del elemento raíz son bastante generales y se enumeran en los indicadores
Meta
. authentication_required
agrega verificación de autorización si es True
.root_required
agrega una root_required
" root is not None
root_required
root is not None
".UpdatePostMutationErrors
ya no es necesario. Se crea una unión de posibles errores sobre la marcha dependiendo de las clases de error de la matriz de checks
.
Genéricos
DefaultMutation
utilizado en la última sección agrega un método pre_mutate
, que le permite cambiar los argumentos de entrada antes de buscar errores y, en consecuencia, invocar la mutación.
También hay un kit de inicio genérico que acorta el código y facilita la vida.
Nota: actualmente el código genérico es específico de django ORM
Createmutation
Requiere uno de los create_function
model
o create_function
. Por defecto, create_function
tiene este aspecto:
model._default_manager.create(**data, owner=user)
Esto puede parecer inseguro, pero no olvide que hay una comprobación de tipo incorporada en graphql, así como comprobaciones en mutaciones.
También proporciona un método post_mutate
que se llama después de create_function
con argumentos (instance_created, user)
, cuyo resultado se devolverá al cliente.
Actualizaciones
Le permite configurar la función de update_function
. Por defecto:
def default_update_function(instance, user=None, **data): instance.__dict__.update(data) instance.save() return instance
root_required
es True
por defecto.
También proporciona un método post_mutate
que se llama después de update_function
con argumentos (instance_updated, user)
, cuyo resultado será devuelto al cliente.
¡Y esto es lo que necesitamos!
Código 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() ), ]
Eliminar mutación
Le permite configurar la función delete_function
. Por defecto:
def default_delete_function(instance, user=None, **data): instance.delete()
Conclusión
Este artículo considera solo un aspecto, aunque en mi opinión es el más complejo. Tengo algunas ideas sobre resolvers y tipos, así como cosas generales en graphene-python.
Es difícil para mí llamarme un desarrollador experimentado, por lo que estaré muy contento de cualquier comentario, así como sugerencias.
El código fuente se puede encontrar aquí .