В новой версии платформы Инкоманд появился No Code инструмент «Инкоманд Объекты», позволяющий создавать структуры данных и управлять ими без написания кода — Object Definition’ы, поля, связи, представления, права доступа настраиваются через UI. Но данные не всегда рождаются и живут в самой системе. На практике объекты часто находятся в смежных системах, и задача разработчика — не дублировать их, а организовать бесшовный доступ.
Рассмотрим реальный кейс: компания мигрирует с SharePoint Online на Инкоманд. Миграция — процесс долгий: нельзя просто взять и перенести все данные за один день. Какое-то время обе системы живут параллельно. Пользователи SharePoint привыкли работать со своими списками: контрагенты, заявки, сотрудники, проектные документы. Но руководство хочет, чтобы новый портал на Инкоманд стал единой точкой входа. Значит, нужно отобразить элементы SharePoint-списков в интерфейсе Инкоманд так, как будто они — нативные объекты платформы.
В этой статье я расскажу, как мы реализовали такое решение с помощью механизма Proxy Object Storage, и с какими проблемами пришлось столкнуться на пути интеграции Инкоманд с Microsoft Graph API.
Архитектура Proxy Object Storage в Инкоманд
Инкоманд построен на Liferay, и его подсистема Objects поддерживает концепцию Object Entry Manager — менеджера, отвечающего за хранение и извлечение записей объекта. По умолчанию записи хранятся в базе данных, но платформа позволяет зарегистрировать альтернативные реализации со своим storage.type.
Именно это мы и сделали: реализовали собственный ObjectEntryManager с типом хранилища sharepoint, который все CRUD-операции перенаправляет в Microsoft Graph API. Получилась архитектура из двух модулей:
|
Модуль |
Назначение |
|---|---|
|
|
API-модуль: конфигурация подключения (tenant ID, client ID, client secret, site URL) |
|
|
Имплементация: основная логика коннектора, аутентификация, конвертация фильтров |
Ключевое преимущество такого подхода: объекты SharePoint выглядят для пользователя как обычные объекты Инкоманд — работают те же представления, фильтры, поиск и пагинация. Никакого дополнительного UI-кода.
Регистрация коннектора в OSGi
Точкой входа служит OSGi-компонент SharePointObjectEntryManagerImpl:
@Component( property = "object.entry.manager.storage.type=sharepoint", service = ObjectEntryManager.class)public class SharePointObjectEntryManagerImpl extends BaseObjectEntryManager implements ObjectEntryManager { // ...}
Ключевое здесь — свойство object.entry.manager.storage.type=sharepoint. Инкоманд использует его для динамического поиска нужного менеджера. Когда пользователь создаёт Object Definition и указывает ему storage.type = "sharepoint", платформа автоматически находит наш компонент.
Конфигурация: как настроить подключение
Для каждого сайта администратор может задать параметры подключения к SharePoint через стандартный интерфейс Инкоманд (Site Settings → Third Party). Конфигурация описывается интерфейсом с аннотациями OSGi Metatype:
@ExtendedObjectClassDefinition( category = "third-party", scope = ExtendedObjectClassDefinition.Scope.GROUP)@Meta.OCD( id = "...SharePointConfiguration", localization = "content/Language", name = "sharepoint-configuration-name")public interface SharePointConfiguration { @Meta.AD(name = "tenant-id", required = false) public String tenantId(); @Meta.AD(name = "client-id", required = false) public String clientId(); @Meta.AD(name = "client-secret", required = false, type = Meta.Type.Password) public String clientSecret(); @Meta.AD(name = "site-url", required = false) public String siteUrl();}
Обратите внимание на scope GROUP — это позволяет разным сайтам портала подключаться к разным SharePoint-сайтам.
Аутентификация: OAuth 2.0 Client Credentials
Первый камень преткновения — доступ к данным. SharePoint Online через Microsoft Graph API требует OAuth 2.0 токен. Используем поток client_credentials, который не требует участия пользователя — идеально для серверной интеграции.
Класс SharePointTokenManager решает две основные проблемы: получение токена и его кеширование с учётом времени жизни.
@Component(service = SharePointTokenManager.class)public class SharePointTokenManager { private final Map<String, CachedToken> tokenCache = new ConcurrentHashMap<>(); public String getAccessToken(SharePointConfiguration config) throws Exception { String cacheKey = config.tenantId() + "_" + config.clientId(); CachedToken cachedToken = tokenCache.get(cacheKey); if ((cachedToken != null) && !cachedToken.isExpired()) { return cachedToken.getAccessToken(); } return fetchNewToken(config, cacheKey); } private synchronized String fetchNewToken( SharePointConfiguration config, String cacheKey) throws Exception { // Double-check после захвата блокировки CachedToken cachedToken = tokenCache.get(cacheKey); if ((cachedToken != null) && !cachedToken.isExpired()) { return cachedToken.getAccessToken(); } String tokenUrl = "https://login.microsoftonline.com/" + config.tenantId() + "/oauth2/v2.0/token"; String body = "grant_type=client_credentials" + "&client_id=" + URLCodec.encodeURL(config.clientId()) + "&client_secret=" + URLCodec.encodeURL(config.clientSecret()) + "&scope=" + URLCodec.encodeURL( "https://graph.microsoft.com/.default"); // ... HTTP POST, парсинг JSON-ответа ... int expiresIn = jsonResponse.getInt("expires_in"); // Вычитаем 60 секунд — обновляем токен заранее, // чтобы избежать 401 в момент истечения tokenCache.put( cacheKey, new CachedToken( accessToken, System.currentTimeMillis() + ((expiresIn - 60) * 1000L))); // ... }}
Что здесь интересного
-
Double-check locking: метод
fetchNewTokenсинхронизирован, но перед этим делается быстрая проверка без блокировки. После захвата монитора — повторная проверка. Исключает ситуацию, когда несколько потоков одновременно запрашивают токен. -
Запас в 60 секунд: токен кешируется не на полный
expires_in, а с вычетом минуты. Это предохраняет от использования токена, который истечёт через долю секунды после отправки запроса. -
Ключ кеша составной:
tenantId + "_" + clientId— так как теоретически разные сайты могут использовать разные Azure AD приложения.
Права приложения в Azure AD
Для работы с Graph API в Azure AD нужно зарегистрировать приложение и выдать ему Application permissions типа Sites.ReadWrite.All. Это нетривиальный момент: прав Sites.Read.All недостаточно, даже если мы только читаем — Graph API требует ReadWrite для некоторых операций со списками.
CRUD: как списки SharePoint становятся объектами Инкоманд
Маппинг сущностей
Самый важный этап проектирования — маппинг между мирами:
|
Концепция Инкоманд |
Сущность SharePoint |
|---|---|
|
|
Имя списка SharePoint (например |
|
|
Внутреннее имя колонки (например |
|
|
ID элемента списка |
Использование externalReferenceCode — ключевое архитектурное решение. Благодаря ему администратор сам связывает поля объекта с колонками SharePoint через UI, без необходимости править код при изменении схемы списка.
Чтение элемента списка
Рассмотрим самый простой запрос — получение одного элемента:
@Overridepublic ObjectEntry getObjectEntry( long companyId, DTOConverterContext dtoConverterContext, String externalReferenceCode, ObjectDefinition objectDefinition, String scopeKey) throws Exception { // Проверяем права пользователя checkPortletResourcePermission( ActionKeys.VIEW, objectDefinition, scopeKey, dtoConverterContext.getUser()); SharePointConfiguration config = getSharePointConfiguration( companyId, getGroupId(objectDefinition, scopeKey)); String listName = objectDefinition.getExternalReferenceCode(); String siteId = resolveSiteId(config); String endpoint = GRAPH_API_BASE + "/sites/" + siteId + "/lists/" + URLCodec.encodeURL(listName) + "/items/" + externalReferenceCode + "?expand=fields"; JSONObject responseJSON = executeGraphRequest( config, endpoint, Http.Method.GET, null); return toObjectEntry(responseJSON, objectDefinition, dtoConverterContext);}
Конечная точка Graph API имеет вид:
GET https://graph.microsoft.com/v1.0/sites/{siteId}/lists/{listName}/items/{itemId}?expand=fields
Параметр ?expand=fields критически важен: без него Graph API не возвращает значения колонок — только системные поля (id, createdDateTime, etc.).
Создание элемента
@Overridepublic ObjectEntry addObjectEntry( DTOConverterContext dtoConverterContext, ObjectDefinition objectDefinition, ObjectEntry objectEntry, String scopeKey) throws Exception { // Извлекаем поля из DTO Map<String, Object> properties = objectEntry.getProperties(); // Строим JSON с полями JSONObject fieldsJSON = buildFieldsJSON(properties, objectDefinition); JSONObject requestJSON = jsonFactory.createJSONObject(); requestJSON.put("fields", fieldsJSON); String endpoint = GRAPH_API_BASE + "/sites/" + siteId + "/lists/" + listName + "/items"; JSONObject responseJSON = executeGraphRequest( config, endpoint, Http.Method.POST, requestJSON); return toObjectEntry(responseJSON, objectDefinition, dtoConverterContext);}
Обратите внимание: Graph API ожидает специальную структуру запроса, где значения колонок обёрнуты в объект fields:
{ "fields": { "Title": "ООО Новая компания", "INN": "7701234567", "City": "Москва" }}
Проблема №1: PATCH-запросы
При обновлении элемента SharePoint нужно послать запрос не на сам элемент, а на его подобъект /fields:
PATCH /sites/{id}/lists/{name}/items/{itemId}/fields
Казалось бы, ничего сложного. Но выяснилось, что Liferay-класс com.liferay.portal.kernel.util.Http не поддерживает HTTP-метод PATCH. В перечислении Http.Method есть только GET, POST, PUT, DELETE.
При этом Graph API отклоняет PUT — нужно именно PATCH. Как быть?
Решение: для POST и PATCH используем стандартный java.net.http.HttpClient (доступный с Java 11), оставляя GET и DELETE на Liferay Http:
private String _executeRequestViaHttpClient( String url, String method, String accessToken, JSONObject requestBody) throws Exception { String body = (requestBody != null) ? requestBody.toString() : ""; HttpRequest request = HttpRequest.newBuilder() .uri(URI.create(url)) .method( method, HttpRequest.BodyPublishers.ofString( body, StandardCharsets.UTF_8)) .header("Authorization", "Bearer " + accessToken) .header("Accept", "application/json") .header("Content-Type", "application/json") .build(); HttpResponse<String> response = HttpClient.newHttpClient().send( request, HttpResponse.BodyHandlers.ofString()); return response.body();}
Метод HttpRequest.Builder.method() позволяет указать произвольное имя HTTP-метода — в том числе PATCH.
Проблема №2: пагинация
Самый коварный сюрприз ждал при реализации списка элементов. Классическая offset-пагинация ($top + $skip) прекрасно работает для большинства endpoint’ов Graph API… но не для list items.
Для эндпоинта /sites/{siteId}/lists/{name}/items Microsoft Graph API:
-
Не поддерживает
$skip— вообще нет такого параметра -
Не даёт стабильного
$count— нельзя получить общее количество записей -
Вместо этого предлагает курсорную пагинацию через
@odata.nextLink
Платформа Инкоманд, в свою очередь, ожидает именно offset-пагинацию с параметрами startPosition и endPosition. Что делать?
Применили прагматичный подход:
private void appendPagination(StringBuilder sb, Pagination pagination) { // Запрашиваем на 1 элемент больше, чем нужно для страницы sb.append("&$top="); sb.append(pagination.getEndPosition() + 1);}
А в Java-коде отрезаем нужный диапазон и определяем наличие следующей страницы по наличию «лишнего» элемента:
int startPosition = pagination.getStartPosition();int endPosition = pagination.getEndPosition();if (allEntries.size() > endPosition) {// Есть ещё элементы — следующая страница существуетpageEntries = allEntries.subList(startPosition, endPosition);totalCount = endPosition + pagination.getPageSize();} else if (allEntries.size() > startPosition) {pageEntries = allEntries.subList(startPosition, allEntries.size());totalCount = allEntries.size();} else {pageEntries = Collections.emptyList();totalCount = allEntries.size();}
Недостаток подхода очевиден: мы не можем точно сказать пользователю, сколько всего записей в списке. Вместо этого показываем оценку — «как минимум столько-то». Для сценария временной миграции это приемлемый компромисс.
Проблема №3: конвертация фильтров
Инкоманд использует OData-синтаксис для фильтрации объектов. Graph API тоже поддерживает OData, но в своём диалекте. Различия существенные:
-
Имена полей: в Инкоманд фильтр ссылается на внутренние имена полей объекта (
City eq 'Moscow'), а SharePoint требует префиксfields/(fields/City eq 'Moscow') -
Функции: Инкоманд поддерживает
contains(), а Graph API для list items — толькоstartsWith() -
Picklist-поля: в объектах Инкоманд это ссылка на
ListTypeEntryс внутренним ключом, а в SharePoint — строковое значениеChoice-колонки
Для конвертации написан ExpressionVisitor, который обходит AST OData-выражения и преобразует их на лету:
public class SharePointQueryExpressionVisitorImpl implements ExpressionVisitor<Object> { @Override public String visitBinaryExpressionOperation( BinaryExpression.Operation operation, Object left, Object right) { // Преобразуем имя поля: "City" → "fields/City" ObjectField objectField = objectFieldLocalService.fetchObjectField( objectDefinitionId, (String)left); if (objectField != null) { left = "fields/" + objectField.getExternalReferenceCode(); right = StringUtil.unquote((String)right); // Для Picklist преобразуем ключ в значение SharePoint if (objectField.compareBusinessType("Picklist")) { String spValue = picklistKeyToSharePointValue( objectField.getListTypeDefinitionId(), (String)right); if (spValue != null) { right = spValue; } } } // Собираем выражение: fields/City eq 'Moscow' // ... } @Override public Object visitMethodExpression( List<Object> expressions, MethodExpression.Type type) { // Graph API поддерживает только startsWith — // оба метода (CONTAINS и STARTS_WITH) мапятся на startsWith if (type == MethodExpression.Type.CONTAINS || type == MethodExpression.Type.STARTS_WITH) { return "startsWith(" + fieldName + ", '" + value + "')"; } throw new UnsupportedOperationException(); }}
Регистрируется фабрика фильтров как OSGi-компонент с ключом sharepoint:
@Component( property = "filter.factory.key=sharepoint", service = FilterFactory.class)public class SharePointFilterFactoryImpl extends BaseFilterFactory<String> implements FilterFactory<String> { // ...}
Инкоманд находит её по ключу и использует для преобразования фильтров, переданных через UI.
Проблема №4: системные поля SharePoint и маппинг дат
SharePoint хранит служебную информацию об элементе: кто создал, когда изменил, ссылку на элемент. Эту информацию мы тоже отображаем в карточке объекта Инкоманд — через специальные системные поля:
// Маппинг в toObjectEntry()if ("createDate".equals(fieldName)) {value = parseDate(jsonObject.getString("createdDateTime")); } else if ("modifiedDate".equals(fieldName)) {value = parseDate(jsonObject.getString("lastModifiedDateTime")); } else if ("creator".equals(fieldName)) {// createdBy.user.displayNameJSONObject createdBy = jsonObject.getJSONObject("createdBy"); if (createdBy != null) {JSONObject user = createdBy.getJSONObject("user"); if (user != null) {value = user.getString("displayName"); } } } else if ("url".equalsIgnoreCase(fieldName)) {value = jsonObject.getString("webUrl");}
Эти же поля мы помечаем как read-only и исключаем из запросов на создание/обновление — SharePoint управляет ими сам:
private static final Set<String> SYSTEM_FIELD_NAMES = Set.of("createDate", "modifiedDate", "creator", "modifier", "url", "link");
Ещё один нюанс — формат дат. Graph API возвращает timestamp’ы в ISO 8601: 2024-01-15T10:30:00Z. Инкоманд ожидает java.util.Date. Для парсинга используем SimpleDateFormat — не самое современное решение, но надёжное в серверном контексте:
private DateFormat getDateFormat() { return new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'");}
Проблема №5: поиск по текстовым полям
Полнотекстовый поиск по элементам списка SharePoint через Graph API отсутствует как класс. Но пользователям портала нужно искать. Реализовали эмуляцию: при вводе поискового запроса проходим по всем текстовым полям объекта и строим OR-фильтр из startsWith():
private void appendSearch( StringBuilder sb, ObjectDefinition objectDefinition, String search) { StringBuilder searchFilter = new StringBuilder(); for (ObjectField objectField : objectFields) { if (!objectField.compareBusinessType("Text")) continue; if (searchFilter.length() > 0) { searchFilter.append(" or "); } searchFilter.append( "startsWith(fields/" + spFieldName + ", '" + search + "')"); } // Всегда добавляем поиск по Title searchFilter.append(" or startsWith(fields/Title, '" + search + "')"); // ...}
Это не замена полнотекстовому поиску, но для большинства сценариев работает достаточно хорошо.
Проблема №6: разрешение ID сайта
Graph API идентифицирует сайт по ID (GUID), а не по URL. Но администратору естественнее указать в настройках именно URL — https://company.sharepoint.com/sites/demo. Поэтому при первом обращении мы разрешаем URL в siteId через специальный endpoint:
private String resolveSiteId(SharePointConfiguration config) throws Exception { URL url = new URL(config.siteUrl()); String hostname = url.getHost(); String path = url.getPath(); String endpoint = GRAPH_API_BASE + "/sites/" + hostname + ":/" + path; JSONObject responseJSON = executeGraphRequest( config, endpoint, Http.Method.GET, null); String siteId = responseJSON.getString("id"); siteIdCache.put(cacheKey, siteId); return siteId;}
Endpoint для разрешения использует необычный синтаксис с двоеточием: /sites/{hostname}:/{path} — это задокументированная фича Graph API для поиска сайта по его «веб-адресу». Полученный GUID кешируется в ConcurrentHashMap на всё время жизни сервера.
Что получилось в итоге
В итоге мы получили прозрачную интеграцию, которая позволяет:
-
Создать Object Definition в UI Инкоманд и указать ему storage type =
sharepoint -
Настроить поля объекта, привязав их к колонкам SharePoint через ERC
-
Использовать стандартные виджеты Инкоманд (таблицы, карточки, фильтры) для отображения и редактирования данных SharePoint
-
Работать с данными как с обычными объектами — с поиском, сортировкой и пагинацией
Ключевые архитектурные решения, которые можно переиспользовать для интеграции с другими системами:
-
Adapter Pattern: весь
ObjectEntryManager— это адаптер между моделью “Объектф Инкоманд” и внешним API -
ERC как мост: использование External Reference Code для маппинга полей и сущностей
-
Фильтры через Visitor: преобразование OData AST в нативный формат целевой системы
-
Кеширование с double-check locking: для токенов, site ID и других редко меняющихся данных
-
Работа с ограничениями API: когда целевая система не поддерживает нужную операцию — ищем прагматичный workaround
ссылка на оригинал статьи https://habr.com/ru/articles/1028932/