d076f4e98d
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/11
146 lines
7.2 KiB
C#
146 lines
7.2 KiB
C#
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("'", "''");
|
||
}
|