From bff76b687a90b51c299e61a45e48830938de502d Mon Sep 17 00:00:00 2001 From: Aleksey Konstantinov Date: Thu, 1 Aug 2024 17:49:12 +0300 Subject: [PATCH] ULMS-3167 Updated api-clients --- src/basic-client.js | 52 +++++------- src/error.js | 171 ++++++++++++++++++++++++++++++++++++---- src/fvs.js | 30 +++++++ src/http-client.js | 36 +++------ src/index.js | 2 - src/portal.js | 13 --- src/presence-ws.js | 14 ++-- src/presence.js | 48 +++++++++++ src/profile-service.js | 6 +- src/profile.js | 65 --------------- src/redux/middleware.js | 8 +- src/retry.js | 11 ++- src/tenant.js | 83 ++++++++++++++++--- src/token-provider.js | 25 +++++- src/ulms.js | 50 +++++++++++- 15 files changed, 429 insertions(+), 185 deletions(-) delete mode 100644 src/portal.js delete mode 100644 src/profile.js diff --git a/src/basic-client.js b/src/basic-client.js index f17e92c..e0837b9 100644 --- a/src/basic-client.js +++ b/src/basic-client.js @@ -26,7 +26,9 @@ const parseParameter = (key, value) => { return `${key}=${value}` } -const responseTransformer = (_) => _.data +async function handleResponse() { + throw new Error('Method `handleResponse` not implemented.') +} class BasicClient { constructor(baseUrl, httpClient, tokenProvider) { @@ -34,8 +36,8 @@ class BasicClient { this.customHeaders = {} this.httpClient = httpClient this.tokenProvider = tokenProvider - this.labels = {} this.trackEvent = undefined + this.handleResponse = handleResponse } url(endpoint, parameters) { @@ -44,7 +46,7 @@ class BasicClient { // eslint-disable-next-line no-restricted-syntax for (const [key, value] of Object.entries(parameters)) { - if (value !== undefined) { + if (value !== undefined && value !== null) { urlParameters += urlParameters ? `&${parseParameter(key, value)}` : parseParameter(key, value) @@ -59,12 +61,11 @@ class BasicClient { return `${this.baseUrl}${endpoint}` } - static headers(token, labels = {}, headers = {}) { + static headers(token, headers = {}) { return { ...headers, authorization: `Bearer ${token}`, 'content-type': 'application/json', - ...labels, } } @@ -72,25 +73,10 @@ class BasicClient { this.customHeaders = headers } - setLabels(labels) { - const { app_audience, app_label, app_version, scope } = labels // eslint-disable-line camelcase - - this.labels = { - ...(app_audience !== undefined && { 'ulms-app-audience': app_audience }), // eslint-disable-line camelcase - ...(app_label !== undefined && { 'ulms-app-label': app_label }), // eslint-disable-line camelcase - ...(app_version !== undefined && { 'ulms-app-version': app_version }), // eslint-disable-line camelcase - ...(scope !== undefined && { 'ulms-scope': scope }), - } - } - setTrackEventFunction(trackEvent) { this.trackEvent = trackEvent } - clearLabels() { - this.labels = {} - } - async get(url, options = {}) { const { retry: retryEnabled } = options @@ -99,13 +85,13 @@ class BasicClient { const token = await this.tokenProvider.getToken() const headers = { ...options.headers, - ...BasicClient.headers(token, this.labels, this.customHeaders), + ...BasicClient.headers(token, this.customHeaders), } const requestOptions = { ...options, headers } return this.httpClient .get(url, requestOptions) - .then(responseTransformer) + .then(this.handleResponse) } return retry(task, onRetry) @@ -114,31 +100,31 @@ class BasicClient { const token = await this.tokenProvider.getToken() const headers = { ...options.headers, - ...BasicClient.headers(token, this.labels, this.customHeaders), + ...BasicClient.headers(token, this.customHeaders), } const requestOptions = { ...options, headers } - return this.httpClient.get(url, requestOptions).then(responseTransformer) + return this.httpClient.get(url, requestOptions).then(this.handleResponse) } async put(url, data, options = {}) { const token = await this.tokenProvider.getToken() const headers = { ...options.headers, - ...BasicClient.headers(token, this.labels, this.customHeaders), + ...BasicClient.headers(token, this.customHeaders), } const requestOptions = { ...options, headers } return this.httpClient .put(url, data, requestOptions) - .then(responseTransformer) + .then(this.handleResponse) } async post(url, data, options = {}) { const token = await this.tokenProvider.getToken() const headers = { ...options.headers, - ...BasicClient.headers(token, this.labels, this.customHeaders), + ...BasicClient.headers(token, this.customHeaders), } const requestOptions = { ...options, headers } @@ -151,7 +137,7 @@ class BasicClient { : undefined const result = this.httpClient .post(url, data, requestOptions) - .then(responseTransformer) + .then(this.handleResponse) result.catch((error) => { const responseEnd = Date.now() @@ -174,31 +160,31 @@ class BasicClient { return this.httpClient .post(url, data, requestOptions) - .then(responseTransformer) + .then(this.handleResponse) } async patch(url, data, options = {}) { const token = await this.tokenProvider.getToken() const headers = { ...options.headers, - ...BasicClient.headers(token, this.labels, this.customHeaders), + ...BasicClient.headers(token, this.customHeaders), } const requestOptions = { ...options, headers } return this.httpClient .patch(url, data, requestOptions) - .then(responseTransformer) + .then(this.handleResponse) } async delete(url, options = {}) { const token = await this.tokenProvider.getToken() const headers = { ...options.headers, - ...BasicClient.headers(token, this.labels, this.customHeaders), + ...BasicClient.headers(token, this.customHeaders), } const requestOptions = { ...options, headers } - return this.httpClient.delete(url, requestOptions).then(responseTransformer) + return this.httpClient.delete(url, requestOptions).then(this.handleResponse) } } diff --git a/src/error.js b/src/error.js index 06f77f6..6c1c68a 100644 --- a/src/error.js +++ b/src/error.js @@ -1,23 +1,64 @@ /* eslint-disable max-classes-per-file, unicorn/prevent-abbreviations */ -export class ApiError extends Error { + +// old api error kinds +// todo: remove after migration to new error kinds (on backend) +const apiErrorKindOldMap = { + ACCESS_DENIED: 'access_denied', + CAPACITY_EXCEEDED: 'capacity_exceeded', + TOXIC_COMMENT: 'toxic_comment', + TOXIC_COMMENT_CLASSIFIER_REQUEST_FAILED: + 'toxic_comment_classifier_request_failed', +} +const apiErrorKindMap = { + INTERNAL_FAILURE: 'internal_failure', + SYSTEM_ACCESS_DENIED: 'system_access_denied', + ULMS_INVALID_COMMENT: 'ulms_invalid_comment', + ULMS_SERVER_CAPACITY_EXCEEDED: 'ulms_server_capacity_exceeded', + ULMS_TOXIC_COMMENT: 'ulms_toxic_comment', +} +// todo: remove after migration to new error kinds (on backend) +const transformApiErrorKindMap = { + [apiErrorKindOldMap.ACCESS_DENIED]: apiErrorKindMap.SYSTEM_ACCESS_DENIED, + [apiErrorKindOldMap.CAPACITY_EXCEEDED]: + apiErrorKindMap.ULMS_SERVER_CAPACITY_EXCEEDED, + [apiErrorKindOldMap.TOXIC_COMMENT]: apiErrorKindMap.ULMS_TOXIC_COMMENT, + [apiErrorKindOldMap.TOXIC_COMMENT_CLASSIFIER_REQUEST_FAILED]: + apiErrorKindMap.ULMS_INVALID_COMMENT, +} + +const decodeErrorKindMap = { + JSON_PARSE_ERROR: 'json_parse_error', +} + +export class UlmsError extends Error { constructor(message, options) { super(message, options) - const { is_transient: isTransient } = options || {} + const { isTransient } = options - this.name = 'ApiError' this.isTransient = isTransient + this.name = 'UlmsError' + } + + static get apiErrorKinds() { + return apiErrorKindMap + } + + static get decodeErrorKinds() { + return decodeErrorKindMap } /** * Factory method for creating error instance - * @param payload {{ kind?: string, is_transient?: boolean }} - * @returns {ApiError} + * @param payload {{ kind?: string, isTransient?: boolean }} + * @returns {UlmsError} */ static fromPayload(payload) { - const { kind, is_transient: isTransient } = payload + const { kind, isTransient } = payload + // todo: remove after migration to new error kinds (on backend), use kind instead + const transformedKind = transformApiErrorKindMap[kind] || kind - return new ApiError(kind, { isTransient }) + return new UlmsError(transformedKind, { isTransient }) } get kind() { @@ -25,6 +66,102 @@ export class ApiError extends Error { } } +// // Error kind matching example +// try { +// const result = await ulmsClient.createEvent(...) +// } catch (error) { +// switch (error.kind) { +// case UlmsError.apiErrorKinds.ACCESS_DENIED: { +// // show ACCESS_DENIED screen +// break +// } +// case UlmsError.apiErrorKinds.TOXIC_COMMENT: { +// // show TOXIC_COMMENT screen +// break +// } +// case UlmsError.decodeErrorKinds.JSON_PARSE_ERROR: { +// // show JSON_PARSE_ERROR screen +// break +// } +// default: { +// // default +// sentry.captureException(error) +// // show default screen +// } +// } +// } + +export class PresenceError extends Error { + constructor(message, options) { + super(message, options) + + const { isTransient } = options + + this.isTransient = isTransient + this.name = 'PresenceError' + } + + static get apiErrorKinds() { + return apiErrorKindMap + } + + static get decodeErrorKinds() { + return decodeErrorKindMap + } + + /** + * Factory method for creating error instance + * @param payload {{ kind?: string, isTransient?: boolean }} + * @returns {PresenceError} + */ + static fromPayload(payload) { + const { kind, isTransient } = payload + // todo: remove after migration to new error kinds (on backend), use kind instead + const transformedKind = transformApiErrorKindMap[kind] || kind + + return new PresenceError(transformedKind, { isTransient }) + } + + get kind() { + return this.message + } +} + +export class FVSError extends Error { + constructor(...arguments_) { + super(...arguments_) + + this.isTransient = false + this.name = 'FVSError' + } + + static get decodeErrorKinds() { + return decodeErrorKindMap + } +} + +export class TenantError extends Error { + constructor(...arguments_) { + super(...arguments_) + + this.isTransient = false + this.name = 'TenantError' + } + + static get decodeErrorKinds() { + return decodeErrorKindMap + } +} + +export class NetworkError extends Error { + constructor(...arguments_) { + super(...arguments_) + + this.isTransient = true + this.name = 'NetworkError' + } +} + export class MQTTClientError extends Error { constructor(...args) { super(...args) @@ -53,11 +190,11 @@ export class TimeoutError extends Error { } } -export class PresenceError extends Error { +export class PresenceWsError extends Error { constructor(...args) { super(...args) - this.name = 'PresenceError' + this.name = 'PresenceWsError' } static get recoverableTypes() { @@ -90,26 +227,26 @@ export class PresenceError extends Error { static fromType(type) { const errorType = - PresenceError.recoverableTypes[type] || - PresenceError.unrecoverableTypes[type] || - PresenceError.unrecoverableTypes.UNKNOWN_ERROR + PresenceWsError.recoverableTypes[type] || + PresenceWsError.unrecoverableTypes[type] || + PresenceWsError.unrecoverableTypes.UNKNOWN_ERROR - return new PresenceError(errorType) + return new PresenceWsError(errorType) } static isReplacedError(error) { - if (!(error instanceof PresenceError)) return false + if (!(error instanceof PresenceWsError)) return false const { message } = error return ( - message === PresenceError.unrecoverableTypes.REPLACED || - message === PresenceError.unrecoverableTypes.SESSION_REPLACED + message === PresenceWsError.unrecoverableTypes.REPLACED || + message === PresenceWsError.unrecoverableTypes.SESSION_REPLACED ) } isRecoverable() { - return !!PresenceError.recoverableTypes[this.message] + return !!PresenceWsError.recoverableTypes[this.message] } } diff --git a/src/fvs.js b/src/fvs.js index 14380c3..f5616e9 100644 --- a/src/fvs.js +++ b/src/fvs.js @@ -1,6 +1,36 @@ import BasicClient from './basic-client' +import { FVSError } from './error' +async function handleResponse(response) { + let data + + try { + data = await response.json() + } catch (error) { + throw new FVSError(FVSError.decodeErrorKinds.JSON_PARSE_ERROR, { + cause: error, + }) + } + + if (!response.ok) { + const { code, detail, message } = data + // eslint-disable-next-line sonarjs/no-nested-template-literals + const errorMessage = `[${response.status}]${code ? `[${code}]` : ''} ${detail}: ${message}` + + throw new FVSError(errorMessage) + } + + return data +} + +// fixme: need authorization header class FVS extends BasicClient { + constructor(...arguments_) { + super(...arguments_) + + this.handleResponse = handleResponse + } + /** * Get issue types in Minigroup * @returns {Promise} diff --git a/src/http-client.js b/src/http-client.js index ae1048b..4612c41 100644 --- a/src/http-client.js +++ b/src/http-client.js @@ -1,4 +1,5 @@ /* eslint-disable class-methods-use-this */ +import { NetworkError } from './error' function createTimeoutSignal(timeout) { const controller = new AbortController() @@ -10,28 +11,6 @@ function createTimeoutSignal(timeout) { } class FetchHttpClient { - static async handleResponse(response) { - const bodyAsText = await response.text() - let data - - try { - data = JSON.parse(bodyAsText) - } catch { - data = { message: bodyAsText } - } - - const result = { - data, - status: response.status, - } - - if (!response.ok) { - throw result - } - - return result - } - request(url, config) { const { timeout, ...requestConfig } = config const requestOptions = { @@ -46,9 +25,16 @@ class FetchHttpClient { onFinally = cleanup } - return fetch(url, requestOptions) - .then(FetchHttpClient.handleResponse) - .finally(() => (onFinally ? onFinally() : undefined)) + const fetchPromise = fetch(url, requestOptions).catch((error) => { + throw new NetworkError('', { cause: error }) + }) + + if (onFinally) { + // eslint-disable-next-line promise/catch-or-return + fetchPromise.finally(() => onFinally()) + } + + return fetchPromise } get(url, config) { diff --git a/src/index.js b/src/index.js index ea3a2b1..eba4e76 100644 --- a/src/index.js +++ b/src/index.js @@ -6,10 +6,8 @@ export { MQTTClient, ReconnectingMQTTClient, defaultOptions } from './mqtt' export { default as NATSClient } from './nats-client' export { default as NatsManager } from './nats-manager' export { default as NetworkStatusMonitor } from './network-status-monitor' -export { default as Portal } from './portal' export { default as Presence } from './presence' export { default as PresenceWS } from './presence-ws' -export { default as HttpProfileResource } from './profile' export { default as ProfileService } from './profile-service' export { default as Storage } from './storage' export { default as Tenant } from './tenant' diff --git a/src/portal.js b/src/portal.js deleted file mode 100644 index 27502b4..0000000 --- a/src/portal.js +++ /dev/null @@ -1,13 +0,0 @@ -import BasicClient from './basic-client' - -class Portal extends BasicClient { - /** - * Get portal options - * @returns {Promise} - */ - getOptions() { - return this.get(this.baseUrl, { mode: 'cors', credentials: 'include' }) - } -} - -export default Portal diff --git a/src/presence-ws.js b/src/presence-ws.js index e80239d..d5f2115 100644 --- a/src/presence-ws.js +++ b/src/presence-ws.js @@ -3,7 +3,7 @@ import Debug from 'debug' import EventEmitter from 'events' // eslint-disable-line unicorn/prefer-node-protocol import { makeDeferred } from './common' -import { PresenceError } from './error' +import { PresenceWsError } from './error' import WsTransport from './ws-transport' const debug = Debug('presence-ws') @@ -112,7 +112,7 @@ class PresenceWS extends EventEmitter { // if previousTransport closed with replaced error, ignore and return if ( previousTransport && - PresenceError.isReplacedError(this.lastProtocolError) + PresenceWsError.isReplacedError(this.lastProtocolError) ) { return p } @@ -155,7 +155,7 @@ class PresenceWS extends EventEmitter { (maybeDisconnectReason && maybeDisconnectReason.payload && maybeDisconnectReason.payload.type !== - PresenceError.recoverableTypes.SERVER_SHUTDOWN) + PresenceWsError.recoverableTypes.SERVER_SHUTDOWN) ) { debug('[flow] transport disconnected, throw') @@ -174,7 +174,7 @@ class PresenceWS extends EventEmitter { const reason = error ? (error.payload && error.payload.type) || error.type === ERROR_MESSAGE_TYPE - ? PresenceError.fromType(error.payload.type.toUpperCase()) + ? PresenceWsError.fromType(error.payload.type.toUpperCase()) : error : undefined @@ -213,7 +213,7 @@ class PresenceWS extends EventEmitter { } else if (type === ERROR_MESSAGE_TYPE) { const { is_transient: isTransient } = payload // process service system messages (error) - this.lastProtocolError = PresenceError.fromType( + this.lastProtocolError = PresenceWsError.fromType( payload.type.toUpperCase(), ) @@ -245,7 +245,9 @@ class PresenceWS extends EventEmitter { disconnected() { if (!this.connected) { return Promise.reject( - PresenceError.fromType(PresenceError.unrecoverableTypes.NOT_CONNECTED), + PresenceWsError.fromType( + PresenceWsError.unrecoverableTypes.NOT_CONNECTED, + ), ) } diff --git a/src/presence.js b/src/presence.js index 86d8d8a..9c1a35d 100644 --- a/src/presence.js +++ b/src/presence.js @@ -1,6 +1,54 @@ import BasicClient from './basic-client' +import { PresenceError } from './error' + +async function handleResponse(response) { + let data + + try { + data = await response.json() + } catch (error) { + throw new PresenceError(PresenceError.decodeErrorKinds.JSON_PARSE_ERROR, { + cause: error, + }) + } + + if (!response.ok) { + const { + // new error format + kind = '', + is_transient: isTransient = false, + + // old error format (ProblemDetail { type: string, title: string, detail: string }) + // todo: remove after migration to new error format + type, + } = data + let errorPayload = { + kind, + isTransient, + } + + // override error payload in case of old error format (or another format from portal or fvs, etc...) + // todo: remove after migration to new error format + if (type !== undefined) { + errorPayload = { + kind: type, + isTransient, + } + } + + throw PresenceError.fromPayload(errorPayload) + } + + return data +} class Presence extends BasicClient { + constructor(...arguments_) { + super(...arguments_) + + this.handleResponse = handleResponse + } + /** * List agent * @param {string} classroomId diff --git a/src/profile-service.js b/src/profile-service.js index 80efdcc..0980c08 100644 --- a/src/profile-service.js +++ b/src/profile-service.js @@ -39,7 +39,7 @@ class ProfileService { this.queue.add(() => this.client - .listProfiles(ids, scope) + .listProfile(ids, scope) .then((response) => { for (const profile of response) { const { id } = profile @@ -76,11 +76,11 @@ class ProfileService { } forceReadProfile(id, scope) { - return this.client.getProfile(id, scope, true) + return this.client.readProfile(id, scope, true) } readMeProfile(scope) { - return this.client.getProfile('me', scope) + return this.client.readProfile('me', scope) } readProfile(id, scope) { diff --git a/src/profile.js b/src/profile.js deleted file mode 100644 index 5ed3709..0000000 --- a/src/profile.js +++ /dev/null @@ -1,65 +0,0 @@ -/* eslint-disable promise/no-nesting */ -const responseTransformer = (_) => _.data - -// todo: extend from BasicClient -class HttpProfileResource { - constructor(host, endpoint, httpClient, tokenProvider) { - this.baseUrl = `${host}/${endpoint}` - this.httpClient = httpClient - this.tokenProvider = tokenProvider - } - - static headers(parameters) { - return { - authorization: `Bearer ${parameters.token}`, - 'content-type': 'application/json', - } - } - - getProfile(id, scope, force = false) { - let qs = '' - - if (scope) { - qs = `?scope=${scope}` - } - - // to avoid Nginx cache - if (force) { - qs += `${qs.length > 0 ? '&' : '?'}timestamp=${Date.now()}` - } - - return this.tokenProvider.getToken().then((token) => - this.httpClient - .get(`${this.baseUrl}/users/${id}${qs}`, { - headers: HttpProfileResource.headers({ token }), - }) - .then(responseTransformer), - ) - } - - listProfiles(ids, scope) { - const qs = `?ids=${ids.join(',')}&scope=${scope}` - - return this.tokenProvider.getToken().then((token) => - this.httpClient - .get(`${this.baseUrl}/users${qs}`, { - headers: HttpProfileResource.headers({ token }), - }) - .then(responseTransformer), - ) - } - - updateAttributes(id, scope, data) { - const qs = `?scope=${scope}` - - return this.tokenProvider.getToken().then((token) => - this.httpClient - .patch(`${this.baseUrl}/users/${id}${qs}`, data, { - headers: HttpProfileResource.headers({ token }), - }) - .then(responseTransformer), - ) - } -} - -export default HttpProfileResource diff --git a/src/redux/middleware.js b/src/redux/middleware.js index 443f70e..406dbc7 100644 --- a/src/redux/middleware.js +++ b/src/redux/middleware.js @@ -3,7 +3,7 @@ import Debug from 'debug' import Backoff from '../backoff' import { sleep } from '../common' -import { PresenceError } from '../error' +import { PresenceWsError } from '../error' import { AGENT_ENTERED, @@ -160,7 +160,7 @@ async function startPresenceFlow( reason = error } - if (reason instanceof PresenceError && reason.isRecoverable()) { + if (reason instanceof PresenceWsError && reason.isRecoverable()) { retryCount += 1 } else { break @@ -232,8 +232,8 @@ async function getPresenceAgentList( trackError(error) } - const { status } = error - const isErrorUnrecoverable = !!status + const { isTransient } = error + const isErrorUnrecoverable = !isTransient const retryLimitExceeded = retryCount === RETRY_LIMIT // error is unrecoverable OR retry limit reached diff --git a/src/retry.js b/src/retry.js index 0a18cc7..bd306a2 100644 --- a/src/retry.js +++ b/src/retry.js @@ -46,10 +46,12 @@ async function retry(task, onRetry, retryLimit = RETRY_LIMIT) { function isErrorRetryable(error) { /* Повторная попытка разрешена для следующих ошибок: + - [+] ошибка из апи, которая позволяет выполнить повторную попытку отправки запроса - [+] клиентская сетевая ошибка ("Failed to fetch *") - - [+] таймаут запроса + - [+] клиентский таймаут запроса - [+] HTTP ответ со статус-кодом: 422, 424, 429 или 5xx */ + const isTransientApiError = error.isTransient const isNetworkError = error instanceof TypeError && error.message.startsWith('Failed to fetch') const isTimeoutError = @@ -61,7 +63,12 @@ function isErrorRetryable(error) { error.status >= 500 : false - return isNetworkError || isTimeoutError || isPassedByStatusCode + return ( + isTransientApiError || + isNetworkError || + isTimeoutError || + isPassedByStatusCode + ) } export default retry diff --git a/src/tenant.js b/src/tenant.js index 06c7392..e4a0a46 100644 --- a/src/tenant.js +++ b/src/tenant.js @@ -1,6 +1,47 @@ import BasicClient from './basic-client' +import { TenantError } from './error' + +async function handleResponse(response) { + let data + + try { + data = await response.json() + } catch (error) { + throw new TenantError(TenantError.decodeErrorKinds.JSON_PARSE_ERROR, { + cause: error, + }) + } + + if (!response.ok) { + const { error, errors, messages } = data + // eslint-disable-next-line sonarjs/no-nested-template-literals + let errorMessage = `[${response.status}]` + + if (error) { + errorMessage += ` ${error}` + } + + if (errors) { + errorMessage += ` ${errors}` + } + + if (messages) { + errorMessage += ` ${messages.join(',')}` + } + + throw new TenantError(errorMessage) + } + + return data +} class Tenant extends BasicClient { + constructor(...arguments_) { + super(...arguments_) + + this.handleResponse = handleResponse + } + /** * Profile role enum * @returns {{MODERATOR: string, USER: string}} @@ -16,16 +57,17 @@ class Tenant extends BasicClient { * Read profile * @param {string} id * @param {string} scope + * @param {boolean} force * @returns {Promise} */ - readProfile(id, scope) { - let qs = '' - - if (scope) { - qs = `?scope=${scope}` - } - - return this.get(`${this.baseUrl}/users/${id}${qs}`) + readProfile(id, scope, force = false) { + return this.get( + this.url(`/users/${id}`, { + scope, + // to avoid Nginx cache + timestamp: force ? Date.now() : undefined, + }), + ) } /** @@ -35,18 +77,17 @@ class Tenant extends BasicClient { * @returns {Promise} */ listProfile(ids, scope) { - const qs = `?ids=${ids.join(',')}&scope=${scope}` - - return this.get(`${this.baseUrl}/users${qs}`) + return this.get(this.url(`/users`, { ids: ids.join(','), scope })) } /** * Read tenant scope * @param {string} scope + * @param {string} lessonId * @returns {Promise} */ - readScope(scope) { - return this.get(`${this.baseUrl}/webinars/${scope}`) + readScope(scope, lessonId) { + return this.get(this.url(`/webinars/${scope}`, { lesson_id: lessonId })) } /** @@ -66,6 +107,22 @@ class Tenant extends BasicClient { createMaterialUrl(id) { return `${this.baseUrl}/materials/${id}` } + + /** + * Get portal options + * @param {string} optionsEndpoint + * @returns {Promise} + */ + getOptions(optionsEndpoint) { + const { origin } = new URL(this.baseUrl) + + return this.httpClient + .get(`${origin}/${optionsEndpoint}`, { + mode: 'cors', + credentials: 'include', + }) + .then(this.handleResponse) + } } export default Tenant diff --git a/src/token-provider.js b/src/token-provider.js index 967238d..0524794 100644 --- a/src/token-provider.js +++ b/src/token-provider.js @@ -16,6 +16,28 @@ class TokenProvider { this.tokenRequestStart = undefined } + static async handleResponse(response) { + let data + + try { + data = await response.json() + } catch (error) { + console.log('[TP:handleResponse] body parsing catch', error) + // todo: change error type (UnexpectedError) + throw error + } + + if (!response.ok) { + // eslint-disable-next-line no-throw-literal + throw { + data, + status: response.status, + } + } + + return data + } + setContext(context) { this.context = context } @@ -39,8 +61,9 @@ class TokenProvider { this.tokenRequestStart = Date.now() this.fetchTokenData() + .then(TokenProvider.handleResponse) .then((response) => { - this.updateTokenData(response.data) + this.updateTokenData(response) this.resolveAndReset() }) .catch((error) => { diff --git a/src/ulms.js b/src/ulms.js index 22f7e8c..75389ee 100644 --- a/src/ulms.js +++ b/src/ulms.js @@ -1,4 +1,5 @@ import BasicClient from './basic-client' +import { UlmsError } from './error' /** * Agent reader configuration @@ -51,7 +52,54 @@ import BasicClient from './basic-client' * @property {[number | null, number | null]} time */ +async function handleResponse(response) { + let data + + try { + data = await response.json() + } catch (error) { + throw new UlmsError(UlmsError.decodeErrorKinds.JSON_PARSE_ERROR, { + cause: error, + }) + } + + if (!response.ok) { + const { + // new error format + kind = '', + is_transient: isTransient = false, + + // old error format (ProblemDetail { type: string, title: string, detail: string }) + // todo: remove after migration to new error format + type, + } = data + let errorPayload = { + kind, + isTransient, + } + + // override error payload in case of old error format (or another format from portal or fvs, etc...) + // todo: remove after migration to new error format + if (type !== undefined) { + errorPayload = { + kind: type, + isTransient, + } + } + + throw UlmsError.fromPayload(errorPayload) + } + + return data +} + class ULMS extends BasicClient { + constructor(...arguments_) { + super(...arguments_) + + this.handleResponse = handleResponse + } + /** * Scope kind enum * @returns {{CHAT: string, MINIGROUP: string, P2P: string, WEBINAR: string}} @@ -120,7 +168,7 @@ class ULMS extends BasicClient { * @returns {Promise} */ banUser({ accountId, ban, classId }) { - this.post(`${this.baseUrl}/account/${accountId}/ban`, { + return this.post(`${this.baseUrl}/account/${accountId}/ban`, { ban, class_id: classId, })