Improve typing of core API client functions

This commit is contained in:
Andrew Kingston 2024-11-25 16:42:20 +00:00
parent f02797416a
commit fdc6e53495
No known key found for this signature in database
4 changed files with 104 additions and 93 deletions

View File

@ -4,7 +4,7 @@
"description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase",
"license": "MPL-2.0",
"svelte": "src/index.js",
"svelte": "./src/index.ts",
"dependencies": {
"@budibase/bbui": "0.0.0",
"@budibase/shared-core": "0.0.0",

View File

@ -1,3 +1,10 @@
import {
HTTPMethod,
APICallParams,
APIClientConfig,
APIClient,
APICallConfig,
} from "../types"
import { Helpers } from "@budibase/bbui"
import { Header } from "@budibase/shared-core"
import { ApiVersion } from "../constants"
@ -45,55 +52,21 @@ import { buildRowActionEndpoints } from "./rowActions"
*/
export const APISessionID = Helpers.uuid()
const defaultAPIClientConfig = {
/**
* Certain definitions can't change at runtime for client apps, such as the
* schema of tables. The endpoints that are cacheable can be cached by passing
* in this flag. It's disabled by default to avoid bugs with stale data.
*/
enableCaching: false,
/**
* A function can be passed in to attach headers to all outgoing requests.
* This function is passed in the headers object, which should be directly
* mutated. No return value is required.
*/
attachHeaders: null,
/**
* A function can be passed in which will be invoked any time an API error
* occurs. An error is defined as a status code >= 400. This function is
* invoked before the actual JS error is thrown up the stack.
*/
onError: null,
/**
* A function can be passed to be called when an API call returns info about a migration running for a specific app
*/
onMigrationDetected: null,
}
/**
* Constructs an API client with the provided configuration.
* @param config the API client configuration
* @return {object} the API client
*/
export const createAPIClient = config => {
config = {
...defaultAPIClientConfig,
...config,
}
let cache = {}
export const createAPIClient = (config: APIClientConfig = {}) => {
let cache: Record<string, any> = {}
// Generates an error object from an API response
const makeErrorFromResponse = async (
response,
method,
response: Response,
method: HTTPMethod,
suppressErrors = false
) => {
// Try to read a message from the error
let message = response.statusText
let json = null
let json: any = null
try {
json = await response.json()
if (json?.message) {
@ -116,29 +89,24 @@ export const createAPIClient = config => {
}
// Generates an error object from a string
const makeError = (message, request) => {
const makeError = (message: string, url?: string, method?: HTTPMethod) => {
return {
message,
json: null,
status: 400,
url: request?.url,
method: request?.method,
url: url,
method: method,
handled: true,
}
}
// Performs an API call to the server.
const makeApiCall = async ({
method,
url,
body,
json = true,
external = false,
parseResponse,
suppressErrors = false,
}) => {
const makeApiCall = async <T>(callConfig: APICallConfig): Promise<T> => {
let { json, method, external, body, url, parseResponse, suppressErrors } =
callConfig
// Ensure we don't do JSON processing if sending a GET request
json = json && method !== "GET"
json = json && method !== HTTPMethod.GET
// Build headers
let headers = { Accept: "application/json" }
@ -159,12 +127,12 @@ export const createAPIClient = config => {
try {
requestBody = JSON.stringify(body)
} catch (error) {
throw makeError("Invalid JSON body", { url, method })
throw makeError("Invalid JSON body", url, method)
}
}
// Make request
let response
let response: Response
try {
response = await fetch(url, {
method,
@ -174,7 +142,7 @@ export const createAPIClient = config => {
})
} catch (error) {
delete cache[url]
throw makeError("Failed to send request", { url, method })
throw makeError("Failed to send request", url, method)
}
// Handle response
@ -182,13 +150,13 @@ export const createAPIClient = config => {
handleMigrations(response)
try {
if (parseResponse) {
return await parseResponse(response)
return await parseResponse<T>(response)
} else {
return await response.json()
return (await response.json()) as T
}
} catch (error) {
delete cache[url]
return null
throw `Failed to parse response: ${error}`
}
} else {
delete cache[url]
@ -196,7 +164,7 @@ export const createAPIClient = config => {
}
}
const handleMigrations = response => {
const handleMigrations = (response: Response) => {
if (!config.onMigrationDetected) {
return
}
@ -210,48 +178,55 @@ export const createAPIClient = config => {
// Performs an API call to the server and caches the response.
// Future invocation for this URL will return the cached result instead of
// hitting the server again.
const makeCachedApiCall = async params => {
const identifier = params.url
if (!identifier) {
return null
}
const makeCachedApiCall = async <T>(
callConfig: APICallConfig
): Promise<T> => {
const identifier = callConfig.url
if (!cache[identifier]) {
cache[identifier] = makeApiCall(params)
cache[identifier] = makeApiCall(callConfig)
cache[identifier] = await cache[identifier]
}
return await cache[identifier]
return (await cache[identifier]) as T
}
// Constructs an API call function for a particular HTTP method
const requestApiCall = method => async params => {
try {
let { url, cache = false, external = false } = params
if (!external) {
url = `/${url}`.replace("//", "/")
}
const requestApiCall =
(method: HTTPMethod) =>
async <T>(params: APICallParams): Promise<T> => {
try {
let callConfig: APICallConfig = {
json: true,
external: false,
suppressErrors: false,
cache: false,
method,
...params,
}
let { url, cache, external } = callConfig
if (!external) {
callConfig.url = `/${url}`.replace("//", "/")
}
// Cache the request if possible and desired
const cacheRequest = cache && config?.enableCaching
const handler = cacheRequest ? makeCachedApiCall : makeApiCall
const enrichedParams = { ...params, method, url }
return await handler(enrichedParams)
} catch (error) {
if (config?.onError) {
config.onError(error)
// Cache the request if possible and desired
const cacheRequest = cache && config?.enableCaching
const handler = cacheRequest ? makeCachedApiCall : makeApiCall
return await handler<T>(callConfig)
} catch (error) {
if (config?.onError) {
config.onError(error)
}
throw error
}
throw error
}
}
// Build the underlying core API methods
let API = {
post: requestApiCall("POST"),
get: requestApiCall("GET"),
patch: requestApiCall("PATCH"),
delete: requestApiCall("DELETE"),
put: requestApiCall("PUT"),
error: message => {
let API: APIClient = {
post: requestApiCall(HTTPMethod.POST),
get: requestApiCall(HTTPMethod.GET),
patch: requestApiCall(HTTPMethod.PATCH),
delete: requestApiCall(HTTPMethod.DELETE),
put: requestApiCall(HTTPMethod.PUT),
error: (message: string) => {
throw makeError(message)
},
invalidateCache: () => {
@ -260,9 +235,9 @@ export const createAPIClient = config => {
// Generic utility to extract the current app ID. Assumes that any client
// that exists in an app context will be attaching our app ID header.
getAppID: () => {
getAppID: (): string => {
let headers = {}
config?.attachHeaders(headers)
config?.attachHeaders?.(headers)
return headers?.[Header.APP_ID]
},
}

View File

@ -0,0 +1,36 @@
export enum HTTPMethod {
POST = "POST",
PATCH = "PATCH",
GET = "GET",
PUT = "PUT",
DELETE = "DELETE",
}
export type APIClientConfig = {
enableCaching?: boolean
attachHeaders?: Function
onError?: Function
onMigrationDetected?: Function
}
export type APICallConfig = {
method: HTTPMethod
url: string
json: boolean
external: boolean
suppressErrors: boolean
cache: boolean
body?: any
parseResponse?: <T>(response: Response) => Promise<T>
}
export type APICallParams = Pick<APICallConfig, "url"> & Partial<APICallConfig>
export type APIClient = {
post: <T>(params: APICallParams) => Promise<T>
get: <T>(params: APICallParams) => Promise<T>
put: <T>(params: APICallParams) => Promise<T>
delete: <T>(params: APICallParams) => Promise<T>
patch: <T>(params: APICallParams) => Promise<T>
[key: string]: any
}