2b8fe77fac
Co-authored-by: gorbenko.pavel <gorbenko.pavel@tansu.kz> Reviewed-on: http://git.tnsu.kz/gorbenko.pavel/tnsu-approval-route/pulls/21
374 lines
21 KiB
C#
374 lines
21 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;
|
||
|
||
/// <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>
|
||
/// Обновить поля элемента списка, найденного по заголовку. fields — анонимный
|
||
/// объект «внутреннее имя колонки → значение»; перечисленные колонки
|
||
/// перезаписываются, остальные остаются как были.
|
||
/// </summary>
|
||
Task UpdateItemByListTitleAsync(
|
||
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken);
|
||
|
||
/// <summary>То же по серверному относительному url списка (см. RequestType.ListId).</summary>
|
||
Task UpdateItemByListUrlAsync(
|
||
string siteUrl, string listServerRelativeUrl, int itemId, object fields, 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);
|
||
|
||
public Task UpdateItemByListTitleAsync(
|
||
string siteUrl, string listTitle, int itemId, object fields, CancellationToken cancellationToken) =>
|
||
MergeAsync(
|
||
siteUrl,
|
||
$"{siteUrl.TrimEnd('/')}/_api/web/lists/getByTitle('{Escape(listTitle)}')/items({itemId})",
|
||
fields,
|
||
cancellationToken);
|
||
|
||
public Task UpdateItemByListUrlAsync(
|
||
string siteUrl, string listServerRelativeUrl, int itemId, object fields, CancellationToken cancellationToken) =>
|
||
MergeAsync(
|
||
siteUrl,
|
||
$"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})",
|
||
fields,
|
||
cancellationToken);
|
||
|
||
/// <summary>
|
||
/// Частичное обновление элемента: у SharePoint REST это POST с заголовком
|
||
/// X-HTTP-Method: MERGE (PATCH он не понимает), и обязательным If-Match.
|
||
///
|
||
/// If-Match: * — намеренно «перезаписать, чей бы ни был последний етаг», а не
|
||
/// оптимистичная блокировка по конкретной версии. Пишем мы точечно, по
|
||
/// отдельным колонкам, а единственный конкурент — человек, принявший решение
|
||
/// по тому же шагу вручную; его защищает не етаг, а проверка статуса строки
|
||
/// перед записью (см. AutoApproveService). Реальный етаг пришлось бы читать
|
||
/// отдельным запросом, и он всё равно устаревал бы к моменту записи.
|
||
/// </summary>
|
||
private Task MergeAsync(string siteUrl, string url, object fields, CancellationToken cancellationToken) =>
|
||
SendAsync(
|
||
HttpMethod.Post, siteUrl, url, JsonSerializer.Serialize(fields),
|
||
allowNotFound: false, cancellationToken, merge: true);
|
||
|
||
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,
|
||
bool merge = false)
|
||
{
|
||
using var request = new HttpRequestMessage(method, url);
|
||
if (merge)
|
||
{
|
||
request.Headers.Add("X-HTTP-Method", "MERGE");
|
||
request.Headers.Add("If-Match", "*");
|
||
}
|
||
|
||
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("'", "''");
|
||
}
|