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", "description": "Budibase frontend core libraries used in builder and client",
"author": "Budibase", "author": "Budibase",
"license": "MPL-2.0", "license": "MPL-2.0",
"svelte": "src/index.js", "svelte": "./src/index.ts",
"dependencies": { "dependencies": {
"@budibase/bbui": "0.0.0", "@budibase/bbui": "0.0.0",
"@budibase/shared-core": "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 { Helpers } from "@budibase/bbui"
import { Header } from "@budibase/shared-core" import { Header } from "@budibase/shared-core"
import { ApiVersion } from "../constants" import { ApiVersion } from "../constants"
@ -45,55 +52,21 @@ import { buildRowActionEndpoints } from "./rowActions"
*/ */
export const APISessionID = Helpers.uuid() 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. * Constructs an API client with the provided configuration.
* @param config the API client configuration
* @return {object} the API client
*/ */
export const createAPIClient = config => { export const createAPIClient = (config: APIClientConfig = {}) => {
config = { let cache: Record<string, any> = {}
...defaultAPIClientConfig,
...config,
}
let cache = {}
// Generates an error object from an API response // Generates an error object from an API response
const makeErrorFromResponse = async ( const makeErrorFromResponse = async (
response, response: Response,
method, method: HTTPMethod,
suppressErrors = false suppressErrors = false
) => { ) => {
// Try to read a message from the error // Try to read a message from the error
let message = response.statusText let message = response.statusText
let json = null let json: any = null
try { try {
json = await response.json() json = await response.json()
if (json?.message) { if (json?.message) {
@ -116,29 +89,24 @@ export const createAPIClient = config => {
} }
// Generates an error object from a string // Generates an error object from a string
const makeError = (message, request) => { const makeError = (message: string, url?: string, method?: HTTPMethod) => {
return { return {
message, message,
json: null, json: null,
status: 400, status: 400,
url: request?.url, url: url,
method: request?.method, method: method,
handled: true, handled: true,
} }
} }
// Performs an API call to the server. // Performs an API call to the server.
const makeApiCall = async ({ const makeApiCall = async <T>(callConfig: APICallConfig): Promise<T> => {
method, let { json, method, external, body, url, parseResponse, suppressErrors } =
url, callConfig
body,
json = true,
external = false,
parseResponse,
suppressErrors = false,
}) => {
// Ensure we don't do JSON processing if sending a GET request // Ensure we don't do JSON processing if sending a GET request
json = json && method !== "GET" json = json && method !== HTTPMethod.GET
// Build headers // Build headers
let headers = { Accept: "application/json" } let headers = { Accept: "application/json" }
@ -159,12 +127,12 @@ export const createAPIClient = config => {
try { try {
requestBody = JSON.stringify(body) requestBody = JSON.stringify(body)
} catch (error) { } catch (error) {
throw makeError("Invalid JSON body", { url, method }) throw makeError("Invalid JSON body", url, method)
} }
} }
// Make request // Make request
let response let response: Response
try { try {
response = await fetch(url, { response = await fetch(url, {
method, method,
@ -174,7 +142,7 @@ export const createAPIClient = config => {
}) })
} catch (error) { } catch (error) {
delete cache[url] delete cache[url]
throw makeError("Failed to send request", { url, method }) throw makeError("Failed to send request", url, method)
} }
// Handle response // Handle response
@ -182,13 +150,13 @@ export const createAPIClient = config => {
handleMigrations(response) handleMigrations(response)
try { try {
if (parseResponse) { if (parseResponse) {
return await parseResponse(response) return await parseResponse<T>(response)
} else { } else {
return await response.json() return (await response.json()) as T
} }
} catch (error) { } catch (error) {
delete cache[url] delete cache[url]
return null throw `Failed to parse response: ${error}`
} }
} else { } else {
delete cache[url] delete cache[url]
@ -196,7 +164,7 @@ export const createAPIClient = config => {
} }
} }
const handleMigrations = response => { const handleMigrations = (response: Response) => {
if (!config.onMigrationDetected) { if (!config.onMigrationDetected) {
return return
} }
@ -210,48 +178,55 @@ export const createAPIClient = config => {
// Performs an API call to the server and caches the response. // Performs an API call to the server and caches the response.
// Future invocation for this URL will return the cached result instead of // Future invocation for this URL will return the cached result instead of
// hitting the server again. // hitting the server again.
const makeCachedApiCall = async params => { const makeCachedApiCall = async <T>(
const identifier = params.url callConfig: APICallConfig
if (!identifier) { ): Promise<T> => {
return null const identifier = callConfig.url
}
if (!cache[identifier]) { if (!cache[identifier]) {
cache[identifier] = makeApiCall(params) cache[identifier] = makeApiCall(callConfig)
cache[identifier] = await cache[identifier] 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 // Constructs an API call function for a particular HTTP method
const requestApiCall = method => async params => { const requestApiCall =
try { (method: HTTPMethod) =>
let { url, cache = false, external = false } = params async <T>(params: APICallParams): Promise<T> => {
if (!external) { try {
url = `/${url}`.replace("//", "/") 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 // Cache the request if possible and desired
const cacheRequest = cache && config?.enableCaching const cacheRequest = cache && config?.enableCaching
const handler = cacheRequest ? makeCachedApiCall : makeApiCall const handler = cacheRequest ? makeCachedApiCall : makeApiCall
return await handler<T>(callConfig)
const enrichedParams = { ...params, method, url } } catch (error) {
return await handler(enrichedParams) if (config?.onError) {
} catch (error) { config.onError(error)
if (config?.onError) { }
config.onError(error) throw error
} }
throw error
} }
}
// Build the underlying core API methods // Build the underlying core API methods
let API = { let API: APIClient = {
post: requestApiCall("POST"), post: requestApiCall(HTTPMethod.POST),
get: requestApiCall("GET"), get: requestApiCall(HTTPMethod.GET),
patch: requestApiCall("PATCH"), patch: requestApiCall(HTTPMethod.PATCH),
delete: requestApiCall("DELETE"), delete: requestApiCall(HTTPMethod.DELETE),
put: requestApiCall("PUT"), put: requestApiCall(HTTPMethod.PUT),
error: message => { error: (message: string) => {
throw makeError(message) throw makeError(message)
}, },
invalidateCache: () => { invalidateCache: () => {
@ -260,9 +235,9 @@ export const createAPIClient = config => {
// Generic utility to extract the current app ID. Assumes that any client // 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. // that exists in an app context will be attaching our app ID header.
getAppID: () => { getAppID: (): string => {
let headers = {} let headers = {}
config?.attachHeaders(headers) config?.attachHeaders?.(headers)
return headers?.[Header.APP_ID] 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
}