Отображаем списки SharePoint в корпоративном портале: опыт реализации Proxy Object Storage для Инкоманд

от автора

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

Чтение элемента списка

Рассмотрим самый простой запрос — получение одного элемента:

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

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

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

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

  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

ссылка на оригинал статьи https://habr.com/ru/articles/1028932/