Mostly complete (read-only) compatibility with web library (#4270)

- Add pagination, limits, and Link header
- Add schema endpoints and dummy /settings endpoint
- Add /file endpoints
  - Browser security restrictions prevent the web library from actually loading
    the file: URIs that the local API returns, but out-of-browser use will work
    fine
- Add toResponseJSONAsync() DataObject function: delegates to toResponseJSON()
  by default, adds information that requires awaiting promises
  - Best attachment (links.attachment) and file size (links.enclosure.length)
    for items, meta.numItems for groups
  - Separate function for compatibility with the existing test code that uses
    toResponseJSON(), but we could consider unifying

This commit does not add the Access-Control headers that allow webpages to make
requests to the local API, since I don't think we actually want that.
This commit is contained in:
Abe Jellinek 2022-10-04 12:23:31 -04:00 committed by Dan Stillman
parent 44d9530ecf
commit 5d197e4b12
6 changed files with 351 additions and 45 deletions

View file

@ -1356,6 +1356,17 @@ Zotero.DataObject.prototype.toResponseJSON = function (options = {}) {
};
/**
* Subclasses can override to provide more information that requires awaiting promises.
* Delegates to {@link Zotero.DataObject#toResponseJSON} by default.
*
* @returns {Promise<Object>}
*/
Zotero.DataObject.prototype.toResponseJSONAsync = async function (options = {}) {
return this.toResponseJSON(options);
};
Zotero.DataObject.prototype._preToJSON = function (options) {
var env = { options };
env.mode = options.mode || 'new';

View file

@ -258,7 +258,6 @@ Zotero.Group.prototype.toResponseJSON = function (options = {}) {
meta: {
// created
// lastModified
// numItems
},
data: {
id: this.id,
@ -273,6 +272,15 @@ Zotero.Group.prototype.toResponseJSON = function (options = {}) {
}
};
Zotero.Group.prototype.toResponseJSONAsync = async function (options = {}) {
let json = this.toResponseJSON(options);
if (options.includeGroupDetails) {
json.meta.numItems = await Zotero.DB.valueQueryAsync(
"SELECT COUNT(*) FROM items WHERE libraryID = ?", this.libraryID);
}
return json;
};
Zotero.Group.prototype.fromJSON = function (json, userID) {
if (json.name !== undefined) this.name = json.name;
if (json.description !== undefined) this.description = json.description;

View file

@ -2345,7 +2345,7 @@ Zotero.Item.prototype.isAttachment = function() {
}
/**
* @return {Promise<Boolean>}
* @return {Boolean}
*/
Zotero.Item.prototype.isImportedAttachment = function() {
if (!this.isAttachment()) {
@ -2361,7 +2361,7 @@ Zotero.Item.prototype.isImportedAttachment = function() {
}
/**
* @return {Promise<Boolean>}
* @return {Boolean}
*/
Zotero.Item.prototype.isStoredFileAttachment = function() {
if (!this.isAttachment()) {
@ -2371,7 +2371,7 @@ Zotero.Item.prototype.isStoredFileAttachment = function() {
}
/**
* @return {Promise<Boolean>}
* @return {Boolean}
*/
Zotero.Item.prototype.isWebAttachment = function() {
if (!this.isAttachment()) {
@ -5492,13 +5492,17 @@ Zotero.Item.prototype.toResponseJSON = function (options = {}) {
// parsedDate
var parsedDate = Zotero.Date.multipartToSQL(this.getField('date', true, true));
if (parsedDate) {
// 0000?
// Trim off trailing -00 segments
parsedDate = parsedDate.replace(/(-00)+$/, '');
json.meta.parsedDate = parsedDate;
}
// numChildren
if (this.isRegularItem()) {
json.meta.numChildren = this.numChildren();
}
else {
json.meta.numChildren = false;
}
if (this.isImportedAttachment()) {
json.links.enclosure = {
@ -5512,6 +5516,25 @@ Zotero.Item.prototype.toResponseJSON = function (options = {}) {
};
Zotero.Item.prototype.toResponseJSONAsync = async function (options = {}) {
let json = this.toResponseJSON(options);
if (this.isRegularItem()) {
let bestAttachment = await this.getBestAttachment();
if (bestAttachment) {
json.links.attachment = {
href: Zotero.URI.toAPIURL(Zotero.URI.getItemURI(bestAttachment), options.apiURL),
type: 'application/json',
attachmentType: bestAttachment.attachmentContentType
};
}
}
else if (this.isImportedAttachment()) {
json.links.enclosure.length = await Zotero.Attachments.getTotalFileSize(this);
}
return json;
};
/**
* Migrate valid fields in Extra to real fields
*

View file

@ -30,32 +30,36 @@ Endpoints are accessible on the local server (localhost:23119 by default) under
Limitations compared to api.zotero.org:
- Only API version 3 (https://www.zotero.org/support/dev/web_api/v3/basics) is supported,
and only one API version will ever be supported at a time. If a new API version is released
and your client needs to maintain support for older versions, first query /api/ and read the
- Only API version 3 (https://www.zotero.org/support/dev/web_api/v3/basics) is supported, and only
one API version will ever be supported at a time. If a new API version is released and your
client needs to maintain support for older versions, first query /api/ and read the
Zotero-API-Version response header, then make requests conditionally.
- Write access is not yet supported.
- No authentication.
- No access to user data for users other than the local logged-in user. Use user ID 0
or the user's actual API user ID (https://www.zotero.org/settings/keys).
- No access to user data for users other than the local logged-in user. Use user ID 0 or the user's
actual API user ID (https://www.zotero.org/settings/keys).
- Minimal access to metadata about groups.
- Atom is not supported.
- Pagination and limits are not supported.
- If your code relies on any undefined behavior or especially unusual corner cases in the
web API, it'll probably work differently when using the local API. This implementation is
primarily concerned with matching the web API's spec and secondarily with matching its
observed behavior, but it does not make any attempt to replicate implementation details
that your code might rely on. Sort orders might differ, quicksearch results will probably
differ, and JSON you get from the local API is never going to be exactly identical to what
you would get from the web API.
- Item type/field endpoints (https://www.zotero.org/support/dev/web_api/v3/types_and_fields) will
return localized names in the user's locale. The locale query parameter is not supported. The
single exception is /api/creatorFields, which follows the web API's behavior in always returning
results in English, *not* the user's locale.
- If your code relies on any undefined behavior or especially unusual corner cases in the web API,
it'll probably work differently when using the local API. This implementation is primarily
concerned with matching the web API's spec and secondarily with matching its observed behavior,
but it does not make any attempt to replicate implementation details that your code might rely on.
Sort orders might differ, quicksearch results will probably differ, and JSON you get from the
local API is never going to be exactly identical to what you would get from the web API.
That said, there are benefits:
- No pagination is needed because the API doesn't mind sending you many megabytes of data
at a time - nothing ever touches the network.
- Pagination is often unnecessary because the API doesn't mind sending you many megabytes of data
at a time - nothing ever touches the network. For that reason, returned results are not limited
by default (unlike in the web API, which has a default limit of 25 and will not return more than
100 results at a time).
- For the same reason, no rate limits, and it's really fast.
- <userOrGroupPrefix>/searches/:searchKey/items returns the set of items matching a saved
search. The web API doesn't support actually executing searches.
- <userOrGroupPrefix>/searches/:searchKey/items returns the set of items matching a saved search
(unlike in the web API, which doesn't support actually executing searches).
*/
@ -90,7 +94,7 @@ class LocalAPIEndpoint {
);
// Only allow mismatched version on /api/ no-op endpoint
if (apiVersion !== LOCAL_API_VERSION && requestData.pathname != '/api/') {
return this.makeResponse(501, 'text/plain', `API version not implemented: ${parseInt(apiVersion)}`);
return this.makeResponse(501, 'text/plain', `API version not implemented: ${apiVersion}`);
}
let userID = requestData.pathParams.userID && parseInt(requestData.pathParams.userID);
@ -106,7 +110,8 @@ class LocalAPIEndpoint {
let response = await this.run(requestData);
if (response.data) {
if (requestData.searchParams.has('since')) {
let dataIsArray = Array.isArray(response.data);
if (dataIsArray && requestData.searchParams.has('since')) {
let since = parseInt(requestData.searchParams.get('since'));
if (Number.isNaN(since)) {
return this.makeResponse(400, 'text/plain', `Invalid 'since' value '${requestData.searchParams.get('since')}'`);
@ -114,7 +119,7 @@ class LocalAPIEndpoint {
response.data = response.data.filter(dataObject => dataObject.version > since);
}
if (Array.isArray(response.data) && response.data.length > 1) {
if (dataIsArray && response.data.length > 1) {
let sort = requestData.searchParams.get('sort') || 'dateModified';
if (!['dateAdded', 'dateModified', 'title', 'creator', 'itemType', 'date', 'publisher', 'publicationTitle', 'journalAbbreviation', 'language', 'accessDate', 'libraryCatalog', 'callNumber', 'rights', 'addedBy', 'numItems']
.includes(sort)) {
@ -160,7 +165,38 @@ class LocalAPIEndpoint {
});
}
return this.makeDataObjectResponse(requestData, response.data);
let totalResults = 1;
let links;
if (dataIsArray) {
totalResults = response.data.length;
let start = parseInt(requestData.searchParams.get('start')) || 0;
if (start < 0) start = 0;
if (start >= response.data.length) start = response.data.length;
let limit = parseInt(requestData.searchParams.get('limit')) || response.data.length - start;
if (limit < 0) limit = 0;
response.data = response.data.slice(start, start + limit);
links = this.buildLinks(requestData, start, limit, totalResults);
}
else {
links = this.buildLinks(requestData, 0, 0, 1);
}
let headers = {
'Total-Results': totalResults,
'Link': Object.entries(links).map(([rel, url]) => `<${url}>; rel="${rel}"`).join(', ')
};
let lastModifiedVersion = dataIsArray
? Zotero.Libraries.get(requestData.libraryID).libraryVersion
: response.data.version;
if (lastModifiedVersion !== undefined) {
headers['Last-Modified-Version'] = lastModifiedVersion;
}
if (requestData.headers['If-Modified-Since-Version']
&& lastModifiedVersion <= parseInt(requestData.headers['If-Modified-Since-Version'])) {
return this.makeResponse(304, headers, '');
}
return this.makeDataObjectResponse(requestData, response.data, headers);
}
else {
return this.makeResponse(...response);
@ -168,11 +204,90 @@ class LocalAPIEndpoint {
}
/**
* @param requestData Passed to {@link init}
* @param dataObjectOrObjects
* Build an object mapping link 'rel' attributes to URLs for the given request data and response info.
*
* @param {Object} requestData
* @param {Number} start
* @param {Number} limit
* @param {Number} totalResults
* @returns {Object}
*/
buildLinks(requestData, start, limit, totalResults) {
let links = {};
let buildURL = (searchParams) => {
let url = new URL(requestData.pathname, 'http://' + requestData.headers['Host']);
url.search = searchParams.toString();
return url.toString();
};
// Logic adapted from https://github.com/zotero/dataserver/blob/18443360/model/API.inc.php#L588-L642
// first
if (start) {
let p = new URLSearchParams(requestData.searchParams);
p.delete('start');
links.first = buildURL(p);
}
// prev
if (start) {
let p = new URLSearchParams(requestData.searchParams);
let prevStart = start - limit;
if (prevStart <= 0) {
p.delete('start');
}
else {
p.set('start', prevStart.toString());
}
links.prev = buildURL(p);
}
// last
if (!start && limit >= totalResults) {
let p = new URLSearchParams(requestData.searchParams);
links.last = buildURL(p);
}
else if (limit) {
let lastStart;
if (start >= totalResults) {
lastStart = totalResults - limit;
}
else {
lastStart = totalResults - totalResults % limit;
if (lastStart == totalResults) {
lastStart = totalResults - limit;
}
}
let p = new URLSearchParams(requestData.searchParams);
if (lastStart > 0) {
p.set('start', lastStart.toString());
}
else {
p.delete('start');
}
links.last = buildURL(p);
// next
let nextStart = start + limit;
if (nextStart < totalResults) {
p.set('start', nextStart.toString());
links.next = buildURL(p);
}
}
// alternate: cut off '/api/', replace userID 0 with current user's ID
links.alternate = ZOTERO_CONFIG.WWW_BASE_URL
+ requestData.pathname.substring(5)
.replace('users/0/', `users/${Zotero.Users.getCurrentUserID()}/`);
return links;
}
/**
* @param {Object} requestData Passed to {@link init}
* @param {Zotero.DataObject | Zotero.DataObject[]} dataObjectOrObjects
* @param {Object} headers
* @returns {Promise} A response to be returned from {@link init}
*/
async makeDataObjectResponse(requestData, dataObjectOrObjects) {
async makeDataObjectResponse(requestData, dataObjectOrObjects, headers) {
let contentType;
let body;
switch (requestData.searchParams.get('format')) {
@ -187,8 +302,7 @@ class LocalAPIEndpoint {
return this.makeResponse(400, 'text/plain', 'Only multi-object requests can output keys');
}
contentType = 'text/plain';
body = dataObjectOrObjects.map(o => o.key)
.join('\n');
body = dataObjectOrObjects.map(o => o.key).join('\n');
break;
case 'versions':
if (!Array.isArray(dataObjectOrObjects)) {
@ -211,7 +325,7 @@ class LocalAPIEndpoint {
return this.makeResponse(400, 'text/plain', `Invalid 'format' value '${requestData.searchParams.get('format')}'`);
}
}
return this.makeResponse(200, contentType, body);
return this.makeResponse(200, { ...headers, 'Content-Type': contentType }, body);
}
/**
@ -260,6 +374,111 @@ Zotero.Server.LocalAPI.Root = class extends LocalAPIEndpoint {
};
Zotero.Server.Endpoints["/api/"] = Zotero.Server.LocalAPI.Root;
Zotero.Server.LocalAPI.Schema = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
async run(_) {
return [200, 'application/json', await Zotero.File.getContentsFromURLAsync('resource://zotero/schema/global/schema.json')];
}
};
Zotero.Server.Endpoints["/api/schema"] = Zotero.Server.LocalAPI.Schema;
Zotero.Server.LocalAPI.ItemTypes = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run(_) {
let itemTypes = Zotero.ItemTypes.getAll().map((type) => {
return {
itemType: type.name,
localized: Zotero.ItemTypes.getLocalizedString(type.name)
};
});
return [200, 'application/json', JSON.stringify(itemTypes, null, 4)];
}
};
Zotero.Server.Endpoints["/api/itemTypes"] = Zotero.Server.LocalAPI.ItemTypes;
Zotero.Server.LocalAPI.ItemFields = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run(_) {
let itemFields = Zotero.ItemFields.getAll().map((field) => {
return {
field: field.name,
localized: Zotero.ItemFields.getLocalizedString(field.name)
};
});
return [200, 'application/json', JSON.stringify(itemFields, null, 4)];
}
};
Zotero.Server.Endpoints["/api/itemFields"] = Zotero.Server.LocalAPI.ItemFields;
Zotero.Server.LocalAPI.ItemTypeFields = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run({ searchParams }) {
let itemType = searchParams.get('itemType');
if (!itemType || !Zotero.ItemTypes.getID(itemType)) {
return [400, 'text/plain', "Invalid or missing 'itemType' value"];
}
let itemFields = Zotero.ItemFields.getItemTypeFields(Zotero.ItemTypes.getID(itemType))
.map((fieldID) => {
return {
field: Zotero.ItemFields.getName(fieldID),
localized: Zotero.ItemFields.getLocalizedString(fieldID)
};
});
return [200, 'application/json', JSON.stringify(itemFields, null, 4)];
}
};
Zotero.Server.Endpoints["/api/itemTypeFields"] = Zotero.Server.LocalAPI.ItemTypeFields;
Zotero.Server.LocalAPI.ItemTypeCreatorTypes = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run({ searchParams }) {
let itemType = searchParams.get('itemType');
if (!itemType || !Zotero.ItemTypes.getID(itemType)) {
return [400, 'text/plain', "Invalid or missing 'itemType' value"];
}
let creatorTypes = Zotero.CreatorTypes.getTypesForItemType(Zotero.ItemTypes.getID(itemType))
.map((creatorType) => {
return {
creatorType: creatorType.name,
localized: creatorType.localizedName
};
});
return [200, 'application/json', JSON.stringify(creatorTypes, null, 4)];
}
};
Zotero.Server.Endpoints["/api/itemTypeCreatorTypes"] = Zotero.Server.LocalAPI.ItemTypeCreatorTypes;
Zotero.Server.LocalAPI.CreatorFields = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run(_) {
let creatorFields = [
{ field: 'firstName', localized: 'First' },
{ field: 'lastName', localized: 'Last' },
{ field: 'name', localized: 'Name' }
];
return [200, 'application/json', JSON.stringify(creatorFields, null, 4)];
}
};
Zotero.Server.Endpoints["/api/creatorFields"] = Zotero.Server.LocalAPI.CreatorFields;
Zotero.Server.LocalAPI.Settings = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run(_) {
return [200, 'application/json', '{}'];
}
};
Zotero.Server.Endpoints["/api/users/:userID/settings"] = Zotero.Server.LocalAPI.Settings;
Zotero.Server.LocalAPI.Collections = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
@ -323,10 +542,14 @@ Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
// Cut it off so other .endsWith() checks work
pathname = pathname.slice(0, -5);
}
let isTop = pathname.endsWith('/top');
if (isTop) {
pathname = pathname.slice(0, -4);
}
let search = new Zotero.Search();
search.libraryID = libraryID;
search.addCondition('noChildren', pathname.endsWith('/top') ? 'true' : 'false');
search.addCondition('noChildren', isTop ? 'true' : 'false');
if (pathParams.collectionKey) {
search.addCondition('collectionID', 'is',
Zotero.Collections.getIDFromLibraryAndKey(libraryID, pathParams.collectionKey));
@ -416,7 +639,7 @@ Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
// But we always want them, so add them back if necessary
let json = await Zotero.Tags.toResponseJSON(libraryID,
tags.map(tag => ({ ...tag, type: tag.type || 0 })));
return [200, 'application/json', JSON.stringify(json, null, 4)];
return { data: json };
}
finally {
await Zotero.DB.queryAsync("DROP TABLE IF EXISTS " + tmpTable, [], { noCache: true });
@ -428,11 +651,13 @@ Zotero.Server.LocalAPI.Items = class extends LocalAPIEndpoint {
};
// Add basic library-wide item endpoints
for (let topTrashPart of ['', '/top', '/trash']) {
for (let tagsPart of ['', '/tags']) {
for (let userGroupPart of ['/api/users/:userID', '/api/groups/:groupID']) {
let path = userGroupPart + '/items' + topTrashPart + tagsPart;
Zotero.Server.Endpoints[path] = Zotero.Server.LocalAPI.Items;
for (let trashPart of ['', '/trash']) {
for (let topPart of ['', '/top']) {
for (let tagsPart of ['', '/tags']) {
for (let userGroupPart of ['/api/users/:userID', '/api/groups/:groupID']) {
let path = userGroupPart + '/items' + trashPart + topPart + tagsPart;
Zotero.Server.Endpoints[path] = Zotero.Server.LocalAPI.Items;
}
}
}
}
@ -451,6 +676,7 @@ for (let topPart of ['', '/top']) {
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/children"] = Zotero.Server.LocalAPI.Items;
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/children"] = Zotero.Server.LocalAPI.Items;
Zotero.Server.Endpoints["/api/users/:userID/publications/items"] = Zotero.Server.LocalAPI.Items;
Zotero.Server.Endpoints["/api/users/:userID/publications/items/top"] = Zotero.Server.LocalAPI.Items;
Zotero.Server.Endpoints["/api/users/:userID/publications/items/tags"] = Zotero.Server.LocalAPI.Items;
Zotero.Server.Endpoints["/api/users/:userID/searches/:searchKey/items"] = Zotero.Server.LocalAPI.Items;
Zotero.Server.Endpoints["/api/groups/:groupID/searches/:searchKey/items"] = Zotero.Server.LocalAPI.Items;
@ -468,6 +694,29 @@ Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey"] = Zotero.Server.Loc
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey"] = Zotero.Server.LocalAPI.Item;
Zotero.Server.LocalAPI.ItemFile = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
run({ pathname, pathParams, libraryID }) {
let item = Zotero.Items.getByLibraryAndKey(libraryID, pathParams.itemKey);
if (!item) return _404;
if (!item.isFileAttachment()) {
return [400, 'text/plain', `Not a file attachment: ${item.key}`];
}
if (pathname.endsWith('/url')) {
return [200, 'text/plain', item.getLocalFileURL()];
}
return [302, { 'Location': item.getLocalFileURL() }, ''];
}
};
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/file"] = Zotero.Server.LocalAPI.ItemFile;
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file"] = Zotero.Server.LocalAPI.ItemFile;
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/file/view"] = Zotero.Server.LocalAPI.ItemFile;
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file/view"] = Zotero.Server.LocalAPI.ItemFile;
Zotero.Server.Endpoints["/api/users/:userID/items/:itemKey/file/view/url"] = Zotero.Server.LocalAPI.ItemFile;
Zotero.Server.Endpoints["/api/groups/:groupID/items/:itemKey/file/view/url"] = Zotero.Server.LocalAPI.ItemFile;
Zotero.Server.LocalAPI.Searches = class extends LocalAPIEndpoint {
supportedMethods = ['GET'];
@ -498,7 +747,7 @@ Zotero.Server.LocalAPI.Tags = class extends LocalAPIEndpoint {
async run({ libraryID }) {
let tags = await Zotero.Tags.getAll(libraryID);
let json = await Zotero.Tags.toResponseJSON(libraryID, tags);
return [200, 'application/json', JSON.stringify(json, null, 4)];
return { data: json };
}
};
Zotero.Server.Endpoints["/api/users/:userID/tags"] = Zotero.Server.LocalAPI.Tags;
@ -511,7 +760,7 @@ Zotero.Server.LocalAPI.Tag = class extends LocalAPIEndpoint {
let tag = decodeURIComponent(pathParams.tag.replaceAll('+', '%20'));
let json = await Zotero.Tags.toResponseJSON(libraryID, [{ tag }]);
if (!json) return _404;
return [200, 'application/json', JSON.stringify(json, null, 4)];
return { data: json };
}
};
Zotero.Server.Endpoints["/api/users/:userID/tags/:tag"] = Zotero.Server.LocalAPI.Tag;
@ -533,10 +782,12 @@ async function toResponseJSON(dataObjectOrObjects, searchParams) {
// Ask the data object for its response JSON representation, updating URLs to point to localhost
let dataObject = dataObjectOrObjects;
let responseJSON = dataObject.toResponseJSON({
apiURL: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/api/`,
includeGroupDetails: true
});
let responseJSON = dataObject.toResponseJSONAsync
? await dataObject.toResponseJSONAsync({
apiURL: `http://localhost:${Zotero.Prefs.get('httpServer.port')}/api/`,
includeGroupDetails: true
})
: dataObject;
// Add includes and remove 'data' if not requested
let include = searchParams.has('include') ? searchParams.get('include') : 'data';

View file

@ -30,6 +30,7 @@ Zotero.Server = new function() {
201:"Created",
204:"No Content",
300:"Multiple Choices",
304:"Not Modified",
400:"Bad Request",
403:"Forbidden",
404:"Not Found",

View file

@ -131,6 +131,18 @@ describe("Local API Server", function () {
assert.isTrue(response.links.up.href.includes('/api/'));
assert.isTrue(response.links.enclosure.href.startsWith('file:'));
});
it("should return file URL from /file/view/url", async function () {
let { response } = await apiGet(`/users/0/items/${subcollectionAttachment.key}/file/view/url`, { responseType: 'text' });
assert.isTrue(response.startsWith('file:'));
});
// followRedirects: false not working?
it.skip("should redirect to file URL from /file/view", async function () {
let request = await apiGet(`/users/0/items/${subcollectionAttachment.key}/file/view`,
{ responseType: 'text', followRedirects: false });
assert.isTrue(request.getResponseHeader('Location').startsWith('file:'));
});
});
describe("?itemType", function () {