|
| 1 | +using ThoughtSpot.Client; |
| 2 | +using ThoughtSpot.Client.Api; |
| 3 | +using ThoughtSpot.Client.Client; |
| 4 | +using ThoughtSpot.Client.Model; |
| 5 | + |
| 6 | +// ───────────────────────────────────────────────────────────────────────────── |
| 7 | +// ThoughtSpot C# SDK — full-stack example (backend) |
| 8 | +// |
| 9 | +// A tiny ASP.NET Core minimal API that wraps the ThoughtSpot.Client SDK and |
| 10 | +// exposes six JSON/file endpoints for the React frontend in ../frontend: |
| 11 | +// |
| 12 | +// POST /api/users — create a user |
| 13 | +// POST /api/style — update style customization |
| 14 | +// GET /api/users — search users |
| 15 | +// GET /api/liveboards — search liveboards |
| 16 | +// GET /api/liveboards/{id}/export — export a liveboard as PDF |
| 17 | +// GET /api/liveboards/{id}/tml — export a liveboard's TML |
| 18 | +// |
| 19 | +// Run with: dotnet run (from this backend/ folder) |
| 20 | +// Config via env vars: TS_HOST, TS_USER, TS_PASS (see defaults below). |
| 21 | +// |
| 22 | +// Uses ThoughtSpotRestApi.CreateAsync(...) instead of the legacy |
| 23 | +// HttpClient/HttpClientHandler constructors. CreateAsync builds its own |
| 24 | +// SocketsHttpHandler/HttpClient internally, which is what actually gives you |
| 25 | +// ConnectTimeout / ReadTimeout / WriteTimeout, connection pooling, SSL |
| 26 | +// handling, and automatic bearer-token fetch + refresh (TokenInjectingHandler). |
| 27 | +// None of that is wired up if you build the client from your own |
| 28 | +// HttpClient/HttpClientHandler via the legacy constructors. |
| 29 | +// ───────────────────────────────────────────────────────────────────────────── |
| 30 | + |
| 31 | +string host = ThoughtSpotConfiguration.NormalizeHost(Environment.GetEnvironmentVariable("TS_HOST")); |
| 32 | +string user = Environment.GetEnvironmentVariable("TS_USER") ?? ""; |
| 33 | +string pass = Environment.GetEnvironmentVariable("TS_PASS") ?? ""; |
| 34 | + |
| 35 | +var api = await ThoughtSpotRestApi.CreateAsync(new ApiClientConfiguration |
| 36 | +{ |
| 37 | + Host = host, |
| 38 | + Username = user, |
| 39 | + Password = pass, |
| 40 | + TokenValiditySeconds = 3600, |
| 41 | + // Self-signed dev/demo clusters. Remove for production. |
| 42 | + IgnoreSslErrors = true, |
| 43 | +}); |
| 44 | + |
| 45 | +var builder = WebApplication.CreateBuilder(args); |
| 46 | +builder.Services.AddCors(options => |
| 47 | +{ |
| 48 | + options.AddDefaultPolicy(policy => |
| 49 | + policy.WithOrigins( |
| 50 | + "http://localhost:5173", |
| 51 | + "http://127.0.0.1:5173", |
| 52 | + "http://0.0.0.0:5173") |
| 53 | + .AllowAnyHeader() |
| 54 | + .AllowAnyMethod()); |
| 55 | +}); |
| 56 | + |
| 57 | +var app = builder.Build(); |
| 58 | +app.UseCors(); |
| 59 | + |
| 60 | +app.MapGet("/api/health", () => Results.Ok(new { host })); |
| 61 | + |
| 62 | +// 1. User creation ------------------------------------------------------------ |
| 63 | +app.MapPost("/api/users", (CreateUserBody body) => |
| 64 | +{ |
| 65 | + var request = new CreateUserRequest( |
| 66 | + name: body.Name, |
| 67 | + displayName: body.DisplayName, |
| 68 | + password: body.Password, |
| 69 | + email: body.Email, |
| 70 | + accountType: CreateUserRequest.AccountTypeEnum.LOCALUSER, |
| 71 | + accountStatus: CreateUserRequest.AccountStatusEnum.ACTIVE, |
| 72 | + triggerWelcomeEmail: false, |
| 73 | + triggerActivationEmail: false); |
| 74 | + |
| 75 | + try |
| 76 | + { |
| 77 | + User created = api.CreateUser(request); |
| 78 | + return Results.Ok(new { id = created.Id, name = created.Name }); |
| 79 | + } |
| 80 | + catch (ApiException ex) |
| 81 | + { |
| 82 | + return Results.Json(new { error = ex.Message }, statusCode: ex.ErrorCode); |
| 83 | + } |
| 84 | +}); |
| 85 | + |
| 86 | +// 2. Style customization ------------------------------------------------------- |
| 87 | +app.MapPost("/api/style", (StyleBody body) => |
| 88 | +{ |
| 89 | + try |
| 90 | + { |
| 91 | + api.UpdateStyleCustomization( |
| 92 | + scope: "ORG", |
| 93 | + operation: "REPLACE", |
| 94 | + navigationPanel: new NavigationPanelInput( |
| 95 | + theme: NavigationPanelInput.ThemeEnum.CUSTOM, |
| 96 | + baseColor: body.BaseColor), |
| 97 | + embeddedFooterText: body.FooterText); |
| 98 | + |
| 99 | + return Results.Ok(new { success = true }); |
| 100 | + } |
| 101 | + catch (ApiException ex) |
| 102 | + { |
| 103 | + return Results.Json(new { error = ex.Message }, statusCode: ex.ErrorCode); |
| 104 | + } |
| 105 | +}); |
| 106 | + |
| 107 | +// 3. Search users --------------------------------------------------------------- |
| 108 | +app.MapGet("/api/users", (string? query, int size) => |
| 109 | +{ |
| 110 | + string? namePattern = string.IsNullOrWhiteSpace(query) ? null : $"%{query}%"; |
| 111 | + var request = new SearchUsersRequest( |
| 112 | + namePattern: namePattern ?? null!, |
| 113 | + recordSize: size <= 0 ? 10 : size); |
| 114 | + |
| 115 | + List<User> users = api.SearchUsers(request); |
| 116 | + return Results.Ok(users.Select(u => new { id = u.Id, name = u.Name, displayName = u.DisplayName })); |
| 117 | +}); |
| 118 | + |
| 119 | +// 4. Search liveboards ------------------------------------------------------------ |
| 120 | +app.MapGet("/api/liveboards", (string? query, int size) => |
| 121 | +{ |
| 122 | + string? namePattern = string.IsNullOrWhiteSpace(query) ? null : $"%{query}%"; |
| 123 | + var metadataItem = new MetadataListItemInput( |
| 124 | + type: MetadataListItemInput.TypeEnum.LIVEBOARD, |
| 125 | + namePattern: namePattern ?? null!); |
| 126 | + |
| 127 | + var request = new SearchMetadataRequest( |
| 128 | + metadata: new List<MetadataListItemInput> { metadataItem }, |
| 129 | + recordSize: size <= 0 ? 10 : size); |
| 130 | + |
| 131 | + List<MetadataSearchResponse> results = api.SearchMetadata(request); |
| 132 | + return Results.Ok(results.Select(r => new { id = r.MetadataId, name = r.MetadataName })); |
| 133 | +}); |
| 134 | + |
| 135 | +// 5. Export liveboard (PDF) -------------------------------------------------------- |
| 136 | +app.MapGet("/api/liveboards/{id}/export", (string id) => |
| 137 | +{ |
| 138 | + var request = new ExportLiveboardReportRequest( |
| 139 | + metadataIdentifier: id, |
| 140 | + fileFormat: ExportLiveboardReportRequest.FileFormatEnum.PDF); |
| 141 | + |
| 142 | + try |
| 143 | + { |
| 144 | + FileParameter file = api.ExportLiveboardReport(request); |
| 145 | + using var ms = new MemoryStream(); |
| 146 | + file.Content.CopyTo(ms); |
| 147 | + // Explicit content-length + attachment disposition so the browser's |
| 148 | + // fetch()/blob download on the frontend gets a well-formed response |
| 149 | + // instead of a chunked stream with no size hint. |
| 150 | + var bytes = ms.ToArray(); |
| 151 | + return Results.Bytes(bytes, "application/pdf", $"liveboard-{id}.pdf"); |
| 152 | + } |
| 153 | + catch (ApiException ex) |
| 154 | + { |
| 155 | + return Results.Json(new { error = ex.Message }, statusCode: ex.ErrorCode); |
| 156 | + } |
| 157 | +}); |
| 158 | + |
| 159 | +// 6. Export TML --------------------------------------------------------------------- |
| 160 | +app.MapGet("/api/liveboards/{id}/tml", (string id) => |
| 161 | +{ |
| 162 | + var request = new ExportMetadataTMLRequest( |
| 163 | + metadata: new List<ExportMetadataTypeInput> |
| 164 | + { |
| 165 | + new ExportMetadataTypeInput(type: ExportMetadataTypeInput.TypeEnum.LIVEBOARD, identifier: id) |
| 166 | + }, |
| 167 | + exportFqn: true, |
| 168 | + edocFormat: ExportMetadataTMLRequest.EdocFormatEnum.JSON); |
| 169 | + |
| 170 | + try |
| 171 | + { |
| 172 | + List<Object> tml = api.ExportMetadataTML(request); |
| 173 | + return Results.Ok(tml); |
| 174 | + } |
| 175 | + catch (ApiException ex) |
| 176 | + { |
| 177 | + return Results.Json(new { error = ex.Message }, statusCode: ex.ErrorCode); |
| 178 | + } |
| 179 | +}); |
| 180 | + |
| 181 | +app.Run(); |
| 182 | + |
| 183 | +public static class ThoughtSpotConfiguration |
| 184 | +{ |
| 185 | + public static string NormalizeHost(string? host) |
| 186 | + { |
| 187 | + if (string.IsNullOrWhiteSpace(host)) |
| 188 | + { |
| 189 | + return "https://try-everywhere.thoughtspot.cloud"; |
| 190 | + } |
| 191 | + |
| 192 | + var trimmed = host.Trim(); |
| 193 | + if (!trimmed.StartsWith("http://", StringComparison.OrdinalIgnoreCase) && |
| 194 | + !trimmed.StartsWith("https://", StringComparison.OrdinalIgnoreCase)) |
| 195 | + { |
| 196 | + trimmed = $"https://{trimmed}"; |
| 197 | + } |
| 198 | + |
| 199 | + if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)) |
| 200 | + { |
| 201 | + throw new ArgumentException($"The TS_HOST value '{host}' is not a valid URL.", nameof(host)); |
| 202 | + } |
| 203 | + |
| 204 | + var path = uri.AbsolutePath.Trim('/'); |
| 205 | + if (path.Equals("v2", StringComparison.OrdinalIgnoreCase)) |
| 206 | + { |
| 207 | + path = string.Empty; |
| 208 | + } |
| 209 | + |
| 210 | + var builder = new UriBuilder(uri) |
| 211 | + { |
| 212 | + Path = string.IsNullOrEmpty(path) ? string.Empty : $"/{path}", |
| 213 | + Query = string.Empty, |
| 214 | + Fragment = string.Empty |
| 215 | + }; |
| 216 | + |
| 217 | + return builder.Uri.GetLeftPart(UriPartial.Authority) + (builder.Path.Length > 1 ? builder.Path : string.Empty); |
| 218 | + } |
| 219 | +} |
| 220 | + |
| 221 | +record CreateUserBody(string Name, string DisplayName, string Email, string Password); |
| 222 | +record StyleBody(string BaseColor, string FooterText); |
0 commit comments