a46782920b
GET /api/basis-requests?requestTypeId=&basisKey= читает заявки от имени приложения по всем партициям, а по каждой проверяет, может ли вызывающий её открыть. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
460 lines
25 KiB
C#
460 lines
25 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>
|
||
/// Может ли пользователь (по email) открыть элемент списка: эффективные права
|
||
/// человека на элемент с учётом разорванного наследования, флаг ViewListItems.
|
||
/// Неизвестный SharePoint логин — не ошибка, а «прав нет».
|
||
/// </summary>
|
||
Task<bool> CanUserViewItemAsync(
|
||
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken);
|
||
|
||
/// <summary>
|
||
/// Запрос в SharePoint Search (postquery) от имени приложения — видит ВСЕ
|
||
/// элементы, а не только доступные пользователю. Возвращает строки результата
|
||
/// как «имя managed property → значение» (RowLimit — не больше 500).
|
||
/// Индекс отстаёт от списков, поэтому найденное нужно перепроверять по REST.
|
||
/// </summary>
|
||
Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, 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 async Task<bool> CanUserViewItemAsync(
|
||
string siteUrl, string listServerRelativeUrl, int itemId, string userEmail, CancellationToken cancellationToken)
|
||
{
|
||
// Claims-логин Entra ID в SharePoint Online. Передаём через параметр-псевдоним
|
||
// @u: в пути функции «|» и «#» SharePoint не разбирает.
|
||
var login = Uri.EscapeDataString($"i:0#.f|membership|{userEmail.Trim().ToLowerInvariant()}");
|
||
var url = $"{siteUrl.TrimEnd('/')}/_api/web/getList('{Escape(listServerRelativeUrl)}')/items({itemId})" +
|
||
$"/getUserEffectivePermissions(@u)?@u='{login}'";
|
||
var json = await GetAsync(siteUrl, url, cancellationToken);
|
||
|
||
// BasePermissions приходит как {High, Low} — числа строками. ViewListItems — бит 0x1 в Low.
|
||
return json.TryGetProperty("Low", out var low)
|
||
&& ulong.TryParse(low.ValueKind == JsonValueKind.String ? low.GetString() : low.GetRawText(), out var bits)
|
||
&& (bits & ViewListItemsMask) != 0;
|
||
}
|
||
|
||
public async Task<IReadOnlyList<IReadOnlyDictionary<string, string?>>> SearchAsync(
|
||
string siteUrl, string queryText, IReadOnlyList<string> selectProperties, CancellationToken cancellationToken)
|
||
{
|
||
var body = JsonSerializer.Serialize(new
|
||
{
|
||
request = new
|
||
{
|
||
Querytext = queryText,
|
||
RowLimit = 500,
|
||
SelectProperties = selectProperties,
|
||
TrimDuplicates = false
|
||
}
|
||
});
|
||
|
||
var json = await SendAsync(
|
||
HttpMethod.Post, siteUrl, $"{siteUrl.TrimEnd('/')}/_api/search/postquery", body,
|
||
allowNotFound: false, cancellationToken);
|
||
|
||
var result = new List<IReadOnlyDictionary<string, string?>>();
|
||
if (json is null
|
||
|| !json.Value.TryGetProperty("PrimaryQueryResult", out var primary)
|
||
|| !primary.TryGetProperty("RelevantResults", out var relevant)
|
||
|| !relevant.TryGetProperty("Table", out var table)
|
||
|| !table.TryGetProperty("Rows", out var rows))
|
||
return result;
|
||
|
||
// nometadata отдаёт Rows массивом, verbose — обёрнутым в results; терпим оба.
|
||
if (rows.ValueKind == JsonValueKind.Object && rows.TryGetProperty("results", out var wrapped))
|
||
rows = wrapped;
|
||
if (rows.ValueKind != JsonValueKind.Array) return result;
|
||
|
||
foreach (var row in rows.EnumerateArray())
|
||
{
|
||
if (!row.TryGetProperty("Cells", out var cells)) continue;
|
||
if (cells.ValueKind == JsonValueKind.Object && cells.TryGetProperty("results", out var wrappedCells))
|
||
cells = wrappedCells;
|
||
|
||
var dict = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
|
||
foreach (var cell in cells.EnumerateArray())
|
||
{
|
||
var key = cell.TryGetProperty("Key", out var k) ? k.GetString() : null;
|
||
if (key is null) continue;
|
||
dict[key] = cell.TryGetProperty("Value", out var v) && v.ValueKind == JsonValueKind.String ? v.GetString() : null;
|
||
}
|
||
|
||
result.Add(dict);
|
||
}
|
||
|
||
return result;
|
||
}
|
||
|
||
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();
|
||
}
|
||
}
|
||
|
||
private const ulong ViewListItemsMask = 0x1;
|
||
|
||
// Одинарная кавычка в OData-литерале экранируется её удвоением. Заголовки
|
||
// списков содержат тире и кириллицу, но кавычка теоретически возможна —
|
||
// без экранирования она сломала бы запрос.
|
||
private static string Escape(string value) => value.Replace("'", "''");
|
||
}
|