Lucas Simon

Web Developer. lucassrod@gmail.com

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.

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:

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

  2. Faço o possível para tratar validar os inputs de dados no incio e tratar os erros antes do processamento real.

  3. É 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.

  4. 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:

  1. Insomnia REST Client

  2. Postman

  3. Httpie

  4. Curl

  5. etc…

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