diff --git a/app/src/api/api.js b/app/src/api/api.js index 3c40d1f9..d199dccb 100644 --- a/app/src/api/api.js +++ b/app/src/api/api.js @@ -4,14 +4,33 @@ */ import store from '@/store' -import { handleResponse } from './handlers' +import { openWebSocket, getResponseData, handleError } from './handlers' import { objectToParams } from '@/helpers/commons' + /** - * A digested fetch response as an object, a string or an error. - * @typedef {(Object|string|Error)} DigestedResponse + * Options available for an API call. + * + * @typedef {Object} Options + * @property {Boolean} wait - If `true`, will display the waiting modal. + * @property {Boolean} websocket - if `true`, will open a websocket connection. + * @property {Boolean} initial - if `true` and an error occurs, the dismiss button will trigger a go back in history. + * @property {Boolean} noCache - if `true`, will disable the cache mecanism for this call. + * @property {Boolean} asFormData - if `true`, will send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`). */ + +/** + * Representation of an API call for `api.fetchAll` + * + * @typedef {Array} Query + * @property {String} 0 - "method" + * @property {String|Object} 1 - "uri", uri to call as string or as an object for cached uris. + * @property {Object|null} 2 - "data" + * @property {Options} 3 - "options" +*/ + + export default { options: { credentials: 'include', @@ -22,111 +41,124 @@ export default { // Auto header is : // "Accept": "*/*", - // Also is this still important ? (needed by back-end) 'X-Requested-With': 'XMLHttpRequest' } }, - /** - * Opens a WebSocket connection to the server in case it sends messages. - * Currently, the connection is closed by the server right after an API call so - * we have to open it for every calls. - * Messages are dispatch to the store so it can handle them. - * - * @return {Promise} Promise that resolve on websocket 'open' or 'error' event. - */ - openWebSocket () { - return new Promise(resolve => { - const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`) - ws.onmessage = ({ data }) => store.dispatch('DISPATCH_MESSAGE', JSON.parse(data)) - // ws.onclose = (e) => {} - ws.onopen = resolve - // Resolve also on error so the actual fetch may be called. - ws.onerror = resolve - }) - }, /** * Generic method to fetch the api without automatic response handling. * - * @param {string} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'. - * @param {string} uri - * @param {Object} [data={}] - data to send as body for 'POST', 'PUT' and 'DELETE' methods. - * @param {Object} [options={}] - * @param {Boolean} [options.websocket=true] - Open a websocket before this request. - * @return {Promise} Promise that resolve a fetch `Response`. + * @param {String} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'. + * @param {String} uri + * @param {Object} [data={}] - data to send as body. + * @param {Options} [options={ wait = true, websocket = true, initial = false, asFormData = false }] + * @return {Promise} Promise that resolve the api response data or an error. */ - async fetch (method, uri, data = {}, { websocket = true } = {}) { - // Open a websocket connection that will dispatch messages received. + async fetch (method, uri, data = {}, { wait = true, websocket = true, initial = false, asFormData = false } = {}) { + // `await` because Vuex actions returns promises by default. + const request = await store.dispatch('INIT_REQUEST', { method, uri, initial, wait, websocket }) + if (websocket) { - await this.openWebSocket() - store.dispatch('WAITING_FOR_RESPONSE', [uri, method]) + await openWebSocket(request) } + let options = this.options if (method === 'GET') { - const localeQs = `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` - return fetch('/yunohost/api/' + uri + localeQs, this.options) + uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` + } else { + options = { ...options, method, body: objectToParams(data, { addLocale: true }) } } - return fetch('/yunohost/api/' + uri, { - ...this.options, - method, - body: objectToParams(data, { addLocale: true }) - }) + const response = await fetch('/yunohost/api/' + uri, options) + const responseData = await getResponseData(response) + store.dispatch('END_REQUEST', { request, success: response.ok, wait }) + + return response.ok ? responseData : handleError(request, response, responseData) }, + + /** + * Api multiple queries helper. + * Those calls will act as one (declare optional waiting for one but still create history entries for each) + * Calls are synchronous since the API can't handle multiple calls. + * + * @param {Array} queries - An array of queries with special representation. + * @param {Object} [options={}] + * @param {Boolean} + * @return {Promise} Promise that resolve the api responses data or an error. + */ + async fetchAll (queries, { wait, initial } = {}) { + const results = [] + if (wait) store.commit('SET_WAITING', true) + try { + for (const [method, uri, data, options = {}] of queries) { + if (wait) options.wait = false + if (initial) options.initial = true + results.push(await this[method.toLowerCase()](uri, data, options)) + } + } finally { + // Stop waiting even if there is an error. + if (wait) store.commit('SET_WAITING', false) + } + + return results + }, + + /** * Api get helper function. * - * @param {string} uri - the uri to call. - * @param {Object} [options={}] - * @param {Boolean} [options.websocket=false] - Open a websocket before this request. - * @return {Promise} Promise that resolve the api response as an object, a string or as an error. + * @param {String|Object} uri + * @param {null} [data=null] - for convenience in muliple calls, just pass null. + * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`) + * @return {Promise} Promise that resolve the api response data or an error. */ - get (uri, { websocket = false } = {}) { - return this.fetch('GET', uri, null, { websocket }).then(response => handleResponse(response, 'GET')) + get (uri, data = null, options = {}) { + options = { websocket: false, wait: false, ...options } + if (typeof uri === 'string') return this.fetch('GET', uri, null, options) + return store.dispatch('GET', { ...uri, options }) }, - /** - * Api get helper function for multiple queries. - * - * @param {string} uri - the uri to call. - * @return {Promise} Promise that resolve the api responses as an array. - */ - getAll (uris) { - return Promise.all(uris.map(uri => this.get(uri))) - }, /** * Api post helper function. * - * @param {string} uri - the uri to call. - * @param {string} [data={}] - data to send as body. - * @return {Promise} Promise that resolve the api responses as an array. + * @param {String|Object} uri + * @param {String} [data={}] - data to send as body. + * @param {Options} [options={}] - options to apply to the call + * @return {Promise} Promise that resolve the api response data or an error. */ - post (uri, data = {}) { - return this.fetch('POST', uri, data).then(response => handleResponse(response, 'POST')) + post (uri, data = {}, options = {}) { + if (typeof uri === 'string') return this.fetch('POST', uri, data, options) + return store.dispatch('POST', { ...uri, data, options }) }, + /** * Api put helper function. * - * @param {string} uri - the uri to call. - * @param {string} [data={}] - data to send as body. - * @return {Promise} Promise that resolve the api responses as an array. + * @param {String|Object} uri + * @param {String} [data={}] - data to send as body. + * @param {Options} [options={}] - options to apply to the call + * @return {Promise} Promise that resolve the api response data or an error. */ - put (uri, data = {}) { - return this.fetch('PUT', uri, data).then(response => handleResponse(response, 'PUT')) + put (uri, data = {}, options = {}) { + if (typeof uri === 'string') return this.fetch('PUT', uri, data, options) + return store.dispatch('PUT', { ...uri, data, options }) }, + /** * Api delete helper function. * - * @param {string} uri - the uri to call. - * @param {string} [data={}] - data to send as body. - * @return {Promise<('ok'|Error)>} Promise that resolve the api responses as an array. + * @param {String|Object} uri + * @param {String} [data={}] - data to send as body. + * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`) + * @return {Promise} Promise that resolve the api response data or an error. */ - delete (uri, data = {}) { - return this.fetch('DELETE', uri, data).then(response => handleResponse(response, 'DELETE')) + delete (uri, data = {}, options = {}) { + if (typeof uri === 'string') return this.fetch('DELETE', uri, data, options) + return store.dispatch('DELETE', { ...uri, data, options }) } } diff --git a/app/src/api/errors.js b/app/src/api/errors.js index 28f061cf..cbca7c12 100644 --- a/app/src/api/errors.js +++ b/app/src/api/errors.js @@ -7,15 +7,15 @@ import i18n from '@/i18n' class APIError extends Error { - constructor (method, { url, status, statusText }, errorData) { + constructor (request, { url, status, statusText }, errorData) { super(errorData.error || i18n.t('error_server_unexpected')) const urlObj = new URL(url) this.name = 'APIError' this.code = status this.status = statusText - this.method = method + this.method = request.method + this.request = request this.path = urlObj.pathname + urlObj.search - this.logRef = errorData.log_ref || null } log () { @@ -23,6 +23,15 @@ class APIError extends Error { } } +// Log (Special error to trigger a redirect to a log page) +class APIErrorLog extends APIError { + constructor (method, response, errorData) { + super(method, response, errorData) + this.logRef = errorData.log_ref + this.name = 'APIErrorLog' + } +} + // 0 — (means "the connexion has been closed" apparently) class APIConnexionError extends APIError { @@ -83,6 +92,7 @@ class APINotRespondingError extends APIError { // Temp factory const errors = { [undefined]: APIError, + log: APIErrorLog, 0: APIConnexionError, 400: APIBadRequestError, 401: APIUnauthorizedError, @@ -95,6 +105,7 @@ const errors = { export { errors as default, APIError, + APIErrorLog, APIBadRequestError, APIConnexionError, APIInternalError, diff --git a/app/src/api/handlers.js b/app/src/api/handlers.js index c8a48d3d..123797da 100644 --- a/app/src/api/handlers.js +++ b/app/src/api/handlers.js @@ -13,7 +13,7 @@ import errors, { APIError } from './errors' * @param {Response} response - A fetch `Response` object. * @return {(Object|String)} Parsed response's json or response's text. */ -async function _getResponseData (response) { +export async function getResponseData (response) { // FIXME the api should always return json as response const responseText = await response.text() try { @@ -25,36 +25,58 @@ async function _getResponseData (response) { /** - * Handler for API responses. + * Opens a WebSocket connection to the server in case it sends messages. + * Currently, the connection is closed by the server right after an API call so + * we have to open it for every calls. + * Messages are dispatch to the store so it can handle them. * - * @param {Response} response - A fetch `Response` object. - * @return {(Object|String)} Parsed response's json, response's text or an error. + * @param {Object} request - Request info data. + * @return {Promise} Promise that resolve on websocket 'open' or 'error' event. */ -export async function handleResponse (response, method) { - const responseData = await _getResponseData(response) - store.dispatch('SERVER_RESPONDED', response.ok) - return response.ok ? responseData : handleError(response, responseData, method) +export function openWebSocket (request) { + return new Promise(resolve => { + const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`) + ws.onmessage = ({ data }) => { + store.dispatch('DISPATCH_MESSAGE', { request, messages: JSON.parse(data) }) + } + // ws.onclose = (e) => {} + ws.onopen = resolve + // Resolve also on error so the actual fetch may be called. + ws.onerror = resolve + }) } /** * Handler for API errors. * - * @param {Response} response - A fetch `Response` object. - * @throws Will throw a custom error with response data. + * @param {Object} request - Request info data. + * @param {Response} response - A consumed fetch `Response` object. + * @param {Object|String} errorData - The response parsed json/text. + * @throws Will throw a `APIError` with request and response data. */ -export async function handleError (response, errorData, method) { - const errorCode = response.status in errors ? response.status : undefined - // FIXME API: Patching errors that are plain text or html. +export async function handleError (request, response, errorData) { + let errorCode = response.status in errors ? response.status : undefined if (typeof errorData === 'string') { + // FIXME API: Patching errors that are plain text or html. errorData = { error: errorData } } + if ('log_ref' in errorData) { + // Define a special error so it won't get caught as a `APIBadRequestError`. + errorCode = 'log' + } // This error can be catched by a view otherwise it will be catched by the `onUnhandledAPIError` handler. - throw new errors[errorCode](method, response, errorData) + throw new errors[errorCode](request, response, errorData) } +/** + * If an APIError is not catched by a view it will be dispatched to the store so the + * error can be displayed in the error modal. + * + * @param {APIError} error + */ export function onUnhandledAPIError (error) { // In 'development', Babel seems to also catch the error so there's no need to log it twice. if (process.env.NODE_ENV !== 'development') { @@ -64,9 +86,12 @@ export function onUnhandledAPIError (error) { } +/** + * Global catching of unhandled promise's rejections. + * Those errors (thrown or rejected from inside a promise) can't be catched by + * `window.onerror`. + */ export function registerGlobalErrorHandlers () { - // Global catching of unhandled promise's rejections. - // Those errors (thrown or rejected from inside a promise) can't be catched by `window.onerror`. window.addEventListener('unhandledrejection', e => { const error = e.reason if (error instanceof APIError) { diff --git a/app/src/api/index.js b/app/src/api/index.js index 3e4bc332..4c919aa1 100644 --- a/app/src/api/index.js +++ b/app/src/api/index.js @@ -1,2 +1,2 @@ export { default } from './api' -export { handleResponse, handleError, registerGlobalErrorHandlers } from './handlers' +export { handleError, registerGlobalErrorHandlers } from './handlers'