UNPKG

zdata-client

Version:

TypeScript client library for zdata backend API with authentication and full CRUD operations

641 lines (588 loc) 18.2 kB
/** * @fileoverview zdata API Client implementation */ import axios, { type AxiosError, type AxiosInstance, type AxiosResponse, } from "axios"; import { ApiClientError, type ApiConfig, type ApiError, type AuthResponse, type FindRecordsParams, type IApiClient, InvalidCredentialsError, type LoginRequest, type PaginatedResponse, type RegisterRequest, ValidationError, } from "./types.js"; // ============================================================================= // INTERNAL TYPES // ============================================================================= interface RequestParams { readonly method: "get" | "post" | "put" | "delete"; readonly endpoint: string; readonly data?: unknown; readonly searchParams?: Readonly<Record<string, string>>; } // ============================================================================= // API CLIENT IMPLEMENTATION // ============================================================================= /** * zdata API Client for handling authentication and CRUD operations * * This client provides a complete interface to the zdata backend API, * including user authentication, token management, and full CRUD operations * for any resource with automatic error handling and type safety. * * @example * ```typescript * import { ZDataClient } from 'zdata-client'; * * const client = new ZDataClient({ * baseUrl: 'https://api.example.com', * workspaceId: 'workspace-123' * }); * * // Authentication * await client.login({ * email: 'user@example.com', * password: 'password' * }); * * // CRUD operations * const users = await client.findRecords({ * resourceName: 'users', * page: 1, * limit: 10, * search: 'john' * }); * * const newUser = await client.createRecord('users', { * name: 'John Doe', * email: 'john@example.com' * }); * // newUser will have id, created_at, updated_at fields added * ``` */ export class ZDataClient implements IApiClient { private readonly httpClient: AxiosInstance; private accessToken: string | null = null; /** * Create a new zdata API client instance * @param config - Client configuration options * @throws {Error} When configuration is invalid */ constructor(config: ApiConfig) { this.validateConfig(config); this.httpClient = this.createHttpClient(config); this.setupInterceptors(); } // ============================================================================= // AUTHENTICATION METHODS // ============================================================================= /** * Authenticate user with email and password * * @param credentials - User login credentials * @returns Promise resolving to authentication response with user data and token * @throws {InvalidCredentialsError} When email/password combination is invalid * @throws {ValidationError} When request data is malformed * @throws {ApiClientError} When API request fails * * @example * ```typescript * try { * const auth = await client.login({ * email: 'user@example.com', * password: 'securePassword123' * }); * console.log('Logged in as:', auth.user.name); * } catch (error) { * if (error instanceof InvalidCredentialsError) { * console.error('Invalid email or password'); * } * } * ``` */ public async login(credentials: LoginRequest): Promise<AuthResponse> { this.validateLoginCredentials(credentials); const response = await this.makeRequest({ method: "post", endpoint: "/login", data: credentials, }); this.setAccessToken(response.access_token); return response; } /** * Register a new user account * * @param userData - User registration data * @returns Promise resolving to authentication response with user data and token * @throws {ValidationError} When registration data is invalid * @throws {ApiClientError} When API request fails (e.g., email already exists) * * @example * ```typescript * try { * const auth = await client.register({ * name: 'John Doe', * email: 'john@example.com', * password: 'securePassword123' * }); * console.log('Registered user:', auth.user.name); * } catch (error) { * if (error instanceof ValidationError) { * console.error('Validation errors:', error.errors); * } * } * ``` */ public async register(userData: RegisterRequest): Promise<AuthResponse> { this.validateRegisterData(userData); return this.makeRequest({ method: "post", endpoint: "/register", data: userData, }); } /** * Clear authentication token and log out user * * @example * ```typescript * client.logout(); * console.log('User logged out'); * ``` */ public logout(): void { this.accessToken = null; } /** * Check if user is currently authenticated * * @returns True if user has a valid access token * * @example * ```typescript * if (client.isAuthenticated()) { * console.log('User is logged in'); * } else { * console.log('User needs to log in'); * } * ``` */ public isAuthenticated(): boolean { return Boolean(this.accessToken); } // ============================================================================= // CRUD METHODS // ============================================================================= /** * Create a new record in the specified resource * * @template T - The entity type to create * @param resourceName - Name of the resource to create record in * @param data - Record data to create (without base entity fields) * @returns Promise resolving to the created record with base entity fields * @throws {ValidationError} When record data is invalid * @throws {ApiClientError} When API request fails * * @example * ```typescript * interface User { * name: string; * email: string; * } * * const newUser = await client.createRecord<User>('users', { * name: 'John Doe', * email: 'john@example.com' * }); * // newUser will have id, created_at, updated_at fields added * ``` */ public async createRecord<T = unknown>( resourceName: string, data: import("./types.js").CreateEntity<T> ): Promise<import("./types.js").EntityWithBase<T>> { this.validateResourceName(resourceName); if (!data || typeof data !== "object") { throw new ValidationError("Record data must be a valid object"); } return this.makeRequest({ method: "post", endpoint: `/${resourceName}`, data, }); } /** * Update an existing record * * @template T - The entity type to update * @param resourceName - Name of the resource * @param id - Unique identifier of the record to update * @param data - Partial record data to update (without base entity fields) * @returns Promise resolving to the updated record with base entity fields * @throws {ValidationError} When record data is invalid * @throws {ApiClientError} When API request fails * * @example * ```typescript * interface User { * name: string; * email: string; * } * * const updatedUser = await client.updateRecord<User>('users', 'user-123', { * name: 'John Smith' * }); * // updatedUser will have all fields including updated updated_at * ``` */ public async updateRecord<T = unknown>( resourceName: string, id: string, data: Partial<import("./types.js").CreateEntity<T>> ): Promise<import("./types.js").EntityWithBase<T>> { this.validateResourceName(resourceName); this.validateId(id); if (!data || typeof data !== "object") { throw new ValidationError("Record data must be a valid object"); } return this.makeRequest({ method: "put", endpoint: `/${resourceName}/${id}`, data, }); } /** * Delete a record by ID * * @param resourceName - Name of the resource * @param id - Record identifier * @returns Promise that resolves when deletion is complete * @throws {ApiClientError} When record is not found or API request fails * * @example * ```typescript * await client.deleteRecord('users', 'user-123'); * console.log('User deleted successfully'); * ``` */ public async deleteRecord(resourceName: string, id: string): Promise<void> { this.validateResourceName(resourceName); this.validateId(id); await this.makeRequest({ method: "delete", endpoint: `/${resourceName}/${id}`, }); } /** * Find a specific record by ID * * @template T - The entity type to return * @param resourceName - Name of the resource * @param id - Record identifier * @returns Promise resolving to the found record with base entity fields * @throws {ApiClientError} When record is not found or API request fails * * @example * ```typescript * interface User { * name: string; * email: string; * } * * const user = await client.findRecordById<User>('users', 'user-123'); * // user will have id, created_at, updated_at fields included * console.log('Found user:', user.name); * ``` */ public async findRecordById<T = unknown>( resourceName: string, id: string ): Promise<import("./types.js").EntityWithBase<T>> { this.validateResourceName(resourceName); this.validateId(id); return this.makeRequest({ method: "get", endpoint: `/${resourceName}/${id}`, }); } /** * Find records with pagination and optional search * * @template T - The entity type to return * @param params - Query parameters including resource name, pagination, and search * @returns Promise resolving to paginated response with records containing base entity fields * @throws {ApiClientError} When API request fails * * @example * ```typescript * interface User { * name: string; * email: string; * } * * const result = await client.findRecords<User>({ * resourceName: 'users', * page: 1, * limit: 10, * search: 'john' * }); * * console.log(`Found ${result.meta.totalRecords} users`); * result.records.forEach(user => { * // user has id, created_at, updated_at fields included * console.log(user.name, user.id); * }); * * if (result.meta.hasNext) { * console.log('More results available'); * } * ``` */ public async findRecords<T = unknown>( params: FindRecordsParams ): Promise<PaginatedResponse<import("./types.js").EntityWithBase<T>>> { this.validateResourceName(params.resourceName); const searchParams = this.buildSearchParams(params); return this.makeRequest({ method: "get", endpoint: `/${params.resourceName}`, searchParams, }); } // ============================================================================= // TOKEN MANAGEMENT // ============================================================================= /** * Set the access token for authentication * * This method allows manual token management, useful when implementing * custom authentication flows or token persistence. * * @param token - JWT access token * @throws {Error} When token is invalid or empty * * @example * ```typescript * // Set token from external source * const savedToken = localStorage.getItem('authToken'); * if (savedToken) { * client.setAccessToken(savedToken); * } * ``` */ public setAccessToken(token: string): void { if (!token || typeof token !== "string") { throw new Error("Invalid access token: must be a non-empty string"); } this.accessToken = token; } /** * Get the current access token * * @returns Current access token or null if not authenticated * * @example * ```typescript * const token = client.getAccessToken(); * if (token) { * localStorage.setItem('authToken', token); * } * ``` */ public getAccessToken(): string | null { return this.accessToken; } // ============================================================================= // PRIVATE VALIDATION METHODS // ============================================================================= private validateConfig(config: ApiConfig): void { if (!config.baseUrl) { throw new Error("Configuration error: baseUrl is required"); } if (!config.workspaceId) { throw new Error("Configuration error: workspaceId is required"); } if ( config.timeout !== undefined && (config.timeout <= 0 || config.timeout > 300000) ) { throw new Error( "Configuration error: timeout must be between 1 and 300000 milliseconds" ); } } private validateLoginCredentials(credentials: LoginRequest): void { if (!credentials.email) { throw new Error("Email is required for login"); } if (!credentials.password) { throw new Error("Password is required for login"); } if ( typeof credentials.email !== "string" || typeof credentials.password !== "string" ) { throw new Error("Email and password must be strings"); } } private validateRegisterData(userData: RegisterRequest): void { if (!userData.name) { throw new Error("Name is required for registration"); } if (!userData.email) { throw new Error("Email is required for registration"); } if (!userData.password) { throw new Error("Password is required for registration"); } if ( typeof userData.name !== "string" || typeof userData.email !== "string" || typeof userData.password !== "string" ) { throw new Error("Name, email, and password must be strings"); } } private validateResourceName(resourceName: string): void { if (!resourceName || typeof resourceName !== "string") { throw new Error( "Resource name is required and must be a non-empty string" ); } if (resourceName.trim() !== resourceName) { throw new Error( "Resource name cannot have leading or trailing whitespace" ); } } private validateId(id: string): void { if (!id || typeof id !== "string") { throw new Error("ID is required and must be a non-empty string"); } if (id.trim() !== id) { throw new Error("ID cannot have leading or trailing whitespace"); } } // ============================================================================= // PRIVATE HTTP CLIENT METHODS // ============================================================================= private createHttpClient(config: ApiConfig): AxiosInstance { return axios.create({ baseURL: `${config.baseUrl}/api/v1/${config.workspaceId}`, timeout: config.timeout ?? 10000, headers: { "Content-Type": "application/json", Accept: "application/json", ...config.headers, }, }); } private setupInterceptors(): void { // Request interceptor - add auth token this.httpClient.interceptors.request.use( (config) => { if (this.accessToken && config.headers) { config.headers.Authorization = `Bearer ${this.accessToken}`; } return config; }, (error: unknown) => Promise.reject(error) ); // Response interceptor - handle errors this.httpClient.interceptors.response.use( (response: AxiosResponse) => response, (error: AxiosError) => { throw this.handleHttpError(error); } ); } private handleHttpError(error: AxiosError): Error { if (!error.response) { return new ApiClientError( "Network error: Unable to connect to the server" ); } const { status, data } = error.response; const apiError = data as ApiError; switch (status) { case 401: return new InvalidCredentialsError( apiError?.message ?? "Authentication failed: Invalid credentials" ); case 400: return new ValidationError( apiError?.message ?? "Validation failed: Invalid request data", apiError?.errors ?? [] ); case 404: return new ApiClientError("Resource not found", 404); case 429: return new ApiClientError( "Rate limit exceeded: Too many requests", 429 ); case 500: return new ApiClientError( "Internal server error: Please try again later", 500 ); case 503: return new ApiClientError( "Service unavailable: Server is temporarily down", 503 ); default: return new ApiClientError( apiError?.message ?? `HTTP error ${status}: Request failed`, status ); } } private buildSearchParams(params: FindRecordsParams): Record<string, string> { const searchParams: Record<string, string> = { page: String(params.page ?? 1), limit: String(params.limit ?? 10), }; if (params.search) { searchParams.search = params.search; } return searchParams; } private async makeRequest(params: RequestParams): Promise<any> { try { let response: AxiosResponse; if (params.method === "get" || params.method === "delete") { response = await this.httpClient[params.method](params.endpoint, { params: params.searchParams, }); } else { response = await this.httpClient[params.method]( params.endpoint, params.data, { params: params.searchParams } ); } return response.data; } catch (error) { // Error is already handled by interceptor throw error; } } } // ============================================================================= // CONVENIENCE EXPORTS // ============================================================================= /** * Legacy alias for backward compatibility * @deprecated Use ZDataClient instead */ export const ExternalApiClient = ZDataClient;