UNPKG

@gohcltech/bitbucket-mcp

Version:

Bitbucket integration for Claude via Model Context Protocol

313 lines 13.1 kB
/** * @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