UNPKG

@simpleapps-com/augur-api

Version:

TypeScript client library for Augur microservices API endpoints

738 lines 29.2 kB
import { ValidationError } from './errors'; import { BaseResponseSchema } from './schemas'; import { z } from 'zod'; /** * Abstract base class for all service clients * * Provides standardized error handling, validation, and request processing * to eliminate boilerplate code across service implementations. * * @example * ```typescript * class MyServiceClient extends BaseServiceClient { * constructor(http: HTTPClient, baseUrl?: string) { * super('myservice', http, baseUrl || 'https://myservice.augur-api.com'); * } * * // Simple method using executeRequest * async getUser(id: number) { * return this.executeRequest({ * method: 'GET', * path: `/users/${id}`, * responseSchema: UserResponseSchema * }); * } * * // Method with parameters * async listUsers(params?: UserListParams) { * return this.executeRequest({ * method: 'GET', * path: '/users', * paramsSchema: UserListParamsSchema, * responseSchema: UserListResponseSchema * }, params); * } * } * ``` */ export class BaseServiceClient { /** * Create a new service client instance * @param serviceName Name of the service (used for error reporting) * @param http Configured HTTPClient instance * @param baseUrl Base URL for the service API */ constructor(serviceName, http, baseUrl) { this.serviceName = serviceName; this.http = http; this.baseUrl = baseUrl; } /** * Execute a standardized API request with automatic validation and error handling * * This method handles all the common patterns: * - Parameter validation with Zod schemas * - HTTP request execution * - Response validation * - Consistent error handling and reporting * * @param config Endpoint configuration including method, path, and schemas * @param params Optional request parameters (query params for GET, body for POST/PUT) * @param pathParams Optional path parameters for URL substitution * @returns Promise resolving to the validated response data * @throws ValidationError When parameters or response validation fails * @throws AugurError For HTTP errors (handled by HTTPClient interceptors) */ async executeRequest(config, params, pathParams) { const endpoint = this.buildEndpointPath(config.path, pathParams); try { const validatedParams = this.validateParameters(config, params); const response = await this.executeHttpRequest(config, endpoint, validatedParams); const validatedResponse = config.responseSchema.parse(response); return validatedResponse; } catch (error) { if (error instanceof z.ZodError) { // Mark if this is a response validation error (not parameter validation) throw this.createValidationError(error, config, params, endpoint, 'response'); } throw error; } } /** * Validate request parameters using the provided schema */ validateParameters(config, params) { if (config.paramsSchema) { try { // Always validate when schema is provided, treating undefined as empty object return config.paramsSchema.parse(params ?? {}); } catch (error) { if (error instanceof z.ZodError) { // Mark parameter validation errors specifically throw this.createValidationError(error, config, params, config.path, 'params'); } throw error; } } return params; } /** * Execute HTTP request based on the configured method */ async executeHttpRequest(config, endpoint, validatedParams) { const url = `${this.baseUrl}${endpoint}`; switch (config.method) { case 'GET': return await this.http.get(url, validatedParams); case 'POST': return await this.http.post(url, validatedParams); case 'PUT': return await this.http.put(url, validatedParams); case 'DELETE': return await this.http.delete(url); default: throw new Error(`Unsupported HTTP method: ${config.method}`); } } /** * Create a detailed validation error with context information */ createValidationError(zodError, config, params, endpoint, errorType) { let message = 'Invalid parameters or response'; let contextInfo = ''; if (errorType === 'params') { message = 'Invalid request parameters'; contextInfo = `\nProvided parameters: ${JSON.stringify(params, null, 2)}`; } else if (errorType === 'response') { message = 'Invalid API response structure'; contextInfo = '\nThis appears to be a response validation error. The API returned data in an unexpected format during API refactoring.'; } else { // Fallback logic for when error type is not specified const hasParamSchema = !!config.paramsSchema; if (hasParamSchema && params !== undefined) { message = 'Invalid request parameters'; contextInfo = `\nProvided parameters: ${JSON.stringify(params, null, 2)}`; } else if (!hasParamSchema) { message = 'Invalid API response structure'; contextInfo = '\nThis appears to be a response validation error. The API returned data in an unexpected format.'; } } const validationError = new ValidationError({ message, service: this.serviceName, endpoint, validationErrors: zodError.errors, }); if (contextInfo) { validationError.message += contextInfo; } return validationError; } /** * Create a standardized list method factory * * Generates a function that follows the standard list pattern with optional parameters, * pagination support, and consistent error handling. * * @param endpoint The API endpoint path * @param paramsSchema Zod schema for validating query parameters * @param responseSchema Zod schema for validating the response * @returns A function that executes the list operation */ createListMethod(endpoint, paramsSchema, responseSchema) { return async (params) => { return this.executeRequest({ method: 'GET', path: endpoint, paramsSchema, responseSchema, }, params); }; } /** * Create a standardized get method factory * * Generates a function that retrieves a single item by ID with optional parameters. * * @param endpointTemplate The API endpoint template with parameter placeholders * @param responseSchema Zod schema for validating the response * @param paramsSchema Optional Zod schema for query parameters * @returns A function that executes the get operation */ createGetMethod(endpointTemplate, responseSchema, paramsSchema) { return async (id, params) => { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); const pathParams = paramName ? { [paramName]: String(id) } : { id: String(id) }; return this.executeRequest({ method: 'GET', path: endpointTemplate, paramsSchema, responseSchema, }, params, pathParams); }; } /** * Create a standardized create method factory * * Generates a function that creates a new resource via POST request. * * @param endpoint The API endpoint path * @param requestSchema Zod schema for validating request data * @param responseSchema Zod schema for validating the response * @returns A function that executes the create operation */ createCreateMethod(endpoint, requestSchema, responseSchema) { return async (data) => { return this.executeRequest({ method: 'POST', path: endpoint, paramsSchema: requestSchema, responseSchema, }, data); }; } /** * Create a standardized update method factory * * Generates a function that updates an existing resource via PUT request. * * @param endpointTemplate The API endpoint template with parameter placeholders * @param requestSchema Zod schema for validating request data * @param responseSchema Zod schema for validating the response * @returns A function that executes the update operation */ createUpdateMethod(endpointTemplate, requestSchema, responseSchema) { return async (id, data) => { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); const pathParams = paramName ? { [paramName]: String(id) } : { id: String(id) }; return this.executeRequest({ method: 'PUT', path: endpointTemplate, paramsSchema: requestSchema, responseSchema, }, data, pathParams); }; } /** * Create a standardized delete method factory * * Generates a function that deletes a resource via DELETE request. * * @param endpointTemplate The API endpoint template with parameter placeholders * @param responseSchema Zod schema for validating the response * @returns A function that executes the delete operation */ createDeleteMethod(endpointTemplate, responseSchema) { return async (id) => { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); const pathParams = paramName ? { [paramName]: String(id) } : { id: String(id) }; return this.executeRequest({ method: 'DELETE', path: endpointTemplate, responseSchema, }, undefined, pathParams); }; } /** * Create a health check method that follows the standard pattern * * @param responseSchema Zod schema for the health check response * @returns A function that executes the health check */ createHealthCheckMethod(responseSchema) { return async () => { // Health check returns the full response object return this.executeRequest({ method: 'GET', path: '/health-check', responseSchema, requiresAuth: false, }); }; } /** * Create a delete method that returns a boolean success indicator * * Common pattern for delete operations that don't return the deleted entity. * The method always returns true if the request succeeds (no error thrown). * * @param endpointTemplate The API endpoint template with parameter placeholders * @returns A function that executes the delete operation and returns true on success */ createDeleteBooleanMethod(endpointTemplate) { return async (id) => { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); const pathParams = paramName ? { [paramName]: String(id) } : { id: String(id) }; await this.executeRequest({ method: 'DELETE', path: endpointTemplate, responseSchema: z.any(), // Accept any response as we only care about success }, undefined, pathParams); return true; }; } /** * Create a search method with required query parameter * * Enforces that a search query is provided and handles the common search pattern. * * @param endpoint The API endpoint path * @param paramsSchema Zod schema for validating search parameters (must include 'q') * @param responseSchema Zod schema for validating the response * @returns A function that executes the search operation */ createSearchMethod(endpoint, paramsSchema, responseSchema) { return async (params) => { if (!params.q || params.q.trim() === '') { throw new ValidationError({ message: 'Search query is required', service: this.serviceName, endpoint, validationErrors: [ { path: ['q'], message: 'Search query cannot be empty', code: 'custom', }, ], }); } return this.executeRequest({ method: 'GET', path: endpoint, paramsSchema, responseSchema, }, params); }; } /** * Create a method that returns the first item from an array response * * Common pattern for endpoints that return an array but we only need the first item. * * @param endpoint The API endpoint path * @param paramsSchema Zod schema for validating request parameters * @param itemSchema Zod schema for validating individual items in the array * @returns A function that executes the request and returns the first item or undefined */ createSingleItemMethod(endpoint, paramsSchema, itemSchema) { return async (params) => { const response = await this.executeRequest({ method: 'GET', path: endpoint, paramsSchema, responseSchema: this.createArrayResponseSchema(itemSchema), }, params); return response.data[0]; }; } /** * Create an action method for operations like enable/disable * * Common pattern for POST/PUT endpoints that perform actions on resources. * * @param endpointTemplate The API endpoint template with placeholders * @param method HTTP method (POST or PUT) * @param responseSchema Zod schema for validating the response * @param pathParams Additional path parameters beyond the id * @returns A function that executes the action */ createActionMethod(endpointTemplate, method, responseSchema, pathParams) { return async (id) => { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); const idParam = paramName ? { [paramName]: String(id) } : { id: String(id) }; return this.executeRequest({ method, path: endpointTemplate, responseSchema, }, undefined, { ...idParam, ...pathParams }); }; } /** * Create a void method that doesn't return meaningful data * * Common pattern for operations that succeed with empty or undefined responses. * * @param endpointTemplate The API endpoint template * @param method HTTP method * @returns A function that executes the operation */ createVoidMethod(endpointTemplate, method) { return async (id) => { let pathParams; if (id !== undefined) { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); pathParams = paramName ? { [paramName]: String(id) } : { id: String(id) }; } await this.executeRequest({ method, path: endpointTemplate, responseSchema: z.any(), }, undefined, pathParams); }; } /** * Helper to create a standard array response schema */ createArrayResponseSchema(itemSchema) { return BaseResponseSchema(z.array(itemSchema)); } /** * Create a standardized document method for endpoints that return enhanced documentation * * Common pattern across services for endpoints like /content/{id}/doc, /users/{id}/doc, etc. * * @param endpointTemplate The API endpoint template with parameter placeholders * @param responseSchema Zod schema for validating the document response * @param paramsSchema Optional Zod schema for query parameters * @returns A function that executes the document retrieval operation */ createDocMethod(endpointTemplate, responseSchema, paramsSchema) { return async (id, params) => { // Extract parameter name from template (e.g., {binTransferHdrUid} -> binTransferHdrUid) const paramName = this.extractParameterName(endpointTemplate); const pathParams = paramName ? { [paramName]: String(id) } : { id: String(id) }; return this.executeRequest({ method: 'GET', path: endpointTemplate, paramsSchema, responseSchema, }, params, pathParams); }; } /** * Create a standardized ping method for service availability testing * * @param responseSchema Zod schema for the ping response (typically string or literal) * @returns A function that executes the ping operation */ createPingMethod(responseSchema) { return async () => { return this.executeRequest({ method: 'GET', path: '/ping', responseSchema, requiresAuth: false, }); }; } /** * Build endpoint path with optional path parameter substitution * * @param pathTemplate Path template with {param} placeholders * @param pathParams Object containing parameter values * @returns Processed endpoint path * * @example * ```typescript * buildEndpointPath('/users/{id}', { id: '123' }) // returns '/users/123' * buildEndpointPath('/health-check') // returns '/health-check' * ``` */ buildEndpointPath(pathTemplate, pathParams) { if (!pathParams) { return pathTemplate; } let path = pathTemplate; for (const [key, value] of Object.entries(pathParams)) { path = path.replace(`{${key}}`, String(value)); } return path; } /** * Create a debugging helper for troubleshooting validation errors * * @param config Endpoint configuration * @param params Parameters being validated * @returns Debug information object */ createDebugInfo(config, params) { return { endpoint: config.path, method: config.method, hasParamSchema: !!config.paramsSchema, providedParams: params, expectedSchema: config.paramsSchema?.description || 'No schema description available', }; } /** * Validate parameters and provide detailed error information for debugging * * @param schema Zod schema to validate against * @param params Parameters to validate * @param context Context information for error messages * @returns Validation result with detailed error information */ validateWithDebugInfo(schema, params, context) { try { const data = schema.parse(params); return { success: true, data }; } catch (error) { if (error instanceof z.ZodError) { const formattedErrors = error.errors.map(err => { const path = err.path.length > 0 ? err.path.join('.') : 'root'; return `${context} - ${path}: ${err.message}`; }); return { success: false, errors: formattedErrors, rawErrors: error, }; } throw error; } } /** * Extract parameter name from endpoint template * * @param endpointTemplate The endpoint template (e.g., '/bin-transfer/{binTransferHdrUid}') * @returns The parameter name (e.g., 'binTransferHdrUid') or null if no parameter found * * @example * ```typescript * extractParameterName('/bin-transfer/{binTransferHdrUid}') // returns 'binTransferHdrUid' * extractParameterName('/users/{id}') // returns 'id' * extractParameterName('/health-check') // returns null * ``` */ extractParameterName(endpointTemplate) { const match = endpointTemplate.match(/\{([^}]+)\}/); return match ? match[1] : null; } /** * Get discovery metadata for this service client * * This method extracts semantic JSDoc metadata from all methods in the service client * to enable AI agents to understand the service topology and navigate endpoints. * * @returns ServiceDiscoveryMetadata containing service info and endpoint metadata */ getDiscoveryMetadata() { const endpoints = []; // Get all methods from the service client class and its prototype chain const methods = this.getAllMethods(); for (const methodName of methods) { const metadata = this.extractMethodMetadata(methodName); if (metadata && metadata.discoverable) { endpoints.push(metadata); } } return { serviceName: this.serviceName, description: this.getServiceDescription(), baseUrl: this.baseUrl, endpoints, }; } /** * Get all method names from the service client instance */ getAllMethods() { return this.walkPrototypeChain(this, []); } /** * Recursively walk prototype chain to collect methods */ walkPrototypeChain(obj, methods) { if (!obj || obj === BaseServiceClient.prototype) { return methods; } const props = Object.getOwnPropertyNames(obj); for (const prop of props) { if (this.isValidMethod(obj, prop) && !methods.includes(prop)) { methods.push(prop); } } return this.walkPrototypeChain(Object.getPrototypeOf(obj), methods); } /** * Check if a property is a valid method to include in discovery */ isValidMethod(obj, prop) { return (typeof obj[prop] === 'function' && prop !== 'constructor' && !prop.startsWith('_')); } /** * Extract semantic metadata from a method's JSDoc comments * * This method parses JSDoc comments to extract the semantic tags required * for AI agent discovery and navigation. */ extractMethodMetadata(methodName) { try { const methodFunc = this.getMethodFunction(methodName); if (!methodFunc) return null; const jsdocContent = this.extractJSDocContent(methodFunc); if (!jsdocContent) return null; const semanticTags = this.parseSemanticTags(jsdocContent); /* istanbul ignore next - JSDoc success path tested but not tracked by Jest coverage */ if (!this.hasRequiredTags(semanticTags, jsdocContent)) return null; /* istanbul ignore next - JSDoc success path tested but not tracked by Jest coverage */ const description = this.extractDescription(jsdocContent); const { method: httpMethod, path } = this.inferMethodDetails(methodName, methodFunc.toString()); /* istanbul ignore next - JSDoc success path tested but not tracked by Jest coverage */ return { fullPath: semanticTags.fullPath, service: semanticTags.service, domain: semanticTags.domain, method: httpMethod, path, description, searchTerms: semanticTags.searchTerms || [], relatedEndpoints: semanticTags.relatedEndpoints || [], commonPatterns: semanticTags.commonPatterns || [], dataMethod: semanticTags.dataMethod, discoverable: true, }; } catch { return null; } } /** * Get method function from instance */ getMethodFunction(methodName) { const methodFunc = this[methodName]; return methodFunc && typeof methodFunc === 'function' ? methodFunc : null; } /** * Extract JSDoc content from method source */ extractJSDocContent(methodFunc) { const methodSource = methodFunc.toString(); const jsdocMatch = methodSource.match(/\/\*\*([\s\S]*?)\*\//); return jsdocMatch ? jsdocMatch[1] : null; } /** * Parse all semantic tags from JSDoc content */ parseSemanticTags(jsdocContent) { /* istanbul ignore next - JSDoc success path tested but not tracked by Jest coverage */ return { fullPath: this.extractJSDocTag(jsdocContent, '@fullPath') || '', service: this.extractJSDocTag(jsdocContent, '@service') || '', domain: this.extractJSDocTag(jsdocContent, '@domain') || '', dataMethod: this.extractJSDocTag(jsdocContent, '@dataMethod') || undefined, searchTerms: this.extractJSDocArrayTag(jsdocContent, '@searchTerms'), relatedEndpoints: this.extractJSDocArrayTag(jsdocContent, '@relatedEndpoints'), commonPatterns: this.extractJSDocArrayTag(jsdocContent, '@commonPatterns'), }; } /** * Check if required semantic tags are present */ hasRequiredTags(tags, jsdocContent) { const discoverableTag = this.extractJSDocTag(jsdocContent, '@discoverable'); return !!(tags.fullPath && tags.service && tags.domain && discoverableTag === 'true'); } /** * Extract description from JSDoc content */ extractDescription(jsdocContent) { const descriptionMatch = jsdocContent.match(/^\s*\*\s*([^@].*?)(?:\n|$)/m); /* istanbul ignore next - JSDoc success path tested but not tracked by Jest coverage */ return descriptionMatch ? descriptionMatch[1].trim() : ''; } /** * Extract a single JSDoc tag value */ extractJSDocTag(jsdocContent, tag) { const regex = new RegExp(`${tag}\\s+(.+?)(?:\\n|$)`, 'i'); const match = jsdocContent.match(regex); return match ? match[1].trim() : null; } /** * Extract an array JSDoc tag value (e.g., @searchTerms ["term1", "term2"]) */ extractJSDocArrayTag(jsdocContent, tag) { const regex = new RegExp(`${tag}\\s+\\[([^\\]]+)\\]`, 'i'); const match = jsdocContent.match(regex); if (!match) return []; try { // Parse the array content const arrayContent = `[${match[1]}]`; return JSON.parse(arrayContent); } catch { return []; } } /** * Infer HTTP method and path from method implementation */ inferMethodDetails(methodName, methodSource) { // Look for executeRequest calls in the method source const executeRequestMatch = methodSource.match(/executeRequest\s*\(\s*\{[^}]*method:\s*['"](\w+)['"][^}]*path:\s*['"]([^'"]+)['"]/); if (executeRequestMatch) { return { method: executeRequestMatch[1], path: executeRequestMatch[2], }; } // Fallback: infer from method name patterns if (methodName.startsWith('list') || methodName.startsWith('get') || methodName.startsWith('search')) { return { method: 'GET', path: `/${methodName}` }; } else if (methodName.startsWith('create')) { return { method: 'POST', path: `/${methodName}` }; } else if (methodName.startsWith('update')) { return { method: 'PUT', path: `/${methodName}` }; } else if (methodName.startsWith('delete')) { return { method: 'DELETE', path: `/${methodName}` }; } return { method: 'GET', path: `/${methodName}` }; } /** * Get service description - can be overridden by subclasses */ getServiceDescription() { return `${this.serviceName} service client for Augur API`; } } //# sourceMappingURL=base-client.js.map