джоба есть
Tests / frontend-test (pull_request) Successful in 47s
Tests / backend-test (pull_request) Successful in 2m7s

This commit is contained in:
gorbenko.pavel
2026-08-17 14:14:45 +05:00
parent 48ede1420d
commit 2a7130c37f
25 changed files with 1533 additions and 5 deletions
@@ -7,11 +7,45 @@ 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);
@@ -50,6 +84,105 @@ public sealed class SharePointClient(
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 отсеивает колонки базового типа (Title, Author, Created …) —
// они есть в любом новом списке. Hidden/ReadOnlyField — служебные, их создать
// всё равно нельзя. Фильтр в OData, а не в C#, чтобы не тащить лишнее по сети.
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/fields" +
"?$select=InternalName,SchemaXml,FromBaseType,Hidden,ReadOnlyField" +
"&$filter=Hidden eq false and ReadOnlyField eq false and FromBaseType 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(
@@ -79,9 +212,26 @@ public sealed class SharePointClient(
: [];
}
private async Task<JsonElement> GetAsync(string siteUrl, string url, CancellationToken cancellationToken)
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(HttpMethod.Get, url);
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")
@@ -89,14 +239,30 @@ public sealed class SharePointClient(
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 body = await response.Content.ReadAsStringAsync(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} на {url}: {body}");
$"SharePoint ответил {(int)response.StatusCode} на {method} {url}: {responseBody}");
using var document = JsonDocument.Parse(body);
if (string.IsNullOrWhiteSpace(responseBody)) return null;
using var document = JsonDocument.Parse(responseBody);
return document.RootElement.Clone();
}