JavaRush /Блог /Java-проекты /Добавляем возможность подписаться на группу статей. (Част...
Roman Beekeeper
35 уровень

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

Статья из группы Java-проекты
Привет! Сегодня будем добавлять подписку на группу статей в 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 - 5Их 4. Как это проверить? Легко: можно пойти на сайт, найти секцию статьи, выбрать все группы и там добавить соответствующий фильтр (https://javarush.com/groups/all?type=COMPANY). И да, действительно, их всего 4. Хотя реально три :D“Java-проект от А до Я”: Добавляем возможность подписаться на группу статей. Часть 1 - 6Пока что сходится. К слову, если мы проверим университеты, то их пока что нет. Ради интереса посмотрите, что будет, если поставить filter = MY в браузере, где вы залогинены в Javarush и не залогинены. Больше о сваггере — в этой статье на Хабре.

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

Теперь на основе открытого API напишем Java клиента, который умеет делать запросы, получать ответы и знает, какие именно объекты будут приходить. Объекты также возьмем из сваггера, из секции Models (в самом низу страницы). Создадим новый пакет и назовем его javarushclient рядом с 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>
Когда у нас появится зависимость, можно начинать добавлять код. Создадим клиент для групп JavaRushGroupClient и реализацию в класс JavaRushGroupClientImpl. Но сперва нужно создать DTO-шки (data transfer object) — то есть, классы, объекты которых будут носить все нужные для клиента данные. Все модели можно посмотреть в сваггере, В самом низу есть секция Models, в которой можно их считать. Вот как выглядит GroupDiscussionInfo в сваггере:“Java-проект от А до Я”: Добавляем возможность подписаться на группу статей. Часть 1 - 7В пакете javarushclient создадим пакет dto, в который добавим, на основе данных из сваггера, следующие классы:
  • MeGroupInfoStatus:

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

  • MeGroupInfo:

    
    package com.github.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.dto;
    
    /**
    * Group Info type;
    */
    public enum GroupInfoType {
       UNKNOWN, CITY, COMPANY, COLLEGE, TECH, SPECIAL, COUNTRY
    }
    

  • UserDiscussionInfo:

    
    package com.github.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.dto;
    
    /**
    * Group Visibility status.
    */
    public enum GroupVisibilityStatus {
       UNKNOWN, RESTRICTED, PUBLIC, PROTECTED, PRIVATE, DISABLED, DELETED
    }
    

  • Потом — GroupInfo:

    
    package com.github.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.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, который подготовит мапу для создания запроса (увидите его далее). Основываясь на описанных выше классах, создадим интерфейс для JavaRushGroupClient:

package com.github.javarushcommunity.jrtb.javarushclient;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupsCountRequestArgs;

import java.util.List;

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

    /**
     * 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.javarushcommunity.jrtb.javarushclient;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.javarushclient.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 JavaRushGroupClient} interface.
*/
@Component
public class JavaRushGroupClientImpl implements JavaRushGroupClient {

   private final String javarushApiGroupPath;

   public JavaRushGroupClientImpl(@Value("${javarush.api.path}") String javarushApi) {
       this.javarushApiGroupPath = javarushApi + "/groups";
   }

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

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

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

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


}
Путь к АПИшке я добавляю в конструкторе, используя уже знакомую нам аннотацию Value. Она подразумевает, что значение внутри аннотации соответствует полю в файле с пропертями. Поэтому добавим в application.properties новую строку:

javarush.api.path=https://javarush.com/api/1.0/rest
Теперь это значение будет находиться в одном месте для всех клиентов АПИ, и если путь к АПИ изменится, мы его быстро обновим. Раньше я забивал гвозди микроскопом получал ответ из http-запроса через Unirest, переводил его в строку и потом эту строку парсил через Jackson… Это было страшно, муторно и требовало многих дополнительных вещей. В этой библиотеке можно посмотреть, как это выглядит. Как дойдут руки — отрефакторю все.
У кого будет желание попробовать обновить эту библиотеку — добавить получение объектов только при помощи инструментов библиотеки unirest — пишите в личку или как новое issue в самой библиотеке. Для вас это будет реальный опыт работы, а мне не жалко. Проведу полноценный code review и помогу, если нужно будет.
Теперь вопрос: а работает ли наш код так, как мы ожидаем? Ответить несложно: нужно просто написать тесты для них. Как я уже не раз говорил, разработчики должны уметь писать тесты. Поэтому, пользуясь нашим Swagger UI, будем отправлять запросы, смотреть ответы и подставлять их в тесты как ожидаемый результат. Вы могли сразу заметить, что количество групп не статическое и может меняться. И вы правы. Вопрос лишь в том, насколько часто это количество меняется? Очень редко, так что в разрезе нескольких месяцев можно утверждать, что это значение будет статично. А если что-то изменится, обновим тесты. Встречайте — JavaRushGroupClientTest:

package com.github.javarushcommunity.jrtb.javarushclient;

import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupDiscussionInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupInfo;
import com.github.javarushcommunity.jrtb.javarushclient.dto.GroupRequestArgs;
import com.github.javarushcommunity.jrtb.javarushclient.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.javarushcommunity.jrtb.javarushclient.dto.GroupInfoType.TECH;

@DisplayName("Integration-level testing for JavaRushGroupClientImplTest")
class JavaRushGroupClientTest {

   private final JavaRushGroupClient groupClient = new JavaRushGroupClientImpl("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, будет интересно! Ну и как обычно — лайк, подписка, колокольчик, ставь звезду нашему проекту, пиши комментарии и оценивай статью!
Полезные ссылки

Список всех материалов серии в начале этой статьи.

Комментарии (12)
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ
Anonymous #2856674 Уровень 18
11 апреля 2024
Народ, имейте ввиду, что когда вы авторизированы на javarush, то количество возвращаемых групп будет отличаться от случая, когда вы не авторизированы В моем случае в браузере было 64, а запрос возвращал 27 - это норм. Там левые группы на иностранных языках выводястся Написал в техподдержку вопрос, почему так
Андрей Иванов Уровень 27
13 ноября 2022
Может не ебать себе мозги и просто подписаться по rss ?
Ivan Уровень 41
29 июля 2021
Роман, все-таки интересно зачем было вводить класс GroupCountRequestArgs с дублированием кода, это описание: Для поиска количества групп он несколько отличается. В нем есть только query, type и filter. И казалось бы, не хочется дублировать код. Вместе с тем, если начать их объединять, получается некрасиво в работе с билдерами. Поэтому я решил их разделить и сделать повторение кода. Не совсем объясняет, что некрасивого получается при работе с билдерами. В своем варианте сделал только 1 класс, все работает без проблем, и нет дублирование кода
Roman Beekeeper Уровень 35
22 апреля 2021
UPD: класс JavaRushGroupClient перепроверьте здесь: https://github.com/javarushcommunity/javarush-telegrambot/blob/main/src/main/java/com/github/javarushcommunity/jrtb/javarushclient/JavaRushGroupClient.java
Dmitry Nevar Уровень 26
19 апреля 2021
Всем привет! У меня появилась непреодолимая проблема и не могу толково погуглить в чем причина( Была предпосылка запустить приложение через VPN (proxy) не помогло, все случилось после второго "угона" токена от бота, сделал revoke (в первый раз помогло, бот вернулся под мой контроль)
bogatovnikita Уровень 33
9 апреля 2021
дождусь полного проекта и сделаю, но все равно посматриваю. Спасибо за труды!