@gohcltech/bitbucket-mcp
Version:
Bitbucket integration for Claude via Model Context Protocol
313 lines • 13.1 kB
JavaScript
/**
* @fileoverview Base HTTP client for Bitbucket Cloud API integration.
*
* This module provides the foundational HTTP client functionality that all
* domain-specific clients inherit from. It handles common concerns like:
* - Token-based authentication (API Token or Repository Token)
* - Request/response interceptors for logging and debugging
* - Rate limiting to respect API quotas
* - Error handling with retry logic
* - Pagination utilities for multi-page responses
*
*/
import axios from 'axios';
import { getLogger } from '../logger.js';
import { getErrorHandler } from '../error-handler.js';
import { getRateLimiter } from '../rate-limiter.js';
import { AuthenticationError } from '../types/common.js';
/**
* Abstract base client for Bitbucket API operations.
*
* This class provides the core HTTP functionality that all domain-specific
* clients extend. It handles authentication, rate limiting, error handling,
* and pagination in a consistent manner across all API operations.
*/
export class BaseClient {
/** Axios HTTP client instance configured for Bitbucket API */
client;
/** Authentication service for token management */
authService;
/** Logger instance for API request/response tracking */
logger = getLogger();
/** Error handler with retry logic for failed requests */
errorHandler = getErrorHandler();
/** Rate limiter to respect Bitbucket API quotas */
rateLimiter = getRateLimiter();
/**
* Creates a new base Bitbucket API client.
*
* @param authService - Authentication service for handling tokens
*/
constructor(authService) {
this.authService = authService;
this.client = axios.create({
baseURL: 'https://api.bitbucket.org/2.0',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'User-Agent': 'bitbucket-mcp/2.0.0',
},
timeout: 30000, // 30 second timeout
});
this.setupInterceptors();
}
/**
* Sets up request and response interceptors for the HTTP client.
* @private
*/
setupInterceptors() {
// Request interceptor for authentication
this.client.interceptors.request.use(async (config) => {
try {
// Get authentication header
const authHeader = this.authService.getAuthHeader();
config.headers.Authorization = authHeader.value;
// Log API request
this.logger.apiRequest(config.method?.toUpperCase() || 'UNKNOWN', config.url || 'unknown', {
params: config.params,
hasData: !!config.data,
});
return config;
}
catch (error) {
this.logger.warn('Failed to get authentication header for request', {
error: error instanceof Error ? error.message : 'Unknown error',
url: config.url,
});
return config;
}
}, (error) => {
this.logger.error('Request interceptor error', error);
return Promise.reject(error);
});
// Response interceptor for error handling
this.client.interceptors.response.use((response) => {
// Log successful API response
this.logger.apiResponse(response.config.method?.toUpperCase() || 'UNKNOWN', response.config.url || 'unknown', response.status, 0 // Duration will be calculated in the rate limiter
);
return response;
}, async (error) => {
const originalRequest = error.config;
// Handle 401 Unauthorized - invalid token
if (error.response?.status === 401) {
this.logger.error('Authentication failed - token is invalid', error, {
url: originalRequest.url,
});
throw new AuthenticationError('Authentication failed. Please check your token credentials.');
}
// Handle rate limiting from Bitbucket
if (error.response?.status === 429) {
const retryAfter = this.extractRetryAfter(error.response.headers);
this.logger.rateLimitHit('bitbucket-api', retryAfter);
}
// Log API error
this.logger.apiError(originalRequest.method?.toUpperCase() || 'UNKNOWN', originalRequest.url || 'unknown', error, 0 // Duration will be calculated in the rate limiter
);
return Promise.reject(error);
});
}
/**
* Makes a GET request to the specified endpoint.
*
* @template T - Expected response type
* @param endpoint - API endpoint path (relative to base URL)
* @param params - Optional query parameters
* @param context - Optional request context for logging and rate limiting
* @returns Promise resolving to the response data
*/
async get(endpoint, params, context) {
return this.executeRequest('GET', endpoint, undefined, params, context);
}
/**
* Makes a POST request to the specified endpoint.
*
* @template T - Expected response type
* @param endpoint - API endpoint path (relative to base URL)
* @param data - Optional request body data
* @param context - Optional request context for logging and rate limiting
* @returns Promise resolving to the response data
*/
async post(endpoint, data, context) {
return this.executeRequest('POST', endpoint, data, undefined, context);
}
/**
* Makes a PUT request to the specified endpoint.
*
* @template T - Expected response type
* @param endpoint - API endpoint path (relative to base URL)
* @param data - Optional request body data
* @param context - Optional request context for logging and rate limiting
* @returns Promise resolving to the response data
*/
async put(endpoint, data, context) {
return this.executeRequest('PUT', endpoint, data, undefined, context);
}
/**
* Makes a DELETE request to the specified endpoint.
*
* @template T - Expected response type
* @param endpoint - API endpoint path (relative to base URL)
* @param context - Optional request context for logging and rate limiting
* @returns Promise resolving to the response data
*/
async delete(endpoint, context) {
return this.executeRequest('DELETE', endpoint, undefined, undefined, context);
}
/**
* Executes an HTTP request with comprehensive error handling and rate limiting.
* @private
*/
async executeRequest(method, endpoint, data, params, context) {
const requestContext = {
method,
endpoint,
operationName: context?.operationName || `${method.toLowerCase()}_${endpoint.split('/').pop()}`,
priority: context?.priority || 5,
};
return this.rateLimiter.execute(async () => {
return this.errorHandler.withRetry(async () => {
const response = await this.client.request({
method,
url: endpoint,
data,
params,
});
return response.data;
}, {
operationName: requestContext.operationName,
toolName: 'bitbucket-client',
});
}, {
operationName: requestContext.operationName,
toolName: 'bitbucket-client',
priority: requestContext.priority,
});
}
/**
* Fetches all pages of a paginated API endpoint and returns combined results.
*
* @template T - The type of items being paginated
* @param endpoint - Initial API endpoint path (relative to base URL)
* @param params - Optional query parameters for the first request
* @param context - Optional request context for logging and rate limiting
* @param maxPages - Maximum number of pages to fetch (default: 50)
* @param maxItems - Maximum total items to fetch (default: 5000)
* @returns Promise resolving to array of all items across all pages
*/
async getAllPages(endpoint, params, context, maxPages = 50, maxItems = 5000) {
const allItems = [];
let currentEndpoint = endpoint;
let currentParams = params;
let pageCount = 0;
this.logger.debug('Starting paginated fetch', {
operation: 'get_all_pages',
endpoint,
maxPages,
maxItems,
});
while (currentEndpoint && pageCount < maxPages) {
pageCount++;
try {
// Fetch current page
const response = await this.get(currentEndpoint, currentParams, {
...context,
operationName: `${context?.operationName || 'paginated_fetch'}_page_${pageCount}`,
});
// Add items from current page
allItems.push(...response.values);
this.logger.debug('Fetched page', {
operation: 'get_all_pages',
page: pageCount,
itemsThisPage: response.values.length,
totalItemsSoFar: allItems.length,
hasNext: !!response.next,
});
// Check if we've hit the item limit
if (allItems.length >= maxItems) {
this.logger.warn('Hit maximum items limit during pagination', {
operation: 'get_all_pages',
maxItems,
actualItems: allItems.length,
endpoint,
});
return allItems.slice(0, maxItems);
}
// Check if there's a next page
if (!response.next) {
this.logger.debug('Pagination complete - no more pages', {
operation: 'get_all_pages',
totalPages: pageCount,
totalItems: allItems.length,
endpoint,
});
break;
}
// Parse next URL to extract endpoint and params for next iteration
const nextUrl = new URL(response.next);
currentEndpoint = nextUrl.pathname.replace('/2.0', ''); // Remove API version prefix
currentParams = Object.fromEntries(nextUrl.searchParams.entries());
}
catch (error) {
this.logger.error('Error during paginated fetch', error, {
operation: 'get_all_pages',
page: pageCount,
endpoint: currentEndpoint,
totalItemsSoFar: allItems.length,
});
throw error;
}
}
// Check if we hit the page limit
if (pageCount >= maxPages) {
this.logger.warn('Hit maximum pages limit during pagination', {
operation: 'get_all_pages',
maxPages,
actualPages: pageCount,
totalItems: allItems.length,
endpoint,
});
}
this.logger.info('Pagination fetch completed', {
operation: 'get_all_pages',
totalPages: pageCount,
totalItems: allItems.length,
endpoint,
});
return allItems;
}
/**
* Fetches a single page with optional page size control.
*
* @template T - The type of items being paginated
* @param endpoint - API endpoint path (relative to base URL)
* @param page - Page number to fetch (1-based indexing)
* @param pageSize - Number of items per page (typically 10-100)
* @param params - Optional additional query parameters
* @param context - Optional request context for logging and rate limiting
* @returns Promise resolving to paginated response for the specified page
*/
async getPage(endpoint, page = 1, pageSize = 50, params, context) {
const paginationParams = {
page: page.toString(),
pagelen: pageSize.toString(),
...params,
};
return this.get(endpoint, paginationParams, {
...context,
operationName: `${context?.operationName || 'get_page'}_page_${page}`,
});
}
/**
* Extracts retry-after delay from HTTP response headers.
* @private
*/
extractRetryAfter(headers) {
const retryAfter = headers['retry-after'] || headers['x-ratelimit-reset'];
if (retryAfter) {
const seconds = parseInt(retryAfter, 10);
return isNaN(seconds) ? undefined : seconds * 1000; // Convert to milliseconds
}
return undefined;
}
}
//# sourceMappingURL=base-client.js.map