Consumindo REST APIs em Python

REST APIs

REST é um "estilo" (e não um padrão) para APIs. Já falei sobre isso algumas vezes, está cheio de material bom por aí; e se você tiver interesse em construir APIs REST em Python, eu recomendaria o Django REST Framework, que tem sido uma das principais ferramentas no meu trabalho nos últimos anos.

Vou assumir a partir de agora que o leitor já tenha uma noção ou passado pela experiência de usar uma API REST exposta por terceiros.

Construir URLs

O que mais me incomodou quando precisei usar APIs de terceiros foi construir as URLs. Um dos patterns que vi bastante por aí era utilizar uma string BASE_URL e concatenar o nome do recurso à essa string (eventualmente concatenando também as chaves para views de detail). Sempre usando a soma de strings, que é até aceitável para um número pequeno de objetos.

In [2]:
import requests

BASE_URL = "http://localhost/"

def my_function():
    # construir a URL
    url = BASE_URL + "resource/"
    # chamar a API via requests
    response = requests.get(url)
    # fazer algo com o resultado
    pass

Eu sempre achei isso feio e suscetível a erros, por exemplo a ausência de "/" no final das urls ou a duplicação no meio delas. Um outro pattern um pouco melhor que vi era uma classe que centralizava a construção dessas URLs.

In [3]:
import requests


class APIConnector:
    def __init__(self, base_url):
        self.base_url = base_url
    
    def get_resource_url(self):
        return self.base_url + "resource/"
    
    def get_resource_detail_url(self, key):
        return self.base_url + "resource/%s/" % key
    
    def get_another_resource_url(self):
        return self.base_url + "another-resource/"
    
    def get_resource_detail_url(self, key):
        return self.base_url + "another-resource/%s/" % key
    

def my_function():
    # instanciar o conector
    connector = APIConnector("http://localhost")
    # pedir uma URL específica
    url = connector.get_resource_detail_url(123)
    # chamar a API via requests
    response = requests.get(url)
    # fazer algo com o resultado
    pass
    

É um padrão melhor, no meu ponto de vista. Centralizar a criação diminui a possibilidade de erros porque diminui o número de trechos do código em que precisamos somar strings.

Minha Proposta

Baseei-me neste último pattern pra tentar algo mais elegante ainda. A primeira mudança que eu faria era parar de somar strings e usar o módulo os para construir as URLs de modo mais seguro, sem risco de errar a manipulação das barras.

Resolvi também não implementar um método para cada tipo de recurso. O nome do recurso seria passado como parâmetro, de preferência como um atributo da classe (no estilo objeto.atributo). E uma vez "dentro" do recurso, expor os métodos no estilo do Django REST Framework: list, retrieve, create, update, partial_update, destroy.

Segue minha implementação.

In [4]:
import os
import requests


class InvalidParamsException(Exception):
    pass


class ResourceDriver:
    def __init__(self, resource_url, urls_only=False):
        self.resource_url = resource_url
        self.urls_only = urls_only

    def _base_resource_url(self):
        return "%s/" % (self.resource_url,)

    def _detail_resource_url(self, key):
        return os.path.join(self.resource_url, str(key), "")

    def list(self, params=None):
        url = self._base_resource_url()
        if self.urls_only:
            return url
        else:
            if params:
                return requests.get(url, params=params)
            else:
                return requests.get(url)

    def create(self, data=None):
        url = self._base_resource_url()
        if self.urls_only:
            return url
        else:
            return requests.post(url, data=data)

    def retrieve(self, key):
        url = self._detail_resource_url(key)
        if self.urls_only:
            return url
        else:
            return requests.get(url)

    def partial_update(self, key, data=None):
        url = self._detail_resource_url(key)
        if self.urls_only:
            return url
        else:
            return requests.patch(url, data=data)

    def update(self, key, data):
        url = self._detail_resource_url(key)
        if self.urls_only:
            return url
        else:
            return requests.put(url, data=data)

    def destroy(self, key):
        url = self._detail_resource_url(key)
        if self.urls_only:
            return url
        else:
            return requests.delete(url)


class RestClient:
    def __init__(self, base_url=None, urls_only=False):
        if not base_url:
            raise InvalidParamsException("base_url is mandatory")
        else:
            self.base_url = base_url
        self.urls_only = urls_only

    def _get_url(self, resource):
        return os.path.join(self.base_url, resource)

    def __getattr__(self, item):
        return ResourceDriver(self._get_url(item), urls_only=self.urls_only)

    
def use_example():
    # instanciar um client
    rest_client = RestClient(base_url="http://localhost/")
    # chamar diretamente os recursos 
    list_response = rest_client.resource.list()
    detail_response = rest_client.resource.retrieve(123)
    post_response = rest_client.resource.create(data={"field": "data", "other_field": "more_data"})
    patch_response = rest_client.resource.partial_update(123, data={"field": "data"})
    update_response = rest_client.resource.update(123, data={"field": "data", "other_field": "more_data"})
    delete_response = rest_client.resource.destroy(123)
    # resource pode ser qualquer coisa, o método __getattr__ tratará o que vier
    list_response = rest_client.users.list()
    # caso o nome do recurso tenha caracteres não-usuais (como um sinal de menos)
    list_response = getattr(rest_client, "algo-bizarro").list()

O código é bem simples! Usei o método especial __getattr__ para, qualquer que seja o atributo pedido, retornar um ResourceDriver que manipule aquele nome como o nome de um recurso. Esta classe mapeia em seguida os métodos pedidos para os métodos da lib requests, construindo as URLs conforme necessário usando o módulo os e assim evitando o risco de uma URL mal-formada.

Criei uma opção urls_only que não faz as chamadas, apenas retorna as URLs construídas.

Coloquei este código num repo do github, onde também estão incluídos testes, que abordam alguns casos variados como recursos com hífen no nome e url base com ou sem barra no final.

Trabalho a Fazer

Provavelmente usarei esse código em alguns projetinhos futuros. E pra isso ainda tem muita coisa pra fazer nele. Um caso que já vem na cabeça é recurso dentro de recurso, como uma URL http://localhost/recurso/1/outro-recurso/3 para acessar detalhes de atributos, por exemplo. Talvez um método __getattr__ dentro da própria classe ResourceDriver sirva para esse trabalho.

Aceito contribuições e palpites!