@simpleapps-com/augur-api
Version:
TypeScript client library for Augur microservices API endpoints
738 lines • 29.2 kB
JavaScript
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