/** * Challonge API v2.1 Service * Client for interacting with Challonge API v2.1 (current version) * * Features: * - OAuth 2.0 support (Bearer tokens) * - API v1 key compatibility * - JSON:API specification compliant * - Tournament, Participant, Match, Race endpoints * - Community and Application scoping * * @see https://challonge.apidog.io/getting-started-1726706m0 * @see https://challonge.apidog.io/llms.txt */ /** * Get the appropriate base URL based on environment * Always use nginx proxy to avoid CORS issues */ function getBaseURL() { return '/api/challonge/v2.1'; } /** * Authentication types for Challonge API v2.1 */ export const AuthType = { OAUTH: 'v2', // Bearer token API_KEY: 'v1' // Legacy API key }; /** * Resource scoping options */ export const ScopeType = { USER: 'user', // /v2.1/tournaments (default) COMMUNITY: 'community', // /v2.1/communities/{id}/tournaments APPLICATION: 'app' // /v2.1/application/tournaments }; /** * Create Challonge API v2.1 client * * @param {Object} auth - Authentication configuration * @param {string} auth.token - OAuth Bearer token or API v1 key * @param {string} auth.type - AuthType.OAUTH or AuthType.API_KEY (default: API_KEY) * @param {Object} options - Client options * @param {string} options.communityId - Default community ID for scoping * @param {boolean} options.debug - Enable debug logging * @returns {Object} API client with methods */ export function createChallongeV2Client(auth, options = {}) { const { token, type = AuthType.API_KEY } = auth || {}; const { communityId: defaultCommunityId, debug = false } = options; const baseURL = getBaseURL(); // Request tracking for debug mode let requestCount = 0; /** * Make API request with JSON:API format */ async function makeRequest(endpoint, options = {}) { const { method = 'GET', body, params = {}, headers = {}, communityId = defaultCommunityId, scopeType = ScopeType.USER } = options; const startTime = performance.now(); requestCount++; // Build URL with scoping let url = baseURL; if (scopeType === ScopeType.COMMUNITY && communityId) { url += `/communities/${communityId}`; } else if (scopeType === ScopeType.APPLICATION) { url += '/application'; } url += `/${endpoint}`; // Add query parameters const urlObj = new URL(url, window.location.origin); Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null) { urlObj.searchParams.append(key, value); } }); // Add communityId as query param if not in path if (communityId && scopeType === ScopeType.USER) { urlObj.searchParams.append('community_id', communityId); } // Prepare headers (JSON:API required format) const requestHeaders = { 'Content-Type': 'application/vnd.api+json', Accept: 'application/json', 'Authorization-Type': type, ...headers }; // No-split-brain: never send Challonge tokens from the browser. // Backend proxy derives auth from the per-session SID cookie and the Authorization-Type hint. // (Token is intentionally ignored here.) const fetchOptions = { method, headers: requestHeaders, credentials: 'include', cache: 'no-store' }; if (body && method !== 'GET') { // Wrap in JSON:API format if not already wrapped const jsonApiBody = body.data ? body : { data: body }; fetchOptions.body = JSON.stringify(jsonApiBody); } if (debug) { console.log( `[Challonge v2.1 Request #${requestCount}]`, method, urlObj.toString() ); if (body) console.log('Body:', fetchOptions.body); } try { const response = await fetch(urlObj.toString(), fetchOptions); const duration = performance.now() - startTime; // Handle 204 No Content if (response.status === 204) { if (debug) console.log( `[Challonge v2.1 Response] 204 No Content (${duration.toFixed(0)}ms)` ); return null; } // Parse response body (prefer JSON when declared) const contentType = response.headers.get('content-type') || ''; let data; try { if (contentType.includes('application/json')) { data = await response.json(); } else { const text = await response.text(); // Best-effort: if it's actually JSON but wrong content-type, parse it. data = text; if (text && (text.startsWith('{') || text.startsWith('['))) { try { data = JSON.parse(text); } catch { // keep as text } } } } catch (parseError) { if (debug) { console.error('[Challonge v2.1 JSON Parse Error]', parseError); } const error = new Error(`HTTP ${response.status}: Failed to parse response`); error.status = response.status; throw error; } if (debug) { console.log( `[Challonge v2.1 Response] ${response.status} (${duration.toFixed(0)}ms)`, data ); } // Handle JSON:API errors if (!response.ok) { if (data.errors && Array.isArray(data.errors)) { const errorDetails = data.errors.map(e => ({ status: e.status || response.status, message: e.detail || e.title || response.statusText, field: e.source?.pointer })); const errorMessage = errorDetails .map( e => `${e.status}: ${e.message}${e.field ? ` (${e.field})` : ''}` ) .join('\n'); const error = new Error(errorMessage); error.status = response.status; error.errors = errorDetails; error.response = data; throw error; } // Handle non-JSON:API error format const messageFromBody = typeof data === 'string' ? data : data?.error || data?.message || response.statusText; const fallbackMessage = response.statusText || 'Request failed'; const finalMessage = typeof messageFromBody === 'string' && messageFromBody.trim().length === 0 ? fallbackMessage : messageFromBody || fallbackMessage; const error = new Error(`HTTP ${response.status}: ${finalMessage}`); error.status = response.status; error.response = data; throw error; } return data; } catch (error) { if (debug) { console.error('[Challonge v2.1 Error]', error); } throw error; } } /** * Helper to unwrap JSON:API response and normalize structure */ function unwrapResponse(response) { if (!response) return null; // If response has data property, it's JSON:API format if (response.data) { const data = response.data; // Handle array of resources if (Array.isArray(data)) { return data.map(item => normalizeResource(item)); } // Handle single resource return normalizeResource(data); } return response; } /** * Normalize JSON:API resource to flat structure */ function normalizeResource(resource) { if (!resource || !resource.attributes) return resource; return { id: resource.id, type: resource.type, ...resource.attributes, relationships: resource.relationships, links: resource.links }; } // ==================== Tournament Methods ==================== const tournaments = { /** * List tournaments * @param {Object} options - Query options * @returns {Promise} */ list: async (options = {}) => { const { communityId, scopeType, ...params } = options; const response = await makeRequest('tournaments.json', { params, communityId, scopeType }); return unwrapResponse(response); }, /** * Get tournament details * @param {string} id - Tournament ID or URL * @param {Object} options - Options * @returns {Promise} */ get: async (id, options = {}) => { const { communityId, scopeType, ifNoneMatch } = options; const response = await makeRequest(`tournaments/${id}.json`, { communityId, scopeType, headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {} }); return unwrapResponse(response); }, /** * Create tournament * @param {Object} data - Tournament data * @param {Object} options - Options * @returns {Promise} */ create: async (data, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest('tournaments.json', { method: 'POST', body: { type: 'Tournaments', attributes: data }, communityId, scopeType }); return unwrapResponse(response); }, /** * Update tournament * @param {string} id - Tournament ID * @param {Object} data - Updated fields * @param {Object} options - Options * @returns {Promise} */ update: async (id, data, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest(`tournaments/${id}.json`, { method: 'PUT', body: { type: 'Tournaments', attributes: data }, communityId, scopeType }); return unwrapResponse(response); }, /** * Delete tournament * @param {string} id - Tournament ID * @param {Object} options - Options * @returns {Promise} */ delete: async (id, options = {}) => { const { communityId, scopeType } = options; return await makeRequest(`tournaments/${id}.json`, { method: 'DELETE', communityId, scopeType }); }, /** * Change tournament state * @param {string} id - Tournament ID * @param {string} state - New state * @param {Object} options - Options * @returns {Promise} */ changeState: async (id, state, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${id}/change_state.json`, { method: 'PUT', body: { type: 'TournamentState', attributes: { state } }, communityId, scopeType } ); return unwrapResponse(response); }, // Convenience methods start: (id, options) => tournaments.changeState(id, 'start', options), finalize: (id, options) => tournaments.changeState(id, 'finalize', options), reset: (id, options) => tournaments.changeState(id, 'reset', options), processCheckIn: (id, options) => tournaments.changeState(id, 'process_checkin', options) }; // ==================== Participant Methods ==================== const participants = { list: async (tournamentId, options = {}) => { const { communityId, scopeType, page, per_page, ifNoneMatch } = options; const response = await makeRequest( `tournaments/${tournamentId}/participants.json`, { params: { page, per_page }, communityId, scopeType, headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {} } ); return unwrapResponse(response); }, get: async (tournamentId, participantId, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/participants/${participantId}.json`, { communityId, scopeType } ); return unwrapResponse(response); }, create: async (tournamentId, data, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/participants.json`, { method: 'POST', body: { type: 'Participants', attributes: data }, communityId, scopeType } ); return unwrapResponse(response); }, update: async (tournamentId, participantId, data, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/participants/${participantId}.json`, { method: 'PUT', body: { type: 'Participants', attributes: data }, communityId, scopeType } ); return unwrapResponse(response); }, delete: async (tournamentId, participantId, options = {}) => { const { communityId, scopeType } = options; return await makeRequest( `tournaments/${tournamentId}/participants/${participantId}.json`, { method: 'DELETE', communityId, scopeType } ); }, bulkAdd: async (tournamentId, participantsData, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/participants/bulk_add.json`, { method: 'POST', body: { type: 'Participants', attributes: { participants: participantsData } }, communityId, scopeType } ); return unwrapResponse(response); }, clear: async (tournamentId, options = {}) => { const { communityId, scopeType } = options; return await makeRequest( `tournaments/${tournamentId}/participants/clear.json`, { method: 'DELETE', communityId, scopeType } ); }, randomize: async (tournamentId, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/participants/randomize.json`, { method: 'PUT', communityId, scopeType } ); return unwrapResponse(response); } }; // ==================== Match Methods ==================== const matches = { list: async (tournamentId, options = {}) => { const { communityId, scopeType, state, participant_id, page, per_page, ifNoneMatch } = options; const response = await makeRequest( `tournaments/${tournamentId}/matches.json`, { params: { state, participant_id, page, per_page }, communityId, scopeType, headers: ifNoneMatch ? { 'If-None-Match': ifNoneMatch } : {} } ); return unwrapResponse(response); }, get: async (tournamentId, matchId, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/matches/${matchId}.json`, { communityId, scopeType } ); return unwrapResponse(response); }, update: async (tournamentId, matchId, data, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/matches/${matchId}.json`, { method: 'PUT', body: { type: 'Match', attributes: data }, communityId, scopeType } ); return unwrapResponse(response); }, changeState: async (tournamentId, matchId, state, options = {}) => { const { communityId, scopeType } = options; const response = await makeRequest( `tournaments/${tournamentId}/matches/${matchId}/change_state.json`, { method: 'PUT', body: { type: 'MatchState', attributes: { state } }, communityId, scopeType } ); return unwrapResponse(response); }, reopen: (tournamentId, matchId, options) => matches.changeState(tournamentId, matchId, 'reopen', options), markAsUnderway: (tournamentId, matchId, options) => matches.changeState(tournamentId, matchId, 'mark_as_underway', options) }; // ==================== User & Community Methods ==================== const user = { getMe: async () => { const response = await makeRequest('me.json'); return unwrapResponse(response); } }; const communities = { list: async () => { const response = await makeRequest('communities.json'); return unwrapResponse(response); } }; return { tournaments, participants, matches, user, communities, // Expose request count for debugging getRequestCount: () => requestCount }; }