import Query from './query';
import ResourceCollection from './resource_collection';
import Resource from './resource';
/**
* ResourceFulEndpoint is the main interaction class against sequoia MDS endpoints.
* This is what you'll be using to present data to users, for example content items, registered users etc.
*
* This class should not be used directly, but should instead be obtained
* from {@link ServiceDescriptor#resourcefulEndpoint}
*
* @param {Transport} transport - Transport instance to use for fetching
* @param {Object} resourceful - JSON object describing this resourceful endpoint
* (fetched from sequoia services 'descriptor')
*
* @example
* // `contents` is used in the rest of the examples as our reference
* // to a ResourcefulEndpoint
* let contents;
*
* client.login('username', 'password').then(session => {
* client.service('metadata').then(service => {
* // Get a resourceful endpoint (this is synchronous as the service passed
* // all the necessary data):
* contents = service.resourcefulEndpoint('contents');
* // whatever
* });
* });
*
* @private
*/
class ResourcefulEndpoint {
/**
* @param {Object} resourceful - the Sequoia descriptor part that describes this endpoint
*
* @returns {ResourcefulEndpoint}
*/
constructor(transport, resourceful) {
this.transport = transport;
this.resourceful = resourceful;
}
get owner() {
if (this.resourceful) {
return this.resourceful.tenant;
}
return undefined;
}
/**
* Turn the first item in the Sequoia response for this endpoint
* into a {@link Resource}. This is a convenience for when operating
* on individual Resources (read, update, store) where the sequoia response
* is of the form
* ```
* {
* <pluralName>: [<Resource>],
* meta: { ... }
* }
* ```
*
* and we want to just operate on `Resource`
*
* @private
*
* @returns {Resource}
*/
responseToResource(json) {
const resource = json[this.pluralName][0];
resource.linked = json.linked;
return new Resource(resource, this);
}
/**
* Obtain a new {@link Resource} for this endpoint.
* A *new* resource is something that has not yet been created remotely.
*
* The `owner` property will be pre-populated with the current tenancy. Provding a
* a value for this will override the current tenancy. This is useful when using
* root tenancy but populating data on behalf of non-root tenancies.
* See {@link Client#setTenancy} for more information.
*
* When a `name` property is provided, the `ref` (unique id) of this resource will
* also be populated. This is useful for allowing linking different kinds of resources
* together (see {@link Resource#link}) before saving the resources.
*
* There is a potential that `name`s you choose will conflict with already stored resources.
* In this case, your changes will override the remote resource. See the below example
* for how to handle these situations.
*
* @param {Object} data -data to populate the {@link Resource} with
* @param {string=} data.owner -Defaults to the current tenancy name
*
* @example <caption>Create a new resource</caption>
*
* const contentItem = contents.newResource({
* name: 'my-new-content',
* title: 'something',
* synopsis: 'a really long synopsis'
* });
*
* // You can now call methods on it, for example, a usual flow for creating
* // a Resource would be:
*
* contentItem.validate()
* .then(() => contentItem.save())
* .then(() => {
* // Success, do something (redirect to a new view?)
* }).catch((error) => {
* // Validation error (from Resource object or the server), show the user what went wrong
* });
*
* @example <caption>Update or create a piece of content</caption>
*
* const potentiallyNewContentItem = contents.newResource({
* name: 'my-potentially-new-content',
* title: 'something new',
* synopsis: 'a really long synopsis'
* });
*
* function findOrCreateResource(resourceful, data) {
* return resourceful.readOne(`${resourceful.owner}:${data.name}`).catch(() => resourceful.newResource(data));
* }
*
* // You can now call methods on it, for example, a usual flow for creating
* // a Resource would be:
*
* findOrCreateResource(contents, potentiallyNewContentItem)
* .then((contentItem) => {
* // Update with new data in case it was an existing resource
* Object.assign(contentItem, potentiallyNewContentItem);
* contentItem.duration = 'PT75M';
*
* return contentItem.save())
* .then(() => {
* // Success, do something (redirect to a new view?)
* }).catch((error) => {
* // Validation error (from the server), show the user what went wrong
* });
*
* @returns {Resource}
*/
newResource(data = { owner: this.owner }) {
data.owner = data.owner || this.owner;
if (data.ref === undefined && data.owner && data.name) {
data.ref = `${data.owner}:${data.name}`;
}
return new Resource(Object.assign({ is_new: true }, data), this);
}
/**
* Create a new {@link ResourceCollection} for this endpoint.
*
* Useful for creating many resources for ingest.
*
* @param {Array<Object>} data -data to populate the {@link ResourceCollection} with
*
* @example <caption>Create a new resource</caption>
*
* const contentItems = contents.newResourceCollection([{
* name: 'one',
* title: 'something',
* synopsis: 'a really long synopsis'
* },
* {
* name: 'two',
* title: 'something else',
* synopsis: 'another really long synopsis'
* }]);
*
* contentItems.validate()
* .then(() => contentItems.save())
* .then(() => {
* // Success, do something (redirect to a new view?)
* }).catch((error) => {
* // Validation error (from a nested Resource object or the server), show the user what went wrong
* });
*
* @returns {ResourceCollection}
*/
newResourceCollection(data = []) {
const { pluralName } = this.resourceful;
let collectionData;
if (Array.isArray(data)) {
collectionData = {
[pluralName]: data
};
} else {
collectionData = data;
console.warn(
'Using newResourceCollection with an object is deprecated - pass an array instead'
);
}
return new ResourceCollection(collectionData, '', this);
}
/**
* Perform a browse (a GET for all items on this resourceful endpoint, with
* optional `criteria`). The collection returned is the first page of results as
* specified by `perPage` (or the Resourceful default e.g. 100)
*
* @param {(string|Query)} criteria - A query string to append to the request
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Trasport#get}
*
* @returns {Promise} - A {@link ResourceCollection}
*/
browse(criteria, options) {
const query = (criteria || '').hasOwnProperty('query')
? criteria
: new Query(criteria);
query.addRelatedThroughFields(
this.resourceful.relationships || {},
Object.keys(this.resourceful.fields || [])
);
return this.transport
.get(this.endPointUrl(null, query, options))
.then(json => new ResourceCollection(json, criteria, this));
}
/**
* The same as {@link ResourcefulEndpoint#browse} but will fetch *all* pages as a single
* {@link ResourceCollection}
*
* @param {(string|Query)} criteria - A query string to append to the request
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Transport#get}
*
* @returns {Promise} - A {@link ResourceCollection}
*/
async all(criteria, options) {
const { pluralName } = this.resourceful;
const rawData = {
meta: {},
[pluralName]: []
};
// This is to avoid memory issues caused by recursion of Promises
// See https://alexn.org/blog/2017/10/11/javascript-promise-leaks-memory.html
// And https://github.com/promises-aplus/promises-spec/issues/179
let collection = await this.browse(criteria, options);
let hasNext = true;
while (hasNext) {
hasNext = collection.hasNextPage();
// Things rely on rawData, not the derived collections
rawData[pluralName].push(...collection.rawData[pluralName]);
if (collection.rawData.linked) {
Object.entries(collection.rawData.linked).forEach(([key, value]) => {
if (!rawData.linked) rawData.linked = {};
if (!rawData.linked[key]) rawData.linked[key] = [];
rawData.linked[key].push(...value);
});
}
if (hasNext) {
// eslint-disable-next-line no-await-in-loop
collection = await collection.nextPage();
}
}
rawData.meta.totalCount = rawData[pluralName].length;
return new ResourceCollection(rawData, criteria, this);
}
/**
* Fetch (performs an HTTP GET on) an individual item, with optional criteria
*
* @param {string} ref - The unique `reference` for this item
* @param {(string|Query)} criteria - A query string to append to the request
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Transport#get}
*
* @returns {Promise} - A {@link Resource}
*/
readOne(ref, criteria, options) {
return this.transport
.get(this.endPointUrl(ref, criteria), options)
.then(json => this.responseToResource(json));
}
/**
* Fetch (performs an HTTP GET on) many items, with optional criteria
*
* @param {Array<String>} refs - The unique `references` for the items
* @param {(string|Query)} criteria - A query string to append to the request
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Transport#get}
*
* @returns {Promise} - A {@link ResourceCollection}
*/
readMany(refs, criteria, options) {
return this.transport
.get(this.endPointUrl(refs.join(','), criteria), options)
.then(json => new ResourceCollection(json, criteria, this));
}
/**
* Store (perform an HTTP POST for) a new item
*
* @param {Resource} resource - A {@link Resource} corresponding to the new resource you wish to save
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Transport#post}
*
* @todo Validate against the descriptor that this is a valid item to store
*
* @returns {Promise}
*/
store(resource, options) {
return this.transport
.post(
this.endPointUrl(),
Object.assign({ body: resource.serialise() }, options)
)
.then(json => this.responseToResource(json));
}
/**
* Update (perform an HTTP PUT for) an existing item
*
* @param {Object} resource - An Object corresponding to the new resouce you wish to save
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Transport#put}
*
* @todo Validate against the descriptor that this is a valid item to store
*
* @returns {Promise}
*/
update(resource, options) {
return this.transport
.put(
this.endPointUrl(resource.ref),
Object.assign({ body: resource.serialise() }, options)
)
.then(json => this.responseToResource(json));
}
/**
* Destroy (perform an HTTP DELETE for) an existing item
*
* @param {Object} resource - An Object corresponding to the resouce you wish to delete
* @param {object} options - [fetch options]{@link https://github.github.io/fetch/#options}
*
* @see {@link Transport#destroy}
*
* @returns {Promise}
*/
destroy(resource, options) {
let { ref } = resource;
if (typeof resource === 'string') {
ref = resource;
}
return this.transport.destroy(this.endPointUrl(ref), options);
}
/**
* Get the relationship info for `relationshipName` from the descriptor
*
* @param {string} relationshipName - The name of the relationship e.g. 'assets'
*
* @throws {Error} - Throws when the relationship doesn't exist
*
* @example
*
* contents.relationshipFor('categories');
*
* // Returns:
*
* {
* "description": "The associated categories",
* "type": "direct",
* "resourceType": "categories",
* "fieldNamePath": "categoryRefs",
* "fields": [
* "ref",
* "title",
* "parentRef",
* "scheme",
* "value"
* ],
* "name": "categories",
* "batchSize": 10
* }
*
* @returns {Object}
*/
relationshipFor(relationshipName) {
const [name] = Object.entries(this.relationships).find(
([, relationship]) => relationship.resourceType === relationshipName
) || [undefined];
if (name) {
return this.relationships[name];
}
throw new Error(`Relationship '${relationshipName}' does not exist`);
}
/**
* Get the relationship info from the descriptor.
*
* @example
*
* contents.relationships
*
* // Returns:
*
* {
* "children": {
* "description": "The associated child contents",
* "type": "indirect",
* "resourceType": "contents",
* "filterName": "withParentRef",
* "fields": [
* "ref",
* "title",
* "parentRef",
* "type"
* ],
* "batchSize": 10,
* "name": "children"
* },
* "assets": {
* "description": "The associated assets",
* "type": "indirect",
* "resourceType": "assets",
* "filterName": "withContentRef",
* "fields": [
* "ref",
* "name",
* "contentRef",
* "type",
* "url",
* "fileFormat",
* "title",
* "fileSize",
* "tags"
* ],
* "batchSize": 10,
* "name": "assets"
* }
* }
*
* @type {object}
*/
get relationships() {
return this.resourceful.relationships;
}
/**
* Get the `pluralName` from the descriptor. The plural name is used to identify
* the content array from the JSON response from Sequoia. e.g. for `assets`, the
* response will be of the form:
*
* ```json
* {
* assets: [ asset resource, asset resource ... ],
* linked: [ linked resource, linked resource ... ],
* meta: { ...meta info }
* }
* ```
* @type {string}
*/
get pluralName() {
return this.resourceful.pluralName;
}
/**
* Get the `singularName` from the descriptor. The singular name is used to identify
* what the specific resourceful resource is identified as.
*
* @type {string}
*/
get singularName() {
return this.resourceful.singularName;
}
/**
* Get the `hyphenatedPluralName` from the descriptor. The hyphenated plural name
* is used to identify what the resourceful endpoint is called. e.g. a camelCased
* resource (`pluralName`) like `contentSegments` will live under an HTTP endpoint
* of `content-segments`
*
* @type {string}
*/
get hyphenatedPluralName() {
return this.resourceful.hyphenatedPluralName;
}
/**
* Get the `serviceName` from the descriptor.
*
* @type {string}
*/
get serviceName() {
return this.resourceful.serviceName;
}
/**
* Get the `batchSize` from the descriptor's `storeBatch` operation.
* This is how many resources can be saved at once when saving a {@link ResourceCollection}
*
* @type {number}
*/
get batchSize() {
const defaultBatchSize = 100;
const { operations } = this.resourceful;
if (operations && operations.storeBatch) {
return operations.storeBatch.limit || defaultBatchSize;
}
return defaultBatchSize;
}
// Private methods
/**
* Return a query string to append to the HTTP call. Will default to appending
* `?owner=<owner>`
*
* @param {(string|Query)?} criteria - A (potential) query string to append to the request
*
* @private
*
* @returns {string}
*/
criteriaToQuery(criteria) {
let stringifiedCriteria = criteria || '';
if (criteria instanceof Query) {
stringifiedCriteria = criteria.toQueryString();
}
let query = `?${stringifiedCriteria}`;
const hasOwner = stringifiedCriteria.match(/&?owner=[^&]+/);
if (!hasOwner) {
query = stringifiedCriteria.length ? `?owner=${this.owner}&${stringifiedCriteria}` : `?owner=${this.owner}`;
}
return query;
}
/**
* Get the full URL to the resourceful endpoint/item we will send the request to
*
* @param {string?} ref - An optional unique ref for performing actions on a unique item (rather than browsing)
* @param {(string|Query)?} criteria - A (potential) query string to append to the request
*
* @see {@link ResourcefulEndpoint#criteriaToQuery}
*
* @private
*
* @returns {string}
*/
endPointUrl(ref, criteria) {
let resourceRef = '';
if (ref) {
resourceRef = `/${ref}`;
}
return `${this.resourceful.location}/${
this.resourceful.hyphenatedPluralName
}${resourceRef}${this.criteriaToQuery(criteria)}`;
}
}
export default ResourcefulEndpoint;