February 21, 2012

Генерируем внешние API по-питоновски

В python есть негласное правило - никогда не повторяйся. Чаще всего если в программе приходиться писать почти одно и то-же два раза, значит вы что-то сделали не так. Я приведу пример, как можно автоматизировать генерацию внешних API таким образом, что достаточно будет в одном месте в удобной и универсальной форме описать поддерживаемые вызовы, а все внешнее API для этих вызовов сделает написаный один раз код.

Итак мы пишем серверный компонент программы, который должен контролироваться внешними утилитами. Типичные варианты управления:

  • CLI - административный интерфейс командной строки, так-же удобен для разработки
  • REST - для других языков, WebUI & Co
  • RCP в каком-то виде (thrift, PyRo, etc)

Нам нужна библиотека, которая позволит один раз задать интерфейсы API функций, сгенерирует по ним интерфейсы для всех внешних API, будет автоматически проверять входящие параметры и сделает удобочитаемую документацию. Для начала хватит.

Любая библиотека проектируется отталкиваясь от примеров ее использования.

Без подсветки синтаксиса
class Add(APICallBase):
    "Add two integers"
    class Params(object):
        params = Param([int], "list of integers to make a sum")

    def execute(self):
        return sum(self.params)


class Sub(APICallBase):
    "Substitute two integers"
    class Params(object):
        params = Param((int, int), "substitute second int from first")

    def execute(self):
        return self.params[0] - self.params[1]


class Ping(APICallBase):
    "Ping host"
    class Params(object):
        ip = Param(IPAddr, "ip addr to ping")
        num_pings = Param(int, "number of pings", default=3)

    def execute(self):
        res = subprocess.check_stdout('ping -c {0} {1}'.format(self.num_pings,
                                                                 self.ip))
        return sum(map(float, re.findall(r'time=(\d+\.?\d*)', out))) / \
                            self.num_pings

Это желаемое описание API. Каждый API вызов наследует класс APICallBase, определяет внутренний класс Params, где экземплярами класса Param описывает параметры вызова и перегружает вызов execute, в котором выполняется вся работа. Этой информации более чем достаточно, что-бы сгенерировать все API и документацию пользуясь интроспекцией и генерацией объектов на лету.

Начнем с базы - нужно уметь находить все классы, унаследованные от APICallBase. Это можно сделать через метаклассы

Показать код

Классы для типов данных, используемых в Params

Без подсветки синтаксиса
# базовый класс для типов данных
class DataType(object):

    # проверить, про val принадлежит к денному типу
    def validate(self, val):
        return True

    # преобразовать val из формата для командной строки
    def from_cli(self, val):
        return None

    # параметры для парсера CLI
    def arg_parser_opts(self):
        return {}

# список параметров определенного типа
class ListType(DataType):
    def __init__(self, dtype):
        self.dtype = get_data_type(dtype)

    def validate(self, val):
        if not isinstance(val, (list, tuple)):
            return False

        for curr_item in val:
            if not self.dtype.valid(curr_item):
                return False

        return True

    def from_cli(self, val):
        return [self.dtype.from_cli(curr_item) for curr_item in val]

    def arg_parser_opts(self):
        opts = self.dtype.arg_parser_opts()
        opts['nargs'] = '*'
        return opts

# целое число
class IntType(DataType):

    def validate(self, val):
        return isinstance(val, int)

    def from_cli(self, val):
        return int(val)

    def arg_parser_opts(self):
        return {'type': int}

Итак переходим к генерации API. Для начала - CLI

Без подсветки синтаксиса
def get_arg_parser():
    parser = argparse.ArgumentParser()
    subparsers = parser.add_subparsers()

    for call in APIMeta.api_classes():

        # для каждого вызова - свой вложенный парсер
        sub_parser = subparsers.add_parser(call.name(),
                                           help=call.__doc__)
        sub_parser.set_defaults(cmd_class=call)

        # проходим по всем параметрам и добавляем для них опции в CLI
        for param in call.all_params():
            opts = {'help':param.help}

            # значение по умолчанию, если оно есть
            # _NoDef это специальный класс, что-бы отличать значение
            # None и полное отсутствие параметра
            if param.default is not _NoDef:
                opts['default'] = param.default

            opts.update(param.arg_parser_opts())
            sub_parser.add_argument('--' + param.name.replace('_', '-'),
                                **opts)
    return parser, subparsers

REST API с помощью CherryPy

Без подсветки синтаксиса
import cherrypy as cp
def get_cherrypy_server():

    class Server(object):
        pass

    # замыкание-обработчик для команды

    def call_me(cmd_class):

        # обмениваться данными будем через json
        @cp.tools.json_out()
        def do_call(self, opts):
            cmd = cmd_class.from_dict(json.loads(opts))
            return cmd.execute()
        return do_call

    # добавляем к классу Server по методу для каждой команды
    # CherryPy будет их вызывать для обработки REST запросов

    for call in APIMeta.all_classes(APICallBase):
        setattr(Server,
                call.name(),
                cp.expose(call_me(call)))

    return Server

CherryPy довольно интересный веб-сервер, который использует интроспекцию и атрибуты классов для обработки HTTP запросов. Запрос вида http://localhost:8080/xyz?a=1&b=2 приведет к вызову Server.xyz(a="1", b="2"), если такой есть и проброшен в web через cherrypy.expose.

Завершающий аккорд - функция main

Без подсветки синтаксиса
def main(argv=None):

    # наполняем парсер CLI и разбираем командную строку
    argv = argv if argv is not None else sys.argv
    parser, subparsers = get_arg_parser()

    sub_parser = subparsers.add_parser('start-server',
                                        help="Start REST server")
    sub_parser.set_defaults(cmd_class='start-server')

    res = parser.parse_args(argv)
    cmd_cls = res.cmd_class

    # если пришел запрос на запуск сервера
    if cmd_cls == 'start-server':
        rest_server = get_cherrypy_server()
        cp.quickstart(rest_server())
    else:
        # иначе конструируем объек-команду
        for opt in cmd_cls.all_params():
            data = {}
            try:
                data[opt.name] = getattr(res, opt.name.replace('_', '-'))
            except AttributeError:
                pass
        cmd = cmd_cls.from_dict(data)

        # если не определена переменная окружения REST_SERVER_URL
        rest_url = os.environ.get('REST_SERVER_URL', None)

        if rest_url is None:
            # исполняем локально
            print "Local exec"
            print "Res =", cmd.execute()
        else:
            # иначе исполняем на сервере
            print "Remote exec"
            params = urllib.urlencode({'opts': json.dumps(cmd.to_dict())})
            res = urllib2.urlopen("http://{0}{1}?{2}".format(rest_url,
                                                             cmd.rest_url(),
                                                             params)).read()
            print "Res =", json.loads(res)


    return 0

Пробуем:

Без подсветки синтаксиса
$ python api.py -h
usage: api.py [-h] {add,sub,ping,start-server} ...

positional arguments:
  {add,sub,ping,start-server}
    add                 Add two integers
    sub                 Substitute two integers
    ping                Ping host
    start-server        Start REST server

optional arguments:
  -h, --help            show this help message and exit

$ python api.py add --params 1 3
Local exec
Res = 4

$ export REST_SERVER_URL=localhost:8080

$ python api.py add --params 1 3
Remote exec
Res = 4

Идея очень простая, так что особенно писать нечего - код говорит сам за себя. Более полный вариант можно найти на koder github. Основная мысль - вынос каждой команды в отдельный класс и описание всех ее параметров в виде, удобном для интроспекции. Похожим на описанный образом можно генерировать логику для django piston, html документацию по всем параметрам, отличия между версиями API для различных версий сервера и другое, как это делается на нашем текущем проекте.

Исходники этого и других постов со скриптами лежат тут - my blog at github При использовании их, пожалуйста, ссылайтесь на koder-ua.blogspot.com

3 comments:

Yury Yurevich said...

Как мне кажется, генерить CLI и REST API из одного описания — красивая, но на практике не очень удачная идея. CLI и RPC как правило сочетается лучше. Поясню почему. CLI чаще всего оперирует глаголами — скопируй файл А в место Б. REST оперирует существительными: обнови атрибут путь у файла А в значение Б. Описать это вместе, в рамках одного DSL мне кажется сложной задачей, IMHO изящно не получится. Т.е. получается декларативно и красиво, но не REST :)

Unknown said...
This comment has been removed by the author.
Unknown said...

Независимо от того как это называть - делают то они одно и то-же. Генерацию внешного вида можно настроить как захочется, но смысл параметров и их типы не изменятся. Приведен весьма упрощенный вариант, - только GET(без POST/DELETE). Вариант расширения, который мы используем : в каждой команде есть поле, указывающее HTTP запрос. Например:

class MoveFile(Command):
cli_cmd = 'mv'
rest_url = ('POST', '/file/$some_inline_params/path')

И в параметрах команды - новый путь