Skip to main content
Glama
tableau-client.ts14.7 kB
/** * Tableau REST API Client Wrapper * * Implements complete Tableau REST API integration with: * - Personal Access Token (PAT) authentication * - Auto-detection of Tableau Cloud vs Server * - Workbook, view, and data source operations * - Data querying and export (CSV/JSON) * - Extract refresh management * - Content search and metadata retrieval * - Dashboard filter access * - PDF and PowerPoint export */ import axios, { AxiosInstance, AxiosError } from 'axios'; import { TableauConfig, TableauAuthResponse, TableauWorkbook, TableauView, TableauDataSource, TableauSearchResult, TableauFilter, TableauExportOptions, TableauRefreshJob } from './types.js'; export class TableauClient { private config: TableauConfig; private authToken: string | null = null; private siteId: string | null = null; private axiosInstance: AxiosInstance; private isCloud: boolean; constructor(config: TableauConfig) { this.config = config; // Auto-detect Cloud vs Server from URL this.isCloud = config.serverUrl.includes('online.tableau.com'); // Configure axios instance with 30 second timeout this.axiosInstance = axios.create({ baseURL: `${config.serverUrl}/api/${config.apiVersion}`, timeout: 30000, headers: { 'Content-Type': 'application/json', 'Accept': 'application/json', }, }); // Add response interceptor for error handling this.axiosInstance.interceptors.response.use( (response) => response, (error: AxiosError) => { return this.handleError(error); } ); } /** * Handle API errors with meaningful messages */ private handleError(error: AxiosError): Promise<never> { if (error.response) { const status = error.response.status; const endpoint = error.config?.url || 'unknown'; const message = (error.response.data as any)?.error?.summary || error.message; throw new Error(`Tableau API Error: ${endpoint} - ${status}: ${message}`); } else if (error.request) { throw new Error(`Tableau API Network Error: No response received - ${error.message}`); } else { throw new Error(`Tableau API Error: ${error.message}`); } } /** * Ensure the client is authenticated before making API calls */ private async ensureAuthenticated(): Promise<void> { if (!this.authToken) { await this.authenticate(); } } /** * Build query parameters for view filters * Converts object like { Region: 'West', Year: '2024' } to vf_Region=West&vf_Year=2024 */ private buildFilterParams(filters?: Record<string, string>): string { if (!filters) return ''; const params = Object.entries(filters) .map(([key, value]) => `vf_${key}=${encodeURIComponent(value)}`) .join('&'); return params ? `?${params}` : ''; } /** * Authenticate with Tableau Server/Cloud using Personal Access Token (PAT) * * @returns Authentication response with token and site ID */ async authenticate(): Promise<TableauAuthResponse> { try { const response = await this.axiosInstance.post('/auth/signin', { credentials: { personalAccessTokenName: this.config.tokenName, personalAccessTokenSecret: this.config.tokenValue, site: { contentUrl: this.config.siteId } } }); const credentials = response.data.credentials; this.authToken = credentials.token; this.siteId = credentials.site.id; // Add auth token to future requests this.axiosInstance.defaults.headers.common['X-Tableau-Auth'] = this.authToken; return { token: credentials.token, siteId: credentials.site.id, userId: credentials.user.id }; } catch (error) { throw new Error(`Authentication failed: ${(error as Error).message}`); } } /** * List all workbooks accessible to the authenticated user * * @param projectName - Optional filter by project name * @param tags - Optional filter by tags * @returns Array of workbooks with metadata */ async listWorkbooks(projectName?: string, tags?: string[]): Promise<TableauWorkbook[]> { await this.ensureAuthenticated(); try { let url = `/sites/${this.siteId}/workbooks?pageSize=100`; // Add filters if provided if (projectName) { url += `&filter=projectName:eq:${encodeURIComponent(projectName)}`; } if (tags && tags.length > 0) { url += `&filter=tags:in:[${tags.map(t => encodeURIComponent(t)).join(',')}]`; } const response = await this.axiosInstance.get(url); const workbooks = response.data.workbooks?.workbook || []; return workbooks.map((wb: any) => ({ id: wb.id, name: wb.name, contentUrl: wb.contentUrl, projectName: wb.project?.name, createdAt: wb.createdAt, updatedAt: wb.updatedAt, tags: wb.tags?.tag?.map((t: any) => t.label) || [] })); } catch (error) { throw new Error(`Failed to list workbooks: ${(error as Error).message}`); } } /** * List all views/dashboards in a specific workbook * * @param workbookId - The workbook identifier * @returns Array of views with metadata */ async listViews(workbookId: string): Promise<TableauView[]> { await this.ensureAuthenticated(); try { const url = `/sites/${this.siteId}/workbooks/${workbookId}/views?pageSize=100`; const response = await this.axiosInstance.get(url); const views = response.data.views?.view || []; return views.map((view: any) => ({ id: view.id, name: view.name, contentUrl: view.contentUrl, workbookId: view.workbook?.id || workbookId, viewUrlName: view.viewUrlName })); } catch (error) { throw new Error(`Failed to list views for workbook ${workbookId}: ${(error as Error).message}`); } } /** * Export view data as CSV or JSON * * @param viewId - The view identifier * @param format - Export format ('csv' or 'json'), defaults to 'csv' * @param maxRows - Optional maximum number of rows to return * @returns View data in the requested format */ async queryViewData(viewId: string, format: 'csv' | 'json' = 'csv', maxRows?: number): Promise<string> { await this.ensureAuthenticated(); try { let url = `/sites/${this.siteId}/views/${viewId}/data`; const params: string[] = []; if (maxRows) { params.push(`maxRows=${maxRows}`); } if (params.length > 0) { url += `?${params.join('&')}`; } const response = await this.axiosInstance.get(url, { headers: { 'Accept': format === 'json' ? 'application/json' : 'text/csv' } }); return typeof response.data === 'string' ? response.data : JSON.stringify(response.data); } catch (error) { throw new Error(`Failed to query view data for ${viewId}: ${(error as Error).message}`); } } /** * Trigger a data source extract refresh * * @param datasourceId - The data source identifier * @param refreshType - 'full' or 'incremental', defaults to 'full' * @returns Job information for tracking the refresh */ async refreshExtract(datasourceId: string, refreshType: 'full' | 'incremental' = 'full'): Promise<TableauRefreshJob> { await this.ensureAuthenticated(); try { const url = `/sites/${this.siteId}/datasources/${datasourceId}/refresh`; const response = await this.axiosInstance.post(url, { refreshType: refreshType }); const job = response.data.job; return { id: job.id, type: job.type, status: job.status, createdAt: job.createdAt }; } catch (error) { throw new Error(`Failed to refresh extract for datasource ${datasourceId}: ${(error as Error).message}`); } } /** * Search across all Tableau content * * @param searchTerm - The search query * @param contentType - Optional filter by content type * @returns Array of matching content items */ async searchContent(searchTerm: string, contentType?: 'workbook' | 'view' | 'datasource' | 'project'): Promise<TableauSearchResult[]> { await this.ensureAuthenticated(); try { let url = `/sites/${this.siteId}/search?searchTerm=${encodeURIComponent(searchTerm)}`; if (contentType) { url += `&filter=contentType:eq:${contentType}`; } const response = await this.axiosInstance.get(url); const results = response.data.results?.result || []; return results.map((result: any) => ({ id: result.id, name: result.name, type: result.type, contentUrl: result.contentUrl })); } catch (error) { throw new Error(`Failed to search content: ${(error as Error).message}`); } } /** * Get detailed metadata for a workbook * * @param workbookId - The workbook identifier * @returns Comprehensive workbook metadata */ async getWorkbookMetadata(workbookId: string): Promise<TableauWorkbook> { await this.ensureAuthenticated(); try { const url = `/sites/${this.siteId}/workbooks/${workbookId}`; const response = await this.axiosInstance.get(url); const wb = response.data.workbook; return { id: wb.id, name: wb.name, contentUrl: wb.contentUrl, projectName: wb.project?.name, createdAt: wb.createdAt, updatedAt: wb.updatedAt, tags: wb.tags?.tag?.map((t: any) => t.label) || [] }; } catch (error) { throw new Error(`Failed to get workbook metadata for ${workbookId}: ${(error as Error).message}`); } } /** * Get detailed metadata for a view/dashboard * * @param viewId - The view identifier * @returns Comprehensive view metadata */ async getViewMetadata(viewId: string): Promise<TableauView> { await this.ensureAuthenticated(); try { const url = `/sites/${this.siteId}/views/${viewId}`; const response = await this.axiosInstance.get(url); const view = response.data.view; return { id: view.id, name: view.name, contentUrl: view.contentUrl, workbookId: view.workbook?.id, viewUrlName: view.viewUrlName }; } catch (error) { throw new Error(`Failed to get view metadata for ${viewId}: ${(error as Error).message}`); } } /** * Get all filter configurations on a dashboard * Note: This endpoint may not be available in all Tableau versions (requires REST API 3.5+) * * @param viewId - The view/dashboard identifier * @returns Array of filters with configuration */ async getDashboardFilters(viewId: string): Promise<TableauFilter[]> { await this.ensureAuthenticated(); try { const url = `/sites/${this.siteId}/views/${viewId}/filters`; const response = await this.axiosInstance.get(url); const filters = response.data.filters?.filter || []; return filters.map((filter: any) => ({ name: filter.name, field: filter.field, type: filter.type, values: filter.values || [], isVisible: filter.isVisible !== false })); } catch (error) { // Gracefully handle if endpoint is not available if ((error as any).message.includes('404')) { throw new Error(`Dashboard filters endpoint not available for this Tableau version or view ${viewId} has no filters`); } throw new Error(`Failed to get dashboard filters for ${viewId}: ${(error as Error).message}`); } } /** * Export dashboard as PDF with optional filters and formatting options * * @param viewId - The view/dashboard identifier * @param filters - Optional filters to apply before export * @param options - Optional page type and orientation settings * @returns PDF file as binary data (Buffer) */ async exportDashboardPDF(viewId: string, filters?: Record<string, string>, options?: TableauExportOptions): Promise<Buffer> { await this.ensureAuthenticated(); try { let url = `/sites/${this.siteId}/views/${viewId}/pdf`; const params: string[] = []; // Add page type and orientation if provided if (options?.pageType) { params.push(`type=${options.pageType}`); } if (options?.orientation) { params.push(`orientation=${options.orientation}`); } // Build filter parameters const filterParams = this.buildFilterParams(filters); if (filterParams) { url += filterParams; if (params.length > 0) { url += '&' + params.join('&'); } } else if (params.length > 0) { url += '?' + params.join('&'); } const response = await this.axiosInstance.get(url, { responseType: 'arraybuffer' }); return Buffer.from(response.data); } catch (error) { throw new Error(`Failed to export PDF for view ${viewId}: ${(error as Error).message}`); } } /** * Export dashboard as PowerPoint presentation with optional filters * * @param viewId - The view/dashboard identifier * @param filters - Optional filters to apply before export * @returns PowerPoint file as binary data (Buffer) */ async exportDashboardPPTX(viewId: string, filters?: Record<string, string>): Promise<Buffer> { await this.ensureAuthenticated(); try { let url = `/sites/${this.siteId}/views/${viewId}/powerpoint`; // Build filter parameters const filterParams = this.buildFilterParams(filters); if (filterParams) { url += filterParams; } const response = await this.axiosInstance.get(url, { responseType: 'arraybuffer' }); return Buffer.from(response.data); } catch (error) { throw new Error(`Failed to export PowerPoint for view ${viewId}: ${(error as Error).message}`); } } /** * Get the environment type (Cloud or Server) */ getEnvironmentType(): 'cloud' | 'server' { return this.isCloud ? 'cloud' : 'server'; } /** * Check if currently authenticated */ isAuthenticated(): boolean { return this.authToken !== null; } /** * Sign out and clear authentication token */ async signOut(): Promise<void> { if (!this.authToken) return; try { await this.axiosInstance.post('/auth/signout'); } finally { this.authToken = null; this.siteId = null; delete this.axiosInstance.defaults.headers.common['X-Tableau-Auth']; } } }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/russelenriquez-agile/tableau-mcp-project'

If you have feedback or need assistance with the MCP directory API, please join our Discord server