Уродливый API

Вступление

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

При разработке фронтенд приложений (Mobile/Web), часто сталкиваешься с тем, что API на бэкенде еще не реализован. Приходится ждать разработчика на бэкенде, когда он предоставит нужные запросы, постоянно напоминая ему о себе. Не редкость и другая ситуация, когда нужные http запросы уже есть, но они реализованы очень плохо и криво.

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

В этом проекте я разрабатываю мобильное приложение на Flutter, используя пакет Retrofit, который помогает мне сократить время и код, который приходится писать самому, генерируя значительный код автоматически. Так же использую Insomnia, для первоначальной проверки запросов до реализации их в коде.

Прежде чем я приступлю к проблемам, я хотел бы отметить, что до того, как я стал мобильным разработчиком, я работал разработчиком Full-Stack более 5 лет. И я понимаю, насколько важно реализовывать красивый API для frontend, с которым легко и приятно интегрироваться и безопасно, в случае будущих изменений в API.

Видео версия статьи

Итак, начнем.

Не-RESTful архитектура

Первым огорчением стал тот факт, что API архитектура реализована не в стиле RESTful. Честно сказать, я и не помню, когда приходилось сталкиваться с интеграцией таких не-RESTful APIs.

REST означает REpresentational State Transfer. RESTful — вид реализации архитектуры API, которая наилучшим образом позволяет использовать протокол HTTP. С REST нам нужно думать о приложении с точки зрения ресурсов. Определить, какие ресурсы мы хотим открыть для внешнего мира (например, tasks, customers, etc.). Используем глаголы, определенные протоколом HTTP, для выполнения CRUD операций с этими ресурсами, т.к. GET, POST, PUT, DELETE.

Пример RESTful API:

Архитектура API моего проекта — не REST архитектура — выглядит следующим образом:

Можно заметить, что все запросы имеют один тип, это POST запросы. И каждый запрос должен содержать параметр type, который определяет операцию. И, видимо, на сервере, в этом одном-единственном endpoint’е имеется какой-то if-else или switch операторы на проверку этого type параметра.

Я, конечно, предпочитаю RESTful, но раз уж другая реализация нормально работает, и на клиенте можно более-менее настроить запросы с Retrofit, я принял этот факт и продолжил интегрироваться с API

Header Accept: application/json

Обычно сначала я оформляю запросы на сервер в Insomnia, проверяю их, и после реализую их в коде. 

В Insomnia, когда я выполнил запрос, во вкладке Preview увидел красивый json и подумал, что ко мне приходит json объект. Я оформил все это в коде, Retrofit мне конвертирует ответ запроса в Dart объект автоматически, и все замечательно сработало.

Но на следующий день моё приложение перестало работать из-за ошибки, связанной с запросом к серверу. Текст ошибки был: “не получается преобразовать строку в объект”.

 Я снова вернулся к Insomnia и проверил запрос. В Preview я увидел тот же json, что и раньше. И только после проверки Header запроса я обнаружил, что Content-Type изменился, и значение его уже text/html, charset-utf-8, хотя Preview показывает мне json. 

Таким образом, я определил, что тип ответа от сервера изменился с application/json на text/html, из-за чего Retrofit уже не может преобразовать ответ от сервера типа строки автоматически в Dart объект.

Я решил попробовать использовать Accept Header в запросе, который скажет серверу, что “я - клиент ожидаю от тебя ответ в формате json”. Но это не сработало, т.к. сервер не берет в расчет этот Header Accept.

Тут мне снова пришлось внести ещё больше кода, чтобы обойти эту проблему с типом ответа сервера:

  1. Добавил новую версию запроса в Retrofit, где во второй версии я ожидаю тип “строка”

  1. Далее начал вызывать новую версию запроса, которая возвращает строку, и добавил преобразование строки в объект. Между тем, это преобразование Retrofit мог бы произвести самостоятельно в своем сгенерированном коде. А сейчас в каждом месте, где мы выполняем запросы на сервер, получая ответы, мы должны добавлять это преобразование сами:

В итоге, добавилось ещё больше строк кода, что никак не радует.

JSON keys case types

Бывает несколько видов оформления названий полей в json:

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

В Dart в идеале для именования полей класса и методов используется camelCase стиль. И если от сервера приходит json с полями в camelCase стиле, то Retrofit автоматически, без усилий с нашей стороны, правильно сопоставит поля класса и json. Но в данной ситуации мне приходится указывать дополнительные аннотации JsonKey, которые в одном случае как snake_case, а в другом —  UPPER_CASE_SNAKE_CASE:

В итоге — нет единообразия, если ошибёшься в case type, то можешь потерять значение, т.к. поля json в Dart класса не сопоставятся. Я разозлился еще больше на API, но интеграцию с API всё же можно было продолжать, затрачивая больше усилий и ещё больше кода.

Различный ответ на один и тот же запрос

Когда я попробовал выполнить вход в приложение под другим пользователем, моё приложение снова сломалось. Я получил сообщение об ошибке типа “Невозможно преобразовать строку в число”. Это было связано с тем, что мой класс, описывающий ответ от сервера, стал неверным. Типы, описанные в классе, не соответствуют типам полей, имеющимся в json ответе. Я снова открыл Insomnia, и выполнил один и тот же запрос входа для двух разных пользователей. И обнаружил, что в одном случае числа приходят в ответе в виде строк, а в другом — в виде чисел.  В Insomnia можно заметить, что строки выделены желтым цветом, а числа — фиолетовым:

Как вообще может быть, что на один и тот же запрос приходит один и тот же json, но с разными типами значений? Неужели на бэкенде стоит условие на пользователя и нужно возвращать разный json? Зачем? Как?

Я знаю, что сервер написан на PHP. Господа, кто программирует на PHP, могли бы вы написать в комментариях, как такое возможно?

Заключение

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

Хотелось бы узнать о вашем опыте интеграции с API, и с какими проблемами вы сталкивались. 

Спасибо. Happy coding!

Комментарии 21

    +4

    Ну, первый пункт – это просто ваши личные вкусы. По мне, так наоборот, RESTful стал чересчур модным, и его теперь пытаются запихнуть везде, хотя во многих проектах – это натягивания совы на глобус. Не говоря уж о том, что RESTful – это не только и не столько про GET/POST/PUT/DELETE.


    Но остальное – это тоска и безысходность, да.

      –1
      Как вообще может быть, что на один и тот же запрос приходит один и тот же json, но с разными типами значений? Неужели на бэкенде стоит условие на пользователя и нужно возвращать разный json? Зачем? Как?

      Да все банально, не нужно паники, из БД числовое значение может прийти в текстовом виде. Для одного пользователя может потребоваться какая-то обработка этого значения, и его обрабатывают, переводя в int. Для другого — не потребовалось, так текстом и уехало на вывод.
      Выброс JSON применен без преобразования типов JSON_NUMERIC_CHECK
        0

        Разные форматы ответа в php могут быть, так как сформировать json можно из каких угодно данных. В данном случае, я предположу, что бэкенд в одном случае привел все поля к строкам, в другом случае оставил те типы, которые должны быть у полей. В любом случае это баг или кривые руки того кто писал бэкенд.

          0
          У меня более экзотический, но реально существующий вариант из жизни: на бэкэнде минимум 2 сервера. На одном нормальный кастинг строки в int на уровне php/mysql, на втором расширение php-intl недоустановили, либо MariaDB, либо (… и т.д.). Обычное дело при распределении нагрузки, либо по Insomnia то к одному стучишься, то к другому. При одинаковой кодовой базе. А ваш вариант мне кажется нежизнеспособным, раз автор говорит про один и тот же метод, но с разными пользователями.
            0
            Пример: yii2 билдером из БД числа вытягиваются как строки, через актив рекордз — числами (сейчас как не знаю, но на одном проекте такое у нас когда-то было). При этом не всегда так и многое зависит от приведения к типам и строгой типизации. Описанный случай в статье — это недосмотр(баг) и отсутствие элементарной документации на апишку (где было бы указано формат возвращаемых значений) и дело тут совсем не в том, что айпи плохое, а в том, что бекенд делает люди без связки с фронтом (мобильным приложением) и это больше организационная проблема, т.к. это обычные «детские» проблемы при разработке, которые и решаются либо тестами (оптимальный вариант) или тестировщиками, которыми могут быть как и фронтендер, так и пользователи и т.д.

            Кстати, такое поведение легко определить по коду бекенда, такие программисты очень любят == вместо ===. Большинство проектов из «тёплых стран» на гитхабе таким страдают.
            0
            В принципе более-менее понятно почему API в таком виде.

            RESTful API — не могу сказать что это общепринятый стандарт. Порой необходимый функционал натянуть на GET/POST/PUT/DELETE — весьма нетривиальная задача.

            Использование snake case не должно смущать, это стандарт основной библиотеки PHP. Несмотря на то, что сейчас в основном методы и т.д. пишутся в camelCase — то что был выбран snake_case для API — ничего удивительного. Несоответствие с заглавными буквами пожалуй тоже понимаю откуда взялось: скорее всего значения в корне прописаны программно, а значения с заглавными буквами — это какой-то select из базы, и там поля хранятся именно так.

            Различный тип для числа (string и int) — PHP-шники до недавнего времени не особо запаривались с типами.

            Если спец по бэкэнду работает в вашей же команде — решить вопросы с int, возвращением заголовка application/json и т.д. — это должно быть просто. Если же вы работаете с внешним API — то ожидать от него любой дичи даже если на первый взгляд всё выглядит хорошо — абсолютно нормально. Гораздо меньше шансов, что какое-то не то значение сломает ваше приложение.
              –1
              классика жанра делаешь классический rest по всем канонам — потом приходят мобильшики и начинают требовать чтоб вот здесь еще и это выдавалась, подключают кого-нить по главнее и начинаешь творить такую жесть чтоб только они все отвалили. А через полгода наблюдаешь не API а какого-то мутанта а
                –1
                не исключено, но в моем случае, я до мобильной разработки писал более 5 лет REST/GraphQL сервисы и знаю как красиво их писать, и я бы хотел помочь выправить его API
                0

                Числа текстового типа как-то сам генерировал в своем первом приложении. Все загвоздка в том что драйвер MySQL числовые типы возвращает как строку и это было для меня самого было полной неожиданностью.
                Когда используются сильные orm вроде Doctrine эта проблема уходит.

                  0
                  Выше пример с yii2 привёл, сами с таким сталкивались, до 7-ки это особой проблемой не было, а потом как начали внедрять строгую типизацию оно стало серьезной проблемой.
                    0
                    PDO + PDO::ATTR_EMULATE_PREPARES => false + PDO::ATTR_STRINGIFY_FETCHES => false должно решить проблему без доктрины.
                    +1

                    Ещё часто бывает, что если есть данные, то возвращается массив, а если нет, то пустой объект. Руки бы ломал за такое.

                      0

                      Ксати интересный вопрос как унифнцировать массивы (если это например поле объекта) возвращать пустой массив, не возвращать ничего или возвращать null?

                        0
                        можно null, но я бы возвращал пустой массив
                          0

                          Пустые массивы легче потом на фронтенде обрабатывать согласен. Так как кругом идет просто перебор элеметов и не нужны эти прверки на null. Но вот если вдруг захочетс тербоания на не пустоту массива добавить (хоть при вводе значений, хоть при выводе) то тут начнутся траблы.

                      0
                      RESTful — вид реализации архитектуры API, которая наилучшим образом позволяет использовать протокол HTTP. С REST нам нужно думать о приложении с точки зрения ресурсов. Определить, какие ресурсы мы хотим открыть для внешнего мира (например, tasks, customers, etc.). Используем глаголы, определенные протоколом HTTP, для выполнения CRUD операций с этими ресурсами, т.к. GET, POST, PUT, DELETE.

                      Я узнаю эту копипасту из блогов и статей, которые перепечатывают одно и то же друг у друга (часто — одинаковыми предложениями) не приводя ни ссылок, ни обоснований ;)

                        0
                        я думаю, многие уже знают про REST и работали с ним, и это статья не туториал.
                        0

                        REST и RPC вполне сосуществуют и дополняют друг друга и там уже в зависимости от того что за апи мы делаем, где-то лучше подходит одно, где-то другое. тут автор явно хотел сделать RPC, но не знал слышал про JSON-RPC, а вдохновлялся по-моему вообще вордпрессом :)


                        Upper case snake case чаще называют screaming snake case, а почему так – элементарно. в коде проще набирать простым snake case потому что для этого не нужно caps lock включать. а в upper case поля в табличке заведены в базе… ну а на клиента это выливается без попытки привести это в какой-то божеский вид, потому что «и так сойдёт»…


                        различные поля в ответах для разных пользователей – это интересная задачка! я бы предположил что в зависимости от какого-то свойства пользователя вызывается какая-то функция, которая превращает все значения в строки. ¯\(ツ)

                          0
                          Может быть там какая-то монга, в которую в разные моменты времени писали то строки, то числа и она честно отдает то, что в ней лежит
                          0
                          а причем здесь «разработка под ios»?
                            0
                            А сейчас в каждом месте, где мы выполняем запросы на сервер, получая ответы, мы должны добавлять это преобразование сами:
                            Пакет retrofit использует dio, поэтому можно добавить Interceptor или Transformer что бы преобразовывать ответ в мапу или хидеры в application/json

                            Возможно будет удобнее так, чем везде заменять

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

                            Самое читаемое