software-architecture
June 26, 2023

Как сделать хорошее API

Обстоятельно и подробно, на конкретных примерах.

Что это и зачем

An application programming interface (API) is a way for two or more computer programs to communicate with each other. It is a type of software interface, offering a service to other pieces of software.[1]

Программный интерфейс приложения (такой перевод мне больше нравится) — это такой метод взаимодействия между программами.

Способ из одной программы вызвать другую.

Полагаю, даже из столь общего описания уже понятно насколько важно уметь «правильно готовить» это самое API.

Знаю случаи преждевременной смерти проекта из-за хреновой реализации API, знаю примеры отказов от использования и потери клиентов, по этой же причине.

Ну и конечно множество случаев срыва сроков разработки и вылезания далеко за границы бюджета.

Мой опыт

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

от WinAPI, COM/COM+,DDE и CORBA и до RMI, gRPC, XML-RPC, SOAP и REST, включая HATEOAS и GraphQL.

И за 20+ лет карьеры видел наверное все более-менее известные и популярные реализации протоколов взаимодействия.

Карьерные амбиции, фриланс и банальное любопытство постоянно толкали меня в самые лютые «интеграционные приключения».

Вообщем другого такого, с опытом от COM+ до GraphQL я больше не встречал, поэтому есть повод считать такой опыт уникальным, хотя-бы для РФ.

Этим самым опытом я и хочу поделиться с вами, дорогой читатель.

Разные реализации API часто обусловлены техническими ограничениями или историческим наследием, а самым универсальным и «чистым» вариантом на данный момент является REST.

Вот на нем и сфокусируемся, хотя все описанное достаточно универсально и применимо для большинства любых других вариантов API.

Минимальный неделимый смысл

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

На конкретном примере:

Допустим, вы создали некий сервис для отправки СМС.

Без API, позволяющего удаленное использование это сервиса из других систем — сам смысл существования такого сервиса теряется.

Поэтому минимальный неделимый смысл для подобного сервиса — вызов метода отправки СМС. Без работы этого метода — все API целиком считается неработающим.

Зачем это все надо?

Чтобы иметь четкое направление развития API и понимать, какие именно функции в дальнейшем отключать нельзя.

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

Вообщем, метод отправки СМС в вашем API будет абсолютно всегда, в любых версиях и при любых условиях.

Конечно со временем вы добавите новые методы, но минимальный неделимый смысл должен оставаться всегда — в виде того самого метода отправки СМС.

Структуры данных

Думаю очевидно что при вызове метода API ему нужно передавать и получать назад какие-то параметры:

строки, числа, булевые флажки, даты и так далее.

Параметров обычно много, поэтому их набор оформляют в специальные объекты DTO — Data Transfer Object и передают и получают именно их.

Считаю что однозначно стОит использовать DTO вместо примитивных типов при проектировании любого API в любых случаях, несмотря на некоторое усложение и увеличение объема кода.

И на это есть серьезные причины.

Допустим, у вас есть вот такой метод API (все также Spring MVC):

@RequestMapping(value = "/api/updates/receive", method = RequestMethod.POST)
public ResponseEntity<?> receiveUpdate(@RequestParam Long taskId, 
                                       @RequestParam String updateInfo,
                                       @RequestParam LocalDateTime updateDt) {
                                     //логика обработки
                                       ..
                                       }

Что произойдет если вам будет нужно добавить новый параметр?

Если вы не отключите признак обязательности (required=false) для нового параметра — при вызове из старых клиентов, еще не знающих о новом параметре, этот метод будет генерировать ошибку.

Наверное во всех API есть понятие «сигнатуры метода»: комбинация из названия, модификаторов и аргументов (включая типы данных).

Без знания правильной сигнатуры, правильный вызов метода API становится невозможным.

В примере выше, добавление параметра как раз меняет эту самую сигнатуру. Поэтому лучший вариант, который сохранит обратную совместимость это использование DTO:

/**
  пример метода с использованием DTO
*/
@RequestMapping(value = "/api/updates/receive", method = RequestMethod.POST)
public ResponseEntity<?> receiveUpdate(@RequestBody UpdateRequestDTO dto) {
 //логика обработки
 ... 
} 
/**
  пример DTO 
*/
class UpdateRequestDTO {
   private Long taskId; 
   private String updateInfo;
   private LocalDateTime updateDt;
   // новый параметр
   private boolean shinyNewFeatureSwitch; 
}

Устаревший клиент, использующий этот метод API, поле с новым параметром просто не заполнит, поэтому объект DTO будет создан со значением по-умолчанию для этого поля.

И все будут счастливы.

Да это фрактал, сгенерирован математикой, визуальное отображение вложенности

Вложенность в DTO

Существует очень мощный инструмент, который точно стоит использовать при проектировании DTO — вложенность объектов.

Допустим у вас есть метод API, который отдает профиль пользователя — много много разных полей, описывающий все его данные: ФИО, телефон, почту, роли, связанные документы и так далее.

Вы все это видели неоднократно, в любой современной веб-системе.

Можно конечно DTO реализовать «в лоб», просто перечислив все поля профиля пользователя подряд:

public class UserProfile {
    private String firstName, lastName, middleName;
    private String email;
    private String workPhone;
    // и так далее, еще 100500 полей
    ...
}

Что будет когда вам понадобится реализовать поддержку нескольких адресов почты или телефонов?

А как насчет нескольких вариантов имени? Например отдельно на национальном языке и отдельно - на английском.

Да, тут тоже есть простой вариант из серии «п#хуй пляшем» — добавлять и добавлять новые поля простыней, усложняя уже бизнес-логику по их использованию.

Но есть вариант лучше, называется «включить голову» и спроектировать:

public class UserProfile {
    // DTO с основным блоком данных о пользователе
    private BasicInfo basicInfo;
    // список DTO с дополнительными блоками данных 
    // для поддержки нескольких вариантов имен 
    private List<BasicInfo> additionalInfo;
    // основные контакты пользователя (есть у 100% пользователей)
    private ContactInfo mainContacts; 
    // дополнительные контакты  (есть у 1-2% особо #бнутых)
    private List<ContactInfo> additionalContacts;
}
// отдельные DTO для блоков данных
class BasicInfo {
 private String firstName, lastName, middleName;
}
class ContactInfo {  
    private String email;
    private String workPhone;    
}

Что тут важно отметить:

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

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

Не поверите, но это асинхронный генератор.

Синхронные и асинхронные вызовы

Наверное вы и так в курсе что существуют «синхронные» и «асинхронные» методы API.

В первом случае результат вызова отдается немедленно, во втором — отдается ссылка на получение результата, сам результат отдается отдельным методом, которому указывается эта ссылка.

Пожалуйста, не ведитесь на «простоту» и никогда не делайте синхронное API:

Синхронное API — корень всех бед производительности сетевых сервисов и абсолютное зло.

Нет никаких исключений, нет особых случаев и нет никаких разумных причин кроме лени и долбо#бизма в существовании синхронных методов API.

Вот такого быть не должно:

@Controller
public class MdmController {
/**
 * Сбор статистики для МДМ по пакету 
 * @param packageId - идентификатор пакета статистики МДМ
 * @param collectorType - тип статистики которую нужно собрать
 * @return мапа с результатом операции сбора статистики по делам
 */
@ResponseBody
@RequestMapping(value = "/package/{packageId}/collect/{collectorType}", method = RequestMethod.POST)
public Object collectStatistics(@PathVariable long packageId, @PathVariable String collectorType) {
    Map<String, Object> result = new HashMap<>();       
    MdmPackage mdmPackage = mdmPackageDao.getById(packageId);
    if (mdmPackage == null) {
        result.put("success", false);
        result.put("errorMessage", "Not found package by ID: " + packageId);
        return result;
    }
    ICollector collector = collectorFactory.getCollector(collectorType);
    if (collector == null) {
        result.put("success", false);
        result.put("errorMessage", "Not found collector by type: " + collectorType);
        return result;
    }
    try {
        collector.collect(mdmPackage);
        result.put("success", true);
    } catch (MDMCollectException e) {      
        result.put("success", false);
        result.put("errorMessage", e.getMessage());
    }
    return result;
}

Запомните:

ответ метода чтения должен быть из памяти — из кэша, из каких-то подготовленных данных всегда.

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

Пример выше со статистикой должен был выглядеть вот так:

/**
 * Сбор статистики для МДМ по пакету
 *
 * @param packageId - идентификатор пакета статистики МДМ
 * @param collectorType - тип статистики которую нужно собрать
 * @return мапа с результатом операции сбора статистики по делам
 */
@ResponseBody
@RequestMapping(value = "/package/{packageId}/collect/{collectorType}", method = RequestMethod.POST)
public Object collectStatistics(@PathVariable long packageId, @PathVariable String collectorType) {
   return cacheService.getCachedStatsFor(packageId);    
}

А заполнение этого кэша со статистикой в отдельном потоке:

@Service
public class CacheService {
 // хранилище со статистикой
 private Map<Long,Map<String,Object>> statsCache = new HashMap<>();
 /**
   фоновое обновление статистики в отдельном потоке
 */   
 @Scheduled(initialDelay=5000)
 public void refreshStats() {
     // получаем все MDM-пакеты из базы
     List<MdmPackage> foundPackages = ..;
     // собираем и обновляем статистику
     for (MdmPackage p: foundPackages) {
        Map<String,Object> stats = collectStats(p);
        statsCache.put(p.getPackageId(),stats);
     }
 }
 /**
  Получить статистику по id пакета
 */
 public Map<String, Object> getCachedStatsFor(long packageId) {
   return statsCache.containsKey(packageId)? 
       statsCache.get(packageId): Collections.emptyMap();
   }   
}

В этом случае скорость ответа метода будет всегда предсказуемой, вне зависимости от количества клиентов.

Но самое главное — не будет линейной нагрузки на используемые внешние ресурсы, в первую очередь СУБД, поскольку реальное чтение данных происходит в фоне, в одном потоке, без связи с клиенсткими потоками.

Если будут проблемы с подключением к базе — клиент этого не увидит, а если вместо одного вызова метода, кто-то из клиентов API сделает тысячу или миллион — ничего не сломается.

Если вам нужно принять данные от клиента, идея ровно та же самая, поэтому вот так делать нельзя:

@RestController
public class QuestionnaireController {
@Transactional
@PostMapping
public Questionnaire create(Users operator, 
                            CurrentArchiveDeals deals, 
                            Integer durationRate, 
                            Integer queueRate,
                            Integer politeRate, 
                            Integer comfortRate, Integer informRate) {
    Questionnaire questionnaire = new Questionnaire();
     // заполнение полей
     ..
    Query q = emf.createEntityManager().createNativeQuery("select nextval('mfc.questionnaire_seq')");
    // сохранение в базу данных
    ...
    return questionnaire;
    }
}

В примере выше, каждый вызов метода API create откроет транзакцию и заблокирует выделенное соединение из пула подключений к базе на время записи.

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

Если вам надо сохранить что-то в базу или на диск из входящего вызова — делайте это также асинхронно:

@Service
public class QuestionnaireService {
 public String createNew(Questionnaire questionnaire) {
    UUID requestId = UUID.randomUUID();
    questionnaire.setRequestId(requestId);   
    createNewAsync(questionnaire);
    return requestId.toString(); 
 } 
 public Questionnaire getByRequestId(String requestId)  {
    // получение из базы выборкой по id запроса или отдача null
    .. 
 }
 @Async
 void createNewAsync(Questionnaire questionnaire) {
   try {
   createNewTrans(questionnaire);
   } catch (Exception e) {
   // ignore
   }
 } 
@Transactional
 void createNewTrans(Questionnaire questionnaire) {
    Query q = emf.createEntityManager()
      .createNativeQuery("select nextval('mfc.questionnaire_seq')");
    // сохранение в базу данных 
 } 
}

Вот так выглядит контроллер с API:

@RestController
public class QuestionnaireController {
  @Autowired 
  private QuestionnaireService qs;
  public String create(Users operator, 
                            CurrentArchiveDeals deals, 
                            Integer durationRate, 
                            Integer queueRate,
                            Integer politeRate, 
                            Integer comfortRate, Integer informRate) {
      Questionnaire questionnaire = new Questionnaire();
      // заполнение полей
      ..
      return qs.createNew(questionnaire);
  }       
  public Questionnaire getByRequestId(String requestId)  {
       return qs.getByRequestId(requestId);
  }
}

Как видите, тут произошла разбивка на три метода: сам метод записи, метод проверки результата и метод API, который лишь создает запрос на запись и отправляет в очередь обработки.

Наружу отдается уникальный ID запроса, по которому возможно получить результат выполнения.

В результате, цепочка:

вызов метода API-> результат

превращается в:

вызов метода API -> запрос на обработку -> обработка -> проверка готовности результата -> результат

Это сложно, правильная реализация будет сильно сложнее и объемнее чем синхронная версия.

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

Вызов удаленного REST-метода в цикле на пару тысяч итераций — теперь норма, асинхронное API — самый лучший способ такого не допускать.

Особенности HTTP

Большинство современных протоколов интеграции так или иначе основаны на веб-технологиях и используют внутри HTTP протокол.

HTTPS внутри также использует HTTP и его методы, если кто вдруг не в курсе.

SOAP, REST, XML-RPC — все эти протоколы работают фактически поверх HTTP. Да, в некоторых случаях заявляется поддержка других движков, но самое массовое использование все равно происходит поверх HTTP/HTTPS.

Поэтому например любой SOAP запрос представляет собой HTTP POST с XML в теле запроса и несколькими дополнительными HTTP заголовками.

Теперь, уже зная все эти вещи, необходимо пояснить что существует разница в обработке GET и POST (технически еще и PUT/DELETE/HEAD) запросов:

ответ GET запроса по-умолчанию кешируется.

Как на клиентской стороне (браузером и клиентскими библиотеками), так и на серверной — всевозможными прокси и «API Gateway» решениями.

При этом никто и никогда не кеширует POST, поскольку он изначально предназначался для отправки данных.

Отсюда моя рекомендация:

всегда использовать исключительно POST запросы при реализации методов API, в том числе для методов получения данных.

Да, это идет в разрез с рекомендациями по тому же REST, зато работает всегда и везде.

Не нужно думать про сбитую системную дату на клиенте, влияющую на срок жизни кэшированных данных и об обрезанных или переделанных заголовках ( что часто делают прокси и проксирующие шлюзы), которые могут повлиять на включение кеширования там где не надо.

Наверное видели как клиентские Javascript библиотеки добавляют параметр со случайным числом к запросу?

Что-то вроде:

GET /api/uptime?t=2232449494

Это один из способов исключить кеширование ответа на стороне клиента.

Коды ответа

Если вы пользуетесь интернетом хоть иногда то обязательно видели страницы с ошибками. Чаще всего с кодом 404 «страница не найдена», 500 «внутренняя ошибка», реже 403 «нет доступа».

Вот тут они все, если вдруг интересно.

Существуют также рекомендации по использованию HTTP кодов ответа при проектировании REST API, вроде использования кода 404 если данные не найдены, 400 (Bad Request) при ошибке валидации входящих параметров и 500 при внутренней ошибке логики.

Мой опыт показывает, что все несколько сложнее и так просто HTTP-коды использовать не стоит:

  1. Коды 404 и 500 в ответе очень часто подпадают под автоматическую обработку на клиенте, в клиентской бибилотеке. Чаще всего произойдет автоматический повторный запрос через несколько секунд. В логах сервера соответственно будет видно несколько вызовов и записано несколько трассировок с ошибкой.
  2. Коды 404, 400,405, 403,500 обрабатываются системами мониторинга для сайтов и вебсервисов — если какой-то из методов, проверяемый такой системой отдаст один из кодов ошибок — это появится в логе системы мониторинга, даже если ошибка вполне себе «бизнесовая» — в логике работы, а не является признаком сбоя.
  3. Код 403 «В доступе отказано» обрабатывается сканерами уязвимостей, которые сейчас работают автоматически. Может случиться что сканер найдет API отдающее код 403 и автоматически пойдет подбирать учетные данные, постоянно бомбардируя сервер запросами.
  4. Не поверите, но внезапно и API Gateway и обычный прокси, которые чаще всего будут стоять перед вашим сервисом с API — тоже программы, в которых тоже бывают ошибки. И эти ошибки абсолютно всегда обрабатываются через HTTP-коды вроде ваших любимых 500, 400 и 405.

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

Если вы про это не знали и Nginx заранее не настоили — увидите 500 либо 405 ошибку, которую выкинул сам Nginx, а не ваш сервис.

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

Что касается HTTP-кодов, то во всех случаях нужно отдавать стандартный 200 OK, за исключением каких-то серьезных ошибок, которые должны быть зарегистрированы мониторингом.

Вся валидация данных, включая проверку на существования, все проверки доступа — везде отдается 200 ОК, не 400, 405, 403.

Вот навороченный вариант, часто копируемый у меня из проекта в проект:

@ApiModel(description = "Общая модель ответа. Используется для выдачи ответа от всех управляющих методов.")
public static class Result implements Serializable {
      private static final long serialVersionUID = 1L;
      // код ответа
      private final int code; 
      // текстовое сообщение
      private final String message; 
      // дополнительные параметры
      private final Map<String, Object> flags = new TreeMap<>(); 
      private final Date createdAt = new Date();
      @JsonCreator
      public Result(@JsonProperty("code") int code,
              @JsonProperty("message") String message) {
          this.code = code; this.message = message;
      }
      @JsonCreator
      public Result(@JsonProperty("code") int code,
              @JsonProperty("message") String message,
              @JsonProperty("flags") Map<String, Object> flags) {
          this.code = code; this.message = message;
          this.flags.clear(); this.flags.putAll(flags);
      }
      @JsonProperty(access = Access.READ_ONLY)
      @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME)
      @ApiModelProperty(value = "дата и время генерации ответа")
      public Date getCreatedAt() {
          return createdAt;
      }
      @JsonProperty(access = Access.READ_ONLY)
      @JsonSerialize(using = IntToHexStringSerializer.class, as = Integer.class)
      @ApiModelProperty(value = "числовой код ответа")
      public int getCode() {
          return code;
      }
      @JsonProperty(access = Access.READ_ONLY)
      @ApiModelProperty(value = "текстовое сообщение ответа")
      public String getMessage() {
          return message;
      }
      @JsonProperty(access = Access.READ_ONLY)
      @ApiModelProperty(value = "дополнительные параметры ответа")
      public Map<String, Object> getFlags() {
          return flags;
      }
      public static Result of(int code) {
          // получить сообщение об ошибке без префикса
          final String message = SystemError.messageForPrefix(code, false);
          return new Result(code, message);
      }
      public static Result of(int code, Map<String, Object> flags) {
          // получить сообщение об ошибке без префикса
          final String message = SystemError.messageForPrefix(code, false);
          return new Result(code, message, flags);
      }
  }

Коды выглядят примерно вот так:

sgate.system.error.0x6013=Cannot delete user, user not found: {}
sgate.system.error.0x6014=No routers present
sgate.system.error.0x6015=User not found: {}
sgate.system.error.0x6016=User has no roles: {}
sgate.system.error.0x7001=ok
sgate.system.error.0x7002=bad secret
sgate.system.error.0x7003=requestId is blank.
sgate.system.error.0x7004=request not found

Дальше данные коды ошибок описываются в документации и используются на клиенте при обработке логики.

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

Это может выглядеть какой-то сильно ненужной работой, но представьте что у вас несколько тысяч таких REST-сервисов, еще и вызываемых через сложные цепочки один из другого и все станет понятно.

Сессии и токены

Забудьте про «публично доступные API»  — на дворе уже очень давно не 1970е, хиппи за компьютерами больше нет.

На данный момент отсутствие какой-либо идентификации пользователя это 100% риск DDOS-атак и перегрузки вашего API, причем быстро вы это не локализуете (из-за отсутствия идентификации) и не остановите.

И делают все это роботы, в автоматическом режиме а не живые люди.

В зависимости от задачи, вам будет нужно реализовать либо сессионную модель либо какой-то токен доступа.

В случае REST и Java это будет использование JWT-токена, передаваемого отдельным HTTP-заголовком либо механизма сессий в сервлет-контейнере.

Но самое главное:

должен быть механизм ограничения доступа для сессии или клиентского токена.

Ваш сервис, предоставляющий API должен уметь блокировать доступ для конкретного клиента.

Зачем такая необходимость?

Дело в том что цепочки вызовов API это самые настоящие современные потоки данных, а как известно, поток без контроля приводит к утечкам и наводнению.

Вообщем нельзя в 21м веке делать публичное API без контроля доступа, нельзя.

Раздавайте токены доступа к API после бесплатной регистрации, но они все равно должны быть, всегда.

Отключаемость и заглушки

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

По хорошему такая заглушка также должна включаться при слишком большом количестве запросов от конкретного клиента или при сбое внешних сервисов - например СУБД.

Зачем это надо?

Чтобы отделить сетевой сбой от внутреннего — одного HTTP 500 кода в нынешних реалиях уже не хватает, когда доходит до поиска проблем в работе распределенных сервисов.

Но есть еще одна проблема, из-за которой механизм заглушек и request rate стал обязательным: «восстания роботов».

Слишком просто стало ошибиться на стороне клиента и отправить слишком много запросов — много автоматики, много скрытой логики в уровнях абстракции сложных современных фреймворков.

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

Да кстати, отвечая на вопрос из зала почему одних кодов HTTP больше недостаточно: автоматическая обработка ошибок, везде и массово.

Поэтому если ваш сервис отдает например HTTP 500 — клиент подумает что на вашей стороне что-то сломалось и через какое-то время повторит запрос.

И будет повторять снова и снова, до победы.

Собственный клиент

Во всех случаях когда это возможно — создавайте самостоятельно клиентские библиотеки для вашего API.

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

Никакие инструкции, примеры и документация не помогут так как своя собственная клиентская библиотека.

Но это конечно не главный козырь.

Как думаете, какое количество ваших пользователей станут исправлять и дорабатывать свой клиентский код при обновлениях вашего API?

Я подскажу: ноль.

Нахер это никому не надо, «работает — не трожь!», «don’t fix what’s not broken» и все такое.

В реальности старым кодом будут пользоваться до тех пор пока он хоть как-то работает, поскольку доработка и исправления это риск и расходы.

Поэтому запомните золотое правило хорошего сервиса:

Создали свой облачный сервис? Создали к нему API? Сделайте и выложите готовый клиент и всем сразу будет хорошо.

А у вас пропадет головная боль по поддержке устаревших клиентов.

Итого

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

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

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

Если есть необходимость в подобной задаче - пишите, помогу.