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
errors
e ok
, 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
None
para 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. O mutate
ideal 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,
update
Post
. - Você deve passar o
id
como 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
id
não id
passado para post
, 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
id
for passado, o elemento correspondente será recuperado. - Se o objeto não for encontrado,
None
retornado 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,
None
retornado 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
Union
específica Union
erros 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: ok
e message
.
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
True
se 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_required
adiciona verificação de autorização se True
.root_required
adiciona uma verificação " root is not None
".UpdatePostMutationErrors
não UpdatePostMutationErrors
mais 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 .