Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,17 @@
All notable changes to this project will be documented in this file.
This project adheres to [Semantic Versioning](http://semver.org/).

## upcoming

### Added

- Add support for [personal access token](https://help.tableau.com/current/api/rest_api/en-us/REST/rest_api_concepts_auth.htm#make-a-sign-in-request-with-a-personal-access-token) authentication

### Changed

- Updated to API version 3.6


## [4.0.0] - 2020-11-30

### Changed/Fixed
Expand Down
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,12 @@ client = TableauApi.new(host: 'https://tableau.domain.tld', site_name: 'Default'
client.auth.trusted_ticket
```

### Personal Access Token Authentication
```
client = TableauApi.new(host: 'https://tableau.domain.tld', site_name: 'Default', personal_access_token_name: 'ExampleTokenName', personal_access_token_secret: 'ExampleTokenSecret')
client.users.create(username: 'baz')
```

### Workbooks
```
# find a workbook by name
Expand Down Expand Up @@ -87,6 +93,7 @@ Then run the commands below:
docker run -it -d \
-v $(pwd):/src \
-e TABLEAU_HOST -e TABLEAU_ADMIN_USERNAME -e TABLEAU_ADMIN_PASSWORD \
-e TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME -e TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET \
ruby /bin/bash
docker exec -it CONTAINER_ID /bin/bash -c "cd /src && bundle && rake"
```
Expand Down
27 changes: 24 additions & 3 deletions lib/tableau_api/client.rb
Original file line number Diff line number Diff line change
@@ -1,8 +1,19 @@
module TableauApi
class Client
attr_reader :host, :username, :password, :site_id, :site_name
AUTH_TYPE_PERSONAL_ACCESS_TOKEN = :personal_access_token_name
AUTH_TYPE_USERNAME_AND_PASSWORD = :username_and_password
AUTH_TYPE_TRUSTED_TICKET = :trusted_ticket

def initialize(host:, site_name:, username:, password: nil)
attr_reader :host,
:username,
:password,
:site_id,
:site_name,
:personal_access_token_name,
:personal_access_token_secret

# rubocop:disable Metrics/ParameterLists
def initialize(host:, site_name:, username: nil, password: nil, personal_access_token_name: nil, personal_access_token_secret: nil)
@resources = {}

raise 'host is required' if host.to_s.empty?
Expand All @@ -11,10 +22,20 @@ def initialize(host:, site_name:, username:, password: nil)
raise 'site_name is required' if site_name.to_s.empty?
@site_name = site_name

raise 'username is required' if username.to_s.empty?
raise 'username or personal_access_token_name is required' if personal_access_token_name.to_s.empty? && username.to_s.empty?
@personal_access_token_name = personal_access_token_name
@username = username

@password = password
@personal_access_token_secret = personal_access_token_secret
end
# rubocop:enable Metrics/ParameterLists

def authentication_type
return AUTH_TYPE_PERSONAL_ACCESS_TOKEN unless @personal_access_token_name.to_s.empty?
return AUTH_TYPE_TRUSTED_TICKET if @password.to_s.empty?

AUTH_TYPE_USERNAME_AND_PASSWORD
end

def connection
Expand Down
2 changes: 1 addition & 1 deletion lib/tableau_api/connection.rb
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
module TableauApi
class Connection
API_VERSION = '3.1'.freeze
API_VERSION = '3.6'.freeze

include HTTParty
headers 'User-Agent' => "tableau_api/#{::TableauApi::VERSION} Ruby/#{RUBY_VERSION}"
Expand Down
15 changes: 14 additions & 1 deletion lib/tableau_api/resources/auth.rb
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ def sign_in
return true if signed_in?

request = Builder::XmlMarkup.new.tsRequest do |ts|
ts.credentials(name: @client.username, password: @client.password) do |cred|
ts.credentials(authentication_credentials) do |cred|
cred.site(contentUrl: @client.site_name == 'Default' ? '' : @client.site_name)
end
end
Expand Down Expand Up @@ -73,6 +73,19 @@ def trusted_ticket

res.body
end

private

def authentication_credentials
if @client.authentication_type == Client::AUTH_TYPE_PERSONAL_ACCESS_TOKEN
{
personalAccessTokenName: @client.personal_access_token_name,
personalAccessTokenSecret: @client.personal_access_token_secret
}
else
{ name: @client.username, password: @client.password }
end
end
end
end
end
15 changes: 12 additions & 3 deletions spec/client_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,23 @@
expect(client).to be_an_instance_of(TableauApi::Client)
end

it 'requires the host, site_name, and username' do
it 'requires the host, site_name, and one of username or personal_access_token_name' do
expect { TableauApi.new(host: nil, site_name: 'bar', username: 'baz') }.to raise_error('host is required')
expect { TableauApi.new(host: '', site_name: 'bar', username: 'baz') }.to raise_error('host is required')

expect { TableauApi.new(host: 'foo', site_name: nil, username: 'baz') }.to raise_error('site_name is required')
expect { TableauApi.new(host: 'foo', site_name: '', username: 'baz') }.to raise_error('site_name is required')

expect { TableauApi.new(host: 'foo', site_name: 'bar', username: nil) }.to raise_error('username is required')
expect { TableauApi.new(host: 'foo', site_name: 'bar', username: '') }.to raise_error('username is required')
expect { TableauApi.new(host: 'foo', site_name: 'bar', username: nil) }.to raise_error('username or personal_access_token_name is required')
expect { TableauApi.new(host: 'foo', site_name: 'bar', username: '') }.to raise_error('username or personal_access_token_name is required')

expect { TableauApi.new(host: 'foo', site_name: 'bar', personal_access_token_name: nil) }.to raise_error('username or personal_access_token_name is required')
expect { TableauApi.new(host: 'foo', site_name: 'bar', personal_access_token_name: '') }.to raise_error('username or personal_access_token_name is required')
end

it 'provides info about the authentication type based on params' do
expect(TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', personal_access_token_name: 'ExampleTokenName', personal_access_token_secret: 'ExampleTokenSecret').authentication_type).to eq(TableauApi::Client::AUTH_TYPE_PERSONAL_ACCESS_TOKEN)
expect(TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername').authentication_type).to eq(TableauApi::Client::AUTH_TYPE_TRUSTED_TICKET)
expect(TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername', password: 'ExamplePassword').authentication_type).to eq(TableauApi::Client::AUTH_TYPE_USERNAME_AND_PASSWORD)
end
end
127 changes: 92 additions & 35 deletions spec/resources/auth_spec.rb
Original file line number Diff line number Diff line change
Expand Up @@ -10,48 +10,105 @@
)
end

# http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_concepts_auth.htm%3FTocPath%3DConcepts%7C_____3
# http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____51
it 'returns an instance of TableauApi::Resources::Auth' do
client = TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername', password: 'ExamplePassword')
expect(client.auth).to be_an_instance_of(TableauApi::Resources::Auth)
end
describe 'username and password' do
# http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_concepts_auth.htm%3FTocPath%3DConcepts%7C_____3
# http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____51
it 'returns an instance of TableauApi::Resources::Auth' do
client = TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', username: 'ExampleUsername', password: 'ExamplePassword')
expect(client.auth).to be_an_instance_of(TableauApi::Resources::Auth)
end

it 'fails appropriately with a bad username or password' do
client = TableauApi.new(host: ENV['TABLEAU_HOST'], site_name: 'Default', username: 'foo', password: 'bar')
expect(client.auth.sign_in).to be false
end
it 'fails appropriately with a bad username or password' do
client = TableauApi.new(host: ENV['TABLEAU_HOST'], site_name: 'Default', username: 'foo', password: 'bar')
expect(client.auth.sign_in).to be false
end

it 'automatically signs in to get the token' do
expect(client.auth.token).to be_a_token
end
it 'automatically signs in to get the token' do
expect(client.auth.token).to be_a_token
end

it 'sucessfully signs in with a correct username and password' do
expect(client.auth.sign_in).to be true
expect(client.auth.token).to be_a_token
expect(client.auth.site_id).to be_a_tableau_id
expect(client.auth.user_id).to be_a_tableau_id
end
it 'sucessfully signs in with a correct username and password' do
expect(client.auth.sign_in).to be true
expect(client.auth.token).to be_a_token
expect(client.auth.site_id).to be_a_tableau_id
expect(client.auth.user_id).to be_a_tableau_id
end

it 'signs into a different site' do
client = TableauApi.new(
host: ENV['TABLEAU_HOST'],
site_name: 'TestSite',
username: ENV['TABLEAU_ADMIN_USERNAME'],
password: ENV['TABLEAU_ADMIN_PASSWORD']
)
expect(client.auth.sign_in).to be true
expect(client.auth.token).to be_a_token
expect(client.auth.site_id).to be_a_tableau_id
expect(client.auth.user_id).to be_a_tableau_id
it 'signs into a different site' do
client = TableauApi.new(
host: ENV['TABLEAU_HOST'],
site_name: 'TestSite',
username: ENV['TABLEAU_ADMIN_USERNAME'],
password: ENV['TABLEAU_ADMIN_PASSWORD']
)
expect(client.auth.sign_in).to be true
expect(client.auth.token).to be_a_token
expect(client.auth.site_id).to be_a_tableau_id
expect(client.auth.user_id).to be_a_tableau_id
end

it 'does not sign in if already signed in' do
expect(client.auth.sign_in).to be true
token = client.auth.token

expect(client.auth.sign_in).to be true
expect(client.auth.token).to eq token
end
end

it 'does not sign in if already signed in' do
expect(client.auth.sign_in).to be true
token = client.auth.token
describe 'personal access token' do
let(:client) do
TableauApi.new(
host: ENV['TABLEAU_HOST'],
site_name: 'Default',
personal_access_token_name: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'],
personal_access_token_secret: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET']
)
end

# http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_concepts_auth.htm%3FTocPath%3DConcepts%7C_____3
# http://onlinehelp.tableau.com/v9.0/api/rest_api/en-us/help.htm#REST/rest_api_ref.htm#Sign_In%3FTocPath%3DAPI%2520Reference%7C_____51
it 'returns an instance of TableauApi::Resources::Auth' do
client = TableauApi.new(host: 'tableau.domain.tld', site_name: 'Default', personal_access_token_name: 'ExamplePersonalAccessTokenName', personal_access_token_secret: 'ExamplePersonalAccessTokenSecret')
expect(client.auth).to be_an_instance_of(TableauApi::Resources::Auth)
end

expect(client.auth.sign_in).to be true
expect(client.auth.token).to eq token
it 'fails appropriately with a bad access token credentials' do
client = TableauApi.new(host: ENV['TABLEAU_HOST'], site_name: 'Default', personal_access_token_name: 'foo', personal_access_token_secret: 'bar')
expect(client.auth.sign_in).to be false
end

it 'automatically signs in to get the token' do
expect(client.auth.token).to be_a_token
end

it 'sucessfully signs in with a correct username and password' do
expect(client.auth.sign_in).to be true
expect(client.auth.token).to be_a_token
expect(client.auth.site_id).to be_a_tableau_id
expect(client.auth.user_id).to be_a_tableau_id
end

it 'signs into a different site' do
client = TableauApi.new(
host: ENV['TABLEAU_HOST'],
site_name: 'TestSite',
personal_access_token_name: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'],
personal_access_token_secret: ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET']
)
expect(client.auth.sign_in).to be true
expect(client.auth.token).to be_a_token
expect(client.auth.site_id).to be_a_tableau_id
expect(client.auth.user_id).to be_a_tableau_id
end

it 'does not sign in if already signed in' do
expect(client.auth.sign_in).to be true
token = client.auth.token

expect(client.auth.sign_in).to be true
expect(client.auth.token).to eq token
end
end

describe '.sign_out' do
Expand Down
13 changes: 10 additions & 3 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,16 @@

require 'vcr'

if ENV['TABLEAU_ADMIN_USERNAME'].nil? || ENV['TABLEAU_ADMIN_PASSWORD'].nil?
puts 'TABLEAU_ADMIN_USERNAME and TABLEAU_ADMIN_PASSWORD must be set to record new VCR cassettes'
if ENV['TABLEAU_ADMIN_USERNAME'].nil? ||
ENV['TABLEAU_ADMIN_PASSWORD'].nil? ||
ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'].nil? ||
ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'].nil?
puts 'TABLEAU_ADMIN_USERNAME, TABLEAU_ADMIN_PASSWORD, TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME, and TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET must be set to record new VCR cassettes'

ENV['TABLEAU_ADMIN_USERNAME'] = 'FakeTableauAdminUsername'
ENV['TABLEAU_ADMIN_PASSWORD'] = 'FakeTableauAdminPassword'
ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'] = 'FakeTableauAdminPersonalAccessTokenName'
ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'] = 'FakeTableauAdminPersonalAccessTokenSecret'
end

ENV['TABLEAU_HOST'] = 'http://localhost:2000' if ENV['TABLEAU_HOST'].nil?
Expand All @@ -22,6 +27,8 @@

config.filter_sensitive_data('TABLEAU_ADMIN_USERNAME') { ENV['TABLEAU_ADMIN_USERNAME'] }
config.filter_sensitive_data('TABLEAU_ADMIN_PASSWORD') { ENV['TABLEAU_ADMIN_PASSWORD'].encode(xml: :text) }
config.filter_sensitive_data('TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME') { ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME'] }
config.filter_sensitive_data('TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET') { ENV['TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_SECRET'].encode(xml: :text) }
config.filter_sensitive_data('http://TABLEAU_HOST') { ENV['TABLEAU_HOST'] }

config.allow_http_connections_when_no_cassette = false
Expand All @@ -30,7 +37,7 @@
config.before_record do |interaction|
response = interaction.response
elements = response.body.scan(/<(?:site|user)\s[^>]+name[^>]+>/)
sensitive_elements = elements.reject { |e| e.match(/"(Default|TestSite|Test Site 2|test|test_test|TABLEAU_ADMIN_USERNAME)"/) }
sensitive_elements = elements.reject { |e| e.match(/"(Default|TestSite|Test Site 2|test|test_test|TABLEAU_ADMIN_USERNAME|TABLEAU_ADMIN_PERSONAL_ACCESS_TOKEN_NAME)"/) }
unless sensitive_elements.empty?
sensitive_elements.each { |e| response.body.gsub! e, '' }
response.body.gsub!(/totalAvailable="\d+"/, "totalAvailable=\"#{elements.length - sensitive_elements.length}\"")
Expand Down