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; public interface ISharePointClient { /// Заголовок списка по его серверному относительному URL (см. RequestType.ListId). Task GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken); /// /// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы /// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync). /// Task GetListServerRelativeUrlAsync(string siteUrl, string listTitle, CancellationToken cancellationToken); /// Элементы списка данных, найденного по серверному относительному URL. Task> GetItemsByListUrlAsync( string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken); /// Элементы списка, найденного по заголовку (списки согласования ищутся именно так). Task> GetItemsByListTitleAsync( string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken); /// /// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter, /// сужается набор строк списка согласования по статусу (Choice-колонка) — /// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в /// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS, /// тот же список, тот же managed property, подтверждено рабочим). Значения /// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом. /// Task> SearchListItemIdsAsync( string siteUrl, string queryText, int rowLimit, 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 async Task GetListServerRelativeUrlAsync( string siteUrl, string listTitle, CancellationToken cancellationToken) { var url = $"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')" + "?$select=RootFolder/ServerRelativeUrl&$expand=RootFolder"; var json = await GetAsync(siteUrl, url, cancellationToken); return ParseServerRelativeUrl(json); } public async Task> SearchListItemIdsAsync( string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken) { var url = $"{siteUrl.TrimEnd('/')}/_api/search/query" + $"?querytext='{Uri.EscapeDataString(queryText)}'" + "&selectproperties='ListItemID'" + $"&rowlimit={rowLimit}" + "&trimduplicates=false"; var json = await GetAsync(siteUrl, url, cancellationToken); return ParseSearchListItemIds(json); } // Разбор вынесен в чистые статические функции — они не ходят в сеть и не // требуют токена, поэтому их (в отличие от остального клиента) можно // проверить тестом без живого SharePoint. Это самая непроверенная часть // клиента: форма ответа Search REST зафиксирована документацией, но без // реального тенанта убедиться в точном соответствии нельзя. internal static string? ParseServerRelativeUrl(JsonElement json) => json.TryGetProperty("RootFolder", out var rootFolder) && rootFolder.TryGetProperty("ServerRelativeUrl", out var relUrl) ? relUrl.GetString() : null; internal static IReadOnlyList ParseSearchListItemIds(JsonElement json) { // Путь до строк результата — фиксированная форма ответа Search REST, // не зависит от запроса. С odata=nometadata Cells приходит уже "голым" // массивом (без обёртки .results) — но на случай, если сервер всё равно // завернёт его (разные версии API отвечали по-разному на практике в этом // проекте, см. комментарии в request-portal про managed properties), // читаем и то, и то (см. Unwrap). if (!json.TryGetProperty("PrimaryQueryResult", out var primary)) return []; if (!primary.TryGetProperty("RelevantResults", out var relevant)) return []; if (!relevant.TryGetProperty("Table", out var table)) return []; if (!table.TryGetProperty("Rows", out var rowsElement)) return []; var ids = new List(); foreach (var row in Unwrap(rowsElement)) { if (!row.TryGetProperty("Cells", out var cellsElement)) continue; foreach (var cell in Unwrap(cellsElement)) { if (!cell.TryGetProperty("Key", out var key) || key.GetString() != "ListItemID") continue; if (!cell.TryGetProperty("Value", out var value)) continue; if (int.TryParse(value.GetString(), out var id)) ids.Add(id); } } return ids; } // odata=nometadata отдаёт коллекции внутри Search-ответа "голым" массивом; // без этого заголовка (или в старых версиях API) — обёрткой { results: [...] }. private static IEnumerable Unwrap(JsonElement element) { if (element.ValueKind == JsonValueKind.Array) return element.EnumerateArray(); return element.TryGetProperty("results", out var results) && results.ValueKind == JsonValueKind.Array ? results.EnumerateArray() : []; } 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); 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) { using var request = new HttpRequestMessage(HttpMethod.Get, url); request.Headers.Authorization = new AuthenticationHeaderValue( "Bearer", await GetTokenAsync(siteUrl, cancellationToken)); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json") { Parameters = { new NameValueHeaderValue("odata", "nometadata") } }); using var response = await httpClient.SendAsync(request, cancellationToken); var body = await response.Content.ReadAsStringAsync(cancellationToken); if (!response.IsSuccessStatusCode) throw new InvalidOperationException( $"SharePoint ответил {(int)response.StatusCode} на {url}: {body}"); using var document = JsonDocument.Parse(body); 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("'", "''"); }