using System.Collections.Concurrent; using System.Net.Http.Headers; using System.Text.Json; using Azure.Core; using Azure.Identity; using Microsoft.Extensions.Options; namespace Tnsu.ApprovalRoute.Infrastructure.SharePoint; /// Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список. public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount); /// Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке. public sealed record SharePointFieldSchema(string InternalName, string SchemaXml); public interface ISharePointClient { /// Список по серверному относительному url. null — списка нет. Task GetListByUrlAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); /// Список по заголовку. null — списка нет. Task GetListByTitleAsync( string siteUrl, string listTitle, CancellationToken cancellationToken); /// /// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные /// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы: /// они появляются в новом списке сами. /// Task> GetCustomFieldsAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); /// Создать пустой список (GenericList с версионированием) и вернуть его. Task CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken); /// Создать колонку в списке из её SchemaXml. Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken); /// Заголовок списка по его серверному относительному URL (см. RequestType.ListId). Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); /// /// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) — /// по нему решается, не пора ли заводить следующий список серии. /// Task GetItemCountAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); /// Элементы списка данных, найденного по серверному относительному URL. Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); /// Элементы списка, найденного по заголовку (списки согласования ищутся именно так). Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); /// /// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ /// элементы, а не только доступные пользователю. Возвращает строки результата /// как «имя managed property → значение» (RowLimit — не больше 500). /// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST. /// Task>> SearchAsync( string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken); /// /// Обновить поля элемента списка, найденного по заголовку. fields — анонимный /// объект «внутреннее имя колонки → значение»; перечисленные колонки /// перезаписываются, остальные остаются как были. /// Task UpdateItemByListTitleAsync( string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken); /// То же по серверному относительному url списка (см. RequestType.ListId). Task UpdateItemByListUrlAsync( string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken); } /// /// Доступ к SharePoint REST от имени приложения (сертификат из таблицы settings). /// /// Сырой HttpClient, а не PnP.Core/CSOM — по образцу api-tnsu-portal и потому, /// что нужно всего одно действие: прочитать элементы двух списков. Тянуть ради /// этого клиентскую библиотеку со своей объектной моделью незачем. /// /// Клиент намеренно «глупый»: отдаёт сырой JSON и ничего не знает ни про SLA, ни /// про строки маршрута — вся логика напоминаний живёт в Application. /// public sealed class SharePointClient( HttpClient httpClient, ICertificateProvider certificateProvider, IOptions options) : ISharePointClient { // Токен запрашивается на КОНКРЕТНЫЙ хост SharePoint, поэтому и кеш по хосту: // списки разных типов заявок могут жить на разных сайтах. private readonly ConcurrentDictionary _tokens = new(StringComparer.OrdinalIgnoreCase); private ClientCertificateCredential? _credential; private readonly SemaphoreSlim _credentialLock = new(1, 1); public async Task GetListTitleAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) { var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=Title"; var json = await GetAsync(siteUrl, url, cancellationToken); return json.TryGetProperty("Title", out var title) ? title.GetString() : null; } public Task GetListByUrlAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) => GetListInfoAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')", cancellationToken); public Task GetListByTitleAsync( string siteUrl, string listTitle, CancellationToken cancellationToken) => GetListInfoAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')", cancellationToken); private async Task GetListInfoAsync( string siteUrl, string listUrl, CancellationToken cancellationToken) { var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder"; var json = await TryGetAsync(siteUrl, url, cancellationToken); if (json is null) return null; var value = json.Value; var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder) && rootFolder.TryGetProperty("ServerRelativeUrl", out var relative) ? relative.GetString() : null; if (serverRelativeUrl is null) return null; return new SharePointListInfo( value.GetProperty("Id").GetGuid(), value.GetProperty("Title").GetString() ?? string.Empty, serverRelativeUrl, value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0); } public async Task> GetCustomFieldsAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) { // Ни FromBaseType, ни CanBeDeleted не годятся как признак «колонку явно // добавили в список»: у переиспользуемых стандартных site-колонок SharePoint // (например, «Department» — встроенная текстовая колонка из схемы v3, // подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у // настоящего системного поля, хотя колонка реально добавлена в список и в // неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования — // без ошибки, просто без предупреждения. // // Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно // нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от // явно добавленных — задача вызывающего кода: он сравнивает с полями // ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync, // alreadyThere) — то, что есть на обоих, действительно базовое; то, что // есть только на источнике, добавлено явно, независимо от флагов SharePoint. var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" + "?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" + "&$filter=Hidden eq false and ReadOnlyField eq false"; var json = await GetAsync(siteUrl, url, cancellationToken); var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array ? value.EnumerateArray() : json.EnumerateArray(); var fields = new List(); foreach (var row in rows) { var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null; var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null; if (internalName is null || schemaXml is null) continue; fields.Add(new SharePointFieldSchema(internalName, schemaXml)); } return fields; } public async Task CreateListAsync( string siteUrl, string title, CancellationToken cancellationToken) { // BaseTemplate 100 — GenericList, как -Template GenericList у провижинера. await PostAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/lists", JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }), cancellationToken); // Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url — // дочитываем список отдельным запросом. return await GetListByTitleAsync(siteUrl, title, cancellationToken) ?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно."); } public Task AddFieldFromXmlAsync( string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) => PostAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml", // Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить // InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается // код портала именно по внутреннему имени, так что это не косметика. JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }), cancellationToken); public async Task GetItemCountAsync( string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) { var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount"; var json = await GetAsync(siteUrl, url, cancellationToken); return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : null; } public Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) => GetItemsAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items?{odataQuery}", cancellationToken); public Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken) => GetItemsAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items?{odataQuery}", cancellationToken); public async Task>> SearchAsync( string siteUrl, string queryText, IReadOnlyList selectProperties, CancellationToken cancellationToken) { var body = JsonSerializer.Serialize(new { request = new { Querytext = queryText, RowLimit = 500, SelectProperties = selectProperties, TrimDuplicates = false } }); var json = await SendAsync( HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body, allowNotFound: false, cancellationToken); var result = new List>(); if (json is null || !json.Value.TryGetProperty("PrimaryQueryResult", out var primary) || !primary.TryGetProperty("RelevantResults", out var relevant) || !relevant.TryGetProperty("Table", out var table) || !table.TryGetProperty("Rows", out var rows)) return result; // nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба. if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped)) rows = wrapped; if (rows.ValueKind != JsonValueKind.Array) return result; foreach (var row in rows.EnumerateArray()) { if (!row.TryGetProperty("Cells", out var cells)) continue; if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells)) cells = wrappedCells; var dict = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var cell in cells.EnumerateArray()) { var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null; if (key is null) continue; dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null; } result.Add(dict); } return result; } public Task UpdateItemByListTitleAsync( string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) => MergeAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items({itemId})", fields, cancellationToken); public Task UpdateItemByListUrlAsync( string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken) => MergeAsync( siteUrl, $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})", fields, cancellationToken); /// /// Частичное обновление элемента: у SharePoint REST это POST с заголовком /// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match. /// /// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не /// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по /// отдельным колонкам, а единственный конкурент — человек, принявший решение /// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки /// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать /// отдельным запросом, и он всё равно устаревал бы к моменту записи. /// private Task MergeAsync(string siteUrl, string url, object fields, CancellationToken cancellationToken) => SendAsync( HttpMethod.Post, siteUrl, url, JsonSerializer.Serialize(fields), allowNotFound: false, cancellationToken, merge: true); private async Task> GetItemsAsync( string siteUrl, string url, CancellationToken cancellationToken) { var json = await GetAsync(siteUrl, url, cancellationToken); // odata=nometadata отдаёт элементы в "value"; на всякий случай терпим и // «голый» массив, если заголовок Accept вдруг не подействует. if (json.ValueKind == JsonValueKind.Array) return json.EnumerateArray().ToList(); return json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array ? value.EnumerateArray().ToList() : []; } private async Task GetAsync(string siteUrl, string url, CancellationToken cancellationToken) => await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken) ?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}."); /// GET, для которого «нет такого списка» — не ошибка, а null. private Task TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) => SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken); private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) => SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken); private async Task SendAsync( HttpMethod method, string siteUrl, string url, string? body, bool allowNotFound, CancellationToken cancellationToken, bool merge = false) { using var request = new HttpRequestMessage(method, url); if (merge) { request.Headers.Add("X-HTTP-Method", "MERGE"); request.Headers.Add("If-Match", "*"); } request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", await GetTokenAsync(siteUrl, cancellationToken)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") { Parameters = { new NameValueHeaderValue("odata", "nometadata") } }); if (body is not null) { request.Content = new StringContent(body); // odata=nometadata и на запись: тогда в теле не нужен блок __metadata с // типом сущности — SharePoint выводит тип из самого эндпоинта. request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json") { Parameters = { new NameValueHeaderValue("odata", "nometadata") } }; } using var response = await httpClient.SendAsync(request, cancellationToken); var responseBody = await response.Content.ReadAsStringAsync(cancellationToken); if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound) return null; if (!response.IsSuccessStatusCode) throw new InvalidOperationException( $"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}"); if (string.IsNullOrWhiteSpace(responseBody)) return null; using var document = JsonDocument.Parse(responseBody); return document.RootElement.Clone(); } private async Task GetTokenAsync(string siteUrl, CancellationToken cancellationToken) { var host = new Uri(siteUrl).Host; // Токен живой ещё пять минут — переиспользуем. Запас нужен, чтобы токен // не истёк между проверкой и приходом запроса в SharePoint. if (_tokens.TryGetValue(host, out var cached) && cached.ExpiresOn > DateTimeOffset.UtcNow.AddMinutes(5)) return cached.Token; var credential = await GetCredentialAsync(cancellationToken); var token = await credential.GetTokenAsync( new TokenRequestContext([$"https://{host}/.default"]), cancellationToken); _tokens[host] = token; return token.Token; } private async Task GetCredentialAsync(CancellationToken cancellationToken) { if (_credential is not null) return _credential; await _credentialLock.WaitAsync(cancellationToken); try { if (_credential is not null) return _credential; var certificate = await certificateProvider.GetCertificateAsync(cancellationToken); _credential = new ClientCertificateCredential( options.Value.TenantId, options.Value.ClientId, certificate); return _credential; } finally { _credentialLock.Release(); } } // Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки // списков содержат тире и кириллицу, но кавычка теоретически возможна — // без экранирования она сломала бы запрос. private static string Escape(string value) => value.Replace("'", "''"); }