Año de aventuras con grafeno-pitón

Año de aventuras con grafeno-pitón


imagen


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.


imagen


¿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:


  1. Demasiados argumentos de la función de mutate , cuyo número puede aumentar incluso si queremos agregar más campos para editar.
  2. 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.
  3. 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.
  4. 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).
  5. Un primer argumento inútil (una raíz que siempre es None para los campos de nivel superior, que es nuestra mutación).
  6. 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.
  7. 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): # ... if not errors: post = Utils.update_post(post, **input.__dict__) return UpdatePostMutation(errors=errors) 



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


  • Se resolve_ok método resolve_ok , por lo que no tenemos que calcular ok nosotros mismos.
  • El campo de query es la Query raíz, que le permite consultar datos directamente dentro de la solicitud de mutación (los datos se solicitarán una vez que se complete la mutación).
     mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok query { profile { totalPosts } } } } 

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:


  1. Con el creciente número de mutaciones, se hace cada vez más difícil encontrar la mutación que necesita.
  2. 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 .
  3. 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:


  1. 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).
  2. Si se pasa la id , se recupera el elemento correspondiente.
  3. Si no se encuentra el objeto, None devuelve None y esto completa la solicitud, no se llama a la mutación.
  4. Si se encuentra el objeto, verifique los derechos del usuario para manipularlo.
  5. Si el usuario no tiene derechos, None devuelve None y se completa la solicitud, no se llama a la mutación.
  6. 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 #   ,    True   # An iterable of tuples (error_class, checker) 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() ), ] def mutate(post, info, input): post = Utils.update_post(post, **input.__dict__) return UpdatePostMutation() 

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í .

Source: https://habr.com/ru/post/461939/


All Articles