Série API em Flask - Parte 7 - Criando usuários
Neste artigo vamos abordar como criar um endpoint para registrar o usuário em nossa base de dados de acordo com o modelo do artigo anterior. Também vamos utilizar o pacote marshmallow
para validar nosso schema e padronizar algumas mensagens de erro.
Para entendimento, segue os capítulos que serão abordados.
Capítulo 1: Introdução, configuração e Hello World
Capítulo 2: Organizando as dependências e requerimentos
Capítulo 3: Configurando o pytest e nosso primeiro teste
Capítulo 4: Configurando o Makefile
Capítulo 5: Adicionando o MongoDB
Capítulo 6: Criando e testando o modelo de usuários
Capítulo 7: Criando usuários Estamos aqui
Capítulo 8: Listando usuários
Capítulo 9: Buscando usuários
Capítulo 10: Editando um usuário
Capítulo 11: Deletando um usuário
Capítulo 12: Autênticação por JWT
Capítulo 13: Criando um container Docker
Capítulo 14: Arquivos de configuração para Deploy na Digital Ocean
Capítulo 15: Automatizando o processo de deploy com Fabric
Capítulo 16: CI e CD com Jenkins, Python, Flask e Fabric
Capítulo 17: Utilizando o RabbitMQ com Flask e Sendgrid para enviar e-mails de boas vindas e ativar a conta do usuário
O repositório com todo o código fonte esta aqui. Os capítulos estão em branches
.
Introdução
Estamos entrando na fase de implementação dos endpoints. A idéia deste endpoint é fazer com que o qualquer cliente (SPA, REST Client, curl e etc…) possa registrar um usuário chamando a rota e o payload correto.
Nos próximos artigos os recursos para listagem, detalhes/atualização/deleção por id serão somente utilizados por quem tiver uma permissão admin
setada como true
no modelo de usuários.
Para detalhes do perfil logado e atualização do usuário logado serão implementados após o artigo de autenticação.
Assim vamos abordar todo o Crud de usuários da plataforma.
Instalando o pacote marshmallow e seu uso
Primeiramente o marshmallow é responsável por converter dados para tipos nativos do Python. Eu utilizo bastante para validar os payloads de entrada e saída dos meus recursos.
Existem outras alteranativas para isso como o schematics porém por questão de habito optei por usar o marshmallow.
Sendo assim, façamos, pip install marshmallow --upgrade
. O ideal é cria-lo em nosso requirements/base.txt
com a entrada marshmallow==2.15.6
em seguida executar o comando $ pip install -r requirements/base.txt
Após instalar vamos criar o arquivo dentro de apps/users/schemas.py
. Ele irá conter nossas definições de schema.
A principio precisamos somente de schema para registrar o usuário então:
# -*- coding: utf-8 -*-
from marshmallow import Schema
from marshmallow.fields import Email, Str
class UserRegistrationSchema(Schema):
full_name = Str(required=True, error_messages={'required': 'Campo obrigatório'})
email = Email(required=True, error_messages={'required': 'Campo obrigatório'})
password = Str(required=True, error_messages={'required': 'Campo obrigatório'})
class UserSchema(Schema):
full_name = Str(required=True, error_messages={'required': 'Campo obrigatório'})
email = Email(required=True, error_messages={'required': 'Campo obrigatório'})
cpf_cnpj = Str()
active = Boolean()
Dado essa classe podemos utilizá-la da seguinte forma. Desserialização de um dicionário para tipos nativos do python e realizando validações.
In [1]: from apps.users.schemas import UserRegistrationSchema
In [2]: payload = {'email': 'teste@teste.com', 'password': '123456'}
In [3]: schema = UserRegistrationSchema()
In [4]: schema.load(payload)
Out[4]: UnmarshalResult(data={'password': '123456', 'email': 'teste@teste.com'}, errors={'full_name': ['Campo obrigatório']})
In [5]: data, errors = schema.load(payload)
In [6]: data
Out[6]: {'password': '123456', 'email': 'teste@teste.com'}
In [7]: errors
Out[7]: {'full_name': ['Campo obrigatório']}
Podem observar que na minha variável payload
eu não coloquei a chave full_name
e ao executar o metódo .load()
ele me retorna múltiplos valores.
Com isso posso retornar erros logo após o meu payload
de dados serem inválidos.
Outro uso para o marshmallow
é serializar objetos de um determinado modelo por exemplo:
In [1]: from apps.users.schemas import UserRegistrationSchema, UserSchema
In [2]: from apps.users.models import User
In [3]: user = User(email='teste@teste.com', password='123456', full_name='Teste OSchema')
In [4]: schema = UserSchema()
In [5]: result = schema.dump(user)
In [6]: result.data
Out[6]:
{'cpf_cnpj': '',
'email': 'teste@teste.com',
'full_name': 'Teste OSchema',
'active': False}
Veja que eu criei uma instância do modelo User e através do método dump()
consigo pegar através da variável result.data
um dicionário com as informações que eu preciso e que podem ser respondidas imediatamente pelo meu recurso.
Claro que esta são uma das possibilidades do marshmallow
e existem inúmeras outras possibilidades para o seu uso.
Padronizando mensagens da aplicação
Uma coisa que eu tenho costume de fazer é padronizar as mensagens da aplicação. Lembro que antigamente existia um documento de mensagens, em análise de sistemas, que detalhava todas as mensagens que a aplicação retornaria. De fato é bem usual essa padronização pois evita escrever mensagens com ou sem acentos (Campo obrigatório / Campo obrigatorio), com ou sem pontos no final da frase e etc… Outro ponto importante é que centraliza as mensagens em um único local facilitando a manutenção posteriormente.
Por isso, vamos criar um arquivo apps/messages.py
que será responsável por documentar essa mensagens.
MSG_FIELD_REQUIRED = 'Campo obrigatório.'
Vamos voltar e refatorar nosso arquivo apps/users/schemas.py
e alterar o nosso error_messages
.
# -*- coding: utf-8 -*-
# importar nossas CONSTANTES
from apps.messages import MSG_FIELD_REQUIRED
class UserRegistrationSchema(Schema):
full_name = Str(required=True, error_messages={'required': MSG_FIELD_REQUIRED})
# Fazer isso para os demais campos
Bem simples e útil certo!? Após importar as constantes, refatore as mensagens required
para receber a constante MSG_FIELD_REQUIRED
. A medida que a aplicação for crescendo iremos visitar esse módulo (messages.py
) várias vezes…
Instalando o Bcrypt e salvando a senha encriptada
Dando sequência, precisamos que nossa senha ao registrarmos através do endpoint seja encriptada, hoje o banco de dados não realiza essa segurança de um dado sensível por isso vamos fazer essa parte na aplicação.
Antes de tudo, devemos instalar o pacote bcrypt através do comando pip install bcrypt
ou pelo pip install -r requirements/base.txt
.
In [1]: from bcrypt import gensalt, hashpw, checkpw
In [2]: hashed = hashpw('123456'.encode('utf-8'), gensalt(12))
In [3]: hashed
Out[3]: b'$2b$12$E6FDm6tCBOVPiQfu6wFr1uU3Lqmyv8Ci7BR92XJ8ZAEXwQ31fi6Qe'
In [4]: checkpw('123456'.encode('utf-8'), hashed)
Out[4]: True
Esse script é bem simples. Importamos as funções do pacote e faço um hash com a string 123456
. Em seguida com o método checkpw
verificamos se a string do primeiro parâmetro é igual ao hashed criado acima.
Padronizando as mensagens de resposta JSON.
Mais uma forma de organizar a aplicação é padronizar as mensagens de resposta de cada recurso. Para isso eu crio vários métodos de acordo com cada tipo de status code do HTTP, com o objetivo de reaproveitamento e padronização.
Antes de seguirmos vamos criar novas mensagens para cada tipo de resposta. No arquivo apps/messages.py
adicione:
MSG_INVALID_DATA = 'Ocorreu um erro nos campos informados.'
MSG_DOES_NOT_EXIST = 'Este(a) {} não existe.'
MSG_EXCEPTION = 'Ocorreu um erro no servidor. Contate o administrador.'
MSG_ALREADY_EXISTS = 'Já existe um(a) {} com estes dados.'
Agora, no arquivo apps/responses.py
vamos criar os métodos.
# -*- coding: utf-8 -*-
from flask import jsonify
from .messages import MSG_INVALID_DATA, MSG_DOES_NOT_EXIST, MSG_EXCEPTION
from .messages import MSG_ALREADY_EXISTS
def resp_data_invalid(resource :str, errors: dict, msg: str = MSG_INVALID_DATA):
'''
Responses 422 Unprocessable Entity
'''
if not isinstance(resource, str):
raise ValueError('O recurso precisa ser uma string.')
resp = jsonify({
'resource': resource,
'message': msg,
'errors': errors,
})
resp.status_code = 422
return resp
def resp_exception(resource :str, description :str = '', msg :str = MSG_EXCEPTION):
'''
Responses 500
'''
if not isinstance(resource, str):
raise ValueError('O recurso precisa ser uma string.')
resp = jsonify({
'resource': resource,
'message': msg,
'description': description
})
resp.status_code = 500
return resp
def resp_does_not_exist(resource :str, description :str):
'''
Responses 404 Not Found
'''
if not isinstance(resource, str):
raise ValueError('O recurso precisa ser uma string.')
resp = jsonify({
'resource': resource,
'message': MSG_DOES_NOT_EXIST.format(description),
})
resp.status_code = 404
return resp
def resp_already_exists(resource :str, description :str):
'''
Responses 400
'''
if not isinstance(resource, str):
raise ValueError('O recurso precisa ser uma string.')
resp = jsonify({
'resource': resource,
'message': MSG_ALREADY_EXISTS.format(description),
})
resp.status_code = 400
return resp
def resp_ok(resource :str, message :str, data=None, **extras):
'''
Responses 200
'''
response = {'status': 200, 'message': message, 'resource': resource}
if data:
response['data'] = data
response.update(extras)
resp = jsonify(response)
resp.status_code = 200
return resp
Um novo endpoint na nossa api
Após todas essas configurações vamos codificar nosso recurso User. Crie um módulo apps/users/resources.py
.
Vou deixar aqui o código comentado e abaixo farei algumas considerações:
# -*- coding:utf-8 -*-
# Python
# Flask
from flask import request
# Third
from flask_restful import Resource
from bcrypt import gensalt, hashpw
from mongoengine.errors import NotUniqueError, ValidationError
# Apps
from apps.responses import (
resp_already_exists,
resp_exception,
resp_data_invalid,
resp_ok
)
from apps.messages import MSG_NO_DATA, MSG_PASSWORD_WRONG, MSG_INVALID_DATA
from apps.messages import MSG_RESOURCE_CREATED
# Local
from .models import User
from .schemas import UserRegistrationSchema, UserSchema
from .utils import check_password_in_signup
class SignUp(Resource):
def post(self, *args, **kwargs):
# Inicializo todas as variaveis utilizadas
req_data = request.get_json() or None
data, errors, result = None, None, None
password, confirm_password = None, None
schema = UserRegistrationSchema()
# Se meus dados postados forem Nulos retorno uma respota inválida
if req_data is None:
return resp_data_invalid('Users', [], msg=MSG_NO_DATA)
password = req_data.get('password', None)
confirm_password = req_data.pop('confirm_password', None)
# verifico através de uma função a senha e a confirmação da senha
# Se as senhas não são iguais retorno uma respota inválida
if not check_password_in_signup(password, confirm_password):
errors = {'password': MSG_PASSWORD_WRONG}
return resp_data_invalid('Users', errors)
# Desserialização os dados postados ou melhor meu payload
data, errors = schema.load(req_data)
# Se houver erros retorno uma resposta inválida
if errors:
return resp_data_invalid('Users', errors)
# Crio um hash da minha senha
hashed = hashpw(password.encode('utf-8'), gensalt(12))
# Salvo meu modelo de usuário com a senha criptografada e email em lower case
# Qualquer exceção ao salvar o modelo retorno uma resposta em JSON
# ao invés de levantar uma exception no servidor
try:
data['password'] = hashed
data['email'] = data['email'].lower()
model = User(**data)
model.save()
except NotUniqueError:
return resp_already_exists('Users', 'fornecedor')
except ValidationError as e:
return resp_exception('Users', msg=MSG_INVALID_DATA, description=e)
except Exception as e:
return resp_exception('Users', description=e)
# Realizo um dump dos dados de acordo com o modelo salvo
schema = UserSchema()
result = schema.dump(model)
# Retorno 200 o meu endpoint
return resp_ok(
'Users', MSG_RESOURCE_CREATED.format('Usuário'), data=result.data,
)
Seguem alguma considerações de desenvolvimento da aplicação:
Sempre inicializo as variáveis no inicio da método. Talvez seja mania vinda do JavaScript como também uma maneira de precaver de exceptions de variável não declarada.
Faço o possível para tratar validar os inputs de dados no incio e tratar os erros antes do processamento real.
É preciso separar o código em pequenas partes como foi feito na chamada do método
check_password_in_signup
. Dessa maneira fica fácil realizar teste unitário além de deixar o código mais limpo. O problema de deixar tudo numa unica função é quantidade de testes que teremos de fazer para cercar todos os casos, de cada condição if else.Em API’s Rest não deixo levantar exceptions. Sempre procuro retornar uma resposta json com o status code diferente de 200.
Criar nosso endpoint
Em nosso módulo apps/api.py
faça a importação do recurso from apps.users.resources import SignUp
e na função configure_api
adicione nosso endpoint:
def configure_api(app):
# demais rotas
# rotas para o endpoint de usuarios
api.add_resource(SignUp, '/users')
# restante do código
Testando nosso modelo
Algumas opções para realizar testes funcionais em nossa API são:
Exemplo de uma requisiçaõ utilizando o Httpie e a resposta do nosso servidor.
$ http -v POST 0.0.0.0:5000/users full_name="07368680629" email="teste@teste.com" password='123456' confirm_password='123456'
POST /users HTTP/1.1
Accept: application/json, */*
Accept-Encoding: gzip, deflate
Connection: keep-alive
Content-Length: 108
Content-Type: application/json
Host: 0.0.0.0:5000
User-Agent: HTTPie/0.9.8
{
"confirm_password": "123456",
"email": "teste@teste.com",
"full_name": "07368680629",
"password": "123456"
}
HTTP/1.0 200 OK
Content-Length: 210
Content-Type: application/json
Date: Thu, 11 Oct 2018 02:02:58 GMT
Server: Werkzeug/0.14.1 Python/3.6.5
{
"data": {
"active": false,
"cpf_cnpj": "",
"email": "teste@teste.com",
"full_name": "07368680629"
},
"message": "Usuário criado(a).",
"resource": "Users",
"status": 200
}
Concluindo, quis mostrar através deste artigo como padronizo minhas aplicações Flask e o modo de como gosto de desenvolver utilizando padrões para facilitar a leitura e a manutenção do código.
Um grande abraço a todos, até o próximo capítulo.
Próximo artigo: Listando usuários