/**
* 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'];
}
}
}