UNPKG

@nextplus/js-sdk

Version:

A TypeScript SDK for interacting with the NextPlus API, automatically generated from OpenAPI specifications.

307 lines (263 loc) 10.4 kB
import debug from 'debug'; import { createClient } from './sdk/client'; import { UserModelService } from './sdk'; import * as AllServices from './sdk'; import type * as AllServiceTypes from './sdk'; const log = debug('nextplus:sdk'); const accessToken = { token: null as string | null, created: null as Date | null, ttl: 0, }; const setAccessToken = (token: string, created: Date = new Date(), ttl: number = 1209600) => { accessToken.token = token; accessToken.created = created; accessToken.ttl = ttl; }; const getAccessToken = () => { return accessToken; }; const isTokenValid = (): boolean => { if (!accessToken.token || !accessToken.created) { return false; } const now = new Date(); const tokenAge = now.getTime() - accessToken.created.getTime(); const tokenTtlMs = accessToken.ttl * 1000; // TTL is in seconds return tokenAge < tokenTtlMs; }; export class NextPlusSDK { private client: any; private credentials: { email?: string; username?: string; password: string }; private returnType: 'raw' | 'data' = 'data'; // Wrap plain developer inputs into the right container (body/query) // and stringify complex query params when required by LoopBack (filter/where) private normalizeOptions(methodName: string, rawOptions: any): any { const options: any = rawOptions && typeof rawOptions === 'object' ? { ...rawOptions } : {}; // Always ensure client is set (will be set by caller afterwards if missing) // We avoid mutating the original object to keep predictable behavior // If developer passed top-level filter/where/fields/order/limit/offset/include // without wrapping into query, move them into a filter object for find/findOne const method = methodName.toString().toLowerCase(); const isFind = method.includes('find') && !method.includes('count'); const isCount = method.includes('count'); const shouldUseBody = /(create|update|replace|patch|login|upsert)/i.test(methodName.toString()); const topLevelKeys = ['filter', 'where', 'fields', 'order', 'limit', 'offset', 'include']; const hasTopLevelFilterBits = topLevelKeys.some((k) => Object.prototype.hasOwnProperty.call(options, k)); // Only create query if relevant for this method if (isFind) { options.query = options.query || {}; // If developer provided a full filter at top-level as object, stringify it if (options.filter && typeof options.filter === 'object') { options.query.filter = JSON.stringify(options.filter); delete options.filter; } else if (hasTopLevelFilterBits) { const filterObject: any = {}; if (options.where !== undefined) filterObject.where = options.where; if (options.fields !== undefined) filterObject.fields = options.fields; if (options.order !== undefined) filterObject.order = options.order; if (options.limit !== undefined) filterObject.limit = options.limit; if (options.offset !== undefined) filterObject.offset = options.offset; if (options.include !== undefined) filterObject.include = options.include; if (Object.keys(filterObject).length > 0) { options.query.filter = typeof filterObject === 'string' ? filterObject : JSON.stringify(filterObject); } // Clean moved keys from top-level topLevelKeys.forEach((k) => delete options[k]); } } else if (isCount) { options.query = options.query || {}; // Allow top-level where or query.where as object → stringify if (options.where !== undefined) { const whereVal = options.where; options.query.where = typeof whereVal === 'string' ? whereVal : JSON.stringify(whereVal); delete options.where; } } else if (shouldUseBody) { // For create/update-like methods: allow passing the payload at top-level → move to body if (options.body === undefined) { const bodyCandidate: any = { ...options }; delete bodyCandidate.client; delete bodyCandidate.query; delete bodyCandidate.path; if (Object.keys(bodyCandidate).length > 0) { options.body = bodyCandidate; // Keep only body/client/query/path at root Object.keys(options).forEach((k) => { if (!['body', 'client', 'query', 'path'].includes(k)) delete options[k]; }); } } } // If developer already provided query, stringify any object/array values automatically if (options.query && typeof options.query === 'object') { Object.keys(options.query).forEach((key) => { const val = options.query[key]; if (val && (Array.isArray(val) || typeof val === 'object')) { options.query[key] = JSON.stringify(val); } }); } return options; } constructor(options: { baseURL: string; email?: string; username?: string; password: string; returnType?: 'raw' | 'data'; }) { // Validate that either email or username is provided if (!options.email && !options.username) { throw new Error('Either email or username must be provided'); } if (options.email && options.username) { throw new Error('Provide either email or username, not both'); } this.credentials = { email: options.email, username: options.username, password: options.password }; this.returnType = options.returnType || 'data'; // remove trailing slash from baseURL if (options.baseURL.endsWith('/')) { options.baseURL = options.baseURL.slice(0, -1); } // if baseURL does not end with /api, add it if (!options.baseURL.endsWith('/api')) { options.baseURL = options.baseURL + '/api'; } this.client = createClient({ baseUrl: options.baseURL, }); // Add authentication interceptor this.client.interceptors.request.use(this.authRequestInterceptor.bind(this)); // Create service proxies dynamically this.createServiceProxies(); } private async authRequestInterceptor(request: Request, _options: any) { // Skip for UserModels/login if (request.url.includes('/UserModels/login')) { return request; } // Ensure we have a valid token await this.ensureValidToken(); const token = getAccessToken(); if (token.token) { request.headers.set('Authorization', token.token); } return request; } private async ensureValidToken(): Promise<void> { if (!isTokenValid()) { log('Token expired or missing, refreshing...'); await this.login(); } } private async login(): Promise<boolean> { try { const loginBody: any = { password: this.credentials.password, forceLogin: true, }; // Use email if provided, otherwise use username if (this.credentials.email) { loginBody.email = this.credentials.email; } else if (this.credentials.username) { loginBody.username = this.credentials.username; } const response = await UserModelService.login({ client: this.client, body: loginBody, }); if (response.data) { const token = (response.data as any).id; const ttl = (response.data as any).ttl || 1209600; if (token) { setAccessToken(token, new Date(), ttl); log('Login successful, access token set'); return true; } } throw new Error('Login failed - no token received'); } catch (error) { console.error('Login failed:', error); throw error; } } private createServiceProxies() { // Get all service classes from the SDK const serviceNames = Object.keys(AllServices).filter(key => key.endsWith('Service') && typeof (AllServices as any)[key] === 'function' ); serviceNames.forEach(serviceName => { const ServiceClass = (AllServices as any)[serviceName]; // Create a proxy for each service (this as any)[serviceName] = this.createServiceProxy(ServiceClass); }); } private createServiceProxy(ServiceClass: any) { const self = this; return new Proxy(ServiceClass, { get(target, prop) { if (typeof target[prop] === 'function') { return async function(...args: any[]) { // Automatically inject client if not provided if (!args[0]) args[0] = {}; // Normalize developer-friendly input into OpenAPI options args[0] = self.normalizeOptions(String(prop), args[0]); if (!args[0].client) args[0].client = self.client; const result = await target[prop].apply(target, args); // Return based on returnType setting if (self.returnType === 'data' && result && typeof result === 'object' && 'data' in result) { return result.data; } if (self.returnType === 'data' && result && typeof result === 'object' && 'error' in result) { const error = new Error(result.error.error.message || result.error.error.code || 'Unknown error') as any; if (result.error.error.details) { error.details = result.error.error.details; } throw error; } // Return raw response (default behavior) return result; }; } return target[prop]; } }); } // Manual login method (optional, for explicit control) async manualLogin(): Promise<boolean> { return this.login(); } } // Expose accurate static service method types on the SDK instance for IDE/type safety type ServiceStatics = { [K in keyof typeof AllServiceTypes as K extends `${string}Service` ? K : never]: typeof AllServiceTypes[K]; }; export interface NextPlusSDK extends ServiceStatics {} // Legacy function for backward compatibility type SDKInstance = NextPlusSDK & ServiceStatics; const configure = (options: { baseURL: string; email?: string; username?: string; password: string; returnType?: 'raw' | 'data'; }): SDKInstance => { return new NextPlusSDK(options) as SDKInstance; }; // Create SDK instance const createSDK = (options: { baseURL: string; email?: string; username?: string; password: string; returnType?: 'raw' | 'data'; }): SDKInstance => { return new NextPlusSDK(options) as SDKInstance; }; export type { SDKInstance }; export { configure, createSDK };