В новой версии платформы Инкоманд появился 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. Получилась архитектура из двух модулей:

Модуль

Назначение

object-storage-sp-api

API-модуль: конфигурация подключения (tenant ID, client ID, client secret, site URL)

object-storage-sp-impl

Имплементация: основная логика коннектора, аутентификация, конвертация фильтров

Ключевое преимущество такого подхода: объекты 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)));
        // ...
    }
}

Что здесь интересного

  1. Double-check locking: метод fetchNewToken синхронизирован, но перед этим делается быстрая проверка без блокировки. После захвата монитора - повторная проверка. Исключает ситуацию, когда несколько потоков одновременно запрашивают токен.

  2. Запас в 60 секунд: токен кешируется не на полный expires_in, а с вычетом минуты. Это предохраняет от использования токена, который истечёт через долю секунды после отправки запроса.

  3. Ключ кеша составной: tenantId + "_" + clientId - так как теоретически разные сайты могут использовать разные Azure AD приложения.

Права приложения в Azure AD

Для работы с Graph API в Azure AD нужно зарегистрировать приложение и выдать ему Application permissions типа Sites.ReadWrite.All. Это нетривиальный момент: прав Sites.Read.All недостаточно, даже если мы только читаем - Graph API требует ReadWrite для некоторых операций со списками.

CRUD: как списки SharePoint становятся объектами Инкоманд

Маппинг сущностей

Самый важный этап проектирования - маппинг между мирами:

Концепция Инкоманд

Сущность SharePoint

ObjectDefinition.externalReferenceCode

Имя списка SharePoint (например Контрагенты)

ObjectField.externalReferenceCode

Внутреннее имя колонки (например INN, City)

ObjectEntry.externalReferenceCode

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, но в своём диалекте. Различия существенные:

  1. Имена полей: в Инкоманд фильтр ссылается на внутренние имена полей объекта (City eq 'Moscow'), а SharePoint требует префикс fields/ (fields/City eq 'Moscow')

  2. Функции: Инкоманд поддерживает contains(), а Graph API для list items - только startsWith()

  3. 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 на всё время жизни сервера.

Что получилось в итоге

В итоге мы получили прозрачную интеграцию, которая позволяет:

  1. Создать Object Definition в UI Инкоманд и указать ему storage type = sharepoint

  2. Настроить поля объекта, привязав их к колонкам SharePoint через ERC

  3. Использовать стандартные виджеты Инкоманд (таблицы, карточки, фильтры) для отображения и редактирования данных SharePoint

  4. Работать с данными как с обычными объектами - с поиском, сортировкой и пагинацией

Ключевые архитектурные решения, которые можно переиспользовать для интеграции с другими системами:

  • Adapter Pattern: весь ObjectEntryManager - это адаптер между моделью “Объектф Инкоманд” и внешним API

  • ERC как мост: использование External Reference Code для маппинга полей и сущностей

  • Фильтры через Visitor: преобразование OData AST в нативный формат целевой системы

  • Кеширование с double-check locking: для токенов, site ID и других редко меняющихся данных

  • Работа с ограничениями API: когда целевая система не поддерживает нужную операцию - ищем прагматичный workaround