Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
T
gorbenko.pavel d076f4e98d
Tests / backend-test (push) Successful in 1m50s
Tests / frontend-test (push) Successful in 49s
Sla (#11)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
2026-08-13 04:44:08 +00:00

146 lines
7.2 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.</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>
/// Доступ к 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 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("'", "''");
}