В новой версии платформы Инкоманд появился 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, без необходимости править код при изменении схемы списка.
Чтение элемента списка
Рассмотрим самый простой запрос — получение одного элемента:
@Override
public 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.).
Создание элемента
@Override
public 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.displayName
JSONObject 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— это адаптер между моделью “Объектф Инкоманд” и внешним APIERC как мост: использование External Reference Code для маппинга полей и сущностей
Фильтры через Visitor: преобразование OData AST в нативный формат целевой системы
Кеширование с double-check locking: для токенов, site ID и других редко меняющихся данных
Работа с ограничениями API: когда целевая система не поддерживает нужную операцию — ищем прагматичный workaround

























