diff --git a/public/app/core/services/backend_srv.ts b/public/app/core/services/backend_srv.ts index ef3a1b0b095..5a9db7ce748 100644 --- a/public/app/core/services/backend_srv.ts +++ b/public/app/core/services/backend_srv.ts @@ -1,4 +1,3 @@ -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'; @@ -14,6 +13,7 @@ import { ContextSrv, contextSrv } from './context_srv'; import { coreModule } from 'app/core/core_module'; import { Emitter } from '../utils/emitter'; import { DataSourceResponse } from '../../types/events'; +import { parseInitFromOptions, parseUrlFromOptions } from '../utils/fetch'; export interface DatasourceRequestOptions { retry?: number; @@ -54,18 +54,6 @@ enum CancellationType { 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; @@ -580,62 +568,3 @@ 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 = parseHeaders(options); - const isAppJson = isContentTypeApplicationJson(headers); - const body = parseBody(options, isAppJson); - - return { - method, - headers, - body, - }; -}; - -export const parseHeaders = (options: BackendSrvRequest) => { - const headers = new Headers({ - 'Content-Type': 'application/json', - Accept: 'application/json, text/plain, */*', - }); - - if (options && options.headers) { - Object.keys(options.headers).forEach(key => { - headers.set(key, options.headers[key]); - }); - } - - return headers; -}; - -export const isContentTypeApplicationJson = (headers: Headers) => { - if (!headers) { - return false; - } - - const contentType = headers.get('content-type'); - if (contentType && contentType.toLowerCase() === 'application/json') { - return true; - } - - return false; -}; - -export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => { - if (!options) { - return options; - } - - if (!options.data || typeof options.data === 'string') { - return options.data; - } - - return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data); -}; diff --git a/public/app/core/specs/backend_srv.test.ts b/public/app/core/specs/backend_srv.test.ts index ffed60b05e7..b4ec54dac80 100644 --- a/public/app/core/specs/backend_srv.test.ts +++ b/public/app/core/specs/backend_srv.test.ts @@ -3,15 +3,7 @@ import { Observable, of } from 'rxjs'; import { delay } from 'rxjs/operators'; import { AppEvents } from '@grafana/data'; -import { - BackendSrv, - getBackendSrv, - isContentTypeApplicationJson, - parseBody, - parseHeaders, - parseInitFromOptions, - parseUrlFromOptions, -} from '../services/backend_srv'; +import { BackendSrv, getBackendSrv } from '../services/backend_srv'; import { Emitter } from '../utils/emitter'; import { ContextSrv, User } from '../services/context_srv'; import { CoreEvents } from '../../types'; @@ -27,7 +19,6 @@ const getTestContext = (overides?: object) => { redirected: false, type: 'basic', url: 'http://localhost:3000/api/some-mock', - headers: { 'Content-Type': 'application/json' }, }; const props = { ...defaults, ...overides }; const textMock = jest.fn().mockResolvedValue(JSON.stringify(props.data)); @@ -40,7 +31,6 @@ const getTestContext = (overides?: object) => { redirected: false, type: 'basic', url: 'http://localhost:3000/api/some-mock', - headers: { 'Content-Type': 'application/json' }, }; return of(mockedResponse); }); @@ -358,9 +348,6 @@ describe('backendSrv', () => { const result = await backendSrv.datasourceRequest({ url, method: 'GET', silent: true }); expect(result).toEqual({ data: { test: 'hello world' }, - headers: { - 'Content-Type': 'application/json', - }, ok: true, redirected: false, status: 200, @@ -373,7 +360,6 @@ describe('backendSrv', () => { body: undefined, headers: { map: { - 'content-type': 'application/json', accept: 'application/json, text/plain, */*', }, }, @@ -391,9 +377,6 @@ describe('backendSrv', () => { const result = await backendSrv.datasourceRequest({ url, method: 'GET' }); const expectedResult = { data: { test: 'hello world' }, - headers: { - 'Content-Type': 'application/json', - }, ok: true, redirected: false, status: 200, @@ -406,7 +389,6 @@ describe('backendSrv', () => { body: undefined as any, headers: { map: { - 'content-type': 'application/json', accept: 'application/json, text/plain, */*', }, }, @@ -432,11 +414,6 @@ describe('backendSrv', () => { status: 200, statusText: 'Ok', text: () => Promise.resolve(JSON.stringify(slowData)), - headers: { - map: { - 'content-type': 'application/json', - }, - }, redirected: false, type: 'basic', url, @@ -449,11 +426,6 @@ describe('backendSrv', () => { status: 200, statusText: 'Ok', text: () => Promise.resolve(JSON.stringify(fastData)), - headers: { - map: { - 'content-type': 'application/json', - }, - }, redirected: false, type: 'basic', url, @@ -469,11 +441,6 @@ describe('backendSrv', () => { const fastResponse = await backendSrv.datasourceRequest(options); expect(fastResponse).toEqual({ data: { message: 'Fast Request' }, - headers: { - map: { - 'content-type': 'application/json', - }, - }, ok: true, redirected: false, status: 200, @@ -486,7 +453,6 @@ describe('backendSrv', () => { body: undefined, headers: { map: { - 'content-type': 'application/json', accept: 'application/json, text/plain, */*', }, }, @@ -504,7 +470,6 @@ describe('backendSrv', () => { body: undefined, headers: { map: { - 'content-type': 'application/json', accept: 'application/json, text/plain, */*', }, }, @@ -641,86 +606,3 @@ describe('backendSrv', () => { }); }); }); - -describe('parseUrlFromOptions', () => { - it.each` - params | url | expected - ${undefined} | ${'api/dashboard'} | ${'api/dashboard'} - ${{ key: 'value' }} | ${'api/dashboard'} | ${'api/dashboard?key=value'} - ${{ key: undefined }} | ${'api/dashboard'} | ${'api/dashboard'} - ${{ firstKey: 'first value', secondValue: 'second value' }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value&secondValue=second%20value'} - ${{ firstKey: 'first value', secondValue: undefined }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value'} - ${{ id: [1, 2, 3] }} | ${'api/dashboard'} | ${'api/dashboard?id=1&id=2&id=3'} - ${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'} - `( - "when called with params: '$params' and url: '$url' then result should be '$expected'", - ({ params, url, expected }) => { - expect(parseUrlFromOptions({ params, url })).toEqual(expected); - } - ); -}); - -describe('parseInitFromOptions', () => { - it.each` - method | expected - ${undefined} | ${{ method: undefined, headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }} - ${'GET'} | ${{ method: 'GET', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }} - ${'POST'} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }} - ${'monkey'} | ${{ method: 'monkey', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }} - `( - "when called with method: '$method', headers: '$headers' and data: '$data' then result should be '$expected'", - ({ method, expected }) => { - expect(parseInitFromOptions({ method, data: { id: '0' }, url: '' })).toEqual(expected); - } - ); -}); - -describe('parseHeaders', () => { - it.each` - options | expected - ${undefined} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} - ${{ propKey: 'some prop value' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} - ${{ headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} - ${{ headers: { 'cOnTent-tYpe': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} - ${{ headers: { 'content-type': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }} - ${{ headers: { 'cOnTent-tYpe': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }} - ${{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }} - ${{ headers: { Accept: 'text/plain' } }} | ${{ map: { accept: 'text/plain', 'content-type': 'application/json' } }} - ${{ headers: { Auth: 'Basic asdasdasd' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json', auth: 'Basic asdasdasd' } }} - `("when called with options: '$options' then the result should be '$expected'", ({ options, expected }) => { - expect(parseHeaders(options)).toEqual(expected); - }); -}); - -describe('isContentTypeApplicationJson', () => { - it.each` - headers | expected - ${undefined} | ${false} - ${new Headers({ 'cOnTent-tYpe': 'application/json' })} | ${true} - ${new Headers({ 'content-type': 'AppLiCatIon/JsOn' })} | ${true} - ${new Headers({ 'cOnTent-tYpe': 'AppLiCatIon/JsOn' })} | ${true} - ${new Headers({ 'content-type': 'application/x-www-form-urlencoded' })} | ${false} - ${new Headers({ auth: 'Basic akdjasdkjalksdjasd' })} | ${false} - `("when called with headers: 'headers' then the result should be '$expected'", ({ headers, expected }) => { - expect(isContentTypeApplicationJson(headers)).toEqual(expected); - }); -}); - -describe('parseBody', () => { - it.each` - options | isAppJson | expected - ${undefined} | ${false} | ${undefined} - ${undefined} | ${true} | ${undefined} - ${{ data: undefined }} | ${false} | ${undefined} - ${{ data: undefined }} | ${true} | ${undefined} - ${{ data: 'some data' }} | ${false} | ${'some data'} - ${{ data: 'some data' }} | ${true} | ${'some data'} - ${{ data: { id: '0' } }} | ${false} | ${new URLSearchParams({ id: '0' })} - ${{ data: { id: '0' } }} | ${true} | ${'{"id":"0"}'} - `( - "when called with options: '$options' and isAppJson: '$isAppJson' then the result should be '$expected'", - ({ options, isAppJson, expected }) => { - expect(parseBody(options, isAppJson)).toEqual(expected); - } - ); -}); diff --git a/public/app/core/utils/fetch.test.ts b/public/app/core/utils/fetch.test.ts new file mode 100644 index 00000000000..ff80ba52c50 --- /dev/null +++ b/public/app/core/utils/fetch.test.ts @@ -0,0 +1,101 @@ +import 'whatwg-fetch'; // fetch polyfill needed for PhantomJs rendering +import { + isContentTypeApplicationJson, + parseBody, + parseHeaders, + parseInitFromOptions, + parseUrlFromOptions, +} from './fetch'; + +describe('parseUrlFromOptions', () => { + it.each` + params | url | expected + ${undefined} | ${'api/dashboard'} | ${'api/dashboard'} + ${{ key: 'value' }} | ${'api/dashboard'} | ${'api/dashboard?key=value'} + ${{ key: undefined }} | ${'api/dashboard'} | ${'api/dashboard'} + ${{ firstKey: 'first value', secondValue: 'second value' }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value&secondValue=second%20value'} + ${{ firstKey: 'first value', secondValue: undefined }} | ${'api/dashboard'} | ${'api/dashboard?firstKey=first%20value'} + ${{ id: [1, 2, 3] }} | ${'api/dashboard'} | ${'api/dashboard?id=1&id=2&id=3'} + ${{ id: [] }} | ${'api/dashboard'} | ${'api/dashboard'} + `( + "when called with params: '$params' and url: '$url' then result should be '$expected'", + ({ params, url, expected }) => { + expect(parseUrlFromOptions({ params, url })).toEqual(expected); + } + ); +}); + +describe('parseInitFromOptions', () => { + it.each` + method | data | expected + ${undefined} | ${undefined} | ${{ method: undefined, headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }} + ${'GET'} | ${undefined} | ${{ method: 'GET', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }} + ${'POST'} | ${{ id: '0' }} | ${{ method: 'POST', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }} + ${'PUT'} | ${{ id: '0' }} | ${{ method: 'PUT', headers: { map: { 'content-type': 'application/json', accept: 'application/json, text/plain, */*' } }, body: '{"id":"0"}' }} + ${'monkey'} | ${undefined} | ${{ method: 'monkey', headers: { map: { accept: 'application/json, text/plain, */*' } }, body: undefined }} + `( + "when called with method: '$method' and data: '$data' then result should be '$expected'", + ({ method, data, expected }) => { + expect(parseInitFromOptions({ method, data, url: '' })).toEqual(expected); + } + ); +}); + +describe('parseHeaders', () => { + it.each` + options | expected + ${undefined} | ${{ map: { accept: 'application/json, text/plain, */*' } }} + ${{ propKey: 'some prop value' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }} + ${{ method: 'GET' }} | ${{ map: { accept: 'application/json, text/plain, */*' } }} + ${{ method: 'POST' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ method: 'PUT' }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ method: 'GET', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ method: 'POST', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ method: 'PUT', headers: { 'content-type': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ headers: { 'cOnTent-tYpe': 'application/json' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/json' } }} + ${{ headers: { 'content-type': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }} + ${{ headers: { 'cOnTent-tYpe': 'AppLiCatIon/JsOn' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'AppLiCatIon/JsOn' } }} + ${{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }} + ${{ method: 'GET', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }} + ${{ method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }} + ${{ method: 'PUT', headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }} | ${{ map: { accept: 'application/json, text/plain, */*', 'content-type': 'application/x-www-form-urlencoded' } }} + ${{ headers: { Accept: 'text/plain' } }} | ${{ map: { accept: 'text/plain' } }} + ${{ headers: { Auth: 'Basic asdasdasd' } }} | ${{ map: { accept: 'application/json, text/plain, */*', auth: 'Basic asdasdasd' } }} + `("when called with options: '$options' then the result should be '$expected'", ({ options, expected }) => { + expect(parseHeaders(options)).toEqual(expected); + }); +}); + +describe('isContentTypeApplicationJson', () => { + it.each` + headers | expected + ${undefined} | ${false} + ${new Headers({ 'cOnTent-tYpe': 'application/json' })} | ${true} + ${new Headers({ 'content-type': 'AppLiCatIon/JsOn' })} | ${true} + ${new Headers({ 'cOnTent-tYpe': 'AppLiCatIon/JsOn' })} | ${true} + ${new Headers({ 'content-type': 'application/x-www-form-urlencoded' })} | ${false} + ${new Headers({ auth: 'Basic akdjasdkjalksdjasd' })} | ${false} + `("when called with headers: 'headers' then the result should be '$expected'", ({ headers, expected }) => { + expect(isContentTypeApplicationJson(headers)).toEqual(expected); + }); +}); + +describe('parseBody', () => { + it.each` + options | isAppJson | expected + ${undefined} | ${false} | ${undefined} + ${undefined} | ${true} | ${undefined} + ${{ data: undefined }} | ${false} | ${undefined} + ${{ data: undefined }} | ${true} | ${undefined} + ${{ data: 'some data' }} | ${false} | ${'some data'} + ${{ data: 'some data' }} | ${true} | ${'some data'} + ${{ data: { id: '0' } }} | ${false} | ${new URLSearchParams({ id: '0' })} + ${{ data: { id: '0' } }} | ${true} | ${'{"id":"0"}'} + `( + "when called with options: '$options' and isAppJson: '$isAppJson' then the result should be '$expected'", + ({ options, isAppJson, expected }) => { + expect(parseBody(options, isAppJson)).toEqual(expected); + } + ); +}); diff --git a/public/app/core/utils/fetch.ts b/public/app/core/utils/fetch.ts new file mode 100644 index 00000000000..5d1630b5f77 --- /dev/null +++ b/public/app/core/utils/fetch.ts @@ -0,0 +1,107 @@ +import { BackendSrvRequest } from '@grafana/runtime'; +import omitBy from 'lodash/omitBy'; + +export const parseInitFromOptions = (options: BackendSrvRequest): RequestInit => { + const method = options.method; + const headers = parseHeaders(options); + const isAppJson = isContentTypeApplicationJson(headers); + const body = parseBody(options, isAppJson); + + return { + method, + headers, + body, + }; +}; + +interface HeaderParser { + canParse: (options: BackendSrvRequest) => boolean; + parse: (headers: Headers) => Headers; +} + +const defaultHeaderParser: HeaderParser = { + canParse: () => true, + parse: headers => { + const accept = headers.get('accept'); + if (accept) { + return headers; + } + + headers.set('accept', 'application/json, text/plain, */*'); + return headers; + }, +}; + +const parseHeaderByMethodFactory = (methodPredicate: string): HeaderParser => ({ + canParse: options => { + const method = options?.method ? options?.method.toLowerCase() : ''; + return method === methodPredicate; + }, + parse: headers => { + const contentType = headers.get('content-type'); + if (contentType) { + return headers; + } + + headers.set('content-type', 'application/json'); + return headers; + }, +}); + +const postHeaderParser: HeaderParser = parseHeaderByMethodFactory('post'); +const putHeaderParser: HeaderParser = parseHeaderByMethodFactory('put'); + +const headerParsers = [postHeaderParser, putHeaderParser, defaultHeaderParser]; + +export const parseHeaders = (options: BackendSrvRequest) => { + const headers = options?.headers ? new Headers(options.headers) : new Headers(); + const parsers = headerParsers.filter(parser => parser.canParse(options)); + const combinedHeaders = parsers.reduce((prev, parser) => { + return parser.parse(prev); + }, headers); + + return combinedHeaders; +}; + +export const isContentTypeApplicationJson = (headers: Headers) => { + if (!headers) { + return false; + } + + const contentType = headers.get('content-type'); + if (contentType && contentType.toLowerCase() === 'application/json') { + return true; + } + + return false; +}; + +export const parseBody = (options: BackendSrvRequest, isAppJson: boolean) => { + if (!options) { + return options; + } + + if (!options.data || typeof options.data === 'string') { + return options.data; + } + + return isAppJson ? JSON.stringify(options.data) : new URLSearchParams(options.data); +}; + +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 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; +};