// on désactive une regle eslint pour ce fichier /* eslint no-async-promise-executor: 0 */ // --> OFF import HTML from "@supersoniks/concorde/core/utils/HTML"; import Objects from "@supersoniks/concorde/core/utils/Objects"; import { CoreJSType } from "@supersoniks/concorde/core/_types/types"; import { dp, PublisherManager } from "./PublisherProxy"; export type APIConfiguration = { serviceURL: string | null; /** un flag pour indiquer de bloquer les appels suivant au même serviceurl tant que celuis-ci n'a pas terminé */ blockUntilDone?: boolean; token: string | null; userName: string | null; password: string | null; authToken: string | null; tokenProvider: string | null; addHTTPResponse?: boolean; credentials?: RequestCredentials; cache?: RequestCache; keepAlive?: boolean; }; export type CallState = "loading" | "done" | "error" | undefined; export type ResultTypeInterface = CoreJSType & { _sonic_http_response_?: Response; text?: string; }; export type APICall = { apiMethod: "get" | "send" | "submitFormData"; path: string; additionalHeaders: HeadersInit | undefined; method?: string | undefined; data?: unknown; cache?: RequestCache; }; export type APIResponse = { http: Response; processed: ResultTypeInterface; }; class API { /** * Ce tableau static permet de ne pas appeler plusieurs fois le même service lors d'appel concurrents en GET. */ static loadingGetPromises: Map> = new Map(); /** * L'url de base du service sans endpoint */ serviceURL: string | null; /** * le nom de l'utilisateur si basic auth est utilisé */ userName: string | null; /** * le password de l'utilisateur si basic auth est utilisé */ password: string | null; /** * le bearer token a passer pour les appels REST */ private _token: string | null | undefined; set token(token: string | null | undefined) { this._token = token; if (!token) { API.tokens.delete(this.serviceURL); return; } if (API.invalidTokens.includes(token)) return; API.tokens.set(this.serviceURL, token); } get token() { // si le token est marqué invalide, on utilise utilise la dernière version valide connue du token pour ce serviceURL return API.invalidTokens.includes(this._token) ? API.tokens.get(this.serviceURL) : this._token; } /** * Le endPoint pour obtenir le bearer token qui sera concaténé à l'url du service */ tokenProvider: string | null; /** * le bearer token à passer pour un éventuel renouvellement de token automatique */ authToken: string | null; /** * credentials */ credentials?: RequestCredentials; /** * Tableau static des tokens stokés en memoire vive (comportement à revoir à l'occasion) */ static tokens = new Map(); /** * Tableau stockant l'ensemble des tokens invalides */ static invalidTokens: (string | null | undefined)[] = []; handleInvalidToken(token: string | null | undefined) { if (!token) return; if (API.invalidTokens.includes(token)) return; API.invalidTokens.push(token); this.token = null; } /** * Tableau static des tentatives échouées de récupération auto du token */ static failledTokenUpdates = new Map(); /** * Tableau static des flags pour savoir si le premier appel a été fait pour chaque serviceURL */ static firstCallDoneFlags = new Map(); /** * Le endPoint pour obtenir le bearer token qui sera concaténé à l'url du service */ addHTTPResponse = false; cache: RequestCache = "default"; lastResult?: Response; isServiceSimulated = false; blockUntilDone = false; keepAlive = false; constructor(config: APIConfiguration) { this.serviceURL = config.serviceURL; this.blockUntilDone = config.blockUntilDone || false; if (this.serviceURL == "publisher://") { this.isServiceSimulated = true; } if (!this.serviceURL) this.serviceURL = document.location.origin; this.userName = config.userName; this.password = config.password; if (config.token) this.token = config.token; this.tokenProvider = config.tokenProvider; this.authToken = config.authToken; this.addHTTPResponse = config.addHTTPResponse || false; this.credentials = config.credentials; this.cache = config.cache || "default"; this.keepAlive = config.keepAlive || false; } async handleResult( fetchResult: Response, lastCall: APICall ): Promise { API.firstCallDoneFlags.set(this.serviceURL, "done"); this.lastResult = fetchResult; const contentType = fetchResult.headers.get("content-type")?.toLowerCase(); const httpCode = fetchResult.status; let result: ResultTypeInterface = {}; if (!contentType || contentType.indexOf("text/") == 0) { const str = await fetchResult.text(); result = { text: str } as typeof result; } else { try { result = await fetchResult.json(); } catch (e) { result = {} as typeof result; } } if (this.addHTTPResponse && Objects.isObject(result)) { result._sonic_http_response_ = fetchResult; } if (httpCode === 498 && !API.failledTokenUpdates.has(this.serviceURL)) { this.handleInvalidToken(this.token); if (lastCall.apiMethod === "get") { result = await this[lastCall.apiMethod]( lastCall.path, lastCall.additionalHeaders ); } else { result = await this[lastCall.apiMethod]( lastCall.path, lastCall.data, lastCall.method, lastCall.additionalHeaders ); } } /** * Publication en global de la réponse de l'api */ const apiPublisher = dp<{lastResponse: APIResponse}>("sonic-api"); apiPublisher.lastResponse.set({ http: fetchResult, processed: result, }); return result; } /** * Basic auth */ async auth() { if (this.token) return; if (API.tokens.has(this.serviceURL)) { this.token = API.tokens.get(this.serviceURL); return; } if (!this.tokenProvider) return; let headers = {}; if (this.userName && this.password) { headers = { Authorization: "Basic " + window.btoa( unescape(encodeURIComponent(this.userName + ":" + this.password)) ), }; } else if (this.authToken) { headers = { Authorization: "Bearer " + this.authToken, }; } const serviceURL = new URL(this.serviceURL as string); const serviceHost = serviceURL.protocol + "//" + serviceURL.host; const result = await fetch( this.computeURL(this.tokenProvider as string, { serviceHost: serviceHost, }), { headers: headers, credentials: this.credentials, keepalive: this.keepAlive, } ); try { const json = await result.json(); const newToken = json.token; if (newToken) { this.token = json.token; } else { API.failledTokenUpdates.set(this.serviceURL, true); } } catch (e) { API.failledTokenUpdates.set(this.serviceURL, true); } } /** * avec localget, on ne passe pas par le système de token * on recupère path sans les searchparams nommée pathWithoutSearchParams * on cherche dans PublisherManager.get(pathWithoutSearchParams).get() les données qui matchent les searchparams présents dans path * on retourne les données selon le même format que le retour de get * * @todo netttoyer */ async localGet(dataProvider: string, searchString: string) { const publisher = PublisherManager.get(dataProvider); const searchParams = new URLSearchParams(searchString.split("?")[1] || ""); const dataObject = publisher.get(); let data = []; if (!Array.isArray(dataObject)) { data = [dataObject]; } else { data = dataObject; } const result = []; let limit = Number.POSITIVE_INFINITY; let offset = 0; let limitOffsetparams = 0; if (searchParams.has("limit")) { limit = parseInt(searchParams.get("limit") || "0"); limitOffsetparams++; } if (searchParams.has("offset")) { offset = parseInt(searchParams.get("offset") || "0"); limitOffsetparams++; } if (limitOffsetparams > 0) { searchParams.delete("limit"); searchParams.delete("offset"); } if (searchParams.size === 0) return data.slice(offset, offset + limit) as T & ResultTypeInterface; for (const [key, value] of searchParams.entries()) { const values = value.split(",").map((s) => s.trim()); for (const v of values) { for (const item of data) { //si l'item est une valeur primitive on regarde si vaue correspond à item simplement if (typeof item !== "object") { if (!isNaN(+item)) { if (item === value) { result.push(item); } } else if ( item.toString().toLowerCase().includes(value.toLowerCase()) ) { result.push(item); } } else { const record = item as Record; //si l'item est un objet //faire un comparaison de la version string de la même clef dans item pour voir si elle contient value if (!record[key]) continue; //si la valeur est un nombre, on compare les valeurs directement if (!isNaN(+(record[key] as string))) { if (record[key] === v) { result.push(item); } } else { if ( record[key]?.toString().toLowerCase().includes(v.toLowerCase()) ) { result.push(item); } } } } } } return result.slice(offset, offset + limit) as T & ResultTypeInterface; } /** * firstCallDone is a function that returns a promise that is resolved when the first call to the API is done for each serviceURL whatever api instance is used */ firstCallDone() { return new Promise((resolve) => { if (!API.firstCallDoneFlags.has(this.serviceURL)) { API.firstCallDoneFlags.set(this.serviceURL, "loading"); resolve(true); } else { const loop = () => { if ( ![undefined, "loading"].includes( API.firstCallDoneFlags.get(this.serviceURL) ) ) { resolve(true); } else { window.requestAnimationFrame(loop); } }; loop(); } }); } async get(path: string, additionalHeaders?: HeadersInit) { await this.firstCallDone(); if (this.blockUntilDone) { API.firstCallDoneFlags.set(this.serviceURL, "loading"); } const regExp = /dataProvider\((.*?)\)(.*?)$/; if (regExp.test(path)) { const match = path.match(regExp); if (!match) throw new Error("dataProvider path is not valid"); return await this.localGet(match[1], match[2]); } const lastCall: APICall = { apiMethod: "get", path: path, additionalHeaders: additionalHeaders, }; const headers = await this.createHeaders(additionalHeaders); const url = this.computeURL(path); const mapKey = JSON.stringify({ url: url, headers: headers, }); if (!API.loadingGetPromises.has(mapKey)) { const promise = new Promise(async (resolve) => { try { const result = await fetch(url, { headers: headers, credentials: this.credentials, cache: this.cache, keepalive: this.keepAlive, }); const handledResult = await this.handleResult(result, lastCall); resolve(handledResult); } catch (e) { resolve(null); } }); API.loadingGetPromises.set(mapKey, promise); } const result = (await API.loadingGetPromises.get(mapKey)) as T; API.loadingGetPromises.delete(mapKey); return result as T & ResultTypeInterface; } /** * Création du header, avec authentification si besoin * ajout du language via le header accept-language qui contient le langue du navigateur */ async createHeaders(additionalHeaders?: HeadersInit) { await this.auth(); const headers: HeadersInit = {}; if (this.token) headers.Authorization = "Bearer " + this.token; headers["Accept-Language"] = HTML.getLanguage(); if (additionalHeaders) { Object.assign(headers, additionalHeaders); } return headers; } /** * Concatène le serviceURL et le endpoint donné en paramètre */ computeURL(path: string, query: Record = {}) { let url = ""; if (path.startsWith("http")) url = path as string; else url = this.serviceURL + "/" + path; if (!url.startsWith("http")) url = window.location.origin + url; const computedUrl = new URL(url); for (const key in query) { computedUrl.searchParams.set(key, query[key]); } return computedUrl.toString().replace(/([^(https?:)])\/{2,}/g, "$1/"); } /* * Envoie des données au endPoint passé en paramètre. par défaut en POST */ async send( path: string, data: SendType, method = "POST", additionalHeaders?: HeadersInit ) { const lastCall: APICall = { apiMethod: "send", path: path, additionalHeaders: additionalHeaders, method: method, data: data, }; const headers = await this.createHeaders(additionalHeaders); headers["Accept"] = "application/json"; headers["Content-Type"] = "application/json"; const result = await fetch(this.computeURL(path), { headers: headers, credentials: this.credentials, method: method, body: JSON.stringify(data), keepalive: this.keepAlive, }); return (await this.handleResult(result, lastCall)) as T & ResultTypeInterface; } /** * Agit comme une soumission de formulaire, mais attends un json en réponse */ async submitFormData( path: string, data: SendType, method = "POST", additionalHeaders?: HeadersInit ) { const lastCall: APICall = { apiMethod: "submitFormData", path: path, additionalHeaders: additionalHeaders, method: method, data: data, }; const headers = await this.createHeaders(additionalHeaders); headers["Accept"] = "application/json"; const formData = new FormData(); const dynamicData = data as unknown as { [property: string]: string }; for (const z in dynamicData) formData.set(z, dynamicData[z]); const result = await fetch(this.computeURL(path), { headers: headers, credentials: this.credentials, method: method, body: formData, keepalive: this.keepAlive, }); return (await this.handleResult(result, lastCall)) as T & ResultTypeInterface; } /** * Appel send en utilisant le méthode PUT */ async put( path: string, data: SendType, additionalHeaders?: HeadersInit ) { return this.send(path, data, "PUT", additionalHeaders); } /** * Appel send en utilisant le méthode POST */ async post( path: string, data: SendType, additionalHeaders?: HeadersInit ) { return this.send(path, data, "POST", additionalHeaders); } /** * Appel send en utilisant le méthode PATCH */ async patch( path: string, data: SendType, additionalHeaders?: HeadersInit ) { return this.send(path, data, "PATCH", additionalHeaders); } /** * Appel send en utilisant le méthode delete */ async delete( path: string, data: SendType, additionalHeaders?: HeadersInit ) { return this.send(path, data, "delete", additionalHeaders); } } export default API; /* logs handling */ let logIsConf = false; let logEndPoint = "log"; let logConfiguration: APIConfiguration = HTML.getApiConfiguration( document.body || document.documentElement ); let logsToSend: any = []; let logsSendId = 0; export const log = async (message: string, variables?: any) => { if (!logIsConf) { return; } logsToSend.push({ message: message, variables: variables ? variables : {}, }); logsSendId++; const id = logsSendId; window.queueMicrotask(() => { if (id !== logsSendId) return; const sendedLogs = [...logsToSend]; logsToSend = []; const api = new API(logConfiguration); return api.post(logEndPoint, { logs: sendedLogs }); }); }; let errorCount = 0; export const configLog = (newConfig: APIConfiguration, endPoint = "log") => { logEndPoint = endPoint; logConfiguration = newConfig; logIsConf = true; window.addEventListener("error", (event) => { if (errorCount > 3) return; errorCount++; let href = ""; try { href = window.location.href; } catch (e) { href = "unknown"; } const errorDetails = { eventType: "error", page: href, filename: event.filename || "unknown", lineno: event.lineno || null, colno: event.colno || null, stack: event.error?.stack || "Stack not available", isCrossOrigin: event.message === "Script error.", }; log(event.message || "Script error.", errorDetails); return false; }); window.addEventListener( "unhandledrejection", (event: PromiseRejectionEvent) => { if (errorCount > 3) return; errorCount++; let href = ""; try { href = window.location.href; } catch (e) { href = "unknown"; } const errorDetails = { eventType: "unhandledrejection", page: href, code: event.reason?.code || "none", stack: event.reason?.stack || "Stack not available", timestamp: new Date().toISOString(), }; log(event.reason?.message || "Unknown rejection reason.", errorDetails); } ); };