Перейти к содержанию

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');

Back to top