Skip to content

Commit d0897c2

Browse files
Fix the causes of the REST self link console warnings (#1415)
* Only warn about REST self links the API isn't expected to change ensureSelfLink() compares the requested url against the self link in the response, but strips embed params from the requested url only. The REST API echoes the request's embed params into the self link, so every request that embeds a subresource looked broken and flooded the console: The response for '.../bundles/<uuid>/bitstreams?page=0&size=5' has the self link '.../bitstreams?page=0&embed=accessStatus&size=5'. These don't match. The same warning also fired when the API clamped an oversized page size (?size=9999 comes back as ?size=1000), which the REST contract mandates: a size over the configured maximum is reset to the maximum, no error thrown. Narrow the warning so it only fires on differences the API isn't expected to introduce: strip embed params from both sides, and accept a page size that shrank when the response's own page.size confirms the smaller value. A self link that contradicts the payload it describes is still reported, as are differing page/sort params, extra params, a size larger than requested, and a missing self link. The self-link normalization itself is left untouched. The response is keyed in the object cache by that href, so which url it is normalized to stays exactly as it was; only whether we warn changes. Adds the first spec for this service - ensureSelfLink was untested. Closes dataquest-dev/dspace-customers#862 * Don't accept an empty page as a clamped page size, and pin the rest Mutation testing of the new spec turned up one behaviour bug and a set of assertions that were never actually pinned down. isReducedPageSize() accepted any smaller page size the response's page block confirmed, including zero: asking for size=10 and getting a self link claiming size=0 with "page": {"size": 0} was silently swallowed. A configured maximum is never zero, so an empty page is not a clamp - require the effective size to be greater than zero and let that case be reported again. Also drop a redundant hasValue(payload) - the caller already guarantees it - and note in the doc comment that running the self link through getUrlWithoutEmbedParams() drops a fragment and a trailing slash too, so differences limited to those stop being reported as well. Tests added for the gaps mutation testing exposed, each verified to fail against the corresponding mutant: - the page size rule tested on its own, without embed params also in play - the exact text of the warning, not just a substring - sibling _links surviving the self link being normalized - `size` matched as a whole param, so `pagesize` isn't mistaken for it - a page block confirming the size only as a string is not confirmation - an empty page is reported - a cross-origin self link is passed through untouched - reordered params are neither reported nor rewritten * Cover the case where an accepted clamp hides another differing param When isReducedPageSize() accepts a reduced page size, isUnexpectedSelfLink() re-compares the remaining parts rather than returning early, so a legitimate clamp can't mask a genuinely wrong page/sort. Nothing defended that: dropping the second urlPartsDiffer() call left all 22 tests green while silently swallowing the defect. * Don't report a self link that only percent decoded a param value The remaining self link warning on the home page came from the usage statistics request: the frontend sends uri=http%3A%2F%2F... and the REST API echoes it back decoded as uri=http://... Same value, different representation - comparing the raw strings compares encodings, not values, which is the same kind of false alarm as the echoed embed params. Percent decode both sides before comparing. Decoding happens per part, after the url was split, so a decoded & can't merge two params, and a malformed sequence falls back to the raw part. A value that differs beyond its encoding still warns. With this, an item page, the home page and a search page all load with zero self link warnings against a DSpace 9.1 backend. Also trims the doc comments on these helpers down to what isn't already obvious from the code. * Stop asking the REST API for pages it will never serve Seven call sites asked for 9999 or 10000 elements to mean "give me everything". The REST API caps a page at 1000 (Spring Data REST's spring.data.rest.max-page-size, which DSpace leaves at its default) and silently reduces anything larger, so those requests never returned more than 1000 anyway - they just claimed something the API does not honour, and the difference between the requested and the effective size is what showed up in the self link and produced the console warnings of #862: GET /core/items/<uuid>/bundles?embed=primaryBitstream&size=9999 self ...?embed=primaryBitstream&size=1000 Introduce MAX_PAGE_SIZE next to FindListOptions and use it instead. Verified against a DSpace 9.1 backend that this returns the identical page - same totalElements, same contents - while the self link now matches the request exactly: ?size=9999 -> self ?size=1000 page {size: 1000, totalElements: 1} ?size=1000 -> self ?size=1000 page {size: 1000, totalElements: 1} This is upstream issue DSpace#2513, whose fix (DSpace#3694) removed 9999 from seven components but missed BundleDataService.findByItemAndName - still on main, dspace-9_x, dtq-dev and dtq-dev-9-base. The three clarin-* call sites are ours. * Stop percent-encoding the usagereports uri param The frontend sent uri=http%3A%2F%2F... and the REST API echoed it back decoded as uri=http://..., which made the self link differ from the requested url and produced the self link warning on the home page. RequestParam encodes by default; pass encodeValue=false, exactly as AuthorizationDataService already does for its own uri param (upstream DSpace#3045/DSpace#3046), and carry the same TODO noting this belongs in the backend. * Report a reduced page size again, now that nothing asks for an impossible one Filtering the page size out of the self link comparison treated the symptom: the frontend went on asking for 9999, the API went on reducing it to 1000, and the code here just stopped saying so. With every call site now within MAX_PAGE_SIZE, a page size the API reduced means a caller asked for something it was never going to get - which is exactly what this warning exists to surface. Drops isReducedPageSize, getPageSizes and PAGE_SIZE_PARAM, and with them the need to pass the response payload into isUnexpectedSelfLink at all. What stays is the part that is a genuine comparison bug rather than a filter: embed params and percent encoding are two ways of writing the same request, so both sides are brought to the same form before being compared. A difference in an actual value is still reported. Spec goes from 25 cases to 17: the nine that pinned the page size filtering are gone, replaced by one asserting a reduced page size warns. * Use MAX_PAGE_SIZE for the license lookups too Three CLARIN license components already asked for exactly 1000, so they never triggered the warning - but leaving the bare literal next to a newly introduced MAX_PAGE_SIZE just invites the question why one place names the limit and the other repeats it. Same value, no behaviour change; the constant now says where the number comes from. * Drop the uri encoding workaround instead of copying its TODO An earlier commit here made UsageReportDataService skip encoding its uri param, copying the workaround and the TODO that AuthorizationDataService carries: // TODO fix encode the uri parameter in the self link in the backend and set // encodeValue to true afterwards Measured against a DSpace 9.1 backend, that TODO is based on a misreading - there is nothing to fix in the backend. It does not decode the self link; it re-encodes the parameter values minimally, and ':' and '/' are legal in a query component per RFC 3986, so it has no reason to escape them: sent probe=a%25b -> self probe=a%25b (not decoded) sent probe=a%20b -> self probe=a%20b (not decoded) sent probe=a+b -> self probe=a%20b ('+' is a space in form encoding) sent uri=http%3A%2F%2Fx -> self uri=http://x encodeURIComponent on the frontend simply encodes more than it has to. Both urls are valid and denote the same value. Since the comparison now decodes both sides, the encoding no longer matters, so put the param back on the default: encoding is the safer choice for a value that could contain '&' or '#'. Verified in a browser - the home page still logs no self link warning with encoding restored. The same workaround in AuthorizationDataService is left alone: it is upstream code, it works either way, and it is out of scope here. * Pass the already split request url into isUnexpectedSelfLink ensureSelfLink splits the requested url into parts before the comparison, then isUnexpectedSelfLink split the same string a second time. The branch runs on every response whose self link differs, which after this change is every embedded list response, so the duplicate work is not rare. Takes the parts instead. No behaviour change. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> * Merge the MAX_PAGE_SIZE import into the existing one section-license.component.ts already imported FindListOptions from find-list-options.model, so adding MAX_PAGE_SIZE as a second import of the same module - under a different specifier - left the file importing one module twice. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6487711 commit d0897c2

13 files changed

Lines changed: 308 additions & 19 deletions

File tree

src/app/bitstream-page/clarin-zip-download-page/clarin-zip-download-page.component.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { BitstreamDataService } from '../../core/data/bitstream-data.service';
2020
import { createSuccessfulRemoteDataObject$ } from '../../shared/remote-data.utils';
2121
import { NotificationsService } from '../../shared/notifications/notifications.service';
2222
import { TranslateService } from '@ngx-translate/core';
23+
import { MAX_PAGE_SIZE } from '../../core/data/find-list-options.model';
2324

2425
/**
2526
* Fetch ZIP file from the server as a single file into `bitstreamRD$` property which is extended and then call
@@ -59,7 +60,7 @@ export class ClarinZipDownloadPageComponent extends ClarinBitstreamDownloadPageC
5960
this.itemRD$.subscribe((itemRD: RemoteData<Item>) => {
6061
this.bitstreamDataService.findAllByItemAndBundleName(itemRD?.payload, 'ORIGINAL', {
6162
currentPage: 1,
62-
elementsPerPage: 9999
63+
elementsPerPage: MAX_PAGE_SIZE
6364
}).pipe(
6465
getFirstCompletedRemoteData(),
6566
).subscribe((bitstreamsRD: RemoteData<PaginatedList<Bitstream>>) => {

src/app/clarin-licenses/clarin-all-licenses-page/clarin-all-licenses-page.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import { BehaviorSubject } from 'rxjs';
33
import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model';
44
import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service';
55
import { getFirstSucceededRemoteListPayload } from '../../core/shared/operators';
6-
import { FindListOptions } from '../../core/data/find-list-options.model';
6+
import { FindListOptions, MAX_PAGE_SIZE } from '../../core/data/find-list-options.model';
77
import { ClarinLicenseRequiredInfo } from '../../core/shared/clarin/clarin-license.resource-type';
88
import { ClarinLicenseRequiredInfoSerializer } from '../../core/shared/clarin/clarin-license-required-info-serializer';
99

@@ -36,7 +36,7 @@ export class ClarinAllLicensesPageComponent implements OnInit {
3636
const options = new FindListOptions();
3737
options.currentPage = 0;
3838
// Load all licenses
39-
options.elementsPerPage = 1000;
39+
options.elementsPerPage = MAX_PAGE_SIZE;
4040
return this.clarinLicenseService.findAll(options, false)
4141
.pipe(getFirstSucceededRemoteListPayload())
4242
.subscribe(res => {

src/app/core/browse/browse.service.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import { followLink, FollowLinkConfig } from '../../shared/utils/follow-link-con
2424
import { BrowseDefinitionDataService } from './browse-definition-data.service';
2525
import { SortDirection } from '../cache/models/sort-options.model';
2626
import { environment } from '../../../environments/environment';
27+
import { MAX_PAGE_SIZE } from '../data/find-list-options.model';
2728

2829

2930
export function getBrowseLinksToFollow(): FollowLinkConfig<BrowseEntry | Item>[] {
@@ -69,7 +70,7 @@ export class BrowseService {
6970
*/
7071
getBrowseDefinitions(): Observable<RemoteData<PaginatedList<BrowseDefinition>>> {
7172
// TODO properly support pagination
72-
return this.browseDefinitionDataService.findAll({ elementsPerPage: 9999 }).pipe(
73+
return this.browseDefinitionDataService.findAll({ elementsPerPage: MAX_PAGE_SIZE }).pipe(
7374
getFirstSucceededRemoteData(),
7475
);
7576
}

src/app/core/data/bundle-data.service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ import { RequestService } from './request.service';
1616
import { PaginatedSearchOptions } from '../../shared/search/models/paginated-search-options.model';
1717
import { Bitstream } from '../shared/bitstream.model';
1818
import { RequestEntryState } from './request-entry-state.model';
19-
import { FindListOptions } from './find-list-options.model';
19+
import { FindListOptions, MAX_PAGE_SIZE } from './find-list-options.model';
2020
import { IdentifiableDataService } from './base/identifiable-data.service';
2121
import { PatchData, PatchDataImpl } from './base/patch-data';
2222
import { DSOChangeAnalyzer } from './dso-change-analyzer.service';
@@ -81,7 +81,7 @@ export class BundleDataService extends IdentifiableDataService<Bundle> implement
8181
findByItemAndName(item: Item, bundleName: string, useCachedVersionIfAvailable = true, reRequestOnStale = true, options?: FindListOptions, ...linksToFollow: FollowLinkConfig<Bundle>[]): Observable<RemoteData<Bundle>> {
8282
//Since we filter by bundleName where the pagination options are not indicated we need to load all the possible bundles.
8383
// This is a workaround, in substitution of the previously recursive call with expand
84-
const paginationOptions = options ?? { elementsPerPage: 9999 };
84+
const paginationOptions = options ?? { elementsPerPage: MAX_PAGE_SIZE };
8585
return this.findAllByItem(item, paginationOptions, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow).pipe(
8686
map((rd: RemoteData<PaginatedList<Bundle>>) => {
8787
if (hasValue(rd.payload) && hasValue(rd.payload.page)) {
Lines changed: 232 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,232 @@
1+
import { DspaceRestResponseParsingService } from './dspace-rest-response-parsing.service';
2+
import { RestRequest } from './rest-request.model';
3+
import { GetRequest, PostRequest } from './request.models';
4+
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
5+
import { ObjectCacheService } from '../cache/object-cache.service';
6+
7+
/**
8+
* Exposes the protected {@link DspaceRestResponseParsingService#ensureSelfLink} so it can be
9+
* tested in isolation.
10+
*/
11+
class TestParsingService extends DspaceRestResponseParsingService {
12+
public callEnsureSelfLink(request: RestRequest, response: RawRestResponse): RawRestResponse {
13+
return this.ensureSelfLink(request, response);
14+
}
15+
}
16+
17+
describe('DspaceRestResponseParsingService', () => {
18+
let service: TestParsingService;
19+
let objectCache: ObjectCacheService;
20+
21+
const MISMATCH = jasmine.stringMatching(/These don't match/);
22+
const NO_SELF_LINK = jasmine.stringMatching(/doesn't have a self link/);
23+
24+
const requestFor = (href: string): RestRequest =>
25+
new GetRequest('c4f0b1b7-3ffa-4b1a-9f5f-8bd6b1c4de71', href);
26+
27+
const responseWithSelfLink = (href: string, page?: any): RawRestResponse => ({
28+
payload: {
29+
_links: {
30+
self: { href },
31+
},
32+
...(page ? { page } : {}),
33+
},
34+
statusCode: 200,
35+
statusText: 'OK',
36+
});
37+
38+
beforeEach(() => {
39+
objectCache = jasmine.createSpyObj('objectCache', ['add', 'remove']);
40+
service = new TestParsingService(objectCache);
41+
spyOn(console, 'warn');
42+
});
43+
44+
describe('ensureSelfLink', () => {
45+
46+
describe('differences the REST API is expected to introduce', () => {
47+
48+
it('should not warn when the self link matches the requested url', () => {
49+
const href = 'https://rest.api/core/bundles/9d18168a/bitstreams?page=0&size=5';
50+
const response = service.callEnsureSelfLink(requestFor(href), responseWithSelfLink(href));
51+
52+
expect(console.warn).not.toHaveBeenCalled();
53+
expect(response.payload._links.self.href).toBe(href);
54+
});
55+
56+
it('should not warn when the self link only echoes the embed params of the request', () => {
57+
// https://github.com/dataquest-dev/dspace-customers/issues/862
58+
const href = 'https://rest.api/core/bundles/9d18168a/bitstreams?page=0&embed=accessStatus&size=5';
59+
const response = service.callEnsureSelfLink(requestFor(href), responseWithSelfLink(href));
60+
61+
expect(console.warn).not.toHaveBeenCalled();
62+
// the self link is still normalized, because that's the url the response is cached under
63+
expect(response.payload._links.self.href).toBe('https://rest.api/core/bundles/9d18168a/bitstreams?page=0&size=5');
64+
});
65+
66+
it('should not warn when the self link echoes embed params and the request has no other params', () => {
67+
// observed in the browser against a DSpace 9.1 backend
68+
const href = 'https://rest.api/core/items/eba1c085/bundles?embed=primaryBitstream&embed=bitstreams/format&embed.size=bitstreams=5';
69+
const response = service.callEnsureSelfLink(requestFor(href), responseWithSelfLink(href));
70+
71+
expect(console.warn).not.toHaveBeenCalled();
72+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085/bundles');
73+
});
74+
75+
it('should not warn when the self link only percent decoded a param value', () => {
76+
// https://github.com/dataquest-dev/dspace-customers/issues/862
77+
const request = requestFor('https://rest.api/statistics/usagereports/search/object?page=-1&size=10&uri=https%3A%2F%2Frest.api%2Fcore%2Fsites%2F8f842a80');
78+
const response = service.callEnsureSelfLink(request, responseWithSelfLink(
79+
'https://rest.api/statistics/usagereports/search/object?page=-1&size=10&uri=https://rest.api/core/sites/8f842a80'));
80+
81+
expect(console.warn).not.toHaveBeenCalled();
82+
expect(response.payload._links.self.href)
83+
.toBe('https://rest.api/statistics/usagereports/search/object?page=-1&size=10&uri=https%3A%2F%2Frest.api%2Fcore%2Fsites%2F8f842a80');
84+
});
85+
86+
it('should not warn or normalize when params are only in a different order', () => {
87+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?page=0&size=5');
88+
const response = service.callEnsureSelfLink(request,
89+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?size=5&page=0'));
90+
91+
expect(console.warn).not.toHaveBeenCalled();
92+
// the urls hold the same params, so nothing is rewritten here
93+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085/bundles?size=5&page=0');
94+
});
95+
96+
});
97+
98+
describe('differences that point at a problem with the endpoint', () => {
99+
100+
it('should warn when the REST API reduced the requested page size', () => {
101+
// callers are expected to stay within MAX_PAGE_SIZE, so a reduced size means a caller asked
102+
// for a page the API was never going to serve
103+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?size=9999');
104+
service.callEnsureSelfLink(request, responseWithSelfLink(
105+
'https://rest.api/core/items/eba1c085/bundles?size=1000',
106+
{ number: 0, size: 1000, totalPages: 1, totalElements: 2 }));
107+
108+
expect(console.warn).toHaveBeenCalledTimes(1);
109+
expect(console.warn).toHaveBeenCalledWith(MISMATCH);
110+
});
111+
112+
it('should warn when the returned page size is larger than the requested one', () => {
113+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?size=5');
114+
service.callEnsureSelfLink(request, responseWithSelfLink(
115+
'https://rest.api/core/items/eba1c085/bundles?size=50',
116+
{ number: 0, size: 50, totalPages: 1, totalElements: 2 }));
117+
118+
expect(console.warn).toHaveBeenCalledTimes(1);
119+
expect(console.warn).toHaveBeenCalledWith(MISMATCH);
120+
});
121+
122+
it('should still warn when a param value differs beyond its encoding', () => {
123+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?uri=https%3A%2F%2Frest.api%2Fcore%2Fsites%2Faaa');
124+
service.callEnsureSelfLink(request,
125+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?uri=https://rest.api/core/sites/bbb'));
126+
127+
expect(console.warn).toHaveBeenCalledTimes(1);
128+
expect(console.warn).toHaveBeenCalledWith(MISMATCH);
129+
});
130+
131+
it('should report the normalized request url and the raw self link in the warning', () => {
132+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?page=0&embed=primaryBitstream&size=5');
133+
service.callEnsureSelfLink(request,
134+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?page=3&embed=primaryBitstream&size=5'));
135+
136+
expect(console.warn).toHaveBeenCalledWith(
137+
'The response for \'https://rest.api/core/items/eba1c085/bundles?page=0&size=5\' has the self link ' +
138+
'\'https://rest.api/core/items/eba1c085/bundles?page=3&embed=primaryBitstream&size=5\'. ' +
139+
'These don\'t match. This could mean there\'s an issue with the REST endpoint');
140+
});
141+
142+
it('should warn when a non-embed param differs', () => {
143+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?page=0&embed=primaryBitstream&size=5');
144+
service.callEnsureSelfLink(request,
145+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?page=3&embed=primaryBitstream&size=5'));
146+
147+
expect(console.warn).toHaveBeenCalledTimes(1);
148+
expect(console.warn).toHaveBeenCalledWith(MISMATCH);
149+
});
150+
151+
it('should warn when the self link has a param the request did not have', () => {
152+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?size=5');
153+
service.callEnsureSelfLink(request,
154+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?size=5&sort=name,ASC'));
155+
156+
expect(console.warn).toHaveBeenCalledTimes(1);
157+
expect(console.warn).toHaveBeenCalledWith(MISMATCH);
158+
});
159+
160+
it('should warn and fill in the requested url when the response has no self link', () => {
161+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?embed=primaryBitstream&size=5');
162+
const response = service.callEnsureSelfLink(request, {
163+
payload: { _links: {} },
164+
statusCode: 200,
165+
statusText: 'OK',
166+
});
167+
168+
expect(console.warn).toHaveBeenCalledTimes(1);
169+
expect(console.warn).toHaveBeenCalledWith(NO_SELF_LINK);
170+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085/bundles?size=5');
171+
});
172+
173+
});
174+
175+
describe('normalization of the self link', () => {
176+
177+
it('should normalize the self link when it differs, so it matches the cache key', () => {
178+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?page=0&embed=primaryBitstream&size=5');
179+
const response = service.callEnsureSelfLink(request,
180+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?page=3&embed=primaryBitstream&size=5'));
181+
182+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085/bundles?page=0&size=5');
183+
});
184+
185+
it('should keep the other links when it normalizes the self link', () => {
186+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?page=0&size=5');
187+
const response = service.callEnsureSelfLink(request, {
188+
payload: {
189+
_links: {
190+
self: { href: 'https://rest.api/core/items/eba1c085/bundles?page=3&size=5' },
191+
primaryBitstream: { href: 'https://rest.api/core/bitstreams/6a5f' },
192+
},
193+
},
194+
statusCode: 200,
195+
statusText: 'OK',
196+
});
197+
198+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085/bundles?page=0&size=5');
199+
expect(response.payload._links.primaryBitstream.href).toBe('https://rest.api/core/bitstreams/6a5f');
200+
});
201+
202+
it('should not touch a self link on a different host', () => {
203+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?size=5');
204+
const response = service.callEnsureSelfLink(request,
205+
responseWithSelfLink('https://other.api/core/items/eba1c085/bundles?size=5'));
206+
207+
expect(console.warn).not.toHaveBeenCalled();
208+
expect(response.payload._links.self.href).toBe('https://other.api/core/items/eba1c085/bundles?size=5');
209+
});
210+
211+
it('should not touch a self link that points at a different path', () => {
212+
const request = requestFor('https://rest.api/core/items/eba1c085/bundles?size=5');
213+
const response = service.callEnsureSelfLink(request,
214+
responseWithSelfLink('https://rest.api/core/items/eba1c085?size=5'));
215+
216+
expect(console.warn).not.toHaveBeenCalled();
217+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085?size=5');
218+
});
219+
220+
it('should leave non-GET requests alone', () => {
221+
const request = new PostRequest('c4f0b1b7-3ffa-4b1a-9f5f-8bd6b1c4de71', 'https://rest.api/core/items/eba1c085/bundles?size=5');
222+
const response = service.callEnsureSelfLink(request,
223+
responseWithSelfLink('https://rest.api/core/items/eba1c085/bundles?size=1000'));
224+
225+
expect(console.warn).not.toHaveBeenCalled();
226+
expect(response.payload._links.self.href).toBe('https://rest.api/core/items/eba1c085/bundles?size=1000');
227+
});
228+
229+
});
230+
231+
});
232+
});

src/app/core/data/dspace-rest-response-parsing.service.ts

Lines changed: 46 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,45 @@ const splitUrlInParts = (url: string): string[] => {
5454
.reduce((combined, current) => [...combined, ...current]);
5555
};
5656

57+
/**
58+
* Return true if two lists of url parts don't hold the same parts, ignoring their order
59+
*/
60+
const urlPartsDiffer = (expected: string[], actual: string[]): boolean => {
61+
return expected.some((part: string) => !actual.includes(part))
62+
|| actual.some((part: string) => !expected.includes(part));
63+
};
64+
65+
/**
66+
* Percent decode each url part, so `uri=http%3A%2F%2Fx` and `uri=http://x` compare equal. Parts are
67+
* decoded one by one, after the url was split, so a decoded `&` can't merge two params.
68+
*/
69+
const decodeUrlParts = (parts: string[]): string[] => {
70+
return parts.map((part: string) => {
71+
try {
72+
return decodeURIComponent(part);
73+
} catch (e) {
74+
return part;
75+
}
76+
});
77+
};
78+
79+
/**
80+
* Return true if the self link differs from the requested url in a way that isn't just a different
81+
* way of writing the same request. Takes the requested url already split, since the caller has it.
82+
*
83+
* Both sides are brought to the same form first: `embed`/`embed.size` params are stripped, because
84+
* the frontend treats them as not part of a resource's identity and indexes without them, and both
85+
* are percent decoded. Anything still differing is a real difference between what was asked for and
86+
* what came back, including a page size the API reduced — callers are expected to stay within
87+
* `MAX_PAGE_SIZE` rather than have that reported difference filtered out here.
88+
*/
89+
const isUnexpectedSelfLink = (requestedUrlParts: string[], selfLink: string): boolean => {
90+
return urlPartsDiffer(
91+
decodeUrlParts(requestedUrlParts),
92+
decodeUrlParts(splitUrlInParts(getUrlWithoutEmbedParams(selfLink))),
93+
);
94+
};
95+
5796
@Injectable({ providedIn: 'root' })
5897
export class DspaceRestResponseParsingService implements ResponseParsingService {
5998
protected serializerConstructor: GenericConstructor<Serializer<any>> = DSpaceSerializer;
@@ -156,10 +195,14 @@ export class DspaceRestResponseParsingService implements ResponseParsingService
156195
});
157196

158197
} else {
198+
const selfLink = response.payload._links.self.href;
159199
const expected = splitUrlInParts(urlWithoutEmbedParams);
160-
const actual = splitUrlInParts(response.payload._links.self.href);
161-
if (expected[0] === actual[0] && (expected.some((e) => !actual.includes(e)) || actual.some((e) => !expected.includes(e)))) {
162-
console.warn(`The response for '${urlWithoutEmbedParams}' has the self link '${response.payload._links.self.href}'. These don't match. This could mean there's an issue with the REST endpoint`);
200+
const actual = splitUrlInParts(selfLink);
201+
if (expected[0] === actual[0] && urlPartsDiffer(expected, actual)) {
202+
// the self link is normalized either way, only the warning is filtered
203+
if (isUnexpectedSelfLink(expected, selfLink)) {
204+
console.warn(`The response for '${urlWithoutEmbedParams}' has the self link '${selfLink}'. These don't match. This could mean there's an issue with the REST endpoint`);
205+
}
163206
response.payload._links = Object.assign({}, response.payload._links, {
164207
self: {
165208
href: urlWithoutEmbedParams

src/app/core/data/find-list-options.model.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,16 @@
11
import { SortOptions } from '../cache/models/sort-options.model';
22
import { RequestParam } from '../cache/models/request-param.model';
33

4+
/**
5+
* The largest page the REST API will serve. Asking for more is not an error: the API silently
6+
* reduces the size to this maximum, so a bigger number returns the exact same page while making the
7+
* request claim something the API never honours.
8+
*
9+
* The limit is Spring Data REST's `spring.data.rest.max-page-size`, which DSpace leaves at its
10+
* default. Use this instead of an arbitrary large number when a caller needs "everything".
11+
*/
12+
export const MAX_PAGE_SIZE = 1000;
13+
414
/**
515
* The options for a find list request
616
*/

0 commit comments

Comments
 (0)