Ano de aventura com grafeno-python

Oi pessoal, Sou desenvolvedor de python. No ano passado, trabalhei com grafeno-python + django ORM e, durante esse período, tentei criar algum tipo de ferramenta para tornar o trabalho com grafeno mais conveniente. Como resultado, recebi uma pequena base de código da graphene-framework e um conjunto de algumas regras que gostaria de compartilhar.

O que é grafeno-python?
De acordo com graphene-python.org , então:
Graphene-Python é uma biblioteca para criar facilmente APIs GraphQL usando Python. Sua principal tarefa é fornecer uma API simples, mas ao mesmo tempo extensível, para facilitar a vida dos programadores.
Sua principal tarefa é fornecer uma API simples, mas ao mesmo tempo extensível, para facilitar a vida dos programadores.
Sim, na realidade o grafeno é simples e extensível, mas me parece muito simples para aplicativos grandes e de crescimento rápido. Uma documentação curta (usei o código-fonte - é muito mais detalhado), além da falta de padrões para escrever código, essa biblioteca não é a melhor opção para sua próxima API.
Seja como for, eu decidi usá-lo no projeto e tive vários problemas, felizmente, por ter resolvido a maioria deles (graças aos ricos recursos não documentados do grafeno). Algumas de minhas soluções são puramente arquitetônicas e podem ser usadas imediatamente, sem a minha estrutura. No entanto, o restante deles ainda exige alguma base de código.
Este artigo não é documentação, mas, em certo sentido, uma breve descrição do caminho que segui e dos problemas que resolvi de uma maneira ou de outra, com uma breve justificativa para minha escolha. Nesta parte, prestei atenção a mutações e coisas relacionadas a elas.
O objetivo deste artigo é obter algum feedback significativo , portanto, aguardarei críticas nos comentários!
Nota: antes de continuar lendo o artigo, recomendo fortemente que você se familiarize com o que é o GraphQL.
Mutações
A maioria das discussões sobre o GraphQL se concentra na obtenção de dados, mas qualquer plataforma que se preze também exige uma maneira de modificar os dados armazenados no servidor.
Vamos começar com mutações.
Considere o seguinte 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 altera a postagem com o id fornecido, usando os dados transferidos e retorna erros se algumas condições não forem atendidas.
É necessário apenas olhar para este código, pois ele se torna visível por sua extensibilidade e falta de suporte devido a:
- Muitos argumentos da função mutate, cujo número pode aumentar mesmo que desejemos adicionar mais campos a serem editados.
- Para que as mutações tenham a mesma aparência no lado do cliente, elas devem retornar errorseok, para que seu status e o que é sempre possível entender.
- Pesquise e recupere um objeto na função mutate. A função de mutação opera com jejum e, se não estiver lá, a mutação não deve ocorrer.
- Verificando permissões em uma mutação. A mutação não deve ocorrer se o usuário não tiver o direito de fazer isso (editar algumas postagens).
- Um primeiro argumento inútil (uma raiz que é sempre Nonepara campos de nível superior, que é a nossa mutação).
- Um conjunto imprevisível de erros: se você não tiver código-fonte ou documentação, não saberá quais erros essa mutação pode retornar, uma vez que não são refletidos no esquema.
- Existem muitas verificações de erro de modelo que são realizadas diretamente no método mutate, o que envolve a alteração dos dados, em vez de uma variedade de verificações. Omutateideal deve consistir em uma linha - uma chamada para a função de pós-edição.
Em resumo, o mutate deve modificar os dados , em vez de cuidar de tarefas de terceiros, como acessar objetos e validar entradas. Nosso objetivo é chegar a algo como:
  def mutate(post, info, input): post = Utils.update_post(post, **input) return UpdatePostMutation(post=post) 
Agora vamos ver os pontos acima.
Tipos personalizados
O campo de email é passado como uma sequência, enquanto é uma sequência de um formato específico . Cada vez que a API recebe um email, deve verificar sua correção. Portanto, a melhor solução seria criar um tipo personalizado.
 class Email(graphene.String):  
Isso pode parecer óbvio, mas vale a pena mencionar.
Tipos de entrada
Use tipos de entrada para suas mutações. Mesmo que não estejam sujeitos a reutilização em outros lugares. Graças aos tipos de entrada, as consultas se tornam menores, o que as torna mais fáceis de ler e mais rápidas de escrever.
 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 } } 
Depois:
 mutation($id: ID!, $input: UpdatePostInput!) { updatePost(id: $id, input: $input) { ok } } 
O código de mutação muda para:
 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 mutação base
Como mencionado no parágrafo 2, as mutações devem retornar errors e ok para que seu status e suas causas sempre possam ser entendidos . É bastante simples, criamos uma classe 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 {} 
Algumas notas:
Isso é muito conveniente quando o cliente atualiza alguns dados após a conclusão da mutação e não deseja solicitar ao responsável pelo retorno todo esse conjunto. Quanto menos código você escreve, mais fácil é manter. Eu peguei essa ideia daqui .
Com a classe de mutação base, o código se transforma em:
 class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput(required=True) id = graphene.ID(required=True) def mutate(_, info, input, id):  
Mutações na raiz
Nosso pedido de mutação agora se parece com isso:
 mutation($id: ID!, $input: PostUpdateInput!) { updatePost(id: $id, input: $input) { ok } } 
Contendo todas as mutações em um escopo global não é uma boa prática. Aqui estão algumas razões pelas quais:
- Com o crescente número de mutações, torna-se cada vez mais difícil encontrar a mutação necessária.
- Devido a um espaço para nome, é necessário incluir "o nome do seu módulo" no nome da mutação, por exemplo, updatePost.
- Você deve passar o idcomo argumento para a mutação.
Eu sugiro o uso de mutações na raiz . Seu objetivo é resolver esses problemas, separando mutações em escopos separados e liberando mutações da lógica de acesso a objetos e direitos de acesso a eles.
A nova solicitação é assim:
 mutation($id: ID!, $input: PostUpdateInput!) { post(id: $id) { update(input: $input) { ok } } } 
Os argumentos da solicitação permanecem os mesmos. Agora, a função de mudança é "chamada" dentro da post , o que permite que a seguinte lógica seja implementada:
- Se o idnãoidpassado parapost, ele retornará{}. Isso permite que você continue executando mutações dentro dele. Usado para mutações que não exigem um elemento raiz (por exemplo, para criar objetos).
- Se idfor passado, o elemento correspondente será recuperado.
- Se o objeto não for encontrado, Noneretornado e isso concluirá a solicitação, a mutação não será chamada.
- Se o objeto for encontrado, verifique os direitos do usuário para manipulá-lo.
- Se o usuário não tiver direitos, Noneretornado e a solicitação será concluída, a mutação não será chamada.
- Se o usuário tiver direitos, o objeto encontrado será retornado e a mutação o receberá como raiz - o primeiro argumento.
Assim, o código de mutação muda para:
 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) 
- A raiz da mutação - o primeiro argumento - agora é um objeto do tipo Post, sobre o qual a mutação é realizada.
- A verificação de autorização foi movida para o código de mutação raiz.
Código de mutação raiz:
 class PostMutationRoot(MutationRoot): class Meta: model = Post has_permission = lambda post, user: post.owner == user update = UpdatePostMutation.Field() 
Interface de erro
Para tornar previsível um conjunto de erros, eles devem ser refletidos no design.
- Como as mutações podem retornar vários erros, os erros devem ser uma lista.
- Como os erros são representados por tipos diferentes, uma UnionespecíficaUnionerros deve existir para uma mutação específica.
- Para que os erros permaneçam semelhantes, eles devem implementar a interface, vamos chamá-la de ErrorInterface. Deixe que ele contenha dois campos:okemessage.
Portanto, os erros devem ser do tipo [SomeMutationErrorsUnion]! . Todos os subtipos de SomeMutationErrorsUnion devem implementar ErrorInterface .
Temos:
 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, ] 
Parece bom, mas há muito código. Usamos a metaclasse para gerar esses erros rapidamente:
 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, ] 
Adicione a declaração dos erros retornados à mutação:
 class UpdatePostMutation(MutationPayload, graphene.Mutation): class Arguments: input = UpdatePostInput() errors = graphene.List(UpdatePostMutationErrors, required=True) def mutate(post, info, input):  
Verifique se há erros
Parece-me que o método mutate não deve se preocupar com nada além de alterar os dados . Para conseguir isso, você precisa verificar se há erros no código para esta função.
Omitindo a implementação, eis o resultado:
 class UpdatePostMutation(DefaultMutation): class Arguments: input = UpdatePostInput() class Meta: root_required = True authentication_required = True  
Antes de iniciar a função mutate , cada verificador (o segundo elemento dos membros da matriz de checks ) é chamado. Se True for retornado, o erro correspondente será encontrado. Se nenhum erro for encontrado, a função mutate será chamada.
Vou explicar:
- As funções de verificação usam os mesmos argumentos que as funções de mutate.
- As funções de validação devem retornar Truese um erro for encontrado.
- As verificações de autorização e a presença do elemento raiz são bastante gerais e estão listadas nos sinalizadores Meta.
- authentication_requiredadiciona verificação de autorização se- True.
- root_requiredadiciona uma verificação "- root is not None".
- UpdatePostMutationErrorsnão- UpdatePostMutationErrorsmais necessário. Uma união de possíveis erros é criada rapidamente, dependendo das classes de erros da matriz de- checks.
Genéricos
DefaultMutation usado na última seção adiciona um método pre_mutate , que permite alterar os argumentos de entrada antes de verificar se há erros e, consequentemente, invocar a mutação.
Há também um kit inicial genérico que reduz o código e facilita a vida.
Nota: atualmente o código genérico é específico ao django ORM
Createmutation
Requer um dos create_function model ou função de create_function . Por padrão, create_function fica assim:
 model._default_manager.create(**data, owner=user) 
Isso pode parecer inseguro, mas não esqueça que há verificação de tipo embutida no graphql, além de verificação de mutações.
Ele também fornece um método post_mutate chamado após a função create_function com argumentos (instance_created, user) , cujo resultado será retornado ao cliente.
Updatemutation
Permite definir a função update_function . Por padrão:
 def default_update_function(instance, user=None, **data): instance.__dict__.update(data) instance.save() return instance 
root_required é True por padrão.
Ele também fornece um método post_mutate chamado após update_function com argumentos (instance_updated, user) , cujo resultado será retornado ao cliente.
E é disso que precisamos!
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() ), ] 
DeleteMutation
Permite definir a função delete_function . Por padrão:
 def default_delete_function(instance, user=None, **data): instance.delete() 
Conclusão
Este artigo considera apenas um aspecto, embora, na minha opinião, seja o mais complexo. Eu tenho alguns pensamentos sobre resolvedores e tipos, bem como coisas gerais em grafeno-python.
É difícil para mim me chamar de desenvolvedor experiente, por isso ficarei muito satisfeito com qualquer feedback e sugestões.
O código fonte pode ser encontrado aqui .