Rework the whole API calling system

This commit is contained in:
axolotle 2021-02-19 18:23:35 +01:00
parent 4d21966fc7
commit b130aeda29
4 changed files with 157 additions and 89 deletions

View file

@ -4,14 +4,33 @@
*/ */
import store from '@/store' import store from '@/store'
import { handleResponse } from './handlers' import { openWebSocket, getResponseData, handleError } from './handlers'
import { objectToParams } from '@/helpers/commons' import { objectToParams } from '@/helpers/commons'
/** /**
* A digested fetch response as an object, a string or an error. * Options available for an API call.
* @typedef {(Object|string|Error)} DigestedResponse *
* @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 { export default {
options: { options: {
credentials: 'include', credentials: 'include',
@ -22,111 +41,124 @@ export default {
// Auto header is : // Auto header is :
// "Accept": "*/*", // "Accept": "*/*",
// Also is this still important ? (needed by back-end)
'X-Requested-With': 'XMLHttpRequest' '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<Event>} 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. * Generic method to fetch the api without automatic response handling.
* *
* @param {string} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'. * @param {String} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'.
* @param {string} uri * @param {String} uri
* @param {Object} [data={}] - data to send as body for 'POST', 'PUT' and 'DELETE' methods. * @param {Object} [data={}] - data to send as body.
* @param {Object} [options={}] * @param {Options} [options={ wait = true, websocket = true, initial = false, asFormData = false }]
* @param {Boolean} [options.websocket=true] - Open a websocket before this request. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
* @return {Promise<Response>} Promise that resolve a fetch `Response`.
*/ */
async fetch (method, uri, data = {}, { websocket = true } = {}) { async fetch (method, uri, data = {}, { wait = true, websocket = true, initial = false, asFormData = false } = {}) {
// Open a websocket connection that will dispatch messages received. // `await` because Vuex actions returns promises by default.
const request = await store.dispatch('INIT_REQUEST', { method, uri, initial, wait, websocket })
if (websocket) { if (websocket) {
await this.openWebSocket() await openWebSocket(request)
store.dispatch('WAITING_FOR_RESPONSE', [uri, method])
} }
let options = this.options
if (method === 'GET') { if (method === 'GET') {
const localeQs = `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}` uri += `${uri.includes('?') ? '&' : '?'}locale=${store.getters.locale}`
return fetch('/yunohost/api/' + uri + localeQs, this.options) } else {
options = { ...options, method, body: objectToParams(data, { addLocale: true }) }
} }
return fetch('/yunohost/api/' + uri, { const response = await fetch('/yunohost/api/' + uri, options)
...this.options, const responseData = await getResponseData(response)
method, store.dispatch('END_REQUEST', { request, success: response.ok, wait })
body: objectToParams(data, { addLocale: true })
}) 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<Query>} queries - An array of queries with special representation.
* @param {Object} [options={}]
* @param {Boolean}
* @return {Promise<Array|Error>} 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. * Api get helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {Object} [options={}] * @param {null} [data=null] - for convenience in muliple calls, just pass null.
* @param {Boolean} [options.websocket=false] - Open a websocket before this request. * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`)
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api response as an object, a string or as an error. * @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
get (uri, { websocket = false } = {}) { get (uri, data = null, options = {}) {
return this.fetch('GET', uri, null, { websocket }).then(response => handleResponse(response, 'GET')) 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<module:api~DigestedResponse[]>} Promise that resolve the api responses as an array.
*/
getAll (uris) {
return Promise.all(uris.map(uri => this.get(uri)))
},
/** /**
* Api post helper function. * Api post helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {string} [data={}] - data to send as body. * @param {String} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api responses as an array. * @param {Options} [options={}] - options to apply to the call
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
post (uri, data = {}) { post (uri, data = {}, options = {}) {
return this.fetch('POST', uri, data).then(response => handleResponse(response, 'POST')) if (typeof uri === 'string') return this.fetch('POST', uri, data, options)
return store.dispatch('POST', { ...uri, data, options })
}, },
/** /**
* Api put helper function. * Api put helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {string} [data={}] - data to send as body. * @param {String} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} Promise that resolve the api responses as an array. * @param {Options} [options={}] - options to apply to the call
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
put (uri, data = {}) { put (uri, data = {}, options = {}) {
return this.fetch('PUT', uri, data).then(response => handleResponse(response, 'PUT')) if (typeof uri === 'string') return this.fetch('PUT', uri, data, options)
return store.dispatch('PUT', { ...uri, data, options })
}, },
/** /**
* Api delete helper function. * Api delete helper function.
* *
* @param {string} uri - the uri to call. * @param {String|Object} uri
* @param {string} [data={}] - data to send as body. * @param {String} [data={}] - data to send as body.
* @return {Promise<('ok'|Error)>} Promise that resolve the api responses as an array. * @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`)
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
*/ */
delete (uri, data = {}) { delete (uri, data = {}, options = {}) {
return this.fetch('DELETE', uri, data).then(response => handleResponse(response, 'DELETE')) if (typeof uri === 'string') return this.fetch('DELETE', uri, data, options)
return store.dispatch('DELETE', { ...uri, data, options })
} }
} }

View file

@ -7,15 +7,15 @@ import i18n from '@/i18n'
class APIError extends Error { class APIError extends Error {
constructor (method, { url, status, statusText }, errorData) { constructor (request, { url, status, statusText }, errorData) {
super(errorData.error || i18n.t('error_server_unexpected')) super(errorData.error || i18n.t('error_server_unexpected'))
const urlObj = new URL(url) const urlObj = new URL(url)
this.name = 'APIError' this.name = 'APIError'
this.code = status this.code = status
this.status = statusText this.status = statusText
this.method = method this.method = request.method
this.request = request
this.path = urlObj.pathname + urlObj.search this.path = urlObj.pathname + urlObj.search
this.logRef = errorData.log_ref || null
} }
log () { 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) // 0 — (means "the connexion has been closed" apparently)
class APIConnexionError extends APIError { class APIConnexionError extends APIError {
@ -83,6 +92,7 @@ class APINotRespondingError extends APIError {
// Temp factory // Temp factory
const errors = { const errors = {
[undefined]: APIError, [undefined]: APIError,
log: APIErrorLog,
0: APIConnexionError, 0: APIConnexionError,
400: APIBadRequestError, 400: APIBadRequestError,
401: APIUnauthorizedError, 401: APIUnauthorizedError,
@ -95,6 +105,7 @@ const errors = {
export { export {
errors as default, errors as default,
APIError, APIError,
APIErrorLog,
APIBadRequestError, APIBadRequestError,
APIConnexionError, APIConnexionError,
APIInternalError, APIInternalError,

View file

@ -13,7 +13,7 @@ import errors, { APIError } from './errors'
* @param {Response} response - A fetch `Response` object. * @param {Response} response - A fetch `Response` object.
* @return {(Object|String)} Parsed response's json or response's text. * @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 // FIXME the api should always return json as response
const responseText = await response.text() const responseText = await response.text()
try { 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. * @param {Object} request - Request info data.
* @return {(Object|String)} Parsed response's json, response's text or an error. * @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event.
*/ */
export async function handleResponse (response, method) { export function openWebSocket (request) {
const responseData = await _getResponseData(response) return new Promise(resolve => {
store.dispatch('SERVER_RESPONDED', response.ok) const ws = new WebSocket(`wss://${store.getters.host}/yunohost/api/messages`)
return response.ok ? responseData : handleError(response, responseData, method) 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. * Handler for API errors.
* *
* @param {Response} response - A fetch `Response` object. * @param {Object} request - Request info data.
* @throws Will throw a custom error with response 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) { export async function handleError (request, response, errorData) {
const errorCode = response.status in errors ? response.status : undefined let errorCode = response.status in errors ? response.status : undefined
// FIXME API: Patching errors that are plain text or html.
if (typeof errorData === 'string') { if (typeof errorData === 'string') {
// FIXME API: Patching errors that are plain text or html.
errorData = { error: errorData } 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. // 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) { export function onUnhandledAPIError (error) {
// In 'development', Babel seems to also catch the error so there's no need to log it twice. // In 'development', Babel seems to also catch the error so there's no need to log it twice.
if (process.env.NODE_ENV !== 'development') { 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 () { 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 => { window.addEventListener('unhandledrejection', e => {
const error = e.reason const error = e.reason
if (error instanceof APIError) { if (error instanceof APIError) {

View file

@ -1,2 +1,2 @@
export { default } from './api' export { default } from './api'
export { handleResponse, handleError, registerGlobalErrorHandlers } from './handlers' export { handleError, registerGlobalErrorHandlers } from './handlers'