JavaRush /Java блог /Random UA /Додаємо можливість передплатити групу статей. (Частина 1)...
Roman Beekeeper
35 рівень

Додаємо можливість передплатити групу статей. (Частина 1) - "Java-проект від А до Я"

Стаття з групи Random UA
Вітання! Сьогодні будемо додавати передплату на групу статей у JavaRush. Цьому відповідає issue JRTB-5 на GitHub. Поясню: у JavaRush є розділ Статті , а в ньому - Групи статей. Ідея полягає в тому, щоб через телеграм-бота отримувати повідомлення про нову статтю з однієї чи кількох груп."Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 1

Додаємо JRTB-5

Скажімо, мені цікаві статті із групи Історії успіху . Тому я хочу підписатися на оновлення цієї групи та отримувати щоразу посилання на нову публікацію. В рамках реалізації цього завдання нам потрібно навчитися користуватися відкритим API для роботи з групами JavaRush. Саме до цього моменту така річ наспіла. Ось посилання на опис відкритого API .
Друзі! Хочете відразу дізнаватися, коли вийде новий код проекту чи нова стаття? Приєднуйтесь до мого каналу . Там збираю свою статтю, думки та open-source розробку воєдино.

Що таке swagger? Зараз розберемося

Про сваггера ми ще не говорабо. Для тих, хто не знає, коротко поясню: це місце, де можна відкрито подивитися на API якогось сервера і спробувати виконати якісь запити до нього. Зазвичай у сваггері згруповано можливі запити. У нашому випадку є три групи: forum-question , group , post. У кожній групі буде один і більше запитів із зазначенням усіх необхідних даних, щоб цей запит побудувати (тобто які додаткові параметри можна передати, що з ними робити, який http метод, і так далі). Раджу почитати і подивитися більше з цієї теми, тому це частина розробки, з якою зіткнеться майже кожен з вас. Щоб розібратися, дізнаємося, скільки груп є в JavaRush. Для цього розкриємо групу group-controller та виберемо Get запит /api/1.0/rest/groups/count . Він поверне нам кількість груп у JavaRush. Дивимося: "Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 2На зображенні видно, що запит підтримує кілька параметрів (query, type, filter). Щоб спробувати цей запит на смак, потрібно знайти кнопку Try it out , після чого ці параметри можна буде налаштувати:"Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 3Також там можна налаштувати і тип, і фільтр, і кверю (тут взагалі цікаво: це буде пошук за текстом у групі). Але поки що запустимо без будь-яких обмежень і подивимося, скільки всього груп у JavaRush. Для цього натисніть на Execute. Трохи нижче буде відповідь (в секції Server Response) на цей запит: "Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 4Ми бачимо, що всього груп 30 , цей запит виконався за 95ms і є набір деяких хедерів у відповіді. Далі спробуємо налаштувати якісь параметри. Виберемо параметр type рівним значенню COMPANY і подивимося, як зміниться результат: "Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 54. Як це перевірити? Легко: можна піти на сайт, знайти секцію статті, вибрати всі групи і там додати відповідний фільтр ( https://javarush.com/groups/all?type=COMPANY ). І так, дійсно, їх всього 4. Хоча реально три :D"Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 6Поки що сходиться. До речі, якщо ми перевіримо університети, їх поки що немає. Заради інтересу подивіться, що буде, якщо поставити filter = MY у браузері, де ви залогінені в Javarush і не залогінені. Більше про сваггер — у цій статті на Хабрі .

Пишемо клієнта до Javarush API для груп

Тепер на основі відкритого API напишемо Java клієнта, який вміє робити запити, отримувати відповіді та знає, які саме об'єкти будуть надходити. Об'єкти також візьмемо із сваггера, із секції Models (у самому низу сторінки). Створимо новий пакет і назвемо його codegymclient поряд з service, repository. У майбутньому ми це винесемо до окремої бібліотеки в рамках організації Javarush Community і будемо використовувати виключно як залежність. Насамперед потрібно додати Unitrest — бібліотеку для створення http запитів до JavaRush API:
<dependency>
  <groupId>com.konghq</groupId>
  <artifactId>unirest-java</artifactId>
  <version>${unirest.version}</version>
</dependency>
І виносимо версію до блоку properties:
<unirest.version>3.11.01</unirest.version>
Коли з'явиться залежність, можна починати додавати код. Створимо клієнт для груп CodeGymGroupClient та реалізацію в клас CodeGymGroupClientImpl. Але спочатку потрібно створити DTO-шки (data transfer object) - тобто, класи, об'єкти яких будуть носити всі необхідні для клієнта дані. Всі моделі можна подивитися в сваггері, У самому низу є секція Models , в якій їх можна вважати. Ось як виглядає GroupDiscussionInfo у сваггері: "Java-проект від А до Я": Додаємо можливість підписатися на групу статей.  Частина 1 - 7У пакеті codegymclient створимо пакет dto , до якого додамо, на основі даних із сваггера, наступні класи:
  • MeGroupInfoStatus :

    package com.github.codegymcommunity.jrtb.codegymclient.dto;
    
    /**
    * Member group status.
    */
    public enum MeGroupInfoStatus {
       UNKNOWN, CANDIDATE, INVITEE, MEMBER, EDITOR, MODERATOR, ADMINISTRATOR, BANNED
    }

  • MeGroupInfo :

    package com.github.codegymcommunity.jrtb.codegymclient.dto;
    
    import lombok.Data;
    
    /**
    * Group information related to authorized user. If there is no user - will be null.
    */
    @Data
    public class MeGroupInfo {
       private MeGroupInfoStatus status;
       private Integer userGroupId;
    }

  • GroupInfoType :

    package com.github.codegymcommunity.jrtb.codegymclient.dto;
    
    /**
    * Group Info type;
    */
    public enum GroupInfoType {
       UNKNOWN, CITY, COMPANY, COLLEGE, TECH, SPECIAL, COUNTRY
    }

  • UserDiscussionInfo :

    package com.github.codegymcommunity.jrtb.codegymclient.dto;
    
    import lombok.Data;
    
    /**
    * DTO for User discussion info.
    */
    @Data
    public class UserDiscussionInfo {
    
       private Boolean isBookmarked;
       private Integer lastTime;
       private Integer newCommentsCount;
    }

  • GroupVisibilitysdtatus :

    package com.github.codegymcommunity.jrtb.codegymclient.dto;
    
    /**
    * Group Visibility status.
    */
    public enum GroupVisibilityStatus {
       UNKNOWN, RESTRICTED, PUBLIC, PROTECTED, PRIVATE, DISABLED, DELETED
    }

  • Потім GroupInfo :

    package com.github.codegymcommunity.jrtb.codegymclient.dto;
    
    import lombok.Data;
    import lombok.ToString;
    
    /**
    * Group Info DTO class.
    */
    @Data
    @ToString
    public class GroupInfo {
    
       private Integer id;
       private String avatarUrl;
       private String createTime;
       private String description;
       private String key;
       private Integer levelToEditor;
       private MeGroupInfo meGroupInfo;
       private String pictureUrl;
       private String title;
       private GroupInfoType type;
       private Integer userCount;
       private GroupVisibilityStatus visibilityStatus;
    }

Оскільки GroupInfo і GroupDiscussionInfo майже повністю збігаються, зв'яжемо їх у наслідуванні — GroupDiscusionInfo :
package com.github.codegymcommunity.jrtb.codegymclient.dto;

import lombok.Data;
import lombok.EqualsAndHashCode;
import lombok.ToString;

/**
* Group discussion info class.
*/
@EqualsAndHashCode(callSuper = true)
@Data
@ToString(callSuper = true)
public class GroupDiscussionInfo extends GroupInfo {

   private UserDiscussionInfo userDiscussionInfo;
   private Integer commentsCount;
}
Також нам знадобиться фільтр для запиту GroupFilter :
package com.github.codegymcommunity.jrtb.codegymclient.dto;

/**
* Filters for group requests.
*/
public enum GroupFilter {

   UNKNOWN, MY, ALL
}
У запиті на отримання ID-шника видає GroupDiscussionInfo, а в запиті на колекцію груп можна отримати як GroupInfo, так і GroupDiscussionInfo. Оскільки запити можуть мати type, query, filter, offset і limit, створимо окремий клас GroupRequestArgs і зробимо його класом-білдером (почитайте, що таке патерн builder):
package com.github.codegymcommunity.jrtb.codegymclient.dto;

import lombok.*;

import java.util.HashMap;
import java.util.Map;

import static java.util.Objects.nonNull;

/**
* Request arguments for group requests.
*/
@Builder
@Getter
public class GroupRequestArgs {

   private final String query;
   private final GroupInfoType type;
   private final GroupFilter filter;

   /**
    * specified where to start getting groups
    */
   private final Integer offset;
   /**
    * Limited number of groups.
    */
   private final Integer limit;

   public Map populateQueries() {
       Map queries = new HashMap<>();
       if(nonNull(query)) {
           queries.put("query", query);
       }
       if(nonNull(type)) {
           queries.put("type", type);
       }
       if(nonNull(filter)) {
           queries.put("filter", filter);
       }
       if(nonNull(offset)) {
           queries.put("offset", offset);
       }
       if(nonNull(limit)) {
           queries.put("limit", limit);
       }
       return queries;
   }
}
Для пошуку кількості груп він дещо відрізняється. У ньому є лише query, type та filter. І начебто, не хочеться дублювати код. Разом з тим, якщо почати об'єднувати їх, виходить некрасиво в роботі з білдерами. Тому я вирішив їх розділити та зробити повторення коду. Ось як виглядає GroupCountRequestArgs :
package com.github.codegymcommunity.jrtb.codegymclient.dto;

import lombok.Builder;
import lombok.Getter;

import java.util.HashMap;
import java.util.Map;

import static java.util.Objects.nonNull;

/**
* Request arguments for group count requests.
*/
@Builder
@Getter
public class GroupsCountRequestArgs {
   private final String query;
   private final GroupInfoType type;
   private final GroupFilter filter;

   public Map populateQueries() {
       Map queries = new HashMap<>();
       if (nonNull(query)) {
           queries.put("query", query);
       }
       if (nonNull(type)) {
           queries.put("type", type);
       }
       if (nonNull(filter)) {
           queries.put("filter", filter);
       }
       return queries;
   }
}
Так, я не згадав, що в останніх двох класах є метод populateQueries, який підготує карту для створення запиту (побачте його далі). Грунтуючись на описаних вище класах, створимо інтерфейс для CodeGymGroupClient :
package com.github.codegymcommunity.jrtb.codegymclient;

import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupRequestArgs;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupsCountRequestArgs;

import java.util.List;

/**
 * Client for Javarush Open API corresponds to Groups.
 */
public interface CodeGymGroupClient {

    /**
     * Get all the {@link GroupInfo} filtered by provided {@link GroupRequestArgs}.
     *
     * @param requestArgs provided {@link GroupRequestArgs}.
     * @return the collection of the {@link GroupInfo} objects.
     */
    List<GroupInfo> getGroupList(GroupRequestArgs requestArgs);

    /**
     * Get all the {@link GroupDiscussionInfo} filtered by provided {@link GroupRequestArgs}.
     *
     * @param requestArgs provided {@link GroupRequestArgs}
     * @return the collection of the {@link GroupDiscussionInfo} objects.
     */
    List<GroupDiscussionInfo> getGroupDiscussionList(GroupRequestArgs requestArgs);

    /**
     * Get count of groups filtered by provided {@link GroupRequestArgs}.
     *
     * @param countRequestArgs provided {@link GroupsCountRequestArgs}.
     * @return the count of the groups.
     */
    Integer getGroupCount(GroupsCountRequestArgs countRequestArgs);

    /**
     * Get {@link GroupDiscussionInfo} by provided ID.
     *
     * @param id provided ID.
     * @return {@link GroupDiscussionInfo} object.
     */
    GroupDiscussionInfo getGroupById(Integer id);
}
Два різні запити на випадок, коли ми хочемо отримати інформацію GroupInfo або GroupDiscussionInfo додав. В іншому ці запити тотожні, і різниця буде лише в тому, що в одному прапорі includeDiscussion буде true, а в іншому - false. Тому вийшло 4 методи, а не три. Тепер приступимо до реалізації:
package com.github.codegymcommunity.jrtb.codegymclient;

import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupRequestArgs;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupsCountRequestArgs;
import kong.unirest.GenericType;
import kong.unirest.Unirest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.util.List;

/**
* Implementation of the {@link CodeGymGroupClient} interface.
*/
@Component
public class CodeGymGroupClientImpl implements CodeGymGroupClient {

   private final String codegymApiGroupPath;

   public CodeGymGroupClientImpl(@Value("${codegym.api.path}") String codegymApi) {
       this.codegymApiGroupPath = codegymApi + "/groups";
   }

   @Override
   public List<GroupInfo> getGroupList(GroupRequestArgs requestArgs) {
       return Unirest.get(codegymApiGroupPath)
               .queryString(requestArgs.populateQueries())
               .asObject(new GenericType<list<GroupInfo>>() {
               })
               .getBody();
   }

   @Override
   public List<GroupDiscussionInfo> getGroupDiscussionList(GroupRequestArgs requestArgs) {
       return Unirest.get(codegymApiGroupPath)
               .queryString(requestArgs.populateQueries())
               .asObject(new GenericType<list<GroupDiscussionInfo>>() {
               })
               .getBody();
   }

   @Override
   public Integer getGroupCount(GroupsCountRequestArgs countRequestArgs) {
       return Integer.valueOf(
               Unirest.get(String.format("%s/count", codegymApiGroupPath))
                       .queryString(countRequestArgs.populateQueries())
                       .asString()
                       .getBody()
       );
   }

   @Override
   public GroupDiscussionInfo getGroupById(Integer id) {
       return Unirest.get(String.format("%s/group%s", codegymApiGroupPath, id.toString()))
               .asObject(GroupDiscussionInfo.class)
               .getBody();
   }


}
Шлях до АПИшке я додаю в конструкторі, використовуючи вже знайому нам інструкцію Value. Вона має на увазі, що значення всередині інструкції відповідає полю у файлі з пропертями. Тому додамо до application.properties новий рядок:
codegym.api.path=https://codegym.com/api/1.0/rest
Тепер це значення буде в одному місці для всіх клієнтів API, і якщо шлях до API зміниться, ми його швидко оновимо. Раніше я забивав цвяхи мікроскопом, отримував відповідь з http-запиту через Unirest, перекладав його в рядок і потім цей рядок парсив через Jackson… Це було страшно, нудно і вимагало багатьох додаткових речей. У цій бібліотеці можна побачити, як це виглядає. Як дійдуть руки — відрефактор все.
У кого буде бажання спробувати оновити цю бібліотеку — додати одержання об'єктів лише за допомогою інструментів бібліотеки unirest — пишіть у личку або як нове issue у самій бібліотеці. Вам це буде реальний досвід роботи, а мені не шкода. Проведу повноцінний code review та допоможу, якщо потрібно буде.
Тепер питання: а чи працює наш код так, як ми очікуємо? Відповісти нескладно: потрібно просто написати тести для них. Як я вже неодноразово говорив, розробники повинні вміти писати тести. Тому, користуючись нашим Swagger UI, відправлятимемо запити, дивитися відповіді та підставлятимемо їх у тести як очікуваний результат. Ви могли відразу помітити, що кількість груп не статична і може змінюватись. І ви маєте рацію. Питання лише в тому, як часто ця кількість змінюється? Дуже рідко, тож у розрізі кількох місяців можна стверджувати, що це значення буде статичним. А якщо щось зміниться, оновимо випробування. Зустрічайте - CodeGymGroupClientTest:
package com.github.codegymcommunity.jrtb.codegymclient;

import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupDiscussionInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupInfo;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupRequestArgs;
import com.github.codegymcommunity.jrtb.codegymclient.dto.GroupsCountRequestArgs;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;

import java.util.List;

import static com.github.codegymcommunity.jrtb.codegymclient.dto.GroupInfoType.TECH;

@DisplayName("Integration-level testing for CodeGymGroupClientImplTest")
class CodeGymGroupClientTest {

   private final CodeGymGroupClient groupClient = new CodeGymGroupClientImpl("https://javarush.com/api/1.0/rest");

   @Test
   public void shouldProperlyGetGroupsWithEmptyArgs() {
       //given
       GroupRequestArgs args = GroupRequestArgs.builder().build();

       //when
       List<GroupInfo> groupList = groupClient.getGroupList(args);

       //then
       Assertions.assertNotNull(groupList);
       Assertions.assertFalse(groupList.isEmpty());
   }

   @Test
   public void shouldProperlyGetWithOffSetAndLimit() {
       //given
       GroupRequestArgs args = GroupRequestArgs.builder()
               .offset(1)
               .limit(3)
               .build();

       //when
       List<GroupInfo> groupList = groupClient.getGroupList(args);

       //then
       Assertions.assertNotNull(groupList);
       Assertions.assertEquals(3, groupList.size());
   }

   @Test
   public void shouldProperlyGetGroupsDiscWithEmptyArgs() {
       //given
       GroupRequestArgs args = GroupRequestArgs.builder().build();

       //when
       List<GroupDiscussionInfo> groupList = groupClient.getGroupDiscussionList(args);

       //then
       Assertions.assertNotNull(groupList);
       Assertions.assertFalse(groupList.isEmpty());
   }

   @Test
   public void shouldProperlyGetGroupDiscWithOffSetAndLimit() {
       //given
       GroupRequestArgs args = GroupRequestArgs.builder()
               .offset(1)
               .limit(3)
               .build();

       //when
       List<GroupDiscussionInfo> groupList = groupClient.getGroupDiscussionList(args);

       //then
       Assertions.assertNotNull(groupList);
       Assertions.assertEquals(3, groupList.size());
   }

   @Test
   public void shouldProperlyGetGroupCount() {
       //given
       GroupsCountRequestArgs args = GroupsCountRequestArgs.builder().build();

       //when
       Integer groupCount = groupClient.getGroupCount(args);

       //then
       Assertions.assertEquals(30, groupCount);
   }

   @Test
   public void shouldProperlyGetGroupTECHCount() {
       //given
       GroupsCountRequestArgs args = GroupsCountRequestArgs.builder()
               .type(TECH)
               .build();

       //when
       Integer groupCount = groupClient.getGroupCount(args);

       //then
       Assertions.assertEquals(7, groupCount);
   }

   @Test
   public void shouldProperlyGetGroupById() {
       //given
       Integer androidGroupId = 16;

       //when
       GroupDiscussionInfo groupById = groupClient.getGroupById(androidGroupId);

       //then
       Assertions.assertNotNull(groupById);
       Assertions.assertEquals(16, groupById.getId());
       Assertions.assertEquals(TECH, groupById.getType());
       Assertions.assertEquals("android", groupById.getKey());
   }
}
Тести написані в такому ж стилі, як раніше. Є по кілька тестів на кожен запит. Тестувати все не має сенсу, тому що я думаю, що цей АПІ і так уже відтестовано найкращим чином.

Висновок

У рамках цієї статті ми додали клієнта Java для груп до JavaRush API. Як то кажуть, вік живи — вік навчайся. Поки писав цього клієнта, скористався їхньою документацією і зручно використовував роботу з об'єктами, яку вони надають. Звертаю увагу на завдання, яке я запропонував. Кому цікаво — пишіть особистим повідомленням, я більш ніж впевнений, що це буде цікавий досвід. То була перша частина. У другій ми реалізуємо безпосередньо команду з додавання та (якщо вкладемося в одну статтю) додамо отримання списку груп, на яких користувач підписаний. Далі, хто має бажання і талант до написання текстів для бота, прошу написати мені в ЛЗ. Я не фахівець у цій справі, і будь-яка допомога мені буде дуже доречною. Оформимо все це як розробку в open source, буде цікаво! Ну і як завжди -лайк, передплата, дзвіночок , став зірку нашому проекту , пиши коментарі та оцінюй статтю!
Корисні посилання

Список всіх матеріалів серії на початку цієї статті.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ