Files
tnsu-approval-route/backend/Tnsu.ApprovalRoute.Infrastructure/SharePoint/SharePointClient.cs
T
gorbenko.pavel 82512eb1db
Tests / frontend-test (push) Successful in 44s
Tests / backend-test (push) Failing after 17m4s
Архивация списков (#13)
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz>
Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/13
2026-08-19 09:08:36 +00:00

323 lines
18 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;
/// <summary>Список SharePoint: Id нужен для операций над колонками, url — как ссылка на список.</summary>
public sealed record SharePointListInfo(Guid Id, string Title, string ServerRelativeUrl, int ItemCount);
/// <summary>Колонка списка в виде её SchemaXml — в таком виде её можно создать в другом списке.</summary>
public sealed record SharePointFieldSchema(string InternalName, string SchemaXml);
public interface ISharePointClient
{
/// <summary>Список по серверному относительному url. null — списка нет.</summary>
Task<SharePointListInfo?> GetListByUrlAsync(
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>Список по заголовку. null — списка нет.</summary>
Task<SharePointListInfo?> GetListByTitleAsync(
string siteUrl, string listTitle, CancellationToken cancellationToken);
/// <summary>
/// Пользовательские колонки списка — те, что нужно повторить в дубле. Системные
/// (унаследованные от базового типа, скрытые, только для чтения) отфильтрованы:
/// они появляются в новом списке сами.
/// </summary>
Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>Создать пустой список (GenericList с версионированием) и вернуть его.</summary>
Task<SharePointListInfo> CreateListAsync(string siteUrl, string title, CancellationToken cancellationToken);
/// <summary>Создать колонку в списке из её SchemaXml.</summary>
Task AddFieldFromXmlAsync(string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken);
/// <summary>Заголовок списка по его серверному относительному URL (см. RequestType.ListId).</summary>
Task<string?> GetListTitleAsync(string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken);
/// <summary>
/// Число элементов в списке (ItemCount самого списка, без выгрузки элементов) —
/// по нему решается, не пора ли заводить следующий список серии.
/// </summary>
Task<int?> GetItemCountAsync(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<SharePointListInfo?> GetListByUrlAsync(
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken) =>
GetListInfoAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')",
cancellationToken);
public Task<SharePointListInfo?> GetListByTitleAsync(
string siteUrl, string listTitle, CancellationToken cancellationToken) =>
GetListInfoAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')",
cancellationToken);
private async Task<SharePointListInfo?> GetListInfoAsync(
string siteUrl, string listUrl, CancellationToken cancellationToken)
{
var url = $"{listUrl}?$select=Id,Title,ItemCount,RootFolder/ServerRelativeUrl&$expand=RootFolder";
var json = await TryGetAsync(siteUrl, url, cancellationToken);
if (json is null) return null;
var value = json.Value;
var serverRelativeUrl = value.TryGetProperty("RootFolder", out var rootFolder)
&& rootFolder.TryGetProperty("ServerRelativeUrl", out var relative)
? relative.GetString()
: null;
if (serverRelativeUrl is null) return null;
return new SharePointListInfo(
value.GetProperty("Id").GetGuid(),
value.GetProperty("Title").GetString() ?? string.Empty,
serverRelativeUrl,
value.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var items) ? items : 0);
}
public async Task<IReadOnlyList<SharePointFieldSchema>> GetCustomFieldsAsync(
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
{
// Ни FromBaseType, ни CanBeDeleted не годятся как признак «колонку явно
// добавили в список»: у переиспользуемых стандартных site-колонок SharePoint
// (например, «Department» — встроенная текстовая колонка из схемы v3,
// подключаемая через DelayActivateTemplateBinding) ОБА флага стоят как у
// настоящего системного поля, хотя колонка реально добавлена в список и в
// неё пишут данные. Из-за этого такие колонки тихо выпадали из копирования —
// без ошибки, просто без предупреждения.
//
// Поэтому здесь фильтруем только Hidden/ReadOnlyField (их создать всё равно
// нельзя), а отделять настоящие базовые поля (Title, ID, ContentType …) от
// явно добавленных — задача вызывающего кода: он сравнивает с полями
// ТОЛЬКО ЧТО СОЗДАННОГО пустого списка того же шаблона (см. CloneAsync,
// alreadyThere) — то, что есть на обоих, действительно базовое; то, что
// есть только на источнике, добавлено явно, независимо от флагов SharePoint.
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
"?$select=InternalName,SchemaXml,Hidden,ReadOnlyField" +
"&$filter=Hidden eq false and ReadOnlyField eq false";
var json = await GetAsync(siteUrl, url, cancellationToken);
var rows = json.TryGetProperty("value", out var value) && value.ValueKind == JsonValueKind.Array
? value.EnumerateArray()
: json.EnumerateArray();
var fields = new List<SharePointFieldSchema>();
foreach (var row in rows)
{
var internalName = row.TryGetProperty("InternalName", out var name) ? name.GetString() : null;
var schemaXml = row.TryGetProperty("SchemaXml", out var schema) ? schema.GetString() : null;
if (internalName is null || schemaXml is null) continue;
fields.Add(new SharePointFieldSchema(internalName, schemaXml));
}
return fields;
}
public async Task<SharePointListInfo> CreateListAsync(
string siteUrl, string title, CancellationToken cancellationToken)
{
// BaseTemplate 100 — GenericList, как -Template GenericList у провижинера.
await PostAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/lists",
JsonSerializer.Serialize(new { Title = title, BaseTemplate = 100, EnableVersioning = true }),
cancellationToken);
// Ответ POST'а не отдаёт RootFolder, а нам нужен серверный относительный url —
// дочитываем список отдельным запросом.
return await GetListByTitleAsync(siteUrl, title, cancellationToken)
?? throw new InvalidOperationException($"Список «{title}» создан, но не читается обратно.");
}
public Task AddFieldFromXmlAsync(
string siteUrl, Guid listId, string schemaXml, CancellationToken cancellationToken) =>
PostAsync(
siteUrl,
$"{siteUrl.TrimEnd('/')}/_api/web/lists(guid'{listId}')/fields/createfieldasxml",
// Options = 8 (AddFieldInternalNameHint): SharePoint обязан сохранить
// InternalName из SchemaXml, а не сгенерировать свой. К колонкам обращается
// код портала именно по внутреннему имени, так что это не косметика.
JsonSerializer.Serialize(new { parameters = new { SchemaXml = schemaXml, Options = 8 } }),
cancellationToken);
public async Task<int?> GetItemCountAsync(
string siteUrl, string listServerRelativeUrl, CancellationToken cancellationToken)
{
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')?$select=ItemCount";
var json = await GetAsync(siteUrl, url, cancellationToken);
return json.TryGetProperty("ItemCount", out var count) && count.TryGetInt32(out var value) ? value : 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) =>
await SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: false, cancellationToken)
?? throw new InvalidOperationException($"SharePoint не вернул тело на {url}.");
/// <summary>GET, для которого «нет такого списка» — не ошибка, а null.</summary>
private Task<JsonElement?> TryGetAsync(string siteUrl, string url, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Get, siteUrl, url, body: null, allowNotFound: true, cancellationToken);
private Task PostAsync(string siteUrl, string url, string json, CancellationToken cancellationToken) =>
SendAsync(HttpMethod.Post, siteUrl, url, json, allowNotFound: false, cancellationToken);
private async Task<JsonElement?> SendAsync(
HttpMethod method,
string siteUrl,
string url,
string? body,
bool allowNotFound,
CancellationToken cancellationToken)
{
using var request = new HttpRequestMessage(method, url);
request.Headers.Authorization = new AuthenticationHeaderValue(
"Bearer", await GetTokenAsync(siteUrl, cancellationToken));
request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")
{
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
});
if (body is not null)
{
request.Content = new StringContent(body);
// odata=nometadata и на запись: тогда в теле не нужен блок __metadata с
// типом сущности — SharePoint выводит тип из самого эндпоинта.
request.Content.Headers.ContentType = new MediaTypeHeaderValue("application/json")
{
Parameters = { new NameValueHeaderValue("odata", "nometadata") }
};
}
using var response = await httpClient.SendAsync(request, cancellationToken);
var responseBody = await response.Content.ReadAsStringAsync(cancellationToken);
if (allowNotFound && response.StatusCode == System.Net.HttpStatusCode.NotFound)
return null;
if (!response.IsSuccessStatusCode)
throw new InvalidOperationException(
$"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}");
if (string.IsNullOrWhiteSpace(responseBody)) return null;
using var document = JsonDocument.Parse(responseBody);
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("'", "''");
}