Как спроектировать долгосрочное API.


Многие современные API сужествуют годами и вынуждены поддерживать обратную совместимость на протяжении десятков версий. Одна из самых известных библиотек для линейной алгебры и прочих математических вычислений - NumPy сужествует с 2005 года и с тех пор поддерживает постоянство в дизайне API.

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

Рассмотрим следующий код, который используется для формирования некоторых отчетов:

class ReportConfig(BaseModel):
    start_date: str
    end_date: str
    emails: list
    user: str
    config: Union[dict, None] = None


@router.post('/v1/send_report')
async def send_report(report_config: ReportConfig, db: Session = Depends(get_db)):
    """
    Формирование отчетов и отправка их на адрес
    """
    log.debug(f'Получены данные: {report_config}')

    report_data = {'report_data': {'emails': report_config.emails,
                                   'start_date': report_config.start_date,
                                   'end_date': report_config.end_date,
                                   'user': report_config.user,
                                   'config': report_config.config},
                   'status': 'created'}

    report_result = db.execute(insert_report(report_data)).all()

    post_msg_to_queue(emails=report_config.emails,
                      start_date=report_config.start_date,
                      end_date=report_config.end_date,
                      user=report_config.user,
                      report_id=report_result[0][0]
                      config=report_config.config)

    return JSONResponse(content={'status': 200, 'message': 'Отчеты отправлены на формирование'},
                        status_code=200)

Подразумевая, что наш API должен быть устойчивым, какие узязвимости есть на /v1/send_report? Что если в ReportConfig поменяется, например, набор полей? Наша система уязыима к этому изменению, но нет ли способа показать это более наглядно?

Есть способ:

Предполагая изменения в ReportConfig, попробуем протестировать поведение API. Для этого напишем простой декоратор:

def remove_attribute(attribute_names: list[str]):
    def decorator(cls: Type[BaseModel]):
        for attribute_name in attribute_names:
            if hasattr(cls, attribute_name):
                delattr(cls, attribute_name)
        return cls
    return decorator

Такой декоратор позволит произвольно удалить атрибут модели данных.

Используем для тестирования (в продовой версии обойдемся, естественно, не будем указывать зачения в remove_attribute).

# @remove_attribute([]) для прода будет следующий декоратор
@remove_attribute(['start_date'])
class ReportConfig(BaseModel):
    start_date: str
    end_date: str
    emails: list
    user: str
    config: Union[dict, None] = None

Теперь, если мы используем код для send_report выше, у нас возникнут проблемы на строчке с report_data. Поэтому в send_report лучше использовать другой подход, например, итерироваться по ReportConfig.__fields__ чтобы проходится по его полям.

Такой приём очень наглядно показывает места в прогамме, где мы слишком полагаемся на “хард-код”. Такие места и будут первой причиной неустойчивости нашего API.

При планировании дизайна API полезно для тестирования “нагружать” его такими изменениями, чтобы лучше предсказать его поведение в будущем и принять меры заранее.