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 { openWebSocket, getResponseData, handleError } from './handlers'
import { openWebSocket, getResponseData, getError } from './handlers'
import type { Obj } from '@/types/commons'
/**
* 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"`).
*/
export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'DELETE'
/**
* 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"
*/
type StoreUri = {
uri: string
storeKey?: string
param?: string
}
type HumanKey = {
key: string
[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
* query string or used as a body in a `fetch` call.
*
* @param {Object} obj - An object literal to convert.
* @param {Object} options
* @param {Boolean} [options.addLocale=false] - Option to append the locale to the query string.
* @return {URLSearchParams}
* @param obj - An object literal to convert to `FormData` or `URLSearchParams`
* @param addLocale - Append the locale to the returned object
* @param formData - Returns a `FormData` instead of `URLSearchParams`
*/
export function objectToParams(
obj,
{ addLocale = false } = {},
formData = false,
obj: Obj,
{ addLocale = false, formData = false } = {},
) {
const urlParams = formData ? new FormData() : new URLSearchParams()
for (const [key, value] of Object.entries(obj)) {
@ -66,36 +107,46 @@ export default {
'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 {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.
* @param method - a method in `'GET' | 'POST' | 'PUT' | 'DELETE'`
* @param uri - URI to fetch
* @param data - Data to send as body
* @param options - {@link APIQueryOptions}
* @returns Promise that resolve the api response data
* @throws Throw an `APIError` or subclass depending on server response
*/
async fetch(
method,
uri,
data = {},
humanKey = null,
{ wait = true, websocket = true, initial = false, asFormData = false } = {},
) {
method: RequestMethod,
uri: string,
data: Obj | null | undefined = {},
humanKey: string | HumanKey | null = null,
{
wait = true,
websocket = true,
initial = false,
asFormData = true,
}: APIQueryOptions = {},
): Promise<Obj | string> {
// `await` because Vuex actions returns promises by default.
const request = await store.dispatch('INIT_REQUEST', {
method,
uri,
humanKey,
initial,
wait,
websocket,
})
const request: APIRequest | APIRequestAction = await store.dispatch(
'INIT_REQUEST',
{
method,
uri,
humanKey,
initial,
wait,
websocket,
},
)
if (websocket) {
await openWebSocket(request)
await openWebSocket(request as APIRequestAction)
}
let options = this.options
@ -105,7 +156,9 @@ export default {
options = {
...options,
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)
store.dispatch('END_REQUEST', { request, success: response.ok, wait })
return response.ok
? responseData
: handleError(request, response, responseData)
if (!response.ok) {
throw getError(request, response, responseData as string | APIErrorData)
}
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)
* 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.
* @param queries - Array of {@link APIQuery}
* @param wait - Show the waiting modal until every queries have been resolved
* @param initial - Inform that thoses queries are required for a view to be displayed
* @returns Promise that resolves an array of server responses
* @throws Throw an `APIError` or subclass depending on server response
*/
async fetchAll(queries, { wait, initial } = {}) {
const results = []
async fetchAll(queries: APIQuery[], { wait = false, initial = false } = {}) {
const results: Array<Obj | string> = []
if (wait) store.commit('SET_WAITING', true)
try {
for (const [method, uri, data, humanKey, options = {}] of queries) {
if (wait) options.wait = false
if (initial) options.initial = true
results.push(
await this[method.toLowerCase()](uri, data, humanKey, options),
await this[method.toLowerCase() as 'get' | 'post' | 'put' | 'delete'](
uri,
data,
humanKey,
options,
),
)
}
} finally {
@ -150,27 +211,41 @@ export default {
/**
* Api get helper function.
*
* @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.
* @param uri - uri to fetch
* @param data - for convenience in muliple calls, just pass null
* @param humanKey - key and eventually some data to build the query's description
* @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 }
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 })
},
/**
* Api post helper function.
*
* @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.
* @param uri - uri to fetch
* @param data - data to send as body
* @param humanKey - key and eventually some data to build the query's description
* @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')
return this.fetch('POST', uri, data, humanKey, options)
return store.dispatch('POST', { ...uri, data, humanKey, options })
@ -179,12 +254,19 @@ export default {
/**
* Api put helper function.
*
* @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.
* @param uri - uri to fetch
* @param data - data to send as body
* @param humanKey - key and eventually some data to build the query's description
* @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')
return this.fetch('PUT', uri, data, humanKey, options)
return store.dispatch('PUT', { ...uri, data, humanKey, options })
@ -193,12 +275,19 @@ export default {
/**
* Api delete helper function.
*
* @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.
* @param uri - uri to fetch
* @param data - data to send as body
* @param humanKey - key and eventually some data to build the query's description
* @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')
return this.fetch('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
*
* @param {Number} attemps - number of attemps before rejecting
* @param {Number} delay - delay between calls to the API in ms.
* @param {Number} initialDelay - delay before calling the API for the first time in ms.
* @return {Promise<undefined|Error>}
* @param attemps - Number of attemps before rejecting
* @param delay - Delay between calls to the API in ms
* @param initialDelay - Delay before calling the API for the first time in ms
* @returns Promise that resolve yunohost version infos
* @throws Throw an `APIError` or subclass depending on server response
*/
tryToReconnect({ attemps = 5, delay = 2000, initialDelay = 0 } = {}) {
return new Promise((resolve, reject) => {
const api = this
function reconnect(n) {
function reconnect(n: number) {
store
.dispatch('GET_YUNOHOST_INFOS')
.then(resolve)

View file

@ -4,16 +4,27 @@
*/
import i18n from '@/i18n'
import type { APIErrorData, RequestMethod, APIRequest } from './api'
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(
error
? error.replaceAll('\n', '<br>')
: i18n.global.t('error_server_unexpected'),
)
const urlObj = new URL(url)
this.name = 'APIError'
this.code = status
this.status = statusText
this.method = request.method
@ -23,76 +34,114 @@ class APIError extends Error {
log() {
/* 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)
class APIErrorLog extends APIError {
constructor(method, response, errorData) {
super(method, response, errorData)
this.logRef = errorData.log_ref
this.name = 'APIErrorLog'
name = 'APIErrorLog'
logRef: string
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)
class APIConnexionError extends APIError {
constructor(method, response) {
super(method, response, {
name = 'APIConnexionError'
constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, {
error: i18n.global.t('error_connection_interrupted'),
})
this.name = 'APIConnexionError'
}
}
// 400 — Bad Request
class APIBadRequestError extends APIError {
constructor(method, response, errorData) {
super(method, response, errorData)
this.name = 'APIBadRequestError'
this.key = errorData.error_key
name = 'APIBadRequestError'
key: string
data: APIErrorData
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.key = errorData.error_key as string
this.data = errorData
}
}
// 401 — Unauthorized
class APIUnauthorizedError extends APIError {
constructor(method, response, errorData) {
super(method, response, { error: i18n.global.t('unauthorized') })
this.name = 'APIUnauthorizedError'
name = 'APIUnauthorizedError'
constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, { error: i18n.global.t('unauthorized') })
}
}
// 404 — Not Found
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')
super(method, response, errorData)
this.name = 'APINotFoundError'
super(request, response, errorData)
}
}
// 500 — Server Internal Error
class APIInternalError extends APIError {
constructor(method, response, errorData) {
super(method, response, errorData)
name = 'APIInternalError'
traceback: string | null
constructor(
request: APIRequest,
response: Response,
errorData: APIErrorData,
) {
super(request, response, errorData)
this.traceback = errorData.traceback || null
this.name = 'APIInternalError'
}
}
// 502 — Bad gateway (means API is down)
class APINotRespondingError extends APIError {
constructor(method, response) {
super(method, response, { error: i18n.global.t('api_not_responding') })
this.name = 'APINotRespondingError'
name = 'APINotRespondingError'
constructor(
request: APIRequest,
response: Response,
_errorData: APIErrorData,
) {
super(request, response, { error: i18n.global.t('api_not_responding') })
}
}
// Temp factory
const errors = {
[undefined]: APIError,
default: APIError,
log: APIErrorLog,
0: APIConnexionError,
400: APIBadRequestError,

View file

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

View file

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