Коммуникация правит миром. Взаимодействие необходимо и между людьми, и между программным обеспечением. Хотите адекватного ответа на ваш запрос к приложению? API вам в помощь! Необходимость в реализации API возникает практически во всех проектах, и со временем мы задумываемся, можно ли улучшить текущий API? Последовательность конкретных шагов и реальные примеры наш рецепт создания рабочего API-проекта.
Первый вопрос, который нужно задать: А точно ли стоит реализовать новую версию API? Возможно, уже работающая версия отвечает всем необходимым вам критериям. Но если вы уже для себя ответили да на поставленный вопрос, то читайте дальше и найдете наш ответ. Если вы ответили нет, то тоже читайте дальше и применяйте наш опыт проектирования и реализации в следующих ваших проектах.
Итак, какие же задачи могут стать поводом к разработке новой реализации API. В рамках развития программных продуктов, например, в сфере комплексной безопасности, как у нас, периодически появляется необходимость ускорить процесс клиентской и серверной разработки, или снизить временные затраты на поддержку и развитие API, или упростить поддержку обратной совместимости с помощью версионности API, а также добавить автогенерацию документации API. Или все эти задачи одновременно.
Какие шаги можно предпринять для решения этих задач, и как выйти на новый уровень зрелости API можно узнать в этой статье. Реальный алгоритм действий при разработке новой реализации API, описанный здесь, поможет в создании собственного проекта, а возможно станет предметом дискуссии. Комментарии профессионалов приветствуются!
Рассмотрим на примере API для работы с котиками то, как мы совершенствовали один из наших проектов.
Как понять, что стоит реализовать новую версию API
Для того чтобы понять, что API пора спасать, необходимо пройтись по следующим пунктам:
определить уровень зрелости API;
проверить, есть ли у API версионность;
проверить наличие документации API.
Разберем каждый пункт по отдельности, чтобы точно определиться, стоит ли игра свеч или и так сойдет.
Определим уровень зрелости API
Для этого идеально подходит модель Леонарда Ричардсона, в которой он выделяет четыре уровня зрелости API:
Уровень 0: Один URI и один HTTP метод (в основном метод POST);
Уровень 1: Несколько URI и один HTTP метод;
Уровень 2: Несколько URI, каждыи из которых поддерживает разные HTTP методы;
Уровень 3: HATEOAS. Ресурсы сами описывают свои возможности и взаимосвязи.
Если API соответствует 0 или 1 уровню зрелости, то определенно есть куда расти, потому что:
Если используется один URI, то не понятно с каким ресурсом работаем;
Не понятно, что делаем с ресурсом, так как используется один HTTP метод;
Использование одного URI создает трудности с документацией API;
Использование одного URI создает трудности с логированием входящих запросов;
Из-за использования одного URI, информация о типе ресурса передается в теле запроса.
Посчитаем, сколько если у нас получилось, и определим направление разработки. Это поможет сконцентрироваться на задачах и четко сформулировать дальнейшие шаги.
Проверим, есть ли у API версионность
Затем необходимо проверить версионность. Это позволит реализовывать изменения в текущем API, а при необходимости даст возможность включать расширения в новую версию API. Кроме того, она позволит серверу облегчить обеспечение обратной совместимости.
Благодаря версионности можно также ранжировать документацию по версиям, чтобы разработчики клиента всегда могли определить, насколько у них актуальная документация. Согласитесь, это удобно разработчикам, тестировщикам и сотрудникам технической поддержки.
Рассмотрим 3 основных способа версионирования API и разберем подробнее каждый из них. Современные разработчики выделяют следующие способы:
- Использование разных URI (Uniform Resource Identifier);
- Использование параметра запроса;
- Использование заголовка, Accept Header/Media Type.
Каждый из приведенных способов версионирования имеет свои плюсы и минусы. И для каждого конкретного случая реализации API необходимо оценить и выбрать оптимальный способ или их комбинацию.
Использование разных URI простой в проектировании, реализации и документировании способ версионирования. Однако он имеет целый ряд недостатков:
приводит к загрязнению URI, так как префиксы и суффиксы добавляются к основным строкам URI;
разбивает существующие URI, то есть все клиенты должны обновиться до нового;
приводит к увеличению размер HTTP кэша для хранения нескольких версии;
создает большое количество дубликатов URI, может снизить производительность приложения из-за увеличения количества обращении к кэшу;
является крайне негибким и не позволяет просто изменить ресурс или небольшой их набор.
Пример:
GET v1/cats/{name}
2.Использование параметра запроса позволяет легко документировать версионность и рекомендуется к использованию в случае, если важно HTTP кэширование. Однако и этот способ приводит к загрязнению пространства URI.
Пример:
GET cats/{name}?version=v1
3. Использование заголовка и Accept Header/Media Type также легко документировать и, в отличие от предыдущих способов не приводит к загрязнению пространства URI. Но и у этого способа выделяют несколько минусов:
-приводит к неправильному использованию заголовков, так как они изначально предусматривались не для версионирования;
-требует для управления версиями на основе заголовка и типа медиа использования таких инструментов, как Postman, или создания автоматизированных средств, добавляющих необходимый заголовок в HTTP запрос.
Пример:
GET cats/{name}
Headers: version=v1
Выбор конкретного способа целиком лежит на плечах разработчика, исходя из поставленной задачи и навыков. Каждый вариант вполне рабочий, но имеет свою специфику и особенности реализации.
Проверим наличие документации API
Даже к фену удобно иметь описание, не говоря уже о серьезной проекте разработки ПО. Поэтому наглядное описание API всегда удобно для использования как backend, так и frontend разработчиками. Документация может быть реализована, например, с помощью Swagger (фреймворк для спецификации RESTful API), он дает возможность не только интерактивно просматривать спецификацию, но и отправлять запросы с помощью Swagger UI:
Немного окунувшись в теорию и предварительный анализ, переходим непосредственно к проектированию и реализации API. Наше API отвечал следующим критериям:
соответствует 0 уровню зрелости (Один URI и один HTTP метод);
невозможно достоверно установить, с каким ресурсом API работает и какие функции выполняет;
отсутствуют автоматизированные средства документации API, что приводит к неполной документации API или ее полному отсутствию для некоторых запросов;
появляются сложности с поддержкой обратной совместимости, так как нет версионности API.
Для большего понимания того, что не нравилось, приведем пример того, как выглядел наш API.
Пример:
POST /cats - должен вернуть котика по имени Пушок (гарантируется, что у
requestBody: { котиков уникальные имена);
"name": "Pushok"
}
POST /cats - должен вернуть вернуть список белых котиков;
requestBody: {
"color": "white"
}
Цели реализации нового API
Если в предыдущем пункте мы уверенно выявили хотя бы один пункт в существующем API, то самое время переходить к реализации нового. Основными целями, которые преследует разработчики при создании API являются:
Ускорение процесса клиентской и серверной разработки;
Снижение временных затрат на поддержку и развитие API;
Добавление автогенерации документации API;
Поддержка версионности API для упрощения поддержки обратной совместимости с помощью версионности API.
Таким образом, проект получает автономность от его создателя, гибкость в масштабировании и апгрейде и, зачастую, серьезно выигрывает по производительности в целом.
Повышаем уровень зрелости API
Для решения вышеперечисленных проблем и достижения целей было принято решение о переходе на 2 уровень зрелости API. Это позволило понять, с каким ресурсом идет текущая работа и что с ним необходимо сделать, плюс появилась возможность документировать API.
Для того, чтобы повысить API с 0 на 2 уровень зрелости были проанализированы все текущие проблемы и выделены следующие пункты:
1.Разделение текущего API на смысловые части для выделения соответствующего ресурса для каждой части;
2.Использование методов, соответствующих действиям над ресурсами: GET, POST, PUT, DELETE;
3.Обозначение ресурса во множественном числе;
Пример:
GET /cats - должен вернуть список котиков
GET /cats/Pushok - должен вернуть котика по имени Пушок
(гарантируется, что у котиков уникальные имена)
4. Указание фильтрации в параметрах.
Пример:
GET /cats?color=white - должен вернуть список белых котиков
Добавляем версионность
После повышения зрелости API выходим на новый этап и выбираем способ версионирования. Для текущего проекта был выбран способ версионирования с использованием собственного заголовка. Это решение не попадает в пункт неправильное использование заголовков, так как будет использоваться собственный заголовок. Для удобства было решено указывать версии вида 2.n.
Для начала реализуем контроллер:
После этого для реализации версионности, создадим enum:
Далее создадим конвертер, используя внутренний Spring Framework интерфейс Converter<S,T>. Он преобразует исходный объект типа S в целевой типа T, в нашем случае преобразует текстовое значение версии в тип Enum ApiVersion до попадания в контроллер:
Если в заголовке было отправлено значение 2.0, до контроллера оно дойдет уже в виде v2. При такой реализации вместо того, чтобы перечислять все доступные версии, можно будет указать в заголовке, что ожидается enum. А контроллер после добавления версионирования будет выглядеть так:
При дальнейшем развитии API и реализации новых версии, список всех существующих версии будет храниться в enum. Когда для конкретной новой версии потребуется уникальная реализация, то в контроллере можно будет указать версию явно.
Указание версии было решено сделать обязательным.
Документируем
И наконец переходим к документированию. Критерии выбора конкретного инструмента были основаны на собственном опыте и дискуссии с коллегами-разработчиками. В ходе обсуждения был выбран популярный и широко используемый инструмент автогенерации API Swagger. Он не является единственным для решения задач документирования, но в нашем случае был признан оптимальным, так как бесплатен, прост для освоения и обладает необходимым набором функций для грамотного документирования API.
Рассмотрим пример реализации документации для созданного выше контроллера. Для этого реализуем интерфейс:
С его помощью можно добавить название и описание API текущего контроллера, описание каждого запроса, плюс есть возможность указать схему тела запроса. Остальную информацию Swagger получает из аннотаций контроллера.
Перейдя в Swagger UI увидим задокументированное API:
Получим более подробную информацию:
Преимущества новой версии API
На примерах выше был продемонстрирован переход с 0 уровня зрелости API на 2 уровень зрелости согласно модели Ричардсона, благодаря этому мы получили:
повышение уровня зрелости API, что дало понимание, с каким ресурсом мы работаем и что с ним делаем;
версионирование, которое позволит вносить изменения в текущий API;
удобное документирование и появление внятной документации, да и документации вообще.
В итоге и на стороне сервера, и на стороне клиента теперь можно увидеть актуальный API с полным описанием методов, моделей, кодов ответов, параметров запроса и версий. Это значительно упростит разработку новых версий в будущим и сэкономит время на разбор чужого кода.
Скорость разработки при переходе на новую версию API значительно возросла, ведь разработчикам стало значительно удобнее и приятнее работать. Наш путь разработки оказался вполне рабочим и его смело можно рекомендовать для применения в реальных проектах. А может в вашем арсенале есть другие варианты? Готовы обсудить!
import com.codeborne.selenide.Condition;import com.codeborne.selenide.WebDriverRunner;import org.testng.annotations.Test;import static com.codeborne.selenide.Selenide.*;public class RandomSheetTests { @Test void addUser() { open("https://ui-app-for-autotest.herokuapp.com/"); $("#loginEmail").sendKeys("test@protei.ru"); $("#loginPassword").sendKeys("test"); $("#authButton").click(); $("#menuMain").shouldBe(Condition.appear); $("#menuUsersOpener").hover(); $("#menuUserAdd").click(); $("#dataEmail").sendKeys("mail@mail.ru"); $("#dataPassword").sendKeys("testPassword"); $("#dataName").sendKeys("testUser"); $("#dataGender").selectOptionContainingText("Женский"); $("#dataSelect12").click(); $("#dataSelect21").click(); $("#dataSelect22").click(); $("#dataSend").click(); $(".uk-modal-body").shouldHave(Condition.text("Данные добавлены.")); WebDriverRunner.closeWebDriver(); }}
public class UsersPage { @FindBy(how = How.ID, using = "dataEmail") private SelenideElement email; @FindBy(how = How.ID, using = "dataPassword") private SelenideElement password; @FindBy(how = How.ID, using = "dataName") private SelenideElement name; @FindBy(how = How.ID, using = "dataGender") private SelenideElement gender; @FindBy(how = How.ID, using = "dataSelect11") private SelenideElement var11; @FindBy(how = How.ID, using = "dataSelect12") private SelenideElement var12; @FindBy(how = How.ID, using = "dataSelect21") private SelenideElement var21; @FindBy(how = How.ID, using = "dataSelect22") private SelenideElement var22; @FindBy(how = How.ID, using = "dataSelect23") private SelenideElement var23; @FindBy(how = How.ID, using = "dataSend") private SelenideElement save; @Step("Complex add user") public UsersPage complexAddUser(String userMail, String userPassword, String userName, String userGender, boolean v11, boolean v12, boolean v21, boolean v22, boolean v23) { email.sendKeys(userMail); password.sendKeys(userPassword); name.sendKeys(userName); gender.selectOption(userGender); set(var11, v11); set(var12, v12); set(var21, v21); set(var22, v22); set(var23, v23); save.click(); return this; } @Step("Fill user Email") public UsersPage sendKeysEmail(String text) {...} @Step("Fill user Password") public UsersPage sendKeysPassword(String text) {...} @Step("Fill user Name") public UsersPage sendKeysName(String text) {...} @Step("Select user Gender") public UsersPage selectGender(String text) {...} @Step("Select user variant 1.1") public UsersPage selectVar11(boolean flag) {...} @Step("Select user variant 1.2") public UsersPage selectVar12(boolean flag) {...} @Step("Select user variant 2.1") public UsersPage selectVar21(boolean flag) {...} @Step("Select user variant 2.2") public UsersPage selectVar22(boolean flag) {...} @Step("Select user variant 2.3") public UsersPage selectVar23(boolean flag) {...} @Step("Click save") public UsersPage clickSave() {...} private void set(SelenideElement checkbox, boolean flag) { if (flag) { if (!checkbox.isSelected()) checkbox.click(); } else { if (checkbox.isSelected()) checkbox.click(); } }}
@Test void addUser() { baseRouter.authPage() .complexLogin("test@protei.ru", "test") .complexOpenAddUser() .complexAddUser("mail@test.ru", "pswrd", "TESTNAME", "Женский", true, false, true, true, true) .checkAndCloseSuccessfulAlert(); }
@Test void addUserWithoutComplex() { //Arrange baseRouter.authPage() .complexLogin("test@protei.ru", "test"); //Act baseRouter.mainPage() .hoverUsersOpener() .clickAddUserMenu(); baseRouter.usersPage() .sendKeysEmail("mail@test.ru") .sendKeysPassword("pswrd") .sendKeysName("TESTNAME") .selectGender("Женский") .selectVar11(true) .selectVar12(false) .selectVar21(true) .selectVar22(true) .selectVar23(true) .clickSave(); //Assert baseRouter.usersPage() .checkTextSavePopup("Данные добавлены.") .closeSavePopup(); }
public class UsersPage { public Table usersTable = new Table(); public InputLine email = new InputLine(By.id("dataEmail")); public InputLine password = new InputLine(By.id("dataPassword")); public InputLine name = new InputLine(By.id("dataName")); public DropdownList gender = new DropdownList(By.id("dataGender")); public Checkbox var11 = new Checkbox(By.id("dataSelect11")); public Checkbox var12 = new Checkbox(By.id("dataSelect12")); public Checkbox var21 = new Checkbox(By.id("dataSelect21")); public Checkbox var22 = new Checkbox(By.id("dataSelect22")); public Checkbox var23 = new Checkbox(By.id("dataSelect23")); public Button save = new Button(By.id("dataSend")); public ErrorPopup errorPopup = new ErrorPopup(); public ModalPopup savePopup = new ModalPopup();}
@Test public void authAsAdmin() { baseRouter .authPage().email.fill("test@protei.ru") .authPage().password.fill("test") .authPage().enter.click() .mainPage().logoutButton.shouldExist(); }
public class AuthSteps{ private BaseRouter baseRouter = new BaseRouter(); @Step("Sigh in as {mail}") public BaseSteps login(String mail, String password) { baseRouter .authPage().email.fill(mail) .authPage().password.fill(password) .authPage().enter.click() .mainPage().logoutButton.shouldExist(); return this; } @Step("Fill E-mail") public AuthSteps fillEmail(String email) { baseRouter.authPage().email.fill(email); return this; } @Step("Fill password") public AuthSteps fillPassword(String password) { baseRouter.authPage().password.fill(password); return this; } @Step("Click enter") public AuthSteps clickEnter() { baseRouter.authPage().enter.click(); return this; } @Step("Enter should exist") public AuthSteps shouldExistEnter() { baseRouter.authPage().enter.shouldExist(); return this; } @Step("Logout") public AuthSteps logout() { baseRouter.mainPage().logoutButton.click() .authPage().enter.shouldExist(); return this; }}public class BaseRouter {// Класс для создания страниц, чтобы не дублировать этот код везде, где понадобится обращение к странице public AuthPage authPage() {return page(AuthPage.class);} public MainPage mainPage() {return page(MainPage.class);} public UsersPage usersPage() {return page(UsersPage.class);} public VariantsPage variantsPage() {return page(VariantsPage.class);}}
public class User { private Integer id; private String mail; private String name; private String password; private Gender gender; private boolean check11; private boolean check12; private boolean check21; private boolean check22; private boolean check23; public enum Gender { MALE, FEMALE; public String getVisibleText() { switch (this) { case MALE: return "Мужской"; case FEMALE: return "Женский"; } return ""; } }}
public class Users { public static final User admin = User.builder().mail("test@protei.ru").password("test").build();}
public static User getUserRandomData() { User user = User.builder() .mail(getRandomEmail()) .password(getShortLatinStr()) .name(getShortLatinStr()) .gender(getRandomFromEnum(User.Gender.class)) .check11(getRandomBool()) .check21(getRandomBool()) .check22(getRandomBool()) .check23(getRandomBool()) .build();//business-logic: 11 xor 12 must be selected if (!user.isCheck11()) user.setCheck12(true); if (user.isCheck11()) user.setCheck12(false); return user; }
public class ApiSettings { private static String loginEndpoint="/login"; public static RequestSpecification testApi() { RequestSpecBuilder tmp = new RequestSpecBuilder() .setBaseUri(testConfig.getSiteUrl()) .setContentType(ContentType.JSON) .setAccept(ContentType.JSON) .addFilter(new BeautifulRest()) .log(LogDetail.ALL); Map<String, String> cookies = RestAssured.given().spec(tmp.build()) .body(admin) .post(loginEndpoint).then().statusCode(200).extract().cookies(); return tmp.addCookies(cookies).build(); }}
.addFilter(new BeautifulRest())
:
public class BeautifulRest extends AllureRestAssured { public BeautifulRest() {} public Response filter(FilterableRequestSpecification requestSpec, FilterableResponseSpecification responseSpec, FilterContext filterContext) { AllureLifecycle lifecycle = Allure.getLifecycle(); lifecycle.startStep(UUID.randomUUID().toString(), (new StepResult()).setStatus(Status.PASSED).setName(String.format("%s: %s", requestSpec.getMethod(), requestSpec.getURI()))); Response response; try { response = super.filter(requestSpec, responseSpec, filterContext); } finally { lifecycle.stopStep(); } return response; }}
@Step("create user") public static User createUser(User user) { String usersEndpoint = "/user"; return RestAssured.given().spec(ApiSettings.testApi()) .when() .body(user) .post(usersEndpoint) .then().log().all() .statusCode(200) .body("state",containsString("OK")) .extract().as(User.class); }
public static Object create(String endpoint, Object model) { return RestAssured.given().spec(ApiSettings.testApi()) .when() .body(model) .post(endpoint) .then().log().all() .statusCode(200) .body("state",containsString("OK")) .extract().as(model.getClass()); } @Step("create user") public static User createUser(User user) { create(User.endpoint, user); }
@Test void checkUserVars() { //Arrange User userForTest = getUserRandomData(); // Проверка корректности сохранения полей уже есть в другом тесте, // этот тест проверяет отображение вариантов из-под залогинившегося юзера, // поэтому не важно, как юзер создан usersSteps.createUser(userForTest); authSteps.login(userForTest); //Act mainMenuSteps .clickVariantsMenu(); //Assert variantsSteps .checkAllVariantsArePresent(userForTest.getVars()) .checkVariantsCount(userForTest.getVarsCount()); //Cleanup usersSteps.deleteUser(userForTest); }
@Test void authAsAdmin() { authSteps.login(Users.admin);// Это всё, просто авторизовались под админом. Все действия и проверки внутри. // Не очень очевидно, не правда ли?
@Test(dataProvider = "usersWithDifferentVars") void checkUserDifferentVars(User userForTest) { //Arrange usersSteps.createUser(userForTest); authSteps.login(userForTest); //Act mainMenuSteps .clickVariantsMenu(); //Assert variantsSteps .checkAllVariantsArePresent(userForTest.getVars()) .checkVariantsCount(userForTest.getVarsCount()); } // Метод возвращает пользователей с полным перебором трех булевых параметров. // Предположим, это важное бизнес-требование. @DataSupplier(name = "usersWithDifferentVars") public Stream<User> usersWithDifferentVars(){ return Stream.of( getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(false), getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(false), getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(false), getUserRandomData().setCheck21(false).setCheck22(false).setCheck23(true), getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(false), getUserRandomData().setCheck21(true).setCheck22(false).setCheck23(true), getUserRandomData().setCheck21(false).setCheck22(true).setCheck23(true), getUserRandomData().setCheck21(true).setCheck22(true).setCheck23(true) ); }