@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
text/typescript
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 };