forked from DSpace/dspace-angular
-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathdspace-rest-response-parsing.service.ts
More file actions
348 lines (314 loc) · 13.6 KB
/
Copy pathdspace-rest-response-parsing.service.ts
File metadata and controls
348 lines (314 loc) · 13.6 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
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
/* eslint-disable max-classes-per-file */
import { hasNoValue, hasValue, isNotEmpty } from '../../shared/empty.util';
import { DSpaceSerializer } from '../dspace-rest/dspace.serializer';
import { Serializer } from '../serializer';
import { PageInfo } from '../shared/page-info.model';
import { ObjectCacheService } from '../cache/object-cache.service';
import { GenericConstructor } from '../shared/generic-constructor';
import { PaginatedList, buildPaginatedList } from './paginated-list.model';
import { getClassForType } from '../cache/builders/build-decorators';
import { environment } from '../../../environments/environment';
import { RawRestResponse } from '../dspace-rest/raw-rest-response.model';
import { DSpaceObject } from '../shared/dspace-object.model';
import { Injectable } from '@angular/core';
import { ResponseParsingService } from './parsing.service';
import { ParsedResponse } from '../cache/response.models';
import { RestRequestMethod } from './rest-request-method';
import { getUrlWithoutEmbedParams, getEmbedSizeParams } from '../index/index.selectors';
import { URLCombiner } from '../url-combiner/url-combiner';
import { CacheableObject } from '../cache/cacheable-object.model';
import { RestRequest } from './rest-request.model';
/**
* Return true if obj has a value for `_links.self`
*
* @param {any} obj The object to test
*/
export function isCacheableObject(obj: any): boolean {
return hasValue(obj) && hasValue(obj._links) && hasValue(obj._links.self) && hasValue(obj._links.self.href);
}
/**
* Return true if halObj has a value for `page` with properties
* `size`, `totalElements`, `totalPages`, `number`
*
* @param {any} halObj The object to test
*/
export function isRestPaginatedList(halObj: any): boolean {
return hasValue(halObj.page) &&
hasValue(halObj.page.size) &&
hasValue(halObj.page.totalElements) &&
hasValue(halObj.page.totalPages) &&
hasValue(halObj.page.number);
}
/**
* The url param holding the page size
*/
const PAGE_SIZE_PARAM = 'size=';
/**
* Split a url into parts
*
* @param url the url to split
*/
const splitUrlInParts = (url: string): string[] => {
return url.split('?')
.map((part) => part.split('&'))
.reduce((combined, current) => [...combined, ...current]);
};
/**
* Return true if two lists of url parts don't hold the same parts, ignoring their order
*/
const urlPartsDiffer = (expected: string[], actual: string[]): boolean => {
return expected.some((part: string) => !actual.includes(part))
|| actual.some((part: string) => !expected.includes(part));
};
/**
* Percent decode each url part, so `uri=http%3A%2F%2Fx` and `uri=http://x` compare equal. Decoding
* after the split keeps a decoded `&` from merging two params.
*/
const decodeUrlParts = (parts: string[]): string[] => {
return parts.map((part: string) => {
try {
return decodeURIComponent(part);
} catch (e) {
return part;
}
});
};
/**
* The page size a url asks for, or undefined when it doesn't ask for a usable one
*/
const getPageSize = (parts: string[]): number | undefined => {
return parts.filter((part: string) => part.startsWith(PAGE_SIZE_PARAM))
.map((part: string) => Number(part.substring(PAGE_SIZE_PARAM.length)))
.find((size: number) => Number.isInteger(size) && size > 0);
};
/**
* Return the parts without the one holding the page size
*/
const withoutPageSize = (parts: string[]): string[] => {
return parts.filter((part: string) => !part.startsWith(PAGE_SIZE_PARAM));
};
/**
* Return the warning to log for a self link, or undefined when it describes the same request as the
* url it was requested with. A reduced page size gets its own message, since the generic one blames
* the endpoint for something the caller did.
*/
const selfLinkWarning = (requestedUrl: string, requestedUrlParts: string[], selfLink: string): string | undefined => {
const expected = decodeUrlParts(requestedUrlParts);
const actual = decodeUrlParts(splitUrlInParts(getUrlWithoutEmbedParams(selfLink)));
if (!urlPartsDiffer(expected, actual)) {
return undefined;
}
const requestedSize = getPageSize(expected);
const servedSize = getPageSize(actual);
if (hasValue(requestedSize) && hasValue(servedSize) && servedSize < requestedSize
&& !urlPartsDiffer(withoutPageSize(expected), withoutPageSize(actual))) {
return `The request for '${requestedUrl}' asked for a page of ${requestedSize} elements, but the REST API served ${servedSize}. Ask for at most MAX_PAGE_SIZE elements`;
}
return `The response for '${requestedUrl}' has the self link '${selfLink}'. These don't match. This could mean there's an issue with the REST endpoint`;
};
@Injectable({ providedIn: 'root' })
export class DspaceRestResponseParsingService implements ResponseParsingService {
protected serializerConstructor: GenericConstructor<Serializer<any>> = DSpaceSerializer;
constructor(
protected objectCache: ObjectCacheService,
) {
}
parse(request: RestRequest, response: RawRestResponse): ParsedResponse {
response = this.ensureSelfLink(request, response);
let alternativeURL: string;
if (request.method === RestRequestMethod.GET) {
// only store an alternative URL when parsing a GET request, as there are cases when e.g. a
// POST or a PUT would have a different response
alternativeURL = getUrlWithoutEmbedParams(request.href);
}
const processRequestDTO = this.process<DSpaceObject>(response.payload, request, alternativeURL);
if (hasValue(processRequestDTO)) {
if (isCacheableObject(processRequestDTO)) {
return new ParsedResponse(response.statusCode, processRequestDTO._links.self);
} else {
return new ParsedResponse(response.statusCode, undefined, processRequestDTO);
}
} else {
return new ParsedResponse(response.statusCode);
}
}
public process<ObjectDomain>(data: any, request: RestRequest, alternativeURL?: string): any {
const embedSizeParams = getEmbedSizeParams(request.href);
if (isNotEmpty(data)) {
if (hasNoValue(data) || (typeof data !== 'object')) {
return data;
} else if (isRestPaginatedList(data)) {
return this.processPaginatedList(data, request, alternativeURL);
} else if (Array.isArray(data)) {
return this.processArray(data, request);
} else if (isCacheableObject(data)) {
const object = this.deserialize(data);
if (isNotEmpty(data._embedded)) {
Object
.keys(data._embedded)
.filter((property) => data._embedded.hasOwnProperty(property))
.forEach((property) => {
let embedAltUrl = data._links[property].href;
const match = embedSizeParams
.find((param: { name: string, size: number }) => param.name === property);
if (hasValue(match)) {
embedAltUrl = new URLCombiner(embedAltUrl, `?size=${match.size}`).toString();
}
if (data._embedded[property] == null) {
// Embedded object is null, meaning it exists (not undefined), but had an empty response (204) -> cache it as null
this.addToObjectCache(null, request, data, embedAltUrl);
} else if (!isCacheableObject(data._embedded[property])) {
// Embedded object exists, but doesn't contain a self link -> cache it using the alternative link instead
this.objectCache.add(data._embedded[property], hasValue(request.responseMsToLive) ? request.responseMsToLive : environment.cache.msToLive.default, request.uuid, embedAltUrl);
}
this.process<ObjectDomain>(data._embedded[property], request, embedAltUrl);
});
}
this.addToObjectCache(object, request, data, alternativeURL);
return object;
}
const result = {};
Object.keys(data)
.filter((property) => data.hasOwnProperty(property))
.filter((property) => hasValue(data[property]))
.forEach((property) => {
result[property] = this.process(data[property], request);
});
return result;
}
}
/**
* Some rest endpoints don't return a self link in their response. This method will fix that for
* the root resource in the response by filling in the requested href, without any embed params.
* It will print a warning in the console, as this could indicate an issue on the REST side.
*
* @param request the {@RestRequest} that was sent to the server
* @param response the {@link RawRestResponse} returned by the server
* @protected
*/
protected ensureSelfLink(request: RestRequest, response: RawRestResponse): RawRestResponse {
const urlWithoutEmbedParams = getUrlWithoutEmbedParams(request.href);
if (request.method === RestRequestMethod.GET && hasValue(response) && hasValue(response.payload) && hasValue(response.payload._links)) {
if (hasNoValue(response.payload._links.self) || hasNoValue(response.payload._links.self.href)) {
console.warn(`The response for '${request.href}' doesn't have a self link. This could mean there's an issue with the REST endpoint`);
response.payload._links = Object.assign({}, response.payload._links, {
self: {
href: urlWithoutEmbedParams
}
});
} else {
const selfLink = response.payload._links.self.href;
const expected = splitUrlInParts(urlWithoutEmbedParams);
const actual = splitUrlInParts(selfLink);
if (expected[0] === actual[0] && urlPartsDiffer(expected, actual)) {
// the self link is normalized either way, only the warning is conditional
const warning = selfLinkWarning(urlWithoutEmbedParams, expected, selfLink);
if (hasValue(warning)) {
console.warn(warning);
}
response.payload._links = Object.assign({}, response.payload._links, {
self: {
href: urlWithoutEmbedParams
}
});
}
}
}
return response;
}
protected processPaginatedList<ObjectDomain>(data: any, request: RestRequest, alternativeURL?: string): PaginatedList<ObjectDomain> {
const pageInfo: PageInfo = this.processPageInfo(data);
let list = data._embedded;
// Workaround for inconsistency in rest response. Issue: https://github.com/DSpace/dspace-angular/issues/238
if (hasNoValue(list)) {
list = [];
} else if (!Array.isArray(list)) {
list = this.flattenSingleKeyObject(list);
}
const page: ObjectDomain[] = this.processArray(list, request);
const paginatedList = buildPaginatedList<ObjectDomain>(pageInfo, page, true, data._links);
this.addToObjectCache(paginatedList, request, data, alternativeURL);
return paginatedList;
}
protected processArray<ObjectDomain>(data: any, request: RestRequest): ObjectDomain[] {
let array: ObjectDomain[] = [];
data.forEach((datum) => {
array = [...array, this.process(datum, request)];
}
);
return array;
}
protected deserialize<ObjectDomain>(obj): any {
const type = obj.type;
const objConstructor = this.getConstructorFor<ObjectDomain>(type);
if (hasValue(objConstructor)) {
const serializer = new this.serializerConstructor(objConstructor);
return serializer.deserialize(obj);
} else {
console.warn('cannot deserialize type ' + type);
return null;
}
}
/**
* Returns the constructor for the given type, or null if there isn't a registered model for that
* type
*
* @param type the object to find the constructor for.
* @protected
*/
protected getConstructorFor<ObjectDomain>(type: string): GenericConstructor<ObjectDomain> {
if (hasValue(type)) {
return getClassForType(type) as GenericConstructor<ObjectDomain>;
} else {
return null;
}
}
/**
* Add the given object to the object cache
*
* @param co the {@link CacheableObject} to add
* @param request the {@link RestRequest} that was sent to the backend
* @param data the (partial) response from the server
* @param alternativeURL an alternative url that can be used to retrieve the object
*/
addToObjectCache(co: CacheableObject, request: RestRequest, data: any, alternativeURL?: string): void {
if (hasValue(co) && !isCacheableObject(co)) {
const type = hasValue(data) && hasValue(data.type) ? data.type : 'object';
let dataJSON: string;
if (hasValue(data._embedded)) {
dataJSON = JSON.stringify(Object.assign({}, data, {
_embedded: '...'
}));
} else {
dataJSON = JSON.stringify(data);
}
console.warn(`Can't cache incomplete ${type}: ${JSON.stringify(co)}, parsed from (partial) response: ${dataJSON}`);
return;
}
if (hasValue(co) && alternativeURL === co._links.self.href) {
alternativeURL = undefined;
}
this.objectCache.add(co, hasValue(request.responseMsToLive) ? request.responseMsToLive : environment.cache.msToLive.default, request.uuid, alternativeURL);
}
processPageInfo(payload: any): PageInfo {
if (hasValue(payload.page)) {
const pageInfoObject = new DSpaceSerializer(PageInfo).deserialize(payload.page);
if (pageInfoObject.currentPage >= 0) {
Object.assign(pageInfoObject, { currentPage: pageInfoObject.currentPage + 1 });
}
return pageInfoObject;
} else {
return undefined;
}
}
protected flattenSingleKeyObject(obj: any): any {
const keys = Object.keys(obj);
if (keys.length !== 1) {
throw new Error(`Expected an object with a single key, got: ${JSON.stringify(obj)}`);
}
return obj[keys[0]];
}
protected isSuccessStatus(statusCode: number) {
return statusCode >= 200 && statusCode < 300;
}
}