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())
+ })
})
})