From 5d197e4b12b7f4b6a26b0a83342f6cf86d52defc Mon Sep 17 00:00:00 2001 From: Abe Jellinek Date: Tue, 4 Oct 2022 12:23:31 -0400 Subject: [PATCH] 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. --- .../content/zotero/xpcom/data/dataObject.js | 11 + chrome/content/zotero/xpcom/data/group.js | 10 +- chrome/content/zotero/xpcom/data/item.js | 31 +- .../zotero/xpcom/localAPI/server_localAPI.js | 331 +++++++++++++++--- chrome/content/zotero/xpcom/server.js | 1 + test/tests/server_localAPITest.js | 12 + 6 files changed, 351 insertions(+), 45 deletions(-) diff --git a/chrome/content/zotero/xpcom/data/dataObject.js b/chrome/content/zotero/xpcom/data/dataObject.js index ceed9a878c..4bc9423c48 100644 --- a/chrome/content/zotero/xpcom/data/dataObject.js +++ b/chrome/content/zotero/xpcom/data/dataObject.js @@ -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} + */ +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'; diff --git a/chrome/content/zotero/xpcom/data/group.js b/chrome/content/zotero/xpcom/data/group.js index 7726726205..a0a766ef98 100644 --- a/chrome/content/zotero/xpcom/data/group.js +++ b/chrome/content/zotero/xpcom/data/group.js @@ -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; diff --git a/chrome/content/zotero/xpcom/data/item.js b/chrome/content/zotero/xpcom/data/item.js index a02a501a12..a95439c576 100644 --- a/chrome/content/zotero/xpcom/data/item.js +++ b/chrome/content/zotero/xpcom/data/item.js @@ -2345,7 +2345,7 @@ Zotero.Item.prototype.isAttachment = function() { } /** - * @return {Promise} + * @return {Boolean} */ Zotero.Item.prototype.isImportedAttachment = function() { if (!this.isAttachment()) { @@ -2361,7 +2361,7 @@ Zotero.Item.prototype.isImportedAttachment = function() { } /** - * @return {Promise} + * @return {Boolean} */ Zotero.Item.prototype.isStoredFileAttachment = function() { if (!this.isAttachment()) { @@ -2371,7 +2371,7 @@ Zotero.Item.prototype.isStoredFileAttachment = function() { } /** - * @return {Promise} + * @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 * diff --git a/chrome/content/zotero/xpcom/localAPI/server_localAPI.js b/chrome/content/zotero/xpcom/localAPI/server_localAPI.js index 7875892162..674e613616 100644 --- a/chrome/content/zotero/xpcom/localAPI/server_localAPI.js +++ b/chrome/content/zotero/xpcom/localAPI/server_localAPI.js @@ -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. -- /searches/:searchKey/items returns the set of items matching a saved - search. The web API doesn't support actually executing searches. +- /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'; diff --git a/chrome/content/zotero/xpcom/server.js b/chrome/content/zotero/xpcom/server.js index 9c0094bd0b..bcdfac7ec6 100755 --- a/chrome/content/zotero/xpcom/server.js +++ b/chrome/content/zotero/xpcom/server.js @@ -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", diff --git a/test/tests/server_localAPITest.js b/test/tests/server_localAPITest.js index d5f9689725..f22588e8a9 100644 --- a/test/tests/server_localAPITest.js +++ b/test/tests/server_localAPITest.js @@ -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 () {