REST-методы для работы с multipart¶
Необходимо разработать REST-методы для сохранения карточки с файлами и получения контента указанного файла. При этом тип контента запроса (в первом случае) и ответа (во втором) должен быть установлен как multipart/form-data
.
Метод для сохранения карточки с файлами должен обрабатывать тело запроса вида:
------WebKitFormBoundaryQPR1lrdOn8TYHhPu
Content-Disposition: form-data; name="Header"
{"Info":null,"Files":{"51629da9-d6f9-4d11-897d-c14b9a27c2a4":{"Info":null,"Size":3765,"Order::int":0},"66793b55-85bb-473a-a0ad-bcdb747bb9e7":{"Info":null,"Size":1722,"Order::int":1}},"OperationID::uid":"4583b598-abab-480c-af65-54bf10f284d9"}
------WebKitFormBoundaryQPR1lrdOn8TYHhPu
Content-Disposition: form-data; name="Request"
{"Info":{".digest":"Исхпр-000007"},"Card":{"CreatedByName":"Admin","Files":[{"Info":null,"CategoryID":null,"CategoryCaption":null,"OriginalFileID":null,"OriginalVersionRowID": ... }
------WebKitFormBoundaryQPR1lrdOn8TYHhPu
Content-Disposition: form-data; name="Файл 1.txt"; filename="Файл 1.txt"
Content-Type: text/plain
<Текстовое содержимое файла>
------WebKitFormBoundaryQPR1lrdOn8TYHhPu
Content-Disposition: form-data; name="Файл 2.txt"; filename="Файл 2.txt"
Content-Type: text/plain
<Текстовое содержимое файла>
------WebKitFormBoundaryQPR1lrdOn8TYHhPu--
А в результате запроса на получение контента тело ответа должно иметь следующее содержимое:
--8d28e37a-9cc6-48fc-af81-09f6637ab81b
Content-Type: application/json; charset=utf-8
Content-Disposition: form-data; name=Response
{"HasContent":true,"Info":{".suggestedFileName":"Document.pdf"},"Size":19995,"ValidationResult":{"Items":null}}
--8d28e37a-9cc6-48fc-af81-09f6637ab81b
Content-Type: application/octet-stream
Content-Length: 19995
Content-Disposition: form-data; name=Content; filename=Document.pdf; filename*=utf-8''Document.pdf
<Cодержимое файла>
--8d28e37a-9cc6-48fc-af81-09f6637ab81b
Создание контроллера¶
Для обработки HTTP-запросов в проекте Tessa.Extensions.Server.Web
необходимо создать контроллер с соответствующим набором методов:
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Http.Features;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.ModelBinding;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.Options;
using Microsoft.Net.Http.Headers;
using Tessa.Cards;
using Tessa.Cards.ComponentModel;
using Tessa.Platform.Data;
using Tessa.Platform.IO;
using Tessa.Platform.Runtime;
using Tessa.Platform.Validation;
using Tessa.Web;
using Tessa.Web.Serialization;
using Tessa.Web.Services;
namespace Tessa.Extensions.Server.Web
{
[Route("multipart"), ApiController]
[ProducesErrorResponseType(typeof(PlainValidationResult))]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
[ProducesResponseType(StatusCodes.Status401Unauthorized)]
public sealed class MultipartController :
Controller
{
#region Private Fields
private readonly ICardStreamServerRepository cardStreamServerRepository;
private readonly IOptions<FormOptions> formOptions;
#endregion
#region Constructor
public MultipartController(
ICardStreamServerRepository cardStreamServerRepository,
IOptions<FormOptions> formOptions)
{
this.cardStreamServerRepository = NotNullOrThrow(cardStreamServerRepository);
this.formOptions = NotNullOrThrow(formOptions);
}
#endregion
#region Get file content
/// <summary>
/// Метод, принимающий запрос на получение файла в формате TypedJSON и возвращающий его контент.
/// </summary>
/// <param name="request">Запрос на получение файла.</param>
/// <param name="cancellationToken">
/// Объект, посредством которого можно отменить асинхронную операцию.
/// </param>
/// <returns>Ответ на запрос и контент файла в виде мультипарта.</returns>
// POST multipart/get-file-content
[HttpPost("get-file-content"), SessionMethod, TypedJsonBody]
[Consumes(MediaTypeNames.Application.Json)]
[Produces(MediaTypes.MultipartFormData, MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK, Type = typeof(MultipartContent))]
public async Task<MultipartResult> PostGetFileContent(
[FromBody] CardGetFileContentRequest request,
CancellationToken cancellationToken = default)
{
ThrowIfNull(request);
// Если не установить ServiceType, то запрос выполнится
// с пропуском ряда проверок на валидность запроса и прав пользователя.
request.ServiceType = CardServiceType.Client;
request.SuggestFileName = true;
ICardFileContentResult contentResult =
await this.cardStreamServerRepository.GetFileContentAsync(request, cancellationToken);
CardGetFileContentResponse contentResponse = contentResult.Response;
var multipartContent = new MultipartFormDataContent
{
// Ответ на запрос получения файла хранится в части мультипарта "Response".
{ TessaHttpContent.FromJson(contentResponse.ToTypedJson()), "Response" }
};
Stream? stream = null;
try
{
if (contentResult.HasContent)
{
// Задание имени файла для указания в мультипарте.
string? fileName = contentResponse.TryGetSuggestedFileName();
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = request.FileName;
if (string.IsNullOrWhiteSpace(fileName))
{
fileName = "file";
}
}
stream = await contentResult.GetContentOrThrowAsync(cancellationToken);
// Сам контент файла хранится в части мультипарта "Content".
multipartContent.Add(TessaHttpContent.FromStream(stream), "Content", fileName);
}
MultipartResult result = this.Multipart(multipartContent);
// Поток не надо закрывать в случае успеха.
stream = null;
return result;
}
finally
{
// Поток необходимо закрыть в случае ошибки.
if (stream is not null)
{
await stream.DisposeAsync();
}
}
}
#endregion
#region Store card with files
/// <summary>
/// Метод принимающий запрос на сохранение карточки с файлами.
/// </summary>
/// <param name="cancellationToken">
/// Объект, посредством которого можно отменить асинхронную операцию.
/// </param>
/// <returns>Ответ на запрос сохранения карточки.</returns>
// POST multipart/store-files
[HttpPost("store-files"), SessionMethod, DisableRequestSizeLimit, DisableFormValueModelBinding]
[Consumes(MediaTypes.MultipartFormData)]
[Produces(MediaTypeNames.Application.Json)]
[ProducesResponseType(StatusCodes.Status200OK)]
public async Task<ActionResult<CardStoreResponse>> PostStoreFiles(
CancellationToken cancellationToken = default)
{
// Проверка, что тип контента установлен как мультипарт.
if (!WebHelper.IsMultipartContentType(this.Request.ContentType))
{
throw new InvalidDataException(
$"Expected a multipart request, but got {this.Request.ContentType}");
}
// Получение обозначения границы и проверка, что её длина валидна
// (по умолчанию не более 128 байт).
string boundary = WebHelper.GetBoundary(
MediaTypeHeaderValue.Parse(this.Request.ContentType),
this.formOptions.Value.MultipartBoundaryLengthLimit);
// Получение максимальной длины тела запроса (по умолчанию 128МБ).
long? multipartBodyLengthLimit = this.formOptions.Value.MultipartBodyLengthLimit;
if (multipartBodyLengthLimit is < 0L or long.MaxValue)
{
multipartBodyLengthLimit = null;
}
var reader = new MultipartReader(boundary, this.HttpContext.Request.Body)
{ BodyLengthLimit = multipartBodyLengthLimit };
// Считывание данных формы.
(KeyValueAccumulator formAccumulator, MultipartSection? section) =
await ReadMultipartParametersAsync(reader, cancellationToken);
// Извлечение заголовка со списком ID файлов и запроса на сохранение файла из формы.
(CardHeader? header, CardStoreRequest? request) =
await this.TryGetHeaderAndRequestAsync(formAccumulator);
if (header is null || request is null)
{
throw new InvalidOperationException(
"Header or request isn't found when requesting to store files.");
}
var orderedFiles = header.GetOrderedFiles().ToArray();
var filesToStore = new List<ICardFileContentProvider>(orderedFiles.Length);
if (orderedFiles.Length > 0)
{
// Первый файл уже считан из мультипарта после выхода из ReadMultipartParametersAsync.
filesToStore.Add(
new CardFileContentProvider(
orderedFiles[0].ID,
orderedFiles[0].Size,
_ => ValueTask.FromResult(section?.Body)));
// Остальные файлы, при их наличии, считываются здесь.
filesToStore.AddRange(
orderedFiles
.Skip(1)
.Select(headerFile =>
new CardFileContentProvider(
headerFile.ID,
headerFile.Size,
async ct => (await reader.ReadNextSectionAsync(ct))!.Body))
.ToArray());
}
// Если не установить ServiceType, то запрос выполнится с пропуском ряда проверок
// на валидность запроса и прав пользователя.
request.ServiceType = CardServiceType.Client;
CardStoreResponse storeResponse = await this.cardStreamServerRepository.StoreAsync(
request, filesToStore, header.OperationID, cancellationToken);
if (!storeResponse.ValidationResult.IsSuccessful())
{
// Необходимо прочитать запрос до конца, чтобы не упасть с ошибкой
// из-за непрочитанного тела запроса, если в процессе сохранения
// в репозитории возникла ошибка.
await this.Request.Body.DrainAsync(cancellationToken);
}
return await this.TypedJsonAsync(storeResponse, cancellationToken: cancellationToken);
}
/// <summary>
/// Считывание данных формы и первого файла.
/// </summary>
/// <param name="reader">Объект, с помощью которого можно читать мультипарт из тела запроса.</param>
/// <param name="cancellationToken">
/// Объект, посредством которого можно отменить асинхронную операцию.
/// </param>
/// <returns>
/// Возвращает объект для чтение простых данных из формы и первый считанный файл в виде секции.
/// </returns>
private static async Task<(KeyValueAccumulator, MultipartSection?)> ReadMultipartParametersAsync(
MultipartReader reader,
CancellationToken cancellationToken = default)
{
var formAccumulator = new KeyValueAccumulator();
MultipartSection? section = await reader.ReadNextSectionAsync(cancellationToken);
while (section is not null)
{
// Проверка на то, что для секции указано её расположение, а также получение его значения.
bool hasContentDispositionHeader = ContentDispositionHeaderValue.TryParse(
section.ContentDisposition,
out ContentDispositionHeaderValue? contentDisposition);
if (hasContentDispositionHeader)
{
Debug.Assert(contentDisposition is not null);
// Если дошли до контента файла, необходимо остановиться,
// т.к. файлы будут обработаны позже вне этого метода.
if (contentDisposition.IsFileDisposition())
{
break;
}
// Если текущая секция относится к данным формы, необходимо их сохранить.
if (contentDisposition.IsFormDisposition())
{
string key = NotNullOrThrow(
HeaderUtilities.RemoveQuotes(contentDisposition.Name).Value);
using var streamReader = new StreamReader(
section.Body,
Encoding.UTF8,
detectEncodingFromByteOrderMarks: true,
bufferSize: 1024,
leaveOpen: true);
string value = await streamReader.ReadToEndAsync(cancellationToken);
if (string.Equals(value, "undefined", StringComparison.OrdinalIgnoreCase))
{
value = string.Empty;
}
formAccumulator.Append(key, value);
if (formAccumulator.ValueCount > FormReader.DefaultValueCountLimit)
{
throw new InvalidDataException(
$"Form key count limit {FormReader.DefaultValueCountLimit} exceeded.");
}
}
}
section = await reader.ReadNextSectionAsync(cancellationToken);
}
return (formAccumulator, section);
}
/// <summary>
/// Получение данных запроса из формы.
/// </summary>
/// <param name="formAccumulator">Объект для чтения данных из формы.</param>
/// <returns>
/// Возвращает:
/// <list type="bullet">
/// <item>в CardHeader - список файлов и их размеров;</item>
/// <item>в CardStoreRequest - запрос на сохранение карточки.</item>
/// </list>
/// </returns>
private async ValueTask<(CardHeader?, CardStoreRequest?)> TryGetHeaderAndRequestAsync(
KeyValueAccumulator formAccumulator)
{
var formRequest = new CardStoreFilesRequest();
// Связывание данных формы с запрашиваемой моделью.
var formValueProvider = new FormValueProvider(
BindingSource.Form,
new FormCollection(formAccumulator.GetResults()),
CultureInfo.InvariantCulture);
bool bindingSuccessful = await this.TryUpdateModelAsync(
formRequest, prefix: string.Empty, valueProvider: formValueProvider);
if (!bindingSuccessful && !this.ModelState.IsValid
|| formRequest.Header is null
|| formRequest.Request is null)
{
return (null, null);
}
var header = new CardHeader(formRequest.Header);
var request = new CardStoreRequest(formRequest.Request);
return (header, request);
}
#endregion
}
}
Создание WebProxy для обращения к REST-методам¶
Для обращения к созданному веб-сервису необходимо создать класс-наследник WebProxy
, который будет отправлять HTTP-запросы для получения файлов и сохранения карточки с файлами.
Tip
Более подробно о создании WebProxy
можно прочитать в разделе Запросы к веб-сервисам посредством прокси-объектов WebProxy.
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Net.Mime;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Net.Http.Headers;
using Tessa.Cards.ComponentModel;
using Tessa.Platform.IO;
using Tessa.Platform.Runtime;
using Tessa.Platform.Storage;
using Tessa.Platform.Web;
namespace Tessa.Cards
{
public sealed class MultipartWebProxy :
WebProxy
{
#region Constructors
public MultipartWebProxy()
: base("multipart", RuntimeHelper.DefaultServiceName,
new[]
{
WebRequestFlags.AddAcceptLanguageHeader,
WebRequestFlags.AddSessionHeader,
WebRequestFlags.AddInstanceInUri,
WebRequestFlags.TypedJsonResponse
})
{
}
#endregion
#region Get file content
/// <summary>
/// Получает контент версии файла.
/// </summary>
/// <param name="request">Запрос на получение контента версии файла.</param>
/// <param name="processContentActionAsync">
/// Делегат, выполняющий обработку контента, полученную от сервера.
/// Обычно контент сохраняется во временный файл.
/// </param>
/// <param name="cancellationToken">
/// Объект, посредством которого можно отменить асинхронную операцию.
/// </param>
/// <returns>Ответ на запрос получения контента версии файла, прикреплённого к карточке.</returns>
public async Task<CardGetFileContentResponse> GetFileContentAsync(
CardGetFileContentRequest request,
Func<Stream, CancellationToken, ValueTask> processContentActionAsync,
CancellationToken cancellationToken = default)
{
ThrowIfNull(request);
ThrowIfNull(processContentActionAsync);
// POST-запрос на получение контента файла.
using var responseContent = await this.SendAsync<HttpContent>(
HttpMethod.Post,
"get-file-content",
WebRequestFlags.PerRequest() + WebRequestFlags.TypedJsonRequest,
request,
cancellationToken: cancellationToken).ConfigureAwait(false);
ThrowIfNull(responseContent);
// Ожидается именно "multipart/form-data".
if (responseContent.Headers.ContentType?.MediaType != MediaTypes.MultipartFormData)
{
throw new InvalidDataException(
$"Expected {MediaTypes.MultipartFormData} content-type "
+ $"returned by {nameof(this.GetFileContentAsync)} method.");
}
// Получение обозначения границ частей мультипарта.
var contentType = MediaTypeHeaderValue.Parse(responseContent.Headers.ContentType.ToString());
string? boundary = HeaderUtilities.RemoveQuotes(contentType.Boundary).Value;
if (string.IsNullOrWhiteSpace(boundary))
{
throw new InvalidDataException(
$"Missing content-type boundary returned by {nameof(this.GetFileContentAsync)} method.");
}
var responseStream =
await responseContent.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
await using var _ = responseStream.ConfigureAwait(false);
var reader = new MultipartReader(boundary, responseStream);
// Считывание части с ответом в виде TypedJSON из части "Response".
var section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false);
if (section is null
|| MediaTypeHeaderValue.Parse(section.ContentType).MediaType.Value
!= MediaTypeNames.Application.Json)
{
throw new InvalidDataException(
$"Expected {MediaTypeNames.Application.Json} content-type for the first section"
+ $" returned by {nameof(this.GetFileContentAsync)} method.");
}
string json = await section.ReadAsStringAsync().ConfigureAwait(false);
var response = new CardGetFileContentResponse(
NotNullOrThrow(StorageHelper.DeserializeFromTypedJson(json)));
if (!response.HasContent)
{
return response;
}
// Считывание контента файла из части "Content".
section = await reader.ReadNextSectionAsync(cancellationToken).ConfigureAwait(false);
if (section is null
|| MediaTypeHeaderValue.Parse(section.ContentType).MediaType.Value
!= MediaTypeNames.Application.Octet)
{
throw new InvalidDataException(
$"Expected {MediaTypeNames.Application.Octet} content-type for the second section"
+ $" returned by {nameof(this.GetFileContentAsync)} method.");
}
var contentStream = new SubStream(section.Body, response.Size);
await using var __ = contentStream.ConfigureAwait(false);
await processContentActionAsync(contentStream, cancellationToken).ConfigureAwait(false);
await contentStream.SeekEndAsync(cancellationToken).ConfigureAwait(false);
return response;
}
#endregion
#region Store card with files
/// <summary>
/// Сохраняет карточку, переданную в запросе.
/// </summary>
/// <param name="request">
/// Запрос на сохранение карточки, содержащий изменённую информацию о карточке.
/// </param>
/// <param name="header">Заголовок потока, содержащего карточку.</param>
/// <param name="fileStreams">
/// Список функций, возвращающих поток для каждого сохраняемого файла,
/// указанного в заголовке <paramref name="header"/>.
/// </param>
/// <param name="cancellationToken">
/// Объект, посредством которого можно отменить асинхронную задачу.
/// </param>
/// <returns>
/// Ответ на запрос, содержащий информацию о валидации процесса сохранения карточки,
/// включая сообщения об ошибках.
/// </returns>
public async Task<CardStoreResponse> StoreAsync(
CardStoreRequest request,
CardHeader header,
IReadOnlyCollection<Func<CancellationToken, ValueTask<Stream>>>? fileStreams = null,
CancellationToken cancellationToken = default)
{
ThrowIfNull(request);
ThrowIfNull(header);
var multipartContent = new MultipartFormDataContent
{
{ TessaHttpContent.FromJson(header.ToTypedJson()), "Header" },
{ TessaHttpContent.FromJson(request.ToTypedJson()), "Request" },
};
var streams = new List<Stream>(fileStreams?.Count ?? 0);
try
{
if (fileStreams?.Count > 0)
{
CardHeaderFile[] headerFiles = header.GetOrderedFiles().ToArray();
ListStorage<CardFile>? cardFiles = request.TryGetCard()?.TryGetFiles();
int index = 0;
foreach (Func<CancellationToken, ValueTask<Stream>> getFileStreamAsync in fileStreams)
{
Guid? fileID = headerFiles.Length < index ? null : headerFiles[index++].ID;
string? fileName = fileID.HasValue
? cardFiles?.FirstOrDefault(x => x.RowID == fileID.Value)?.Name
: null;
if (string.IsNullOrEmpty(fileName))
{
fileName = "file";
}
var contentDisposition = new ContentDispositionHeaderValue("form-data");
contentDisposition.SetHttpFileName(fileName);
Stream stream = await getFileStreamAsync(cancellationToken).ConfigureAwait(false);
streams.Add(stream);
StreamContent content = TessaHttpContent.FromStream(stream, restartableSeek: true);
content.Headers.ContentDisposition =
new System.Net.Http.Headers.ContentDispositionHeaderValue(
NotNullOrThrow(contentDisposition.DispositionType.Value))
{
FileName = contentDisposition.FileName.Value,
FileNameStar = contentDisposition.FileNameStar.Value,
};
multipartContent.Add(content);
}
}
var response = await this.SendAsync<CardStoreResponse>(
HttpMethod.Post,
"store-files",
content: multipartContent,
cancellationToken: cancellationToken).ConfigureAwait(false);
return NotNullOrThrow(response);
}
finally
{
foreach (Stream stream in streams)
{
await stream.DisposeAsync().ConfigureAwait(false);
}
}
}
#endregion
}
}
Сохранение карточки с файлами в web-клиенте¶
Теперь рассмотрим пример сохранения карточки с файлами из web-клиента. Для этого создадим следующий класс в файле .ts
:
import { IApiClient, IApiClient$, inject, injectable, createInjectToken } from '@tessa/application';
import { TypedJsonConverter } from '@tessa/core';
import {
CardHelper,
CardStoreMode,
CardStoreRequest,
CardStoreRequestHeader,
CardStoreResponse,
FileContentResolver
} from '@tessa/platform';
// Атрибут, определяющий возможность использования данного класса в DI-контейнере.
@injectable()
export class MultipartClient {
//#region ctor
// IApiClient ранее был зарегистрирован в DI-контейнере.
constructor(@inject(IApiClient$) private _apiClient: IApiClient) {}
//#endregion
//#region method
async storeFiles(
request: CardStoreRequest,
fileContentResolver?: FileContentResolver | null
): Promise<CardStoreResponse> {
const card = request.card;
const header = new CardStoreRequestHeader();
const contents: File[] = [];
const storeMode = card.storeMode;
if (storeMode === CardStoreMode.Update) {
card.updateStates();
}
card.removeAllButChanged(storeMode, request.method);
const filesToSave = CardHelper.getFilesToSave(card);
if (!fileContentResolver) {
throw new Error('Could not save card with files without fileContentResolver.');
}
let index = 0;
for (const cardFile of filesToSave) {
const fileId = cardFile.rowId;
const content = await fileContentResolver(fileId);
if (!content) {
// Содержимое файла может отсутствовать, если оно определяется на сервере.
// Например, при копировании файла.
continue;
}
const cardHeaderFile = header.files.add(fileId);
cardHeaderFile.size = content.size;
cardHeaderFile.order = index++;
contents.push(content);
}
const formData = new FormData();
formData.append('Header', TypedJsonConverter.serialize(header.getStorage()));
formData.append('Request', TypedJsonConverter.serialize(request.getStorage()));
contents.forEach(value => formData.append(value.name, value));
const storeResponse = await this._apiClient
.post(`/multipart/store-files`, { body: formData })
.typedJson();
return new CardStoreResponse(storeResponse);
}
//#endregion
}
// Регистрация в DI-контейнере.
export const MultipartClient$ = createInjectToken<MultipartClient>('MultipartClient');