diff --git a/src/TableauSharp/Common/Http/TableauPublishContentBuilder.cs b/src/TableauSharp/Common/Http/TableauPublishContentBuilder.cs new file mode 100644 index 0000000..fb5fe1c --- /dev/null +++ b/src/TableauSharp/Common/Http/TableauPublishContentBuilder.cs @@ -0,0 +1,136 @@ +using System.Net.Http.Headers; +using System.Security; +using System.Text; + +namespace TableauSharp.Common.Http; + +public static class TableauPublishContentBuilder +{ + private static readonly HashSet WorkbookExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".twb", + ".twbx" + }; + + private static readonly HashSet DataSourceExtensions = new(StringComparer.OrdinalIgnoreCase) + { + ".hyper", + ".tds", + ".tdsx", + ".tde" + }; + + public static async Task CreateWorkbookContentAsync( + string name, + string projectId, + string filePath, + CancellationToken cancellationToken) + { + ValidatePublishRequest(name, projectId, filePath, WorkbookExtensions, "Workbook"); + + var payload = $""" + + + + + + """; + + return await CreateMultipartContentAsync( + payload, + "tableau_workbook", + filePath, + cancellationToken); + } + + public static async Task CreateDataSourceContentAsync( + string name, + string projectId, + string? description, + string filePath, + CancellationToken cancellationToken) + { + ValidatePublishRequest(name, projectId, filePath, DataSourceExtensions, "Data source"); + + var descriptionAttribute = string.IsNullOrWhiteSpace(description) + ? string.Empty + : $" description=\"{Escape(description)}\""; + var payload = $""" + + + + + + """; + + return await CreateMultipartContentAsync( + payload, + "tableau_datasource", + filePath, + cancellationToken); + } + + private static async Task CreateMultipartContentAsync( + string payload, + string filePartName, + string filePath, + CancellationToken cancellationToken) + { + var content = new MultipartContent("mixed"); + + var payloadContent = new StringContent(payload, Encoding.UTF8, "text/xml"); + payloadContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") + { + Name = "\"request_payload\"" + }; + content.Add(payloadContent); + + var fileContent = new ByteArrayContent(await File.ReadAllBytesAsync(filePath, cancellationToken)); + fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream"); + fileContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data") + { + Name = $"\"{filePartName}\"", + FileName = $"\"{Path.GetFileName(filePath)}\"" + }; + content.Add(fileContent); + + return content; + } + + private static void ValidatePublishRequest( + string name, + string projectId, + string filePath, + HashSet allowedExtensions, + string resourceName) + { + if (string.IsNullOrWhiteSpace(name)) + { + throw new ArgumentException($"{resourceName} name is required.", nameof(name)); + } + + if (string.IsNullOrWhiteSpace(projectId)) + { + throw new ArgumentException("Project id is required.", nameof(projectId)); + } + + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("File path is required.", nameof(filePath)); + } + + if (!File.Exists(filePath)) + { + throw new FileNotFoundException("Publish file was not found.", filePath); + } + + var extension = Path.GetExtension(filePath); + if (!allowedExtensions.Contains(extension)) + { + throw new NotSupportedException($"{resourceName} file extension '{extension}' is not supported."); + } + } + + private static string Escape(string value) + => SecurityElement.Escape(value) ?? string.Empty; +} diff --git a/src/TableauSharp/DataSources/Services/DataSourceService.cs b/src/TableauSharp/DataSources/Services/DataSourceService.cs index cd27ee5..151c5b3 100644 --- a/src/TableauSharp/DataSources/Services/DataSourceService.cs +++ b/src/TableauSharp/DataSources/Services/DataSourceService.cs @@ -1,45 +1,30 @@ -using Microsoft.Extensions.Options; using System.Text.Json; -using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; using TableauSharp.DataSources.Models; -using TableauSharp.Settings; namespace TableauSharp.DataSources.Services; public class DataSourceService : IDataSourceService { private readonly IHttpClientFactory _httpClientFactory; - private readonly ITableauTokenProvider _tokenProvider; - private readonly TableauAuthOptions _authOptions; - private readonly TableauOptions _tableauOptions; + private readonly ITableauRequestBuilder _requestBuilder; public DataSourceService( IHttpClientFactory httpClientFactory, - ITableauTokenProvider tokenProvider, - IOptions authOptions, - IOptions tableauOptions) + ITableauRequestBuilder requestBuilder) { _httpClientFactory = httpClientFactory; - _tokenProvider = tokenProvider; - _authOptions = authOptions.Value; - _tableauOptions = tableauOptions.Value; + _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/{_authOptions.SiteContentUrl}/"); - client.DefaultRequestHeaders.Add("X-Tableau-Auth", _tokenProvider.GetToken()); - return client; - } - - public async Task> GetAllAsync() - { - using var client = CreateClient(); - var response = await client.GetAsync("datasources"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, "datasources"); + 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 dataSources = new List(); @@ -65,13 +50,14 @@ public async Task> GetAllAsync() return dataSources; } - public async Task GetByIdAsync(string dataSourceId) + public async Task GetByIdAsync(string dataSourceId, CancellationToken cancellationToken = default) { - using var client = CreateClient(); - var response = await client.GetAsync($"datasources/{dataSourceId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"datasources/{dataSourceId}"); + 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 ds = doc.RootElement.GetProperty("datasource"); @@ -89,30 +75,25 @@ public async Task GetByIdAsync(string dataSourceId) }; } - public async Task PublishAsync(DataSourcePublishRequest request) + public async Task PublishAsync(DataSourcePublishRequest request, CancellationToken cancellationToken = default) { - using var client = CreateClient(); - using var form = new MultipartFormDataContent(); - - form.Add(new StringContent(request.ProjectId), "projectId"); - form.Add(new StringContent(request.Name), "datasourceName"); - form.Add(new StringContent(request.Overwrite.ToString().ToLower()), "overwrite"); - - if (!string.IsNullOrEmpty(request.Description)) - { - form.Add(new StringContent(request.Description), "description"); - } - - // Attach data source file - var fileBytes = await File.ReadAllBytesAsync(request.FilePath); - var fileContent = new ByteArrayContent(fileBytes); - fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); - form.Add(fileContent, "tableau_datasource", Path.GetFileName(request.FilePath)); - - var response = await client.PostAsync("datasources", form); + var client = _httpClientFactory.CreateClient("TableauClient"); + ArgumentNullException.ThrowIfNull(request); + + using var content = await TableauPublishContentBuilder.CreateDataSourceContentAsync( + request.Name, + request.ProjectId, + request.Description, + request.FilePath, + cancellationToken); + using var httpRequest = _requestBuilder.CreateSiteRequest( + HttpMethod.Post, + $"datasources?overwrite={request.Overwrite.ToString().ToLowerInvariant()}"); + httpRequest.Content = content; + 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 ds = doc.RootElement.GetProperty("datasource"); @@ -130,17 +111,19 @@ public async Task PublishAsync(DataSourcePublishRequest reque }; } - public async Task DeleteAsync(string dataSourceId) + public async Task DeleteAsync(string dataSourceId, CancellationToken cancellationToken = default) { - using var client = CreateClient(); - var response = await client.DeleteAsync($"datasources/{dataSourceId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, $"datasources/{dataSourceId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } - public async Task RefreshAsync(string dataSourceId) + public async Task RefreshAsync(string dataSourceId, CancellationToken cancellationToken = default) { - using var client = CreateClient(); - var response = await client.PostAsync($"datasources/{dataSourceId}/refresh", null); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Post, $"datasources/{dataSourceId}/refresh"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } } diff --git a/src/TableauSharp/DataSources/Services/IDataSourceService.cs b/src/TableauSharp/DataSources/Services/IDataSourceService.cs index f0dd37a..5933843 100644 --- a/src/TableauSharp/DataSources/Services/IDataSourceService.cs +++ b/src/TableauSharp/DataSources/Services/IDataSourceService.cs @@ -10,25 +10,25 @@ public interface IDataSourceService /// /// Get all data sources in the site /// - Task> GetAllAsync(); + Task> GetAllAsync(CancellationToken cancellationToken = default); /// /// Get a specific data source by ID /// - Task GetByIdAsync(string dataSourceId); + Task GetByIdAsync(string dataSourceId, CancellationToken cancellationToken = default); /// /// Publish a data source to a project /// - Task PublishAsync(DataSourcePublishRequest request); + Task PublishAsync(DataSourcePublishRequest request, CancellationToken cancellationToken = default); /// /// Delete a data source /// - Task DeleteAsync(string dataSourceId); + Task DeleteAsync(string dataSourceId, CancellationToken cancellationToken = default); /// /// Refresh a data source extract /// - Task RefreshAsync(string dataSourceId); + Task RefreshAsync(string dataSourceId, CancellationToken cancellationToken = default); } diff --git a/src/TableauSharp/Permissions/Services/IPermissionService.cs b/src/TableauSharp/Permissions/Services/IPermissionService.cs index e6230b7..df76f3a 100644 --- a/src/TableauSharp/Permissions/Services/IPermissionService.cs +++ b/src/TableauSharp/Permissions/Services/IPermissionService.cs @@ -10,45 +10,45 @@ public interface IPermissionService /// /// Get permissions for a workbook /// - Task> GetWorkbookPermissionsAsync(string workbookId); + Task> GetWorkbookPermissionsAsync(string workbookId, CancellationToken cancellationToken = default); /// /// Get permissions for a project /// - Task> GetProjectPermissionsAsync(string projectId); + Task> GetProjectPermissionsAsync(string projectId, CancellationToken cancellationToken = default); /// /// Get permissions for a data source /// - Task> GetDataSourcePermissionsAsync(string dataSourceId); + Task> GetDataSourcePermissionsAsync(string dataSourceId, CancellationToken cancellationToken = default); /// /// Add or update permission capability for a workbook /// - Task AddWorkbookPermissionAsync(string workbookId, TableauPermission permission); + Task AddWorkbookPermissionAsync(string workbookId, TableauPermission permission, CancellationToken cancellationToken = default); /// /// Add or update permission capability for a project /// - Task AddProjectPermissionAsync(string projectId, TableauPermission permission); + Task AddProjectPermissionAsync(string projectId, TableauPermission permission, CancellationToken cancellationToken = default); /// /// Add or update permission capability for a data source /// - Task AddDataSourcePermissionAsync(string dataSourceId, TableauPermission permission); + Task AddDataSourcePermissionAsync(string dataSourceId, TableauPermission permission, CancellationToken cancellationToken = default); /// /// Delete permission for a workbook /// - Task DeleteWorkbookPermissionAsync(string workbookId, string granteeId, string granteeType, string capability); + Task DeleteWorkbookPermissionAsync(string workbookId, string granteeId, string granteeType, string capability, CancellationToken cancellationToken = default); /// /// Delete permission for a project /// - Task DeleteProjectPermissionAsync(string projectId, string granteeId, string granteeType, string capability); + Task DeleteProjectPermissionAsync(string projectId, string granteeId, string granteeType, string capability, CancellationToken cancellationToken = default); /// /// Delete permission for a data source /// - Task DeleteDataSourcePermissionAsync(string dataSourceId, string granteeId, string granteeType, string capability); + Task DeleteDataSourcePermissionAsync(string dataSourceId, string granteeId, string granteeType, string capability, CancellationToken cancellationToken = default); } diff --git a/src/TableauSharp/Permissions/Services/PermissionService.cs b/src/TableauSharp/Permissions/Services/PermissionService.cs index 81aade1..7ef7511 100644 --- a/src/TableauSharp/Permissions/Services/PermissionService.cs +++ b/src/TableauSharp/Permissions/Services/PermissionService.cs @@ -1,61 +1,46 @@ -using Microsoft.Extensions.Options; using System.Text; using System.Text.Json; -using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; using TableauSharp.Permissions.Models; -using TableauSharp.Settings; namespace TableauSharp.Permissions.Services; public class PermissionService : IPermissionService { private readonly IHttpClientFactory _httpClientFactory; - private readonly ITableauTokenProvider _tokenProvider; - private readonly TableauAuthOptions _authOptions; - private readonly TableauOptions _tableauOptions; + private readonly ITableauRequestBuilder _requestBuilder; public PermissionService( IHttpClientFactory httpClientFactory, - ITableauTokenProvider tokenProvider, - IOptions authOptions, - IOptions tableauOptions) + ITableauRequestBuilder requestBuilder) { _httpClientFactory = httpClientFactory; - _tokenProvider = tokenProvider; - _authOptions = authOptions.Value; - _tableauOptions = tableauOptions.Value; + _requestBuilder = requestBuilder; } - private HttpClient CreateClient() + public async Task> GetWorkbookPermissionsAsync(string workbookId, CancellationToken cancellationToken = default) { - var client = _httpClientFactory.CreateClient("TableauClient"); - client.BaseAddress = new Uri($"{_tableauOptions.Server}/api/{_tableauOptions.Version}/sites/{_authOptions.SiteContentUrl}/"); - client.DefaultRequestHeaders.Add("X-Tableau-Auth", _tokenProvider.GetToken()); - return client; - } - - public async Task> GetWorkbookPermissionsAsync(string workbookId) - { - return await GetPermissionsAsync($"workbooks/{workbookId}/permissions"); + return await GetPermissionsAsync($"workbooks/{workbookId}/permissions", cancellationToken); } - public async Task> GetProjectPermissionsAsync(string projectId) + public async Task> GetProjectPermissionsAsync(string projectId, CancellationToken cancellationToken = default) { - return await GetPermissionsAsync($"projects/{projectId}/permissions"); + return await GetPermissionsAsync($"projects/{projectId}/permissions", cancellationToken); } - public async Task> GetDataSourcePermissionsAsync(string dataSourceId) + public async Task> GetDataSourcePermissionsAsync(string dataSourceId, CancellationToken cancellationToken = default) { - return await GetPermissionsAsync($"datasources/{dataSourceId}/permissions"); + return await GetPermissionsAsync($"datasources/{dataSourceId}/permissions", cancellationToken); } - private async Task> GetPermissionsAsync(string endpoint) + private async Task> GetPermissionsAsync(string endpoint, CancellationToken cancellationToken) { - using var client = CreateClient(); - var response = await client.GetAsync(endpoint); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, endpoint); + 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 permissions = new List(); @@ -107,24 +92,24 @@ private async Task> GetPermissionsAsync(string en return permissions; } - public async Task AddWorkbookPermissionAsync(string workbookId, TableauPermission permission) + public async Task AddWorkbookPermissionAsync(string workbookId, TableauPermission permission, CancellationToken cancellationToken = default) { - await AddPermissionAsync($"workbooks/{workbookId}/permissions", permission); + await AddPermissionAsync($"workbooks/{workbookId}/permissions", permission, cancellationToken); } - public async Task AddProjectPermissionAsync(string projectId, TableauPermission permission) + public async Task AddProjectPermissionAsync(string projectId, TableauPermission permission, CancellationToken cancellationToken = default) { - await AddPermissionAsync($"projects/{projectId}/permissions", permission); + await AddPermissionAsync($"projects/{projectId}/permissions", permission, cancellationToken); } - public async Task AddDataSourcePermissionAsync(string dataSourceId, TableauPermission permission) + public async Task AddDataSourcePermissionAsync(string dataSourceId, TableauPermission permission, CancellationToken cancellationToken = default) { - await AddPermissionAsync($"datasources/{dataSourceId}/permissions", permission); + await AddPermissionAsync($"datasources/{dataSourceId}/permissions", permission, cancellationToken); } - private async Task AddPermissionAsync(string endpoint, TableauPermission permission) + private async Task AddPermissionAsync(string endpoint, TableauPermission permission, CancellationToken cancellationToken) { - using var client = CreateClient(); + var client = _httpClientFactory.CreateClient("TableauClient"); var payload = new { @@ -154,28 +139,30 @@ private async Task AddPermissionAsync(string endpoint, TableauPermission permiss Encoding.UTF8, "application/json"); - var response = await client.PutAsync(endpoint, jsonContent); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Put, endpoint); + request.Content = jsonContent; + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } - public async Task DeleteWorkbookPermissionAsync(string workbookId, string granteeId, string granteeType, string capability) + public async Task DeleteWorkbookPermissionAsync(string workbookId, string granteeId, string granteeType, string capability, CancellationToken cancellationToken = default) { - await DeletePermissionAsync($"workbooks/{workbookId}/permissions", granteeId, granteeType, capability); + await DeletePermissionAsync($"workbooks/{workbookId}/permissions", granteeId, granteeType, capability, cancellationToken); } - public async Task DeleteProjectPermissionAsync(string projectId, string granteeId, string granteeType, string capability) + public async Task DeleteProjectPermissionAsync(string projectId, string granteeId, string granteeType, string capability, CancellationToken cancellationToken = default) { - await DeletePermissionAsync($"projects/{projectId}/permissions", granteeId, granteeType, capability); + await DeletePermissionAsync($"projects/{projectId}/permissions", granteeId, granteeType, capability, cancellationToken); } - public async Task DeleteDataSourcePermissionAsync(string dataSourceId, string granteeId, string granteeType, string capability) + public async Task DeleteDataSourcePermissionAsync(string dataSourceId, string granteeId, string granteeType, string capability, CancellationToken cancellationToken = default) { - await DeletePermissionAsync($"datasources/{dataSourceId}/permissions", granteeId, granteeType, capability); + await DeletePermissionAsync($"datasources/{dataSourceId}/permissions", granteeId, granteeType, capability, cancellationToken); } - private async Task DeletePermissionAsync(string endpoint, string granteeId, string granteeType, string capability) + private async Task DeletePermissionAsync(string endpoint, string granteeId, string granteeType, string capability, CancellationToken cancellationToken) { - using var client = CreateClient(); + var client = _httpClientFactory.CreateClient("TableauClient"); // Validate granteeType if (!granteeType.Equals("User", StringComparison.OrdinalIgnoreCase) && @@ -187,7 +174,8 @@ private async Task DeletePermissionAsync(string endpoint, string granteeId, stri var granteeTypeParam = granteeType.ToLower() + "s"; // "users" or "groups" var url = $"{endpoint}/{granteeTypeParam}/{granteeId}/{capability}"; - var response = await client.DeleteAsync(url); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, url); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } } diff --git a/src/TableauSharp/Projects/Services/IProjectService.cs b/src/TableauSharp/Projects/Services/IProjectService.cs index c438b52..a7c943f 100644 --- a/src/TableauSharp/Projects/Services/IProjectService.cs +++ b/src/TableauSharp/Projects/Services/IProjectService.cs @@ -4,9 +4,9 @@ namespace TableauSharp.Projects.Services; public interface IProjectService { - Task> GetAllAsync(); - Task GetByIdAsync(string projectId); - Task CreateAsync(ProjectCreateRequest request); - Task UpdateAsync(string projectId, ProjectUpdateRequest request); - Task DeleteAsync(string projectId); -} \ No newline at end of file + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(string projectId, CancellationToken cancellationToken = default); + Task CreateAsync(ProjectCreateRequest request, CancellationToken cancellationToken = default); + Task UpdateAsync(string projectId, ProjectUpdateRequest request, CancellationToken cancellationToken = default); + Task DeleteAsync(string projectId, CancellationToken cancellationToken = default); +} diff --git a/src/TableauSharp/Projects/Services/ProjectService.cs b/src/TableauSharp/Projects/Services/ProjectService.cs index 9e4cee2..a651d2e 100644 --- a/src/TableauSharp/Projects/Services/ProjectService.cs +++ b/src/TableauSharp/Projects/Services/ProjectService.cs @@ -1,24 +1,29 @@ using System.Text; using System.Text.Json; +using TableauSharp.Common.Http; using TableauSharp.Projects.Models; namespace TableauSharp.Projects.Services; public class ProjectService : IProjectService { - private readonly HttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ITableauRequestBuilder _requestBuilder; - public ProjectService(HttpClient httpClient) + public ProjectService(IHttpClientFactory httpClientFactory, ITableauRequestBuilder requestBuilder) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; + _requestBuilder = requestBuilder; } - public async Task> GetAllAsync() + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - var response = await _httpClient.GetAsync("projects"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, "projects"); + 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 projects = new List(); @@ -37,12 +42,14 @@ public async Task> GetAllAsync() return projects; } - public async Task GetByIdAsync(string projectId) + public async Task GetByIdAsync(string projectId, CancellationToken cancellationToken = default) { - var response = await _httpClient.GetAsync($"projects/{projectId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"projects/{projectId}"); + 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 p = doc.RootElement.GetProperty("project"); @@ -56,15 +63,18 @@ public async Task GetByIdAsync(string projectId) }; } - public async Task CreateAsync(ProjectCreateRequest request) + public async Task CreateAsync(ProjectCreateRequest request, CancellationToken cancellationToken = default) { var payload = new { project = request }; var jsonContent = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("projects", jsonContent); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Post, "projects"); + httpRequest.Content = jsonContent; + 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 p = doc.RootElement.GetProperty("project"); @@ -78,15 +88,18 @@ public async Task CreateAsync(ProjectCreateRequest request) }; } - public async Task UpdateAsync(string projectId, ProjectUpdateRequest request) + public async Task UpdateAsync(string projectId, ProjectUpdateRequest request, CancellationToken cancellationToken = default) { var payload = new { project = request }; var jsonContent = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PutAsync($"projects/{projectId}", jsonContent); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Put, $"projects/{projectId}"); + httpRequest.Content = jsonContent; + 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 p = doc.RootElement.GetProperty("project"); @@ -100,9 +113,11 @@ public async Task UpdateAsync(string projectId, ProjectUpdateReq }; } - public async Task DeleteAsync(string projectId) + public async Task DeleteAsync(string projectId, CancellationToken cancellationToken = default) { - var response = await _httpClient.DeleteAsync($"projects/{projectId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, $"projects/{projectId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } -} \ No newline at end of file +} diff --git a/src/TableauSharp/Users/Services/GroupService.cs b/src/TableauSharp/Users/Services/GroupService.cs index 7d8c3bd..0330b7e 100644 --- a/src/TableauSharp/Users/Services/GroupService.cs +++ b/src/TableauSharp/Users/Services/GroupService.cs @@ -1,24 +1,29 @@ using System.Text; using System.Text.Json; +using TableauSharp.Common.Http; using TableauSharp.Users.Models; namespace TableauSharp.Users.Services; public class GroupService : IGroupService { - private readonly HttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ITableauRequestBuilder _requestBuilder; - public GroupService(HttpClient httpClient) + public GroupService(IHttpClientFactory httpClientFactory, ITableauRequestBuilder requestBuilder) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; + _requestBuilder = requestBuilder; } - public async Task> GetAllAsync() + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - var response = await _httpClient.GetAsync("groups"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, "groups"); + 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 groups = new List(); @@ -34,12 +39,14 @@ public async Task> GetAllAsync() return groups; } - public async Task GetByIdAsync(string groupId) + public async Task GetByIdAsync(string groupId, CancellationToken cancellationToken = default) { - var response = await _httpClient.GetAsync($"groups/{groupId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"groups/{groupId}"); + 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 g = doc.RootElement.GetProperty("group"); @@ -50,15 +57,18 @@ public async Task GetByIdAsync(string groupId) }; } - public async Task CreateAsync(GroupCreateRequest request) + public async Task CreateAsync(GroupCreateRequest request, CancellationToken cancellationToken = default) { var payload = new { group = request }; var jsonContent = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("groups", jsonContent); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Post, "groups"); + httpRequest.Content = jsonContent; + 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 g = doc.RootElement.GetProperty("group"); @@ -69,24 +79,31 @@ public async Task CreateAsync(GroupCreateRequest request) }; } - public async Task AddUserToGroupAsync(string groupId, string userId) + public async Task AddUserToGroupAsync(string groupId, string userId, CancellationToken cancellationToken = default) { var payload = new { user = new { id = userId } }; var jsonContent = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync($"groups/{groupId}/users", jsonContent); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Post, $"groups/{groupId}/users"); + request.Content = jsonContent; + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } - public async Task RemoveUserFromGroupAsync(string groupId, string userId) + public async Task RemoveUserFromGroupAsync(string groupId, string userId, CancellationToken cancellationToken = default) { - var response = await _httpClient.DeleteAsync($"groups/{groupId}/users/{userId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, $"groups/{groupId}/users/{userId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } - public async Task DeleteAsync(string groupId) + public async Task DeleteAsync(string groupId, CancellationToken cancellationToken = default) { - var response = await _httpClient.DeleteAsync($"groups/{groupId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, $"groups/{groupId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } -} \ No newline at end of file +} diff --git a/src/TableauSharp/Users/Services/IGroupService.cs b/src/TableauSharp/Users/Services/IGroupService.cs index 611a14c..c9e42bb 100644 --- a/src/TableauSharp/Users/Services/IGroupService.cs +++ b/src/TableauSharp/Users/Services/IGroupService.cs @@ -4,10 +4,10 @@ namespace TableauSharp.Users.Services; public interface IGroupService { - Task> GetAllAsync(); - Task GetByIdAsync(string groupId); - Task CreateAsync(GroupCreateRequest request); - Task AddUserToGroupAsync(string groupId, string userId); - Task RemoveUserFromGroupAsync(string groupId, string userId); - Task DeleteAsync(string groupId); -} \ No newline at end of file + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(string groupId, CancellationToken cancellationToken = default); + Task CreateAsync(GroupCreateRequest request, CancellationToken cancellationToken = default); + Task AddUserToGroupAsync(string groupId, string userId, CancellationToken cancellationToken = default); + Task RemoveUserFromGroupAsync(string groupId, string userId, CancellationToken cancellationToken = default); + Task DeleteAsync(string groupId, CancellationToken cancellationToken = default); +} diff --git a/src/TableauSharp/Users/Services/IUserService.cs b/src/TableauSharp/Users/Services/IUserService.cs index 165d8e9..0130639 100644 --- a/src/TableauSharp/Users/Services/IUserService.cs +++ b/src/TableauSharp/Users/Services/IUserService.cs @@ -4,9 +4,9 @@ namespace TableauSharp.Users.Services; public interface IUserService { - Task> GetAllAsync(); - Task GetByIdAsync(string userId); - Task CreateAsync(UserCreateRequest request); - Task UpdateAsync(string userId, UserUpdateRequest request); - Task DeleteAsync(string userId); -} \ No newline at end of file + Task> GetAllAsync(CancellationToken cancellationToken = default); + Task GetByIdAsync(string userId, CancellationToken cancellationToken = default); + Task CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default); + Task UpdateAsync(string userId, UserUpdateRequest request, CancellationToken cancellationToken = default); + Task DeleteAsync(string userId, CancellationToken cancellationToken = default); +} diff --git a/src/TableauSharp/Users/Services/UserService.cs b/src/TableauSharp/Users/Services/UserService.cs index 5f420c3..2d7bae5 100644 --- a/src/TableauSharp/Users/Services/UserService.cs +++ b/src/TableauSharp/Users/Services/UserService.cs @@ -1,24 +1,29 @@ using System.Text; using System.Text.Json; +using TableauSharp.Common.Http; using TableauSharp.Users.Models; namespace TableauSharp.Users.Services; public class UserService : IUserService { - private readonly HttpClient _httpClient; + private readonly IHttpClientFactory _httpClientFactory; + private readonly ITableauRequestBuilder _requestBuilder; - public UserService(HttpClient httpClient) + public UserService(IHttpClientFactory httpClientFactory, ITableauRequestBuilder requestBuilder) { - _httpClient = httpClient; + _httpClientFactory = httpClientFactory; + _requestBuilder = requestBuilder; } - public async Task> GetAllAsync() + public async Task> GetAllAsync(CancellationToken cancellationToken = default) { - var response = await _httpClient.GetAsync("users"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, "users"); + 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 users = new List(); @@ -36,12 +41,14 @@ public async Task> GetAllAsync() return users; } - public async Task GetByIdAsync(string userId) + public async Task GetByIdAsync(string userId, CancellationToken cancellationToken = default) { - var response = await _httpClient.GetAsync($"users/{userId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Get, $"users/{userId}"); + 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 u = doc.RootElement.GetProperty("user"); @@ -54,15 +61,18 @@ public async Task GetByIdAsync(string userId) }; } - public async Task CreateAsync(UserCreateRequest request) + public async Task CreateAsync(UserCreateRequest request, CancellationToken cancellationToken = default) { var payload = new { user = request }; var jsonContent = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PostAsync("users", jsonContent); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Post, "users"); + httpRequest.Content = jsonContent; + 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 u = doc.RootElement.GetProperty("user"); @@ -75,15 +85,18 @@ public async Task CreateAsync(UserCreateRequest request) }; } - public async Task UpdateAsync(string userId, UserUpdateRequest request) + public async Task UpdateAsync(string userId, UserUpdateRequest request, CancellationToken cancellationToken = default) { var payload = new { user = request }; var jsonContent = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json"); - var response = await _httpClient.PutAsync($"users/{userId}", jsonContent); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Put, $"users/{userId}"); + httpRequest.Content = jsonContent; + 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 u = doc.RootElement.GetProperty("user"); @@ -96,9 +109,11 @@ public async Task UpdateAsync(string userId, UserUpdateRequest requ }; } - public async Task DeleteAsync(string userId) + public async Task DeleteAsync(string userId, CancellationToken cancellationToken = default) { - var response = await _httpClient.DeleteAsync($"users/{userId}"); + var client = _httpClientFactory.CreateClient("TableauClient"); + using var request = _requestBuilder.CreateSiteRequest(HttpMethod.Delete, $"users/{userId}"); + var response = await client.SendAsync(request, cancellationToken); response.EnsureSuccessStatusCode(); } -} \ No newline at end of file +} diff --git a/src/TableauSharp/Workbooks/Services/WorkbookService.cs b/src/TableauSharp/Workbooks/Services/WorkbookService.cs index 49d4b80..6cfb602 100644 --- a/src/TableauSharp/Workbooks/Services/WorkbookService.cs +++ b/src/TableauSharp/Workbooks/Services/WorkbookService.cs @@ -72,19 +72,17 @@ public async Task PublishAsync(WorkbookPublishRequest request, { var client = _httpClientFactory.CreateClient("TableauClient"); - using var form = new MultipartFormDataContent(); - form.Add(new StringContent(request.ProjectId), "projectId"); - form.Add(new StringContent(request.Name), "workbookName"); - form.Add(new StringContent(request.Overwrite.ToString().ToLower()), "overwrite"); - - // Attach workbook file - var fileBytes = await System.IO.File.ReadAllBytesAsync(request.FilePath); - var fileContent = new ByteArrayContent(fileBytes); - fileContent.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream"); - form.Add(fileContent, "tableau_workbook", Path.GetFileName(request.FilePath)); - - using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Post, "workbooks"); - httpRequest.Content = form; + ArgumentNullException.ThrowIfNull(request); + + using var content = await TableauPublishContentBuilder.CreateWorkbookContentAsync( + request.Name, + request.ProjectId, + request.FilePath, + cancellationToken); + using var httpRequest = _requestBuilder.CreateSiteRequest( + HttpMethod.Post, + $"workbooks?overwrite={request.Overwrite.ToString().ToLowerInvariant()}"); + httpRequest.Content = content; var response = await client.SendAsync(httpRequest, cancellationToken); response.EnsureSuccessStatusCode(); diff --git a/test/TableauSharp.Tests/Common/SiteScopedServiceTestContext.cs b/test/TableauSharp.Tests/Common/SiteScopedServiceTestContext.cs new file mode 100644 index 0000000..19c0e89 --- /dev/null +++ b/test/TableauSharp.Tests/Common/SiteScopedServiceTestContext.cs @@ -0,0 +1,51 @@ +using Microsoft.Extensions.Options; +using Moq; +using RichardSzalay.MockHttp; +using TableauSharp.Common.Helper; +using TableauSharp.Common.Http; +using TableauSharp.Common.Models; +using TableauSharp.Settings; + +namespace TableauSharp.Tests.Common; + +internal sealed class SiteScopedServiceTestContext : IDisposable +{ + public const string Server = "https://tableau.example.com"; + public const string ApiVersion = "3.23"; + public const string SiteId = "site-luid-123"; + public const string AuthToken = "test-auth-token-abc"; + + public MockHttpMessageHandler MockHttp { get; } = new(); + public IHttpClientFactory HttpClientFactory { get; } + public ITableauRequestBuilder RequestBuilder { get; } + public string SiteBase => $"{Server}/api/{ApiVersion}/sites/{SiteId}/"; + + public SiteScopedServiceTestContext() + { + var httpClient = MockHttp.ToHttpClient(); + + var factory = new Mock(MockBehavior.Strict); + factory.Setup(f => f.CreateClient("TableauClient")).Returns(httpClient); + HttpClientFactory = factory.Object; + + var tokenProvider = new Mock(MockBehavior.Strict); + tokenProvider.Setup(p => p.GetTokenInfo()).Returns(new AuthToken + { + Token = AuthToken, + SiteId = SiteId, + SiteContentUrl = "friendly-site", + UserId = "user-luid-123", + Expiration = DateTime.UtcNow.AddHours(2) + }); + + RequestBuilder = new TableauRequestBuilder( + Options.Create(new TableauOptions { Server = Server, Version = ApiVersion }), + tokenProvider.Object); + } + + public static bool HasAuthHeader(HttpRequestMessage request) + => request.Headers.TryGetValues("X-Tableau-Auth", out var values) + && values.SingleOrDefault() == AuthToken; + + public void Dispose() => MockHttp.Dispose(); +} diff --git a/test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs b/test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs new file mode 100644 index 0000000..5e44f19 --- /dev/null +++ b/test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs @@ -0,0 +1,175 @@ +using RichardSzalay.MockHttp; +using TableauSharp.DataSources.Services; +using TableauSharp.Tests.Common; + +namespace TableauSharp.Tests.DataSources; + +[TestFixture] +public class DataSourceServiceTests +{ + private SiteScopedServiceTestContext _context = null!; + private DataSourceService _service = null!; + private readonly List _tempFiles = []; + + [SetUp] + public void SetUp() + { + _context = new SiteScopedServiceTestContext(); + _service = new DataSourceService(_context.HttpClientFactory, _context.RequestBuilder); + } + + [TearDown] + public void TearDown() + { + _context.Dispose(); + foreach (var file in _tempFiles) + { + File.Delete(file); + } + _tempFiles.Clear(); + } + + [Test] + public async Task GetAllAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Get, $"{_context.SiteBase}datasources") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", DataSourcesJson); + + var result = (await _service.GetAllAsync()).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo("ds-001")); + }); + } + + [Test] + public async Task RefreshAsync_UsesSignedInSiteIdAndAuthHeader() + { + var called = false; + + _context.MockHttp.When(HttpMethod.Post, $"{_context.SiteBase}datasources/ds-001/refresh") + .With(req => + { + called = SiteScopedServiceTestContext.HasAuthHeader(req); + return called; + }) + .Respond(System.Net.HttpStatusCode.Accepted); + + await _service.RefreshAsync("ds-001"); + + Assert.That(called, Is.True); + } + + [Test] + public async Task PublishAsync_SendsTableauMultipartMixedRequest() + { + var filePath = CreateTempFile("datasource.tdsx", "datasource-content"); + string? contentType = null; + string? requestBody = null; + + _context.MockHttp.When(HttpMethod.Post, $"{_context.SiteBase}datasources?overwrite=true") + .With(req => + { + contentType = req.Content!.Headers.ContentType?.MediaType; + requestBody = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return SiteScopedServiceTestContext.HasAuthHeader(req); + }) + .Respond("application/json", SingleDataSourceJson); + + var result = await _service.PublishAsync(new() + { + Name = "Sales & Data", + ProjectId = "proj-001", + Description = "A source", + FilePath = filePath, + Overwrite = true + }); + + Assert.Multiple(() => + { + Assert.That(result.Id, Is.EqualTo("ds-001")); + Assert.That(contentType, Is.EqualTo("multipart/mixed")); + Assert.That(requestBody, Does.Contain("request_payload")); + Assert.That(requestBody, Does.Contain("tableau_datasource")); + Assert.That(requestBody, Does.Contain("filename=\"datasource.tdsx\"")); + Assert.That(requestBody, Does.Contain("")); + Assert.That(requestBody, Does.Contain("(() => _service.PublishAsync(new() + { + Name = "Sales Data", + ProjectId = "proj-001", + FilePath = missingPath + })); + } + + [Test] + public void PublishAsync_WhenDataSourceExtensionUnsupported_ThrowsNotSupportedException() + { + var filePath = CreateTempFile("datasource.csv", "not-a-supported-datasource"); + + var ex = Assert.ThrowsAsync(() => _service.PublishAsync(new() + { + Name = "Sales Data", + ProjectId = "proj-001", + FilePath = filePath + })); + + Assert.That(ex!.Message, Does.Contain("extension")); + } + + private static string DataSourcesJson => """ + { + "datasources": [ + { + "id": "ds-001", + "name": "Sales Data", + "project": { "id": "proj-001" }, + "owner": { "id": "user-001" }, + "type": "hyper", + "createdAt": "2024-01-15T10:00:00Z", + "updatedAt": "2024-06-01T08:30:00Z", + "isCertified": true, + "contentUrl": "sales-data" + } + ] + } + """; + + private static string SingleDataSourceJson => """ + { + "datasource": { + "id": "ds-001", + "name": "Sales Data", + "project": { "id": "proj-001" }, + "owner": { "id": "user-001" }, + "type": "hyper", + "createdAt": "2024-01-15T10:00:00Z", + "updatedAt": "2024-06-01T08:30:00Z", + "isCertified": true, + "contentUrl": "sales-data" + } + } + """; + + private string CreateTempFile(string fileName, string content) + { + var directory = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString()); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, fileName); + File.WriteAllText(path, content); + _tempFiles.Add(path); + return path; + } +} diff --git a/test/TableauSharp.Tests/Permissions/PermissionServiceTests.cs b/test/TableauSharp.Tests/Permissions/PermissionServiceTests.cs new file mode 100644 index 0000000..05443e7 --- /dev/null +++ b/test/TableauSharp.Tests/Permissions/PermissionServiceTests.cs @@ -0,0 +1,92 @@ +using RichardSzalay.MockHttp; +using TableauSharp.Permissions.Models; +using TableauSharp.Permissions.Services; +using TableauSharp.Tests.Common; + +namespace TableauSharp.Tests.Permissions; + +[TestFixture] +public class PermissionServiceTests +{ + private SiteScopedServiceTestContext _context = null!; + private PermissionService _service = null!; + + [SetUp] + public void SetUp() + { + _context = new SiteScopedServiceTestContext(); + _service = new PermissionService(_context.HttpClientFactory, _context.RequestBuilder); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + [Test] + public async Task GetWorkbookPermissionsAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Get, $"{_context.SiteBase}workbooks/wb-001/permissions") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", PermissionsJson); + + var result = (await _service.GetWorkbookPermissionsAsync("wb-001")).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].GranteeId, Is.EqualTo("user-001")); + }); + } + + [Test] + public async Task DeleteProjectPermissionAsync_UsesSignedInSiteIdAndAuthHeader() + { + var called = false; + + _context.MockHttp.When(HttpMethod.Delete, $"{_context.SiteBase}projects/proj-001/permissions/users/user-001/Read") + .With(req => + { + called = SiteScopedServiceTestContext.HasAuthHeader(req); + return called; + }) + .Respond(System.Net.HttpStatusCode.NoContent); + + await _service.DeleteProjectPermissionAsync("proj-001", "user-001", "User", "Read"); + + Assert.That(called, Is.True); + } + + [Test] + public async Task AddDataSourcePermissionAsync_UsesSignedInSiteIdAndAuthHeader() + { + var permission = new TableauPermission + { + GranteeId = "group-001", + GranteeType = "Group" + }; + + _context.MockHttp.When(HttpMethod.Put, $"{_context.SiteBase}datasources/ds-001/permissions") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond(System.Net.HttpStatusCode.OK); + + await _service.AddDataSourcePermissionAsync("ds-001", permission); + + Assert.Pass(); + } + + private static string PermissionsJson => """ + { + "permissions": { + "granteeCapabilities": [ + { + "user": { "id": "user-001" }, + "capabilities": { + "capability": [ + { "name": "Read", "mode": "Allow" } + ] + } + } + ] + } + } + """; +} diff --git a/test/TableauSharp.Tests/Projects/ProjectServiceTests.cs b/test/TableauSharp.Tests/Projects/ProjectServiceTests.cs new file mode 100644 index 0000000..d103d3c --- /dev/null +++ b/test/TableauSharp.Tests/Projects/ProjectServiceTests.cs @@ -0,0 +1,79 @@ +using RichardSzalay.MockHttp; +using TableauSharp.Projects.Models; +using TableauSharp.Projects.Services; +using TableauSharp.Tests.Common; + +namespace TableauSharp.Tests.Projects; + +[TestFixture] +public class ProjectServiceTests +{ + private SiteScopedServiceTestContext _context = null!; + private ProjectService _service = null!; + + [SetUp] + public void SetUp() + { + _context = new SiteScopedServiceTestContext(); + _service = new ProjectService(_context.HttpClientFactory, _context.RequestBuilder); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + [Test] + public async Task GetAllAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Get, $"{_context.SiteBase}projects") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", ProjectsJson); + + var result = (await _service.GetAllAsync()).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo("proj-001")); + }); + } + + [Test] + public async Task UpdateAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Put, $"{_context.SiteBase}projects/proj-001") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", SingleProjectJson); + + var result = await _service.UpdateAsync("proj-001", new ProjectUpdateRequest + { + Name = "Renamed", + Description = "Updated" + }); + + Assert.That(result.Id, Is.EqualTo("proj-001")); + } + + private static string ProjectsJson => """ + { + "projects": [ + { + "id": "proj-001", + "name": "Default", + "description": "Project", + "owner": { "id": "user-001" } + } + ] + } + """; + + private static string SingleProjectJson => """ + { + "project": { + "id": "proj-001", + "name": "Renamed", + "description": "Updated", + "owner": { "id": "user-001" } + } + } + """; +} diff --git a/test/TableauSharp.Tests/Users/GroupServiceTests.cs b/test/TableauSharp.Tests/Users/GroupServiceTests.cs new file mode 100644 index 0000000..0efcd66 --- /dev/null +++ b/test/TableauSharp.Tests/Users/GroupServiceTests.cs @@ -0,0 +1,67 @@ +using RichardSzalay.MockHttp; +using TableauSharp.Tests.Common; +using TableauSharp.Users.Services; + +namespace TableauSharp.Tests.Users; + +[TestFixture] +public class GroupServiceTests +{ + private SiteScopedServiceTestContext _context = null!; + private GroupService _service = null!; + + [SetUp] + public void SetUp() + { + _context = new SiteScopedServiceTestContext(); + _service = new GroupService(_context.HttpClientFactory, _context.RequestBuilder); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + [Test] + public async Task GetAllAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Get, $"{_context.SiteBase}groups") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", GroupsJson); + + var result = (await _service.GetAllAsync()).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo("group-001")); + }); + } + + [Test] + public async Task AddUserToGroupAsync_UsesSignedInSiteIdAndAuthHeader() + { + var called = false; + + _context.MockHttp.When(HttpMethod.Post, $"{_context.SiteBase}groups/group-001/users") + .With(req => + { + called = SiteScopedServiceTestContext.HasAuthHeader(req); + return called; + }) + .Respond(System.Net.HttpStatusCode.NoContent); + + await _service.AddUserToGroupAsync("group-001", "user-001"); + + Assert.That(called, Is.True); + } + + private static string GroupsJson => """ + { + "groups": [ + { + "id": "group-001", + "name": "Analysts" + } + ] + } + """; +} diff --git a/test/TableauSharp.Tests/Users/UserServiceTests.cs b/test/TableauSharp.Tests/Users/UserServiceTests.cs new file mode 100644 index 0000000..c44a8db --- /dev/null +++ b/test/TableauSharp.Tests/Users/UserServiceTests.cs @@ -0,0 +1,75 @@ +using RichardSzalay.MockHttp; +using TableauSharp.Tests.Common; +using TableauSharp.Users.Models; +using TableauSharp.Users.Services; + +namespace TableauSharp.Tests.Users; + +[TestFixture] +public class UserServiceTests +{ + private SiteScopedServiceTestContext _context = null!; + private UserService _service = null!; + + [SetUp] + public void SetUp() + { + _context = new SiteScopedServiceTestContext(); + _service = new UserService(_context.HttpClientFactory, _context.RequestBuilder); + } + + [TearDown] + public void TearDown() => _context.Dispose(); + + [Test] + public async Task GetAllAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Get, $"{_context.SiteBase}users") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", UsersJson); + + var result = (await _service.GetAllAsync()).ToList(); + + Assert.Multiple(() => + { + Assert.That(result, Has.Count.EqualTo(1)); + Assert.That(result[0].Id, Is.EqualTo("user-001")); + }); + } + + [Test] + public async Task UpdateAsync_UsesSignedInSiteIdAndAuthHeader() + { + _context.MockHttp.When(HttpMethod.Put, $"{_context.SiteBase}users/user-001") + .With(SiteScopedServiceTestContext.HasAuthHeader) + .Respond("application/json", SingleUserJson); + + var result = await _service.UpdateAsync("user-001", new UserUpdateRequest { SiteRole = "Viewer" }); + + Assert.That(result.Id, Is.EqualTo("user-001")); + } + + private static string UsersJson => """ + { + "users": [ + { + "id": "user-001", + "name": "alice", + "email": "alice@example.com", + "siteRole": "Viewer" + } + ] + } + """; + + private static string SingleUserJson => """ + { + "user": { + "id": "user-001", + "name": "alice", + "email": "alice@example.com", + "siteRole": "Viewer" + } + } + """; +} diff --git a/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs b/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs index 355d89d..58e304d 100644 --- a/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs +++ b/test/TableauSharp.Tests/Workbooks/WorkbookServiceTests.cs @@ -25,6 +25,7 @@ public class WorkbookServiceTests private Mock _mockFactory = null!; private Mock _mockTokenProvider = null!; private WorkbookService _service = null!; + private readonly List _tempFiles = []; [SetUp] public void SetUp() @@ -53,7 +54,15 @@ public void SetUp() } [TearDown] - public void TearDown() => _mockHttp.Dispose(); + public void TearDown() + { + _mockHttp.Dispose(); + foreach (var file in _tempFiles) + { + File.Delete(file); + } + _tempFiles.Clear(); + } // --- GetAllAsync --- @@ -178,6 +187,75 @@ public void GetByIdAsync_WhenApiReturns404_ThrowsHttpRequestException() Assert.ThrowsAsync(() => _service.GetByIdAsync("missing")); } + // --- PublishAsync --- + + [Test] + public async Task PublishAsync_SendsTableauMultipartMixedRequest() + { + var filePath = CreateTempFile("workbook.twbx", "workbook-content"); + string? contentType = null; + string? requestBody = null; + + _mockHttp.When(HttpMethod.Post, $"{SiteBase}workbooks?overwrite=true") + .With(req => + { + contentType = req.Content!.Headers.ContentType?.MediaType; + requestBody = req.Content.ReadAsStringAsync().GetAwaiter().GetResult(); + return true; + }) + .Respond("application/json", SingleWorkbookJson("wb-001", "Published Workbook")); + + var result = await _service.PublishAsync(new WorkbookPublishRequest + { + Name = "Published & Workbook", + ProjectId = "proj-1", + FilePath = filePath, + Overwrite = true + }); + + Assert.Multiple(() => + { + Assert.That(result.Id, Is.EqualTo("wb-001")); + Assert.That(contentType, Is.EqualTo("multipart/mixed")); + Assert.That(requestBody, Does.Contain("request_payload")); + Assert.That(requestBody, Does.Contain("tableau_workbook")); + Assert.That(requestBody, Does.Contain("filename=\"workbook.twbx\"")); + Assert.That(requestBody, Does.Contain("")); + Assert.That(requestBody, Does.Contain("(() => _service.PublishAsync(new WorkbookPublishRequest + { + Name = "Workbook", + ProjectId = "proj-1", + FilePath = filePath + })); + + Assert.That(ex!.Message, Does.Contain("extension")); + } + + [Test] + public void PublishAsync_WhenProjectIdMissing_ThrowsArgumentException() + { + var filePath = CreateTempFile("workbook.twbx", "workbook-content"); + + var ex = Assert.ThrowsAsync(() => _service.PublishAsync(new WorkbookPublishRequest + { + Name = "Workbook", + ProjectId = "", + FilePath = filePath + })); + + Assert.That(ex!.Message, Does.Contain("Project id")); + } + // --- DeleteAsync --- [Test] @@ -251,4 +329,14 @@ private static string SingleWorkbookJson(string id, string name) => $$""" } } """; + + private string CreateTempFile(string fileName, string content) + { + var directory = Path.Combine(TestContext.CurrentContext.WorkDirectory, Guid.NewGuid().ToString()); + Directory.CreateDirectory(directory); + var path = Path.Combine(directory, fileName); + File.WriteAllText(path, content); + _tempFiles.Add(path); + return path; + } }