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
JavaScript
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