From 34611dfe129f02b5ccabab3c1d0d49173e8da134 Mon Sep 17 00:00:00 2001 From: Cozy Pierre Date: Thu, 24 Feb 2022 17:55:16 +0100 Subject: [PATCH] fix(stack-client): Get Icon Url uses preloaded url when oAuth not needed --- docs/api/cozy-stack-client.md | 39 ++- .../cozy-stack-client/src/CozyStackClient.js | 4 +- packages/cozy-stack-client/src/getIconURL.js | 166 +++++++--- .../cozy-stack-client/src/getIconURL.spec.js | 300 ++++++++++-------- 4 files changed, 334 insertions(+), 175 deletions(-) diff --git a/docs/api/cozy-stack-client.md b/docs/api/cozy-stack-client.md index 8f50fab548..f905ce8839 100644 --- a/docs/api/cozy-stack-client.md +++ b/docs/api/cozy-stack-client.md @@ -102,6 +102,10 @@ See getAccessToken()string

Get the app token string

+
getIconURL(stackClient, opts)Promise.<string> | string
+

Get Icon URL using blob mechanism if OAuth connected +or using preloaded url when blob not needed

+
garbageCollect()

Delete outdated results from cache

@@ -264,7 +268,7 @@ Main API against the `cozy-stack` server. * [CozyStackClient](#CozyStackClient) * [.collection(doctype)](#CozyStackClient+collection) ⇒ [DocumentCollection](#DocumentCollection) - * [.fetch(method, path, body, opts)](#CozyStackClient+fetch) ⇒ object + * [.fetch(method, path, [body], [opts])](#CozyStackClient+fetch) ⇒ object * [.checkForRevocation()](#CozyStackClient+checkForRevocation) * [.refreshToken()](#CozyStackClient+refreshToken) ⇒ Promise * [.fetchJSON(method, path, body, options)](#CozyStackClient+fetchJSON) ⇒ object @@ -284,7 +288,7 @@ Creates a [DocumentCollection](#DocumentCollection) instance. -### cozyStackClient.fetch(method, path, body, opts) ⇒ object +### cozyStackClient.fetch(method, path, [body], [opts]) ⇒ object Fetches an endpoint in an authorized way. **Kind**: instance method of [CozyStackClient](#CozyStackClient) @@ -293,12 +297,12 @@ Fetches an endpoint in an authorized way. - FetchError -| Param | Type | Description | -| --- | --- | --- | -| method | string | The HTTP method. | -| path | string | The URI. | -| body | object | The payload. | -| opts | object | Options for fetch | +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| method | string | | The HTTP method. | +| path | string | | The URI. | +| [body] | object | | The payload. | +| [opts] | object | {} | Options for fetch | @@ -1820,6 +1824,25 @@ Get the app token string **Kind**: global function **Returns**: string - token **See**: CozyStackClient.getAccessToken + + +## getIconURL(stackClient, opts) ⇒ Promise.<string> \| string +Get Icon URL using blob mechanism if OAuth connected +or using preloaded url when blob not needed + +**Kind**: global function +**Returns**: Promise.<string> \| string - DOMString containing URL source or a URL representing the Blob or ErrorReturned + +| Param | Type | Default | Description | +| --- | --- | --- | --- | +| stackClient | [CozyStackClient](#CozyStackClient) | | CozyStackClient | +| stackClient.oauthOptions | object | | oauthOptions used to detect fetching mechanism | +| opts | object | | Options | +| opts.type | string | | Options type | +| opts.slug | string | | Options slug | +| opts.appData | object | | Apps data - io.cozy.apps | +| [opts.priority] | string | "'stack'" | Options priority | + ## garbageCollect() diff --git a/packages/cozy-stack-client/src/CozyStackClient.js b/packages/cozy-stack-client/src/CozyStackClient.js index a2e6f09810..75eb9b7cd4 100644 --- a/packages/cozy-stack-client/src/CozyStackClient.js +++ b/packages/cozy-stack-client/src/CozyStackClient.js @@ -97,8 +97,8 @@ class CozyStackClient { * * @param {string} method The HTTP method. * @param {string} path The URI. - * @param {object} body The payload. - * @param {object} opts Options for fetch + * @param {object} [body] The payload. + * @param {object} [opts={}] Options for fetch * @returns {object} * @throws {FetchError} */ diff --git a/packages/cozy-stack-client/src/getIconURL.js b/packages/cozy-stack-client/src/getIconURL.js index 21df98550d..0afa6d19b6 100644 --- a/packages/cozy-stack-client/src/getIconURL.js +++ b/packages/cozy-stack-client/src/getIconURL.js @@ -1,4 +1,49 @@ import memoize, { ErrorReturned } from './memoize' + +/** + * Get Icon source Url + * + * @param {object} app - Apps data - io.cozy.apps + * @param {string|undefined} domain - Host to use in the origin (e.g. cozy.tools) + * @param {string} protocol - Url protocol (e.g. http / https) + * @returns {string} Source Url of icon + * @private + * @throws {Error} When cannot fetch or get icon source + */ +const loadIcon = async (app, domain, protocol) => { + if (!domain) throw new Error('Cannot fetch icon: missing domain') + const source = _getAppIconURL(app, domain, protocol) + if (!source) { + throw new Error(`Cannot get icon source for app ${app.name}`) + } + return source +} + +/** + * Get App Icon URL + * + * @param {object|string} app - Apps data - io.cozy.apps + * @param {string|undefined} domain - Host to use in the origin (e.g. cozy.tools) + * @param {string} protocol - Url protocol (e.g. http / https) + * @private + * @returns {string|null} App Icon URL + */ +const _getAppIconURL = (app, domain, protocol) => { + const path = (app && app.links && app.links.icon) || _getRegistryIconPath(app) + return path ? `${protocol}//${domain}${path}` : null +} + +/** + * Get Registry Icon Path + * + * @param {object|string} app - Apps data - io.cozy.apps + * @returns {string|undefined} Registry icon path + * @private + */ +const _getRegistryIconPath = app => + app?.latest_version?.version && + `/registry/${app.slug}/${app.latest_version.version}/icon` + const mimeTypes = { gif: 'image/gif', ico: 'image/vnd.microsoft.icon', @@ -8,6 +53,16 @@ const mimeTypes = { svg: 'image/svg+xml' } +/** + * Get icon extension + * + * @param {object} app io.cozy.apps or io.cozy.konnectors document + * @param {string} app.icon - App Icon + * @param {string} app.name - App Name + * @returns {string} icon extension + * @private + * @throws {Error} When problem while detecting icon mime type + */ const getIconExtensionFromApp = app => { if (!app.icon) { throw new Error( @@ -58,52 +113,89 @@ const fetchAppOrKonnectorViaRegistry = (stackClient, type, slug) => .fetchJSON('GET', `/registry/${slug}`) .then(x => x.latest_version.manifest) -const _getIconURL = async (stackClient, opts) => { +/** + * Get Icon URL using blob mechanism if OAuth connected + * or using preloaded url when blob not needed + * + * @param {CozyStackClient} stackClient - CozyStackClient + * @param {object} stackClient.oauthOptions - oauthOptions used to detect fetching mechanism + * @param {object} opts - Options + * @param {string} opts.type - Options type + * @param {string} opts.slug - Options slug + * @param {object} opts.appData - Apps data - io.cozy.apps + * @param {string} [opts.priority='stack'] - Options priority + * @returns {Promise|string} DOMString containing URL source or a URL representing the Blob . + * @private + * @throws {Error} while fetching icon, or unknown image extension + */ +export const _getIconURL = async (stackClient, opts) => { const { type, slug, appData, priority = 'stack' } = opts - const iconDataFetchers = [ - () => stackClient.fetch('GET', `/${type}s/${slug}/icon`), - () => stackClient.fetch('GET', `/registry/${slug}/icon`) - ] - if (priority === 'registry') { - iconDataFetchers.reverse() - } - const resp = await fallbacks(iconDataFetchers, resp => { - if (!resp.ok) { - throw new Error(`Error while fetching icon ${resp.statusText}`) - } - }) - let icon = await resp.blob() - let app - if (!icon.type) { - // iOS10 does not set correctly mime type for images, so we assume - // that an empty mime type could mean that the app is running on iOS10. - // For regular images like jpeg, png or gif it still works well in the - // Safari browser but not for SVG. - // So let's set a mime type manually. We cannot always set it to - // image/svg+xml and must guess the mime type based on the icon attribute - // from app/manifest - // See https://stackoverflow.com/questions/38318411/uiwebview-on-ios-10-beta-not-loading-any-svg-images - const appDataFetchers = [ - () => fetchAppOrKonnector(stackClient, type, slug), - () => fetchAppOrKonnectorViaRegistry(stackClient, type, slug) + if (stackClient.oauthOptions) { + const iconDataFetchers = [ + () => stackClient.fetch('GET', `/${type}s/${slug}/icon`), + () => stackClient.fetch('GET', `/registry/${slug}/icon`) ] if (priority === 'registry') { - appDataFetchers.reverse() + iconDataFetchers.reverse() + } + const resp = await fallbacks(iconDataFetchers, resp => { + if (!resp.ok) { + throw new Error(`Error while fetching icon ${resp.statusText}`) + } + }) + let icon = await resp.blob() + let app + if (!icon.type) { + // iOS10 does not set correctly mime type for images, so we assume + // that an empty mime type could mean that the app is running on iOS10. + // For regular images like jpeg, png or gif it still works well in the + // Safari browser but not for SVG. + // So let's set a mime type manually. We cannot always set it to + // image/svg+xml and must guess the mime type based on the icon attribute + // from app/manifest + // See https://stackoverflow.com/questions/38318411/uiwebview-on-ios-10-beta-not-loading-any-svg-images + const appDataFetchers = [ + () => fetchAppOrKonnector(stackClient, type, slug), + () => fetchAppOrKonnectorViaRegistry(stackClient, type, slug) + ] + if (priority === 'registry') { + appDataFetchers.reverse() + } + app = appData || (await fallbacks(appDataFetchers)) || {} + const ext = getIconExtensionFromApp(app) + if (!mimeTypes[ext]) { + throw new Error(`Unknown image extension "${ext}" for app ${app.name}`) + } + icon = new Blob([icon], { type: mimeTypes[ext] }) } - app = appData || (await fallbacks(appDataFetchers)) || {} - const ext = getIconExtensionFromApp(app) - if (!mimeTypes[ext]) { - throw new Error(`Unknown image extension "${ext}" for app ${app.name}`) + return URL.createObjectURL(icon) + } else { + try { + const { host: domain, protocol } = new URL(stackClient.uri) + return loadIcon(appData, domain, protocol) + } catch (error) { + throw new Error( + `Cannot fetch icon: invalid stackClient.uri: ${error.message}` + ) } - icon = new Blob([icon], { type: mimeTypes[ext] }) } - return URL.createObjectURL(icon) } +/** + * Get Icon URL using blob mechanism if OAuth connected + * or using preloaded url when blob not needed + * + * @param {CozyStackClient} stackClient - CozyStackClient + * @param {object} stackClient.oauthOptions - oauthOptions used to detect fetching mechanism + * @param {object} opts - Options + * @param {string} opts.type - Options type + * @param {string} opts.slug - Options slug + * @param {object} opts.appData - Apps data - io.cozy.apps + * @param {string} [opts.priority='stack'] - Options priority + * @returns {Promise|string} DOMString containing URL source or a URL representing the Blob or ErrorReturned + */ const getIconURL = function() { - return _getIconURL.apply(this, arguments).catch(e => { - return new ErrorReturned() - }) + return _getIconURL.apply(this, arguments).catch(() => new ErrorReturned()) } export default memoize(getIconURL, { diff --git a/packages/cozy-stack-client/src/getIconURL.spec.js b/packages/cozy-stack-client/src/getIconURL.spec.js index 6130225298..32e83e3c8f 100644 --- a/packages/cozy-stack-client/src/getIconURL.spec.js +++ b/packages/cozy-stack-client/src/getIconURL.spec.js @@ -1,17 +1,17 @@ import { getIconURL } from './getIconURL' -import getIconUrlDefault from './getIconURL' +import { ErrorReturned } from './memoize' const FakeBlob = (data, options) => { return { data, ...options } } -const resetcreateObjectURL = () => { +const resetCreateObjectURL = () => { global.URL.createObjectURL = jest.fn(blob => { return blob }) } -let stackClient = {}, - responses +let responses + const fakeResp = (method, url) => { const resp = responses[url] return resp @@ -19,6 +19,8 @@ const fakeResp = (method, url) => { : Promise.reject(`404: ${url} (not found in fake server)`) } describe('get icon', () => { + let stackClient = {} + beforeEach(() => { responses = {} @@ -39,142 +41,184 @@ describe('get icon', () => { slug: 'caissedepargne1' } - it('should build a url when app is installed', async () => { - responses['/konnectors/caissedepargne1/icon'] = { - ok: true, - blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) - } - const url = await getIconURL(stackClient, defaultOpts) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'image/svg+xml' - }) - ) - expect(url.data[0]).toBe('') - }) + describe('when consuming app is using oauth', () => { + beforeEach(() => { + stackClient.oauthOptions = {} + }) - it('should build a url when app is not installed', async () => { - responses['/registry/caissedepargne1/icon'] = { - ok: true, - blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) - } - const url = await getIconURL(stackClient, defaultOpts) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'image/svg+xml' - }) - ) - expect(url.data[0]).toBe('') - }) + it('should build a url when app is installed', async () => { + responses['/konnectors/caissedepargne1/icon'] = { + ok: true, + blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) + } + const url = await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'image/svg+xml' + }) + ) + expect(url.data[0]).toBe('') + }) - it('should build a url when app is installed but no mime type is sent in response', async () => { - responses['/konnectors/caissedepargne1/icon'] = { - ok: true, - blob: () => new FakeBlob([svgData], {}) - } - responses['/registry/caissedepargne1'] = { - latest_version: { manifest: { icon: 'icon.svg' } } - } - await getIconURL(stackClient, defaultOpts) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'image/svg+xml' - }) - ) - }) + it('should build a url when app is not installed', async () => { + responses['/registry/caissedepargne1/icon'] = { + ok: true, + blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) + } + const url = await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'image/svg+xml' + }) + ) + expect(url.data[0]).toBe('') + }) + + it('should build a url when app is installed but no mime type is sent in response', async () => { + responses['/konnectors/caissedepargne1/icon'] = { + ok: true, + blob: () => new FakeBlob([svgData], {}) + } + responses['/registry/caissedepargne1'] = { + latest_version: { manifest: { icon: 'icon.svg' } } + } + await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'image/svg+xml' + }) + ) + }) + + it('should build a url when app is not installed and no mime type is sent in response', async () => { + responses['/konnectors/caissedepargne1/icon'] = { + ok: true, + blob: () => new FakeBlob([svgData], {}) + } + responses['/registry/caissedepargne1'] = { + latest_version: { manifest: { icon: 'icon.svg' } } + } + await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'image/svg+xml' + }) + ) + }) + + it('should return nothing for unknown file type', async () => { + responses['/konnectors/caissedepargne1/icon'] = { + ok: true, + blob: () => new FakeBlob([svgData], {}) + } + responses['/registry/caissedepargne1'] = { + data: { name: 'caissedepargne1', icon: 'icon.mp4' } + } + jest.spyOn(console, 'warn').mockImplementation(() => {}) + const url = await getIconURL(stackClient, defaultOpts) + console.warn.mockRestore() + expect(url).toEqual('') + }) - it('should build a url when app is not installed and no mime type is sent in response', async () => { - responses['/konnectors/caissedepargne1/icon'] = { - ok: true, - blob: () => new FakeBlob([svgData], {}) - } - responses['/registry/caissedepargne1'] = { - latest_version: { manifest: { icon: 'icon.svg' } } - } - await getIconURL(stackClient, defaultOpts) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'image/svg+xml' + it('should respect priority', async () => { + responses['/konnectors/caissedepargne1/icon'] = { + ok: true, + blob: () => new FakeBlob([svgData], { type: 'image/svg+xml' }) + } + responses['/registry/caissedepargne1/icon'] = { + ok: true, + blob: () => + new FakeBlob([''], { type: 'image/svg+xml' }) + } + await getIconURL(stackClient, { + ...defaultOpts, + priority: 'registry' }) - ) - }) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + data: [''] + }) + ) + }) - it('should return nothing for unknown file type', async () => { - responses['/konnectors/caissedepargne1/icon'] = { - ok: true, - blob: () => new FakeBlob([svgData], {}) - } - responses['/registry/caissedepargne1'] = { - data: { name: 'caissedepargne1', icon: 'icon.mp4' } - } - jest.spyOn(console, 'warn').mockImplementation(() => {}) - const url = await getIconURL(stackClient, defaultOpts) - console.warn.mockRestore() - expect(url).toEqual('') - }) + it('should call the server the second time if the first time it failed', async () => { + responses['/registry/caissedepargne1/icon'] = { + ok: true, + blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) + } + stackClient.fetch = jest.fn().mockRejectedValue('No connexion') + + await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).not.toHaveBeenCalled() + + stackClient.fetch = jest.fn().mockImplementation(fakeResp) + const url2 = await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'image/svg+xml' + }) + ) + expect(url2.data[0]).toBe('') + }) - it('should respect priority', async () => { - responses['/konnectors/caissedepargne1/icon'] = { - ok: true, - blob: () => new FakeBlob([svgData], { type: 'image/svg+xml' }) - } - responses['/registry/caissedepargne1/icon'] = { - ok: true, - blob: () => - new FakeBlob([''], { type: 'image/svg+xml' }) - } - await getIconURL(stackClient, { - ...defaultOpts, - priority: 'registry' - }) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - data: [''] + it('should not call create createObjectURL since it should be memoized', async () => { + /** + * Create a new response / request to be sure to not having memoized it already + */ + responses['/konnectors/caissedepargne10/icon'] = { + ok: true, + blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) + } + const url = await getIconURL(stackClient, { + type: 'konnector', + slug: 'caissedepargne10' }) - ) + expect(global.URL.createObjectURL).toHaveBeenCalledWith( + expect.objectContaining({ + type: 'image/svg+xml' + }) + ) + expect(url.data[0]).toBe('') + resetCreateObjectURL() + await getIconURL(stackClient, defaultOpts) + + expect(global.URL.createObjectURL).not.toHaveBeenCalled() + }) }) - it('should call the server the second time if the first time it failed', async () => { - responses['/registry/caissedepargne1/icon'] = { - ok: true, - blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) - } - stackClient.fetch = jest.fn().mockRejectedValue('No connexion') + describe('when consuming app is not using oauth', () => { + beforeEach(() => { + stackClient.oauthOptions = undefined + stackClient.uri = 'http://cozy.tools:8080/anywhere/where/user/can/be' + }) - await getIconUrlDefault(stackClient, defaultOpts) - expect(global.URL.createObjectURL).not.toHaveBeenCalled() + it('should not create object url as blob', async () => { + await getIconURL(stackClient, defaultOpts) + expect(global.URL.createObjectURL).not.toHaveBeenCalled() + }) - stackClient.fetch = jest.fn().mockImplementation(fakeResp) - const url2 = await getIconUrlDefault(stackClient, defaultOpts) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'image/svg+xml' - }) - ) - expect(url2.data[0]).toBe('') - }) + it('should return url from appData, links, icon if defined', async () => { + defaultOpts.appData = { links: { icon: '/path/to/icon' } } + const url = await getIconURL(stackClient, defaultOpts) + expect(url).toEqual('http://cozy.tools:8080/path/to/icon') + }) - it('should not call create createObjectURL since it should be memoized', async () => { - /** - * Create a new response / request to be sure to not having memoized it already - */ - responses['/konnectors/caissedepargne10/icon'] = { - ok: true, - blob: () => FakeBlob([svgData], { type: 'image/svg+xml' }) - } - const url = await getIconUrlDefault(stackClient, { - type: 'konnector', - slug: 'caissedepargne10' - }) - expect(global.URL.createObjectURL).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'image/svg+xml' - }) - ) - expect(url.data[0]).toBe('') - resetcreateObjectURL() - await getIconUrlDefault(stackClient, defaultOpts) + it('should return url from registry if app data not containing icon path', async () => { + defaultOpts.appData = { latest_version: { version: '3' }, slug: 'slug' } + const url = await getIconURL(stackClient, defaultOpts) + expect(url).toEqual('http://cozy.tools:8080/registry/slug/3/icon') + }) + + it('should catch occurring error and returns nothing - ErrorReturned', async () => { + stackClient.uri = undefined + const url = await getIconURL(stackClient, defaultOpts) + expect(url).toEqual(new ErrorReturned()) + }) - expect(global.URL.createObjectURL).not.toHaveBeenCalled() + it('should catch occurring error and returns nothing - ErrorReturned', async () => { + defaultOpts.appData = { latest_version: undefined, slug: 'slug' } + const url = await getIconURL(stackClient, defaultOpts) + expect(url).toEqual(new ErrorReturned()) + }) }) })