OData - очень интересная технология. В несколько строчек кода вы можете добавить к своему сервису возможность фильтрации данных, постраничной выборки, частичной выборки данных, ... Сегодня ей на смену приходит GraphQL, но OData всё ещё очень привлекательна.

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


Простейшее использование

Для начала нам потребуется Web-сервис. Я создам его в помощью ASP.NET Core. Для того, чтобы использовать OData, нужно установить NuGet-пакет Microsoft.AspNetCore.OData. Теперь необходимо произвести настройку. Вот содержимое Program.cs:

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.

builder.Services
    .AddControllers()
    .AddOData(opts =>
    {
        opts
            .Select()
            .Expand()
            .Filter()
            .Count()
            .OrderBy()
            .SetMaxTop(1000);
    });

var app = builder.Build();

// Configure the HTTP request pipeline.

app.UseAuthorization();

app.MapControllers();

app.Run();

В методе AddOData мы указываем, какие именно операции из всех, возможных в OData, мы разрешаем.

Естественно, OData предназначен для работы с данными. Давайте добавим данные в наше приложение. Они будут очень простыми:

public class Author
{
    [Key]
    public int Id { get; set; }

    [Required]
    public string FirstName { get; set; }

    [Required]
    public string LastName { get; set; }

    public string? ImageUrl { get; set; }

    public string? HomePageUrl { get; set; }

    public ICollection<Article> Articles { get; set; }
}

public class Article
{
    [Key]
    public int Id { get; set; }

    public int AuthorId { get; set; }

    [Required]
    public string Title { get; set; }
}

Я буду использовать Entity Framework для работы с ними. Тестовые данные я создам с помощью Bogus:

public class AuthorsContext : DbContext
{
    public DbSet<Author> Authors { get; set; } = null!;

    public AuthorsContext(DbContextOptions<AuthorsContext> options)
        : base(options)
    { }

    public async Task Initialize()
    {
        await Database.EnsureDeletedAsync();
        await Database.EnsureCreatedAsync();

        var rnd = Random.Shared;

        Authors.AddRange(
            Enumerable
                .Range(0, 10)
                .Select(_ =>
                {
                    var faker = new Faker();

                    var person = faker.Person;

                    return new Author
                    {
                        FirstName = person.FirstName,
                        LastName = person.LastName,
                        ImageUrl = person.Avatar,
                        HomePageUrl = person.Website,
                        Articles = new List<Article>(
                            Enumerable
                                .Range(0, rnd.Next(1, 5))
                                .Select(_ => new Article
                                {
                                    Title = faker.Lorem.Slug(rnd.Next(3, 5))
                                })
                        )
                    };
                })
        );

        await SaveChangesAsync();
    }
}

В качестве хранилища для данных я буду использовать расположенную в памяти Sqlite. Вот как я конфигурирую моё хранилище в Program.cs:

...

var inMemoryDatabaseConnection = new SqliteConnection("DataSource=:memory:");
inMemoryDatabaseConnection.Open();

builder.Services.AddDbContext<AuthorsContext>(optionsBuilder =>
    {
        optionsBuilder.UseSqlite(inMemoryDatabaseConnection);
    }
);

...

using (var scope = app.Services.CreateScope())
{
    await scope.ServiceProvider.GetRequiredService<AuthorsContext>().Initialize();
}

...

Что ж, хранилище готово. Давайте создадим простой контроллер, возвращающий пользователю его данные:

[ApiController]
[Route("/api/v1/authors")]
public class AuthorsController : ControllerBase
{
    private readonly AuthorsContext _db;

    public AuthorsController(
        AuthorsContext db
        )
    {
        _db = db ?? throw new ArgumentNullException(nameof(db));
    }

    [HttpGet("no-odata")]
    public ActionResult GetWithoutOData()
    {
        return Ok(_db.Authors);
    }
}

Теперь по адресу /api/v1/authors/no-odata мы можем получить результат:

[
  {
    "id": 1,
    "firstName": "Fred",
    "lastName": "Kuhlman",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/54.jpg",
    "homePageUrl": "donald.com"
  },
  {
    "id": 2,
    "firstName": "Darrel",
    "lastName": "Armstrong",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/796.jpg",
    "homePageUrl": "angus.org"
  },
  ...
]

Естественно, ни о какой поддержке OData пока речи нет. Но насколько тяжело её добавить?

Базовая поддержка OData

Очень легко. Давайте создадим ещё одну конечную точку:

[HttpGet("odata")]
[EnableQuery]
public IQueryable<Author> GetWithOData()
{
    return _db.Authors;
}

Как видите, отличия небольшие. Но теперь вы можете использовать OData в ваших запросах. Например, запрос вида /api/v1/authors/odata?$filter=id lt 3&$orderby=firstName даёт результат:

[
  {
    "id": 2,
    "firstName": "Darrel",
    "lastName": "Armstrong",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/796.jpg",
    "homePageUrl": "angus.org"
  },
  {
    "id": 1,
    "firstName": "Fred",
    "lastName": "Kuhlman",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/54.jpg",
    "homePageUrl": "donald.com"
  }
]

Замечательно. Но есть небольшой недостаток. Дело в том, что наш метод контроллера возвращает объект IQueryable<>. На практике же обычно нужно возвращать несколько вариантов ответов. Например, NotFound, BadRequest, ... Что же делать?

Оказывается, что реализация OData нормально работает в случае возврата IQueryable<>, обёрнутого в Ok:

[HttpGet("odata")]
[EnableQuery]
public IActionResult GetWithOData()
{
    return Ok(_db.Authors);
}

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

Постраничная обработка

Как вы наверняка знаете, OData позволяет получить не полный результат, а только определённую страницу. Это делается с помощью операторов skip и top (например, /api/v1/authors/odata?$skip=3&$top=2). Нужно только не забыть вызвать метод SetMaxTop при конфигурации OData в Program.cs, иначе попытка использовать оператор top может привести к ошибке:

The query specified in the URI is not valid. The limit of '0' for Top query has been exceeded.

Но для полноценного использования механизма постраничного получения данных очень полезно знать, с��олько всего страниц у вас есть. Для этого нужно, чтобы наша конечная точка кроме самих данных одной страницы возвращала и общее количество данных, соответствующих указанному фильтру. Для этого в OData присутствует оператор count: (/api/v1/authors/odata?$skip=3&$top=2&$count=true). Но простое добавление его к запросу не приводит ни к какому результату. Чтобы получить то, что нам нужно, необходимо настроить EDM (entity data model). Но чтобы она заработала, нужно определиться с адресом нашей конечной точки.

Итак, пусть мы хотим получать наши данные по адресу /api/v1/authors/edm. По этому адресу мы будем возвращать объекты типа Author. Тогда настройка EDM производится так. В нашем Program.cs внесём некоторые изменения в настройку OData:

builder.Services
    .AddControllers()
    .AddOData(opts =>
    {
        opts.AddRouteComponents("api/v1/authors", GetAuthorsEdm());

        IEdmModel GetAuthorsEdm()
        {
            ODataConventionModelBuilder edmBuilder = new();

            edmBuilder.EntitySet<Author>("edm");

            return edmBuilder.GetEdmModel();
        }

        opts
            .Select()
            .Expand()
            .Filter()
            .Count()
            .OrderBy()
            .SetMaxTop(1000);
    });

Обратите внимание на то, что имя маршрута для компонентов (api/v1/authors) совпадает с префиксом адреса для нашей конечной точки, а имя набора сущностей (entity set) совпадает с окончанием этого адреса (edm).

Чтобы это заработало, необходимо добавить к соответствующему методу контроллера атрибут ODataAttributeRouting:

[HttpGet("edm")]
[ODataAttributeRouting]
[EnableQuery]
public IQueryable<Author> GetWithEdm()
{
    return _db.Authors;
}

Теперь данные, возвращаемые этой конечной точкой, для страничного запроса /api/v1/authors/edm?$top=2&$count=true будут иметь вид:

{
  "@odata.context": "http://localhost:5293/api/v1/authors/$metadata#edm",
  "@odata.count": 10,
  "value": [
    {
      "Id": 1,
      "FirstName": "Steve",
      "LastName": "Schaefer",
      "ImageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/670.jpg",
      "HomePageUrl": "kylie.info"
    },
    {
      "Id": 2,
      "FirstName": "Stella",
      "LastName": "Ankunding",
      "ImageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/884.jpg",
      "HomePageUrl": "allen.name"
    }
  ]
}

Как видите, поле @odata.count содержит общее число элементов, удовлетворяющих фильтру запроса, что нам и требовалось.

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

Определённую помощь может оказать отладочная страница, которую вы можете включить при конфигурации приложения:

if (app.Environment.IsDevelopment())
{
    app.UseODataRouteDebug();
}

После этого по адресу /$odata вы сможете посмотреть, какие конечные точки у вас есть, и ассоциированы ли с ними модели.

Сериализация JSON

Обратили ли вы внимание на то, какое изменение произошло с возвращаемыми нами данными после того, как мы добавили EDM? Все имена свойств стали начинаться с большой буквы (было firstName, а стало FirstName). На самом деле это может быть большой проблемой для JavaScript-клиентов, для которых существует разница между заглавными и строчными буквами. Нам нужно как-то управлять именами свойств возвращаемых объектов. OData использует System.Text.Json для сериализации данных. К сожалению, использование атрибутов этого пространства имён ничего не даёт:

[JsonPropertyName("firstName")]
public string FirstName { get; set; }

По-видимому, OData берёт имена свойств из EDM, а не из определения класса.

Реализация OData от Microsoft предлагает нам два выхода в случае использования EDM. Первый из них позволяет включить "lower camel case" для всей модели при помощи вызова функции EnableLowerCamelCase:

IEdmModel GetAuthorsEdm()
{
    ODataConventionModelBuilder edmBuilder = new();

    edmBuilder.EnableLowerCamelCase();

    edmBuilder.EntitySet<Author>("edm");

    return edmBuilder.GetEdmModel();
}

Теперь мы получаем данные вида:

{
  "@odata.context": "http://localhost:5293/api/v1/authors/$metadata#edm",
  "@odata.count": 10,
  "value": [
    {
      "id": 1,
      "firstName": "Troy",
      "lastName": "Gottlieb",
      "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/228.jpg",
      "homePageUrl": "avery.net"
    },
    {
      "id": 2,
      "firstName": "Mathew",
      "lastName": "Schiller",
      "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/401.jpg",
      "homePageUrl": "marion.biz"
    }
  ]
}

Это хорошо, но что если нам требуется более глубокий контроль над именами JSON-свойств? Что если нам нужно, чтобы некоторое свойство в JSON имело имя, неразрешённое для имён свойств в C# (как, например, @odata.count)?

Можно сделать и это через EDM. Давайте переименуем homePageUrl в @url.home:

IEdmModel GetAuthorsEdm()
{
    ODataConventionModelBuilder edmBuilder = new();

    edmBuilder.EnableLowerCamelCase();

    edmBuilder.EntitySet<Author>("edm");

    edmBuilder.EntityType<Author>()
        .Property(a => a.HomePageUrl).Name = "@url.home";

    return edmBuilder.GetEdmModel();
}

Здесь нас ждёт неприятный сюрприз:

Microsoft.OData.ODataException: The property name '@url.home' is invalid; property names must not contain any of the reserved characters ':', '.', '@'.

Что ж, попробуем что-нибудь попроще:

edmBuilder.EntityType<Author>()
        .Property(a => a.HomePageUrl).Name = "url_home";

Так уже работает:

{
    "url_home": "danielle.info",
    "id": 1,
    "firstName": "Armando",
    "lastName": "Hammes",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/956.jpg"
},

Неприятно, конечно, но что поделаешь.

Преобразование данных

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

Я создам простые обёртки наших классов:

public class AuthorDto
{
    public int Id { get; set; }

    public string FirstName { get; set; }

    public string LastName { get; set; }

    public string? ImageUrl { get; set; }

    public string? HomePageUrl { get; set; }

    public ICollection<ArticleDto> Articles { get; set; }
}

public class ArticleDto
{
    public string Title { get; set; }
}

Для преобразования я буду пользоваться AutoMapper. С Mapster я глубоко не разбирался, но знаю, что он тоже поддерживает работу с Entity Framework.

Для AutoMapper необходимо создать нужное нам преобразование:

public class DefaultProfile : Profile
{
    public DefaultProfile()
    {
        CreateMap<Article, ArticleDto>();
        CreateMap<Author, AuthorDto>();
    }
}

и зарегистрировать его при старте приложения (для чего используется NuGet-пакет AutoMapper.Extensions.Microsoft.DependencyInjection):

builder.Services.AddAutoMapper(typeof(Program).Assembly);

Теперь я могу создать ещё одну конечную точку на моём контроллере:

...

private readonly IMapper _mapper;
private readonly AuthorsContext _db;

public AuthorsController(
    IMapper mapper,
    AuthorsContext db
    )
{
    _mapper = mapper ?? throw new ArgumentNullException(nameof(mapper));
    _db = db ?? throw new ArgumentNullException(nameof(db));
}

...

[HttpGet("mapping")]
[EnableQuery]
public IQueryable<AuthorDto> GetWithMapping()
{
    return _db.Authors.ProjectTo<AuthorDto>(_mapper.ConfigurationProvider);
}

Как видите, преобразование осуществляется довольно просто. К сожалению, возвращаемые данные содержат развёрнутый список статей автора:

[
  {
    "id": 1,
    "firstName": "Edward",
    "lastName": "O'Kon",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1162.jpg",
    "homePageUrl": "zachariah.info",
    "articles": [
      {
        "title": "animi-sint-atque"
      },
      {
        "title": "aut-eum-iure"
      }
    ]
  },
  ...
]

Т. е. мы фактически утратили возможность произвольно выполнять операцию expand OData. Что ж, это дело поправимое. Давайте немного изменим конфигурацию AutoMapper для AuthorDto:

CreateMap<Author, AuthorDto>()
    .ForMember(a => a.Articles, o => o.ExplicitExpansion());

Теперь на запрос /api/v1/authors/mapping нам возвращаются правильные данные:

[
  {
    "id": 1,
    "firstName": "Spencer",
    "lastName": "Cummerata",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/286.jpg",
    "homePageUrl": "woodrow.info"
  },
  ...
]

А на запрос /api/v1/authors/mapping?$expand=articles:

InvalidOperationException: The LINQ expression '$it => new SelectAll<ArticleDto>{
Model = __TypedProperty_1,
Instance = $it,
UseInstanceForProperties = True
}
' could not be translated.

Да, проблемка. Но AutoMapper предос��авляет нам ещё один способ работать с OData. Существует пакет AutoMapper.AspNetCore.OData.EFCore. С его помощью я могу реализовать свою конечную точку так:

[HttpGet("automapper")]
public IQueryable<AuthorDto> GetWithAutoMapper(ODataQueryOptions<AuthorDto> query)
{
    return _db.Authors.GetQuery(_mapper, query);
}

Обратите внимание, что мы не помечаем наш метод атрибутом EnableQuery. Вместо этого мы собираем передаваемые в запросе OData-параметры в объект ODataQueryOptions и применяем необходимые преобразование "вручную".

На этот раз всё работает нормально: и запрос без развёртывания:

[
  {
    "id": 1,
    "firstName": "Nathan",
    "lastName": "Heller",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/764.jpg",
    "homePageUrl": "jamarcus.biz",
    "articles": null
  },
  ...
]

и запрос с развёртыванием:

[
  {
    "id": 1,
    "firstName": "Nathan",
    "lastName": "Heller",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/764.jpg",
    "homePageUrl": "jamarcus.biz",
    "articles": [
      {
        "title": "quidem-nulla-et"
      }
    ]
  },
  ...
]

Кроме того, у этого подхода есть ещё одно достоинство. Он позволяет использовать стандартные JSON-инструменты для управления сериализацией наших объектов. Например, мы можем убрать свойства, имеющие значение null из наших результатов, настроив сериализацию:

builder.Services
    .AddJsonOptions(configure =>
    {
        configure.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
        configure.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    });

Далее, мы можем управлять именами свойств через атрибуты:

[JsonPropertyName("@url.home")]
public string? HomePageUrl { get; set; }

Теперь м�� можем дать нашему свойству такое имя:

[
  {
    "id": 1,
    "firstName": "Edward",
    "lastName": "Schmidt",
    "imageUrl": "https://cloudflare-ipfs.com/ipfs/Qmd3W5DuhgHirLHGVixi6V76LhCkZUz6pnFt5AJBiyvHye/avatar/1046.jpg",
    "@url.home": "justen.com"
  },
  ...
]

Добавление данных

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

Во-первых, преобразование данных может быть простым. Например, я хочу возвращать не имя и фамилию по-отдельности, а полное имя автора:

public class ComplexAuthor
{
    [Key]
    public int Id { get; set; }

    public string FullName { get; set; }
}

Мы можем настроить отображение AutoMapper для этого класса так:

CreateMap<Author, ComplexAuthor>()
    .ForMember(d => d.FullName,
        opt => opt.MapFrom(s => s.FirstName + " " + s.LastName));

В данном случае мы получаем искомый результат:

[
  {
    "id": 1,
    "fullName": "Lance Rice"
  },
  ...
]

Более того, мы по-прежнему можем фильтровать и упорядочивать наши записи по нашему новому полю (/api/v1/authors/nonsql?$filter=startswith(fullName,'A')):

[
  {
    "id": 4,
    "fullName": "Andre Medhurst"
  },
  {
    "id": 6,
    "fullName": "Amber Terry"
  }
]

Дело здесь в том, что наше простое выражение (s.FirstName + " " + s.LastName) может быть легко преобразовано в часть SQL запроса. Вот какой запрос сгенерировал для меня в данном случае Entity Framework:

SELECT "a"."Id", ("a"."FirstName" || ' ') || "a"."LastName"
      FROM "Authors" AS "a"
      WHERE (@__TypedProperty_0 = '') OR (((("a"."FirstName" || ' ') || "a"."LastName" LIKE @__TypedProperty_0 || '%') AND (substr(("a"."FirstName" || ' ') || "a"."LastName", 1, length(@__TypedProperty_0)) = @__TypedProperty_0)) OR (@__TypedProperty_0 = ''))

Именно поэтому операции фильтрации и упорядочивания продолжают работать.

Но, очевидно, не все преобразования данных могут быть переведены в SQL. Предположим, например, что нам по какой-то причине потребовалось считать хэш нашего полного имени:

public class ComplexAuthor
{
    [Key]
    public int Id { get; set; }

    public string FullName { get; set; }

    public string NameHash { get; set; }
}

Теперь инструкции для AutoMapper выглядят так:

CreateMap<Author, ComplexAuthor>()
    .ForMember(d => d.FullName,
        opt => opt.MapFrom(s => s.FirstName + " " + s.LastName))
    .ForMember(
        d => d.NameHash,
        opt => opt.MapFrom(a => string.Join(",", SHA256.HashData(Encoding.UTF32.GetBytes(a.FirstName + " " + a.LastName))))
    );

Давайте попробуем получить наши данные:

[
  {
    "id": 1,
    "fullName": "Julius Haag",
    "nameHash": "66,19,82,19,233,224,181,226,111,125,241,228,81,6,200,47,5,112,248,30,186,26,173,91,83,73,9,137,6,158,138,115"
  },
  {
    "id": 2,
    "fullName": "Anita Wilderman",
    "nameHash": "196,131,191,35,182,3,174,193,196,91,70,199,22,173,72,54,123,73,110,83,254,178,19,129,219,24,137,197,83,158,76,209"
  },
  ...
]

Интересно. Несмотря на то, что результирующее выражение не может быть выражено в терминах SQL, но система всё ещё продолжает работать. Видимо Entity Framework знает, какие вычисления можно выполнить на стороне сервера.

Попробуем теперь фильтровать данные по нашему новому полю (nameHash): /api/v1/authors/nonsql?$filter=nameHash eq '1'

InvalidOperationException: The LINQ expression 'DbSet<Author>()
.Where(a => (string)string.Join<byte>(
separator: ",",
values: SHA256.HashData(__UTF32_0.GetBytes(a.FirstName + " " + a.LastName))) == __TypedProperty_1)' could not be translated.

Теперь мы уже не можем избежать преобразования нашего выражения в SQL. И, поскольку оно не может быть выполнено, мы получаем сообщение об ошибке.

В данном случае, если не удаётся переписать выражение так, чтобы оно могло быть конвертируемо в SQL, можно попробовать запретить фильтрацию и сортировку по данному полю. Для этого существуют атрибуты NonFilterable и NotFilterable, NotSortable и Unsortable. Вы можете использовать любые из этих пар:

public class ComplexAuthor
{
    [Key]
    public int Id { get; set; }

    public string FullName { get; set; }

    [NonFilterable]
    [Unsortable]
    public string NameHash { get; set; }
}

Мне бы лично хотелось, чтобы при попытке фильтровать по нашему полю, пользователю возвращался Bad Request. Но само по себе добавление этих атрибутов ничего не даёт. Фильтрация по nameHash приводит к появлению всё той же ошибки. Необходимо провести валидацию запроса вручную:

[HttpGet("nonsql")]
public IActionResult GetNonSqlConvertible(ODataQueryOptions<ComplexAuthor> options)
{
    try
    {
        options.Validator.Validate(options, new ODataValidationSettings());
    }
    catch (ODataException e)
    {
        return BadRequest(e.Message);
    }

    return Ok(_db.Authors.GetQuery(_mapper, options));
}

Теперь при попытке фильтрации мы получаем сообщение:

The property 'NameHash' cannot be used in the $filter query option.

Так уже лучше. Хотя возвращаемое пользователю имя свойства начинается с маленькой буквы (nameHash), а не с большой (NameHash).

Интересно, а как вообще обстоят дела с изменением имён свойств с помощью атрибута JsonPropertyName? Например, я хочу, чтобы имя называлось name:

[JsonPropertyName("name")]
public string FullName { get; set; }

Могу ли я теперь фильтровать по name (/api/v1/authors/nonsql?$filter=startswith(name,'A'))? Оказывается, нет:

Could not find a property named 'name' on type 'ODataJourney.Models.ComplexAuthor'.

А что, если вернуться к EDM? Для этого достаточно добавить атрибут ODataAttributeRouting к методу контроллера:

[HttpGet("nonsql")]
[ODataAttributeRouting]
public IActionResult GetNonSqlConvertible(ODataQueryOptions<ComplexAuthor> options)

И прописать нашу модель:

...

edmBuilder.EntitySet<ComplexAuthor>("nonsql");

edmBuilder.EntityType<ComplexAuthor>()
    .Property(a => a.FullName).Name = "name";

...

Теперь мы можем фильтровать по name:

{
  "@odata.context": "http://localhost:5293/api/v1/authors/$metadata#nonsql",
  "value": [
    {
      "name": "Leona Bauch",
      "id": 3,
      "nameHash": "56,114,131,251,22,63,188,105,37,55,74,232,36,181,152,24,9,111,131,55,229,89,164,181,230,158,109,163,206,137,147,173"
    },
    {
      "name": "Leo Schimmel",
      "id": 7,
      "nameHash": "78,48,88,216,170,3,241,99,96,251,10,176,45,187,250,58,240,215,104,159,26,158,217,244,93,219,183,119,206,40,130,102"
    }
  ]
}

Но, как видите, структура возвращаемых данных поменялась. Мы получили OData-обёртку. Кроме того, мы вернулись к описанному выше ограничению на имена свойств.

В заключении давайте рассмотрим ещё один тип преобразования возвращаемых данных. До сих пор мы делали это преобразование с помощью AutoMapper. Но в таком случае мы не можем использовать контекст запроса. Преобразования AutoMapper описываются в отдельном файле, где нет доступа к информации, поступающей к нам в запросе. Но иногда это бывает важным. Например, мы хотим выполнить Web-запрос на основе полученных в запросе данных и изменить возвращаемую пользователю информацию на основе результатов этого запроса. В следующем примере я использую простой цикл foreach для представления некоторой серверной обработки данных:

[HttpGet("add")]
public IActionResult ApplyAdditionalData(ODataQueryOptions<ComplexAuthor> options)
{
    try
    {
        options.Validator.Validate(options, new ODataValidationSettings());
    }
    catch (ODataException e)
    {
        return BadRequest(e.Message);
    }

    var query = _db.Authors.ProjectTo<ComplexAuthor>(_mapper.ConfigurationProvider);

    var authors = query.ToArray();

    foreach (var author in authors)
    {
        author.FullName += " (Mr)";
    }

    return Ok(authors);
}

Естественно, ни о какой OData тут речи не идёт. Но как мы можем включить поддержку OData? Нам бы не хотелось терять возможность фильтрации, сортировки и разбиения на страницы.

Возможным выходом будет следующий подход. Можно сначала выполнить все запрошенные операции кроме select. В данном случае мы всё ещё будем работать с полным объектом ComplexAuthor. После этого мы внесём в полученные данные наши изменения, а затем применим операцию select, если она была запрошена. Это позволит нам получить из базы данных только небольшое количество записей, соответствующих нашему фильтру и странице:

[HttpGet("add")]
public IActionResult ApplyAdditionalData(ODataQueryOptions<ComplexAuthor> options)
{
    try
    {
        options.Validator.Validate(options, new ODataValidationSettings());
    }
    catch (ODataException e)
    {
        return BadRequest(e.Message);
    }

    var query = _db.Authors.ProjectTo<ComplexAuthor>(
        _mapper.ConfigurationProvider);

    var authors = options
        .ApplyTo(query, AllowedQueryOptions.Select)
        .Cast<ComplexAuthor>()
        .ToArray();

    foreach (var author in authors)
    {
        author.FullName += " (Mr)";
    }

    var result = options.ApplyTo(
        authors.AsQueryable(),
        AllowedQueryOptions.All & ~AllowedQueryOptions.Select
    );

    return Ok(result);
}

Объект ODataQueryOptions позволяет указывать, какие именно операции OData должны применяться. Это позволяет нам разделить их на два этапа, между которыми мы и включаем свою обработку.

У этого подхода есть свои недостатки. Во-первых, мы опять потеряли возможность конфигурировать имена свойств наших объектов через JSON-атрибуты. Это можно исправить введением EDM, но за это приходится платить изменением формы возвращаемых данных (появляется OData-обёртка).

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

public ICollection<ArticleDto> Articles { get; set; }

Использованный нами ранее метод GetQuery из пакета AutoMapper.AspNetCore.OData.EFCore не позволяет частично выполнять операции OData. А без него мне не удалось заставить систему корректно разворачивать Articles. Я дошёл до непонятной ошибки:

ODataException: Property 'articles' on type 'ODataJourney.Models.ComplexAuthor' is not a navigation property or complex property. Only navigation properties can be expanded.

Может у кого-то получится преодолеть её.

Заключение

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

Будем надеяться, что я здесь чего-то не понял, и есть надёжный способ преодолеть все эти трудности. Удачи!

P.S. Исходный код для этой статьи вы можете найти на GitHub.