import omitBy from 'lodash/omitBy'; import { from, merge, MonoTypeOperatorFunction, Observable, of, Subject, throwError } from 'rxjs'; import { catchError, filter, map, mergeMap, retryWhen, share, takeUntil, tap, throwIfEmpty } from 'rxjs/operators'; import { fromFetch } from 'rxjs/fetch'; import { BackendSrv as BackendService, BackendSrvRequest } from '@grafana/runtime'; import { AppEvents } from '@grafana/data'; import appEvents from 'app/core/app_events'; import config from 'app/core/config'; import { DashboardModel } from 'app/features/dashboard/state/DashboardModel'; import { DashboardSearchHit } from 'app/types/search'; import { CoreEvents, DashboardDTO, FolderInfo } from 'app/types'; import { ContextSrv, contextSrv } from './context_srv'; import { coreModule } from 'app/core/core_module'; import { Emitter } from '../utils/emitter'; import { DataSourceResponse } from '../../types/events'; export interface DatasourceRequestOptions { retry?: number; method?: string; requestId?: string; timeout?: Promise; url?: string; headers?: Record; silent?: boolean; data?: Record; } interface FetchResponseProps { message?: string; } interface ErrorResponseProps extends FetchResponseProps { status?: string; error?: string | any; } export interface FetchResponse extends DataSourceResponse {} interface SuccessResponse extends FetchResponseProps, Record {} interface DataSourceSuccessResponse extends FetchResponse {} interface ErrorResponse { status: number; statusText?: string; isHandled?: boolean; data: T | string; cancelled?: boolean; } enum CancellationType { request, dataSourceRequest, } function serializeParams(data: Record): string { return Object.keys(data) .map(key => { const value = data[key]; if (Array.isArray(value)) { return value.map(arrayValue => `${encodeURIComponent(key)}=${encodeURIComponent(arrayValue)}`).join('&'); } return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`; }) .join('&'); } export interface BackendSrvDependencies { fromFetch: (input: string | Request, init?: RequestInit) => Observable; appEvents: Emitter; contextSrv: ContextSrv; logout: () => void; } export class BackendSrv implements BackendService { private inFlightRequests: Subject = new Subject(); private HTTP_REQUEST_CANCELED = -1; private noBackendCache: boolean; private dependencies: BackendSrvDependencies = { fromFetch: fromFetch, appEvents: appEvents, contextSrv: contextSrv, logout: () => { window.location.href = config.appSubUrl + '/logout'; }, }; constructor(deps?: BackendSrvDependencies) { if (deps) { this.dependencies = { ...this.dependencies, ...deps, }; } } async get(url: string, params?: any, requestId?: string) { return await this.request({ method: 'GET', url, params, requestId }); } async delete(url: string) { return await this.request({ method: 'DELETE', url }); } async post(url: string, data?: any) { return await this.request({ method: 'POST', url, data }); } async patch(url: string, data: any) { return await this.request({ method: 'PATCH', url, data }); } async put(url: string, data: any) { return await this.request({ method: 'PUT', url, data }); } withNoBackendCache(callback: any) { this.noBackendCache = true; return callback().finally(() => { this.noBackendCache = false; }); } requestErrorHandler = (err: ErrorResponse) => { if (err.isHandled) { return; } let data = err.data ?? { message: 'Unexpected error' }; if (typeof data === 'string') { data = { message: data }; } if (err.status === 422) { this.dependencies.appEvents.emit(AppEvents.alertWarning, ['Validation failed', data.message]); throw data; } if (data.message) { let description = ''; let message = data.message; if (message.length > 80) { description = message; message = 'Error'; } this.dependencies.appEvents.emit(err.status < 500 ? AppEvents.alertWarning : AppEvents.alertError, [ message, description, ]); } throw data; }; async request(options: BackendSrvRequest): Promise { // A requestId is a unique identifier for a particular query. // Every observable below has a takeUntil that subscribes to this.inFlightRequests and // will cancel/unsubscribe that observable when a new datasourceRequest with the same requestId is made if (options.requestId) { this.inFlightRequests.next(options.requestId); } options = this.parseRequestOptions(options, this.dependencies.contextSrv.user?.orgId); const fromFetchStream = this.getFromFetchStream(options); const failureStream = fromFetchStream.pipe(this.toFailureStream(options)); const successStream = fromFetchStream.pipe( filter(response => response.ok === true), map(response => { const fetchSuccessResponse: SuccessResponse = response.data; return fetchSuccessResponse; }), tap(response => { if (options.method !== 'GET' && response?.message && options.showSuccessAlert !== false) { this.dependencies.appEvents.emit(AppEvents.alertSuccess, [response.message]); } }) ); return merge(successStream, failureStream) .pipe( catchError((err: ErrorResponse) => { // this setTimeout hack enables any caller catching this err to set isHandled to true setTimeout(() => this.requestErrorHandler(err), 50); return throwError(err); }), this.handleStreamCancellation(options, CancellationType.request) ) .toPromise(); } resolveCancelerIfExists(requestId: string) { this.inFlightRequests.next(requestId); } async datasourceRequest(options: BackendSrvRequest): Promise { // A requestId is provided by the datasource as a unique identifier for a // particular query. Every observable below has a takeUntil that subscribes to this.inFlightRequests and // will cancel/unsubscribe that observable when a new datasourceRequest with the same requestId is made if (options.requestId) { this.inFlightRequests.next(options.requestId); } options = this.parseDataSourceRequestOptions( options, this.dependencies.contextSrv.user?.orgId, this.noBackendCache ); const fromFetchStream = this.getFromFetchStream(options); const failureStream = fromFetchStream.pipe(this.toDataSourceRequestFailureStream(options)); const successStream = fromFetchStream.pipe( filter(response => response.ok === true), map(response => { const fetchSuccessResponse: DataSourceSuccessResponse = { ...response }; return fetchSuccessResponse; }), tap(res => { if (!options.silent) { this.dependencies.appEvents.emit(CoreEvents.dsRequestResponse, res); } }) ); return merge(successStream, failureStream) .pipe( catchError((err: ErrorResponse) => { // populate error obj on Internal Error if (typeof err.data === 'string' && err.status === 500) { err.data = { error: err.statusText, response: err.data, }; } // for Prometheus if (err.data && !err.data.message && typeof err.data.error === 'string') { err.data.message = err.data.error; } if (!options.silent) { this.dependencies.appEvents.emit(CoreEvents.dsRequestError, err); } return throwError(err); }), this.handleStreamCancellation(options, CancellationType.dataSourceRequest) ) .toPromise(); } loginPing() { return this.request({ url: '/api/login/ping', method: 'GET', retry: 1 }); } search(query: any): Promise { return this.get('/api/search', query); } getDashboardBySlug(slug: string) { return this.get(`/api/dashboards/db/${slug}`); } getDashboardByUid(uid: string) { return this.get(`/api/dashboards/uid/${uid}`); } getFolderByUid(uid: string) { return this.get(`/api/folders/${uid}`); } saveDashboard( dash: DashboardModel, { message = '', folderId, overwrite = false }: { message?: string; folderId?: number; overwrite?: boolean } = {} ) { return this.post('/api/dashboards/db/', { dashboard: dash, folderId, overwrite, message, }); } createFolder(payload: any) { return this.post('/api/folders', payload); } deleteFolder(uid: string, showSuccessAlert: boolean) { return this.request({ method: 'DELETE', url: `/api/folders/${uid}`, showSuccessAlert: showSuccessAlert === true }); } deleteDashboard(uid: string, showSuccessAlert: boolean) { return this.request({ method: 'DELETE', url: `/api/dashboards/uid/${uid}`, showSuccessAlert: showSuccessAlert === true, }); } deleteFoldersAndDashboards(folderUids: string[], dashboardUids: string[]) { const tasks = []; for (const folderUid of folderUids) { tasks.push(this.createTask(this.deleteFolder.bind(this), true, folderUid, true)); } for (const dashboardUid of dashboardUids) { tasks.push(this.createTask(this.deleteDashboard.bind(this), true, dashboardUid, true)); } return this.executeInOrder(tasks); } moveDashboards(dashboardUids: string[], toFolder: FolderInfo) { const tasks = []; for (const uid of dashboardUids) { tasks.push(this.createTask(this.moveDashboard.bind(this), true, uid, toFolder)); } return this.executeInOrder(tasks).then((result: any) => { return { totalCount: result.length, successCount: result.filter((res: any) => res.succeeded).length, alreadyInFolderCount: result.filter((res: any) => res.alreadyInFolder).length, }; }); } private async moveDashboard(uid: string, toFolder: FolderInfo) { const fullDash: DashboardDTO = await this.getDashboardByUid(uid); const model = new DashboardModel(fullDash.dashboard, fullDash.meta); if ((!fullDash.meta.folderId && toFolder.id === 0) || fullDash.meta.folderId === toFolder.id) { return { alreadyInFolder: true }; } const clone = model.getSaveModelClone(); const options = { folderId: toFolder.id, overwrite: false, }; try { await this.saveDashboard(clone, options); return { succeeded: true }; } catch (err) { if (err.data?.status !== 'plugin-dashboard') { return { succeeded: false }; } err.isHandled = true; options.overwrite = true; try { await this.saveDashboard(clone, options); return { succeeded: true }; } catch (e) { return { succeeded: false }; } } } private createTask(fn: (...args: any[]) => Promise, ignoreRejections: boolean, ...args: any[]) { return async (result: any) => { try { const res = await fn(...args); return Array.prototype.concat(result, [res]); } catch (err) { if (ignoreRejections) { return result; } throw err; } }; } private executeInOrder(tasks: any[]) { return tasks.reduce((acc, task) => { return Promise.resolve(acc).then(task); }, []); } private parseRequestOptions = (options: BackendSrvRequest, orgId?: number): BackendSrvRequest => { options.retry = options.retry ?? 0; const requestIsLocal = !options.url.match(/^http/); if (requestIsLocal) { if (orgId) { options.headers = options.headers ?? {}; options.headers['X-Grafana-Org-Id'] = orgId; } if (options.url.startsWith('/')) { options.url = options.url.substring(1); } if (options.url.endsWith('/')) { options.url = options.url.slice(0, -1); } } return options; }; private parseDataSourceRequestOptions = ( options: BackendSrvRequest, orgId?: number, noBackendCache?: boolean ): BackendSrvRequest => { options.retry = options.retry ?? 0; const requestIsLocal = !options.url.match(/^http/); if (requestIsLocal) { if (orgId) { options.headers = options.headers || {}; options.headers['X-Grafana-Org-Id'] = orgId; } if (options.url.startsWith('/')) { options.url = options.url.substring(1); } if (options.headers?.Authorization) { options.headers['X-DS-Authorization'] = options.headers.Authorization; delete options.headers.Authorization; } if (noBackendCache) { options.headers['X-Grafana-NoCache'] = 'true'; } } return options; }; private getFromFetchStream = (options: BackendSrvRequest) => { const url = parseUrlFromOptions(options); const init = parseInitFromOptions(options); return this.dependencies.fromFetch(url, init).pipe( mergeMap(async response => { const { status, statusText, ok, headers, url, type, redirected } = response; const textData = await response.text(); // this could be just a string, prometheus requests for instance let data; try { data = JSON.parse(textData); // majority of the requests this will be something that can be parsed } catch { data = textData; } const fetchResponse: FetchResponse = { status, statusText, ok, data, headers, url, type, redirected, request: { url, ...init }, }; return fetchResponse; }), share() // sharing this so we can split into success and failure and then merge back ); }; private toFailureStream = (options: BackendSrvRequest): MonoTypeOperatorFunction => inputStream => inputStream.pipe( filter(response => response.ok === false), mergeMap(response => { const { status, statusText, data } = response; const fetchErrorResponse: ErrorResponse = { status, statusText, data }; return throwError(fetchErrorResponse); }), retryWhen((attempts: Observable) => attempts.pipe( mergeMap((error, i) => { const firstAttempt = i === 0 && options.retry === 0; if (error.status === 401 && this.dependencies.contextSrv.user.isSignedIn && firstAttempt) { return from(this.loginPing()).pipe( catchError(err => { if (err.status === 401) { this.dependencies.logout(); return throwError(err); } return throwError(err); }) ); } return throwError(error); }) ) ) ); private toDataSourceRequestFailureStream = ( options: BackendSrvRequest ): MonoTypeOperatorFunction => inputStream => inputStream.pipe( filter(response => response.ok === false), mergeMap(response => { const { status, statusText, data } = response; const fetchErrorResponse: ErrorResponse = { status, statusText, data }; return throwError(fetchErrorResponse); }), retryWhen((attempts: Observable) => attempts.pipe( mergeMap((error, i) => { const requestIsLocal = !options.url.match(/^http/); const firstAttempt = i === 0 && options.retry === 0; // First retry, if loginPing returns 401 this retry sequence will abort with throwError and user is logged out if (requestIsLocal && firstAttempt && error.status === 401) { return from(this.loginPing()).pipe( catchError(err => { if (err.status === 401) { this.dependencies.logout(); return throwError(err); } return throwError(err); }) ); } return throwError(error); }) ) ) ); private handleStreamCancellation = ( options: BackendSrvRequest, resultType: CancellationType ): MonoTypeOperatorFunction => inputStream => inputStream.pipe( takeUntil( this.inFlightRequests.pipe( filter(requestId => { let cancelRequest = false; if (options && options.requestId && options.requestId === requestId) { // when a new requestId is started it will be published to inFlightRequests // if a previous long running request that hasn't finished yet has the same requestId // we need to cancel that request cancelRequest = true; } return cancelRequest; }) ) ), // when a request is cancelled by takeUntil it will complete without emitting anything so we use throwIfEmpty to identify this case // in throwIfEmpty we'll then throw an cancelled error and then we'll return the correct result in the catchError or rethrow throwIfEmpty(() => ({ cancelled: true, })), catchError(err => { if (!err.cancelled) { return throwError(err); } if (resultType === CancellationType.dataSourceRequest) { return of({ data: [], status: this.HTTP_REQUEST_CANCELED, statusText: 'Request was aborted', request: { url: parseUrlFromOptions(options), ...parseInitFromOptions(options) }, }); } return of([]); }) ); } coreModule.factory('backendSrv', () => backendSrv); // Used for testing and things that really need BackendSrv export const backendSrv = new BackendSrv(); export const getBackendSrv = (): BackendSrv => backendSrv; export const parseUrlFromOptions = (options: BackendSrvRequest): string => { const cleanParams = omitBy(options.params, v => v === undefined || (v && v.length === 0)); const serializedParams = serializeParams(cleanParams); return options.params && serializedParams.length ? `${options.url}?${serializedParams}` : options.url; }; export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => { const method = options.method; const headers = { 'Content-Type': 'application/json', Accept: 'application/json, text/plain, */*', ...options.headers, }; const body = parseBody({ ...options, headers }); return { method, headers, body, }; }; const parseBody = (options: BackendSrvRequest) => { if (!options.data || typeof options.data === 'string') { return options.data; } if (options.headers['Content-Type'] === 'application/json') { return JSON.stringify(options.data); } return new URLSearchParams(options.data); };