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 { 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<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.
*
* @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<Response>} 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<Object|Error>} 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<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.
*
* @param {string} uri - the uri to call.
* @param {Object} [options={}]
* @param {Boolean} [options.websocket=false] - Open a websocket before this request.
* @return {Promise<module:api~DigestedResponse>} 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<Object|Error>} 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<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.
*
* @param {string} uri - the uri to call.
* @param {string} [data={}] - data to send as body.
* @return {Promise<module:api~DigestedResponse>} 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<Object|Error>} 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<module:api~DigestedResponse>} 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<Object|Error>} 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<Object|Error>} 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 })
}
}

View file

@ -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,

View file

@ -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<Event>} 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) {

View file

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