Согласование содержимого. Собственные форматировщики

В ASP.NET Core используются два вида форматировщиков – для форматирования входных данных (input formatters) и форматирования выходных данных (output formatters). И, хотя в подавляющем большинстве случаев, для приложения Web API достаточно того же формата JSON для форматирования данных, всё же иногда может потребоваться, чтобы наше приложение могло обрабатывать и такие форматы, которые не предусмотрены по умолчанию в ASP.NET Core.

Например, мы решим, что было бы неплохо, чтобы пользователь мог как импортировать, так и экспортировать задачи в формате iCalendar. Таких форматировщиков нет по умолчанию в ASP.NET Core. В этом случае нам потребуется разработать два форматировщика.

Форматировщик выходных данных

Для создания своего форматировщика выходных данных нам необходимо:

  1. Выбрать базовый класс для форматировщика. На данный момент все форматировщики, используемые в ASP.NET Core, расположены в пространстве имен Microsoft.AspNetCore.Mvc.Formatters. Для нашей задачи наиболее подходящим является форматировщик TextOutputFormatter
  2. Переопределить методы базового класса для формирования данных
  3. Указать поддерживаемые форматировщиком MIME-типы

Создадим новый проект ASP.NET Core Web API и добавим в проект новый класс, который будет описывать некую задачу:

public class TaskData
{
    public string Name { get; set; }
    public string Description { get; set; }
    public string Status { get; set; }
    public DateTime CreatedDate { get; set; }
    public DateTime Deadline { get; set; }
}

Именно объекты этого типа мы будем форматировать в iCal. Теперь добавим в проект папку Formatters и разместим в ней следующий класс

using Microsoft.AspNetCore.Mvc.Formatters;
using System.Text;
using Microsoft.Net.Http.Headers;


namespace WebApplication13.Formatters
{
    public class CalendarOutputFormatter : TextOutputFormatter
    {
        public CalendarOutputFormatter()
        {
            SupportedMediaTypes.Add(
            MediaTypeHeaderValue.Parse("text/calendar"));
            SupportedEncodings.Add(Encoding.UTF8);
            SupportedEncodings.Add(Encoding.Unicode);
        }

        protected override bool CanWriteType(Type? type)
        {
            return typeof(TaskData).IsAssignableFrom(type); 
        }

        public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
        {
            var httpContext = context.HttpContext;
            var buffer = new StringBuilder();
            buffer.AppendLine("BEGIN:VCALENDAR");
            buffer.AppendLine("VERSION:2.0");
            buffer.AppendLine("PRODID: TaskManager v1.0");
            buffer.AppendLine("CALSCALE:GREGORIAN");
            buffer.AppendLine("METHOD:PUBLISH");

            FormatCalendar(buffer, (TaskData)context.Object!);

            buffer.AppendLine("END:VCALENDAR");
            await httpContext.Response.WriteAsync(buffer.ToString(), selectedEncoding);
        }

        private static void FormatCalendar(StringBuilder buffer, TaskData task)
        {
            buffer.AppendLine("BEGIN:VEVENT");
            buffer.AppendLine($"DTSTART:{task.CreatedDate.ToString("yyyyMMddTHHmmssZ")}");
            buffer.AppendLine($"DTEND:{task.Deadline.ToString("yyyyMMddTHHmmssZ")}");
            buffer.AppendLine($"UID:{Guid.NewGuid()}");
            buffer.AppendLine($"CREATED:{task.Deadline.ToString("yyyyMMddTHHmmssZ")}");
            buffer.AppendLine($"STATUS:{task.Status}");
            buffer.AppendLine($"SUMMARY:{task.Name}");
            buffer.AppendLine($"DESCRIPTION:{task.Description}");
            buffer.AppendLine("END:VEVENT");
        }
    }
}


Рассмотрим работу этого класса подробно. В конструкторе класса мы указываем поддерживаемый MIME-тип данных и кодировки текста:

public CalendarOutputFormatter()
{
    SupportedMediaTypes.Add(
    MediaTypeHeaderValue.Parse("text/calendar"));
    SupportedEncodings.Add(Encoding.UTF8);
    SupportedEncodings.Add(Encoding.Unicode);
}

Здесь мы указали, что наш форматировщик может записывать в ответ данные в формате «text/calendar». Теперь, если клиент пришлет в заголовке Accept запроса этот MIME-тип, то ASP.NET Core попытается сформировать текст ответа, используя именно этот форматировщик, если он будет зарегистрирован в приложении. Далее мы переопределили метод базового класса CanWriteType()

protected override bool CanWriteType(Type? type)
{
    return typeof(TaskData).IsAssignableFrom(type); 
}

Этот метод возвращает true, если мы можем сериализовать предоставленные данные в требуемый формат.  Если мы пытаемся сериализовать данные типа, представленного в методе (TaskData), то метод вернет true, иначе – false и ASP.NET Core будет искать следующий подходящий форматировщик данных.

Следующий переопределенный метод базового класса – это метод WriteResponseBodyAsync(). Именно в этом методе мы должны записать тело ответа. В качестве параметров этот метод принимает некий контекст типа OutputFormatterWriteContext и кодировку текста

public override async Task WriteResponseBodyAsync(OutputFormatterWriteContext context, Encoding selectedEncoding)
{
    var httpContext = context.HttpContext;
    var buffer = new StringBuilder();
    buffer.AppendLine("BEGIN:VCALENDAR");
    buffer.AppendLine("VERSION:2.0");
    buffer.AppendLine("PRODID: TaskManager v1.0");
    buffer.AppendLine("CALSCALE:GREGORIAN");
    buffer.AppendLine("METHOD:PUBLISH");

    FormatCalendar(buffer, (TaskData)context.Object!);

    buffer.AppendLine("END:VCALENDAR");
    await httpContext.Response.WriteAsync(buffer.ToString(), selectedEncoding);
}

Используя контекст, мы можем получить как контекст запроса:

var httpContext = context.HttpContext;

так и непосредственно данные, которые нам необходимо сериализовать. Объект сериализации находится в свойстве Object контекста. Cериализуемый объект передается в наш собственный метод FormatCalendar(), который записывает в буфер данные задачи в формате iCalendar. Таким образом, на выходе из метода WriteResponseBodyAsync() мы получаем сформированный ответ клиенту.

Теперь, чтобы форматировщик заработал, его необходимо зарегистрировать в приложении. Делается это следующим образом:

builder.Services.AddControllers(config =>
{
    config.OutputFormatters.Add(new CalendarOutputFormatter());
});

Здесь мы добавляем в коллекцию форматировщиков выходных данных наш форматировщик CalendarOutputFormatter(). Теперь мы можем проверить работу нашего приложения. Создадим в проекте новый контроллер следующего содержания:

[Route("[controller]")]
[ApiController]
public class TasksController : ControllerBase
{
    [HttpGet]
    public TaskData GetTaskData() 
    {
        return new TaskData()
        {
            Name = "Test",
            CreatedDate = DateTime.Now,
            Deadline = DateTime.Now.AddDays(2),
            Description = "Test description",
            Status = "Active"
        };
    }
}

Добавим в http-файл проекта следующий запрос

@WebApplication13_HostAddress = http://localhost:5086

GET {{WebApplication13_HostAddress}}/tasks/
Accept: text/calendar

После выполнения этого запроса мы получим следующий ответ

 

Как можно видеть, сервер вернул задачу в заданном формате. Если же запросить данные в привычном нам JSON-формате, то ответ сервера будет таким:

Форматировщик входных данных

Форматировщики входных данных используются для привязки моделей, и их разработка мало чем отличается от разработки предыдущего форматировщика. Мы так же должны выбрать подходящий форматировщик, переопределить ряд методов и зарегистрировать новый форматировщик в приложении. Поэтому сразу перейдем к разработке и разместим в папке Formatters следующий класс

public class CalendarInputFormatter : TextInputFormatter
{
    public CalendarInputFormatter()
    {
        SupportedMediaTypes.Add(MediaTypeHeaderValue.Parse("text/calendar"));
        SupportedEncodings.Add(Encoding.UTF8);
        SupportedEncodings.Add(Encoding.Unicode);
    }

    protected override bool CanReadType(Type? type)
    {
        return typeof(TaskData).IsAssignableFrom(type) || typeof(IEnumerable<TaskData>).IsAssignableFrom(type);
    }


    public override async Task<InputFormatterResult> ReadRequestBodyAsync(InputFormatterContext context, Encoding encoding)
    {
        var httpContext = context.HttpContext;
        var serviceProvider = httpContext.RequestServices;
        using var reader = new StreamReader(httpContext.Request.Body, encoding);
        string? line = null;
        TaskData? task = null;
        List<TaskData> tasks = [];

        bool isList = context.ModelType.IsAssignableFrom(typeof(IEnumerable<TaskData>));

        try
        {
            line = await reader.ReadLineAsync();
            while (line != null)
            {
                if (line.StartsWith("BEGIN:VEVENT"))
                    task = new TaskData();
                string? data = null;
                if (task != null)
                {
                    data = await reader.ReadLineAsync();
                    while ((data != null) && !data.StartsWith("END:VEVENT"))
                    {
                        var values = data.Split(":", StringSplitOptions.TrimEntries);
                        switch (values[0])
                        {
                            case "DTSTART":
                                {
                                    task.CreatedDate = DateTime.ParseExact(values[1], "yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);
                                    break;
                                }
                            case "DTEND":
                                {
                                    task.Deadline = DateTime.ParseExact(values[1], "yyyyMMddTHHmmssZ", CultureInfo.InvariantCulture);
                                    break;
                                }
                            case "SUMMARY":
                                {
                                    task.Name = values[1];
                                    break;
                                }
                            case "DESCRIPTION":
                                {
                                    task.Description = values[1];
                                    break;
                                }
                            case "STATUS":
                                {
                                    task.Status = values[1];
                                    break;
                                }
                        }
                        data = await reader.ReadLineAsync();
                    }
                }

                if ((data != null) && data.StartsWith("END:VEVENT") && (task != null))
                {
                    if (isList)
                    {
                        tasks.Add(task);
                    }
                    else
                        return await InputFormatterResult.SuccessAsync(task);
                }
                line = await reader.ReadLineAsync();
            }
            return await InputFormatterResult.SuccessAsync(tasks);
        }
        catch
        {
            return await InputFormatterResult.FailureAsync();
        }
    }
}

Подробно рассматривать класс, думаю, не стоит. Остановимся только на основных моментах и отличиях этого форматировщика от предыдущего. Что касается конструктора, то он полностью идентичен конструктору форматировщика выходных данных.

Основное отличие в реализации форматировщика входных данных содержится в методе ReadRequestBodyAsync(), а точнее, в первом его параметре типа InputFormatterContext. Так, если при формировании ответа в нашем распоряжении находится уже готовый объект, который необходимо сериализовать, то объект типа InputFormatterContext содержит только тип модели для привязки и текст запроса. Поэтому для десериализации мы используем StreamReader, читая текст тела запроса построчно. Что касается типа модели, то в нашем случае он нам нужен для того, чтобы определить, что именно мы должны получить из текста запроса: отдельную задачу или список задач? Для этого, в методе производится проверка:

bool isList = context.ModelType.IsAssignableFrom(typeof(IEnumerable<TaskData>));

Здесь свойство ModelType и есть тип модели для привязки. Таким образом, если даже пользователь передает в приложение список задач, но привязка осуществляется к объекту, представляющему собой одиночную задачу, то этот форматировщик просто вернет первую найденную задачу. Дополнительно можно было бы предусмотреть так же проверку того, что в запросе, в принципе содержатся какие-либо задачи, а не пустой календарь и так далее, но опустим дополнительные проверки, а перейдем сразу к следующей части – зарегистрируем форматировщик и проверим его работу.

Регистрация форматировщика входных данных также осуществляется при конфигурации MVC

builder.Services.AddControllers(config =>
{
    config.OutputFormatters.Add(new CalendarOutputFormatter());
    config.InputFormatters.Add(new CalendarInputFormatter());
});

Теперь проверим работу приложения, добавив в http-файл следующий запрос:

POST {{WebApplication13_HostAddress}}/tasks/
Accept: text/calendar
Content-Type: text/calendar

BEGIN:VCALENDAR
VERSION:2.0
PRODID: TaskManager v1.0
CALSCALE:GREGORIAN
METHOD:PUBLISH
BEGIN:VEVENT
DTSTART:20240622T152309Z
DTEND:20240801T000000Z
UID:a173f6a6-a88f-4f5b-b522-5dd809893b46
CREATED:20240901T000000Z
STATUS:Completed
SUMMARY:Задача из iCalendar
DESCRIPTION:Описание
END:VEVENT
BEGIN:VEVENT
DTSTART:20240622T153252Z
DTEND:20240901T000000Z
UID:258cedb9-e16d-4d32-8a4d-9622595963ef
CREATED:20240901T000000Z
STATUS:Completed
SUMMARY:Новая задача
DESCRIPTION:Описание
END:VEVENT
END:VCALENDAR

Как можно увидеть, запрос содержит список задач. Добавим в контроллер TasksController новое действие

[HttpPost]
public TaskData PostTaskData(TaskData data)
{
    return data;
}

Так как форматировщик входных данных используется для привязки модели, то если форматировщик сработает без ошибок, то это действие вернет нам первую задачу из запроса. Ниже представлен результат выполнения запроса

Теперь наше приложение «научилось» работать с новым форматом данных. Можно, при желании, добавить новое действие контроллера для импорта задач iCalendar – наш форматировщик справится с задачей и разберет текст запроса на список задач.

Итого

При необходимости, мы можем в наше приложение свои форматировщики данных и, тем самым, позволить нашему приложению как сериализовать данные в новый формат, так и десериализовать данные из запроса пользователя в новый формат.

Подписаться
Уведомить о
guest
0 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии