diff --git a/src/Exceptionless.Web/ClientApp/app/event/event-controller.js b/src/Exceptionless.Web/ClientApp/app/event/event-controller.js index 9f0a20e27c..6d9d4a205d 100644 --- a/src/Exceptionless.Web/ClientApp/app/event/event-controller.js +++ b/src/Exceptionless.Web/ClientApp/app/event/event-controller.js @@ -739,6 +739,7 @@ ); }, options: { + page: 1, limit: 10, mode: "summary", }, diff --git a/src/Exceptionless.Web/ClientApp/app/event/reference-controller.js b/src/Exceptionless.Web/ClientApp/app/event/reference-controller.js index 176888bf10..f8c0eb10ea 100644 --- a/src/Exceptionless.Web/ClientApp/app/event/reference-controller.js +++ b/src/Exceptionless.Web/ClientApp/app/event/reference-controller.js @@ -16,6 +16,7 @@ }); }, options: { + page: 1, limit: 20, mode: "summary", }, diff --git a/src/Exceptionless.Web/ClientApp/app/events-controller.js b/src/Exceptionless.Web/ClientApp/app/events-controller.js index 1eb1f90a26..63778d53d6 100644 --- a/src/Exceptionless.Web/ClientApp/app/events-controller.js +++ b/src/Exceptionless.Web/ClientApp/app/events-controller.js @@ -214,6 +214,7 @@ header: "Events", get: eventService.getAll, options: { + page: 1, limit: 15, mode: "summary", }, diff --git a/src/Exceptionless.Web/ClientApp/app/stack/stack-controller.js b/src/Exceptionless.Web/ClientApp/app/stack/stack-controller.js index ccdcbe038e..daa64f874b 100644 --- a/src/Exceptionless.Web/ClientApp/app/stack/stack-controller.js +++ b/src/Exceptionless.Web/ClientApp/app/stack/stack-controller.js @@ -947,6 +947,7 @@ showType: false, }, options: { + page: 1, limit: 10, mode: "summary", }, diff --git a/src/Exceptionless.Web/ClientApp/components/events/events-directive.js b/src/Exceptionless.Web/ClientApp/components/events/events-directive.js index 77b065ce73..12532e608a 100644 --- a/src/Exceptionless.Web/ClientApp/components/events/events-directive.js +++ b/src/Exceptionless.Web/ClientApp/components/events/events-directive.js @@ -111,11 +111,13 @@ vm.previous = links.previous; vm.next = links.next; - vm.pageSummary = paginationService.getCurrentPageSummary( - response.data, - vm.currentOptions.page, - vm.currentOptions.limit - ); + if (vm.currentOptions.page) { + vm.pageSummary = paginationService.getCurrentPageSummary( + response.data, + vm.currentOptions.page, + vm.currentOptions.limit + ); + } if (vm.events.length === 0 && vm.currentOptions.page && vm.currentOptions.page > 1) { return get(); @@ -227,6 +229,7 @@ vm.beforeRelativeText = beforeRelativeText; vm.canRefresh = canRefresh; vm.currentEventId = vm.settings.eventId; + vm.currentOptions = null; vm.events = []; vm.get = get; vm.hasFilter = filterService.hasFilter; diff --git a/src/Exceptionless.Web/ClientApp/components/events/events-directive.tpl.html b/src/Exceptionless.Web/ClientApp/components/events/events-directive.tpl.html index fb2f0f2c56..84a400f0b7 100644 --- a/src/Exceptionless.Web/ClientApp/components/events/events-directive.tpl.html +++ b/src/Exceptionless.Web/ClientApp/components/events/events-directive.tpl.html @@ -128,11 +128,7 @@ -
+
{{vm.pageSummary}}
diff --git a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs index 6a46d2f10e..48f3983a7a 100644 --- a/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs +++ b/src/Exceptionless.Web/Controllers/Base/ExceptionlessApiController.cs @@ -219,14 +219,9 @@ protected OkWithHeadersContentResult OkWithLinks(T content, string[] links return new OkWithHeadersContentResult(content, headers); } - protected OkWithResourceLinks OkWithResourceLinks(IEnumerable content, bool hasMore, Func pagePropertyAccessor = null, IHeaderDictionary headers = null, bool isDescending = false) where TEntity : class + protected OkWithResourceLinks OkWithResourceLinks(IEnumerable content, bool hasMore, int? page = null, long? total = null, string before = null, string after = null) where TEntity : class { - return new OkWithResourceLinks(content, hasMore, null, pagePropertyAccessor, headers, isDescending); - } - - protected OkWithResourceLinks OkWithResourceLinks(IEnumerable content, bool hasMore, int page, long? total = null, IHeaderDictionary headers = null) where TEntity : class - { - return new OkWithResourceLinks(content, hasMore, page, total, headers: headers); + return new OkWithResourceLinks(content, hasMore, page, total, before, after); } protected string GetResourceLink(string url, string type) @@ -234,8 +229,11 @@ protected string GetResourceLink(string url, string type) return url != null ? $"<{url}>; rel=\"{type}\"" : null; } - protected bool NextPageExceedsSkipLimit(int page, int limit) + protected bool NextPageExceedsSkipLimit(int? page, int limit) { + if (page is null) + return false; + return (page + 1) * limit >= MAXIMUM_SKIP; } } diff --git a/src/Exceptionless.Web/Controllers/EventController.cs b/src/Exceptionless.Web/Controllers/EventController.cs index a2faf4c599..4aed0be562 100644 --- a/src/Exceptionless.Web/Controllers/EventController.cs +++ b/src/Exceptionless.Web/Controllers/EventController.cs @@ -22,6 +22,7 @@ using Foundatio.Caching; using Foundatio.Queues; using Foundatio.Repositories; +using Foundatio.Repositories.Elasticsearch.Extensions; using Foundatio.Repositories.Extensions; using Foundatio.Repositories.Models; using Foundatio.Utility; @@ -197,11 +198,12 @@ public async Task> GetAsync(string id, string time /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. [HttpGet] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetAsync(string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetAsync(string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); if (organizations.All(o => o.IsSuspended)) @@ -209,7 +211,7 @@ public async Task>> GetAsync(s var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays)); var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, after); + return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); } private async Task> CountInternalAsync(AppFilter sf, TimeInfo ti, string filter = null, string aggregations = null, string mode = null) @@ -248,11 +250,29 @@ private async Task> CountInternalAsync(AppFilter sf, T return Ok(result); } - private async Task>> GetInternalAsync(AppFilter sf, TimeInfo ti, string filter = null, string sort = null, string mode = null, int page = 1, int limit = 10, string after = null, bool usesPremiumFeatures = false) + private async Task>> GetInternalAsync(AppFilter sf, TimeInfo ti, string filter = null, string sort = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null, bool usesPremiumFeatures = false) { - page = GetPage(page); + using var _ = _logger.BeginScope(new ExceptionlessState() + .Property("Search Filter", new + { + Mode = mode, + SystemFilter = sf, + UserFilter = filter, + Time = ti, + Page = page, + Limit = limit, + Before = before, + After = after + }) + .Tag("Search") + .Identity(CurrentUser.EmailAddress) + .Property("User", CurrentUser) + .SetHttpContext(HttpContext) + ); + + int resolvedPage = GetPage(page.GetValueOrDefault(1)); limit = GetLimit(limit); - int skip = GetSkip(page, limit); + int skip = GetSkip(resolvedPage, limit); if (skip > MAXIMUM_SKIP) return Ok(EmptyModels); @@ -261,7 +281,6 @@ private async Task>> GetIntern return BadRequest(pr.Message); sf.UsesPremiumFeatures = pr.UsesPremiumFeatures || usesPremiumFeatures; - bool useSearchAfter = !String.IsNullOrEmpty(after); try { @@ -269,7 +288,7 @@ private async Task>> GetIntern switch (mode) { case "summary": - events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, after, useSearchAfter); + events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after); return OkWithResourceLinks(events.Documents.Select(e => { var summaryData = _formattingPluginManager.GetEventSummaryData(e); @@ -280,7 +299,7 @@ private async Task>> GetIntern Date = e.Date, Data = summaryData.Data }; - }).ToList(), events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total); + }).ToList(), events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total, events.GetSearchBeforeToken(), events.GetSearchAfterToken()); case "stack_recent": case "stack_frequent": case "stack_new": @@ -310,7 +329,7 @@ private async Task>> GetIntern .SystemFilter(systemFilter) .FilterExpression(filter) .EnforceEventStackFilter() - .AggregationsExpression($"terms:(stack_id~{GetSkip(page + 1, limit) + 1} {stackAggregations})")); + .AggregationsExpression($"terms:(stack_id~{GetSkip(resolvedPage + 1, limit) + 1} {stackAggregations})")); var stackTerms = countResponse.Aggregations.Terms("terms_stack_id"); if (stackTerms == null || stackTerms.Buckets.Count == 0) @@ -320,21 +339,19 @@ private async Task>> GetIntern var stacks = (await _stackRepository.GetByIdsAsync(stackIds)).Select(s => s.ApplyOffset(ti.Offset)).ToList(); var summaries = await GetStackSummariesAsync(stacks, stackTerms.Buckets, sf, ti); - return OkWithResourceLinks(summaries.Take(limit).ToList(), summaries.Count > limit, page); + return OkWithResourceLinks(summaries.Take(limit).ToList(), summaries.Count > limit, resolvedPage); default: - events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, after, useSearchAfter); - return OkWithResourceLinks(events.Documents, events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total); + events = await GetEventsInternalAsync(sf, ti, filter, sort, page, limit, before, after); + return OkWithResourceLinks(events.Documents, events.HasMore && !NextPageExceedsSkipLimit(page, limit), page, events.Total, events.GetSearchBeforeToken(), events.GetSearchAfterToken()); } } catch (ApplicationException ex) { - string message = "An error has occurred. Please check your search filter."; + string message = "An error has occurred: Please check your search filter."; if (ex is DocumentLimitExceededException) - message = $"An error has occurred. {ex.Message ?? "Please limit your search criteria."}"; - - using (_logger.BeginScope(new ExceptionlessState().Property("Search Filter", new { SystemFilter = sf, UserFilter = filter, Time = ti, Page = page, Limit = limit }).Tag("Search").Identity(CurrentUser.EmailAddress).Property("User", CurrentUser).SetHttpContext(HttpContext))) - _logger.LogError(ex, message); + message = $"An error has occurred: {ex.Message ?? "Please limit your search criteria."}"; + _logger.LogError(ex, message); return BadRequest(message); } } @@ -373,15 +390,20 @@ private string AddFirstOccurrenceFilter(DateTimeRange timeRange, string filter) return sb.ToString(); } - private Task> GetEventsInternalAsync(AppFilter sf, TimeInfo ti, string filter, string sort, int page, int limit, string after, bool useSearchAfter) + private Task> GetEventsInternalAsync(AppFilter sf, TimeInfo ti, string filter, string sort, int? page, int limit, string before, string after) { if (String.IsNullOrEmpty(sort)) - sort = "-date"; - - return _repository.FindAsync(q => q.AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null).FilterExpression(filter).EnforceEventStackFilter().SortExpression(sort).DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field), - o => useSearchAfter - ? o.SearchAfterPaging().SearchAfter(after).PageLimit(limit) - : o.PageNumber(page).PageLimit(limit)); + sort = $"-{EventIndex.Alias.Date}"; + + return _repository.FindAsync( + q => q.AppFilter(ShouldApplySystemFilter(sf, filter) ? sf : null) + .FilterExpression(filter) + .EnforceEventStackFilter() + .SortExpression(sort) + .DateRange(ti.Range.UtcStart, ti.Range.UtcEnd, ti.Field), + o => page.HasValue + ? o.PageNumber(page).PageLimit(limit) + : o.SearchBeforeToken(before).SearchAfterToken(after).PageLimit(limit)); } /// @@ -395,13 +417,14 @@ private Task> GetEventsInternalAsync(AppFilter sf, /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The organization could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/events")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByOrganizationAsync(string organizationId = null, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetByOrganizationAsync(string organizationId = null, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var organization = await GetOrganizationAsync(organizationId); if (organization == null) @@ -412,7 +435,7 @@ public async Task>> GetByOrgan var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays)); var sf = new AppFilter(organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, after); + return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); } /// @@ -426,13 +449,14 @@ public async Task>> GetByOrgan /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The project could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByProjectAsync(string projectId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetByProjectAsync(string projectId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var project = await GetProjectAsync(projectId); if (project is null) @@ -447,7 +471,7 @@ public async Task>> GetByProje var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays)); var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, after); + return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); } /// @@ -461,13 +485,14 @@ public async Task>> GetByProje /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The stack could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/stacks/{stackId:objectid}/events")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByStackAsync(string stackId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetByStackAsync(string stackId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var stack = await GetStackAsync(stackId); if (stack == null) @@ -482,7 +507,7 @@ public async Task>> GetByStack var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(stack, _appOptions.MaximumRetentionDays)); var sf = new AppFilter(stack, organization); - return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, after); + return await GetInternalAsync(sf, ti, filter, sort, mode, page, limit, before, after); } /// @@ -493,11 +518,12 @@ public async Task>> GetByStack /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. [HttpGet("by-ref/{referenceId:identifier}")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByReferenceIdAsync(string referenceId, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetByReferenceIdAsync(string referenceId, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository); if (organizations.All(o => o.IsSuspended)) @@ -505,7 +531,7 @@ public async Task>> GetByRefer var ti = GetTimeInfo(null, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays)); var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, after); + return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, before, after); } /// @@ -517,13 +543,14 @@ public async Task>> GetByRefer /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The project could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/by-ref/{referenceId:identifier}")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetByReferenceIdAsync(string referenceId, string projectId, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetByReferenceIdAsync(string referenceId, string projectId, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var project = await GetProjectAsync(projectId); if (project is null) @@ -538,7 +565,7 @@ public async Task>> GetByRefer var ti = GetTimeInfo(null, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays)); var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, after); + return await GetInternalAsync(sf, ti, String.Concat("reference:", referenceId), null, mode, page, limit, before, after); } /// @@ -552,11 +579,12 @@ public async Task>> GetByRefer /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. [HttpGet("sessions/{sessionId:identifier}")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetBySessionIdAsync(string sessionId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetBySessionIdAsync(string sessionId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); if (organizations.All(o => o.IsSuspended)) @@ -564,7 +592,7 @@ public async Task>> GetBySessi var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays)); var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, $"(reference:{sessionId} OR ref.session:{sessionId}) {filter}", sort, mode, page, limit, after, true); + return await GetInternalAsync(sf, ti, $"(reference:{sessionId} OR ref.session:{sessionId}) {filter}", sort, mode, page, limit, before, after, true); } /// @@ -579,13 +607,14 @@ public async Task>> GetBySessi /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The project could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/sessions/{sessionId:identifier}")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetBySessionIdAndProjectAsync(string sessionId, string projectId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetBySessionIdAndProjectAsync(string sessionId, string projectId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var project = await GetProjectAsync(projectId); if (project is null) @@ -600,7 +629,7 @@ public async Task>> GetBySessi var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays)); var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, $"(reference:{sessionId} OR ref.session:{sessionId}) {filter}", sort, mode, page, limit, after, true); + return await GetInternalAsync(sf, ti, $"(reference:{sessionId} OR ref.session:{sessionId}) {filter}", sort, mode, page, limit, before, after, true); } /// @@ -613,11 +642,12 @@ public async Task>> GetBySessi /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. [HttpGet("sessions")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetSessionsAsync(string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetSessionsAsync(string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var organizations = await GetSelectedOrganizationsAsync(_organizationRepository, _projectRepository, _stackRepository, filter); if (organizations.All(o => o.IsSuspended)) @@ -625,7 +655,7 @@ public async Task>> GetSession var ti = GetTimeInfo(time, offset, organizations.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays)); var sf = new AppFilter(organizations) { IsUserOrganizationsFilter = true }; - return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, after, true); + return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, before, after, true); } /// @@ -639,13 +669,14 @@ public async Task>> GetSession /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The project could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/organizations/{organizationId:objectid}/events/sessions")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetSessionByOrganizationAsync(string organizationId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetSessionByOrganizationAsync(string organizationId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var organization = await GetOrganizationAsync(organizationId); if (organization == null) @@ -656,7 +687,7 @@ public async Task>> GetSession var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(_appOptions.MaximumRetentionDays)); var sf = new AppFilter(organization); - return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, after, true); + return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, before, after, true); } /// @@ -670,13 +701,14 @@ public async Task>> GetSession /// If no mode is set then the whole event object will be returned. If the mode is set to summary than a light weight object will be returned. /// The page parameter is used for pagination. This value must be greater than 0. /// A limit on the number of objects to be returned. Limit can range between 1 and 100 items. - /// The after parameter is a cursor used for pagination and defines your place in the list of results. Pass in the last event id in the previous call to fetch the next page of the list. + /// The before parameter is a cursor used for pagination and defines your place in the list of results. + /// The after parameter is a cursor used for pagination and defines your place in the list of results. /// Invalid filter. /// The project could not be found. /// Unable to view event occurrences for the suspended organization. [HttpGet("~/" + API_PREFIX + "/projects/{projectId:objectid}/events/sessions")] [Authorize(Policy = AuthorizationRoles.UserPolicy)] - public async Task>> GetSessionByProjectAsync(string projectId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int page = 1, int limit = 10, string after = null) + public async Task>> GetSessionByProjectAsync(string projectId, string filter = null, string sort = null, string time = null, string offset = null, string mode = null, int? page = null, int limit = 10, string before = null, string after = null) { var project = await GetProjectAsync(projectId); if (project is null) @@ -691,7 +723,7 @@ public async Task>> GetSession var ti = GetTimeInfo(time, offset, organization.GetRetentionUtcCutoff(project, _appOptions.MaximumRetentionDays)); var sf = new AppFilter(project, organization); - return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, after, true); + return await GetInternalAsync(sf, ti, $"type:{Event.KnownTypes.Session} {filter}", sort, mode, page, limit, before, after, true); } /// diff --git a/src/Exceptionless.Web/Controllers/OrganizationController.cs b/src/Exceptionless.Web/Controllers/OrganizationController.cs index ec0c93d141..f7bf403fc3 100644 --- a/src/Exceptionless.Web/Controllers/OrganizationController.cs +++ b/src/Exceptionless.Web/Controllers/OrganizationController.cs @@ -315,7 +315,7 @@ public async Task>> GetInvoic var invoiceService = new InvoiceService(client); var invoiceOptions = new InvoiceListOptions { Customer = organization.StripeCustomerId, Limit = limit + 1, EndingBefore = before, StartingAfter = after }; var invoices = (await MapCollectionAsync(await invoiceService.ListAsync(invoiceOptions), true)).ToList(); - return OkWithResourceLinks(invoices.Take(limit).ToList(), invoices.Count > limit, i => i.Id); + return OkWithResourceLinks(invoices.Take(limit).ToList(), invoices.Count > limit); } /// diff --git a/src/Exceptionless.Web/Startup.cs b/src/Exceptionless.Web/Startup.cs index 9eb6362886..11582196ed 100644 --- a/src/Exceptionless.Web/Startup.cs +++ b/src/Exceptionless.Web/Startup.cs @@ -39,7 +39,7 @@ public void ConfigureServices(IServiceCollection services) .SetIsOriginAllowed(isOriginAllowed: _ => true) .AllowCredentials() .SetPreflightMaxAge(TimeSpan.FromMinutes(5)) - .WithExposedHeaders("ETag", "Link", Headers.RateLimit, Headers.RateLimitRemaining, "X-Result-Count", Headers.LegacyConfigurationVersion, Headers.ConfigurationVersion))); + .WithExposedHeaders("ETag", Headers.LegacyConfigurationVersion, Headers.ConfigurationVersion, Headers.Link, Headers.RateLimit, Headers.RateLimitRemaining, Headers.ResultCount))); services.Configure(options => { @@ -291,7 +291,7 @@ public void Configure(IApplicationBuilder app) app.UseSwagger(c => { c.RouteTemplate = "docs/{documentName}/swagger.json"; - // TODO: Remove once 5.6.4+ is released + // TODO: Remove once 5.6.4+ is released c.PreSerializeFilters.Add((doc, _) => doc.Servers?.Clear()); }); app.UseSwaggerUI(s => diff --git a/src/Exceptionless.Web/Utility/Headers.cs b/src/Exceptionless.Web/Utility/Headers.cs index c4af264242..2b9bc7438e 100644 --- a/src/Exceptionless.Web/Utility/Headers.cs +++ b/src/Exceptionless.Web/Utility/Headers.cs @@ -2,13 +2,12 @@ public static class Headers { - public const string Bearer = "Bearer"; + public const string ContentEncoding = "Content-Encoding"; public const string LegacyConfigurationVersion = "v"; public const string ConfigurationVersion = "X-Exceptionless-ConfigVersion"; public const string Client = "X-Exceptionless-Client"; + public const string Link = "Link"; public const string RateLimit = "X-RateLimit-Limit"; public const string RateLimitRemaining = "X-RateLimit-Remaining"; - public const string LimitedByPlan = "X-LimitedByPlan"; - - public const string ContentEncoding = "Content-Encoding"; + public const string ResultCount = "X-Result-Count"; } diff --git a/src/Exceptionless.Web/Utility/Results/OkPaginatedResult.cs b/src/Exceptionless.Web/Utility/Results/OkPaginatedResult.cs index 3820aafec2..45b2d5fad6 100644 --- a/src/Exceptionless.Web/Utility/Results/OkPaginatedResult.cs +++ b/src/Exceptionless.Web/Utility/Results/OkPaginatedResult.cs @@ -1,4 +1,4 @@ -using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Primitives; namespace Exceptionless.Web.Utility.Results; @@ -22,7 +22,7 @@ public override void OnFormatting(ActionContext context) AddPageLinkHeaders(context.HttpContext.Request); if (Total.HasValue) - Headers.Add("X-Result-Count", Total.ToString()); + Headers.Add(Utility.Headers.ResultCount, Total.ToString()); base.OnFormatting(context); } @@ -35,13 +35,14 @@ public void AddPageLinkHeaders(HttpRequest request) if (!includePrevious && !includeNext) return; + var links = new List(2); if (includePrevious) { var previousParameters = new Dictionary(request.Query) { ["page"] = (Page - 1).ToString() }; - Headers.Add("Link", String.Concat("<", request.Path, "?", String.Join('&', previousParameters.Values), ">; rel=\"previous\"")); + links.Add(String.Concat("<", request.Path, "?", String.Join('&', previousParameters.Select(kvp => $"{kvp.Key}={kvp.Value}")), ">; rel=\"previous\"")); } if (includeNext) @@ -51,7 +52,9 @@ public void AddPageLinkHeaders(HttpRequest request) ["page"] = (Page + 1).ToString() }; - Headers.Add("Link", String.Concat("<", request.Path, "?", String.Join('&', nextParameters.Values), ">; rel=\"next\"")); + links.Add(String.Concat("<", request.Path, "?", String.Join('&', nextParameters.Select(kvp => $"{kvp.Key}={kvp.Value}")), ">; rel=\"next\"")); } + + Headers.Add(Headers.Link, links.ToArray()); } } diff --git a/src/Exceptionless.Web/Utility/Results/OkWithHeadersContentResult.cs b/src/Exceptionless.Web/Utility/Results/OkWithHeadersContentResult.cs index d1d84057f0..ffe7f013bb 100644 --- a/src/Exceptionless.Web/Utility/Results/OkWithHeadersContentResult.cs +++ b/src/Exceptionless.Web/Utility/Results/OkWithHeadersContentResult.cs @@ -17,43 +17,38 @@ public OkWithHeadersContentResult(T content, IHeaderDictionary headers = null) : public class OkWithResourceLinks : OkWithHeadersContentResult> where TEntity : class { - //public OkWithResourceLinks(IEnumerable content, IHeaderDictionary headers = null) : base(content, headers) { } + public OkWithResourceLinks(IEnumerable content, bool hasMore, int? page = null, string before = null, string after = null) + : this(content, hasMore, page, null, before, after) { } - public OkWithResourceLinks(IEnumerable content, bool hasMore, int? page = null, Func pagePropertyAccessor = null, IHeaderDictionary headers = null, bool isDescending = false) - : this(content, hasMore, page, null, pagePropertyAccessor, headers, isDescending) { } - - public OkWithResourceLinks(IEnumerable content, bool hasMore, int? page = null, long? total = null, Func pagePropertyAccessor = null, IHeaderDictionary headers = null, bool isDescending = false) : base(content, headers) + public OkWithResourceLinks(IEnumerable content, bool hasMore, int? page = null, long? total = null, string before = null, string after = null) : base(content) { Content = content; HasMore = hasMore; - IsDescending = isDescending; + Before = before; + After = after; Page = page; Total = total; - PagePropertyAccessor = pagePropertyAccessor; } public IEnumerable Content { get; } public bool HasMore { get; } - public bool IsDescending { get; } + public string Before { get; } + public string After { get; } public int? Page { get; } public long? Total { get; } - public Func PagePropertyAccessor { get; } public override void OnFormatting(ActionContext context) { if (Content != null) { - List links; - if (Page.HasValue) - links = GetPagedLinks(new Uri(context.HttpContext.Request.GetDisplayUrl()), Page.Value, HasMore); - else - links = GetBeforeAndAfterLinks(new Uri(context.HttpContext.Request.GetDisplayUrl()), Content, IsDescending, HasMore, PagePropertyAccessor); - + var links = Page.HasValue + ? GetPagedLinks(new Uri(context.HttpContext.Request.GetDisplayUrl()), Page.Value, HasMore) + : GetBeforeAndAfterLinks(new Uri(context.HttpContext.Request.GetDisplayUrl()), Before, After); if (links.Count > 0) - Headers.Add("Link", links.ToArray()); + Headers.Add(Utility.Headers.Link, links.ToArray()); if (Total.HasValue) - Headers.Add("X-Result-Count", Total.ToString()); + Headers.Add(Utility.Headers.ResultCount, Total.ToString()); } base.OnFormatting(context); @@ -73,70 +68,31 @@ public static List GetPagedLinks(Uri url, int page, bool hasMore) string baseUrl = url.GetBaseUrl(); - string previousLink = $"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""; - string nextLink = $"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""; - - var links = new List(); + var links = new List(2); if (includePrevious) - links.Add(previousLink); + links.Add($"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""); if (includeNext) - links.Add(nextLink); + links.Add($"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""); return links; } - public static List GetBeforeAndAfterLinks(Uri url, IEnumerable content, bool isDescending, bool hasMore, Func pagePropertyAccessor) + public static List GetBeforeAndAfterLinks(Uri url, string before, string after) { - var contentList = content.ToList(); - if (pagePropertyAccessor == null && typeof(IIdentity).IsAssignableFrom(typeof(TEntity))) - pagePropertyAccessor = e => ((IIdentity)e).Id; - - if (pagePropertyAccessor == null) - return new List(); - - string firstId = contentList.Any() ? pagePropertyAccessor(!isDescending ? contentList.First() : contentList.Last()) : String.Empty; - string lastId = contentList.Any() ? pagePropertyAccessor(!isDescending ? contentList.Last() : contentList.First()) : String.Empty; - - bool hasBefore = false; - bool hasAfter = false; - var previousParameters = HttpUtility.ParseQueryString(url.Query); - if (previousParameters["before"] != null) - hasBefore = true; previousParameters.Remove("before"); - if (previousParameters["after"] != null) - hasAfter = true; previousParameters.Remove("after"); - var nextParameters = new NameValueCollection(previousParameters); - - previousParameters.Add("before", firstId); - nextParameters.Add("after", lastId); - bool includePrevious = hasBefore ? hasMore : true; - bool includeNext = !hasBefore ? hasMore : true; - if (hasBefore && !contentList.Any()) - { - // are we currently before the first page? - includePrevious = false; - includeNext = true; - nextParameters.Remove("after"); - } - else if (!hasBefore && !hasAfter) - { - // are we at the first page? - includePrevious = false; - } + var nextParameters = new NameValueCollection(previousParameters); + previousParameters.Add("before", before); + nextParameters.Add("after", after); string baseUrl = url.GetBaseUrl(); - - string previousLink = $"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""; - string nextLink = $"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""; - - var links = new List(); - if (includePrevious) - links.Add(previousLink); - if (includeNext) - links.Add(nextLink); + var links = new List(2); + if (!String.IsNullOrEmpty(before)) + links.Add($"<{baseUrl}?{previousParameters.ToQueryString()}>; rel=\"previous\""); + if (!String.IsNullOrEmpty(after)) + links.Add($"<{baseUrl}?{nextParameters.ToQueryString()}>; rel=\"next\""); return links; } diff --git a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs index 76631f364f..ce8c709060 100644 --- a/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs +++ b/tests/Exceptionless.Tests/Controllers/EventControllerTests.cs @@ -3,7 +3,10 @@ using System.Net; using System.Net.Http.Headers; using System.Text; +using System.Text.RegularExpressions; +using System.Web; using Exceptionless.Core.Billing; +using Exceptionless.Core.Extensions; using Exceptionless.Core.Jobs; using Exceptionless.Core.Models; using Exceptionless.Core.Plugins.EventParser; @@ -1288,4 +1291,215 @@ await CreateDataAsync(d => await StackData.CreateSearchDataAsync(GetService(), GetService(), true); await EventData.CreateSearchDataAsync(GetService(), _eventRepository, GetService(), true); } + + [Fact] + public async Task CanEventsWithPagingAsync() + { + await CreateDataAsync(d => + { + d.Event().TestProject().Type(Event.KnownTypes.Log); + d.Event().TestProject().Type(Event.KnownTypes.Log); + d.Event().TestProject().Type(Event.KnownTypes.Log); + }); + + Log.SetLogLevel(LogLevel.Trace); + Log.SetLogLevel(LogLevel.Trace); + Log.SetLogLevel(LogLevel.Trace); + + var response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("page", 1) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + + var links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Single(links); + + string nextPage = GetQueryStringValue(links["next"], "page"); + Assert.Equal("2", nextPage); + + var result = await response.Content.ReadFromJsonAsync>(); + string firstEventId = result.Single().Id; + + // Go to second page + response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("page", nextPage) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Equal(2, links.Count); + + string previousPage = GetQueryStringValue(links["previous"], "page"); + Assert.Equal("1", previousPage); + + nextPage = GetQueryStringValue(links["next"], "page"); + Assert.Equal("3", nextPage); + + result = await response.Content.ReadFromJsonAsync>(); + string secondEventId = result.Single().Id; + Assert.NotEqual(firstEventId, secondEventId); + + // Go to last page + response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("page", nextPage) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Single(links); + + previousPage = GetQueryStringValue(links["previous"], "page"); + Assert.Equal("2", previousPage); + + result = await response.Content.ReadFromJsonAsync>(); + string thirdEventId = result.Single().Id; + Assert.NotEqual(secondEventId, thirdEventId); + + // go to previous page + response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("page", previousPage) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Equal(2, links.Count); + + result = await response.Content.ReadFromJsonAsync>(); + Assert.Equal(secondEventId, result.Single().Id); + } + + [Fact] + public async Task CanEventsWithStablePagingAsync() + { + await CreateDataAsync(d => + { + d.Event().TestProject().Type(Event.KnownTypes.Log); + d.Event().TestProject().Type(Event.KnownTypes.Log); + d.Event().TestProject().Type(Event.KnownTypes.Log); + }); + + Log.SetLogLevel(LogLevel.Trace); + Log.SetLogLevel(LogLevel.Trace); + Log.SetLogLevel(LogLevel.Trace); + + var response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + + var links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Single(links); + + string after = GetQueryStringValue(links["next"], "after"); + Assert.NotNull(after); + + var result = await response.Content.ReadFromJsonAsync>(); + string firstEventId = result.Single().Id; + + // Go to second page + response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("after", after) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Equal(2, links.Count); + + string before = GetQueryStringValue(links["previous"], "before"); + Assert.NotNull(before); + + after = GetQueryStringValue(links["next"], "after"); + Assert.NotNull(after); + Assert.Equal(before, after); + + result = await response.Content.ReadFromJsonAsync>(); + string secondEventId = result.Single().Id; + Assert.NotEqual(firstEventId, secondEventId); + + // Go to last page + response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("after", after) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Single(links); + + before = GetQueryStringValue(links["previous"], "before"); + Assert.NotNull(before); + + result = await response.Content.ReadFromJsonAsync>(); + string thirdEventId = result.Single().Id; + Assert.NotEqual(secondEventId, thirdEventId); + + // go to previous page + response = await SendRequestAsync(r => r + .AsGlobalAdminUser() + .AppendPath("events") + .QueryString("limit", "1") + .QueryString("before", before) + .StatusCodeShouldBeOk() + ); + + Assert.Equal("3", response.Headers.GetValues(Headers.ResultCount).Single()); + links = ParseLinkHeaderValue(response.Headers.GetValues(Headers.Link).ToArray()); + Assert.Equal(2, links.Count); + + result = await response.Content.ReadFromJsonAsync>(); + Assert.Equal(secondEventId, result.Single().Id); + } + + private string GetQueryStringValue(string url, string name) + { + var uri = new Uri(url); + var parameters = HttpUtility.ParseQueryString(uri.Query); + return parameters.GetValue(name); + } + + private static IDictionary ParseLinkHeaderValue(string[] links) + { + if (links is not { Length: > 0 }) + return new Dictionary(0); + + var result = new Dictionary(); + foreach (string link in links) + { + var match = Regex.Match(link, @"<(?[^>]*)>;\s+rel=""(?\w+)"""); + if (!match.Success) + continue; + + result.Add(match.Groups["rel"].Value, match.Groups["url"].Value); + } + + return result; + } } diff --git a/tests/Exceptionless.Tests/Miscellaneous/EfficientPagingTests.cs b/tests/Exceptionless.Tests/Miscellaneous/EfficientPagingTests.cs index a73353bdf4..a29c9b51bb 100644 --- a/tests/Exceptionless.Tests/Miscellaneous/EfficientPagingTests.cs +++ b/tests/Exceptionless.Tests/Miscellaneous/EfficientPagingTests.cs @@ -9,18 +9,14 @@ public class EfficientPagingTests : TestWithServices public EfficientPagingTests(ITestOutputHelper output) : base(output) { } [Theory] - [InlineData("http://localhost", false, false, false)] - [InlineData("http://localhost", true, false, true)] - [InlineData("http://localhost?after=1", false, true, false)] - [InlineData("http://localhost?after=1", true, true, true)] - [InlineData("http://localhost?before=11", false, false, true)] - [InlineData("http://localhost?before=11", true, true, true)] - public void CanBeforeAndAfterLinks(string url, bool hasMore, bool expectPrevious, bool expectNext) + [InlineData("http://localhost", null, null, false, false)] + [InlineData("http://localhost?after=1", "1", null, true, false)] + [InlineData("http://localhost?after=1", "1", "2", true, true)] + [InlineData("http://localhost?before=11", null, "1", false, true)] + public void CanBeforeAndAfterLinks(string url, string before, string after, bool expectPrevious, bool expectNext) { - var data = new List { "1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11" }; - - var links = OkWithResourceLinks.GetBeforeAndAfterLinks(new Uri(url), data, false, hasMore, s => s); - int expectedLinkCount = 0; + var links = OkWithResourceLinks.GetBeforeAndAfterLinks(new Uri(url), before, after); ; + byte expectedLinkCount = 0; if (expectPrevious) expectedLinkCount++; if (expectNext) @@ -31,9 +27,9 @@ public void CanBeforeAndAfterLinks(string url, bool hasMore, bool expectPrevious Assert.Equal(expectedLinkCount, links.Count); if (expectPrevious) - Assert.Contains(links, l => l.Contains("previous")); + Assert.Contains(links, l => l.Contains("previous") && l.Contains("before")); if (expectNext) - Assert.Contains(links, l => l.Contains("next")); + Assert.Contains(links, l => l.Contains("next") && l.Contains("after")); } [Theory] @@ -56,8 +52,8 @@ public void CanPageLinks(string url, int pageNumber, bool hasMore, bool expectPr Assert.Equal(expectedLinkCount, links.Count); if (expectPrevious) - Assert.Contains(links, l => l.Contains("previous")); + Assert.Contains(links, l => l.Contains("previous") && l.Contains("page")); if (expectNext) - Assert.Contains(links, l => l.Contains("next")); + Assert.Contains(links, l => l.Contains("next") && l.Contains("page")); } }