Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
T
2026-08-12 15:12:11 +05:00

238 lines
12 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
{
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>
/// Серверный относительный URL списка, найденного по заголовку — нужен, чтобы
/// построить условие Path в KQL-запросе (см. SearchListItemIdsAsync).
/// </summary>
Task<string?> GetListServerRelativeUrlAsync(string siteUrl, string listTitle, CancellationToken cancellationToken);
/// <summary>Элементы списка данных, найденного по серверному относительному URL.</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken);
/// <summary>Элементы списка, найденного по заголовку (списки согласования ищутся именно так).</summary>
Task<IReadOnlyList<JsonElement>> GetItemsByListTitleAsync(
string siteUrl, string listTitle, string odataQuery, CancellationToken cancellationToken);
/// <summary>
/// Id элементов, найденных Search'ем по KQL-запросу. Именно так, а не $filter,
/// сужается набор строк списка согласования по статусу (Choice-колонка) —
/// $filter по ней в SharePoint REST ненадёжен: см. подробный разбор в
/// request-portal/myRequests.service.ts (MP_STATUS/ApprovalStatusOWSCHCS,
/// тот же список, тот же managed property, подтверждено рабочим). Значения
/// колонок не возвращает — только Id, дочитывать поля нужно отдельным REST-запросом.
/// </summary>
Task<IReadOnlyList<int>> SearchListItemIdsAsync(
string siteUrl, string queryText, int rowLimit, CancellationToken cancellationToken);
}
/// <summary>
/// Доступ к SharePoint REST от имени приложения (сертификат из таблицы settings).
///
/// Сырой HttpClient, а не PnP.Core/CSOM — по образцу api-tnsu-portal и потому,
/// что нужно всего одно действие: прочитать элементы двух списков. Тянуть ради
/// этого клиентскую библиотеку со своей объектной моделью незачем.
///
/// Клиент намеренно «глупый»: отдаёт сырой JSON и ничего не знает ни про SLA, ни
/// про строки маршрута — вся логика напоминаний живёт в Application.
/// </summary>
public sealed class SharePointClient(
HttpClient httpClient,
ICertificateProvider certificateProvider,
IOptions<SharePointOptions> options) : ISharePointClient
{
// Токен запрашивается на КОНКРЕТНЫЙ хост SharePoint, поэтому и кеш по хосту:
// списки разных типов заявок могут жить на разных сайтах.
private readonly ConcurrentDictionary<string, AccessToken> _tokens = new(StringComparer.OrdinalIgnoreCase);
private ClientCertificateCredential? _credential;
private readonly SemaphoreSlim _credentialLock = new(1, 1);
public async Task<string?> 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<string?> 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<IReadOnlyList<int>> 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<int> 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<int>();
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<JsonElement> 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<IReadOnlyList<JsonElement>> GetItemsByListUrlAsync(
string siteUrl, string listServerRelativeUrl, string odataQuery, CancellationToken cancellationToken) =>
GetItemsAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items?{odataQuery}",
cancellationToken);
public Task<IReadOnlyList<JsonElement>> 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<IReadOnlyList<JsonElement>> 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<JsonElement> 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<string> 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<ClientCertificateCredential> 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("'", "''");
}