Как стать автором
Обновить

Мой опыт работы с OData

Время на прочтение16 мин
Количество просмотров5.6K
Автор оригинала: Ivan Iakimov

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.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 3: ↑3 и ↓0+3
Комментарии5

Публикации