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.
