ketting
Version:
Opinionated HATEOAS / Rest client.
354 lines • 11.1 kB
JavaScript
import { headStateFactory, isState } from './state/index.js';
import { resolve } from './util/uri.js';
import { FollowPromiseOne, FollowPromiseMany } from './follow-promise.js';
import { LinkNotFound } from './link.js';
import { EventEmitter } from '#events';
import { needsJsonStringify } from '#fetch-body-helper';
/**
* A 'resource' represents an endpoint on a server.
*
* A resource has a uri, methods that correspond to HTTP methods,
* and events to subscribe to state changes.
*/
// eslint-disable-next-line @typescript-eslint/no-unsafe-declaration-merging
export class Resource extends EventEmitter {
/**
* URI of the current resource
*/
uri;
/**
* Reference to the Client that created the resource
*/
client;
/**
* This object tracks all in-flight requests.
*
* When 2 identical requests are made in quick succession, this object is
* used to de-duplicate the requests.
*/
activeRefresh = new Map();
/**
* Create the resource.
*
* This is usually done by the Client.
*/
constructor(client, uri) {
super();
this.client = client;
this.uri = uri;
this.setMaxListeners(500);
}
/**
* Gets the current state of the resource.
*
* This function will return a State object.
*/
get(getOptions) {
const state = this.getCache();
if (state) {
return Promise.resolve(state);
}
const params = optionsToRequestInit('GET', getOptions);
const uri = this.uri;
const hash = requestHash(this.uri, getOptions);
if (!this.activeRefresh.has(hash)) {
this.activeRefresh.set(hash, (async () => {
try {
const response = await this.fetchOrThrow(params);
const state = await this.client.getStateForResponse(uri, response);
this.updateCache(state);
return state;
}
finally {
this.activeRefresh.delete(hash);
}
})());
}
return this.activeRefresh.get(hash);
}
/**
* Does a HEAD request and returns a HeadState object.
*
* If there was a valid existing cache for a GET request, it will
* still return that.
*/
async head(headOptions) {
let state = this.client.cache.get(this.uri);
if (state) {
return state;
}
const response = await this.fetchOrThrow(optionsToRequestInit('HEAD', headOptions));
state = await headStateFactory(this.client, this.uri, response);
return state;
}
/**
* Gets the current state of the resource, skipping
* the cache.
*
* This function will return a State object.
*/
refresh(getOptions) {
const params = optionsToRequestInit('GET', getOptions);
params.cache = 'no-cache';
const uri = this.uri;
const hash = requestHash(this.uri, getOptions);
if (!this.activeRefresh.has(hash)) {
this.activeRefresh.set(hash, (async () => {
try {
const response = await this.fetchOrThrow(params);
const state = await this.client.getStateForResponse(uri, response);
this.updateCache(state);
return state;
}
finally {
this.activeRefresh.delete(hash);
}
})());
}
return this.activeRefresh.get(hash);
}
/**
* Updates the server state with a PUT request
*/
async put(options) {
const requestInit = optionsToRequestInit('PUT', options);
/**
* If we got a 'State' object passed, it means we don't need to emit a
* stale event, as the passed object is the new
* state.
*
* We're gonna track that with a custom header that will be removed
* later in the fetch pipeline.
*/
if (isState(options)) {
requestInit.headers.set('X-KETTING-NO-STALE', '1');
}
await this.fetchOrThrow(requestInit);
if (isState(options)) {
this.updateCache(options);
}
}
/**
* Deletes the resource
*/
async delete() {
await this.fetchOrThrow({ method: 'DELETE' });
}
/**
* Sends a POST request to the resource.
*
* See the documentation for PostRequestOptions for more details.
* This function is used for RPC-like endpoints and form submissions.
*
* This function will return the response as a State object.
*/
async post(options) {
const response = await this.fetchOrThrow(optionsToRequestInit('POST', options));
return this.client.getStateForResponse(this.uri, response);
}
/**
* Sends a POST request, and follows to the next resource.
*
* If a server responds with a 201 Status code and a Location header,
* it will automatically return the newly created resource.
*
* If the server responded with a 204 or 205, this function will return
* `this`.
*/
async postFollow(options) {
const response = await this.fetchOrThrow(optionsToRequestInit('POST', options));
switch (response.status) {
case 201:
if (response.headers.has('location')) {
return this.go(response.headers.get('location'));
}
throw new Error('Could not follow after a 201 request, because the server did not reply with a Location header. If you sent a Location header, check if your service is returning "Access-Control-Expose-Headers: Location".');
case 204:
case 205:
return this;
default:
throw new Error('Did not receive a 201, 204 or 205 status code so we could not follow to the next resource');
}
}
/**
* Sends a PATCH request to the resource.
*
* This function defaults to a application/json content-type header.
*
* If the server responds with 200 Status code this will return a State object
*/
async patch(options) {
const response = await this.fetchOrThrow(optionsToRequestInit('PATCH', options));
if (response.status === 200) {
return await this.client.getStateForResponse(this.uri, response);
}
}
/**
* Follows a relationship, based on its reltype. For example, this might be
* 'alternate', 'item', 'edit' or a custom url-based one.
*
* This function can also follow templated uris. You can specify uri
* variables in the optional variables argument.
*/
follow(rel, variables) {
return new FollowPromiseOne(this, rel, variables);
}
/**
* Follows a relationship based on its reltype. This function returns a
* Promise that resolves to an array of Resource objects.
*
* If no resources were found, the array will be empty.
*/
followAll(rel) {
return new FollowPromiseMany(this, rel);
}
/**
* Resolves a new resource based on a relative uri.
*
* Use this function to manually get a Resource object via a uri. The uri
* will be resolved based on the uri of the current resource.
*
* This function doesn't do any HTTP requests.
*/
go(uri) {
if (typeof uri === 'string') {
return this.client.go(resolve(this.uri, uri));
}
else {
return this.client.go(uri);
}
}
/**
* Does a HTTP request on the current resource URI
*/
fetch(init) {
return this.client.fetcher.fetch(this.uri, init);
}
/**
* Does a HTTP request on the current resource URI.
*
* If the response was a 4XX or 5XX, this function will throw
* an exception.
*/
fetchOrThrow(init) {
return this.client.fetcher.fetchOrThrow(this.uri, init);
}
/**
* Updates the state cache, and emits events.
*
* This will update the local state but *not* update the server
*/
updateCache(state) {
if (state.uri !== this.uri) {
throw new Error('When calling updateCache on a resource, the uri of the State object must match the uri of the Resource');
}
this.client.cacheState(state);
}
/**
* Clears the state cache for this resource.
*/
clearCache() {
this.client.clearResourceCache([this.uri], []);
}
/**
* Retrieves the current cached resource state, and return `null` if it's
* not available.
*/
getCache() {
return this.client.cache.get(this.uri);
}
/**
* Returns a Link object, by its REL.
*
* If the link does not exist, a LinkNotFound error will be thrown.
*
* @deprecated
*/
async link(rel) {
const state = await this.get();
const link = state.links.get(rel);
if (!link) {
throw new LinkNotFound(`Link with rel: ${rel} not found on ${this.uri}`);
}
return link;
}
/**
* Returns all links defined on this object.
*
* @deprecated
*/
async links(rel) {
const state = await this.get();
if (!rel) {
return state.links.getAll();
}
else {
return state.links.getMany(rel);
}
}
/**
*
* Returns true or false depending on if a link with the specified relation
* type exists.
*
* @deprecated
*/
async hasLink(rel) {
const state = await this.get();
return state.links.has(rel);
}
}
export default Resource;
function optionsToRequestInit(method, options) {
if (!options) {
return {
method,
headers: new Headers(),
};
}
let headers;
if (options.getContentHeaders) {
headers = new Headers(options.getContentHeaders());
}
else if (options.headers) {
headers = new Headers(options.headers);
}
else {
headers = new Headers();
}
if (!headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
let body;
if (options.serializeBody !== undefined) {
body = options.serializeBody();
}
else if (options.data) {
body = options.data;
if (needsJsonStringify(body)) {
body = JSON.stringify(body);
}
}
else {
body = null;
}
return {
method,
body,
headers,
};
}
function requestHash(uri, options) {
const headers = {};
if (options) {
new Headers(options.getContentHeaders?.() || options.headers)
.forEach((value, key) => {
headers[key] = value;
});
}
const headerStr = Object.entries(headers).map(([name, value]) => {
return name.toLowerCase() + ':' + value;
}).join(',');
return uri + '|' + headerStr;
}
//# sourceMappingURL=resource.js.map