diff --git a/scheduler/clients/javascript/lib/baseClient.ts b/scheduler/clients/javascript/lib/baseClient.ts index 72cc3443..30b99952 100644 --- a/scheduler/clients/javascript/lib/baseClient.ts +++ b/scheduler/clients/javascript/lib/baseClient.ts @@ -1,5 +1,9 @@ -import axios, { type AxiosInstance, type AxiosResponse } from 'axios' -import { config } from '.' +import axios, { + AxiosRequestConfig, + type AxiosInstance, + type AxiosResponse, +} from 'axios' +import { ICredentials } from './helpers/credentials' /** * Base client for the API @@ -7,32 +11,22 @@ import { config } from '.' * It shouldn't be exposed to the end user */ export abstract class NettuBaseClient { - private readonly credentials: ICredentials - private readonly axiosClient: AxiosInstance - - constructor(credentials: ICredentials) { - this.credentials = credentials - this.axiosClient = axios.create({ - headers: this.credentials.createAuthHeaders(), - validateStatus: () => true, // allow all status codes without throwing error - paramsSerializer: { - indexes: null, // Force to stringify arrays like value1,value2 instead of value1[0],value1[1] - }, - }) + constructor(private readonly axiosClient: AxiosInstance) { + this.axiosClient = axiosClient } protected async get( path: string, params: Record = {} ): Promise> { - const res = await this.axiosClient.get(`${config.baseUrl}${path}`, { + const res = await this.axiosClient.get(path, { params, }) return new APIResponse(res) } protected async delete(path: string): Promise> { - const res = await this.axiosClient.delete(`${config.baseUrl}${path}`) + const res = await this.axiosClient.delete(path) return new APIResponse(res) } @@ -43,7 +37,7 @@ export abstract class NettuBaseClient { const res = await this.axiosClient({ method: 'DELETE', data, - url: `${config.baseUrl}${path}`, + url: path, }) return new APIResponse(res) } @@ -52,12 +46,12 @@ export abstract class NettuBaseClient { path: string, data: unknown ): Promise> { - const res = await this.axiosClient.post(`${config.baseUrl}${path}`, data) + const res = await this.axiosClient.post(path, data) return new APIResponse(res) } protected async put(path: string, data: unknown): Promise> { - const res = await this.axiosClient.put(`${config.baseUrl}${path}`, data) + const res = await this.axiosClient.put(path, data) return new APIResponse(res) } } @@ -78,56 +72,70 @@ export class APIResponse { } /** - * Credentials for the API for end users (usually frontend) + * Create an Axios instance for the frontend + * + * Compared to the backend, this function is not async + * And the frontend cannot keep the connection alive + * + * @param args specify base URL for the API + * @param credentials credentials for the API + * @returns an Axios instance */ -export class UserCreds implements ICredentials { - private readonly nettuAccount: string - private readonly token?: string - - constructor(nettuAccount: string, token?: string) { - this.nettuAccount = nettuAccount - this.token = token +export const createAxiosInstanceFrontend = ( + args: { + baseUrl: string + }, + credentials: ICredentials +): AxiosInstance => { + const config: AxiosRequestConfig = { + baseURL: args.baseUrl, + headers: credentials.createAuthHeaders(), + validateStatus: () => true, // allow all status codes without throwing error + paramsSerializer: { + indexes: null, // Force to stringify arrays like value1,value2 instead of value1[0],value1[1] + }, } - createAuthHeaders() { - const creds: Record = { - 'nettu-account': this.nettuAccount, - } - if (this.token) { - creds.authorization = `Bearer ${this.token}` - } - - return Object.freeze(creds) - } + return axios.create(config) } /** - * Credentials for the API for admins (usually backend) + * Create an Axios instance for the backend + * + * On the backend (NodeJS), it is possible to keep the connection alive + * @param args specify base URL and if the connection should be kept alive + * @param credentials credentials for the API + * @returns Promise of an Axios instance */ -export class AccountCreds implements ICredentials { - private readonly apiKey: string - - constructor(apiKey: string) { - this.apiKey = apiKey +export const createAxiosInstanceBackend = async ( + args: { + baseUrl: string + keepAlive: boolean + }, + credentials: ICredentials +): Promise => { + const config: AxiosRequestConfig = { + baseURL: args.baseUrl, + headers: credentials.createAuthHeaders(), + validateStatus: () => true, // allow all status codes without throwing error + paramsSerializer: { + indexes: null, // Force to stringify arrays like value1,value2 instead of value1[0],value1[1] + }, } - createAuthHeaders() { - return Object.freeze({ - 'x-api-key': this.apiKey, - }) - } -} - -export interface ICredentials { - createAuthHeaders(): object -} - -export class EmptyCreds implements ICredentials { - createAuthHeaders() { - return Object.freeze({}) + // If keepAlive is true, and if we are in NodeJS + // create an agent to keep the connection alive + if (args.keepAlive && typeof module !== 'undefined' && module.exports) { + if (args.baseUrl.startsWith('https')) { + // This is a dynamic import to avoid loading the https module in the browser + const https = await import('https') + config.httpsAgent = new https.Agent({ keepAlive: true }) + } else { + // This is a dynamic import to avoid loading the http module in the browser + const http = await import('http') + config.httpAgent = new http.Agent({ keepAlive: true }) + } } -} -export interface ICredentials { - createAuthHeaders(): object + return axios.create(config) } diff --git a/scheduler/clients/javascript/lib/helpers/credentials.ts b/scheduler/clients/javascript/lib/helpers/credentials.ts new file mode 100644 index 00000000..ec682588 --- /dev/null +++ b/scheduler/clients/javascript/lib/helpers/credentials.ts @@ -0,0 +1,88 @@ +/** + * Partial credentials to be used for the client + */ +export type PartialCredentials = { + /** + * API key (admin) + */ + apiKey?: string + /** + * Nettu account id (admin) + */ + nettuAccount?: string + /** + * Token (user) + */ + token?: string +} + +/** + * Create credentials for the client (admin or user) + * @param creds partial credentials + * @returns credentials + */ +export const createCreds = (creds?: PartialCredentials): ICredentials => { + if (creds?.apiKey) { + return new AccountCreds(creds.apiKey) + } + if (creds?.nettuAccount) { + return new UserCreds(creds?.nettuAccount, creds?.token) + } + // throw new Error("No api key or nettu account provided to nettu client."); + return new EmptyCreds() +} + +/** + * Credentials for the API for end users (usually frontend) + */ +export class UserCreds implements ICredentials { + private readonly nettuAccount: string + private readonly token?: string + + constructor(nettuAccount: string, token?: string) { + this.nettuAccount = nettuAccount + this.token = token + } + + createAuthHeaders() { + const creds: Record = { + 'nettu-account': this.nettuAccount, + } + if (this.token) { + creds.authorization = `Bearer ${this.token}` + } + + return Object.freeze(creds) + } +} + +/** + * Credentials for the API for admins (usually backend) + */ +export class AccountCreds implements ICredentials { + private readonly apiKey: string + + constructor(apiKey: string) { + this.apiKey = apiKey + } + + createAuthHeaders() { + return Object.freeze({ + 'x-api-key': this.apiKey, + }) + } +} + +export interface ICredentials { + createAuthHeaders(): object +} + +export class EmptyCreds implements ICredentials { + createAuthHeaders() { + return Object.freeze({}) + } +} + +export interface ICredentials { + createAuthHeaders(): object +} diff --git a/scheduler/clients/javascript/lib/index.ts b/scheduler/clients/javascript/lib/index.ts index 9a86e37d..d3a4c3f3 100644 --- a/scheduler/clients/javascript/lib/index.ts +++ b/scheduler/clients/javascript/lib/index.ts @@ -1,13 +1,12 @@ import { NettuAccountClient } from './accountClient' import { - AccountCreds, - EmptyCreds, - type ICredentials, - UserCreds, + createAxiosInstanceBackend, + createAxiosInstanceFrontend, } from './baseClient' import { NettuCalendarClient, NettuCalendarUserClient } from './calendarClient' import { NettuEventClient, NettuEventUserClient } from './eventClient' import { NettuHealthClient } from './healthClient' +import { createCreds, PartialCredentials } from './helpers/credentials' import { NettuScheduleUserClient, NettuScheduleClient } from './scheduleClient' import { NettuServiceUserClient, NettuServiceClient } from './serviceClient' import { @@ -17,12 +16,6 @@ import { export * from './domain' -type PartialCredentials = { - apiKey?: string - nettuAccount?: string - token?: string -} - export interface INettuUserClient { calendar: NettuCalendarUserClient events: NettuEventUserClient @@ -41,51 +34,77 @@ export interface INettuClient { user: _NettuUserClient } +/** + * Base configuration for the client + */ type ClientConfig = { - baseUrl: string + /** + * Base URL for the API + */ + baseUrl?: string + + /** + * Keep the connection alive + */ + keepAlive?: boolean } -export const config: ClientConfig = { +const DEFAULT_CONFIG: Required = { baseUrl: 'http://localhost:5000/api/v1', + keepAlive: false, } +/** + * Create a client for the Nettu API (user client, not admin) + * @param config configuration and credentials to be used + * @returns user client + */ export const NettuUserClient = ( - partialCreds?: PartialCredentials + config?: PartialCredentials & ClientConfig ): INettuUserClient => { - const creds = createCreds(partialCreds) + const creds = createCreds(config) + + const finalConfig = { ...DEFAULT_CONFIG, ...config } + + // User clients should not keep the connection alive (usually on the frontend) + const axiosClient = createAxiosInstanceFrontend( + { baseUrl: finalConfig.baseUrl }, + creds + ) return Object.freeze({ - calendar: new NettuCalendarUserClient(creds), - events: new NettuEventUserClient(creds), - service: new NettuServiceUserClient(creds), - schedule: new NettuScheduleUserClient(creds), - user: new NettuUserUserClient(creds), + calendar: new NettuCalendarUserClient(axiosClient), + events: new NettuEventUserClient(axiosClient), + service: new NettuServiceUserClient(axiosClient), + schedule: new NettuScheduleUserClient(axiosClient), + user: new NettuUserUserClient(axiosClient), }) } -export const NettuClient = ( - partialCreds?: PartialCredentials -): INettuClient => { - const creds = createCreds(partialCreds) +/** + * Create a client for the Nettu API (admin client) + * @param config configuration and credentials to be used + * @returns admin client + */ +export const NettuClient = async ( + config?: PartialCredentials & ClientConfig +): Promise => { + const creds = createCreds(config) + + const finalConfig = { ...DEFAULT_CONFIG, ...config } + + const axiosClient = await createAxiosInstanceBackend( + { baseUrl: finalConfig.baseUrl, keepAlive: finalConfig.keepAlive }, + creds + ) return Object.freeze({ - account: new NettuAccountClient(creds), - events: new NettuEventClient(creds), - calendar: new NettuCalendarClient(creds), - user: new _NettuUserClient(creds), - service: new NettuServiceClient(creds), - schedule: new NettuScheduleClient(creds), - health: new NettuHealthClient(creds), + account: new NettuAccountClient(axiosClient), + events: new NettuEventClient(axiosClient), + calendar: new NettuCalendarClient(axiosClient), + user: new _NettuUserClient(axiosClient), + service: new NettuServiceClient(axiosClient), + schedule: new NettuScheduleClient(axiosClient), + health: new NettuHealthClient(axiosClient), }) } - -const createCreds = (creds?: PartialCredentials): ICredentials => { - if (creds?.apiKey) { - return new AccountCreds(creds.apiKey) - } - if (creds?.nettuAccount) { - return new UserCreds(creds?.nettuAccount, creds?.token) - } - // throw new Error("No api key or nettu account provided to nettu client."); - return new EmptyCreds() -} diff --git a/scheduler/clients/javascript/lib/userClient.ts b/scheduler/clients/javascript/lib/userClient.ts index 68274d5b..9369e529 100644 --- a/scheduler/clients/javascript/lib/userClient.ts +++ b/scheduler/clients/javascript/lib/userClient.ts @@ -194,12 +194,12 @@ export class NettuUserClient extends NettuBaseClient { status: res.status, data: { events: res.data.events.map(event => { - return { - event: convertEventDates(event.event), - instances: event.instances.map(convertInstanceDates), - } - }), - } + return { + event: convertEventDates(event.event), + instances: event.instances.map(convertInstanceDates), + } + }), + }, } } @@ -232,14 +232,11 @@ export class NettuUserClient extends NettuBaseClient { public async freebusyMultipleUsers( req: GetMultipleUsersFeebusyReq ): Promise> { - const res = await this.post( - '/user/freebusy', - { - userIds: req.userIds, - startTime: req.startTime.toISOString(), - endTime: req.endTime.toISOString(), - } - ) + const res = await this.post('/user/freebusy', { + userIds: req.userIds, + startTime: req.startTime.toISOString(), + endTime: req.endTime.toISOString(), + }) if (!res.data) { return res diff --git a/scheduler/clients/javascript/tests/account.spec.ts b/scheduler/clients/javascript/tests/account.spec.ts index da6556f3..5a96d10d 100644 --- a/scheduler/clients/javascript/tests/account.spec.ts +++ b/scheduler/clients/javascript/tests/account.spec.ts @@ -1,4 +1,4 @@ -import { NettuClient } from '../lib' +import { INettuClient, NettuClient } from '../lib' import { setupAccount, setupUserClientForAccount, @@ -7,9 +7,10 @@ import { import { readPrivateKey, readPublicKey } from './helpers/utils' describe('Account API', () => { - const client = NettuClient() + let client: INettuClient it('should create account', async () => { + client = await NettuClient({}) const { status, data } = await client.account.create({ code: CREATE_ACCOUNT_CODE, }) @@ -24,7 +25,9 @@ describe('Account API', () => { if (!data) { throw new Error('Account not created') } - const accountClient = NettuClient({ apiKey: data.secretApiKey }) + const accountClient = await NettuClient({ + apiKey: data.secretApiKey, + }) const res = await accountClient.account.me() expect(res.status).toBe(200) if (!res.data) { diff --git a/scheduler/clients/javascript/tests/calendar.spec.ts b/scheduler/clients/javascript/tests/calendar.spec.ts index 88124743..929fc466 100644 --- a/scheduler/clients/javascript/tests/calendar.spec.ts +++ b/scheduler/clients/javascript/tests/calendar.spec.ts @@ -1,17 +1,13 @@ -import { - INettuClient, - NettuClient, - config, - type INettuUserClient, -} from '../lib' +import { INettuClient, NettuClient, type INettuUserClient } from '../lib' import { setupUserClient } from './helpers/fixtures' describe('Calendar API', () => { let client: INettuUserClient let userId: string - const unauthClient = NettuClient() + let unauthClient: INettuClient beforeAll(async () => { + unauthClient = await NettuClient({}) const data = await setupUserClient() client = data.userClient userId = data.userId diff --git a/scheduler/clients/javascript/tests/calendarEvent.spec.ts b/scheduler/clients/javascript/tests/calendarEvent.spec.ts index 718ac567..a9ce388a 100644 --- a/scheduler/clients/javascript/tests/calendarEvent.spec.ts +++ b/scheduler/clients/javascript/tests/calendarEvent.spec.ts @@ -14,7 +14,9 @@ describe('CalendarEvent API', () => { beforeAll(async () => { const data = await setupUserClient() client = data.userClient - unauthClient = NettuClient({ nettuAccount: data.accountId }) + unauthClient = await NettuClient({ + nettuAccount: data.accountId, + }) const calendarRes = await client.calendar.create({ timezone: 'UTC', }) diff --git a/scheduler/clients/javascript/tests/health.spec.ts b/scheduler/clients/javascript/tests/health.spec.ts index 906db487..fc06982b 100644 --- a/scheduler/clients/javascript/tests/health.spec.ts +++ b/scheduler/clients/javascript/tests/health.spec.ts @@ -1,9 +1,8 @@ import { NettuClient } from '../lib' describe('Health API', () => { - const client = NettuClient() - it('should report healthy status', async () => { + const client = await NettuClient({}) const status = await client.health.checkStatus() expect(status).toBe(200) }) diff --git a/scheduler/clients/javascript/tests/helpers/fixtures.ts b/scheduler/clients/javascript/tests/helpers/fixtures.ts index 08de1ea8..b50f9368 100644 --- a/scheduler/clients/javascript/tests/helpers/fixtures.ts +++ b/scheduler/clients/javascript/tests/helpers/fixtures.ts @@ -6,14 +6,16 @@ export const CREATE_ACCOUNT_CODE = process.env.CREATE_ACCOUNT_SECRET_CODE || 'opqI5r3e7v1z2h3P' export const setupAccount = async () => { - const client = NettuClient() + const client = await NettuClient() const account = await client.account.create({ code: CREATE_ACCOUNT_CODE }) const accountId = account.data?.account.id if (!accountId) { throw new Error('Account not created') } return { - client: NettuClient({ apiKey: account.data?.secretApiKey }), + client: await NettuClient({ + apiKey: account.data?.secretApiKey, + }), accountId: account.data?.account.id, } } @@ -62,7 +64,10 @@ export const setupUserClientForAccount = ( ) return { token, - client: NettuUserClient({ token, nettuAccount: accountId }), + client: NettuUserClient({ + token, + nettuAccount: accountId, + }), } } diff --git a/scheduler/clients/javascript/tests/requirements/requirements.spec.ts b/scheduler/clients/javascript/tests/requirements/requirements.spec.ts index a5261dce..917f35e6 100644 --- a/scheduler/clients/javascript/tests/requirements/requirements.spec.ts +++ b/scheduler/clients/javascript/tests/requirements/requirements.spec.ts @@ -878,7 +878,10 @@ describe('Requirements', () => { expect(res?.status).toBe(200) expect(res?.data).toBeDefined() expect(res?.data?.events.length).toBe(1) - console.log('res?.data?.events[0].event.startTime', res?.data?.events[0].event.startTime) + console.log( + 'res?.data?.events[0].event.startTime', + res?.data?.events[0].event.startTime + ) expect(res?.data?.events[0].event.startTime).toEqual(date1.toDate()) }) diff --git a/scheduler/clients/javascript/tests/schedule.spec.ts b/scheduler/clients/javascript/tests/schedule.spec.ts index bc02aff8..dd984809 100644 --- a/scheduler/clients/javascript/tests/schedule.spec.ts +++ b/scheduler/clients/javascript/tests/schedule.spec.ts @@ -1,4 +1,5 @@ import { + INettuClient, type INettuUserClient, NettuClient, ScheduleRuleVariant, @@ -8,10 +9,11 @@ import { setupUserClient } from './helpers/fixtures' describe('Schedule API', () => { let client: INettuUserClient - const unauthClient = NettuClient() + let unauthClient: INettuClient let userId: string beforeAll(async () => { + unauthClient = await NettuClient({}) const data = await setupUserClient() client = data.userClient userId = data.userId diff --git a/scheduler/clients/javascript/tests/service.spec.ts b/scheduler/clients/javascript/tests/service.spec.ts index 688eeb34..59dac7f8 100644 --- a/scheduler/clients/javascript/tests/service.spec.ts +++ b/scheduler/clients/javascript/tests/service.spec.ts @@ -4,7 +4,7 @@ import { ScheduleRuleVariant, Weekday, } from '../lib' -import { setupAccount, setupUserClient } from './helpers/fixtures' +import { setupUserClient } from './helpers/fixtures' describe('Service API', () => { let client: INettuClient diff --git a/scheduler/clients/javascript/tests/user.spec.ts b/scheduler/clients/javascript/tests/user.spec.ts index 7fa328b4..dc6af05e 100644 --- a/scheduler/clients/javascript/tests/user.spec.ts +++ b/scheduler/clients/javascript/tests/user.spec.ts @@ -13,12 +13,15 @@ describe('User API', () => { let accountClient: INettuClient let client: INettuUserClient let unauthClient: INettuClient + beforeAll(async () => { const data = await setupUserClient() client = data.userClient accountClient = data.accountClient userId = data.userId - unauthClient = NettuClient({ nettuAccount: data.accountId }) + unauthClient = await NettuClient({ + nettuAccount: data.accountId, + }) const calendarRes = await client.calendar.create({ timezone: 'UTC' }) if (!calendarRes.data) { throw new Error('Calendar not created')