import { globalState } from '../app/global-state'; import PowerduckState from './../app/powerduck-state'; import { AjaxProviderXhr } from './ajax-xhr'; import ObjectPostProcessor from './utils/object-post-processor'; import TemporalUtils from './utils/temporal-utils'; import { PortalUtils } from './utils/utils'; /** * Interface for the AJAX setting that will configure the AJAX request. * The `T` type parameter ties this definition to its expected response shape * for downstream call sites; it isn't referenced in the body but flows * through `callAjax()` / `callApi()` etc. via the `Promise` return. */ // eslint-disable-next-line unused-imports/no-unused-vars -- phantom type parameter (see jsdoc) export interface AjaxDefinition { /** * Specifies the content type for content negotiation with the server */ contentType?: string; /** * Data to be sent to the server. */ data?: string | object; /** * Timeout (in milliseconds) for the request. */ timeout?: number; /** * Custom JSON property adapter allowing changing JSON property to custom object if desired */ jsonAdapter?: (val: any, propName: string, obj: any) => void; /** * Additional HTTP Headers to-be sent */ headers?: Array; /** * HTTP request provider */ requestProvider?: AppHttpRequestProvider; /** * Settings for automated UI-blocking interface */ blockUi?: AjaxBlockUiDefinition; /** * The type of request to make ("POST" or "GET"), default is "GET". Note: Other HTTP request methods, such as PUT and DELETE, can also be used here, but they are not supported by all browsers. */ type?: string; /** * Determines whether cross-site Access-Control requests should be made using credentials such as cookies. */ withCredentials?: boolean; /** * When true, skip `AppHttpProvider.preRequestHooks` for this call. Set by auth * strategies on their own /refresh request to avoid a deadlock — the hook * would otherwise try to refresh while the refresh call is itself in flight. */ skipPreRequestHooks?: boolean; /** * When true, a 401 on this call is NEVER handed to `AppHttpProvider.authStrategy`. * Set by auth strategies on their own /refresh request: if that request's 401 * ever carried the recoverable envelope (misbehaving proxy/CDN, mocked route), * re-entering the strategy would await the very refresh promise the request * belongs to — a circular wait that hangs every recovery. */ skipAuthStrategy?: boolean; } export interface AjaxDefinitionWithUrl extends AjaxDefinition { /** * A string containing the URL to which the request is sent. */ url: string; } export interface AjaxDefinitionWithApiMethodName extends AjaxDefinition { /** * Name of the app API method to call */ apiMethod: string; /** * Determines if public or private API should be accessed */ apiMode: AppApiMode; /** * Optional App system domain on which the request should be performed, not mandatory */ appDomain?: string; } export interface AjaxHeader { name: string; value: (() => string) | string; } export interface AjaxBlockUiDefinition { enabled: boolean | string; blockArgument: boolean | string; block?: (data: AjaxBlockUiArgs) => void; unblock?: (data: AjaxBlockUiArgs) => void; } export interface AjaxBlockUiArgs { callingElement: HTMLElement; blockArgument: boolean | string; } /** * Error data of the AJAX request */ export interface AjaxError { /** * Determines if the request was authorized by the App server */ authorized: boolean; /** * Response data of the AJAX request */ responseData?: any; /** * Error data of the AJAX request */ errorData?: any; /** * Unique App error code */ appErrorCode: AppErrorCode; /** * HTTP status code */ httpCode: number; /** * Text of the error response from the server */ responseText: string; } export type AppHttpRequestProvider = 'cordovaNative' | 'xhr'; export enum AppApiMode { Public = 0, Private = 1, } export enum AppErrorCode { NotSpecified = 0, Timeout = 1, NullUser = 100, Unauthorized = 101, // Generic units must be below 10000 MandatoryFieldEmpty = 200, // Each tematical unit has reserved 4-digit leading and each part has 4-digit unique code NotEnoughFreeTickets = 10000001, SeatingReservationValidation = 10000002, OneEmailPerEventValidation = 10000003, ReservationExpired = 10000004, PromoterTermsNotAgreedPromoter = 10010001, PromoterTermsNotAgreedAdmin = 10010002, PromoterDataEmptyPromoter = 10010003, PromoterDataEmptyAdmin = 10010004, SeatingReservedBySomeoneElse = 10060001, } export enum AuthenticationScheme { Bearer = 'Bearer ', TokenOnly = '', } export interface AjaxUrlBuilder { buildUrl: (args: AjaxUrlBuilderArgs) => string; } export interface AjaxUrlBuilderArgs { endpoint: string; appDomain: string; apiMode: AppApiMode; } export interface AjaxRequestProviderRequestArgs { method: string; data: any; query: string[]; headers: AjaxHeader[]; timeout: number; url: string; withCredentials?: boolean; } export interface AjaxRequestProviderRequestResponse { httpCode: number; responseText: string; } export interface AjaxRequestProvider { // eslint-disable-next-line unused-imports/no-unused-vars -- phantom type parameter retained for backward compat sendRequest: (args: AjaxRequestProviderRequestArgs) => Promise; } export interface AjaxLogProvider { log: (level: 'debug' | 'info' | 'warning' | 'error', message: string, data: any) => any; } export interface AjaxMiddlewareArgs { response: any; httpCode: number; } export type AjaxMiddleware = (args: AjaxMiddlewareArgs) => Promise; /** * Hook fired BEFORE each request (unless the call opts out via `skipPreRequestHooks`). * Receives the call definition so a hook can read `url`, `type`, `data`, headers, etc. * Multiple hooks may be registered (like {@link AjaxMiddleware}); they run sequentially * in array order. Errors are caught and logged so a misbehaving hook can't block traffic. * * Use case: refresh an about-to-expire access token before the request fires. */ export type AppHttpPreRequestHook = (args: AjaxDefinition) => Promise; /** * Opt-in hook for 401 handling — e.g. transparent refresh-token rotation. * * When set, `AppHttpProvider` calls `onAuthFailure` whenever a request returns * HTTP 401 (Unauthorized / NullUser). The implementation should attempt token * refresh and resolve with the retried response, or reject to propagate the * 401 to the original caller. * * Default: `null` (no-op — 401 errors bubble up as-is). */ export interface AppHttpAuthStrategy { /** * Called when the server returns 401. The strategy receives both a retry * thunk AND the original {@link AjaxError} so it can decide whether the 401 * is recoverable. Powerduck stays app-agnostic — the strategy owns the * recovery policy (e.g. "only refresh when `responseText === 'AUTH_TOKEN_EXPIRED'`"). * * @param retry - Re-runs the original request. Call after refreshing tokens. * @param errObj - The 401 error as built by powerduck. The strategy can read * `responseText`, `appErrorCode`, etc. to discriminate. * @returns - The retried request's result, OR throws (typically the * original `errObj`) to propagate the 401 to the caller. */ onAuthFailure: (retry: () => Promise, errObj: AjaxError) => Promise; } export class AppHttpProvider { static getRetryCount = 0; static postRetryCount = 0; static appLanguage: string = null; static logProvider: AjaxLogProvider = null; static arraySerializeForGet: (val: any[]) => string; static defaultRequestProvider: AppHttpRequestProvider = 'xhr'; static xhrRequestProvider: AjaxRequestProvider = new AjaxProviderXhr(); static cordovaRequestProvider: AjaxRequestProvider = null; static middlewares: AjaxMiddleware[] = []; /** * Optional 401-recovery strategy. When set, called on every Unauthorized * response before bubbling the error to the caller. See {@link AppHttpAuthStrategy}. */ static authStrategy: AppHttpAuthStrategy = null; /** * Pre-request hooks — run sequentially BEFORE every request that doesn't opt * out via `skipPreRequestHooks`. Mirrors {@link AppHttpProvider.middlewares} * (which run AFTER the response) but on the pre-flight side. Each hook * receives the call's {@link AjaxDefinition} args. Errors are caught + logged. */ static preRequestHooks: AppHttpPreRequestHook[] = []; static getRequestTimeoutMessage(): string { return PowerduckState.getResourceValue('requestTimeout'); } static ajaxDefaults = { blockUi: ({ enabled: false, block: null, unblock: null, blockArgument: null, }), headers: >[], withCredentials: false, }; static getRequestProvider(): AjaxRequestProvider { if (this.defaultRequestProvider == 'cordovaNative' && AppHttpProvider.cordovaRequestProvider != null) { return AppHttpProvider.cordovaRequestProvider; } return AppHttpProvider.xhrRequestProvider; } private static log( level: 'info' | 'warning' | 'error', message: string, data?: any, ) { if (this.logProvider != null) { this.logProvider.log( level, message, data, ); } } /** * Post-process JS object to auto-load some common framework types * * @param jsObj * @param jsonAdapter */ static postProcessJsObject(jsObj: T, jsonAdapter?: any): T { return ObjectPostProcessor.postProcessJsObject(jsObj, jsonAdapter); } private static async performAjaxCall( args: AjaxDefinition, url: string, isAuthRetry: boolean = false, ): Promise { // Run pre-request hooks (sequentially, in registration order) before sending. // Errors are non-fatal — log and continue so one misbehaving hook can't block traffic. if (AppHttpProvider.preRequestHooks?.length > 0 && !args.skipPreRequestHooks) { for (const hook of AppHttpProvider.preRequestHooks) { try { await hook(args as AjaxDefinition); } catch (e) { AppHttpProvider.log( 'warning', 'preRequestHook threw — continuing without it', e, ); } } } // Snapshot before the GET/DELETE query-string is appended to `url` below — // the auth-strategy retry must re-enter with the ORIGINAL url, otherwise the // rebuilt query string would be appended a second time. const baseUrl = url; return new Promise((resolve, reject) => { const tryGetJSON = (resp: any): any => { try { return JSON.parse(resp); } catch (e) { try { return JSON.parse(`${resp}}`); } catch (e) { return null; } } }; const method = (args.type || 'GET').toUpperCase(); let data = (args.data || {}); const ajaxDefaults = this.ajaxDefaults; let blockUiEnabled = false; const query: string[] = []; if ((method == 'GET' || method == 'DELETE') && data != null) { for (const key in data) { const value = data[key]; if (value != null) { if (Array.isArray(value)) { const encodedKey = encodeURIComponent(key); if (AppHttpProvider.arraySerializeForGet != null) { query.push(`${encodedKey}=${AppHttpProvider.arraySerializeForGet(value)}`); } else { for (let i = 0; i < value.length; i++) { query.push(`${encodedKey}=${value[i]}`); } } } else { query.push(`${encodeURIComponent(key)}=${encodeURIComponent(data[key])}`); } } } data = null; } if (args.blockUi != null) { if (typeof args.blockUi.enabled === 'string') { blockUiEnabled = args.blockUi.enabled === 'true'; } else { blockUiEnabled = args.blockUi.enabled; } } else if (ajaxDefaults.blockUi != null) { if (typeof ajaxDefaults.blockUi.enabled === 'string') { blockUiEnabled = ajaxDefaults.blockUi.enabled === 'true'; } else { blockUiEnabled = ajaxDefaults.blockUi.enabled; } } let customAuth = false; // Evaluate function-valued headers into FRESH objects — never mutate the // shared `ajaxDefaults.headers` entries in place. In-place evaluation froze // a dynamic header (e.g. `value: () => AppState.currentLanguage`) at its // first-request value for the rest of the session, and stale-valued // headers leaked into auth-strategy retries. let headerArr: AjaxHeader[] = (args.headers || []) .concat(ajaxDefaults.headers || []) .map((ajaxHeader) => { if (ajaxHeader.name == 'Authorization') { customAuth = true; } if (typeof ajaxHeader.value === 'string' || ajaxHeader.value instanceof String) { return { name: ajaxHeader.name, value: ajaxHeader.value }; } return { name: ajaxHeader.name, value: ajaxHeader.value() }; }); if (!customAuth) { if ( AppHttpProvider.bearerToken != null && AppHttpProvider.bearerToken.length > 0 && !url.includes('UserLogin') ) { if (headerArr == null) { headerArr = []; } headerArr.push({ name: AppHttpProvider.authorizationHeaderName, value: AppHttpProvider.authenticationScheme + AppHttpProvider.bearerToken, }); } } if (AppHttpProvider.appLanguage != null && AppHttpProvider.appLanguage.length > 0) { if (headerArr == null) { headerArr = []; } headerArr.push({ name: 'app-language', value: AppHttpProvider.appLanguage, }); } if (args.contentType != null) { if (headerArr == null) { headerArr = []; } headerArr.push({ name: 'Content-type', value: args.contentType, }); headerArr.push({ name: 'Accept', value: '*/*', }); } // Build GET url url = url + (query.length ? `?${query.join('&')}` : ''); // Start request let retryCount = 0; const requestLoop = () => { const start = TemporalUtils.dateNowMs(); AppHttpProvider.getRequestProvider() .sendRequest({ data, headers: headerArr, method, query, timeout: args.timeout, url, withCredentials: args.withCredentials ?? this.ajaxDefaults?.withCredentials, }) .then(async (resp) => { if (blockUiEnabled) { try { this.unblockUi(args); } catch (e) { } } const stop = TemporalUtils.dateNowMs(); const jsonData = tryGetJSON(resp.responseText); if (jsonData != null) { AppHttpProvider.postProcessJsObject(jsonData, args.jsonAdapter); } if (AppHttpProvider.middlewares?.length > 0) { for (const middleware of this.middlewares) { try { await middleware({ httpCode: resp.httpCode, response: jsonData ?? resp.responseText, }); } catch (e) { reject(e); return; } } } if (resp.httpCode < 300 && resp.httpCode > 0) { if (resp.httpCode == 204) { AppHttpProvider.log('info', `Request success to ${url}, took ${stop - start}ms`); resolve(null); } else if (jsonData != null) { AppHttpProvider.log( 'info', `Request success to ${url}, took ${stop - start}ms, data returned`, jsonData, ); resolve(jsonData); } else { AppHttpProvider.log( 'info', `Request success to ${url}, took ${stop - start}ms, text returned`, resp.responseText, ); resolve(resp.responseText); } } else { const errObj: AjaxError = { httpCode: resp.httpCode, data: null, responseText: null, appErrorCode: null, authorized: true, }; // Determine if it's an ExceptionWithStatusCode API call error object, or some other, construct the error object if (jsonData != null && jsonData.ErrorCode != null) { errObj.responseData = jsonData; errObj.responseText = jsonData.ErrorMessage; errObj.appErrorCode = jsonData.ErrorCode; errObj.errorData = jsonData.ErrorData; errObj.authorized = !( errObj.appErrorCode == AppErrorCode.Unauthorized || errObj.appErrorCode == AppErrorCode.NullUser ); try { delete errObj.responseData.ErrorCode; delete errObj.responseData.ErrorMessage; delete errObj.responseData.ErrorData; } catch (e) { // Do nothing, not that essential } } else { errObj.responseText = resp.responseText; } if (resp.httpCode == -4 || resp.httpCode == -6) { const timeoutMsg = AppHttpProvider.getRequestTimeoutMessage(); if (timeoutMsg != null && timeoutMsg.length > 0) { errObj.responseText = timeoutMsg; } errObj.appErrorCode = AppErrorCode.Timeout; } if (errObj.appErrorCode != null && errObj.appErrorCode != AppErrorCode.Timeout) { // 401 hook — hand the error off to the auth strategy if one is // registered. The strategy decides whether the 401 is recoverable // (by reading `errObj.responseText` / app-specific discriminators) // and either retries with fresh tokens or rethrows. Powerduck stays // app-agnostic — no policy lives here. // // `isAuthRetry` caps recovery at ONE retry per logical request: if // the retried call 401s again despite a successful refresh, the // error bubbles to the caller instead of looping refresh→retry→401 // forever. The retry re-enters with `baseUrl` (pre-query-append) so // GET/DELETE params are not serialized twice. if (!errObj.authorized && AppHttpProvider.authStrategy != null && !isAuthRetry && !args.skipAuthStrategy) { AppHttpProvider.authStrategy.onAuthFailure(() => AppHttpProvider.performAjaxCall( args, baseUrl, true, ), errObj) .then(resolve) .catch(reject); return; } AppHttpProvider.log( 'warning', `Request to ${url} failed with known exception, took ${stop - start}ms`, errObj, ); reject(errObj); return; } const performRetry = () => { const getRandomArbitrary = (min, max) => Math.random() * (max - min) + min; setTimeout(() => { AppHttpProvider.log( 'warning', `Attempting retry to ${url }, the request took ${stop - start }ms`, args.data, ); requestLoop(); }, getRandomArbitrary(250, 1100)); }; if (method == 'GET' && retryCount < AppHttpProvider.getRetryCount) { retryCount += 1; performRetry(); } else if (retryCount < AppHttpProvider.postRetryCount) { retryCount += 1; performRetry(); } else { AppHttpProvider.log( 'error', `Request to ${url} has failed, took ${stop - start}ms`, { err: errObj, postData: data, }, ); reject(errObj); } } }); }; AppHttpProvider.log( 'info', `Starting request to ${url}`, args, ); requestLoop(); }); } static enforceDomain: string; static apiBasePath: string = '/api/'; static bearerToken: string; static authenticationScheme: AuthenticationScheme = AuthenticationScheme.Bearer; static authorizationHeaderName: string = 'Authorization'; /** * Performs AJAX call based on given call definition */ static callAjax(args: AjaxDefinitionWithUrl): Promise { return this.performAjaxCall(args, args.url); } /** * Performs AJAX call to current App API version */ static callApi(args: AjaxDefinitionWithApiMethodName): Promise { return this.performAjaxCall(args, this.getApiUrl( args.apiMode, args.appDomain, args.apiMethod, ) + args.apiMethod); } /** * Load JSON-encoded data from the server using a POST HTTP request. * * @param url A string containing the URL to which the request is sent. * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static postJSON( url: string, data: object | string, timeout?: number, ): Promise { if (data != null && !(typeof data === 'string' || data instanceof String)) { data = JSON.stringify(data); } return this.callAjax({ type: 'POST', url, data, contentType: 'application/json; charset=utf-8', timeout, }); } /** * Load JSON-encoded data from the server using a POST HTTP request. * * @param url A string containing the URL to which the request is sent. * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static patchJSON( url: string, data: object | string, timeout?: number, ): Promise { if (data != null && !(typeof data === 'string' || data instanceof String)) { data = JSON.stringify(data); } return this.callAjax({ type: 'PATCH', url, data, contentType: 'application/json; charset=utf-8', timeout, }); } /** * POST data to App API and obtain response * * @param apiMethodName App server API method name * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static apiPost( apiMethodName: string, data: object | string, timeout?: number, ): Promise { return this.postJSON( this.getApiUrl( AppApiMode.Public, null, apiMethodName, ) + apiMethodName, data, timeout, ); } /** * POST data to PRIVATE App API and obtain response * * @param apiMethodName App server API method name * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static privateApiPost( apiMethodName: string, data: object | string, timeout?: number, ): Promise { return this.postJSON( this.getApiUrl( AppApiMode.Private, null, apiMethodName, ) + apiMethodName, data, timeout, ); } /** * PATCH data to App API and obtain response * * @param apiMethodName App server API method name * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static apiPatch( apiMethodName: string, data: object | string, timeout?: number, ): Promise { return this.patchJSON( this.getApiUrl( AppApiMode.Public, null, apiMethodName, ) + apiMethodName, data, timeout, ); } /** * PATCH data to PRIVATE App API and obtain response * * @param apiMethodName App server API method name * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static privateApiPatch( apiMethodName: string, data: object | string, timeout?: number, ): Promise { return this.patchJSON( this.getApiUrl( AppApiMode.Private, null, apiMethodName, ) + apiMethodName, data, timeout, ); } /** * Load JSON-encoded data from the server using a GET HTTP request. * * @param url A string containing the URL to which the request is sent. * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ static getJSON( url: string, data: object | string, timeout?: number, ): Promise { return this.callAjax({ type: 'GET', url, data, contentType: 'application/json; charset=utf-8', timeout, }); } /** * GETs data from App server API * * @param apiMethodName App server API method name * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ public static apiGet( apiMethodName: string, data: object | string, timeout: number = null, ): Promise { return this.getJSON( this.getApiUrl( AppApiMode.Public, null, apiMethodName, ) + apiMethodName, data, timeout, ); } /** * GETs data from PRIVATE App server API * * @param apiMethodName App server API method name * @param data A plain object or string that is sent to the server with the request. * @param timeout Amount of ms after which the request times out */ public static privateApiGet( apiMethodName: string, data: object | string, timeout: number = null, ): Promise { return this.getJSON( this.getApiUrl( AppApiMode.Private, null, apiMethodName, ) + apiMethodName, data, timeout, ); } /** * DELETE data to App API and obtain response * * @param apiMethodName App server API method name * @param args Plain object / string / number used to build the URL (path id or query string). * @param timeout Amount of ms after which the request times out */ static apiDelete( apiMethodName: string, args: number | string | any, timeout?: number, ): Promise { let url: string; if ((!isNaN(parseFloat(args)) && isFinite(args)) || typeof args === 'string' || args instanceof String) { url = `${apiMethodName}/${args}`; } else { url = apiMethodName; for (const key in args as any) { // Skip null/undefined so they aren't serialized as the literal // strings "null"/"undefined" — `encodeURIComponent(null)` returns // "null", which the server would then UPDATE column = 'null' for. if (args[key] == null) { continue; } if (url.includes('?')) { url += '&'; } else { url += '?'; } url += key; url += '='; url += encodeURIComponent(args[key]); } } return this.callApi({ apiMethod: url, apiMode: AppApiMode.Public, type: 'DELETE', timeout, contentType: 'application/json;charset=utf-8', }); } public static getApiUrl( apiMode: AppApiMode, appDomain?: string, endpoint?: string, ): string { if (endpoint != null && endpoint.length > 0) { const builderArr = this.getApiUrlBuilders(); if (builderArr != null && builderArr.length > 0) { const buildArgs: AjaxUrlBuilderArgs = { apiMode, appDomain, endpoint, }; for (let i = 0, len = builderArr.length; i < len; i++) { const builder = builderArr[i]; try { const retVal = builder.buildUrl(buildArgs); if (retVal != null && retVal.length > 0) { return retVal; } } catch (e) { } } } } if (apiMode == AppApiMode.Private) { return '/api/private/'; } if (this.enforceDomain != null && this.enforceDomain.length > 0) { return this.enforceDomain; } if (PortalUtils && (PortalUtils as any).INV_FORCE_DOMAIN != null) { appDomain = (PortalUtils as any).INV_FORCE_DOMAIN; } if (appDomain == null) { appDomain = ''; } return appDomain + AppHttpProvider.apiBasePath; } private static getApiUrlBuilders(): AjaxUrlBuilder[] { if (globalState.inviton && globalState.inviton.ajaxUrlBuilders) { return globalState.inviton.ajaxUrlBuilders; } else if (globalState.appAjaxUrlBuilders) { return globalState.appAjaxUrlBuilders; } return null; } private static blockUi(args: AjaxDefinition) { const blockArgs = this.getBlockArgs(args); const ajaxDefaults = this.ajaxDefaults; if (args.blockUi != null && args.blockUi.block != null) { args.blockUi.block(blockArgs); } else if (ajaxDefaults.blockUi != null && ajaxDefaults.blockUi.block != null) { ajaxDefaults.blockUi.block(blockArgs); } } private static unblockUi(args: AjaxDefinition) { const blockArgs = this.getBlockArgs(args); const ajaxDefaults = this.ajaxDefaults; if (args.blockUi != null && args.blockUi.unblock != null) { args.blockUi.unblock(blockArgs); } else if (ajaxDefaults.blockUi != null && ajaxDefaults.blockUi.unblock != null) { ajaxDefaults.blockUi.unblock(blockArgs); } } private static getBlockArgs(args: AjaxDefinition): AjaxBlockUiArgs { const defVals = (this.ajaxDefaults.blockUi || { blockArgument: null }); return { callingElement: null, // invUtils.getTopMostCallerTarget(null, null) blockArgument: args.blockUi != null ? args.blockUi.blockArgument : defVals.blockArgument, }; } }