惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

L
LINUX DO - 最新话题
小众软件
小众软件
C
Check Point Blog
酷 壳 – CoolShell
酷 壳 – CoolShell
V
Visual Studio Blog
Last Week in AI
Last Week in AI
P
Proofpoint News Feed
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
The GitHub Blog
The GitHub Blog
T
Tailwind CSS Blog
Recorded Future
Recorded Future
雷峰网
雷峰网
WordPress大学
WordPress大学
A
Arctic Wolf
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
Project Zero
Project Zero
T
Tor Project blog
T
Threat Research - Cisco Blogs
Scott Helme
Scott Helme
Spread Privacy
Spread Privacy
G
Google Developers Blog
Security Latest
Security Latest
MongoDB | Blog
MongoDB | Blog
T
Threatpost
I
InfoQ
T
Tenable Blog
T
The Exploit Database - CXSecurity.com
S
Security Affairs
H
Hackread – Cybersecurity News, Data Breaches, AI and More
人人都是产品经理
人人都是产品经理
C
Cisco Blogs
博客园 - 三生石上(FineUI控件)
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
aimingoo的专栏
aimingoo的专栏
C
CXSECURITY Database RSS Feed - CXSecurity.com
美团技术团队
Google DeepMind News
Google DeepMind News
The Hacker News
The Hacker News
D
Docker
博客园 - 【当耐特】
大猫的无限游戏
大猫的无限游戏
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
Vercel News
Vercel News
PCI Perspectives
PCI Perspectives
Microsoft Azure Blog
Microsoft Azure Blog
C
CERT Recently Published Vulnerability Notes
月光博客
月光博客
Cloudbric
Cloudbric
A
About on SuperTechFans
F
Fortinet All Blogs

Все публикации подряд на Хабре

Ловим музу за клавиатуру: как айтишнику стать автором Что умеет Midjourney в 2026? Мой немного грустный разбор этого шикарного инструмента Никто не любит писать тесты, но ИИ может исправить это IPv8 выглядит как мечта. Поэтому почти наверняка не взлетит Производители вернули в продажу материнки с DDR3. Что происходит? Управление агентом с телефона через Telegram теперь в KodaCode От координации к лидерству: как меняется роль руководителя разработки Я сделала родителям бизнес вместо пенсии: зарабатываем 70 тысяч, мама не даёт продать В три раза быстрее приемка товара и оптимизация трудозатрат на 73%: как «РСТ-Инвент» помог Gulliver Group ИИ-шечный мир победил? О влиянии искусственного интеллекта на игропром Кремль снижает давление на Телеграмм пока Европа строит интернет по паспорту Как CEO, CTO и CIO за 8 часов собрали ИИ-директора, который умеет держать позицию под давлением Как (не) потерять домен за выходные Вместо 8 разных VPS: как я организовал практику студентам на одном сервере Почему твой Open Source проект не замечают? R&D: искусство управления неопределенностью в разработке AI-дефляция: вакансий для разработчиков больше, а рост зарплат — худший за 15 лет Мы отдали управление роботами OpenClaw. Что из этого вышло Галактический ID: система идентификации для всех форм разумной жизни Шесть основ бизнес-анализа: начинаем с вопроса «Кто в игре?» Код-ревью, в котором дело не в коде Данные переехали. Команда — нет Системной подход к сдаче OSWE в 2025 Почему комната управления реактором покрашена в цвет морской пены 4 YAML-файла вместо PySpark: как аналитикам строить пайплайны без разработчиков LLM-агент для поиска свободных доменов: автоматизируем подбор Когда, зачем и как правильно начинать новую сессию в Claude Code? Как я заставил нейросеть писать макросы для FreeCAD Анатомия ИИ‑агента для подбора персонала. От тысячи резюме к топ‑10 за минуты Опыт разработчика как экономика внимания Автономность как точка невозврата: кто будет субъектом в цифровом будущем Обучение ИИ в «диких» условиях: как рутинные действия превращаются в датасеты Как измерить LLM для задач кибербеза: обзор открытых бенчмарков Где хранить код? Сравнение GitHub, GitLab и Bitbucket Математика объясняет, почему нормальное распределение встречается повсюду Почему ваш FinOps не работает: 12 тезисов от практиков Как подписать проектную документацию УКЭП с использованием бесплатных лицензий Pilot Адаптивное администрирование Sigla Vision Я грузил уран в бочки, а потом 20 лет строил ИТ в атомной отрасли Чем позвонить с Эвереста? История и обзор спутниковой связи. Часть 2 Как языковая модель помогает контролировать качество инструктажей по охране труда в металлургии Как не передать на desktop свой IP в РКН Анатомия SAP Privileges: как устроено управление правами в macOS MoneyDev: Сказка про три главных слова Обновлённый токенизатор видео K-VAE 2.0 от Сбера Как сделать диспетчеризацию дома на 1284 квартиры почти бесплатно Как мы разогнали железную дорогу Мы дали агентам рутину. Теперь надо решить — что делать с освободившимся временем Токсичный контент, промпт-хакинг и защита ИИ — всё о Guardrails для LLM Умный город начинается с точного взгляда: как «Фалькон Тех» меняет пространство к лучшему Навайбкодил приложение для анализа графов Почему Дюну так интересно читать? Упрощаем работу с рутиной или как стать Гендальфом Белым Деконструкция Go: CPU, RAM и что там происходит. Go Assembler база. Часть 1.1 Какие профессии исчезнут из-за ИИ, а какие появятся? И что с этим делать Как мы построили IT-отдел, где хочется расти: архитектурные встречи, прозрачные метрики и книжные подарки Rufler: Делаем из Claude Code автономный рой через один YAML-конфиг Sing-box и белый список приложений Как построить надёжный обмен сообщениями в микросервисах: лучшие практики для enterprise OpenAI строит MLM-пирамиду, а McKinsey и Accenture помогают ей в этом Дом, который не построил Фишер (Часть 2) «Сверхзвуковой математик» против «Вдумчивого логиста»: битва алгоритмов 3D-упаковки Мультимодальные модели – грубый и дорогой инструмент Разговоры ничего не стоят. Код тоже Проверки физических лиц: с кого начнет ФНС Топ-10 бесплатных нейросетей для создания видео в 2026 году Первые слои кода: как наши решения сегодня определяют архитектуру ИИ на десятилетия Разработка нового статического анализатора: PVS-Studio JavaScript Поиск уязвимостей ПО: базовый минимум или роскошный максимум Почему оценка персонала не работает как инструмент управления Как мы разработали ИИ-ассистента и сократили рутину продуктовой команды на 50% Как я ушел из найма, нажарил косточек и продал на маркетплейсах на 168 млн в год Когда 1С:ERP уже внедрена, а нормального производственного плана всё ещё нет Как я сделал Claude мультимодальным, подключив к нему Qwen Omni Как приглашение на вакансию мечты превращается в атаку Infrastructure as Code: философия и лучшие практики IaC Тестируем Yandex Code Assistant на задаче, в которой нужно хранить секреты nxs-universal-chart v3.0: новое поколение универсального Helm-чарта Callback Injection: Техника, которая отправила Microsoft Defender в глухой нокаут «Все идеи на стол»: митап как способ вывести проект из тупика Сегодня я узнал нечто новое о GPU благодаря багу в своей игре Как заставить LLM ̶ ̶г̶а̶л̶л̶ю̶ ̶ эволюционировать Карта событий как фундамент аналитики: практический кейс для E-commerce Что выбрать для AI: x86, ARM или RISC-V? Дайджест железа за март Роль соматических мутаций в развитии аутоиммунных заболеваний: путь к избирательной терапии Mythos от Anthropic — тревожный сигнал для всех, а не только для банков Guardrails для LLM на Java: как приручить промпт‑инъекции и токсичные ответы Green-VLA: как мы собрали VLA-модель для реального антропоморфного робота и не потеряли обобщение Финансовая гонка вооружений: почему умные люди добровольно в ней участвуют Эра ИИ-агентов наступила: выбираем лучшего цифрового сотрудника # Практический опыт внедрения WinCC Redundancy на производственном предприятии Сделал MVP за 3 дня, а потом неделю прикручивал оплату. Оно того стоило? Физика против Маска: почему Starship V3 может оказаться ещё одной катастрофой Нефть Венесуэлы: крупнейшие запасы в мире, но не крупнейшая нефтяная держава JPA 4. Переосмысление Hibernate Почему зеркальная фотокамера Nikon D5 десятилетней давности идеально подошла для миссии «Артемида-2» Проект «Уровень-Спутник» или как мы сделали платформу для гидрологов «Замедлиться, чтобы ускориться»: почему ИИ повышает цену ошибок в требованиях и архитектуре Как с нуля поднять трафик IT-компании на 1657% при бюджете 55 тыс. и выжить Pixel-perfect Downsampling — идеальная отрисовка 50 миллионов точек без потерь
Отображаем списки SharePoint в корпоративном портале: опыт реализации Proxy Object Storage для Инкоманд
akakunin · 2026-04-28 · via Все публикации подряд на Хабре

Уровень сложностиСредний

Время на прочтение12 мин

Охват и читатели186

Туториал

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