diff --git a/.gitignore b/.gitignore index 36b442e..e185211 100644 --- a/.gitignore +++ b/.gitignore @@ -1,7 +1,4 @@ - -# java dirs -**/build -**/lib - -# python files *.pyc +/java/build/* +/java/.idea/* +/java/lib/* \ No newline at end of file diff --git a/java/README.md b/java/README.md index 69dea68..e4c0a0b 100644 --- a/java/README.md +++ b/java/README.md @@ -1,8 +1,8 @@ ## Requirements -* Tableau Server -* Java SDK 8 +* Tableau Server, or an account on Tableau Cloud +* Java SDK * Download: * Apache Ant * It is not neccessary to run the "Optional" steps in the Apache Ant guide. @@ -14,6 +14,11 @@ ## Getting started 1. Install the tools listed in the "Requirements" section. + 1. Make sure that Tableau Server is running if you are using your own server. +1. In the /res folder, open the config.properties file using a text editor. + 1. Modify the configurations as instructed in the file. + 2. A sample workbook is already provided with this sample, but you can use any packaged workbook that you want. + 1. Download the REST API schema and save it in the `/res` folder under the folder where this README file is. For more information about the schema, see the following documentation: @@ -44,9 +49,9 @@ 1. Make sure that Tableau Server is running. 1. Open a command prompt or terminal. -1. In the command prompt window, change directory to the sample code's parent folder. -1. Enter `ant` in the command prompt to compile the sample code and download dependencies. -1. Enter `ant run` in the command prompt to run the sample code after compilation. + 1. In the command prompt window, change directory to the sample code's parent folder. + 1. Enter `ant` in the command prompt to compile the sample code and download dependencies. + 1. Enter `ant run` in the command prompt to run the sample code after compilation. ## Possible problems @@ -54,3 +59,13 @@ When `ant` is run in a command prompt, it may respond with "ant is not recognize Make sure that the `ANT_HOME` and `JAVA_HOME` variables are set as described in the installation guide for Apache Ant. Paths should not include quotes. For more information, see +------ + +The example code uses version 3.0 of the REST API and schema. If you want to use a different version: +1. Download the REST API schema and save it in the `/res` folder under the folder where this README file is. For more information about the schema, see the following documentation: + + + +1. In the `/res` folder, open the `config.properties` file using a text editor. +1. Update the schema configuration properties to match the new file. +------ \ No newline at end of file diff --git a/java/build.xml b/java/build.xml index 321b58f..1bac0b4 100644 --- a/java/build.xml +++ b/java/build.xml @@ -1,18 +1,11 @@ + + + diff --git a/java/ivy.xml b/java/ivy.xml index 8d981fe..c3631fd 100644 --- a/java/ivy.xml +++ b/java/ivy.xml @@ -6,7 +6,8 @@ - + + @@ -14,7 +15,10 @@ - + + + + \ No newline at end of file diff --git a/java/res/config.properties b/java/res/config.properties index 9cc66c4..e7d8b85 100644 --- a/java/res/config.properties +++ b/java/res/config.properties @@ -1,10 +1,11 @@ # Set this to the name or IP address of the Tableau Server installation. server.host=http://YOUR-SERVER -# Set where the REST API schema is located +# Set where the REST API schema is located and the version to use # The latest schema can be downloaded from here: # http://onlinehelp.tableau.com/current/api/rest_api/en-us/help.htm#REST/rest_api_concepts_schema.htm -server.schema.location=res/ts-api_X_X.xsd +server.schema.location=res/ts-api_3_0.xsd +server.schema.version=3.0 # Set this to the content URL of the default site. # Not assigning a value to this configuration references the default site. diff --git a/java/res/ts-api_3_0.xsd b/java/res/ts-api_3_0.xsd new file mode 100644 index 0000000..832c323 --- /dev/null +++ b/java/res/ts-api_3_0.xsd @@ -0,0 +1,834 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + Not a real value. Used to specify 'no limit' when site is created or updated. Never returned from server in response. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/java/src/com/tableausoftware/documentation/api/rest/Demo.java b/java/src/com/tableausoftware/documentation/api/rest/Demo.java index 77aacbd..6b30625 100644 --- a/java/src/com/tableausoftware/documentation/api/rest/Demo.java +++ b/java/src/com/tableausoftware/documentation/api/rest/Demo.java @@ -9,8 +9,8 @@ import java.util.Map; import java.util.Properties; -import org.apache.log4j.BasicConfigurator; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; import com.tableausoftware.documentation.api.rest.bindings.GranteeCapabilitiesType; import com.tableausoftware.documentation.api.rest.bindings.GroupType; @@ -38,15 +38,16 @@ */ public class Demo { - private static Logger s_logger = Logger.getLogger(Demo.class); + private static Logger s_logger = LogManager.getLogger(); private static Properties s_properties = new Properties(); private static final RestApiUtils s_restApiUtils = RestApiUtils.getInstance(); static { - // Configures the logger to log to stdout - BasicConfigurator.configure(); + org.apache.logging.log4j.core.config.Configurator.setAllLevels( + LogManager.getRootLogger().getName(), org.apache.logging.log4j.Level.ALL); + s_logger.info("Configuring..."); // Loads the values from configuration file into the Properties instance try { @@ -57,6 +58,7 @@ public class Demo { } public static void main(String[] args) { + s_logger.info("Running..."); // Sets the username, password, and content URL, which are all required // in the payload of a Sign In request String username = s_properties.getProperty("user.admin.name"); @@ -65,6 +67,11 @@ public static void main(String[] args) { // Signs in to server and saves the authentication token, site ID, and current user ID TableauCredentialsType credential = s_restApiUtils.invokeSignIn(username, password, contentUrl); + + if (credential == null || credential.getSite() == null || credential.getToken() == null){ + s_logger.error("Failed to sign in: null or invalid credential returned"); + return; + } String currentSiteId = credential.getSite().getId(); String currentUserId = credential.getUser().getId(); diff --git a/java/src/com/tableausoftware/documentation/api/rest/util/RestApiUtils.java b/java/src/com/tableausoftware/documentation/api/rest/util/RestApiUtils.java index e8e6429..bb6abbd 100644 --- a/java/src/com/tableausoftware/documentation/api/rest/util/RestApiUtils.java +++ b/java/src/com/tableausoftware/documentation/api/rest/util/RestApiUtils.java @@ -22,7 +22,8 @@ import javax.xml.validation.Schema; import javax.xml.validation.SchemaFactory; -import org.apache.log4j.Logger; +import org.apache.logging.log4j.Logger; +import org.apache.logging.log4j.LogManager; import org.xml.sax.SAXException; import com.google.common.io.Files; @@ -112,21 +113,43 @@ public static RestApiUtils getInstance() { * @return the URI builder */ private static UriBuilder getApiUriBuilder() { - return UriBuilder.fromPath(m_properties.getProperty("server.host") + "/api/3.24"); + return UriBuilder.fromPath(m_properties.getProperty("server.host") + + "/api/" + + m_properties.getProperty("server.schema.version")); } /** * Initializes the RestApiUtils. The initialize code loads values from the configuration * file and initializes the JAXB marshaller and unmarshaller. */ private static void initialize() { + m_logger.info("Initializing..."); try { m_properties.load(new FileInputStream("res/config.properties")); - jaxbContext = JAXBContext.newInstance(TsRequest.class, TsResponse.class); + } catch (Exception ex) { + throw new IllegalStateException("Failed to read configuration properties"); + } + Schema schema; + try { + final String schemaLocation = m_properties.getProperty("server.schema.location"); + m_logger.info("Schema at " + schemaLocation); + File schemaFile = new File(schemaLocation); SchemaFactory schemaFactory = SchemaFactory.newInstance(XMLConstants.W3C_XML_SCHEMA_NS_URI); - schema = schemaFactory.newSchema(new File(m_properties.getProperty("server.schema.location"))); - } catch (JAXBException | SAXException | IOException ex) { - throw new IllegalStateException("Failed to initialize the REST API"); + schema = schemaFactory.newSchema(schemaFile); + m_logger.info("Schema factory complete"); + } catch (SAXException ex) { + throw new IllegalStateException("Failed to load schema file"); } + try { + JAXBContext jaxbContext = JAXBContext.newInstance(TsRequest.class, TsResponse.class); + s_jaxbMarshaller = jaxbContext.createMarshaller(); + s_jaxbUnmarshaller = jaxbContext.createUnmarshaller(); + s_jaxbUnmarshaller.setSchema(schema); + s_jaxbMarshaller.setSchema(schema); + } catch (JAXBException ex) { + throw new IllegalStateException("Failed to initialize the REST API with schema", ex); + + } + m_logger.info("Schema initialization complete"); } private Marshaller getMarshallerInstance(){ @@ -155,7 +178,7 @@ private Unmarshaller getUnmarshallerInstance(){ private final String TABLEAU_PAYLOAD_NAME = "request_payload"; - private Logger m_logger = Logger.getLogger(RestApiUtils.class); + private static Logger m_logger = LogManager.getLogger(); private ObjectFactory m_objectFactory = new ObjectFactory(); @@ -418,7 +441,7 @@ public TableauCredentialsType invokeSignIn(String username, String password, Str TsResponse response = post(url, null, payload); // Verifies that the response has a credentials element - if (response.getCredentials() != null) { + if (response != null && response.getCredentials() != null) { m_logger.info("Sign in is successful!"); return response.getCredentials(); @@ -815,21 +838,23 @@ private TsResponse post(String url, String authToken) { * @return the response from the request */ private TsResponse post(String url, String authToken, TsRequest requestPayload) { - // Creates an instance of StringWriter to hold the XML for the request - StringWriter writer = new StringWriter(); + String payload = ""; // Marshals the TsRequest object into XML format if it is not null if (requestPayload != null) { + // Creates an instance of StringWriter to hold the XML for the request + StringWriter writer = new StringWriter(); + try { getMarshallerInstance().marshal(requestPayload, writer); } catch (JAXBException ex) { - m_logger.error("There was a problem marshalling the payload"); + m_logger.error("There was a problem marshalling the payload: " + ex); + m_logger.error("Not posting to " + url); + throw new IllegalStateException(ex); } + // Converts the XML into a string + payload = writer.toString(); } - - // Converts the XML into a string - String payload = writer.toString(); - m_logger.debug("Input payload: \n" + payload); // Creates the HTTP client object and makes the HTTP request to the @@ -1038,8 +1063,10 @@ private TsResponse unmarshalResponse(String responseXML) { StringReader reader = new StringReader(responseXML); tsResponse = getUnmarshallerInstance().unmarshal(new StreamSource(reader), TsResponse.class).getValue(); } catch (JAXBException e) { - m_logger.error("Failed to parse response from server due to:"); - e.printStackTrace(); + m_logger.error("Failed to parse response from server due to:" + e.toString()); + // if more information is needed + // e.printStackTrace(); + m_logger.error(responseXML); } return tsResponse; diff --git a/python/README.md b/python/README.md index 379e7ca..20d17b3 100644 --- a/python/README.md +++ b/python/README.md @@ -10,8 +10,9 @@ Running the samples * All samples require 2 arguments: server adress (without a trailing slash) and username * Run by executing ```python sample_file_name.py ``` * Specific information for each sample are included at the top of each file -* API version is set to 3.5 by default for Tableau Server 2019.3, but it can be changed in [version.py](./version.py) -* For Tableau Server 9.0, the REST API namespace must be changed (refer to the comment in each sample where the namespace, `xmlns`, is defined) +* API version is set to 3.24 by default, but it can be changed in [version.py](./version.py) + +For API versions and server versions, see https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_versions.htm REST API Samples --------------- diff --git a/python/add_user_to_pulse_metric_subscription.py b/python/add_user_to_pulse_metric_subscription.py new file mode 100644 index 0000000..01718b9 --- /dev/null +++ b/python/add_user_to_pulse_metric_subscription.py @@ -0,0 +1,259 @@ +# ========================================================================================================== +# Script Name: Tableau Pulse Metric Subscription Script +# Description: +# This script subscribes a user to a Pulse metric on Tableau Cloud Site. +# The script utilizes the Tableau Server Client (TSC) and requests libraries +# to authenticate with Tableau Cloud Site using a Personal Access Token (PAT), +# fetch the user ID based on email, and subscribe the user to a specified +# Pulse metric. +# +# Help: +# - https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_ref_pulse.htm#PulseSubscriptionService_CreateSubscription +# - https://help.tableau.com/current/api/rest_api/en-us/REST/TAG/index.html#tag/Pulse-Methods/operation/PulseSubscriptionService_CreateSubscription +# +# Input Parameters: +# - server_url: URL of your Tableau POD (e.g., https://dub01.online.tableau.com/) +# - site_name: Tableau site name (e.g., darkplatypus) +# - pat_name: Name of the Personal Access Token for an admin user +# - pat_secret: Secret key of the Personal Access Token for an admin user +# - user_email: Email address of the user to subscribe to the metric +# - metric_id: The LUID of the Pulse metric +# +# LUID and pod of a Pulse Metric: +# The Tableau Cloud pod is the is the first part of the domain URL, for example 10AX in the metric URL below. +# The LUID is the last part of the metric URL, for example 5aa997e2-07ed-4c60-bda5-154ca9f8d013 for the URL below. +# - https://dub01.online.tableau.com/pulse/site/darkplatypus/metrics/5aa997e2-07ed-4c60-bda5-154ca9f8d013 +# +# Requirements: +# - Python 3.x +# - tableauserverclient (TSC) library (https://tableau.github.io/server-client-python/docs/) +# - requests library +# +# Usage: +# - Install the required dependencies: +# pip install tableauserverclient requests +# +# - Run the script to subscribe a user: +# python subscribe_to_pulse_metric.py +# ========================================================================================================== + + +import tableauserverclient as TSC +import requests +import traceback +import sys + +class TableauServerConnection: + """ + Class to manage the Tableau Cloud Site connection using a context manager. + Handles authentication and ensures proper sign-in/sign-out from the server. + """ + def __init__(self, server_url, site_name, pat_name, pat_secret): + self.server_url = server_url + self.site_name = site_name + self.pat_name = pat_name + self.pat_secret = pat_secret + + def __enter__(self): + """ + Establishes the Tableau connection using Personal Access Token (PAT) credentials. + Validates required fields, creates auth and server objects, and signs in. + """ + # Validate that all required fields are set + if not all([self.server_url, self.site_name, self.pat_name, self.pat_secret]): + print("Missing Tableau configuration parameters.") + sys.exit(1) + + try: + # Create a Tableau auth object using Personal Access Token credentials + self.tableau_auth = TSC.PersonalAccessTokenAuth( + token_name=self.pat_name, + personal_access_token=self.pat_secret, + site_id=self.site_name + ) + # Create a server object + self.server = TSC.Server(self.server_url, use_server_version=True) + # Sign in to the server + self.server.auth.sign_in(self.tableau_auth) + return self.server + except Exception as e: + print(f"Error during Tableau authentication: {str(e)}") + print(traceback.format_exc()) + sys.exit(2) + + def __exit__(self, exc_type, exc_val, exc_tb): + """ + Signs out from the Tableau Cloud Site once operations are complete. + Ensures clean disconnection from the server. + """ + self.server.auth.sign_out() + + +def make_tableau_request(method, url, auth_token, headers=None, data=None, content_type='application/json'): + """ + Sends a request to Tableau Cloud Site. + Parameters: + - method: HTTP method (GET, POST, etc.) + - url: Endpoint URL for the request + - auth_token: Authentication token for the request + - headers: Optional HTTP headers + - data: Optional request payload (usually for POST/PUT) + - content_type: Content-Type for the request, defaults to 'application/json' + Returns: + - The server response object if the request is successful, otherwise None. + """ + if headers is None: + headers = {} + + headers.update({ + 'X-Tableau-Auth': auth_token, + 'Accept': 'application/json', + 'Content-Type': content_type + }) + + try: + if data: + if content_type == 'application/json': + response = requests.request(method, url, headers=headers, json=data, timeout=10) + else: + response = requests.request(method, url, headers=headers, data=data, timeout=10) + else: + response = requests.request(method, url, headers=headers, timeout=10) + + response.raise_for_status() + return response + except requests.exceptions.RequestException as e: + print(f"RequestException for URL {url}: {e}") + print(traceback.format_exc()) + return None + + +def get_user_id(server_url, site_name, pat_name, pat_secret, user_email): + """ + Fetches the user ID based on the provided email using a filtered query. + This method is faster than iterating through all users, as it uses RequestOptions + to filter the result server-side. + + Parameters: + - server_url: Tableau Cloud Site URL + - site_name: Tableau site name + - pat_name: Name of the Personal Access Token (PAT) + - pat_secret: Secret of the Personal Access Token (PAT) + - user_email: Email of the user to search for + + Returns: + - User ID if found, otherwise None. + """ + req_option = TSC.RequestOptions() + req_option.filter.add(TSC.Filter(TSC.RequestOptions.Field.Name, TSC.RequestOptions.Operator.Equals, user_email)) + + try: + with TableauServerConnection(server_url, site_name, pat_name, pat_secret) as server: + all_users, pagination_item = server.users.get(req_option) + if all_users: + return all_users[0].id + else: + print(f"No user found with email: {user_email}") + return None + except Exception as e: + print(f"Error while fetching user: {str(e)}") + print(traceback.format_exc()) + sys.exit(3) + +def is_user_already_a_follower(server_url, site_name, pat_name, pat_secret, user_id): + """ + Checks if the user is already a follower of any Pulse metric on Tableau Cloud Site. + Parameters: + - server_url: Tableau Cloud Site URL + - site_name: Tableau site name + - pat_name: Name of the Personal Access Token (PAT) + - pat_secret: Secret of the Personal Access Token (PAT) + - user_id: ID of the user to check for existing subscription + + Returns: + - True if the user is already a follower, otherwise False. + """ + try: + with TableauServerConnection(server_url, site_name, pat_name, pat_secret) as server: + headers = { + 'X-Tableau-Auth': server.auth_token, + 'Accept': 'application/json' + } + page_size = 1000 + next_page_token = '' + + while True: + if next_page_token: + url = f'{server_url}/api/-/pulse/subscriptions?page_size={page_size}&page_token={next_page_token}' + else: + url = f'{server_url}/api/-/pulse/subscriptions?page_size={page_size}' + + response = requests.get(url, headers=headers) + data = response.json() + + for subscription in data.get('subscriptions', []): + follower_user_id = subscription.get('follower', {}).get('user_id') + if follower_user_id == user_id: + return True # User is already subscribed + + next_page_token = data.get('next_page_token') + if not next_page_token: + return False # No more pages, user is not a follower + except Exception as e: + print(f"An error occurred: {str(e)}") + return False + + +def subscribe_to_metric(server_url, site_name, pat_name, pat_secret, metric_id, user_id): + """ + Subscribes a user to a specified Pulse metric on Tableau Cloud Site. + Parameters: + - server_url: Tableau Cloud Site URL + - site_name: Tableau site name + - pat_name: Name of the Personal Access Token (PAT) + - pat_secret: Secret of the Personal Access Token (PAT) + - metric_id: ID of the Pulse metric to subscribe to + - user_id: ID of the user to subscribe to the metric + """ + with TableauServerConnection(server_url, site_name, pat_name, pat_secret) as server: + auth_token = server.auth_token + url = f'{server_url}/api/-/pulse/subscriptions' + data = { + "metric_id": metric_id, + "follower": {"user_id": user_id} + } + + response = make_tableau_request("POST", url, auth_token, data=data) + if response and response.status_code == 201: + print(f"Subscription to {metric_id} successful.") + else: + print(f"Failed to subscribe. Status code: {response.status_code}" if response else "Failed to subscribe.") + + + + + +if __name__ == "__main__": + + + # Tableau Cloud Site details + server_url = 'https://{tableau_pod}.online.tableau.com/' #for example https://10ax.online.tableau.com/ + site_name = 'your_site_name' + pat_name = 'your_pat_name' + pat_secret = 'your_pat_secret' + + # User email + user_email = 'user@example.com' + # Metric Id + metric_id = '{LUID_of_the_Pulse_metric}' # for example: 5aa997e2-07ed-4c60-bda5-154ca9f8d013 + + # Fetch user ID + user_id = get_user_id(server_url, site_name, pat_name, pat_secret, user_email) + + # Proceed if user ID is found + if user_id: + already_follower = is_user_already_a_follower(server_url, site_name, pat_name, pat_secret, user_id) + if not already_follower: + subscribe_to_metric(server_url, site_name, pat_name, pat_secret, metric_id, user_id) + else: + print(f"User with ID {user_id} is already subscribed to the metric.")