Repository navigation
Expand file tree
/
Copy pathTableController.Query.cs
More file actions
210 lines (185 loc) · 9.72 KB
/
Copy pathTableController.Query.cs
File metadata and controls
210 lines (185 loc) · 9.72 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
// See the LICENSE file in the project root for more information.
using CommunityToolkit.Datasync.Server.OData;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.OData.Extensions;
using Microsoft.AspNetCore.OData.Query;
using Microsoft.AspNetCore.OData.Query.Expressions;
using Microsoft.AspNetCore.OData.Query.Validator;
using Microsoft.AspNetCore.OData.Routing.Controllers;
using Microsoft.AspNetCore.WebUtilities;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using Microsoft.OData;
using Microsoft.OData.UriParser;
using System.Diagnostics.CodeAnalysis;
using System.Globalization;
namespace CommunityToolkit.Datasync.Server;
public partial class TableController<TEntity> : ODataController where TEntity : class, ITableData
{
private const string SkipParameterName = "$skip";
private const string TopParameterName = "$top";
/// <summary>
/// <para>
/// The GET method is used to retrieve resource representation. The resource is never modified.
/// In this case, an OData v4 query is accepted with the following options:
/// </para>
/// <para>
/// - <c>$count</c> is used to return a count of entities within the search parameters within the <see cref="PagedResult"/> response.
/// - <c>$filter</c> is used to restrict the entities to be sent.
/// - <c>$orderby</c> is used for ordering the entities to be sent.
/// - <c>$select</c> is used to select which properties of the entities are sent.
/// - <c>$skip</c> is used to skip some entities
/// - <c>$top</c> is used to limit the number of entities returned.
/// </para>
/// <para>
/// In addition, the <c>__includeDeleted</c> parameter is used to decide whether to include soft-deleted items in the result.
/// </para>
/// </summary>
/// <param name="cancellationToken">A cancellation token</param>
/// <returns>An <see cref="OkObjectResult"/> response object with the items.</returns>
[HttpGet]
[ActionName("QueryAsync")]
[ProducesResponseType(StatusCodes.Status200OK)]
public virtual async Task<IActionResult> QueryAsync(CancellationToken cancellationToken = default)
{
Logger.LogInformation("QueryAsync: {querystring}", HttpContext.Request.QueryString);
await AuthorizeRequestAsync(TableOperation.Query, null, cancellationToken).ConfigureAwait(false);
_ = BuildServiceProvider(Request);
ODataValidationSettings validationSettings = new() { MaxTop = Options.MaxTop };
ODataQuerySettings querySettings = new() { PageSize = Options.PageSize, EnsureStableOrdering = true };
ODataQueryContext queryContext = new(EdmModel, typeof(TEntity), new ODataPath());
ODataQueryOptions<TEntity> queryOptions = new(queryContext, Request);
try
{
queryOptions.Validate(validationSettings);
}
catch (ODataException validationException)
{
Logger.LogWarning("Query: Error when validating query: {Message}", validationException.Message);
return BadRequest(validationException.Message);
}
// Determine the dataset to be queried for this user.
IQueryable<TEntity> dataset = (await Repository.AsQueryableAsync(cancellationToken).ConfigureAwait(false))
.ApplyDataView(AccessControlProvider.GetDataView())
.ApplyDeletedView(Request, Options.EnableSoftDelete);
// Apply the requested filter from the OData transaction.
IQueryable<TEntity> filteredDataset = dataset.ApplyODataFilter(queryOptions.Filter, querySettings);
// Count the number of items within the filtered dataset - this is used when $count is requested.
int filteredCount;
try
{
filteredCount = await Repository.CountAsync(filteredDataset, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
{
throw new HttpException(400, "Client-side evaluation is not supported. Please ensure that the query can be translated to a server-side query.");
}
// Now apply the OrderBy, Skip, and Top options to the dataset.
IQueryable<TEntity> orderedDataset = filteredDataset
.ApplyODataOrderBy(queryOptions.OrderBy, querySettings)
.ApplyODataPaging(queryOptions, querySettings);
// Get the list of items within the dataset that need to be returned.
IList<TEntity> entitiesInResultSet;
try
{
entitiesInResultSet = await Repository.ToListAsync(orderedDataset, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is InvalidOperationException or NotSupportedException)
{
throw new HttpException(400, "Client-side evaluation is not supported. Please ensure that the query can be translated to a server-side query.");
}
// Produce the paged result.
PagedResult result = BuildPagedResult(queryOptions, entitiesInResultSet.ApplyODataSelect(queryOptions.SelectExpand, querySettings), filteredCount, IsLegacyClient(Request));
Logger.LogInformation("Query: {Count} items being returned", entitiesInResultSet.Count);
return Ok(result);
}
/// <summary>
/// Creates a new <see cref="IServiceProvider"/> for the request to handle OData requests.
/// </summary>
/// <param name="request">The <see cref="HttpRequest"/> currently being processed.</param>
/// <returns>An <see cref="IServiceProvider"/> for the request pipeline.</returns>
[NonAction]
[SuppressMessage("Style", "IDE0058:Expression value is never used", Justification = "Lots of service adjustments where the result is never used.")]
protected static IServiceProvider BuildServiceProvider(HttpRequest request)
{
IServiceCollection services = new ServiceCollection();
services.AddSingleton(_ => new DefaultQueryConfigurations
{
EnableCount = true,
EnableFilter = true,
EnableOrderBy = true,
EnableSelect = true
});
services
.AddScoped<IFilterBinder, DatasyncFilterBinder>()
.AddScoped<ODataQuerySettings>(_ => new ODataQuerySettings { EnsureStableOrdering = true })
.AddSingleton<ODataUriResolver>(_ => new UnqualifiedODataUriResolver { EnableCaseInsensitive = true })
.AddScoped<ODataUriParserSettings>();
IServiceProvider provider = services.BuildServiceProvider();
request.ODataFeature().Services = provider;
return provider;
}
/// <summary>
/// Determines whether the request came from a legacy (Datasync/Zumo v6 or earlier) client, which
/// indicates via the <c>X-ZUMO-OPTIONS</c> header that it expects a relative <c>nextLink</c>.
/// </summary>
/// <param name="request">The <see cref="HttpRequest"/> to check.</param>
/// <returns><c>true</c> if the request contains <c>nextLink=path</c> in the <c>X-ZUMO-OPTIONS</c> header; otherwise <c>false</c>.</returns>
[NonAction]
internal static bool IsLegacyClient(HttpRequest request)
{
if (!request.Headers.TryGetValue("X-ZUMO-OPTIONS", out StringValues values))
{
return false;
}
return values.Any(value => value is not null && value.Contains("nextLink=path", StringComparison.OrdinalIgnoreCase));
}
/// <summary>
/// Creates a <see cref="PagedResult"/> object from the results of a query.
/// </summary>
/// <param name="queryOptions">The OData query options to use in constructing the result.</param>
/// <param name="results">The set of results.</param>
/// <param name="count">The count of items to be returned by the query without paging.</param>
/// <param name="legacyClient">If <c>true</c>, the <c>nextLink</c> is returned as a path relative to the host root.</param>
/// <returns>A <see cref="PagedResult"/> object.</returns>
[NonAction]
internal PagedResult BuildPagedResult(ODataQueryOptions queryOptions, IEnumerable<object>? results, int count, bool legacyClient = false)
{
int resultCount = results?.Count() ?? 0;
int skip = (queryOptions.Skip?.Value ?? 0) + resultCount;
int top = (queryOptions.Top?.Value ?? 0) - resultCount;
// Internal function to create the nextLink property for the paged result.
static string CreateNextLink(HttpRequest request, bool legacyClient, int skip = 0, int top = 0)
{
Dictionary<string, StringValues> query = QueryHelpers.ParseNullableQuery(request.QueryString.Value) ?? [];
if (skip > 0)
{
query[SkipParameterName] = skip.ToString(CultureInfo.InvariantCulture);
}
if (top > 0)
{
query[TopParameterName] = top.ToString(CultureInfo.InvariantCulture);
}
string queryString = QueryHelpers.AddQueryString(string.Empty, query).TrimStart('?');
if (legacyClient)
{
return ((request.PathBase + request.Path).Value ?? string.Empty).Trim('/') + "?" + queryString;
}
return queryString;
}
PagedResult result = new(results ?? []) { Count = queryOptions.Count != null ? count : null };
if (queryOptions.Top is not null)
{
result.NextLink = skip >= count || top <= 0 ? null : CreateNextLink(Request, legacyClient, skip, top);
}
else
{
result.NextLink = skip >= count ? null : CreateNextLink(Request, legacyClient, skip, 0);
}
return result;
}
}