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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
136 changes: 136 additions & 0 deletions src/TableauSharp/Common/Http/TableauPublishContentBuilder.cs
Original file line number Diff line number Diff line change
@@ -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<string> WorkbookExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".twb",
".twbx"
};

private static readonly HashSet<string> DataSourceExtensions = new(StringComparer.OrdinalIgnoreCase)
{
".hyper",
".tds",
".tdsx",
".tde"
};

public static async Task<HttpContent> CreateWorkbookContentAsync(
string name,
string projectId,
string filePath,
CancellationToken cancellationToken)
{
ValidatePublishRequest(name, projectId, filePath, WorkbookExtensions, "Workbook");

var payload = $"""
<tsRequest>
<workbook name="{Escape(name)}">
<project id="{Escape(projectId)}" />
</workbook>
</tsRequest>
""";

return await CreateMultipartContentAsync(
payload,
"tableau_workbook",
filePath,
cancellationToken);
}

public static async Task<HttpContent> 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 = $"""
<tsRequest>
<datasource name="{Escape(name)}"{descriptionAttribute}>
<project id="{Escape(projectId)}" />
</datasource>
</tsRequest>
""";

return await CreateMultipartContentAsync(
payload,
"tableau_datasource",
filePath,
cancellationToken);
}

private static async Task<HttpContent> 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<string> 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;
}
31 changes: 12 additions & 19 deletions src/TableauSharp/DataSources/Services/DataSourceService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -78,25 +78,18 @@ public async Task<TableauDataSource> GetByIdAsync(string dataSourceId, Cancellat
public async Task<TableauDataSource> 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();

Expand Down
24 changes: 11 additions & 13 deletions src/TableauSharp/Workbooks/Services/WorkbookService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -72,19 +72,17 @@ public async Task<TableauWorkbook> 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();

Expand Down
103 changes: 102 additions & 1 deletion test/TableauSharp.Tests/DataSources/DataSourceServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ public class DataSourceServiceTests
{
private SiteScopedServiceTestContext _context = null!;
private DataSourceService _service = null!;
private readonly List<string> _tempFiles = [];

[SetUp]
public void SetUp()
Expand All @@ -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()
Expand Down Expand Up @@ -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 <trusted> 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("<datasource name=\"Sales &amp; Data\" description=\"A &lt;trusted&gt; source\">"));
Assert.That(requestBody, Does.Contain("<project id=\"proj-001\""));
Assert.That(requestBody, Does.Not.Contain("datasourceName"));
});
}

[Test]
public void PublishAsync_WhenFileMissing_ThrowsFileNotFoundException()
{
var missingPath = Path.Combine(TestContext.CurrentContext.WorkDirectory, $"{Guid.NewGuid()}.tdsx");

Assert.ThrowsAsync<FileNotFoundException>(() => _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<NotSupportedException>(() => _service.PublishAsync(new()
{
Name = "Sales Data",
ProjectId = "proj-001",
FilePath = filePath
}));

Assert.That(ex!.Message, Does.Contain("extension"));
}

private static string DataSourcesJson => """
{
"datasources": [
Expand All @@ -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;
}
}
Loading
Loading