From a6bef10d2020bb59b10544d48743a9d4dbfca6c9 Mon Sep 17 00:00:00 2001 From: Teesofttech Date: Sun, 14 Jun 2026 19:32:23 +0100 Subject: [PATCH] fix: publish tableau multipart content --- .../Http/TableauPublishContentBuilder.cs | 136 ++++++++++++++++++ .../DataSources/Services/DataSourceService.cs | 31 ++-- .../Workbooks/Services/WorkbookService.cs | 24 ++-- .../DataSources/DataSourceServiceTests.cs | 103 ++++++++++++- .../Workbooks/WorkbookServiceTests.cs | 90 +++++++++++- 5 files changed, 350 insertions(+), 34 deletions(-) create mode 100644 src/TableauSharp/Common/Http/TableauPublishContentBuilder.cs 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 bc10c0f..151c5b3 100644 --- a/src/TableauSharp/DataSources/Services/DataSourceService.cs +++ b/src/TableauSharp/DataSources/Services/DataSourceService.cs @@ -78,25 +78,18 @@ public async Task GetByIdAsync(string dataSourceId, Cancellat public async Task PublishAsync(DataSourcePublishRequest request, CancellationToken cancellationToken = default) { var client = _httpClientFactory.CreateClient("TableauClient"); - 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, cancellationToken); - 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)); - - using var httpRequest = _requestBuilder.CreateSiteRequest(HttpMethod.Post, "datasources"); - httpRequest.Content = form; + 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(); 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/DataSources/DataSourceServiceTests.cs b/test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs index 0ced910..5e44f19 100644 --- a/test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs +++ b/test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs @@ -9,6 +9,7 @@ public class DataSourceServiceTests { private SiteScopedServiceTestContext _context = null!; private DataSourceService _service = null!; + private readonly List _tempFiles = []; [SetUp] public void SetUp() @@ -18,7 +19,15 @@ public void SetUp() } [TearDown] - public void TearDown() => _context.Dispose(); + public void TearDown() + { + _context.Dispose(); + foreach (var file in _tempFiles) + { + File.Delete(file); + } + _tempFiles.Clear(); + } [Test] public async Task GetAllAsync_UsesSignedInSiteIdAndAuthHeader() @@ -54,6 +63,72 @@ public async Task RefreshAsync_UsesSignedInSiteIdAndAuthHeader() 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": [ @@ -71,4 +146,30 @@ public async Task RefreshAsync_UsesSignedInSiteIdAndAuthHeader() ] } """; + + 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/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; + } }