основное сделано
This commit is contained in:
@@ -0,0 +1,237 @@
|
||||
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("'", "''");
|
||||
}
|
||||
Reference in New Issue
Block a user