diff --git a/src/api/util.spec.ts b/src/api/util.spec.ts index 80a55f2..dc0c59a 100644 --- a/src/api/util.spec.ts +++ b/src/api/util.spec.ts @@ -13,15 +13,45 @@ describe('module api/util', () => { }) describe('detectVersion', () => { - it('returns version string from version field in response', async () => { - const version = '1.2.3-rc4' + it('returns version string for API v4 with major and minor', async () => { + const expectedVersion = '4.2' mockedFetch.mockResolvedValueOnce({ ok: true, status: 200, - json: () => Promise.resolve({ version }), + json: () => + Promise.resolve({ data_api_version: { major: 4, minor: 2 } }), } as Response) const actualVersion = await detectVersion(DEFAULT_URL) - expect(actualVersion).toBe(version) + expect(actualVersion).toBe(expectedVersion) + }) + + it('returns version string for API v4 with major version only', async () => { + const expectedVersion = '4' + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => Promise.resolve({ data_api_version: { major: 4 } }), + } as Response) + const actualVersion = await detectVersion(DEFAULT_URL) + expect(actualVersion).toBe(expectedVersion) + }) + + it('throws if unsupported response format', async () => { + mockedFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + json: () => + Promise.resolve({ + new_version_structure: { + major: 5, + minor: 2, + patch: 7, + text: '5.2.7', + build: 'experimental', + }, + }), + } as Response) + await expect(detectVersion(DEFAULT_URL)).rejects.toThrow() }) it('returns undefined on HTTP 404', async () => { diff --git a/src/api/util.ts b/src/api/util.ts index a5b2423..aa3b713 100644 --- a/src/api/util.ts +++ b/src/api/util.ts @@ -1,12 +1,5 @@ -import * as decoders from 'decoders' import { fetchWithTimeout } from '../http' - -type VersionInfo = { - version: string -} -const versionGuard = decoders.guard( - decoders.object({ version: decoders.string }) -) +import { dataApiVersionResponseGuard } from './v4/apiv4decoders' /** * Contact the API provider at base URL `url` and try to read @@ -24,17 +17,25 @@ export const detectVersion = async ( headers: { Accept: 'application/json' }, }) if (resp.status === 404) { - return undefined + return undefined // OK, this API provider doesn't provide a /version endpoint } if (!resp.ok) { throw new Error( `could not detect version: ${resp.status} ${resp.statusText}` ) } + const respData: unknown = await resp.json() + // in turn, try each known API version try { - const versionInfo: VersionInfo = versionGuard(await resp.json()) - return versionInfo.version + const tmp = dataApiVersionResponseGuard(respData) + let v = `${tmp.data_api_version.major}` + if (tmp.data_api_version.minor) { + v += `.${tmp.data_api_version.minor}` + } + return v } catch (e) { - throw new Error('received malformed version info') + // decoder throws error --> it's not Data API V4; try next one (if there is any) } + // out of known API versions / providers --> throw error + throw new Error('could not identify version from API response') } diff --git a/src/api/v4/apiv4.spec.ts b/src/api/v4/apiv4.spec.ts index e06cf10..cad4761 100644 --- a/src/api/v4/apiv4.spec.ts +++ b/src/api/v4/apiv4.spec.ts @@ -6,6 +6,7 @@ import { DataApiV4ChannelSearchOptions, DataApiV4ChannelSearchResult, DataApiV4Client, + DataApiV4DataApiVersionResult, DataApiV4EventsQueryOptions, DataApiV4EventsQueryResult, } from './apiv4' @@ -225,4 +226,40 @@ describe('class DataApiV4Client', () => { expect(result).toEqual(DUMMY_RESPONSE) }) }) + + describe('method queryDataApiVersion', () => { + const DUMMY_RESPONSE: DataApiV4DataApiVersionResult = { + data_api_version: { + major: 4, + minor: 5, + }, + } + + beforeEach(() => { + mockedGet.mockResolvedValue(DUMMY_RESPONSE) + }) + + it('sends a GET request to the right URL', async () => { + const expectedUrl = `${BASE_URL}/version` + await api.queryDataApiVersion() + expect(mockedGet).toHaveBeenCalledTimes(1) + const actualUrl = mockedGet.mock.calls[0][0] as string + expect(actualUrl).toEqual(expectedUrl) + }) + + it('uses DEFAULT_TIMEOUT if not specified', async () => { + await api.queryDataApiVersion() + expect(mockedGet.mock.calls[0][1]).toBe(DEFAULT_TIMEOUT) + }) + + it('overrides DEFAULT_TIMEOUT if specified', async () => { + await api.queryDataApiVersion(60000) + expect(mockedGet.mock.calls[0][1]).toBe(60000) + }) + + it('returns version events', async () => { + const result = await api.queryDataApiVersion() + expect(result).toEqual(DUMMY_RESPONSE) + }) + }) }) diff --git a/src/api/v4/apiv4.ts b/src/api/v4/apiv4.ts index ec7a5fb..a72a062 100644 --- a/src/api/v4/apiv4.ts +++ b/src/api/v4/apiv4.ts @@ -4,6 +4,7 @@ import { backendsResponseGuard, binnedQueryResponseGuard, channelSearchResponseGuard, + dataApiVersionResponseGuard, eventsQueryResponseGuard, } from './apiv4decoders' @@ -50,6 +51,14 @@ export type DataApiV4ChannelSearchResult = { channels: DataApiV4ChannelSearchResultItem[] } +/** response for a data api version query operation */ +export type DataApiV4DataApiVersionResult = { + data_api_version: { + major: number + minor?: number + } +} + /** options for an events query operation */ export type DataApiV4EventsQueryOptions = { /** the backend where the channel is stored */ @@ -169,6 +178,15 @@ export class DataApiV4Client { return backendsResponseGuard(result) } + /** query data api version */ + public async queryDataApiVersion( + timeoutMs: number = DEFAULT_TIMEOUT + ): Promise { + const url = `${this.baseUrl}/version` + const result = await get(url, timeoutMs) + return dataApiVersionResponseGuard(result) + } + /** query for data (raw) */ public async queryEvents( queryOptions: DataApiV4EventsQueryOptions, diff --git a/src/api/v4/apiv4decoders.spec.ts b/src/api/v4/apiv4decoders.spec.ts index 29b99d5..e7b30bc 100644 --- a/src/api/v4/apiv4decoders.spec.ts +++ b/src/api/v4/apiv4decoders.spec.ts @@ -3,6 +3,7 @@ import { binnedQueryResponseGuard, channelSearchResponseGuard, eventsQueryResponseGuard, + dataApiVersionResponseGuard, } from './apiv4decoders' describe('module apiv4decoders', () => { @@ -449,4 +450,79 @@ describe('module apiv4decoders', () => { expect(() => binnedQueryResponseGuard(input)).toThrowError() }) }) + + describe('dataApiVersionResponseGuard', () => { + it('works with good data', () => { + const input = { + data_api_version: { + major: 4, + minor: 0, + }, + } + const result = dataApiVersionResponseGuard(input) + expect(result).toEqual(input) + }) + + it('rejects empty object', () => { + expect(() => dataApiVersionResponseGuard({})).toThrowError() + expect(() => + dataApiVersionResponseGuard({ data_api_version: {} }) + ).toThrowError() + }) + + it('rejects major missing', () => { + expect(() => + dataApiVersionResponseGuard({ + data_api_version: { + minor: 0, + }, + }) + ).toThrowError() + }) + + it('rejects major is string not number', () => { + expect(() => + dataApiVersionResponseGuard({ + data_api_version: { + major: '4', + minor: 0, + }, + }) + ).toThrowError() + }) + + it('rejects minor is string not number', () => { + expect(() => + dataApiVersionResponseGuard({ + data_api_version: { + major: 4, + minor: '0', + }, + }) + ).toThrowError() + }) + + it('accepts minor missing', () => { + const expectedOutput = { + data_api_version: { + major: 4, + }, + } + const result = dataApiVersionResponseGuard(expectedOutput) + expect(result).toEqual(expectedOutput) + }) + + it('removes extra keys in object', () => { + const expectedOutput = { + data_api_version: { + major: 4, + minor: 0, + }, + } + const input = { ...expectedOutput, patch: 12 } + const result = dataApiVersionResponseGuard(input) + expect(result).not.toHaveProperty('patch') + expect(result).toEqual(expectedOutput) + }) + }) }) diff --git a/src/api/v4/apiv4decoders.ts b/src/api/v4/apiv4decoders.ts index afacc4a..18123fb 100644 --- a/src/api/v4/apiv4decoders.ts +++ b/src/api/v4/apiv4decoders.ts @@ -70,3 +70,12 @@ export const binnedQueryResponseGuard = guard( counts: array(either3(integer, array(integer), array(array(integer)))), }) ) + +export const dataApiVersionResponseGuard = guard( + object({ + data_api_version: object({ + major: integer, + minor: optional(integer), + }), + }) +)