UNPKG

semantic-network

Version:

A utility library for manipulating a list of links that form a semantic interface to a network of resources.

484 lines 27.5 kB
import { __awaiter, __rest } from "tslib"; import { LinkUtil } from 'semantic-link'; import { Status } from './status'; import { SingletonMerger } from './singletonMerger'; import { LinkRelation } from '../linkRelation'; import { HttpRequestFactory } from '../http/httpRequestFactory'; import { TrackedRepresentationUtil } from '../utils/trackedRepresentationUtil'; import { isHttpRequestError } from '../interfaces/httpRequestError'; import { parallelWaitAll, sequentialWaitAll } from '../utils/promiseWaitAll'; import { CollectionMerger } from './collectionMerger'; import { SparseRepresentationFactory } from './sparseRepresentationFactory'; import anylogger from 'anylogger'; import { RepresentationUtil } from '../utils/representationUtil'; import { instanceOfTrackedRepresentation } from '../utils/instanceOf/instanceOfTrackedRepresentation'; import { instanceOfFeed } from '../utils/instanceOf/instanceOfFeed'; import { instanceOfCollection } from '../utils/instanceOf/instanceOfCollection'; import { defaultRequestOptions } from '../http/defaultRequestOptions'; import { RequestHeaders } from './requestHeaders'; import { cloneDetached } from './cloneDetached'; const log = anylogger('TrackedRepresentationFactory'); export class TrackedRepresentationFactory { /** * Creates (POST) a representation in the context of a resource. The resulting representation from the Location header * is hydrated and returned. * * Note: a 201 returns a location whereas the 200 and 202 do not and undef * * @param resource context in which a resource is created * @param document content of the representation * @param options * @returns a 201 returns a representation whereas the 200 and 202 return undefined * @throws AxiosError */ static create(resource, document, options) { return __awaiter(this, void 0, void 0, function* () { const { rel = LinkRelation.Self, getUri = LinkUtil.getUri, throwOnCreateError = defaultRequestOptions.throwOnCreateError, } = Object.assign({}, options); const uri = getUri(resource, rel); log.debug('tracked representation create: start'); if (uri) { try { const response = yield HttpRequestFactory.Instance().create(resource, document, options); if (response) { const { headers = {}, status } = response; if (!headers) { // realistically only for tests log.error('response does not like an http request'); } // create 201 should have a header and be populated if (status === 201 || !status) { if (!status) { // cater for tests not return status headers log.warn('server not returning status code'); } const uri = headers.location; if (uri) { // TODO: decide on pluggable hydration strategy const hydrated = yield this.load(SparseRepresentationFactory.make(Object.assign(Object.assign({}, options), { uri })), Object.assign(Object.assign({}, options), { rel: LinkRelation.Self })); log.debug('tracked representation created and loaded %s', uri); return hydrated; } else { log.error('create: response no Location header for \'%s\'', uri); // fall through to undefined } } else { // other response codes (200, 202) should be dealt with separately // see https://stackoverflow.com/a/29096228 log.warn('response returned %s, no resource processed', status); // fall through to undefined } } else { log.error('response not found on http request'); } } catch (e) { if (isHttpRequestError(e)) { // errors don't get attached back on the context resource, just log them log.warn(`Request error returning undefined: '${e.message}'}`); // fall through to undefined } if (throwOnCreateError) { throw e; } } } else { return Promise.reject(new Error('create tracked representation has no context to find uri to POST on')); } return undefined; }); } static del(resource, options) { return __awaiter(this, void 0, void 0, function* () { if (instanceOfTrackedRepresentation(resource)) { const { rel = LinkRelation.Self, getUri = LinkUtil.getUri, } = Object.assign({}, options); const uri = getUri(resource, rel); // check uri exists is useful because it picks up early errors in the understanding (or implementation) // of the API. It also keeps error out of the loading code below where it would fail too but is harder // to diagnose. if (uri) { const trackedState = TrackedRepresentationUtil.getState(resource); switch (trackedState.status) { case Status.virtual: log.info('Resource is client-side only and will not be deleted %s %s', uri, trackedState.status.toString()); return resource; case Status.deleted: case Status.deleteInProgress: return Promise.reject(new Error(`Resource 'deleted' unable to delete '${uri}'`)); case Status.forbidden: // TODO: enhance forbidden strategy as needed currently assumes forbidden access doesn't change per session log.info('Resource is already forbidden and will not be deleted %s', uri); return resource; } try { trackedState.previousStatus = trackedState.status; trackedState.status = Status.deleteInProgress; // when was it retrieved - for later queries const response = yield HttpRequestFactory.Instance().del(resource, options); trackedState.status = Status.deleted; // mutate the original resource headers // how was it retrieved trackedState.headers = this.mergeHeaders(trackedState.headers, response.headers); // save the across-the-wire metadata, so we can check for collisions/staleness trackedState.retrieved = new Date(); return resource; } catch (e) { if (isHttpRequestError(e)) { this.processError(e, uri, resource, trackedState); } } } else { log.error('undefined returned on link \'%s\' (check stack trace)', rel); } } else { // TODO: decide if we want to make a locationOnly resource if possible and then continue return Promise.reject(new Error(`delete tracked representation has no state on '${LinkUtil.getUri(resource, LinkRelation.Self)}'`)); } return resource; }); } /** * * @throws */ static update(resource, document, options) { return __awaiter(this, void 0, void 0, function* () { if (instanceOfTrackedRepresentation(resource)) { const { rel = LinkRelation.Self, getUri = LinkUtil.getUri, throwOnUpdateError = defaultRequestOptions.throwOnUpdateError, } = Object.assign({}, options); const uri = getUri(resource, rel); // check uri exists is useful because it picks up early errors in the understanding (or implementation) // of the API. It also keeps error out of the loading code below where it would fail too but is harder // to diagnose. if (uri) { const trackedState = TrackedRepresentationUtil.getState(resource); try { const response = yield HttpRequestFactory.Instance().update(resource, document, options); // mutate the original resource headers // how was it retrieved trackedState.headers = this.mergeHeaders(trackedState.headers, response.headers); // save the across-the-wire metadata, so we can check for collisions/staleness trackedState.previousStatus = trackedState.status; trackedState.status = Status.hydrated; // when was it retrieved - for later queries trackedState.retrieved = new Date(); return yield this.processResource(resource, document, options); } catch (e) { // TODO: add options error type detection factory if (isHttpRequestError(e)) { this.processError(e, uri, resource, trackedState); } if (throwOnUpdateError) { throw e; } } } else { log.error(`No link rel found for '${rel}'`); } return resource; } else { return Promise.reject(new Error(`update tracked representation has no state on '${LinkUtil.getUri(resource, LinkRelation.Self)}'`)); } }); } /** * Processes all the hydration rules of the {@link LinkedRepresentation} of whether or not a resource a should * be fetched based on its state and http headers. * * Its responsibility is to deal with the tracking of the representation. * * TODO: load would ideally NOT come in on a TrackedRepresentation but rather a LinkedRepresentation only * * @param resource existing resource * @param options */ static load(resource, options) { return __awaiter(this, void 0, void 0, function* () { if (instanceOfTrackedRepresentation(resource)) { const { rel = LinkRelation.Self, getUri = LinkUtil.getUri, includeItems = false, refreshStaleItems = true, throwOnLoadError = defaultRequestOptions.throwOnLoadError, trackResponse = true, trackResponseStrategies = TrackedRepresentationFactory.defaultResponseStrategies, } = Object.assign({}, options); const uri = getUri(resource, rel); // check uri exists is useful because it picks up early errors in the understanding (or implementation) // of the API. It also keeps error out of the loading code below where it would fail too but is harder // to diagnose. if (uri) { const trackedState = TrackedRepresentationUtil.getState(resource); switch (trackedState.status) { case Status.virtual: log.info('Resource is client-side only and will not be fetched %s %s', uri, trackedState.status.toString()); return resource; case Status.deleted: log.info('Resource is already deleted and will not be fetched %s', uri); return resource; case Status.deleteInProgress: log.info('Resource is being deleted and will not be fetched %s', uri); return resource; case Status.forbidden: // TODO: enhance forbidden strategy as needed currently assumes forbidden access doesn't change per session log.info('Resource is already forbidden and will not be fetched %s', uri); return resource; } // check if load due to cache expiry is required. if (TrackedRepresentationUtil.needsFetchFromState(resource, options) || TrackedRepresentationUtil.needsFetchFromHeaders(resource, options)) { try { const loadResource = (options) => __awaiter(this, void 0, void 0, function* () { const _a = Object.assign({}, options), { rel = LinkRelation.Self, requestHeadersStrategies = RequestHeaders.defaultStrategies } = _a, opts = __rest(_a, ["rel", "requestHeadersStrategies"]); const requestHeaders = requestHeadersStrategies.reduce((acc, curr) => (Object.assign(Object.assign({}, acc), (curr(resource, opts)))), RequestHeaders.emptyHeaders); return yield HttpRequestFactory .Instance() .load(resource, rel, Object.assign(Object.assign({}, options), { headers: requestHeaders })); }); let response = yield loadResource(options); // mutate the original resource headers // how was it retrieved trackedState.headers = this.mergeHeaders(trackedState.headers, response.headers); // retry strategy if the eTags aren't matching if (TrackedRepresentationUtil.hasFeedETag(resource)) { if (TrackedRepresentationUtil.hasStaleFeedETag(resource)) { // feed item is out of date and need to do extra request TrackedRepresentationUtil.setStateStaleFromETag(resource); // retry with strategy (ie no cache) log.debug('ETags do not match: load again'); response = yield loadResource(options); trackedState.headers = this.mergeHeaders(trackedState.headers, response.headers); } // clear the eTags // TrackedRepresentationUtil.setFeedETag(resource); } // save the across-the-wire metadata, so we can check for collisions/staleness trackedState.previousStatus = trackedState.status; trackedState.status = Status.hydrated; // when was it retrieved - for later queries trackedState.retrieved = new Date(); for (const strategy of trackResponseStrategies) { trackedState.representation = strategy(response, trackResponse); } return yield this.processResource(resource, response.data, options); } catch (e) { if (isHttpRequestError(e)) { this.processError(e, uri, resource, trackedState); } if (throwOnLoadError) { throw e; } } } else { if (instanceOfCollection(resource)) { // If the resource is a collection, then if the collection has been loaded (hydrated), and // the caller has requested the items be loaded, then iterate over the individual items // to check they are loaded. if (includeItems) { yield this.processCollectionItems(resource, options); } else if (refreshStaleItems) { // otherwise, walk through the collection and ensure stale items are refreshed yield this.processStaleCollectionItems(resource, options); } } } } else { log.error('undefined returned on link \'%s\' (check stack trace)', rel); } } else { const uri = LinkUtil.getUri(resource, LinkRelation.Self); if (uri) { log.debug('tracked representation created: unknown on \'%s\'', uri); const unknown = SparseRepresentationFactory.make(Object.assign(Object.assign({}, options), { addStateOn: resource, status: Status.unknown })); return yield TrackedRepresentationFactory.load(unknown, options); } else { log.error('load tracked representation has no processable uri'); } } return resource; }); } /** * Removes the item from the collection by matching its Self link. If not found, it returns undefined. * If an items is removed from a collection, it is marked as 'stale' */ static removeCollectionItem(collection, item) { const itemFromCollection = RepresentationUtil.removeItemFromCollection(collection, item); if (instanceOfTrackedRepresentation(itemFromCollection)) { const trackedState = TrackedRepresentationUtil.getState(itemFromCollection); trackedState.previousStatus = trackedState.status; trackedState.status = Status.stale; return itemFromCollection; } return undefined; } static mergeHeaders(trackedHeaders, responseHeaders) { const { 'Etag': eTag = {}, } = Object.assign({}, trackedHeaders); // note: etag may have been added also at the application level of the feed // so retain but override if existing return Object.assign(Object.assign({}, eTag), responseHeaders); } /** * Updates the state object based on the error * * TODO: add client status errors to state for surfacing field validations errors * - this will require an error processing factory given most system * present these errors differently * * TODO: add onErrorHandling strategy (eg throw or quiet) */ static processError(e, uri, resource, trackedState) { const { response } = e; if (response) { if (response.status === 403) { log.debug(`Request forbidden ${response.status} ${response.statusText} '${uri}'`); // save the across-the-wire metadata, so we can check for collisions/staleness trackedState.status = Status.forbidden; // when was it retrieved trackedState.retrieved = new Date(); // how was it retrieved trackedState.headers = this.mergeHeaders(trackedState.headers, response.headers); /** * On a forbidden resource we are going to let the decision of what to do with * it lie at the application level. So we'll set the state and return the * resource. This means that the application needs to check if it is {@link Status.forbidden} * and decide whether to remove (from say the set, or in the UI dim the item). */ trackedState.error = e; } else if (response.status === 404) { const message = `Likely stale collection for '${LinkUtil.getUri(resource, LinkRelation.Self)}' on resource ${uri}`; log.info(message); trackedState.status = Status.deleted; // TODO: this should return a Promise.reject for it to be dealt with } else if (response.status >= 400 && response.status < 499) { log.info(`Client error '${response.statusText}' on resource ${uri}`); trackedState.status = Status.unknown; trackedState.error = e; } else if (response.status >= 500 && response.status < 599) { log.info(`Server error '${response.statusText}' on resource ${uri}`); trackedState.status = Status.unknown; trackedState.error = e; } else { log.error(`Request error: '${e.message}'}`); log.debug(e.stack); trackedState.status = Status.unknown; trackedState.error = e; /** * We really don't know what is happening here. But allow the application * to continue. */ } } } static processResource(resource, data, options) { return __awaiter(this, void 0, void 0, function* () { if (instanceOfFeed(data)) { return yield this.processCollection(resource, data, options); } else { return yield this.processSingleton(resource, data, options); } }); } /** * Ensures the in-memory collection resource and its items are up-to-date with the server with * the number of items matching and all items at least sparsely populated. Use 'includeItems' flag * to fully hydrate each item. */ static processCollection(resource, data, options) { return __awaiter(this, void 0, void 0, function* () { const { rel = LinkRelation.Self, includeItems, refreshStaleItems = true, } = Object.assign({}, options); const uri = LinkUtil.getUri(resource, rel); if (!uri) { throw new Error('no uri found'); } // ensure that all the items are sparsely populated const fromFeed = SparseRepresentationFactory.make(Object.assign(Object.assign({}, options), { uri, sparseType: 'collection', addStateOn: data })); // merge the existing and the response such that // - any in existing but not in response are removed from existing // - any in response but not in existing are added // the result is to reduce network retrieval because existing hydrated items are not response resource = CollectionMerger.merge(resource, fromFeed, Object.assign(Object.assign({}, options), { mergeHeaders: false })); // hydrate items is required (also, merged hydrated items won't go back across the network // unless there is a {@link forceLoad} // disable force load on collections items for items only when {@forceLoadFeedOnly} is set if (includeItems) { yield this.processCollectionItems(resource, options); } else if (refreshStaleItems) { // otherwise, walk through the collection and ensure stale items are refreshed yield this.processStaleCollectionItems(resource, options); } // now merge the collection (attributes) (and do so with observers to trigger) // return SingletonMerger.merge(resource2, representation, options); return resource; }); } static processStaleCollectionItems(resource, options) { return __awaiter(this, void 0, void 0, function* () { const { batchSize = 1 } = Object.assign({}, options); /** * Iterating over the resource(s) and use the options for the iterator. The batch size * indicates at this stage whether the queries or sequential or parallel (ie batch size is a bit misleading * because in practice batch size is either one (sequential) or all (parallel). This can be extended when needed. */ const waitAll = (batchSize > 0) ? parallelWaitAll : sequentialWaitAll; yield waitAll(resource, (item) => __awaiter(this, void 0, void 0, function* () { if (instanceOfTrackedRepresentation(item) && TrackedRepresentationUtil.hasStaleFeedETag(item)) { yield this.load(item, Object.assign(Object.assign({}, options), { rel: LinkRelation.Self })); } })); }); } static processCollectionItems(resource, options) { return __awaiter(this, void 0, void 0, function* () { const { forceLoad, forceLoadFeedOnly, batchSize = 1 } = Object.assign({}, options); if (forceLoad && forceLoadFeedOnly) { options = Object.assign(Object.assign({}, options), { forceLoad: false }); } /** * Iterating over the resource(s) and use the options for the iterator. The batch size * indicates at this stage whether the queries or sequential or parallel (ie batch size is a bit misleading * because in practice batch size is either one (sequential) or all (parallel). This can be extended when needed. */ const waitAll = (batchSize > 0) ? parallelWaitAll : sequentialWaitAll; options = Object.assign(Object.assign({}, options), { rel: LinkRelation.Self }); yield waitAll(resource, (item) => __awaiter(this, void 0, void 0, function* () { yield this.load(item, options); })); }); } /** * Ensures the in-memory resource is up-to-date with the server. Synchronising needs to * occur within the context of this {@link State} object so that {@link State.status} flag of * the to-be-retrieved resource is in context. * * Note: singleton also processes a form and may need to be separated for deep merge of items */ static processSingleton(resource, representation, options) { return __awaiter(this, void 0, void 0, function* () { return SingletonMerger.merge(resource, representation, options); }); } } TrackedRepresentationFactory.defaultResponseStrategies = [ /** * Strategy one allows for putting the original response data on to the state object. Can be turned * off by setting {@link ResourceFetchOptions.trackResponse} to false */ (axiosResponse, trackResponse) => { if (trackResponse) { return cloneDetached(axiosResponse.data); } } ]; //# sourceMappingURL=trackedRepresentationFactory.js.map