Реализация HATEOAS в ASP.NET Core Web API

Что такое HATEOAS и почему это так важно?

HATEOAS (гипермедиа как механизм состояния приложения) - очень важное ограничение REST. Без него REST API не может считаться RESTful, и многие преимущества, которые мы получаем от реализации архитектуры REST, недоступны.

Гипермедиа относится к любому виду контента, который содержит ссылки на типы мультимедиа, такие как документы, изображения, видео…

Архитектура REST позволяет нам динамически генерировать гипермедийные ссылки в наших ответах, что значительно упрощает навигацию. Чтобы представить это в перспективе, подумайте о веб-сайте, который использует гиперссылки, чтобы помочь вам переходить к различным его частям. Вы можете добиться того же эффекта с HATEOAS в своем REST API.

Представьте себе веб-сайт, у которого есть домашняя страница, и вы попадаете на нее, но нигде нет ссылок. Вам нужно очистить веб-сайт или найти другой способ навигации по нему, чтобы добраться до нужного вам контента. Мы не говорим, что веб-сайт аналогичен REST API, но вы поняли суть.

Возможность самостоятельно изучить API может быть очень полезной.

Давайте посмотрим, как это работает.

Типичный ответ с реализованной HATEOAS

Допустим, мы хотим получить несколько владельцев от нашего API.

Но как нам это сделать?

Мы даже не знаем, как добраться до owners. Что ж, сначала мы перейдем к единственному, что мы знаем, как запрашивать, и это корень приложения:

https://localhost:50001/api

Наша корневая конечная точка должна рассказать нам больше о нашем API или, точнее, с чего начать его изучение:

[
    {
        "href": "http://localhost:5001/api",
        "rel": "self",
        "method": "GET"
    },
    {
        "href": "http://localhost:5001/api/owner",
        "rel": "owner",
        "method": "GET"
    },
    {
        "href": "http://localhost:5000/api/owner",
        "rel": "create_owner",
        "method": "POST"
    }
]

Хорошо, теперь мы знаем, что нам доступно, и можем приступить к поиску существующих владельцев:

http://localhost:5001/api/owner

И действительно получаем владельцев:

[
	{
		"Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
		"Name": "John Keen",
		"DateOfBirth": "1980-12-05T00:00:00",
		"Address": "61 Wellfield Road",
		"Links": [
			{
				"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
				"rel": "self",
				"method": "GET"
			},
			{
				"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
				"rel": "delete_owner",
				"method": "DELETE"
			},
			{
				"href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
				"rel": "update_owner",
				"method": "PUT"
			}
		]
	},
	{
		"Id": "261e1685-cf26-494c-b17c-3546e65f5620",
		"Name": "Anna Bosh",
		"DateOfBirth": "1974-11-14T00:00:00",
		"Address": "27 Colored Row",
		"Links": [
			{
				"href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620",
				"rel": "self",
				"method": "GET"
			},
			{
				"href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620",
				"rel": "delete_owner",
				"method": "DELETE"
			},
			{
				"href": "https://localhost:5001/api/owner/261e1685-cf26-494c-b17c-3546e65f5620",
				"rel": "update_owner",
				"method": "PUT"
			}
		]
	},
	"..."
]

Как видите, у нас есть список наших владельцев, и для каждого владельца все действия, которые мы можем с ними выполнить. И так далее…

Итак, это хороший способ сделать API самопознанным и развивающимся.

Что такое ссылка?

Согласно RFC5988, ссылка представляет собой «типизированное соединение. между двумя ресурсами, которые определены с помощью интернационализированных идентификаторов ресурсов (IRI)». Проще говоря, мы используем ссылки для просмотра Интернета или, скорее, ресурсов в Интернете.

Наши ответы содержат массив ссылок, которые состоят из нескольких свойств в соответствии с RFC:

  • href - представляет целевой URI.
  • rel - представляет тип связи ссылки, что означает, что он описывает, как текущий контекст связан с целевым ресурсом.
  • метод - нам нужен HTTP-метод, чтобы знать, как различать одни и те же целевые URI.

Плюсы/минусы внедрения HATEOAS

Итак, какие преимущества мы можем ожидать от внедрения HATEOAS?

HATEOAS реализовать нетривиально, но награды, которые мы получаем, того стоят. Что мы можем ожидать от реализации HATEOAS:

  • API становится доступным для самостоятельного обнаружения и исследования
  • Клиент может использовать ссылки для реализации своей логики, это становится намного проще, и любые изменения, происходящие в структуре API, напрямую отражаются на клиенте.
  • Сервер управляет состоянием приложения и структурой URL, а не наоборот.
  • Связи ссылок могут использоваться для указания на документацию разработчика.
  • Управление версиями с помощью гиперссылок становится проще.
  • Уменьшение количества вызовов транзакций с недопустимым состоянием.
  • API можно развивать, не нарушая работу всех клиентов.

С HATEOAS мы можем сделать так много. Но поскольку реализовать все эти функции непросто, мы должны помнить о масштабах нашего API и о том, действительно ли все это нам нужно. Существует большая разница между общедоступным API большого объема и некоторым внутренним API, который необходим для взаимодействия между частями одной и той же системы.

Как и во всем остальном, контекст решает все. Делайте проще.

На данный момент этой теории более чем достаточно. Приступим к работе и посмотрим, как выглядит конкретная реализация HATEOAS.

Расширение модели при подготовке к реализации HATEOAS

Начнем с концепции, которую мы знаем до сих пор, а именно с ссылки. Сначала мы собираемся создать сущность Link в папке Models проекта Entities:

public class Link
{
	public string Href { get; set; }
	public string Rel { get; set; }
	public string Method { get; set; }

	public Link()
	{

	}

	public Link(string href, string rel, string method)
	{
		Href = href;
		Rel = rel;
		Method = method;
	}
}

Обратите внимание, что у нас тоже есть пустой конструктор. Нам это понадобится для сериализации XML, так что оставьте это так.

Далее нам нужно создать класс, который будет содержать все наши ссылки - LinkResourceBase:

public class LinkResourceBase
{
	public LinkResourceBase()
	{

	}

	public List<Link> Links { get; set; } = new List<Link>();
}

И наконец, так как наш ответ должен описывать корень контроллера, нам нужна оболочка для наших ссылок:

public class LinkCollectionWrapper<T> : LinkResourceBase
{
	public List<T> Value { get; set; } = new List<T>();

	public LinkCollectionWrapper()
	{

	}

	public LinkCollectionWrapper(List<T> value)
	{
		Value = value;
	}
}

Возможно, этот класс сейчас не имеет особого смысла, но оставайтесь с нами, и это станет ясно позже в будущем. А пока давайте просто предположим, что мы обернули наши ссылки в другой класс для целей представления ответа.

Поскольку наш ответ теперь тоже будет содержать ссылки, нам нужно расширить правила сериализации XML, чтобы наш ответ XML возвращал правильно отформатированные ссылки. Без этого мы получили бы что-то вроде: <Links>System.Collections.Generic.List1[Entites.Models.Link]<Links>.

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

public void WriteXml(XmlWriter writer)
{
	foreach (var key in expando.Keys)
	{
		var value = expando[key];
		WriteLinksToXml(key, value, writer);
	}
}

private void WriteLinksToXml(string key, object value, XmlWriter writer)
{
	writer.WriteStartElement(key);

	if (value.GetType() == typeof(List<Link>))
	{
		foreach (var val in value as List<Link>)
		{
			writer.WriteStartElement(nameof(Link));
			WriteLinksToXml(nameof(val.Href), val.Href, writer);
			WriteLinksToXml(nameof(val.Method), val.Method, writer);
			WriteLinksToXml(nameof(val.Rel), val.Rel, writer);
			writer.WriteEndElement();
		}
	}
	else
	{
		writer.WriteString(value.ToString());
	}

	writer.WriteEndElement();
}

Как и в статье о формировании данных, мы не будем вдаваться в подробности здесь. поскольку это выходит за рамки статьи, но и логика не слишком сложна. Короче говоря, мы проверяем, является ли тип List<Link>, и если это так, мы перебираем все ссылки и рекурсивно вызываем метод для каждого из свойств: href, method и rel.

Это все, что нам сейчас нужно. У нас есть прочная основа для реализации HATEOAS в наших контроллерах.

Реализация HATEOAS в веб-API ASP.NET Core

Теперь давайте перейдем к нашему OwnerController и реализуем HATEOAS.

Во-первых, нам нужно расширить наш контроллер с помощью класса LinkGenerator, который поможет нам создавать нужные нам ссылки:

private ILoggerManager _logger;
private IRepositoryWrapper _repository;
private LinkGenerator _linkGenerator;

public OwnerController(ILoggerManager logger,
	IRepositoryWrapper repository,
	LinkGenerator linkGenerator)
{
	_logger = logger;
	_repository = repository;
	_linkGenerator = linkGenerator;
}

Затем нам нужно расширить наш метод GetOwners, чтобы добавить соответствующие ссылки для возвращаемых владельцев:

[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	if (!ownerParameters.ValidYearRange)
	{
		return BadRequest("Max year of birth cannot be less than min year of birth");
	}

	var owners = _repository.Owner.GetOwners(ownerParameters);

	var metadata = new
	{
		owners.TotalCount,
		owners.PageSize,
		owners.CurrentPage,
		owners.TotalPages,
		owners.HasNext,
		owners.HasPrevious
	};

	Response.Headers.Add("X-Pagination", JsonConvert.SerializeObject(metadata));

	_logger.LogInfo($"Returned {owners.TotalCount} owners from database.");

	for (var index = 0; index < owners.Count(); index++)
	{
		var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
		owners[index].Add("Links", ownerLinks);
	}

	var ownersWrapper = new LinkCollectionWrapper<Entity>(owners);

	return Ok(CreateLinksForOwners(ownersWrapper));
}

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

Теперь нам нужна реализация для методов CreateLinksForOwner и CreateLinksForOwners:

private IEnumerable<Link> CreateLinksForOwner(Guid id, string fields = "")
{
	var links = new List<Link>
	{
		new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwnerById), values: new { id, fields }),
		"self",
		"GET"),

		new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(DeleteOwner), values: new { id }),
		"delete_owner",
		"DELETE"),

		new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(UpdateOwner), values: new { id }),
		"update_owner",
		"PUT")
	};

	return links;
}

private LinkCollectionWrapper<Entity> CreateLinksForOwners(LinkCollectionWrapper<Entity> ownersWrapper)
{
	ownersWrapper.Links.Add(new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetOwners), values: new { }),
			"self",
			"GET"));

	return ownersWrapper;
}

Здесь следует отметить несколько моментов.

Нам нужно учитывать поля при создании ссылок, поскольку мы можем использовать их в наших запросах. Мы создаем ссылки с помощью метода GetUriByAction LinkGenerator, который принимает HttpContext, имя действия и значения, которые необходимо использовать, чтобы URL стал действительным. В случае OwnersController мы отправляем идентификатор владельца и поля.

Ожидаемый ответ в точности соответствует тому, который мы использовали в качестве примера.

Мы делаем что-то похожее на наш метод GetOwnerById:

[HttpGet("{id}", Name = "OwnerById")]
public IActionResult GetOwnerById(Guid id, [FromQuery] string fields)
{
	var owner = _repository.Owner.GetOwnerById(id, fields);

	if (owner.Id == Guid.Empty)
	{
		_logger.LogError($"Owner with id: {id}, hasn't been found in db.");
		return NotFound();
	}

	owner.Add("Links", CreateLinksForOwner(owner.Id, fields));

	return Ok(owner);
}

Логику намного проще реализовать для одного владельца, поскольку нам не нужно ее оборачивать.

Мы собираемся сделать то же самое для AccountController, но с небольшой разницей. Как вы знаете, путь к AccountController немного отличается:

/api/owner//account

/api/owner//account/

Мы должны учитывать это при создании ссылок HATEOAS:

private List<Link> CreateLinksForAccount(Guid ownerId, Guid id, string fields = "")
{
	var links = new List<Link>
	{
		new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetAccountForOwner), values: new { ownerId, id, fields }),
		"self",
		"GET"),
	};

	return links;
}

private LinkCollectionWrapper<Entity> CreateLinksForAccounts(LinkCollectionWrapper<Entity> accountsWrapper)
{
	accountsWrapper.Links.Add(new Link(_linkGenerator.GetUriByAction(HttpContext, nameof(GetAccountsForOwner), values: new { }),
			"self",
			"GET"));

	return accountsWrapper;
}

Наша реализация AccountController не содержит методов Create, Update или Delete, поэтому она намного проще, но, как видите, в дополнение к идентификатору учетной записи нам также необходимо предоставить идентификатор владельца, чтобы правильно создавать ссылки. В этом случае нужно учитывать fields.

Давайте быстро протестируем нашу реализацию и посмотрим, как она работает.

Тестирование нашего решения

Начнем с простого запроса к конечной точке наших владельцев: GET https://localhost:5001/api/owner

Должен появиться список владельцев со ссылками:

{
    "value": [
        {
            "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "Name": "John Keen",
            "DateOfBirth": "1980-12-05T00:00:00",
            "Address": "61 Wellfield Road",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
		...
		...
		...
    "links": [
        {
            "href": "https://localhost:5001/api/owner",
            "rel": "self",
            "method": "GET"
        }
    ]
}

Как видите, есть все ссылки, которые мы определили для нашего владельца. Вся совокупность владельцев заключена в сущность «значение», потому что в самом конце мы определяем ссылку, которая описывает «себя» или, скорее, как добраться до контроллера. Мы можем в любое время расширить его, добавив другие релевантные ссылки, которые важны для контроллера.

В этом весь смысл реализованной нами оболочки.

Теперь приступим к тестированию единственного владельца: GET https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906

Это должно привести к:

{
    "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
    "Name": "John Keen",
    "DateOfBirth": "1980-12-05T00:00:00",
    "Address": "61 Wellfield Road",
    "Links": [
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "self",
            "method": "GET"
        },
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "delete_owner",
            "method": "DELETE"
        },
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "rel": "update_owner",
            "method": "PUT"
        }
    ]
}

Один хозяин, никаких фантиков.

Теперь давайте попробуем получить аккаунты этого владельца: GET https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account

Мы получаем список аккаунтов этого владельца и ссылки на них:

{
    "value": [
        {
            "Id": "371b93f2-f8c5-4a32-894a-fc672741aa5b",
            "DateCreated": "1999-05-04T00:00:00",
            "AccountType": "Domestic",
            "OwnerId": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account/371b93f2-f8c5-4a32-894a-fc672741aa5b",
                    "rel": "self",
                    "method": "GET"
                }
            ]
        }
		...
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account",
            "rel": "self",
            "method": "GET"
        }
    ]
}

И, наконец, давайте протестируем одну учетную запись: GET https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906/account/371b93f2-f8c5-4a32-894a-fc672741aa5b

Результат должен быть довольно очевидным:

{
    "Id": "371b93f2-f8c5-4a32-894a-fc672741aa5b",
    "DateCreated": "1999-05-04T00:00:00",
    "AccountType": "Domestic",
    "OwnerId": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
    "Links": [
        {
            "href": "https://localhost:5001/api/owner/371b93f2-f8c5-4a32-894a-fc672741aa5b/account/371b93f2-f8c5-4a32-894a-fc672741aa5b",
            "rel": "self",
            "method": "GET"
        }
    ]
}

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

Теперь осталось протестировать только то, как это работает, когда мы выбираем нужные поля: GET https://localhost:5001/api/owner?fields=name

И, к нашему большому удивлению, это не работает.

Почему?

Улучшаем нашу реализацию

Как видите, реализация HATEOAS полагается на наличие доступных идентификаторов для создания ссылок для ответа. Формирование данных, с другой стороны, позволяет нам возвращать только те поля, которые нам нужны. В нашем тестовом сценарии нам нужны были только имена владельцев. Это означает, что мы не вернули идентификаторы этих владельцев и сейчас не можем создать целевые URI.

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

Сначала мы реализуем оболочку с именем ShapedEntity, которая помимо сущности содержит свойство Id:

public class ShapedEntity
{
	public ShapedEntity()
	{
		Entity = new Entity();
	}

	public Guid Id { get; set; }
	public Entity Entity { get; set; }
}

Далее мы собираемся заменить все использования класса Entity во всем проекте на ShapedEntity. Классы, в которых необходимо заменять каждое использование класса Entity: AccountRepository, IAccountRepository, OwnerRepository, IOwnerRepository, DataShaper и IDataShaper.

В дополнение к этому нам необходимо расширить метод FetchDataForEntity в классе DataShaper, чтобы получить идентификатор отдельно:

private ShapedEntity FetchDataForEntity(T entity, IEnumerable<PropertyInfo> requiredProperties)
{
	var shapedObject = new ShapedEntity();

	foreach (var property in requiredProperties)
	{
		var objectPropertyValue = property.GetValue(entity);
		shapedObject.Entity.TryAdd(property.Name, objectPropertyValue);
	}

	var objectProperty = entity.GetType().GetProperty("Id");
	shapedObject.Id = (Guid)objectProperty.GetValue(entity);

	return shapedObject;
}

Теперь у нас есть все, что нужно для создания ссылок, независимо от того, какие свойства мы получаем при формировании данных.

Все, что осталось, это немного подправить действия нашего контроллера:

[HttpGet]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	...//implementation

	var shapedOwners = owners.Select(o => o.Entity).ToList();

	for (var index = 0; index < owners.Count(); index++)
	{
		var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
		shapedOwners[index].Add("Links", ownerLinks);
	}

	var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners);

	return Ok(CreateLinksForOwners(ownersWrapper));
}

[HttpGet("{id}", Name = "OwnerById")]
public IActionResult GetOwnerById(Guid id, [FromQuery] string fields)
{
	var owner = _repository.Owner.GetOwnerById(id, fields);

	if (owner.Id == Guid.Empty)
	{
		_logger.LogError($"Owner with id: {id}, hasn't been found in db.");
		return NotFound();
	}

	owner.Entity.Add("Links", CreateLinksForOwner(owner.Id, fields));

	return Ok(owner.Entity);
}

Класс AccountController обрабатывается так же.

Теперь давайте снова попробуем тот же запрос: GET https://localhost:5001/api/owner?fields=name

И получаем:

{
    "value": [
        {
            "Name": "John Keen",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906?fields=name",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
		...
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owner",
            "rel": "self",
            "method": "GET"
        }
    ]
}

Знакомство с пользовательскими типами мультимедиа

Теперь возвращение ссылок HATEOAS - хорошее дополнение к нашему API. Но, как видите, ответы могут быть немного длинными, и мы намеренно сократили некоторые из них, чтобы сделать статью более читаемой.

Мы хотим дать возможность пользователю API сделать это тоже.

И как мы можем это сделать?

Ответ заключается в использовании возможностей настраиваемых типов мультимедиа.

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

Прежде чем мы начнем, давайте посмотрим, как создать собственный тип мультимедиа. Пользовательский тип носителя должен выглядеть примерно так: application/vnd.testdomain.hateoas+json. Чтобы сравнить его с типичным типом носителя json, который мы используем по умолчанию: application/json.

Итак, давайте разберем различные части настраиваемого типа мультимедиа:

  • vnd - префикс поставщика, он всегда есть
  • testdomain - идентификатор поставщика, мы выбрали testdomain, потому что почему бы и нет
  • hateoas - название типа носителя
  • json - суффикс, мы можем использовать его, чтобы указать, нужен ли нам ответ json или XML, например.

Теперь давайте реализуем это в нашем приложении.

Регистрация пользовательских типов мультимедиа

Во-первых, мы хотим зарегистрировать наши новые настраиваемые типы мультимедиа в промежуточном программном обеспечении. В противном случае мы получим просто 406 Not Acceptable.

Давайте добавим новый метод расширения к нашим ServiceExtensions:

public static void AddCustomMediaTypes(this IServiceCollection services)
{
	services.Configure<MvcOptions>(config =>
	{
		var newtonsoftJsonOutputFormatter = config.OutputFormatters
				.OfType<NewtonsoftJsonOutputFormatter>()?.FirstOrDefault();

		if (newtonsoftJsonOutputFormatter != null)
		{
			newtonsoftJsonOutputFormatter.SupportedMediaTypes.Add("application/vnd.testdomain.hateoas+json");
		}

		var xmlOutputFormatter = config.OutputFormatters
				.OfType<XmlDataContractSerializerOutputFormatter>()?.FirstOrDefault();

		if (xmlOutputFormatter != null)
		{
			xmlOutputFormatter.SupportedMediaTypes.Add("application/vnd.testdomain.hateoas+xml");
		}
	});
}

Мы регистрируем два новых пользовательских типа носителей: application/vnd.testdomain.hateoas+json для нашего newtonSoftJsonSerializerOutputFormatter и application/vnd.testdomain.hateoas+xml для нашего xmlDataContractSerializerOutputFormatter. Это гарантирует, что мы не получим ответ 406 Not Acceptable.

Добавьте это в наш Startup.cs в методе ConfigureServices сразу после метода AddControllers:

services.AddControllers(config =>
{
	config.RespectBrowserAcceptHeader = true;
	config.ReturnHttpNotAcceptable = true;
}).AddXmlDataContractSerializerFormatters()
.AddNewtonsoftJson();

services.AddCustomMediaTypes();

Это позаботится о регистрации пользовательских типов мультимедиа.

Реализация фильтра проверки типа мультимедиа

Теперь, поскольку мы реализовали настраиваемые типы мультимедиа, мы хотим, чтобы наш заголовок Accept присутствовал в наших запросах, чтобы мы могли определять, когда пользователь запрашивал расширенный ответ HATEOAS.

Для этого мы реализуем ActionFilter, который будет проверять наш заголовок Accept и типы мультимедиа:

public class ValidateMediaTypeAttribute : IActionFilter
{
	public void OnActionExecuting(ActionExecutingContext context)
	{
		var acceptHeaderPresent = context.HttpContext.Request.Headers.ContainsKey("Accept");

		if (!acceptHeaderPresent)
		{
			context.Result = new BadRequestObjectResult($"Accept header is missing.");
			return;
		}

		var mediaType = context.HttpContext.Request.Headers["Accept"].FirstOrDefault();

		if (!MediaTypeHeaderValue.TryParse(mediaType, out MediaTypeHeaderValue outMediaType))
		{
			context.Result = new BadRequestObjectResult($"Media type not present. Please add Accept header with the required media type.");
			return;
		}

		context.HttpContext.Items.Add("AcceptHeaderMediaType", outMediaType);
	}

	public void OnActionExecuted(ActionExecutedContext context)
	{

	}
}

Сначала мы проверяем наличие заголовка Accept. Если его нет, мы возвращаем BadRequest. Если это так, мы анализируем тип носителя, и если действительный тип носителя отсутствует, мы возвращаем BadRequest.

После прохождения проверок мы передаем проанализированный тип носителя в HttpContext контроллера.

Не забудьте зарегистрировать фильтр в IoC:

services.AddScoped<ValidateMediaTypeAttribute>();

Теперь нам нужно доработать наши действия GetOwners и GetOwnerById [ServiceFilter(typeof(ValidateMediaTypeAttribute))], чтобы эти проверки заработали.

Что касается самой логики, нам нужно немного расширить эти действия:

[HttpGet]
[ServiceFilter(typeof(ValidateMediaTypeAttribute))]
public IActionResult GetOwners([FromQuery] OwnerParameters ownerParameters)
{
	//Implementation

	var shapedOwners = owners.Select(o => o.Entity).ToList();

	var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"];

	if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase))
	{
		return Ok(shapedOwners);
	}

	for (var index = 0; index < owners.Count(); index++)
	{
		var ownerLinks = CreateLinksForOwner(owners[index].Id, ownerParameters.Fields);
		shapedOwners[index].Add("Links", ownerLinks);
	}

	var ownersWrapper = new LinkCollectionWrapper<Entity>(shapedOwners);

	return Ok(CreateLinksForOwners(ownersWrapper));
}

Мы читаем тип мультимедиа, который мы проанализировали в ActionFilter ValidateMediaTypeAttribute, и приводим его к типу MediaTypeHeaderValue. Используя SubTypeWithoutSuffix.EndsWith из класса MediaTypeHeaderValue, мы проверяем, запрашивается ли HATEOAS, и если нет, мы немедленно возвращаем владельцев. Если это так, мы добавляем ссылки и возвращаем их, как делали до этой реализации.

Та же история с действием GetOwnerById:

[HttpGet("{id}", Name = "OwnerById")]
[ServiceFilter(typeof(ValidateMediaTypeAttribute))]
public IActionResult GetOwnerById(Guid id, [FromQuery] string fields)
{
	var owner = _repository.Owner.GetOwnerById(id, fields);

	if (owner.Id == Guid.Empty)
	{
		_logger.LogError($"Owner with id: {id}, hasn't been found in db.");
		return NotFound();
	}

	var mediaType = (MediaTypeHeaderValue)HttpContext.Items["AcceptHeaderMediaType"];

	if (!mediaType.SubTypeWithoutSuffix.EndsWith("hateoas", StringComparison.InvariantCultureIgnoreCase))
	{
		_logger.LogInfo($"Returned shaped owner with id: {id}");
		return Ok(owner.Entity);
	}

	owner.Entity.Add("Links", CreateLinksForOwner(owner.Id, fields));

	return Ok(owner.Entity);
}

Теперь, чтобы проверить это, давайте попробуем выполнить простой запрос, чтобы получить владельцев с заголовком запроса Accept, установленным в application / json: GET https://localhost:5001/api/owner

У нас должно получиться:

[
    {
        "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
        "Name": "John Keen",
        "DateOfBirth": "1980-12-05T00:00:00",
        "Address": "61 Wellfield Road"
    },
    {
        "Id": "261e1685-cf26-494c-b17c-3546e65f5620",
        "Name": "Anna Bosh",
        "DateOfBirth": "1974-11-14T00:00:00",
        "Address": "27 Colored Row"
    },
	...
]

А теперь, когда мы меняем заголовок Accept на application/vnd.testdomain.hateoas+json и должны получить:

{
    "value": [
        {
            "Id": "24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
            "Name": "John Keen",
            "DateOfBirth": "1980-12-05T00:00:00",
            "Address": "61 Wellfield Road",
            "Links": [
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "self",
                    "method": "GET"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "delete_owner",
                    "method": "DELETE"
                },
                {
                    "href": "https://localhost:5001/api/owner/24fd81f8-d58a-4bcc-9f35-dc6cd5641906",
                    "rel": "update_owner",
                    "method": "PUT"
                }
            ]
        },
        ...
    ],
    "links": [
        {
            "href": "https://localhost:5001/api/owner",
            "rel": "self",
            "method": "GET"
        }
    ]
}

Теперь у нас есть возможность выбирать между ответами json, XML и HATEOAS по своему усмотрению.

Заключение

HATEOAS - действительно одна из самых полезных, но в то же время одна из самых сложных концепций REST для реализации. Как вы реализуете HATEOAS в своем API и сколько вы вкладываете в его доводку, зависит от вас. HATEOAS - это то, что отличает отличный API от хороших и плохих.

Итак, что мы узнали на этот раз:

  • Что такое HATEOAS и насколько он важен в мире RESTful
  • Как реализовать HATEOAS в проекте ASP.NET Core WebAPI
  • Наше решение улучшено для удобной работы с формированием данных.
  • Как поддержать XML-сериализацию динамических объектов с помощью ссылок HATEOAS
  • Что такое пользовательские типы мультимедиа и как их использовать, чтобы сделать HATEOAS необязательным в наших ответах.