В первой части статьи я рассказал о том, как в «АльфаСтрахование» была реализована OData API на .NET Core с использованием EF Core. В этой статье я коснусь реализации методов контроллера для одной из моделей.

Как правило, статьи по реализации API OData на .NET Core содержат всего пару примеров – получение всех сущностей и получение одной сущности по ее ID. Я же хочу дать больше образцов кода для написания методов, которые позволяют осуществлять основные манипуляции с данными в рамках требований OData.

Общее описание маршрутизации запросов OData

Пакет Microsoft.AspNetCore.OData версии 8 включает в себя 10 встроенных соглашений маршрутизации. Эти соглашения определяют, какому действию контроллера будет сопоставлен тот или иной запрос. Ниже даны примеры реализации методов контроллеров для выполнения основных манипуляций с данными в рамках этих конвенций. Эти примеры требует адаптации к реальному приложению, выделения логики из контроллеров в другие сущности, однако дают понимание логики того, как работает стандартный роутинг.

Приведенные методы извлечения данных (Get) при запросе элемента с несуществующим идентификатором возвращают успешный ответ, обработанный OData. В то же время, методы модификации данных (Post, Patch, Put, Delete) при отправке некорректных данных выбрасывают исключения, поэтому в указанные методы в реальном проекте может потребоваться включить дополнительные проверки и отлов исключений.

При использовании встроенных конвенций нет нужды использовать атрибуты контроллеров или методов, кроме [EnableQuery], который позволяет осуществлять манипуляции с результатом действия с помощью операторов OData ($select, $filter, $expand и др.). Эти операторы в явном виде задаются при вызове services.AddOData() в Startup.cs.

Возьмем запрос для примера:

GET http://localhost:61268/odata/UsingSystems(1)/UploadTemplates?$select=Name

По умолчанию, в пути запроса выделяются 3 части: Service Root (http://localhost:61268/odata), путь OData (UsingSystems(1)/UploadTemplates) и параметры запроса с операторами (?$select=Name).

Первая часть пути OData - имя контроллера, который будет искать механизм маршрутизации - UsingSystemsController. Поиск нужного действия метода в контроллере осуществляется исходя из типа запроса (GET запрос ищет метод с названием, начинающимся на Get). В случае наличия ИД или ключа для поиска (1 в нашем примере), механизм маршрутизации будет искать метод с входным параметром key.

Если определено 2 ключа, например, UsingSystems(1)/UploadTemplates(2), будет найден метод с параметрами key и relatedKey. Наконец, наличие имени свойства в пути (UploadTemplates) укажет механизму на то, что нужно искать метод с названием GetUploadTemplates или GetUploadTemplatesFromUsingSystem. Таким образом, указанному пути будет сопоставлен метод GetUploadTemplates(T key) в UsingSystemsController.

Библиотека Microsoft.AspNetCore.OData также позволяет создать свои конвенции, либо вручную сопоставлять пути, контроллеры и их действия при помощи атрибутов.

Получение записей справочника (EntitySetRoutingConvention)

Данный метод прост в реализации и позволяет считывать данные справочники с помощью операторов OData – параметров запроса к API. От вас требуется лишь вернуть IQueryable требуемого типа, причем сделать это можно несколькими способами. Наиболее удобный лично для меня:

[EnableQuery]
public IActionResult Get()
{
    return Ok(_dbContext.UsingSystems);
}

Пример запроса:

GET http://localhost:61268/odata/UsingSystems

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

GET http://localhost:61268/odata/UsingSystems?$expand=UploadTemplates

а также явно включить это свойство в возвращаемый объект в контроллере:

    [EnableQuery]
    public IActionResult Get()
    {
        return Ok(_dbContext.UsingSystems
            .Include(x => x.UploadTemplates));
    }

Получение записи по ID (EntityRoutingConvention)

Данный способ позволяет обращаться к одной записи справочника по ее идентификатору (тип идентификатора может быть целочисленным либо строковым), оставляя возможными дополнительные действия через параметры запроса. Ниже приведена его реализация с возможностью $expand для свойства UploadTemplates:

    [EnableQuery]
    public IActionResult Get(long key)
    {
        return Ok(_dbContext.UsingSystems
            .Where(x => x.Id == key)
            .Include(x => x.UploadTemplates));
    }

У меня данный метод корректно заработал только после того, как я назвал параметр «key». Примеры запросов:

GET http://localhost:61268/odata/UsingSystems(1)

GET http://localhost:61268/odata/UsingSystems/1

Получение свойства записи по ID (PropertyRoutingConvention)

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

Примеры запросов для обычного свойства:

GET http://localhost:61268/odata/UsingSystems(1)/Description

GET http://localhost:61268/odata/UsingSystems/1/Description

    [EnableQuery]
    public IActionResult GetDescription(long key)
    {
        return Ok(_dbContext.UsingSystems
            .Where(x => x.Id == key)
            .Select(x => x.Description));
    }

Примеры запросов для навигационного свойства:

GET http://localhost:61268/odata/UsingSystems(1)/UploadTemplates

GET http://localhost:61268/odata/UsingSystems/1/UploadTemplates

    [EnableQuery]
    public IActionResult GetUploadTemplates(long key)
    {
        return Ok(_dbContext.UsingSystems
            .Where(x => x.Id == key)
            .Select(x => x.UploadTemplates));
    }

При наличии в справочнике связи один-ко-многим, навигационное свойство представляет собой не List<T>, а одиночный объект. В этом случае возможна модификация вышеприведенного метода для приема запроса вида:

GET http://localhost:61268/odata/UsingSystems(1)/SystemOwner/Name

В этом случае, по соглашениям маршрутизации, следует назвать метод GetNameFrom SystemOwner(key).

Создание записи

Для создания новой записи следует отправить следующий запрос:

POST http://localhost:61268/odata/UsingSystems

В теле запроса необходимо передать JSON структуру создаваемого объекта, например:

{
    "Id": 0,
    "Name": "test_add_sys",
    "Description": "Тестовая система для добавления"
}

В качестве первичного ключа передается дефолтное значение. В документации OData говорится о том, что в ответ необходимо вернуть созданный объект. В примере ниже в возвращаемый объект при сохранении изменений в контексте БД записывается идентификатор, присвоенный при добавлении. Атрибут [EnableQuery] используется для того, чтобы сделать возможным манипуляции с созданным объектом средствами OData, например, вернуть клиенту только ID запросом

POST http://localhost:61268/odata/UsingSystems?$select=id

Для возвращения результата используется метод Created (201), который возвращает URI, передаваемый клиенту в заголовке Located, и созданный объект, передаваемый в теле запроса.

    [EnableQuery]
    public IActionResult Post([FromBody] UsingSystem usingSystem)
    {
        _dbContext.Add(usingSystem);
        _dbContext.SaveChanges();
        return Created($"{HttpContext.Request.GetDisplayUrl()}({usingSystem.Id})", usingSystem);
    }

Удаление записи

Для удаления записи используется следующий запрос:

DELETE http://localhost:61268/odata/UsingSystems(1)

DELETE http://localhost:61268/odata/UsingSystems/1

Поскольку документация OData не предусматривают возврата чего-либо в теле ответа, атрибут [EnableQuery] здесь использовать бесполезно. В соответствии со стандартом, при успехе возвращается код 204 (No Content):

    public IActionResult Delete(long key)
    {
        var entity = _dbContext.UsingSystems
            .FirstOrDefault(x => x.Id == key);
        _dbContext.Remove(entity);
        _dbContext.SaveChanges();
        return NoContent();
    }

Изменение записи

Документация OData требует обязательной реализации метода модификации PATCH, а возможность реализации метода PUT оставляет на усмотрение разработчика. Соответственно, примеры запросов:

PATCH http://localhost:61268/odata/UsingSystems(1)

PATCH http://localhost:61268/odata/UsingSystems/1

PUT http://localhost:61268/odata/UsingSystems(1) (опционально)

PUT http://localhost:61268/odata/UsingSystems/1 (опционально)

Согласно документации OData AspNet WebApi V7, в теле запроса PATCH передается неполный объект, содержащий только модифицированные свойства, например:

{
    "Description": "Изменение"
}

В отличие от PATCH, метод PUT предполагает передачу полного объекта:

{
    "Id": 2,
    "Name": "test_add_sys",
    "Description": "Тестовая система для изменения"
}

В данном примере реализованы оба метода. Обновление существующей записи производится через объект Microsoft.AspNetCore.OData.Delta, но во втором случае приходится создавать его самостоятельно:

    public IActionResult Patch(long key, [FromBody] Delta<UsingSystem> delta)
    {
        var entity = _dbContext.UsingSystems
            .FirstOrDefault(x => x.Id == key);
        delta.Patch(entity);
        _dbContext.SaveChanges();
        return NoContent();
    }
    
    public IActionResult Put(long key, [FromBody] UsingSystem usingSystem)
    {
        var delta = new Delta<UsingSystem>();
        delta.TrySetPropertyValue(nameof(usingSystem.Name), usingSystem.Name);
        delta.TrySetPropertyValue(nameof(usingSystem.Description), usingSystem.Description);
        var entity = _dbContext.UsingSystems
            .FirstOrDefault(x => x.Id == key);
        delta.Patch(entity);
        _dbContext.SaveChanges();
        return NoContent();
    }

Для обновления связей объектов, т.е. навигационных свойств, данный метод использовать нельзя. В случае передачи навигационного свойства в теле запроса, в параметр дельты придет null c возможным выбросом NullReferenceException.

Манипуляции с навигационными свойствами

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

Для манипуляций с навигационными свойствами, которые являются ссылками на другие справочниками, в запросах используется оператор $ref, для сопоставления нужных методов, методы должны включать в название Ref, например, CreateRefToUploadTemplates.

Поскольку связи можно получить методом GetUploadTemplates, ниже я привел реализацию методов добавления и удаления связи при отношении «один-ко-многим».

Запрос на добавление связи:

POST или PUT http://localhost:61268/odata/UsingSystems(5)/UploadTemplates/$ref

Тело запроса, по стандарту, выглядит так:

{
    "@odata.id": "http://localhost:61268/odata/UploadTemplates(2)"
}

Код метода контроллера:

    public IActionResult CreateRefToUploadTemplates(int key, string navigationProperty, [FromBody] JsonElement link)
    {
        UsingSystem? entity = _dbContext.UsingSystems
           .FirstOrDefault(x => x.Id == key);

        string path = link.GetProperty("@odata.id")
           .GetString();

        long relatedKey = long.Parse(Regex.Match(path, "\\(\\d+\\)")
           .Value);

        UploadTemplate? relatedEntity = _dbContext.UploadTemplates
           .FirstOrDefault(x => x.Id == relatedKey);

        entity.UploadTemplates.Add(relatedEntity);
        _dbContext.SaveChanges();

        return NoContent();
    }

Документация AspNet WebApi V7 предусматривает для данного метода иную сигнатуру; последний параметр имеет тип Uri, и должен самостоятельно извлекаться из JSON. Однако, в используемой версии это не работает, так как на вход из тела приходит JsonElement (если бы мы использовали Newtonsoft.Json, предположительно, это был бы JObject). Так или иначе, тело ответа приходится разбирать самостоятельно, вытаскивая их него ИД связываемого объекта.

Запрос на удаление связи:

DELETE http://localhost:61268/odata/UsingSystems(5)/UploadTemplates(2)/$ref

Код метода контроллера:

    public IActionResult DeleteRefToUploadTemplates(int key, int relatedKey, string navigationProperty)
    {
        UsingSystem entity = _dbContext.UsingSystems
           .Where(x => x.Id == key)
           .Include(x => x.UploadTemplates)
           .FirstOrDefault();

        UploadTemplate relatedEntity = _dbContext.UploadTemplates
           .FirstOrDefault(x => x.Id == relatedKey);

        entity.UploadTemplates.Remove(relatedEntity);
        _dbContext.SaveChanges();

        return NoContent();
    }

Принципиальным здесь является получение записи типа UsingSystem со включенным навигационным свойством UploadTemplates (с помощью Include). Если этого не сделать, связь не будет удалена.

Источники

ASP.NET OData 8.0 Preview for .NET 5

Routing in ASP.NET OData 8.0 Preview

OData AspNet WebApi V7 Built-in Routing Conventions

OData Basic Tutorial

Комментарии (0)