Arquitetura de Informação

17 jun, 2016

Arquitetura Composible: um exemplo do mundo real

Publicidade

Eu sou o principal mantenedor de uma significativa API REST escrita usando Flask-Restful. Flash-Restful é uma biblioteca linda, cheia de recursos para a criação de frameworks Rest com Flask. Ela tem tudo o que você precisa para gerenciar o roteamento de recursos baseados em classe, uma biblioteca para mobilizar objetos para o formato JSON, e reqparse, uma biblioteca inspirada em argparse para escrever input parsers.

É sobre reqparse que irei falar hoje.

Uma introdução ao Reqparse

Reqparse tem uma UI muito simples:

from flask.ext.restful import reqparse

parser = reqparse.RequestParser()

parser.add_argument(
  "name",
  type=str,
  required=True
)


# Within a flask request context
args = parser.parse_args()
args.get('name')  # => The "name" parameter from the request

Você declara o parser e, em seguida, adiciona argumentos a ele. Cada argumento é, na verdade, uma instância de uma classe Argument, que recebe os argumentos passados para add_argument. Em termos gerais, aqui está um pseudocódigo explicando como reqparse funciona:

class Argument(object):
    def __init__(self, name, **kwargs):
        self.name = name
        self.type = kwargs.pop('type')

    def parse(self, data):
        val = data.get(self.name)
        try:
            return self.type(val)
        except Exception as e:
            return ValueError(e.message)


class RequestParser(object):
    def __init__(self):
        self.args = []

    def add_argument(self, name, **kwargs):
        self.args.append(Argument(name, **kwargs))

    def parse_args(self):
        results = {}
        for arg in self.args:
            result = arg.parse(flask.request.json)
            if isinstance(result, ValueError):
                abort(400, result.message)
            results[arg.name] = results
        return results

Isso é tudo muito lindo, mas se você já escreveu qualquer tipo de código de validação antes, deve ter notado todos os tipos de casos que dificultam o que foi mostrado acima. Vou focar em: o argumento de palavra-chave é necessário?

Um conto de duas filosofias

O código acima é um pseudocódigo, mas o cerne da questão aqui é: qual a melhor forma de implementar o que foi mostrado acima?

Para contar a história fora de ordem, a abordagem do reqparse (como eu aprendi quando eu tentei atualizá-lo) envolve a adição de mais kwargs a Argument e RequestParser, e atualizar suas implementações com casos especiais para esse argumento. Como consequência, é assim que o método parse do Argument se parece agora:

    def parse(self, request, bundle_errors=False):
        """Parses argument value(s) from the request, converting according to
        the argument's type.
        :param request: The flask request object to parse arguments from
        :param do not abort when first error occurs, return a
            dict with the name of the argument and the error message to be
            bundled
        """
        source = self.source(request)

        results = []

        # Sentinels
        _not_found = False
        _found = True

        for operator in self.operators:
            name = self.name + operator.replace("=", "", 1)
            if name in source:
                # Account for MultiDict and regular dict
                if hasattr(source, "getlist"):
                    values = source.getlist(name)
                else:
                    values = [source.get(name)]

                for value in values:
                    if hasattr(value, "strip") and self.trim:
                        value = value.strip()
                    if hasattr(value, "lower") and not self.case_sensitive:
                        value = value.lower()

                        if hasattr(self.choices, "__iter__"):
                            self.choices = [choice.lower()
                                            for choice in self.choices]

                    try:
                        value = self.convert(value, operator)
                    except Exception as error:
                        if self.ignore:
                            continue
                        return self.handle_validation_error(error, bundle_errors)

                    if self.choices and value not in self.choices:
                        if current_app.config.get("BUNDLE_ERRORS", False) or bundle_errors:
                            return self.handle_validation_error(
                                ValueError(u"{0} is not a valid choice".format(
                                    value)), bundle_errors)
                        self.handle_validation_error(
                                ValueError(u"{0} is not a valid choice".format(
                                    value)), bundle_errors)

                    if name in request.unparsed_arguments:
                        request.unparsed_arguments.pop(name)
                    results.append(value)

        if not results and self.required:
            if isinstance(self.location, six.string_types):
                error_msg = u"Missing required parameter in {0}".format(
                    _friendly_location.get(self.location, self.location)
                )
            else:
                friendly_locations = [_friendly_location.get(loc, loc)
                                      for loc in self.location]
                error_msg = u"Missing required parameter in {0}".format(
                    ' or '.join(friendly_locations)
                )
            if current_app.config.get("BUNDLE_ERRORS", False) or bundle_errors:
                return self.handle_validation_error(ValueError(error_msg), bundle_errors)
            self.handle_validation_error(ValueError(error_msg), bundle_errors)

        if not results:
            if callable(self.default):
                return self.default(), _not_found
            else:
                return self.default, _not_found

        if self.action == 'append':
            return results, _found

        if self.action == 'store' or len(results) == 1:
            return results[0], _found
        return results, _found

Vou fazer uma pausa aqui para salientar que esse código funciona perfeitamente bem, e Flask-Restful tem sido uma ótima biblioteca para usar. Mas procure por alguma lógica de branch necessária para os casos especiais. Por exemplo, esse ramo deixa o valor lowercase e, em seguida, tem de verificar no argumento choice (usado para especificar um conjunto de valores válidos que a entrada poderia tomar).

if hasattr(value, "lower") and not self.case_sensitive:
        value = value.lower()

        if hasattr(self.choices, "__iter__"):
            self.choices = [choice.lower()
                            for choice in self.choices]

Isso tem cheiro de um bug nascido de um caso extremamente obscuro e, na verdade, podemos encontrar o commit onde esse branch foi adicionado.

Voltando no tempo antes de tudo isso: como nosso aplicativo cresceu mais e mais quanto às exigências dos seus dados, uma biblioteca reutilizável “validators” nasceu. Ela simplesmente fornece funções que podem ser passadas para o parâmetro type do add_argument do reqparse. Ele começou simples o suficiente, com validators como e-mail:

import re
EMAIL_RE = re.compile(r".+@.+\..+")

def email(s):
    if not EMAIL_RE.match(s):
        raise ValidationError("Invalid email format")
    return s

Eventualmente, porém, ele ganha algumas faixas adicionais. Aqui está o validator choice que foi adicionado em algum momento, provavelmente por mim quando eu não tive paciência para encontrar a opção choice embutida do reqparse.

def choice(choices, msg='Invalid Choice'):
    def choose(choice):
        if choice not in choices:
            raise ValidationError(msg=msg)
        if isinstance(choices, dict):
            return choices.get(choice)
        else:
            return choice
    return choose


# Usage
parser.add_argument(
  'category',
  type=validators.choice(['News', 'Entertainment', 'Other']),
)

Viu como a responsabilidade por essa validação é agora transferida para a função type, garantindo que nenhum tratamento especial de código de escolha seja mantido longe das funções de análise de uso geral? Sem querer, a biblioteca validators acrescentou um monte de funcionalidades embutidas do reqparse em paralelo. Em paralelo às palavras-chave do reqparse, validators ganhou optional, required, nullable e validated_list.

Recentemente, eu tive que atualizar Flask-Restful por algum motivo, e foi aterrorizante quando alguns dos validators pararam de funcionar. Isso não foi realmente culpa de ninguém, apenas um caso em que minhas funções de validação esperavam algum comportamento edge-case, que tinha mudado. Mas eu percebi isso, e agora não preciso de todas as funcionalidades que o reqparse oferece. Eu troquei as classes RequestParser e Argument por algumas personalizadas que se parecem muito mais com o exemplo de pseudocódigo acima do que com as implementações atuais que existem dentro do flash-restful. Não é um processo em que tudo é feito de uma vez, mas ao longo do tempo, e todos os validators baseados em palavras-chave estão se movendo para baseados em funções, e implementações Argument e RequestParser continuam ficando mais e mais simples.

Aliás, esse é o tipo de sistema que WTForms, outra ótima biblioteca de imput-wrangling, sempre usou. Cada classe Field aceita uma lista de validators, que podem ser totalmente escritos de forma personalizada, assim como o meu acima (você pode até mesmo torná-los closures retornando funções). O que WTForms fornece, em vez de argumentos extras, é um conjunto de validators que cobrem casos comuns, como DataRequired, EqualTo etc:

class MyForm(Form):
  email = StringField("email", validators=[DataRequired(), Email()])

Mas, para não ficar atrás no campo da austeridade em palavra-chave, validators há muito tem proporcionado um método comp para esse tipo de situação:

parser.add_argument(
  'email',
  type=validators.comp(validators.required, validators.email)
)

Então, qual é a lição? Eu não sei, eu não sou um professor, mas eu acho que existe algo ao longo das linhas: sempre que possível, escolha a composição da função para aqueles casos em que ela realmente torna as coisas mais simples.

***

Adam Bard faz parte do time de colunistas internacionais do iMasters. A tradução do artigo é feita pela redação iMasters, com autorização do autor, e você pode acompanhar o artigo em inglês no link: https://adambard.com/blog/composible-architecture-a-real-world-example