From 358a4a418e9ab634d801f68016ce6cedf76194a0 Mon Sep 17 00:00:00 2001 From: Sergo Date: Thu, 16 Jan 2020 17:12:24 +0300 Subject: [PATCH] Add list method (#6) --- .babelrc => .babelrc.json | 0 LICENSE | 2 +- package.json | 47 ++++-- src/events.js | 111 ++++++++++++- src/http-client.js | 31 +++- src/token-provider.js | 6 +- test/babel-register.js | 6 + test/events/events.test.js | 257 +++++++++++++++++++++++++++++ test/events/token-provider.test.js | 40 +++++ test/response.mock.js | 99 +++++++++++ 10 files changed, 569 insertions(+), 30 deletions(-) rename .babelrc => .babelrc.json (100%) create mode 100644 test/babel-register.js create mode 100644 test/events/events.test.js create mode 100644 test/events/token-provider.test.js create mode 100644 test/response.mock.js diff --git a/.babelrc b/.babelrc.json similarity index 100% rename from .babelrc rename to .babelrc.json diff --git a/LICENSE b/LICENSE index 7627746..5b8341f 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) 2019 Netology Group +Copyright (c) 2019 OLC Netology group LLC Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/package.json b/package.json index 67374e9..fa69b25 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,17 @@ { "name": "@ulms/events", - "version": "0.2.0", + "version": "0.2.1", "description": "JavaScript API-client for uLMS Events service", + "homepage": "https://github.com/netology-group/ulms-events-js", + "bugs": { + "url": "https://github.com/netology-group/ulms-events-js/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/netology-group/ulms-events-js" + }, + "license": "MIT", + "author": "OLC Netology group LLC", "files": [ "es", "lib" @@ -9,34 +19,35 @@ "main": "lib/index.js", "module": "es/index.js", "scripts": { + "prebuild": "rm -rf es lib", "build": "npm run build:es && npm run build:lib", - "build:es": "BABEL_ENV=es babel src -d es", - "build:lib": "BABEL_ENV=lib babel src -d lib", + "build:es": "BABEL_ENV=es babel src --config-file ./.babelrc.json -d es", + "build:lib": "BABEL_ENV=lib babel src --config-file ./.babelrc.json -d lib", "lint": "eslint .", - "prebuild": "rm -rf es lib", "prepublishOnly": "npm run test && npm run build", - "test": "eslint ." + "tap": "tap --node-arg=--require=./test/babel-register.js", + "tapas": "npm run tap \"test/**/*.test.js\"", + "test": "eslint . && npm run tapas" }, - "repository": { - "type": "git", - "url": "git+https://github.com/netology-group/ulms-events-js.git" - }, - "author": "netology-group", - "license": "MIT", - "bugs": { - "url": "https://github.com/netology-group/ulms-events-js/issues" - }, - "homepage": "https://github.com/netology-group/ulms-events-js#readme", + "dependencies": {}, "devDependencies": { "@babel/cli": "~7.2.3", "@babel/core": "~7.2.2", + "@babel/plugin-proposal-class-properties": "^7.7.4", + "@babel/plugin-transform-block-scoping": "^7.7.4", "@babel/preset-env": "~7.3.1", + "@babel/register": "^7.7.7", + "@netology-group/account": "^2.5.1", + "babel-plugin-annotate-pure-calls": "^0.4.0", + "debug": "^4.1.1", "eslint": "~5.12.1", "eslint-config-standard": "~12.0.0", "eslint-plugin-import": "~2.15.0", "eslint-plugin-node": "~8.0.1", "eslint-plugin-promise": "~4.0.1", - "eslint-plugin-standard": "~4.0.0" - }, - "dependencies": {} + "eslint-plugin-standard": "~4.0.0", + "fetch-mock": "^8.3.1", + "isomorphic-fetch": "^2.2.1", + "tap": "^12.4.0" + } } diff --git a/src/events.js b/src/events.js index 15a5087..4228f4e 100644 --- a/src/events.js +++ b/src/events.js @@ -1,3 +1,32 @@ +const fFetchPageUntil = (fn, options, acc) => function fetchEach () { + const maybePage = fn(options, acc) + if (!(maybePage instanceof Promise)) throw new Error('awaits promise') + + return maybePage + .then(([opts, result]) => { + return opts + ? fFetchPageUntil(fn, { ...options, ...opts }, result) + : result + }) +} + +const trampoline = function (fn) { + return function (...argv) { + let result = fn(...argv) + + const isCallable = a => a instanceof Function + const repeat = (nextFn) => { + const nextIsCallable = isCallable(nextFn) + + return !nextIsCallable + ? Promise.resolve(nextFn) + : nextFn().then(repeat) + } + + return repeat(result) + } +} + export class HttpEventsResource { constructor ( host, @@ -5,9 +34,17 @@ export class HttpEventsResource { httpClient, tokenProvider ) { - this.baseUrl = `${host}/${endpoint}` - this.httpClient = httpClient - this.tokenProvider = tokenProvider + if (typeof endpoint === 'string') { + // TODO: deprecate complex url later + this.baseUrl = `${host}/${endpoint}` + this.httpClient = httpClient + this.tokenProvider = tokenProvider + } else { + // bypass solid url on instantiation + this.baseUrl = `${host}` + this.httpClient = endpoint + this.tokenProvider = httpClient + } } static _headers (token, params = {}) { const { randomId } = params @@ -23,6 +60,9 @@ export class HttpEventsResource { ...additionalHeaders } } + _token () { + return this.tokenProvider.getToken() + } getState (audience, roomId, params = {}) { const { offset, direction } = params const qsParts = [] @@ -47,6 +87,71 @@ export class HttpEventsResource { ) ) } + _list (opts = {}) { + const { + qs, + after, + audience, + before, + direction, + lastId, + page, + roomId, + type + } = opts + + if (!audience) return Promise.reject(new TypeError('`audience` is absent')) + if (!direction) return Promise.reject(new TypeError('`direction` is absent')) + if (!roomId) return Promise.reject(new TypeError('`roomId` is absent')) + + const qsParts = qs && qs.length ? qs.split('&') : [] + + if (!qs) { + !isNaN(after) && qsParts.push(`after=${after}`) + !isNaN(before) && qsParts.push(`before=${before}`) + direction && qsParts.push(`direction=${direction}`) + lastId && qsParts.push(`last_id=${lastId}`) + type && qsParts.push(`type=${type}`) + page && qsParts.push(`page=${page}`) + + qsParts.push(`audience=${audience}`) + qsParts.push(`room_id=${roomId}`) + } + + return this._token() + .then((token) => { + const qs = qsParts.length ? `?${qsParts.join('&')}` : '' + + const url = new URL(`${this.baseUrl}/${audience}/rooms/${roomId}/events${qs}`) + + return this.httpClient.get( + url.href, + { + headers: HttpEventsResource._headers(token) + } + ) + }) + } + list (opts) { + const { direction = 'forward' } = opts + const options = { ...opts, direction } + + const mergeResult = (o, acc = []) => { + return this._list(o) + .then((res) => { + const accNext = o.direction === 'forward' + ? acc.concat(res.events) + : res.events.concat(acc) + + return res.has_next_page + ? [ { ...options, qs: res.next_page }, accNext ] + : [ undefined, { events: accNext } ] + }) + } + const shouldFetch = trampoline(fFetchPageUntil) + + return shouldFetch(mergeResult, options, []) + } getEvents (audience, roomId, direction, params = {}) { const { after, before, lastId, type } = params const qsParts = [] diff --git a/src/http-client.js b/src/http-client.js index 506e202..59ccae3 100644 --- a/src/http-client.js +++ b/src/http-client.js @@ -2,21 +2,38 @@ export class FetchHttpClient { static _processResponse (response) { - if (response.status === 204) { - return null + if (response.status === 200) return response.json() + if (response.status === 204) return Promise.resolve() + + if (/^4\d{2}/.test(response.status)) { + return response.json().then((a) => { + return Promise.reject(a) + }) } - return response.json() + return response.text().then((text) => { + throw new Error(text) + }) + } + constructor () { + this.__fetch = fetch + return this + } + __provider (provider) { + if (!provider) throw new TypeError('provider is absent') + this.__fetch = provider + + return this } get (url, config) { - return fetch(url, { + return this.__fetch.call(null, url, { method: 'GET', headers: config.headers }) .then(FetchHttpClient._processResponse) } post (url, data, config) { - return fetch(url, { + return this.__fetch.call(null, url, { method: 'POST', headers: config.headers, body: JSON.stringify(data) @@ -24,7 +41,7 @@ export class FetchHttpClient { .then(FetchHttpClient._processResponse) } patch (url, data, config) { - return fetch(url, { + return this.__fetch.call(null, url, { method: 'PATCH', headers: config.headers, body: JSON.stringify(data) @@ -32,7 +49,7 @@ export class FetchHttpClient { .then(FetchHttpClient._processResponse) } delete (url, data, config) { - return fetch(url, { + return this.__fetch.call(null, url, { method: 'DELETE', headers: config.headers, body: JSON.stringify(data) diff --git a/src/token-provider.js b/src/token-provider.js index 96385a2..3d7a3c6 100644 --- a/src/token-provider.js +++ b/src/token-provider.js @@ -1,8 +1,12 @@ export class SimpleTokenProvider { constructor (token) { - this.token = token + if (!token) throw new TypeError('Can not initialize TokenProvider. `token` is absent') + this.token = typeof token !== 'string' ? String(token) : token } getToken () { return Promise.resolve(this.token) } + token () { + return this.getToken() + } } diff --git a/test/babel-register.js b/test/babel-register.js new file mode 100644 index 0000000..a20aa2c --- /dev/null +++ b/test/babel-register.js @@ -0,0 +1,6 @@ +const babelrc = require('../.babelrc.json') + +// eslint-disable-next-line node/no-unpublished-require +require('@babel/register')({ + ...babelrc.env.cjs +}) diff --git a/test/events/events.test.js b/test/events/events.test.js new file mode 100644 index 0000000..70ace8f --- /dev/null +++ b/test/events/events.test.js @@ -0,0 +1,257 @@ +/* eslint promise/no-callback-in-promise: 0 */ +import 'isomorphic-fetch' +import Debug from 'debug' +import fetchMock from 'fetch-mock' +import t from 'tap' + +import { FetchHttpClient } from '../../src/http-client' +import { HttpEventsResource } from '../../src/events' +import { SimpleTokenProvider } from '../../src/token-provider' +import { + audience, + direction, + endpoint, + eventsAllPage1, + eventsAllPage2, + eventsAllPage3, + eventsAllPage4, + host, + invalidJSONRsponse, + invalidRoomResponseOnNumber, + invalidRoomResponseOnString, + roomId, + token +} from '../response.mock.js' + +const { DISABLE_MOCKS = '0' } = process.env + +const debug = Debug(`@ulms/events-js/account`) +const useMocks = !Number(DISABLE_MOCKS) + +debug(`Mocks are ${useMocks ? 'enabled' : 'disabled'}`) + +const getEnv = () => { + const { + BEARER_TOKEN, + EVENTS_AUDIENCE, + EVENTS_BEARER_TOKEN, + EVENTS_ENDPOINT, + EVENTS_HOST, + EVENTS_ROOM_ID + } = process.env + + let ACCESS_TOKEN = EVENTS_BEARER_TOKEN || BEARER_TOKEN + let AUDIENCE = EVENTS_AUDIENCE + let ENDPOINT = EVENTS_ENDPOINT + let HOST = EVENTS_HOST + let ROOM_ID = EVENTS_ROOM_ID + + if (useMocks) { + ACCESS_TOKEN = token + AUDIENCE = audience + ENDPOINT = endpoint + HOST = host + ROOM_ID = roomId + } + + if ( + !ACCESS_TOKEN || + !AUDIENCE || + !ENDPOINT || + !HOST || + !ROOM_ID + ) throw new TypeError('Needed params are absent') + + return { + ACCESS_TOKEN, AUDIENCE, ENDPOINT, HOST, ROOM_ID + } +} + +const makeClient = ({ host, token, fetch } = {}) => { + const { ENDPOINT } = getEnv() + + const httpClient = new FetchHttpClient() + if (fetch) httpClient.__provider(fetch) + + const client = new HttpEventsResource( + host, + ENDPOINT, + httpClient, + new SimpleTokenProvider(token) + ) + + return client +} + +t.test('Events Resource | `_list` fails on wrong host', (test) => { + const { ACCESS_TOKEN, AUDIENCE, ROOM_ID } = getEnv() + const HOST = '//weird.tld' + + const client = makeClient({ host: HOST, token: ACCESS_TOKEN }) + + client._list({ + audience: AUDIENCE, + roomId: ROOM_ID + }) + .then(() => { t.fail('Got valid response') }) + .catch((error) => { + t.equal(error instanceof Error, true) + }) + .finally(() => { test.end() }) +}) + +t.test('Events Resource | `_list` fails on wrong token', (test) => { + const { AUDIENCE, ENDPOINT, HOST, ROOM_ID } = getEnv() + const BEARER = 'INCORRECT_OR_INVALID_TOKEN' + + let maybeFetch + if (useMocks) { + maybeFetch = fetchMock + .sandbox() + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${ROOM_ID}/events?direction=${direction}&audience=${AUDIENCE}&room_id=${ROOM_ID}`, + { throws: invalidJSONRsponse } + ) + } + + const client = makeClient({ host: HOST, token: BEARER, fetch: maybeFetch }) + + client._list({ + audience: AUDIENCE, + direction, + roomId: ROOM_ID + }) + .then(() => { t.fail('Got valid response') }) + .catch((error) => { + if (error.name === 'FetchError') { t.match(error.message, /^invalid json response/) } else { + t.fail(error.message || 'Unknown error') + } + }) + .finally(() => { test.end() }) +}) + +t.test('Events Resource | `_list` fails on wrong room', (test) => { + const { ACCESS_TOKEN, AUDIENCE, ENDPOINT, HOST } = getEnv() + + let maybeFetch + if (useMocks) { + maybeFetch = fetchMock + .sandbox() + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${12345}/events?direction=${direction}&audience=${AUDIENCE}&room_id=${12345}`, + { throws: invalidRoomResponseOnNumber } + ) + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${'INCORRECT_OR_UNKNOWN_ROOM_ID'}/events?direction=${direction}&audience=${AUDIENCE}&room_id=${'INCORRECT_OR_UNKNOWN_ROOM_ID'}`, + { throws: invalidRoomResponseOnString } + ) + } + + const client = makeClient({ host: HOST, token: ACCESS_TOKEN, fetch: maybeFetch }) + + const t1 = client._list({ + audience: AUDIENCE, + direction, + roomId: 12345 + }) + .then(() => { t.fail('Got unexpected response') }) + .catch((error) => { + t.same(error, { error: 'invalid room ID' }) + }) + + const t2 = client._list({ + audience: AUDIENCE, + direction, + roomId: 'INCORRECT_OR_UNKNOWN_ROOM_ID' + }) + .then(() => { t.fail('Got unexpected response') }) + .catch((error) => { + t.same(error, { error: 'invalid room ID' }) + }) + + Promise.all([t1, t2]).finally(() => { test.end() }) +}) + +t.test('Events Resource | `_list` fails on wrong direction', (test) => { + const { ACCESS_TOKEN, AUDIENCE, HOST, ROOM_ID } = getEnv() + + const client = makeClient({ host: HOST, token: ACCESS_TOKEN }) + + client._list({ + audience: AUDIENCE, + roomId: ROOM_ID + }) + .catch((error) => { + t.equal(error.message === '`direction` is absent', true) + }) + .finally(() => { test.end() }) +}) + +t.test('Events Resource | `_list` is ok', (test) => { + const { ACCESS_TOKEN, AUDIENCE, ENDPOINT, HOST, ROOM_ID } = getEnv() + + let maybeFetch + if (useMocks) { + maybeFetch = fetchMock + .sandbox() + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${ROOM_ID}/events?direction=${direction}&audience=${AUDIENCE}&room_id=${ROOM_ID}`, + eventsAllPage1 + ) + } + + const client = makeClient({ host: HOST, token: ACCESS_TOKEN, fetch: maybeFetch }) + + client._list({ + audience: AUDIENCE, + direction, + roomId: ROOM_ID + }) + .then((res) => { + t.equal(Array.isArray(res.events), true) + t.equal(typeof res.next_page === 'string', true) + t.equal(typeof res.has_next_page === 'boolean', true) + }) + .finally(() => { test.end() }) +}) + +t.test('Events Resource | `list` is ok', (test) => { + const { ACCESS_TOKEN, AUDIENCE, ENDPOINT, HOST, ROOM_ID } = getEnv() + + let maybeFetch + if (useMocks) { + maybeFetch = fetchMock + .sandbox() + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${ROOM_ID}/events?direction=${direction}&audience=${AUDIENCE}&room_id=${ROOM_ID}`, + eventsAllPage1 + ) + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${ROOM_ID}/events?${eventsAllPage1.next_page}`, + eventsAllPage2 + ) + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${ROOM_ID}/events?${eventsAllPage2.next_page}`, + eventsAllPage3 + ) + .mock( + `${HOST}/${ENDPOINT}/${AUDIENCE}/rooms/${ROOM_ID}/events?${eventsAllPage3.next_page}`, + eventsAllPage4 + ) + } + + const client = makeClient({ host: HOST, token: ACCESS_TOKEN, fetch: maybeFetch }) + + client.list({ + audience: AUDIENCE, + direction, + roomId: ROOM_ID + }) + .then((res) => { + t.equal(Array.isArray(res.events), true) + if (useMocks) { + t.equals(res.events.length, 5) + } + }) + .finally(() => { test.end() }) +}) diff --git a/test/events/token-provider.test.js b/test/events/token-provider.test.js new file mode 100644 index 0000000..0a85fdb --- /dev/null +++ b/test/events/token-provider.test.js @@ -0,0 +1,40 @@ +import tap from 'tap' + +import { SimpleTokenProvider } from '../../src/token-provider' + +tap.test('Token Provider', (t) => { + t.test('fails with absent token', (test) => { + t.throws(() => { + new SimpleTokenProvider() // eslint-disable-line no-new + }) + t.throws(() => { + new SimpleTokenProvider('') // eslint-disable-line no-new + }) + t.throws(() => { + new SimpleTokenProvider() // eslint-disable-line no-new + }) + + test.end() + }) + + t.end() +}) + +tap.test('Token Provider', (t) => { + t.test('is ok with token', (test) => { + t.equal( + new SimpleTokenProvider('access_token') instanceof SimpleTokenProvider, + true + ) + + t.equal( + new SimpleTokenProvider(12345) instanceof SimpleTokenProvider, + true + ) + t.equal(new SimpleTokenProvider(12345).token, '12345') + + test.end() + }) + + t.end() +}) diff --git a/test/response.mock.js b/test/response.mock.js new file mode 100644 index 0000000..e216078 --- /dev/null +++ b/test/response.mock.js @@ -0,0 +1,99 @@ +export const audience = 'AUDIENCE' +export const direction = 'forward' +export const endpoint = 'api/v1' +export const host = 'http://domain.tld' +export const roomId = 'ROOM_ID' +export const token = 'BEARER' + +export const invalidRoomResponseOnNumber = { error: 'invalid room ID' } + +export const invalidRoomResponseOnString = { error: 'invalid room ID' } + +export const invalidRoomResponse = { error: 'invalid room ID' } + +class FetchErrorStub extends Error { + constructor (message) { + super() + this.name = 'FetchError' + this.message = message + } +} + +export const invalidJSONRsponse = new FetchErrorStub('invalid json response at...') + +export const eventsAllPage1 = { + events: [ + { id: 'uuid-1', + random_id: '', + type: 'unsubscribe', + created_at: '2020-01-13T17:24:49.867305Z', + offset: 372728660, + data: [], + account_id: 'account_label_1', + updated_at: null, + reactions: {} + }, + { id: 'uuid-2', + random_id: '', + type: 'subscribe', + created_at: '2020-01-14T05:23:11.135917Z', + offset: 415829928, + data: [], + account_id: 'account_label_2', + updated_at: null, + reactions: {} + } + ], + has_next_page: true, + next_page: `direction=forward&audience=${audience}&room_id=${roomId}&after=415829928` +} +export const eventsAllPage2 = { + events: [ + { id: 'uuid-3', + random_id: '', + type: 'unsubscribe', + created_at: '2020-01-14T06:59:01.108522Z', + offset: 421579901, + data: [], + account_id: 'account_label_3', + updated_at: null, + reactions: {} + } + ], + has_next_page: true, + next_page: `direction=forward&audience=${audience}&room_id=${roomId}&after=421579901` +} + +export const eventsAllPage3 = { + events: [ + { id: 'uuid-4', + random_id: '', + type: 'unsubscribe', + created_at: '2020-01-14T06:59:01.108522Z', + offset: 421579902, + data: [], + account_id: 'account_label_3', + updated_at: null, + reactions: {} + } + ], + has_next_page: true, + next_page: `direction=forward&audience=${audience}&room_id=${roomId}&after=421579902` +} + +export const eventsAllPage4 = { + events: [ + { id: 'uuid-5', + random_id: '', + type: 'unsubscribe', + created_at: '2020-01-14T06:59:01.108522Z', + offset: 421579903, + data: [], + account_id: 'account_label_3', + updated_at: null, + reactions: {} + } + ], + has_next_page: false, + next_page: `direction=forward&audience=${audience}&room_id=${roomId}&after=421579903` +}