JavaRush /Java Blog /Random-KO /기사 그룹을 구독하는 기능을 추가하고 있습니다. (1부) - "Java 프로젝트 A부터 Z까지"
Roman Beekeeper
레벨 35

기사 그룹을 구독하는 기능을 추가하고 있습니다. (1부) - "Java 프로젝트 A부터 Z까지"

Random-KO 그룹에 게시되었습니다
안녕하세요! 오늘 우리는 JavaRush의 기사 그룹에 구독을 추가할 것입니다. 이는 GitHub의 JRTB-5 문제 에 해당합니다 . 설명하겠습니다. JavaRush에는 Articles라는 섹션이 있고 그 안에는 Articles 그룹이 있습니다 . 텔레그램 봇을 통해 하나 이상의 그룹으로부터 새 기사에 대한 알림을 받는 것이 아이디어입니다."A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 1

JRTB-5 추가

내가 Success Stories 그룹 의 기사에 관심이 있다고 가정해 보겠습니다 . 따라서 저는 이 그룹의 업데이트를 구독하고 매번 새로운 출판물에 대한 링크를 받고 싶습니다. 이 작업의 일부로 JavaRush에서 그룹 작업을 위해 개방형 API를 사용하는 방법을 배워야 합니다. 바로 이 순간 그런 것이 도착했습니다. 오픈 API에 대한 설명 링크 입니다 .
친구! 프로젝트의 새 코드나 새 기사가 공개되면 즉시 알고 싶으십니까? 내 tg 채널에 가입하세요 . 그곳에서 나는 내 기사, 생각, 오픈 소스 개발을 함께 수집합니다.

스웨거란 무엇인가? 이제 알아 봅시다

우리는 아직 스웨거에 대해 이야기하지 않았습니다. 모르시는 분들을 위해 간단히 설명하겠습니다. 이곳은 서버의 API를 공개적으로 살펴보고 서버에 요청을 해볼 수 있는 곳입니다. 일반적으로 Swagger는 가능한 요청을 그룹화합니다. 우리의 경우 forum-question , group , post 세 가지 그룹이 있습니다 . 각 그룹에는 이 요청을 작성하는 데 필요한 모든 데이터(즉, 전달할 수 있는 추가 매개변수, 이를 사용하여 수행할 작업, http 메소드 등)를 나타내는 하나 이상의 요청이 있습니다. 나는 이 주제에 대해 더 많이 읽고 시청할 것을 권합니다. 왜냐하면 이것이 거의 모든 사람이 접하게 될 개발의 일부이기 때문입니다. 이를 파악하기 위해 JavaRush에 몇 개의 그룹이 있는지 알아봅시다. 이렇게 하려면 그룹 컨트롤러 그룹을 확장하고 Get request /api/1.0/rest/groups/count 를 선택합니다 . JavaRush의 그룹 수를 반환합니다. 살펴보겠습니다. "A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 2이미지는 이 쿼리가 여러 매개변수(쿼리, 유형, 필터)를 지원함을 보여줍니다. 이 요청을 시험해 보려면 시험해 보기 버튼을 찾아야 하며 그 후에 이러한 매개변수를 구성할 수 있습니다. "A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 3또한 여기에서 유형, 필터 및 쿼리를 구성할 수도 있습니다(여기서 실제로 흥미롭습니다. 이는 텍스트 검색이 됩니다. 그룹). 하지만 지금은 아무런 제한 없이 실행하여 JavaRush에 몇 개의 그룹이 있는지 살펴보겠습니다. 이렇게 하려면 실행을 클릭하세요. 바로 아래에는 이 요청에 대한 응답(서버 응답 섹션)이 있습니다. 총 30개의"A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 4 그룹이 있고 이 요청은 95ms 내에 완료되었으며 응답에 일부 헤더 세트가 있습니다. 다음으로 몇 가지 매개변수를 구성해 보겠습니다. COMPANY 값과 동일한 유형 매개변수를 선택하고 결과가 어떻게 변하는지 살펴보겠습니다. 그 중 4개가 있습니다. 이를 확인하는 방법은 무엇입니까? 쉽습니다. 웹사이트로 이동하여 기사 섹션을 찾은 다음 모든 그룹을 선택하고 거기에 적절한 필터를 추가할 수 있습니다( https://javarush.com/groups/all?type=COMPANY ). 그리고 네, 실제로는 4개만 있습니다. 비록 실제로는 3개가 있지만 :D 지금까지는 맞습니다. 그런데 대학을 확인해 보면 아직 대학이 하나도 없습니다. 재미삼아 Javarush에 로그인했지만 로그인하지 않은 브라우저에서 filter = MY를 설정하면 어떻게 되는지 확인해 보세요. 스웨거에 대한 자세한 내용은 Habré에 관한 기사를 참조하세요 ."A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 5"A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 6

그룹용 Javarush API용 클라이언트 작성

이제 개방형 API를 기반으로 요청하고, 응답을 받고, 어떤 객체가 도착할지 정확히 알 수 있는 Java 클라이언트를 작성하겠습니다. 또한 모델 섹션 (페이지 맨 아래) 의 swagger에서 개체를 가져옵니다 . 새 패키지를 생성하고 서비스, 저장소 옆에 javarushclient라고 부르겠습니다. 앞으로는 이를 Javarush 커뮤니티 조직 내의 별도 라이브러리로 옮기고 종속성으로만 사용할 것입니다.
저는 "Skyscanner API용 클라이언트 생성 및 jCenter 및 Maven Central에 게시에 대한 가이드" 기사에서 Java 클라이언트 생성에 대해 이미 썼습니다 .
먼저 JavaRush API에 대한 http 요청을 생성하기 위한 라이브러리인 Unitrest를 추가해야 합니다.
<dependency>
  <groupId>com.konghq</groupId>
  <artifactId>unirest-java</artifactId>
  <version>${unirest.version}</version>
</dependency>
그리고 속성 블록에 버전을 넣으세요.
<unirest.version>3.11.01</unirest.version>
종속성이 있으면 코드 추가를 시작할 수 있습니다. JavaRushGroupClient 그룹용 클라이언트와 JavaRushGroupClientImpl 클래스의 구현을 만들어 보겠습니다. 하지만 먼저 DTO(데이터 전송 객체), 즉 클라이언트에 필요한 모든 데이터를 전달하는 객체가 포함된 클래스를 만들어야 합니다. 모든 모델은 Swagger에서 볼 수 있으며 맨 아래에는 모델 섹션이 있어 셀 수 있습니다. 다음은 swagger에서 GroupDiscussionInfo 의 모습입니다. javarushclient 패키지에서 dto"A부터 Z까지의 Java 프로젝트": 기사 그룹을 구독하는 기능 추가.  파트 1 - 7 패키지를 생성 하고 swagger의 데이터를 기반으로 다음 클래스를 추가합니다.
  • 나그룹정보상태 :

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

  • 나그룹정보 :

    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;
    }

  • 그룹정보 유형 :

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

  • 사용자토론정보 :

    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;
    }

  • GroupVisibilitydtatus :

    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;
    }

GroupInfoGroupDiscussionInfo는 거의 완전히 동일 하므로 상속에서 연결해 보겠습니다. GroupDiscussionInfo :
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를 모두 가져올 수 있습니다. 요청에는 유형, 쿼리, 필터, 오프셋 및 제한이 있을 수 있으므로 별도의 GroupRequestArgs 클래스를 만들고 이를 빌더 클래스로 만들어 보겠습니다(빌더 패턴이 무엇인지 읽어보세요).
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;
   }
}
그룹 수를 검색하는 경우에는 약간 다릅니다. 쿼리, 유형 및 필터만 있습니다. 그리고 코드를 복제하고 싶지 않은 것 같습니다. 동시에, 결합을 시작하면 빌더와 작업할 때 보기 흉한 결과가 나옵니다. 그래서 나는 그것들을 분리하고 코드를 반복하기로 결정했습니다. 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라는 점입니다. 따라서 3가지 방법이 아닌 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 주석을 사용하여 생성자에 API에 대한 경로를 추가합니다. 이는 주석 내부의 값이 속성 파일의 필드에 해당함을 의미합니다. 따라서 application.properties에 새 줄을 추가해 보겠습니다.
javarush.api.path=https://javarush.com/api/1.0/rest
이제 이 값은 모든 API 클라이언트에 대해 한 곳에 보관되며 API 경로가 변경되면 신속하게 업데이트됩니다. 이전에는 현미경으로 못을 박고, Unirest를 통해 http 요청에 대한 응답을 받고, 이를 문자열로 변환한 후, 이 문자열을 Jackson을 통해 파싱했는데... 무섭기도 하고, 지루하고, 부가적인 것들이 많이 필요했습니다. 이 라이브러리 에서는 그것이 어떻게 생겼는지 볼 수 있습니다. 손에 넣자마자 모든 것을 리팩터링하겠습니다.
이 라이브러리를 업데이트하려는 사람은 불안한 라이브러리의 도구를 사용해서만 수신 개체를 추가하고 개인 메시지를 작성하거나 라이브러리 자체의 새 이슈로 작성합니다. 이것은 당신에게 실제 업무 경험이 될 것이지만 상관 없습니다. 전체 코드 검토를 수행하고 필요한 경우 도움을 드리겠습니다.
이제 질문은: 코드가 예상대로 작동합니까? 대답은 간단합니다. 테스트를 작성하기만 하면 됩니다. 여러 번 말했듯이 개발자는 테스트를 작성할 수 있어야 합니다. 따라서 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());
   }
}
테스트는 이전과 동일한 스타일로 작성됩니다. 각 요청에 대해 여러 가지 테스트가 있습니다. 이 API는 이미 가장 좋은 방법으로 테스트되었다고 생각하기 때문에 모든 것을 테스트할 필요가 없습니다.

결론

이 기사의 일부로 우리는 JavaRush API에 그룹용 Java 클라이언트를 추가했습니다. 그들이 말했듯이 살고 배우십시오. 이 클라이언트를 작성하는 동안 나는 그들의 문서를 활용하고 그들이 제공하는 개체를 사용하여 작업을 편리하게 사용했습니다. 제가 제안한 작업에 주목해 주세요. 누구든지 관심이 있으시면 저에게 개인 메시지를 보내주세요. 매우 흥미로운 경험이 될 것이라고 확신합니다. 이것이 첫 번째 부분이었습니다. 두 번째에서는 추가 명령을 직접 구현하고 (하나의 기사에 해당하는 경우) 사용자가 구독하는 그룹 목록을 가져오는 것을 추가할 것입니다. 다음으로, 봇을 위한 텍스트를 작성하려는 욕구와 재능이 있는 사람은 누구든지 PM으로 저에게 편지를 보내주세요. 저는 이 문제에 대한 전문가가 아니며 어떤 도움이라도 큰 도움이 될 것입니다. 이 모든 것을 오픈 소스 개발로 공식화하면 흥미로울 것입니다! 음, 평소와 같이 좋아요, 구독, 종소리 , 프로젝트에 별표 표시 , 댓글 작성, 기사 평가 등이 가능합니다!
유용한 링크

시리즈의 모든 자료 목록은 이 기사의 시작 부분에 있습니다.

코멘트
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION