ts: add api modules typing

This commit is contained in:
axolotle 2024-07-05 15:17:01 +02:00
parent 7c6092e6dc
commit cdaf8a7bcb
4 changed files with 275 additions and 132 deletions

View file

@ -4,41 +4,82 @@
*/ */
import store from '@/store' import store from '@/store'
import { openWebSocket, getResponseData, handleError } from './handlers' import { openWebSocket, getResponseData, getError } from './handlers'
import type { Obj } from '@/types/commons'
/** export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
* 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} asFormData - if `true`, will send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`).
*/
/** type StoreUri = {
* Representation of an API call for `api.fetchAll` uri: string
* storeKey?: string
* @typedef {Array} Query param?: string
* @property {String} 0 - "method" }
* @property {String|Object} 1 - "uri", uri to call as string or as an object for cached uris. type HumanKey = {
* @property {Object|null} 2 - "data" key: string
* @property {Options} 3 - "options" [propName: string]: any
*/ }
type APIQueryOptions = {
// Display the waiting modal
wait?: boolean
// Open a websocket connection
websocket?: boolean
// If an error occurs, the dismiss button will trigger a go back in history
initial?: boolean
// Send the data with a body encoded as `"multipart/form-data"` instead of `"x-www-form-urlencoded"`)
asFormData?: boolean
}
export type APIQuery = [
method: RequestMethod,
uri: string | StoreUri,
data?: Obj | null,
humanKey?: string | HumanKey | null,
options?: APIQueryOptions,
]
export type APIErrorData = {
error: string
error_key?: string
log_ref?: string
traceback?: string
name?: string // FIXME name is field id right?
}
type RequestStatus = 'pending' | 'success' | 'warning' | 'error'
export type APIRequest = {
method: RequestMethod
uri: string
humanRouteKey: HumanKey['key']
humanRoute: string
initial: APIQueryOptions['initial']
status: RequestStatus
}
type WebsocketMessage = {
text: string
status: 'info' | 'success' | 'warning' | 'error'
}
export type APIRequestAction = APIRequest & {
messages: WebsocketMessage[]
date: number
warning: number
errors: number
}
/** /**
* Converts an object literal into an `URLSearchParams` that can be turned into a * Converts an object literal into an `URLSearchParams` that can be turned into a
* query string or used as a body in a `fetch` call. * query string or used as a body in a `fetch` call.
* *
* @param {Object} obj - An object literal to convert. * @param obj - An object literal to convert to `FormData` or `URLSearchParams`
* @param {Object} options * @param addLocale - Append the locale to the returned object
* @param {Boolean} [options.addLocale=false] - Option to append the locale to the query string. * @param formData - Returns a `FormData` instead of `URLSearchParams`
* @return {URLSearchParams}
*/ */
export function objectToParams( export function objectToParams(
obj, obj: Obj,
{ addLocale = false } = {}, { addLocale = false, formData = false } = {},
formData = false,
) { ) {
const urlParams = formData ? new FormData() : new URLSearchParams() const urlParams = formData ? new FormData() : new URLSearchParams()
for (const [key, value] of Object.entries(obj)) { for (const [key, value] of Object.entries(obj)) {
@ -66,36 +107,46 @@ export default {
'X-Requested-With': 'XMLHttpRequest', 'X-Requested-With': 'XMLHttpRequest',
}, },
}, } as RequestInit,
/** /**
* Generic method to fetch the api without automatic response handling. * Generic method to fetch the api.
* *
* @param {String} method - a method between 'GET', 'POST', 'PUT' and 'DELETE'. * @param method - a method in `'GET' | 'POST' | 'PUT' | 'DELETE'`
* @param {String} uri * @param uri - URI to fetch
* @param {Object} [data={}] - data to send as body. * @param data - Data to send as body
* @param {Options} [options={ wait = true, websocket = true, initial = false, asFormData = false }] * @param options - {@link APIQueryOptions}
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error.
* @returns Promise that resolve the api response data
* @throws Throw an `APIError` or subclass depending on server response
*/ */
async fetch( async fetch(
method, method: RequestMethod,
uri, uri: string,
data = {}, data: Obj | null | undefined = {},
humanKey = null, humanKey: string | HumanKey | null = null,
{ wait = true, websocket = true, initial = false, asFormData = false } = {}, {
) { wait = true,
websocket = true,
initial = false,
asFormData = true,
}: APIQueryOptions = {},
): Promise<Obj | string> {
// `await` because Vuex actions returns promises by default. // `await` because Vuex actions returns promises by default.
const request = await store.dispatch('INIT_REQUEST', { const request: APIRequest | APIRequestAction = await store.dispatch(
method, 'INIT_REQUEST',
uri, {
humanKey, method,
initial, uri,
wait, humanKey,
websocket, initial,
}) wait,
websocket,
},
)
if (websocket) { if (websocket) {
await openWebSocket(request) await openWebSocket(request as APIRequestAction)
} }
let options = this.options let options = this.options
@ -105,7 +156,9 @@ export default {
options = { options = {
...options, ...options,
method, method,
body: objectToParams(data, { addLocale: true }, true), body: data
? objectToParams(data, { addLocale: true, formData: asFormData })
: null,
} }
} }
@ -113,9 +166,11 @@ export default {
const responseData = await getResponseData(response) const responseData = await getResponseData(response)
store.dispatch('END_REQUEST', { request, success: response.ok, wait }) store.dispatch('END_REQUEST', { request, success: response.ok, wait })
return response.ok if (!response.ok) {
? responseData throw getError(request, response, responseData as string | APIErrorData)
: handleError(request, response, responseData) }
return responseData
}, },
/** /**
@ -123,20 +178,26 @@ export default {
* Those calls will act as one (declare optional waiting for one but still create history entries for each) * 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. * Calls are synchronous since the API can't handle multiple calls.
* *
* @param {Array<Query>} queries - An array of queries with special representation. * @param queries - Array of {@link APIQuery}
* @param {Object} [options={}] * @param wait - Show the waiting modal until every queries have been resolved
* @param {Boolean} * @param initial - Inform that thoses queries are required for a view to be displayed
* @return {Promise<Array|Error>} Promise that resolve the api responses data or an error. * @returns Promise that resolves an array of server responses
* @throws Throw an `APIError` or subclass depending on server response
*/ */
async fetchAll(queries, { wait, initial } = {}) { async fetchAll(queries: APIQuery[], { wait = false, initial = false } = {}) {
const results = [] const results: Array<Obj | string> = []
if (wait) store.commit('SET_WAITING', true) if (wait) store.commit('SET_WAITING', true)
try { try {
for (const [method, uri, data, humanKey, options = {}] of queries) { for (const [method, uri, data, humanKey, options = {}] of queries) {
if (wait) options.wait = false if (wait) options.wait = false
if (initial) options.initial = true if (initial) options.initial = true
results.push( results.push(
await this[method.toLowerCase()](uri, data, humanKey, options), await this[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](
uri,
data,
humanKey,
options,
),
) )
} }
} finally { } finally {
@ -150,27 +211,41 @@ export default {
/** /**
* Api get helper function. * Api get helper function.
* *
* @param {String|Object} uri * @param uri - uri to fetch
* @param {null} [data=null] - for convenience in muliple calls, just pass null. * @param data - for convenience in muliple calls, just pass null
* @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`) * @param humanKey - key and eventually some data to build the query's description
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @param options - {@link APIQueryOptions}
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/ */
get(uri, data = null, humanKey = null, options = {}) { get(
uri: string | StoreUri,
data: Obj | null = null,
humanKey: string | HumanKey | null = null,
options: APIQueryOptions = {},
): Promise<Obj | string> {
options = { websocket: false, wait: false, ...options } options = { websocket: false, wait: false, ...options }
if (typeof uri === 'string') if (typeof uri === 'string')
return this.fetch('GET', uri, null, humanKey, options) return this.fetch('GET', uri, data, humanKey, options)
return store.dispatch('GET', { ...uri, humanKey, options }) return store.dispatch('GET', { ...uri, humanKey, options })
}, },
/** /**
* Api post helper function. * Api post helper function.
* *
* @param {String|Object} uri * @param uri - uri to fetch
* @param {String} [data={}] - data to send as body. * @param data - data to send as body
* @param {Options} [options={}] - options to apply to the call * @param humanKey - key and eventually some data to build the query's description
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @param options - {@link APIQueryOptions}
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/ */
post(uri, data = {}, humanKey = null, options = {}) { post(
uri: string | StoreUri,
data: Obj | null | undefined = {},
humanKey: string | HumanKey | null = null,
options: APIQueryOptions = {},
): Promise<Obj | string> {
if (typeof uri === 'string') if (typeof uri === 'string')
return this.fetch('POST', uri, data, humanKey, options) return this.fetch('POST', uri, data, humanKey, options)
return store.dispatch('POST', { ...uri, data, humanKey, options }) return store.dispatch('POST', { ...uri, data, humanKey, options })
@ -179,12 +254,19 @@ export default {
/** /**
* Api put helper function. * Api put helper function.
* *
* @param {String|Object} uri * @param uri - uri to fetch
* @param {String} [data={}] - data to send as body. * @param data - data to send as body
* @param {Options} [options={}] - options to apply to the call * @param humanKey - key and eventually some data to build the query's description
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @param options - {@link APIQueryOptions}
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/ */
put(uri, data = {}, humanKey = null, options = {}) { put(
uri: string | StoreUri,
data: Obj | null | undefined = {},
humanKey: string | HumanKey | null = null,
options: APIQueryOptions = {},
): Promise<Obj | string> {
if (typeof uri === 'string') if (typeof uri === 'string')
return this.fetch('PUT', uri, data, humanKey, options) return this.fetch('PUT', uri, data, humanKey, options)
return store.dispatch('PUT', { ...uri, data, humanKey, options }) return store.dispatch('PUT', { ...uri, data, humanKey, options })
@ -193,12 +275,19 @@ export default {
/** /**
* Api delete helper function. * Api delete helper function.
* *
* @param {String|Object} uri * @param uri - uri to fetch
* @param {String} [data={}] - data to send as body. * @param data - data to send as body
* @param {Options} [options={}] - options to apply to the call (default is `{ websocket: false, wait: false }`) * @param humanKey - key and eventually some data to build the query's description
* @return {Promise<Object|Error>} Promise that resolve the api response data or an error. * @param options - {@link APIQueryOptions}
* @returns Promise that resolve the api response data or an error
* @throws Throw an `APIError` or subclass depending on server response
*/ */
delete(uri, data = {}, humanKey = null, options = {}) { delete(
uri: string | StoreUri,
data: Obj | null | undefined = {},
humanKey: string | HumanKey | null = null,
options: APIQueryOptions = {},
): Promise<Obj | string> {
if (typeof uri === 'string') if (typeof uri === 'string')
return this.fetch('DELETE', uri, data, humanKey, options) return this.fetch('DELETE', uri, data, humanKey, options)
return store.dispatch('DELETE', { ...uri, data, humanKey, options }) return store.dispatch('DELETE', { ...uri, data, humanKey, options })
@ -207,16 +296,15 @@ export default {
/** /**
* Api reconnection helper. Resolve when server is reachable or fail after n attemps * Api reconnection helper. Resolve when server is reachable or fail after n attemps
* *
* @param {Number} attemps - number of attemps before rejecting * @param attemps - Number of attemps before rejecting
* @param {Number} delay - delay between calls to the API in ms. * @param delay - Delay between calls to the API in ms
* @param {Number} initialDelay - delay before calling the API for the first time in ms. * @param initialDelay - Delay before calling the API for the first time in ms
* @return {Promise<undefined|Error>} * @returns Promise that resolve yunohost version infos
* @throws Throw an `APIError` or subclass depending on server response
*/ */
tryToReconnect({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) { tryToReconnect({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
const api = this function reconnect(n: number) {
function reconnect(n) {
store store
.dispatch('GET_YUNOHOST_INFOS') .dispatch('GET_YUNOHOST_INFOS')
.then(resolve) .then(resolve)

View file

@ -4,16 +4,27 @@
*/ */
import i18n from '@/i18n' import i18n from '@/i18n'
import type { APIErrorData, RequestMethod, APIRequest } from './api'
class APIError extends Error { class APIError extends Error {
constructor(request, { url, status, statusText }, { error }) { name = 'APIError'
code: number
status: string
method: RequestMethod
request: APIRequest
path: string
constructor(
request: APIRequest,
{ url, status, statusText }: Response,
{ error }: APIErrorData,
) {
super( super(
error error
? error.replaceAll('\n', '<br>') ? error.replaceAll('\n', '<br>')
: i18n.global.t('error_server_unexpected'), : i18n.global.t('error_server_unexpected'),
) )
const urlObj = new URL(url) const urlObj = new URL(url)
this.name = 'APIError'
this.code = status this.code = status
this.status = statusText this.status = statusText
this.method = request.method this.method = request.method
@ -23,76 +34,114 @@ class APIError extends Error {
log() { log() {
/* eslint-disable-next-line */ /* eslint-disable-next-line */
console.error(`${this.name} (${this.code}): ${this.uri}\n${this.message}`) console.error(`${this.name} (${this.code}): ${this.path}\n${this.message}`)
} }
} }
// Log (Special error to trigger a redirect to a log page) // Log (Special error to trigger a redirect to a log page)
class APIErrorLog extends APIError { class APIErrorLog extends APIError {
constructor(method, response, errorData) { name = 'APIErrorLog'
super(method, response, errorData) logRef: string
this.logRef = errorData.log_ref
this.name = 'APIErrorLog' constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.logRef = errorData.log_ref as string
} }
} }
// 0 — (means "the connexion has been closed" apparently) // 0 — (means "the connexion has been closed" apparently)
class APIConnexionError extends APIError { class APIConnexionError extends APIError {
constructor(method, response) { name = 'APIConnexionError'
super(method, response, { constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, {
error: i18n.global.t('error_connection_interrupted'), error: i18n.global.t('error_connection_interrupted'),
}) })
this.name = 'APIConnexionError'
} }
} }
// 400 — Bad Request // 400 — Bad Request
class APIBadRequestError extends APIError { class APIBadRequestError extends APIError {
constructor(method, response, errorData) { name = 'APIBadRequestError'
super(method, response, errorData) key: string
this.name = 'APIBadRequestError' data: APIErrorData
this.key = errorData.error_key
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.key = errorData.error_key as string
this.data = errorData this.data = errorData
} }
} }
// 401 — Unauthorized // 401 — Unauthorized
class APIUnauthorizedError extends APIError { class APIUnauthorizedError extends APIError {
constructor(method, response, errorData) { name = 'APIUnauthorizedError'
super(method, response, { error: i18n.global.t('unauthorized') })
this.name = 'APIUnauthorizedError' constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, { error: i18n.global.t('unauthorized') })
} }
} }
// 404 — Not Found // 404 — Not Found
class APINotFoundError extends APIError { class APINotFoundError extends APIError {
constructor(method, response, errorData) { name = 'APINotFoundError'
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
errorData.error = i18n.global.t('api_not_found') errorData.error = i18n.global.t('api_not_found')
super(method, response, errorData) super(request, response, errorData)
this.name = 'APINotFoundError'
} }
} }
// 500 — Server Internal Error // 500 — Server Internal Error
class APIInternalError extends APIError { class APIInternalError extends APIError {
constructor(method, response, errorData) { name = 'APIInternalError'
super(method, response, errorData) traceback: string | null
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.traceback = errorData.traceback || null this.traceback = errorData.traceback || null
this.name = 'APIInternalError'
} }
} }
// 502 — Bad gateway (means API is down) // 502 — Bad gateway (means API is down)
class APINotRespondingError extends APIError { class APINotRespondingError extends APIError {
constructor(method, response) { name = 'APINotRespondingError'
super(method, response, { error: i18n.global.t('api_not_responding') })
this.name = 'APINotRespondingError' constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, { error: i18n.global.t('api_not_responding') })
} }
} }
// Temp factory // Temp factory
const errors = { const errors = {
[undefined]: APIError, default: APIError,
log: APIErrorLog, log: APIErrorLog,
0: APIConnexionError, 0: APIConnexionError,
400: APIBadRequestError, 400: APIBadRequestError,

View file

@ -5,18 +5,20 @@
import store from '@/store' import store from '@/store'
import errors, { APIError } from './errors' import errors, { APIError } from './errors'
import type { Obj } from '@/types/commons'
import type { APIErrorData, APIRequest, APIRequestAction } from './api'
/** /**
* Try to get response content as json and if it's not as text. * Try to get response content as json and if it's not as text.
* *
* @param {Response} response - A fetch `Response` object. * @param response - A fetch `Response` object.
* @return {(Object|String)} Parsed response's json or response's text. * @returns Parsed response's json or response's text.
*/ */
export async function getResponseData(response) { export async function getResponseData(response: 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 {
return JSON.parse(responseText) return JSON.parse(responseText) as Obj
} catch { } catch {
return responseText return responseText
} }
@ -28,10 +30,10 @@ export async function getResponseData(response) {
* we have to open it for every calls. * we have to open it for every calls.
* Messages are dispatch to the store so it can handle them. * Messages are dispatch to the store so it can handle them.
* *
* @param {Object} request - Request info data. * @param request - Request info data.
* @return {Promise<Event>} Promise that resolve on websocket 'open' or 'error' event. * @returns Promise that resolve on websocket 'open' or 'error' event.
*/ */
export function openWebSocket(request) { export function openWebSocket(request: APIRequestAction): Promise<Event> {
return new Promise((resolve) => { return new Promise((resolve) => {
const ws = new WebSocket( const ws = new WebSocket(
`wss://${store.getters.host}/yunohost/api/messages`, `wss://${store.getters.host}/yunohost/api/messages`,
@ -52,13 +54,19 @@ export function openWebSocket(request) {
/** /**
* Handler for API errors. * Handler for API errors.
* *
* @param {Object} request - Request info data. * @param request - Request info data.
* @param {Response} response - A consumed fetch `Response` object. * @param response - A consumed fetch `Response` object.
* @param {Object|String} errorData - The response parsed json/text. * @param errorData - The response parsed json/text.
* @throws Will throw a `APIError` with request and response data. * @returns an `APIError` or subclass with request and response data.
*/ */
export async function handleError(request, response, errorData) { export function getError(
let errorCode = response.status in errors ? response.status : undefined request: APIRequest,
response: Response,
errorData: string | APIErrorData,
) {
let errorCode = (
response.status in errors ? response.status : 'default'
) as keyof typeof errors
if (typeof errorData === 'string') { if (typeof errorData === 'string') {
// FIXME API: Patching errors that are plain text or html. // FIXME API: Patching errors that are plain text or html.
errorData = { error: errorData } errorData = { error: errorData }
@ -69,16 +77,14 @@ export async function handleError(request, response, errorData) {
} }
// 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](request, response, errorData) return new errors[errorCode](request, response, errorData)
} }
/** /**
* If an APIError is not catched by a view it will be dispatched to the store so the * 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. * error can be displayed in the error modal.
*
* @param {APIError} error
*/ */
export function onUnhandledAPIError(error) { export function onUnhandledAPIError(error: APIError) {
error.log() error.log()
store.dispatch('HANDLE_ERROR', error) store.dispatch('HANDLE_ERROR', error)
} }

View file

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