REST DataStore

Задача REST DataStore — предоставить простой способ интеграции приложений Jmix. В результате такой интеграции появляется возможность получать доступ к внешним сущностям из удалённого приложения Jmix через интерфейс DataManager таким же образом, как к локальным сущностям JPA. Внешние сущности могут отображаться в пользовательском интерфейсе, обновляться и сохраняться обратно в удалённое приложение с использованием стандартной CRUD-функциональности, предоставляемой Jmix, без написания дополнительного кода.

Данный раздел предоставляет справочную информацию о дополнении REST DataStore. Для получения информации о том, как использовать его в различных сценариях, обратитесь к следующим руководствам:

В данном документе используются следующие термины:

  • Сервисное приложение — приложение Jmix, предоставляющее данные через универсальный REST API.

  • Клиентское приложение — приложение Jmix, получающее данные от Сервисного приложения с использованием REST DataStore.

Сервисное и клиентское приложения могут использовать разные версии Jmix.

Установка

Для автоматической установки через Jmix Marketplace следуйте инструкциям в разделе Дополнения.

Для ручной установки добавьте следующие зависимости в ваш build.gradle:

implementation 'io.jmix.restds:jmix-restds-starter'

Конфигурация

Базовая конфигурация

Базовая конфигурация включает следующие шаги.

В проекте сервисного приложения:

В проекте клиентского приложения:

  • Добавьте дополнение REST DataStore, как описано выше.

  • Добавьте дополнительное хранилище данных с дескриптором restds_RestDataStoreDescriptor, например:

    jmix.core.additional-stores=serviceapp
    jmix.core.store-descriptor-serviceapp=restds_RestDataStoreDescriptor
  • Укажите свойства подключения к сервису для данного хранилища данных по его имени, например:

    serviceapp.baseUrl=http://localhost:8081
    serviceapp.clientId=clientapp
    serviceapp.clientSecret=clientapp123

Аутентификация с помощью Password Grant

Если вы хотите выполнять аутентификацию реальных пользователей в сервисном приложении, как продемонстрировано в руководстве Separating Application Tiers, настройте Password Grant в сервисном приложении и добавьте следующие свойства в клиентское приложение:

serviceapp.authenticator=restds_RestPasswordAuthenticator
jmix.restds.authentication-provider-store=serviceapp

Внешняя аутентификация

Если вы хотите использовать внешний провайдер аутентификации (например, Keycloak) вместо Jmix Authorization Server, удалите дополнение Authorization Server из проекта и добавьте дополнение OpenID Connect.

Определите следующие свойства приложения:

serviceapp.authenticator=restds_RestOAuth2ClientAuthenticator
serviceapp.oauth2-client-registration=keycloak
  • Свойство [datastore-name].authenticator определяет имя бина, который реализует интерфейс RestAuthenticator и используется для аутентификации запросов к REST API.

    Бин restds_RestOAuth2ClientAuthenticator использует OAuth2-токен текущего пользователя.

  • Свойство [datastore-name].oauth2-client-registration определяет ID регистрации клиента OAuth2 в Spring Security, который используется в свойствах spring.security.oauth2.client.registration.[ID] для подключения к OIDC-провайдеру.

    Например, если вы подключаетесь к Keycloak с помощью этого свойства:

    spring.security.oauth2.client.registration.keycloak.client-id=integrated-apps-client

    Тогда значение свойства [datastore-name].oauth2-client-registration должно быть keycloak.

Полный пример см. в руководстве REST DataStore with External Authentication.

UI-настройки пользователей

Чтобы сохранять UI-настройки пользователей и конфигурации фильтров через REST-хранилище данных, выполните следующие действия:

  • В build.gradle:

    • добавьте зависимость io.jmix.flowui:jmix-flowui-restds-starter

    • удалите зависимость io.jmix.flowui:jmix-flowui-data-starter

  • В application.properties укажите имя REST-хранилища данных в свойстве jmix.restds.ui-config-store, например:

    jmix.restds.ui-config-store=serviceapp

См. также Frontend Application Configuration в руководстве Separating Application Tiers.

REST API Paths

Если эндпойнты REST API имеют нестандартные пути, вы можете настроить доступ к ним, используя свойства приложения, показанные ниже. Свойства начинаются с имени хранилища данных (serviceapp в данном примере):

serviceapp.basePath=/rest
serviceapp.entitiesPath=/entities
serviceapp.userInfoPath=/userInfo
serviceapp.permissionsPath=/permissions
serviceapp.capabilitiesPath=/capabilities

Модель данных

Клиентское приложение должно содержать DTO-сущности, эквивалентные сущностям сервисного приложения. Для автоматического сопоставления атрибуты этих сущностей должны совпадать по имени и типу.

Набор атрибутов может различаться. Например, сервисная сущность может иметь больше атрибутов, чем клиентская. Атрибуты, отсутствующие в одной из сущностей, будут иметь значение null после передачи данных.

Клиентская DTO-сущность должна иметь аннотацию @Store, указывающую на дополнительное хранилище данных.

Пример определения сущности Region в сервисном и клиентском приложениях представлен ниже.

Сущность Region в сервисном приложении
@JmixEntity
@Table(name = "REGION")
@Entity
public class Region {
    @JmixGeneratedValue
    @Column(name = "ID", nullable = false)
    @Id
    private UUID id;

    @Column(name = "VERSION", nullable = false)
    @Version
    private Integer version;

    @InstanceName
    @Column(name = "NAME", nullable = false)
    @NotNull
    private String name;

    // getters and setters
Сущность Region в клиентском приложении
@Store(name = "serviceapp")
@JmixEntity
public class Region {
    @JmixGeneratedValue
    @JmixId
    private UUID id;

    private Integer version;

    @InstanceName
    @NotNull
    private String name;

    // getters and setters

Если имя клиентской сущности отличается от сервисного, используйте аннотацию @RestDataStoreEntity для явного указания имени сущности в сервисе. Например:

@Store(name = "serviceapp")
@JmixEntity
@RestDataStoreEntity(remoteName = "Region")
public class RegionDto {
    // ...

Для вложенных атрибутов на стороне клиента используйте аннотацию @JmixEmbedded вместо @Embedded из JPA.

Для атрибутов композиции с типом один-ко-многим на стороне клиента определите атрибут inverse в аннотации @Composition.

Пример:

@Store(name = "serviceapp")
@JmixEntity
public class Customer {
    // ...

    @JmixEmbedded
    @EmbeddedParameters(nullAllowed = false)
    private Address address;

    @Composition(inverse = "customer")
    private Set<Contact> contacts;

    // ...

Фетч-планы

Когда вы загружаете внешнюю сущность в клиентском приложении, вы можете указать фетч-план для загрузки ссылок.

Если сервисное приложение построено с использованием Jmix до версии 2.5, его универсальный REST API поддерживает только именованные фетч-планы, определенные в репозитории фетч-планов. Таким образом, REST DataStore будет запрашивать данные у сервиса, предоставляя имя фетч-плана. Следовательно, как сервисные, так и клиентские приложения должны определять все фетч-планы в своих репозиториях фетч-планов с соответствующими именами.

Начиная с Jmix 2.5, универсальный REST поддерживает встроенные (inline) фетч-планы, если эта функция не отключена свойством jmix.rest.inline-fetch-plan-enabled. Таким образом, вы можете определять фетч-планы в XML экрана и Java-коде вашего клиента так же, как и при работе с базами данных.

REST DataStore полагается на Capabilities API REST сервисного приложения, чтобы определить, включены ли встроенные фетч-планы. Если проверка подтверждает их доступность, REST DataStore передает встроенные фетч-планы, в противном случае передаются только имена фетч-планов.

Загруженное состояние

Если фетч-план не включает какой-либо атрибут, этот атрибут не загружается. В отличие от атрибутов сущностей JPA, атрибуты REST-сущностей, которые не были загружены, имеют значение null и не вызывают исключений при доступе к ним.

При обновлении сущности REST DataStore сохраняет только загруженные атрибуты. Если атрибут не был загружен из сервиса, но впоследствии изменён с null на какое-либо значение, он считается загруженным, и новое значение будет сохранено.

Метод EntityStates.isLoaded(entity, property) корректно возвращает информацию о том, загружен ли определённый атрибут REST-сущности.

Фильтрация загружаемых данных

Данный подраздел описывает параметры фильтрации, поддерживаемые при загрузке внешних сущностей с помощью DataManager. Все эти опции приводят к вызову эндпойнта поиска REST API сервисного приложения, поэтому по сети передаются только найденные сущности.

По условиям

Пример:

List<Customer> loadByCondition(String lastName) {
    return dataManager.load(Customer.class)
            .condition(PropertyCondition.equal("lastName", lastName))
            .list();
}

По запросу

Запрос представляет собой выражение на JSON, поддерживаемое универсальным REST API в эндпойнте поиска:

List<Customer> loadByQuery(String lastName) {
    String query = """
    {
      "property": "lastName",
      "operator": "=",
      "value": "%s"
    }
    """.formatted(lastName);

    return dataManager.load(Customer.class)
            .query(query)
            .list();
}

По идентификаторам

Пример:

List<Customer> loadByIdentifiers(UUID id1, UUID id2, UUID id3) {
    return dataManager.load(Customer.class)
            .ids(id1, id2, id3)
            .list();
}

Использование запроса в XML экранов

JSON-запрос может быть указан в XML-дескрипторах экранов для контейнеров данных и элементов itemsQuery:

<entityComboBox id="regionField" property="region">
    <itemsQuery class="com.company.clientapp.entity.Region"
                searchStringFormat="${inputString}">
        <fetchPlan extends="_base"/>
        <query>
            <![CDATA[
            {
              "property": "name",
              "operator": "contains",
              "parameterName": "searchString"
            }
            ]]>
        </query>
    </itemsQuery>
</entityComboBox>

Чтобы указать параметр вместо фиксированного значения в JSON-условиях запроса, используйте ключ parameterName вместо value, как показано выше. REST DataStore заменит это свойство на "value": <parameter-value> в результирующем запросе.

Также можно использовать фасет dataLoadCoordinator, но только с ручной конфигурацией. В следующем примере контейнеры данных regionsDc и customersDc связаны, используя JSON-запрос и dataLoadCoordinator, для предоставления в режиме мастер-деталь списка регионов и клиентов для выбранного региона:

<data>
    <collection id="regionsDc"
                class="com.company.clientapp.entity.Region">
        <loader id="regionsDl" readOnly="true"/>
    </collection>
    <collection id="customersDc" class="com.company.clientapp.entity.Customer">
        <fetchPlan extends="_base"/>
        <loader id="customersDl" readOnly="true">
            <query>
                <![CDATA[
                {
                    "property": "region",
                    "operator": "=",
                    "parameterName": "region"
                }
                ]]>
            </query>
        </loader>
    </collection>
</data>
<facets>
    <dataLoadCoordinator>
        <refresh loader="regionsDl">
            <onViewEvent type="BeforeShow"/>
        </refresh>
        <refresh loader="customersDl">
            <onContainerItemChanged container="regionsDc" param="region"/>
        </refresh>
    </dataLoadCoordinator>
    <!-- ... -->

События сущностей

REST DataStore отправляет события EntitySavingEvent и EntityLoadingEvent так же, как JpaDataStore. Однако оно не отправляет событие EntityChangedEvent, так как не может предоставить информацию об изменённых с момента загрузки атрибутах. Вместо события EntityChangedEvent REST DataStore отправляет два специфических события:

  • RestEntitySavedEvent — отправляется после успешного сохранения сущности в сервисе. Оно содержит сохранённый экземпляр сущности в состоянии перед отправкой в сервис.

  • RestEntityRemovedEvent — отправляется после удаления сущности из сервиса. Оно содержит удалённую сущность в состоянии перед отправкой в сервис.

Безопасность

REST DataStore применяет политику операций над сущностями, определённую ресурсными ролями, и политику предикатов, определённую ролями уровня строк.

Аутентификация в REST DataStore может выполняться с использованием Client Credentials Grant или Password Grant, предоставляемых дополнением Authorization Server. Последний вариант требует настройки дополнительных свойств <ds-name>.authenticator и jmix.restds.authentication-provider-store, как описано в разделе Конфигурация.

Хранилище файлов

Сущности сервисного приложения могут содержать ссылки FileRef на файлы в своих хранилищах файлов. REST DataStore дополнение предоставляет RestFileStorage реализацию интерфейса FileStorage, который является прокси, позволяющим работать с файлами, расположенными в хранилищах файлов сервисного приложения.

RestFileStorage использует общий REST Files API для передачи файлов между клиентом и сервисным приложением.

Чтобы использовать RestFileStorage в вашем клиентском приложении, определите следующий бин в главном классе приложения или в любом другом классе Spring configuration:

@Bean
FileStorage serviceappFileStorage() {
    return new RestFileStorage("serviceapp", "fs");
}

Здесь serviceapp - это имя REST DataStore, как определено в application.properties, fs - это имя соответствующего хранилища файлов сервисного приложения.

Имя REST хранилища файлов в клиентском приложении формируется путем объединения этих параметров с дефисом между ними. Таким образом, в приведенном выше примере это будет serviceapp-fs. Если у вас есть несколько хранилищ файлов в клиентском приложении (скажем, локальный fs и удаленный serviceapp-fs), см. Использование нескольких хранилищ файлов для получения дополнительной информации.

Пример работы с RestFileStorage приведён в руководстве Separating Application Tiers.

Вызов сервисов

Если сервисное приложение предоставляет Java-сервис через REST API, клиентские приложения могут его использовать.

Интерфейсы @RemoteService

Самый простой способ вызвать сервис — создать интерфейс, который отражает методы сервиса, и пометить его аннотацией @RemoteService. Для интерфейсов с этой аннотацией дополнение REST DataStore генерирует клиентские прокси-объекты. Эти объекты автоматически сериализуют параметры, вызывают удалённые REST-сервисы и десериализуют результаты.

В следующем примере показана реализация сервисного бина:

CustomerService.java в сервисном приложении
package com.company.serviceapp.service;

import com.company.serviceapp.entity.Customer;
import io.jmix.core.DataManager;
import io.jmix.rest.annotation.RestMethod;
import io.jmix.rest.annotation.RestService;
import org.springframework.beans.factory.annotation.Autowired;

import java.util.List;

@RestService("customers")
public class CustomerService {

    @Autowired
    private DataManager dataManager;

    @RestMethod
    public List<Customer> getCustomersByName(String name) {
        return dataManager.load(Customer.class)
                .query("e.name = ?1", name).list();
    }
}

Метод сервиса доступен через эндпойнт /rest/services/customers/getCustomersByName.

Чтобы вызвать этот сервис из клиентского приложения, создайте следующий интерфейс:

CustomerService.java в клиентском приложении
package com.company.clientapp.service;

import com.company.clientapp.entity.Customer;
import io.jmix.restds.annotation.RemoteService;

import java.util.List;

@RemoteService(store = "serviceapp", remoteName = "customers")
public interface CustomerService {

    List<Customer> getCustomersByName(String name);
}

Дополнение REST DataStore автоматически создаст бин для этого интерфейса, так что вы сможете инжектировать его в экран или бин и использовать как обычный Spring-бин. Например:

@Autowired
private CustomerService customerService;

private void processCustomers(String name) {
    List<Customer> customers = customerService.getCustomersByName(name);
    // ...
}

Другой пример вызова метода бизнес-сервиса можно найти в руководстве Integrating Jmix Applications.

Методы интерфейсов @RemoteService могут иметь параметры и возвращать результаты следующих типов:

  • Простые типы: примитивы (int, double и т.д.), обёртки (Integer, Double и т.д.), String.

  • Сущность

  • Перечисление

  • Тип, для которого существует соответствующий Datatype. Сюда входят типы, поддерживаемые в атрибутах сущностей, а также пользовательские типы данных.

  • Произвольный POJO или Java-record с полями всех вышеперечисленных типов.

  • java.util.List, параметризованный любым из вышеперечисленных типов.

Использование RestClient

Вы также можете вызывать любые эндпойнты сервисного приложения с помощью Spring RestClient, предварительно настроенного параметрами подключения и аутентификации соответствующего REST-хранилища данных:

  1. Получите экземпляр RestClient из бина RestDataStoreUtils с помощью метода getRestClient(String dataStoreName).

  2. Используйте API RestClient для вызова эндпойнта с соответствующими параметрами.

Выделение общего кода приложений

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

При таком подходе общий код помещается в отдельное дополнение (add-on), которое используется как клиентским, так и серверным приложениями. Общий код может включать JPA-сущности, интерфейсы бизнес-сервисов, роли безопасности, а также любые общие утилитные классы.

Ниже мы опишем необходимые шаги для совместного использования сущностей, интерфейсов сервисов и ролей, используя в качестве примера приложения Products и Orders. Приложение Products предоставляет свои сущности Product и ProductCategory и сервис InventoryService через REST API. Приложение Orders использует эти сущности и вызывает сервис через REST DataStore.

Общее дополнение

Общее дополнение было создано с помощью стандартного шаблона Add-On (Java) в мастере New Project студии. Оно включает модули shared и shared-starter:

shared-addon
├── shared
│   ├── src
│   │   ├── main
│   │   │   ├── java
│   │   │   │   └── com
│   │   │   │       └── company
│   │   │   │           └── shared
│   │   │   │               ├── entity
│   │   │   │               │   ├── Product.java
│   │   │   │               │   └── ProductCategory.java
│   │   │   │               ├── security
│   │   │   │               │   └── ProductsRole.java
│   │   │   │               ├── service
│   │   │   │               │   └── InventoryService.java
│   │   │   │               └── SharedConfiguration.java
│   │   │   └── resources
│   │   │       └── com
│   │   │           └── company
│   │   │               └── shared
│   │   │                   ├── liquibase
│   │   │                   │   ├── changelog
│   │   │                   │   │   └── 010-init-products.xml
│   │   │                   │   └── changelog.xml
│   │   │                   ├── messages.properties
│   │   │                   └── module.properties
│   │   └── test
│   │       └── ...
│   └── shared.gradle
├── shared-starter
│   ├── src
│   │   └── ...
│   └── shared-starter.gradle
├── build.gradle
└── settings.gradle

Скрипт сборки основного модуля shared определяет только минимальный набор зависимостей Jmix:

shared-addon/shared/shared.gradle
dependencies {
    implementation 'io.jmix.core:jmix-core-starter'
    implementation 'io.jmix.data:jmix-eclipselink-starter'
    implementation 'io.jmix.security:jmix-security-starter'

Дополнение включает следующий общий код:

  • JPA-сущности Product и ProductCategory.

  • Ресурсная роль ProductsRole определяет политики, разрешающие доступ к сущностям и их атрибутам.

  • InventoryService — интерфейс, определяющий некоторый бизнес-метод.

  • 010-init-products.xml — Liquibase changelog, создающий схему базы данных для сущностей Product и ProductCategory.

Сервисное приложение

Сервисное приложение Products включает общее дополнение стандартным образом без какой-либо специфической конфигурации.

Скрипт сборки приложения Products определяет зависимость от стартера общего дополнения:

products/build.gradle
dependencies {
    implementation 'com.company:shared-starter:0.0.1-SNAPSHOT'

Приложение предоставляет реализацию интерфейса InventoryService:

products/src/main/java/com/company/products/service/InventoryServiceImpl.java
@RestService("InventoryService")
public class InventoryServiceImpl implements InventoryService {

    @RestMethod
    @Override
    public Double getAvailableInStock(Product product) {
        return (double) Math.round(Math.random() * 100);
    }
}

Клиентское приложение

Клиентское приложение Orders также включает общее дополнение:

orders/build.gradle
dependencies {
    implementation 'com.company:shared-starter:0.0.1-SNAPSHOT'

Дополнительно, клиентское приложение корректирует конфигурацию сущностей и удаленных сервисов для правильной работы с общим кодом.

  1. Скрипт сборки исключает пакет общих сущностей из JPA-маппинга в свойстве jmix.entitiesEnhancing.nonJpaPackages:

    orders/build.gradle
    jmix {
        // ...
        entitiesEnhancing {
            nonJpaPackages = ['com.company.shared.entity']
        }
    }

    Другой вариант — исключить отдельные классы в свойстве jmix.entitiesEnhancing.nonJpaClasses.

  2. Бин SharedEntitiesConfigurer устанавливает REST DataStore products в качестве хранилища данных для сущностей общего дополнения:

    orders/src/main/java/com/company/orders/SharedEntitiesConfigurer.java
    package com.company.orders;
    
    import com.company.shared.entity.Product;
    import io.jmix.core.JmixOrder;
    import io.jmix.core.MetadataMutationTools;
    import io.jmix.core.MetadataPostProcessor;
    import io.jmix.core.metamodel.model.MetaClass;
    import io.jmix.core.metamodel.model.Session;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.core.annotation.Order;
    import org.springframework.stereotype.Component;
    
    @Component
    @Order(JmixOrder.HIGHEST_PRECEDENCE - 10)
    public class SharedEntitiesConfigurer implements MetadataPostProcessor {
    
        @Autowired
        private MetadataMutationTools metadataMutationTools;
    
        @Override
        public void process(Session session) {
            for (MetaClass metaClass : session.getClasses()) {
                if (metaClass.getJavaClass().getPackage().equals(Product.class.getPackage())) {
                    metadataMutationTools.setStore(metaClass, "products");
                }
            }
        }
    }
  3. Бин SharedServicesConfigurer добавляет фильтр сканирования для поиска интерфейсов сервисов общего дополнения для создания клиентских прокси. Он также определяет имя хранилища для интерфейсов сервисов:

    orders/src/main/java/com/company/orders/SharedServicesConfigurer.java
    package com.company.orders;
    
    import com.company.shared.service.InventoryService;
    import io.jmix.core.JmixOrder;
    import io.jmix.restds.util.RemoteServiceConfigurationCustomizer;
    import org.springframework.core.annotation.Order;
    import org.springframework.core.type.filter.TypeFilter;
    import org.springframework.stereotype.Component;
    
    @Component
    @Order(JmixOrder.HIGHEST_PRECEDENCE - 10)
    public class SharedServicesConfigurer implements RemoteServiceConfigurationCustomizer {
    
        @Override
        public TypeFilter getScannerIncludeFilter() {
            return (metadataReader, metadataReaderFactory) ->
                    metadataReader.getClassMetadata().getClassName().equals(InventoryService.class.getName());
        }
    
        @Override
        public ServiceParameters getServiceParameters(Class<?> serviceInterface) {
            return serviceInterface.equals(InventoryService.class) ?
                    new ServiceParameters().withStoreName("products") : null;
        }
    }

Ограничения

REST DataStore имеет следующие ограничения по сравнению с хранилищем данных JPA:

  • Не поддерживает ленивую загрузку ссылок. Ссылки, которые не были загружены по фетч-плану, остаются null при доступе.

  • Отсутствует событие EntityChangeEvent с AttributeChanges.

  • Методы DataManager.loadValues() и loadValue() не реализованы и выбрасывают UnsupportedOperationException.