diff --git a/README.md b/README.md index e317e4e..32b2442 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,12 @@ Add your Tableau settings in `appsettings.json`: ```json { - "Tableau": { - "ServerUrl": "https://your-tableau-server", + "TableauOptions": { + "Server": "https://your-tableau-server", + "Version": "3.23", + "Site": "yoursite" + }, + "TableauAuthOptions": { "SiteContentUrl": "yoursite", "PersonalAccessTokenName": "your-token-name", "PersonalAccessTokenSecret": "your-token-secret", @@ -61,18 +65,29 @@ using TableauSharp.Extensions; var builder = WebApplication.CreateBuilder(args); -// Bind Tableau settings -builder.Services.Configure( - builder.Configuration.GetSection("Tableau")); - // Register Tableau services -builder.Services.AddTableauSharp(); +builder.Services.AddTableauSharp(builder.Configuration); var app = builder.Build(); ``` --- +## Authentication Lifecycle + +Call one of the sign-in methods before using site-scoped services: + +```csharp +var authToken = await authService.SignInWithPATAsync(); +var workbooks = await workbookService.GetAllAsync(); +``` + +Successful sign-in stores the Tableau auth token, site LUID, site content URL, user LUID, and expiration in the scoped Tableau session. Site-scoped REST requests use the signed-in site LUID returned by Tableau, not the friendly site content URL. + +The built-in session is scoped for a single logical caller. Server applications that serve multiple Tableau users should create an appropriate DI scope per caller/request or manage user-specific sessions explicitly. + +--- + ## Quick Start ### Fetch Workbooks diff --git a/src/TableauSharp/Auth/Service/AuthService.cs b/src/TableauSharp/Auth/Service/AuthService.cs index fa775bb..5bea495 100644 --- a/src/TableauSharp/Auth/Service/AuthService.cs +++ b/src/TableauSharp/Auth/Service/AuthService.cs @@ -19,7 +19,7 @@ public class AuthService(IHttpClientFactory httpClientFactory, private readonly ITableauTokenProvider _tokenProvider = tokenProvider; private readonly TableauOptions _tableauOptions = tableauOptions.Value; - public async Task SignInWithPATAsync() + public async Task SignInWithPATAsync(CancellationToken cancellationToken = default) { var payload = new { @@ -31,22 +31,16 @@ public async Task SignInWithPATAsync() } }; var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.PostAsync("auth/signin", GetJsonContent(payload)); + var response = await client.PostAsync("auth/signin", GetJsonContent(payload), cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - _tokenProvider.SetToken(doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!); - return new AuthToken - { - Token = doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!, - SiteId = doc.RootElement.GetProperty("credentials").GetProperty("site").GetProperty("id").GetString()!, - UserId = doc.RootElement.GetProperty("credentials").GetProperty("user").GetProperty("id").GetString()!, - Expiration = DateTime.UtcNow.AddHours(2) - }; + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var authToken = ParseAuthToken(json); + _tokenProvider.SetToken(authToken); + return authToken; } - public async Task SignInWithUserCredentialsAsync() + public async Task SignInWithUserCredentialsAsync(CancellationToken cancellationToken = default) { var payload = new { @@ -58,28 +52,23 @@ public async Task SignInWithUserCredentialsAsync() } }; var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.PostAsync("auth/signin", GetJsonContent(payload)); + var response = await client.PostAsync("auth/signin", GetJsonContent(payload), cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); - using var doc = JsonDocument.Parse(json); - - return new AuthToken - { - Token = doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!, - SiteId = doc.RootElement.GetProperty("credentials").GetProperty("site").GetProperty("id").GetString()!, - UserId = doc.RootElement.GetProperty("credentials").GetProperty("user").GetProperty("id").GetString()!, - Expiration = DateTime.UtcNow.AddMinutes(_options.Jwt_Expiry_Minutes) - }; + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var authToken = ParseAuthToken(json); + _tokenProvider.SetToken(authToken); + return authToken; } - public async Task SignOutAsync(string token) + public async Task SignOutAsync(string token, CancellationToken cancellationToken = default) { using var request = new HttpRequestMessage(HttpMethod.Post, "auth/signout"); request.Headers.Add("X-Tableau-Auth", token); var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.SendAsync(request); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); + _tokenProvider.ClearToken(); } private static StringContent GetJsonContent(object payload) @@ -88,37 +77,63 @@ private static StringContent GetJsonContent(object payload) return new StringContent(json, Encoding.UTF8, "application/json"); } - public async Task SignInWithJWTAsync(string username, CancellationToken cancellationToken) + public async Task SignInWithJWTAsync(string username, CancellationToken cancellationToken = default) { + string[] scopes = _options.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); var payload = new { credentials = new { - jwt = CreateJwtToken(username), - site = new { contentUrl = _tableauOptions.Site} + jwt = CreateJwt(username, scopes), + site = new { contentUrl = GetSiteContentUrl() } } }; var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.PostAsync("auth/signin", GetJsonContent(payload)); + var response = await client.PostAsync("auth/signin", GetJsonContent(payload), cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); + var authToken = ParseAuthToken(json); + _tokenProvider.SetToken(authToken); + return authToken; + } + + private AuthToken ParseAuthToken(string json) + { using var doc = JsonDocument.Parse(json); + var credentials = doc.RootElement.GetProperty("credentials"); + var site = credentials.GetProperty("site"); + var user = credentials.GetProperty("user"); return new AuthToken { - Token = doc.RootElement.GetProperty("credentials").GetProperty("token").GetString()!, - SiteId = doc.RootElement.GetProperty("credentials").GetProperty("site").GetProperty("id").GetString()!, - UserId = doc.RootElement.GetProperty("credentials").GetProperty("user").GetProperty("id").GetString()!, - Expiration = DateTime.UtcNow.AddHours(2) + Token = credentials.GetProperty("token").GetString() ?? string.Empty, + SiteId = site.GetProperty("id").GetString() ?? string.Empty, + SiteContentUrl = site.TryGetProperty("contentUrl", out var contentUrl) + ? contentUrl.GetString() + : GetSiteContentUrl(), + UserId = user.GetProperty("id").GetString() ?? string.Empty, + Expiration = GetExpiration(credentials) }; } private string CreateJwt(string username, string[] scopes) { + if (string.IsNullOrWhiteSpace(_options.SecretValue)) + { + throw new InvalidOperationException("TableauAuthOptions.SecretValue must be configured for JWT sign-in."); + } + + if (string.IsNullOrWhiteSpace(_options.SecretId)) + { + throw new InvalidOperationException("TableauAuthOptions.SecretId must be configured for JWT sign-in."); + } + SecurityTokenDescriptor tokenDescriptor = new() { - Audience = _options.Jwt_Audience, + Audience = string.IsNullOrWhiteSpace(_options.Jwt_Audience) + ? _tableauOptions.Server + : _options.Jwt_Audience, Subject = new System.Security.Claims.ClaimsIdentity(new[] { new System.Security.Claims.Claim("sub", username), @@ -133,15 +148,30 @@ private string CreateJwt(string username, string[] scopes) return jsonWebTokenHandler.CreateToken(tokenDescriptor); } - private TableauJWT CreateJwtToken(string username) + private DateTime GetExpiration(JsonElement credentials) { - string[] scopes = _options.Scopes.Split(' ', StringSplitOptions.RemoveEmptyEntries); - return new TableauJWT + if (credentials.TryGetProperty("estimatedTimeToExpiration", out var expirationElement)) { - ExpiryMinutes = _options.Jwt_Expiry_Minutes, - Server = _tableauOptions.Server, - Site = _tableauOptions.Site, - Token = CreateJwt(username, scopes) - }; + var value = expirationElement.GetString(); + if (!string.IsNullOrWhiteSpace(value)) + { + var parts = value.Split(':'); + if (parts.Length == 3 + && int.TryParse(parts[0], out var days) + && int.TryParse(parts[1], out var hours) + && int.TryParse(parts[2], out var minutes)) + { + return DateTime.UtcNow.Add(new TimeSpan(days, hours, minutes, 0)); + } + } + } + + var fallbackMinutes = _options.Jwt_Expiry_Minutes > 0 ? _options.Jwt_Expiry_Minutes : 120; + return DateTime.UtcNow.AddMinutes(fallbackMinutes); } -} \ No newline at end of file + + private string GetSiteContentUrl() + => !string.IsNullOrWhiteSpace(_options.SiteContentUrl) + ? _options.SiteContentUrl + : _tableauOptions.Site; +} diff --git a/src/TableauSharp/Auth/Service/IAuthService.cs b/src/TableauSharp/Auth/Service/IAuthService.cs index 3264a82..0e1a630 100644 --- a/src/TableauSharp/Auth/Service/IAuthService.cs +++ b/src/TableauSharp/Auth/Service/IAuthService.cs @@ -4,9 +4,8 @@ namespace TableauSharp.Auth.Service; public interface IAuthService { - Task SignInWithPATAsync(); - Task SignInWithUserCredentialsAsync(); - Task SignOutAsync(string token); - Task SignInWithJWTAsync(string username, CancellationToken cancellationToken); - -} \ No newline at end of file + Task SignInWithPATAsync(CancellationToken cancellationToken = default); + Task SignInWithUserCredentialsAsync(CancellationToken cancellationToken = default); + Task SignOutAsync(string token, CancellationToken cancellationToken = default); + Task SignInWithJWTAsync(string username, CancellationToken cancellationToken = default); +} diff --git a/src/TableauSharp/Common/Helper/ITableauTokenProvider.cs b/src/TableauSharp/Common/Helper/ITableauTokenProvider.cs index 36b9f28..92958e6 100644 --- a/src/TableauSharp/Common/Helper/ITableauTokenProvider.cs +++ b/src/TableauSharp/Common/Helper/ITableauTokenProvider.cs @@ -1,3 +1,5 @@ +using TableauSharp.Common.Models; + namespace TableauSharp.Common.Helper; public interface ITableauTokenProvider @@ -7,13 +9,23 @@ public interface ITableauTokenProvider /// string GetToken(); + /// + /// Gets the current Tableau authentication/session details. + /// + AuthToken GetTokenInfo(); + /// /// Sets or updates the Tableau authentication token. /// void SetToken(string token); + /// + /// Sets or updates the Tableau authentication/session details. + /// + void SetToken(AuthToken token); + /// /// Clears the current token (e.g., after sign out). /// void ClearToken(); -} \ No newline at end of file +} diff --git a/src/TableauSharp/Common/Helper/TableauTokenProvider.cs b/src/TableauSharp/Common/Helper/TableauTokenProvider.cs index 1bb566e..3c2136c 100644 --- a/src/TableauSharp/Common/Helper/TableauTokenProvider.cs +++ b/src/TableauSharp/Common/Helper/TableauTokenProvider.cs @@ -1,24 +1,59 @@ +using TableauSharp.Common.Models; + namespace TableauSharp.Common.Helper; public class TableauTokenProvider : ITableauTokenProvider { - private string _token; + private readonly object _gate = new(); + private AuthToken? _token; public string GetToken() { - if (string.IsNullOrWhiteSpace(_token)) - throw new InvalidOperationException("No Tableau auth token set. Please sign in first."); + return GetTokenInfo().Token; + } - return _token; + public AuthToken GetTokenInfo() + { + lock (_gate) + { + if (_token is null || string.IsNullOrWhiteSpace(_token.Token)) + { + throw new InvalidOperationException("No Tableau auth token set. Please sign in first."); + } + + return _token; + } } public void SetToken(string token) { - _token = token ?? throw new ArgumentNullException(nameof(token)); + if (string.IsNullOrWhiteSpace(token)) + { + throw new ArgumentException("Token cannot be null or empty.", nameof(token)); + } + + SetToken(new AuthToken { Token = token }); + } + + public void SetToken(AuthToken token) + { + ArgumentNullException.ThrowIfNull(token); + if (string.IsNullOrWhiteSpace(token.Token)) + { + throw new ArgumentException("Token cannot be null or empty.", nameof(token)); + } + + lock (_gate) + { + _token = token; + } } public void ClearToken() { - _token = null; + lock (_gate) + { + _token = null; + } } -} \ No newline at end of file +} diff --git a/src/TableauSharp/Common/Http/ITableauRequestBuilder.cs b/src/TableauSharp/Common/Http/ITableauRequestBuilder.cs new file mode 100644 index 0000000..46493c9 --- /dev/null +++ b/src/TableauSharp/Common/Http/ITableauRequestBuilder.cs @@ -0,0 +1,7 @@ +namespace TableauSharp.Common.Http; + +public interface ITableauRequestBuilder +{ + HttpRequestMessage CreateSiteRequest(HttpMethod method, string relativePath); + HttpRequestMessage CreateServerRequest(HttpMethod method, string relativePath); +} diff --git a/src/TableauSharp/Common/Http/TableauRequestBuilder.cs b/src/TableauSharp/Common/Http/TableauRequestBuilder.cs new file mode 100644 index 0000000..f1c1a89 --- /dev/null +++ b/src/TableauSharp/Common/Http/TableauRequestBuilder.cs @@ -0,0 +1,48 @@ +using Microsoft.Extensions.Options; +using TableauSharp.Common.Helper; +using TableauSharp.Settings; + +namespace TableauSharp.Common.Http; + +public class TableauRequestBuilder( + IOptions options, + ITableauTokenProvider tokenProvider) : ITableauRequestBuilder +{ + private readonly TableauOptions _options = options.Value; + private readonly ITableauTokenProvider _tokenProvider = tokenProvider; + + public HttpRequestMessage CreateSiteRequest(HttpMethod method, string relativePath) + { + var session = _tokenProvider.GetTokenInfo(); + if (string.IsNullOrWhiteSpace(session.SiteId)) + { + throw new InvalidOperationException("No Tableau site id set. Please sign in first."); + } + + var request = new HttpRequestMessage( + method, + BuildUri($"api/{_options.Version}/sites/{session.SiteId}/{relativePath.TrimStart('/')}")); + request.Headers.Add("X-Tableau-Auth", session.Token); + return request; + } + + public HttpRequestMessage CreateServerRequest(HttpMethod method, string relativePath) + { + var session = _tokenProvider.GetTokenInfo(); + var request = new HttpRequestMessage(method, BuildUri(relativePath)); + request.Headers.Add("X-Tableau-Auth", session.Token); + return request; + } + + private Uri BuildUri(string relativePath) + { + if (string.IsNullOrWhiteSpace(_options.Server)) + { + throw new InvalidOperationException("TableauOptions.Server must be configured."); + } + + var server = _options.Server.TrimEnd('/'); + var path = relativePath.TrimStart('/'); + return new Uri($"{server}/{path}"); + } +} diff --git a/src/TableauSharp/Common/Models/AuthToken.cs b/src/TableauSharp/Common/Models/AuthToken.cs index 46e4ddc..ad31704 100644 --- a/src/TableauSharp/Common/Models/AuthToken.cs +++ b/src/TableauSharp/Common/Models/AuthToken.cs @@ -5,14 +5,17 @@ namespace TableauSharp.Common.Models; public class AuthToken { [JsonPropertyName("token")] - public string Token { get; set; } + public string Token { get; set; } = string.Empty; [JsonPropertyName("siteId")] - public string SiteId { get; set; } + public string SiteId { get; set; } = string.Empty; + + [JsonPropertyName("siteContentUrl")] + public string? SiteContentUrl { get; set; } [JsonPropertyName("userId")] - public string UserId { get; set; } + public string UserId { get; set; } = string.Empty; [JsonPropertyName("expiration")] public DateTime Expiration { get; set; } -} \ No newline at end of file +} diff --git a/src/TableauSharp/Common/Models/TableauJWT.cs b/src/TableauSharp/Common/Models/TableauJWT.cs deleted file mode 100644 index 01bd43a..0000000 --- a/src/TableauSharp/Common/Models/TableauJWT.cs +++ /dev/null @@ -1,9 +0,0 @@ -namespace TableauSharp.Common.Models; - -public sealed class TableauJWT -{ - public string Token { get; set; } - public int ExpiryMinutes { get; set; } - public string Site { get; set; } - public string Server { get; set; } -} diff --git a/src/TableauSharp/Extensions/IServiceCollectionExtensions.cs b/src/TableauSharp/Extensions/IServiceCollectionExtensions.cs index 4026f21..e202ff5 100644 --- a/src/TableauSharp/Extensions/IServiceCollectionExtensions.cs +++ b/src/TableauSharp/Extensions/IServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.Options; using TableauSharp.Auth.Service; using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; using TableauSharp.DataSources.Services; using TableauSharp.Embedding.Services; using TableauSharp.Permissions.Services; @@ -19,7 +20,6 @@ public static IServiceCollection AddTableauSharp(this IServiceCollection service { services.Configure(configuration.GetSection("TableauOptions")); services.Configure(configuration.GetSection("TableauAuthOptions")); - services.AddHttpClient("TableauClient", (sp, client) => { @@ -27,7 +27,8 @@ public static IServiceCollection AddTableauSharp(this IServiceCollection service client.BaseAddress = new Uri($"{options.Server}/api/{options.Version}/"); }); - services.AddSingleton(); + services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(); @@ -40,4 +41,4 @@ public static IServiceCollection AddTableauSharp(this IServiceCollection service return services; } -} \ No newline at end of file +} diff --git a/src/TableauSharp/Settings/TableauAuthOptions.cs b/src/TableauSharp/Settings/TableauAuthOptions.cs index 4f28342..b2077fa 100644 --- a/src/TableauSharp/Settings/TableauAuthOptions.cs +++ b/src/TableauSharp/Settings/TableauAuthOptions.cs @@ -10,7 +10,7 @@ public class TableauAuthOptions public bool UsePAT { get; set; } = true; public string? SecretValue { get; set; } public string? SecretId { get; set; } - public int Jwt_Expiry_Minutes { get; set; } - public string Jwt_Audience { get; set; } = "tableau"; + public int Jwt_Expiry_Minutes { get; set; } = 5; + public string Jwt_Audience { get; set; } = string.Empty; public string Scopes { get; set; } = "tableau:views:read tableau:workbooks:read"; -} \ No newline at end of file +} diff --git a/src/TableauSharp/Settings/TableauOptions.cs b/src/TableauSharp/Settings/TableauOptions.cs index 5826a41..1e3be6d 100644 --- a/src/TableauSharp/Settings/TableauOptions.cs +++ b/src/TableauSharp/Settings/TableauOptions.cs @@ -4,5 +4,5 @@ public class TableauOptions { public string Server { get; set; } = string.Empty; public string Version { get; set; } = "3.23"; // default fallback - public string Site { get; set; } + public string Site { get; set; } = string.Empty; } diff --git a/src/TableauSharp/Workbooks/Services/IViewService.cs b/src/TableauSharp/Workbooks/Services/IViewService.cs index ded3bd8..79bae2b 100644 --- a/src/TableauSharp/Workbooks/Services/IViewService.cs +++ b/src/TableauSharp/Workbooks/Services/IViewService.cs @@ -4,6 +4,6 @@ namespace TableauSharp.Workbooks.Services; public interface IViewService { - Task> GetViewsByWorkbookIdAsync(string workbookId); - Task ExportViewAsync(ExportRequest request); -} \ No newline at end of file + Task> GetViewsByWorkbookIdAsync(string workbookId, CancellationToken cancellationToken = default); + Task ExportViewAsync(ExportRequest request, CancellationToken cancellationToken = default); +} diff --git a/src/TableauSharp/Workbooks/Services/IWorkbookService.cs b/src/TableauSharp/Workbooks/Services/IWorkbookService.cs index 121034b..ae842ad 100644 --- a/src/TableauSharp/Workbooks/Services/IWorkbookService.cs +++ b/src/TableauSharp/Workbooks/Services/IWorkbookService.cs @@ -4,8 +4,8 @@ namespace TableauSharp.Workbooks.Services; public interface IWorkbookService { - Task> GetAllAsync(); - Task GetByIdAsync(string workbookId); - Task PublishAsync(WorkbookPublishRequest request); - Task DeleteAsync(string workbookId); -} \ No newline at end of file + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(string workbookId, CancellationToken cancellationToken = default); + Task PublishAsync(WorkbookPublishRequest request, CancellationToken cancellationToken = default); + Task DeleteAsync(string workbookId, CancellationToken cancellationToken = default); +} diff --git a/src/TableauSharp/Workbooks/Services/ViewService.cs b/src/TableauSharp/Workbooks/Services/ViewService.cs index ab8e890..6f12c6d 100644 --- a/src/TableauSharp/Workbooks/Services/ViewService.cs +++ b/src/TableauSharp/Workbooks/Services/ViewService.cs @@ -1,7 +1,5 @@ using System.Text.Json; -using Microsoft.Extensions.Options; -using TableauSharp.Common.Helper; -using TableauSharp.Settings; +using TableauSharp.Common.Http; using TableauSharp.Workbooks.Models; namespace TableauSharp.Workbooks.Services; @@ -9,38 +7,25 @@ namespace TableauSharp.Workbooks.Services; public class ViewService : IViewService { private readonly IHttpClientFactory _httpClientFactory; - private readonly TableauAuthOptions _options; - private readonly ITableauTokenProvider _tokenProvider; - private readonly TableauOptions _tableauOptions; + private readonly ITableauRequestBuilder _requestBuilder; public ViewService( IHttpClientFactory httpClientFactory, - IOptions options, - ITableauTokenProvider tokenProvider, - IOptions tableauOptions) + ITableauRequestBuilder requestBuilder) { _httpClientFactory = httpClientFactory; - _options = options.Value; - _tokenProvider = tokenProvider; - _tableauOptions = tableauOptions.Value; + _requestBuilder = requestBuilder; } - private HttpClient CreateClient() + public async Task> GetViewsByWorkbookIdAsync(string workbookId, CancellationToken cancellationToken = default) { var client = _httpClientFactory.CreateClient("TableauClient"); - client.BaseAddress = new Uri($"{_tableauOptions.Server}/api/{_tableauOptions.Version}/sites/{_options.SiteContentUrl}/"); - client.DefaultRequestHeaders.Add("X-Tableau-Auth", _tokenProvider.GetToken()); - return client; - } - - public async Task> GetViewsByWorkbookIdAsync(string workbookId) - { - using var client = CreateClient(); - var response = await client.GetAsync($"workbooks/{workbookId}/views"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"workbooks/{workbookId}/views"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var views = new List(); @@ -63,14 +48,15 @@ public async Task> GetViewsByWorkbookIdAsync(string wor return views; } - public async Task ExportViewAsync(ExportRequest request) + public async Task ExportViewAsync(ExportRequest request, CancellationToken cancellationToken = default) { - using var client = CreateClient(); + var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.GetAsync($"views/{request.ViewId}/{request.Format.ToLower()}"); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"views/{request.ViewId}/{request.Format.ToLower()}"); + var response = await client.SendAsync(httpRequest, cancellationToken); response.EnsureSuccessStatusCode(); - var bytes = await response.Content.ReadAsByteArrayAsync(); + var bytes = await response.Content.ReadAsByteArrayAsync(cancellationToken); var contentType = response.Content.Headers.ContentType?.MediaType ?? "application/octet-stream"; return new ExportResponse diff --git a/src/TableauSharp/Workbooks/Services/WorkbookService.cs b/src/TableauSharp/Workbooks/Services/WorkbookService.cs index f3b9108..49d4b80 100644 --- a/src/TableauSharp/Workbooks/Services/WorkbookService.cs +++ b/src/TableauSharp/Workbooks/Services/WorkbookService.cs @@ -1,7 +1,5 @@ -using Microsoft.Extensions.Options; using System.Text.Json; -using TableauSharp.Common.Helper; -using TableauSharp.Settings; +using TableauSharp.Common.Http; using TableauSharp.Workbooks.Models; namespace TableauSharp.Workbooks.Services; @@ -9,37 +7,24 @@ namespace TableauSharp.Workbooks.Services; public class WorkbookService : IWorkbookService { private readonly IHttpClientFactory _httpClientFactory; - private readonly TableauAuthOptions _options; - private readonly ITableauTokenProvider _tokenProvider; - private readonly TableauOptions _tableauOptions; + private readonly ITableauRequestBuilder _requestBuilder; public WorkbookService( IHttpClientFactory httpClientFactory, - IOptions options, - IOptions tableauOptions, - ITableauTokenProvider tokenProvider) + ITableauRequestBuilder requestBuilder) { _httpClientFactory = httpClientFactory; - _options = options.Value; - _tableauOptions = tableauOptions.Value; - _tokenProvider = tokenProvider; + _requestBuilder = requestBuilder; } - private HttpClient CreateClient() + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { var client = _httpClientFactory.CreateClient("TableauClient"); - client.BaseAddress = new Uri($"{_tableauOptions.Server}/api/{_tableauOptions.Version}/sites/{_options.SiteContentUrl}/"); - client.DefaultRequestHeaders.Add("X-Tableau-Auth", _tokenProvider.GetToken()); - return client; - } - - public async Task> GetAllAsync() - { - using var client = CreateClient(); - var response = await client.GetAsync("workbooks"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, "workbooks"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var workbooks = new List(); @@ -59,14 +44,15 @@ public async Task> GetAllAsync() return workbooks; } - public async Task GetByIdAsync(string workbookId) + public async Task GetByIdAsync(string workbookId, CancellationToken cancellationToken = default) { - using var client = CreateClient(); + var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.GetAsync($"workbooks/{workbookId}"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"workbooks/{workbookId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var w = doc.RootElement.GetProperty("workbook"); @@ -82,9 +68,9 @@ public async Task GetByIdAsync(string workbookId) } - public async Task PublishAsync(WorkbookPublishRequest request) + public async Task PublishAsync(WorkbookPublishRequest request, CancellationToken cancellationToken = default) { - using var client = CreateClient(); + var client = _httpClientFactory.CreateClient("TableauClient"); using var form = new MultipartFormDataContent(); form.Add(new StringContent(request.ProjectId), "projectId"); @@ -97,10 +83,12 @@ public async Task PublishAsync(WorkbookPublishRequest request) fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); form.Add(fileContent, "tableau_workbook", Path.GetFileName(request.FilePath)); - var response = await client.PostAsync("workbooks", form); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Post, "workbooks"); + httpRequest.Content = form; + var response = await client.SendAsync(httpRequest, cancellationToken); response.EnsureSuccessStatusCode(); - var json = await response.Content.ReadAsStringAsync(); + var json = await response.Content.ReadAsStringAsync(cancellationToken); using var doc = JsonDocument.Parse(json); var w = doc.RootElement.GetProperty("workbook"); @@ -115,13 +103,14 @@ public async Task PublishAsync(WorkbookPublishRequest request) }; } - public async Task DeleteAsync(string workbookId) + public async Task DeleteAsync(string workbookId, CancellationToken cancellationToken = default) { - using var client = CreateClient(); + var client = _httpClientFactory.CreateClient("TableauClient"); - var response = await client.DeleteAsync($"workbooks/{workbookId}"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, $"workbooks/{workbookId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } -} \ No newline at end of file +} diff --git a/test/TableauSharp.Tests/Auth/AuthServiceTests.cs b/test/TableauSharp.Tests/Auth/AuthServiceTests.cs new file mode 100644 index 0000000..f744a4f --- /dev/null +++ b/test/TableauSharp.Tests/Auth/AuthServiceTests.cs @@ -0,0 +1,146 @@ +using System.Net; +using Microsoft.Extensions.Options; +using Moq; +using RichardSzalay.MockHttp; +using TableauSharp.Auth.Service; +using TableauSharp.Common.Helper; +using TableauSharp.Common.Models; +using TableauSharp.Settings; + +namespace TableauSharp.Tests.Auth; + +[TestFixture] +public class AuthServiceTests +{ + private const string Server = "https://tableau.example.com"; + private const string ApiVersion = "3.23"; + private const string SiteContentUrl = "mysite"; + private const string Token = "auth-token"; + private const string SiteId = "site-luid"; + private const string UserId = "user-luid"; + + private MockHttpMessageHandler _mockHttp = null!; + private Mock _mockFactory = null!; + private TableauTokenProvider _tokenProvider = null!; + private AuthService _service = null!; + + [SetUp] + public void SetUp() + { + _mockHttp = new MockHttpMessageHandler(); + var httpClient = _mockHttp.ToHttpClient(); + httpClient.BaseAddress = new Uri($"{Server}/api/{ApiVersion}/"); + + _mockFactory = new Mock(MockBehavior.Strict); + _mockFactory.Setup(f => f.CreateClient("TableauClient")).Returns(httpClient); + + _tokenProvider = new TableauTokenProvider(); + + _service = new AuthService( + _mockFactory.Object, + Options.Create(new TableauAuthOptions + { + SiteContentUrl = SiteContentUrl, + PersonalAccessTokenName = "pat-name", + PersonalAccessTokenSecret = "pat-secret", + Username = "user", + Password = "password", + Jwt_Expiry_Minutes = 10, + SecretId = "secret-id", + SecretValue = "this-is-a-test-secret-value-32-bytes" + }), + Options.Create(new TableauOptions + { + Server = Server, + Version = ApiVersion, + Site = SiteContentUrl + }), + _tokenProvider); + } + + [TearDown] + public void TearDown() => _mockHttp.Dispose(); + + [Test] + public async Task SignInWithPATAsync_StoresTokenAndSiteSession() + { + _mockHttp.When(HttpMethod.Post, $"{Server}/api/{ApiVersion}/auth/signin") + .Respond("application/json", SignInJson); + + var result = await _service.SignInWithPATAsync(); + var stored = _tokenProvider.GetTokenInfo(); + + Assert.Multiple(() => + { + Assert.That(result.Token, Is.EqualTo(Token)); + Assert.That(result.SiteId, Is.EqualTo(SiteId)); + Assert.That(result.SiteContentUrl, Is.EqualTo(SiteContentUrl)); + Assert.That(result.UserId, Is.EqualTo(UserId)); + Assert.That(stored.Token, Is.EqualTo(Token)); + Assert.That(stored.SiteId, Is.EqualTo(SiteId)); + }); + } + + [Test] + public async Task SignInWithUserCredentialsAsync_StoresTokenAndSiteSession() + { + _mockHttp.When(HttpMethod.Post, $"{Server}/api/{ApiVersion}/auth/signin") + .Respond("application/json", SignInJson); + + await _service.SignInWithUserCredentialsAsync(); + + Assert.That(_tokenProvider.GetTokenInfo().SiteId, Is.EqualTo(SiteId)); + } + + [Test] + public async Task SignOutAsync_ClearsTokenAfterSuccessfulSignOut() + { + _tokenProvider.SetToken(new AuthToken { Token = Token, SiteId = SiteId }); + + _mockHttp.When(HttpMethod.Post, $"{Server}/api/{ApiVersion}/auth/signout") + .Respond(HttpStatusCode.NoContent); + + await _service.SignOutAsync(Token); + + Assert.Throws(() => _tokenProvider.GetTokenInfo()); + } + + [Test] + public async Task SignInWithJWTAsync_SendsRawJwtString() + { + string? requestBody = null; + + _mockHttp.When(HttpMethod.Post, $"{Server}/api/{ApiVersion}/auth/signin") + .With(req => + { + requestBody = req.Content!.ReadAsStringAsync().GetAwaiter().GetResult(); + return true; + }) + .Respond("application/json", SignInJson); + + await _service.SignInWithJWTAsync("tableau-user"); + + Assert.Multiple(() => + { + Assert.That(requestBody, Does.Contain("\"jwt\":\"")); + Assert.That(requestBody, Does.Not.Contain("\"Token\"")); + Assert.That(requestBody, Does.Contain($"\"contentUrl\":\"{SiteContentUrl}\"")); + }); + } + + private static string SignInJson => $$""" + { + "credentials": { + "token": "{{Token}}", + "estimatedTimeToExpiration": "2:12:30", + "site": { + "id": "{{SiteId}}", + "contentUrl": "{{SiteContentUrl}}" + }, + "user": { + "id": "{{UserId}}" + } + } + } + """; +} diff --git a/test/TableauSharp.Tests/Common/TableauRequestBuilderTests.cs b/test/TableauSharp.Tests/Common/TableauRequestBuilderTests.cs new file mode 100644 index 0000000..ac2e79e --- /dev/null +++ b/test/TableauSharp.Tests/Common/TableauRequestBuilderTests.cs @@ -0,0 +1,55 @@ +using Microsoft.Extensions.Options; +using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; +using TableauSharp.Common.Models; +using TableauSharp.Settings; + +namespace TableauSharp.Tests.Common; + +[TestFixture] +public class TableauRequestBuilderTests +{ + [Test] + public void CreateSiteRequest_UsesSignedInSiteIdAndAuthHeader() + { + var tokenProvider = new TableauTokenProvider(); + tokenProvider.SetToken(new AuthToken + { + Token = "auth-token", + SiteId = "site-luid-123", + SiteContentUrl = "friendly-site" + }); + + var builder = new TableauRequestBuilder( + Options.Create(new TableauOptions + { + Server = "https://tableau.example.com/", + Version = "3.23" + }), + tokenProvider); + + using var request = builder.CreateSiteRequest(HttpMethod.Get, "/workbooks"); + + Assert.Multiple(() => + { + Assert.That(request.RequestUri!.ToString(), Is.EqualTo("https://tableau.example.com/api/3.23/sites/site-luid-123/workbooks")); + Assert.That(request.Headers.GetValues("X-Tableau-Auth").Single(), Is.EqualTo("auth-token")); + }); + } + + [Test] + public void CreateSiteRequest_WhenSiteIdMissing_ThrowsClearException() + { + var tokenProvider = new TableauTokenProvider(); + tokenProvider.SetToken("auth-token"); + + var builder = new TableauRequestBuilder( + Options.Create(new TableauOptions { Server = "https://tableau.example.com", Version = "3.23" }), + tokenProvider); + + var ex = Assert.Throws(() => + builder.CreateSiteRequest(HttpMethod.Get, "workbooks")); + + Assert.That(ex!.Message, Does.Contain("site id")); + } +} diff --git a/test/TableauSharp.Tests/Common/TableauTokenProviderTests.cs b/test/TableauSharp.Tests/Common/TableauTokenProviderTests.cs new file mode 100644 index 0000000..3f748ee --- /dev/null +++ b/test/TableauSharp.Tests/Common/TableauTokenProviderTests.cs @@ -0,0 +1,43 @@ +using TableauSharp.Common.Helper; +using TableauSharp.Common.Models; + +namespace TableauSharp.Tests.Common; + +[TestFixture] +public class TableauTokenProviderTests +{ + [Test] + public void GetTokenInfo_WhenTokenNotSet_ThrowsClearException() + { + var provider = new TableauTokenProvider(); + + var ex = Assert.Throws(() => provider.GetTokenInfo()); + + Assert.That(ex!.Message, Does.Contain("No Tableau auth token set")); + } + + [Test] + public void SetToken_WithAuthToken_StoresFullSession() + { + var provider = new TableauTokenProvider(); + + provider.SetToken(new AuthToken + { + Token = "auth-token", + SiteId = "site-luid", + SiteContentUrl = "mysite", + UserId = "user-luid", + Expiration = DateTime.UtcNow.AddHours(1) + }); + + var session = provider.GetTokenInfo(); + + Assert.Multiple(() => + { + Assert.That(session.Token, Is.EqualTo("auth-token")); + Assert.That(session.SiteId, Is.EqualTo("site-luid")); + Assert.That(session.SiteContentUrl, Is.EqualTo("mysite")); + Assert.That(session.UserId, Is.EqualTo("user-luid")); + }); + } +} diff --git a/test/TableauSharp.Tests/Workbooks/ViewServiceTests.cs b/test/TableauSharp.Tests/Workbooks/ViewServiceTests.cs new file mode 100644 index 0000000..ab6e504 --- /dev/null +++ b/test/TableauSharp.Tests/Workbooks/ViewServiceTests.cs @@ -0,0 +1,133 @@ +using System.Net; +using Microsoft.Extensions.Options; +using Moq; +using RichardSzalay.MockHttp; +using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; +using TableauSharp.Common.Models; +using TableauSharp.Settings; +using TableauSharp.Workbooks.Models; +using TableauSharp.Workbooks.Services; + +namespace TableauSharp.Tests.Workbooks; + +[TestFixture] +public class ViewServiceTests +{ + private const string Server = "https://tableau.example.com"; + private const string ApiVersion = "3.23"; + private const string SiteId = "site-luid-123"; + private const string AuthToken = "test-auth-token-abc"; + private string SiteBase => $"{Server}/api/{ApiVersion}/sites/{SiteId}/"; + + private MockHttpMessageHandler _mockHttp = null!; + private ViewService _service = null!; + + [SetUp] + public void SetUp() + { + _mockHttp = new MockHttpMessageHandler(); + + var httpClient = _mockHttp.ToHttpClient(); + var factory = new Mock(MockBehavior.Strict); + factory.Setup(f => f.CreateClient("TableauClient")).Returns(httpClient); + + var tokenProvider = new Mock(MockBehavior.Strict); + tokenProvider.Setup(p => p.GetTokenInfo()).Returns(new AuthToken + { + Token = AuthToken, + SiteId = SiteId, + SiteContentUrl = "mysite", + UserId = "user-luid-123", + Expiration = DateTime.UtcNow.AddHours(2) + }); + + var requestBuilder = new TableauRequestBuilder( + Options.Create(new TableauOptions { Server = Server, Version = ApiVersion }), + tokenProvider.Object); + + _service = new ViewService(factory.Object, requestBuilder); + } + + [TearDown] + public void TearDown() => _mockHttp.Dispose(); + + [Test] + public async Task GetViewsByWorkbookIdAsync_UsesSignedInSiteIdAndAuthHeader() + { + string? capturedToken = null; + + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks/wb-001/views") + .With(req => + { + capturedToken = req.Headers.TryGetValues("X-Tableau-Auth", out var values) + ? values.SingleOrDefault() + : null; + return true; + }) + .Respond("application/json", ViewsJson); + + var result = (await _service.GetViewsByWorkbookIdAsync("wb-001")).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo("view-001")); + Assert.That(capturedToken, Is.EqualTo(AuthToken)); + }); + } + + [Test] + public async Task ExportViewAsync_ReturnsFileContentFromSignedInSite() + { + var fileContent = new byte[] { 1, 2, 3 }; + + _mockHttp.When(HttpMethod.Get, $"{SiteBase}views/view-001/pdf") + .Respond(_ => new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new ByteArrayContent(fileContent) + { + Headers = { ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/pdf") } + } + }); + + var result = await _service.ExportViewAsync(new ExportRequest + { + ViewId = "view-001", + Format = "PDF" + }); + + Assert.Multiple(() => + { + Assert.That(result.FileContent, Is.EqualTo(fileContent)); + Assert.That(result.ContentType, Is.EqualTo("application/pdf")); + Assert.That(result.FileName, Is.EqualTo("view_view-001.pdf")); + }); + } + + [Test] + public void GetViewsByWorkbookIdAsync_WhenCancellationRequested_ThrowsTaskCanceledException() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks/wb-001/views") + .Respond("application/json", ViewsJson); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.ThrowsAsync(() => _service.GetViewsByWorkbookIdAsync("wb-001", cts.Token)); + } + + private static string ViewsJson => """ + { + "views": [ + { + "id": "view-001", + "name": "Sales View", + "contentUrl": "sales-view", + "totalViews": 42, + "lastViewedAt": "2024-06-01T08:30:00Z" + } + ] + } + """; +} diff --git a/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs b/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs index d3865f8..355d89d 100644 --- a/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs +++ b/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs @@ -3,6 +3,8 @@ using Moq; using RichardSzalay.MockHttp; using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; +using TableauSharp.Common.Models; using TableauSharp.Settings; using TableauSharp.Workbooks.Models; using TableauSharp.Workbooks.Services; @@ -15,8 +17,9 @@ public class WorkbookServiceTests private const string Server = "https://tableau.example.com"; private const string ApiVersion = "3.23"; private const string SiteContentUrl = "mysite"; + private const string SiteId = "site-luid-123"; private const string AuthToken = "test-auth-token-abc"; - private string SiteBase => $"{Server}/api/{ApiVersion}/sites/{SiteContentUrl}/"; + private string SiteBase => $"{Server}/api/{ApiVersion}/sites/{SiteId}/"; private MockHttpMessageHandler _mockHttp = null!; private Mock _mockFactory = null!; @@ -34,12 +37,19 @@ public void SetUp() _mockFactory.Setup(f => f.CreateClient("TableauClient")).Returns(httpClient); _mockTokenProvider = new Mock(MockBehavior.Strict); - _mockTokenProvider.Setup(p => p.GetToken()).Returns(AuthToken); + _mockTokenProvider.Setup(p => p.GetTokenInfo()).Returns(new AuthToken + { + Token = AuthToken, + SiteId = SiteId, + SiteContentUrl = SiteContentUrl, + UserId = "user-luid-123", + Expiration = DateTime.UtcNow.AddHours(2) + }); - var authOptions = Options.Create(new TableauAuthOptions { SiteContentUrl = SiteContentUrl }); var tableauOptions = Options.Create(new TableauOptions { Server = Server, Version = ApiVersion }); + var requestBuilder = new TableauRequestBuilder(tableauOptions, _mockTokenProvider.Object); - _service = new WorkbookService(_mockFactory.Object, authOptions, tableauOptions, _mockTokenProvider.Object); + _service = new WorkbookService(_mockFactory.Object, requestBuilder); } [TearDown] @@ -84,20 +94,20 @@ public async Task GetAllAsync_SendsAuthTokenHeader() } [Test] - public async Task GetAllAsync_CallsTokenProvider_ToGetToken() + public async Task GetAllAsync_CallsTokenProvider_ToGetSession() { _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks") .Respond("application/json", WorkbookListJson); await _service.GetAllAsync(); - _mockTokenProvider.Verify(p => p.GetToken(), Times.Once); + _mockTokenProvider.Verify(p => p.GetTokenInfo(), Times.Once); } [Test] public void GetAllAsync_WhenTokenProviderThrows_PropagatesException() { - _mockTokenProvider.Setup(p => p.GetToken()) + _mockTokenProvider.Setup(p => p.GetTokenInfo()) .Throws(new InvalidOperationException("No Tableau auth token set. Please sign in first.")); var ex = Assert.ThrowsAsync(() => _service.GetAllAsync()); @@ -113,6 +123,18 @@ public void GetAllAsync_WhenApiReturns401_ThrowsHttpRequestException() Assert.ThrowsAsync(() => _service.GetAllAsync()); } + [Test] + public void GetAllAsync_WhenCancellationRequested_ThrowsTaskCanceledException() + { + _mockHttp.When(HttpMethod.Get, $"{SiteBase}workbooks") + .Respond("application/json", WorkbookListJson); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.ThrowsAsync(() => _service.GetAllAsync(cts.Token)); + } + // --- GetByIdAsync --- [Test]