Если разработчик веб-сервиса хочет дать максимум удобств и пользы клиентам, ему нужно создать общедоступный API для программной работы с этим сервисом. В экосистеме Java есть один подход к разработке API, весьма удобный для программистов. Он заключается в размещении DTO и интерфейсов конечной точки в .jar-файле API и в создании, с использованием фреймворка Retrofit, типобезопасных клиентов для интеграционного тестирования. В этом материале приведён подробный разбор проекта, созданного с применением такого подхода к работе.



Если вы занимались крупными Java-проектами, то вы, наверное, помните старый добрый WSDL (Web Services Description Language, язык описания веб-сервисов), за которым стоят IBM и Microsoft. WSDL — это язык описания веб-сервисов, основанный на XML. А, может, вы всё ещё пользуетесь этим языком? WSDL и его брат-близнец — язык XML Schema, относятся к тем стандартам W3C, которые являются излюбленным объектом ненависти бывалых программистов. Файлы спецификаций WSDL не особенно легко читать людям, а об удобстве их ручного составления лучше и не говорить. Но, к счастью, работать с подобными файлами вручную и не нужно. Они могут быть сгенерированы конечной точкой сервера и переданы прямо в кодогенератор для создания объектов переноса данных (DTO, Data Transfer Object) и стабов сервиса.

Цель документа, описывающего спецификации контракта, заключается в том, чтобы сообщить сведения о внешних частях вашего сервиса программистам, которые пользуются этим сервисом. Ни одно сложное приложение не обходится без подобного документа. Особенно это касается микросервисных проектов, поддерживаемых удалёнными командами разработчиков. Если подумать о том, чтобы избавиться от WSDL, и вспомнить одно известное высказывание, которое звучит как «Не стоит выплёскивать из ванны с грязной водой и самого ребёнка», то окажется, что размеры и сложность WSDL-спецификаций — это «вода», а чёткие описания сервиса — это «ребёнок». Как бы нам ни хотелось избавиться от «грязной воды», «ребёнка» мы «выплеснуть» не можем. Индустриальные стандарты, которые не должны зависеть от реализации сервисов и должны иметь широкое распространение, вышли из моды из-за их неисправимой сложности. Но нам просто необходима альтернатива таким стандартам.

Если вы, работая в одной и той же экосистеме, занимаетесь созданием и клиента, и сервера, это значит, что у вас есть роскошь обладания собственным мнением по поводу выбора протоколов и наборов инструментов. Это может сделать процесс создания API проще и удобнее с точки зрения разработчика.

Мы рассмотрим проект REST-сервиса, который имеет одну конечную точку, а так же — отдельный проект, предназначенный для тестирования этого сервиса. Мы будем применять подход, в котором можно выделить две части:

  • Публикация API путём создания .jar-файла, содержащего DTO в виде простых Java-объектов (POJO, Plain Old Java Object) и описания конечных точек API в виде Java-интерфейсов.
  • И REST-сервер, и проект, используемый для тестирования сервера, зависят от .jar-файла с описанием API. Пользователи сервиса используют интерфейсы, описанные в этом файле, для создания клиентских прокси-объектов с применением фреймворка Retrofit. А REST-сервер лишь использует ссылки на DTO.

Обзор проекта


Исходный код проекта можно найти в этом GitLab-репозитории. Клонировать его можно так:

git clone git@gitlab.com:jsprengers/spring-retrofit-demo.git

Это — maven-проект, в состав которого входит три подпроекта: service, api и integration. Вот основные сведения об этих проектах:

  • api: содержит DTO и спецификации REST-контроллера.
  • service: это — REST-сервис, созданный с использованием Spring Boot. Он, в плане DTO, зависит от подпроекта api .
  • integration: включает в себя только интеграционные тесты. Он зависит от подпроекта api в плане DTO и спецификаций конечной точки REST-сервиса.

Проект service


Конечная точка возвращает и принимает DTO Person, описанные в проекте api . Тут, для имитации постоянного хранилища, в котором данные размещаются между вызовами, используется объект, данные которого хранятся в памяти. В реализации Basic Authentication различаются две роли — user и admin. Пользователь admin может выполнять запросы PUT, POST и DELETE (то есть — обладает возможностью чтения и записи данных), а пользователь user может выполнить лишь запрос GET (то есть — может лишь читать данные). Пароли хранятся в виде переменных окружения, которые загружаются при запуске проекта. Для учётных записей user и admin, по умолчанию, используются, соответственно, пароли nosecret и secret. Тут, как видите, всё очень просто. Всё же, это — учебный проект.

@RestController
@RequestMapping("api/person")
@RequiredArgsConstructor
@Slf4j
public class PersonController {

    @Autowired
    private final PersonDAO personDAO;

    @GetMapping
    List<Person> getAll(@RequestParam(value = "fields", required = false) String fields) {
        return personDAO.getAll(fields);
    }

    @GetMapping("/{id}")
    Person getPersonById(@PathVariable("id") String id, @RequestParam(value = "fields", required = false) String fields) {
        return personDAO.getById(id, fields).orElseThrow(() -> {
            throw new NotFoundException("No such ID: " + id);
        });
    }

    @PostMapping
    void createPerson(@RequestBody Person person) {
        if (personDAO.getById(person.getId(), null).isPresent()) {
            throw new IllegalArgumentException("Person with ID already exists: " + person.getId());
        }
        log.info("Storing person with id {}", person.getId());
        personDAO.put(person);
    }

    @PutMapping
    void upsertPerson(@RequestBody Person person) {
        personDAO.put(person);
    }

    @DeleteMapping("/{id}")
    void deletePerson(@PathVariable("id") String id) {
        personDAO.deleteById(id);
    }

}

Проект api


Проект api включает в себя DTO из предметной области приложения, в нашем случае это — Person в виде POJO. Применение фреймворка Lombok способствует минимизации объёма шаблонного кода, применяемого в проекте. Он позволяет включать в проект, в формате JSON, документацию и подсказки по сериализации (десериализации) объектов.

@Data
@NoArgsConstructor
@AllArgsConstructor
@Builder(toBuilder = true)
public class Person {
    private String id;
    private String name;
    private String address;
    private String email;
}

Вторая часть контракта сервиса — это описание конечных точек контроллера, выполненное в виде интерфейсов Java. Они, в целом, представляют собой методы контроллера, у которых нет тел методов. Именно тут в дело вступает фреймворк Retrofit. Он позволяет пользоваться аннотациями для декорирования интерфейсов, которые будут превращены в сетевые прокси-объекты для сервиса, на работу с которым они рассчитаны. Эти аннотации очень похожи на те, которые используются в серверных контроллерах.

public interface PersonAPIClient {

    @GET("api/person")
    Call<List<Person>> getAll(@Query("fields") String fields);

    @GET("api/person/{id}")
    Call<Person> getPersonById(@Path("id") String id, @Query("fields") String fields);

    @POST("api/person")
    Call<Void> createPerson(@Body Person person);

    @PUT("api/person")
    Call<Void> upsertPerson(@Body Person person);

    @DELETE("api/person/{id}")
    Call<Void> deletePerson(@Path("id") String id);
}

Обратите внимание на возвращаемые типы. Как вы увидите ниже — эти интерфейсы представляют собой шаблоны для реализаций методов, которые возвращают параметризованный объект Call, который является прокси-объектом для сетевого клиента более низкого уровня. Клиент считывает тело запроса и даёт сведения о статусе HTTP-запроса. Учитывайте, что из-за того, что везде используется стандартный возвращаемый тип Call, мы не можем позволить REST-контроллерам реализовать интерфейс API. Можно обстоятельно поговорить о том, хорошо ли, когда в проекте имеется столь тесная связь между сущностями, но это — спорный вопрос. Сейчас нам нужно поддерживать интерфейсы вручную и держать соответствующий код обособленно.

Проект integration


Этот проект демонстрирует тесты, в ходе выполнения которых отправляются запросы к REST-сервису, при этом зависимость этого проекта от общедоступного API сервиса выражается лишь в использовании соответствующего кода при создании тестов. На стадии сборки package осуществляется отправка Docker-образа с рабочим сервисом в локальный репозиторий с использованием jib-maven-plugin. При подготовке интеграционных тестов к работе осуществляется загрузка этого образа и запуск контейнера с применением фреймворка Testcontainers.

public class PersonAPIContainerizedIntegrationTest {

    private static AppContainer container;
    private static PersonAPIClient userClient;
    private static PersonAPIClient adminClient;

    @BeforeAll
    public static void initialize() {
        container = new AppContainer();
        container.startAndWait();
        // Сведения о порте для конечной точки localhost можно получить 
        // посредством container.getFirstMappedPort()
        RetrofitClientFactory retrofitClientFactory = 
           new RetrofitClientFactory(container.getFirstMappedPort());
        userClient = retrofitClientFactory.authenticatedClient("user","nosecret");
        adminClient = retrofitClientFactory.authenticatedClient("admin", "secret");
    }

    @AfterAll public static void shutdown() {
        if (container != null && container.isRunning())
            container.stop();
    }
   [ ... ]
}

AppContainer — это реализация GenericContainer во фреймворке TestContainer. AppContainer запускает контейнеризованный REST-сервер, контейнер которого был собран и отправлен в локальный репозиторий при сборке проекта service.

public class AppContainer extends GenericContainer<AppContainer> {

    public AppContainer() {
        // Докеризованное springboot-приложение работает на порте 8080, это - единственный порт, который образ выводит во внешний мир
        super(DockerImageName.parse("spring-retrofit-test-server:LATEST"));
        withExposedPorts(8080);
    }

    protected void startAndWait(){
        this.start();
        // Порт контейнера 8080 назначен свободному порту, сведения о котором получены с помощью getFirstMappedPort()
        // он заблокирован до того момента, когда можно будет работать с api/person.
        this.waitingFor(new HttpWaitStrategy()
           .forPath("api/person/")
           .forPort(getFirstMappedPort()));
    }
}

Создание REST-клиента с помощью Retrofit


Экземпляр Retrofit представляет собой фабрику, которая создаёт REST-клиенты на основе интерфейсов. Её нужно настроить с использованием паттерна Builder. Соответствующему механизму нужно передать, как минимум, базовый URL сервиса. Мы, в роли JSON-конвертера, используем библиотеку Google GSON. Ещё нам надо настроить библиотеку OkHttpClient на использование реализованной в проекте схемы Basic Authentication. Это позволит нам протестировать сервис на предмет соблюдения ограничений доступа для ролей user и admin. Мы можем инициализировать различные клиенты для проверки различных ролей. Порт, входящий в URL, известен только после того, как будет запущен контейнер с сервисом, поэтому его мы не можем жёстко задать в коде.

Мы, для создания прокси-объекта, рассчитанного на работу с конечной точкой сервиса, используем Retrofit.create (PersonApiClient в проекте api). Удобно, хотя и необязательно, иметь по одному интерфейсу для каждого класса контроллера.

public class RetrofitClientFactory {
    private final int port;

    PersonAPIClient authenticatedClient(String username, String password) {
        OkHttpClient okHttpClient = new OkHttpClient.Builder().authenticator(
                (route, response) -> response.request().newBuilder().header("Authorization", Credentials.basic(username, password))
                        .build()).build();
        Retrofit retrofit = new Retrofit.Builder()
                .baseUrl(String.format("http://localhost:%d/", port)).
                        addConverterFactory(GsonConverterFactory.create())
                .client(okHttpClient)
                .build();
        return retrofit.create(PersonAPIClient.class);
    }
}

Использование клиента, созданного с помощью Retrofit


Вот код тестов:

@Test
    public void EntityLifeCycleHappyFlow() throws IOException {
       
        executeCall(adminClient.createPerson(Person.builder().id("42").name("Jane").build()));
        executeCall(adminClient.createPerson(Person.builder().id("43").name("Jack").build()));
        assertThat(executeCall(userClient.getPersonById("42", null)).body().getName()).isEqualTo("Jane");
        assertThat(executeCall(userClient.getPersonById("43", null)).body().getName()).isEqualTo("Jack");

        Response<List<Person>> response = executeCall(userClient.getAll(null));
        assertThat(response.body()).hasSize(2);

        executeCall(adminClient.upsertPerson(Person.builder().id("42").name("Jane").address("London").build()));
        Person jane = executeCall(userClient.getPersonById("42", "address,dateofBirth")).body();
        assertThat(jane.getAddress()).isEqualTo("London");
        executeCall(adminClient.deletePerson("42"));
        assertThat(userClient.getAll(null).execute().body()).hasSize(1);
    }

  private <T> Response<T> executeCall(Call<T> call) throws IOException {
        Response<T> response = call.execute();
        if (!response.isSuccessful()) {
            fail("response returned " + response.errorBody().string());
        }
        return response;
    }

Тут можно видеть действия клиента, созданного с помощью Retrofit. Программирование с использованием интерфейса позволяет писать чистый, компактный и типобезопасный код. Не будем забывать о том, что каждый метод возвращает объект Call. Для того чтобы получить Response — нужно выполнить метод execute() этого объекта. Метод executeCall() — это удобный механизм, который позволяет предотвратить появление исключений RuntimeException, возникающих в том случае, если выполнить вызов не удалось.

Кстати говоря, это упрощает и облегчает тестирование неправильных путей. Объект Response даёт нам все необходимые данные.

// Пользователю с такой ролью не разрешено выполнение запросов POST
assertThat(userClient.createPerson(Person.builder().id("42").name("Jane").build()).execute().code()).isEqualTo(403);

// Уже имеется пользователь с id 42.
Response<Void> personExists = adminClient.createPerson(Person.builder().id("42").name("Jane").build()).execute();
assertThat(personExists.code()).isEqualTo(400);
assertThat(personExists.errorBody().string()).isEqualTo("Person with ID already exists: 42");

// Имя пользователя не может быть пустым
Response<Void> incompletePost = adminClient.createPerson(Person.builder().id("44").name(null).build()).execute();
assertThat(incompletePost.code()).isEqualTo(400);
assertThat(incompletePost.errorBody().string()).isEqualTo("Person name cannot be null");

Итоги


Надеюсь, что вам понравилась эта статья, и что вы нашли в ней что-то полезное. Подробнее о Retrofit можно узнать из документации к этому фреймворку. Там есть много такого, о чём я тут не рассказывал. В частности, вызовы можно выполнять в асинхронном режиме, используя коллбэки, а не пользоваться применённым здесь подходом, когда выполнение кода блокируется в ожидании ответа. Возможности Retrofit не ограничены созданием интеграционных тестов. Этот фреймворк можно использовать в продакшн-коде, в котором ведётся работа с различными сервисами.

В целом могу сказать, что фреймворк Retrofit, при подготовке с его помощью интеграционных тестов, показался мне понятным и приятным инструментом. Он гораздо дружелюбнее относится к программистам, чем, например, его соперник REST Assured.

Пользуетесь ли вы Retrofit?