Skip to content

Commit 37dbf89

Browse files
authored
Merge pull request #9 from thiagoluga/feat/xlsx-s3
feat(xlsx-s3): XLSX (ClosedXML) + S3 + multi-output numa passada
2 parents 2319e56 + 4c7a283 commit 37dbf89

21 files changed

Lines changed: 950 additions & 5 deletions

.gitignore

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,3 +61,8 @@ desktop.ini
6161
.env.local
6262
appsettings.*.local.json
6363
secrets.json
64+
65+
# Scratch files used by the local build/test loop (never committed)
66+
_*.log
67+
_*.txt
68+
*.flag

NeoReports.sln

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,16 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Destinations.Loc
3131
EndProject
3232
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Sources.Sql.IntegrationTests", "tests\NeoReports.Sources.Sql.IntegrationTests\NeoReports.Sources.Sql.IntegrationTests.csproj", "{EBCE7C5A-C4D5-45B2-962B-0DE16BE1C8EE}"
3333
EndProject
34+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Formats.Xlsx", "src\Formats\NeoReports.Formats.Xlsx\NeoReports.Formats.Xlsx.csproj", "{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}"
35+
EndProject
36+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Destinations.S3", "src\Destinations\NeoReports.Destinations.S3\NeoReports.Destinations.S3.csproj", "{66FF3152-38BF-4830-9534-3F77642E651B}"
37+
EndProject
38+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Formats.Xlsx.UnitTests", "tests\NeoReports.Formats.Xlsx.UnitTests\NeoReports.Formats.Xlsx.UnitTests.csproj", "{A96127D0-8777-4B00-AB8A-B59BAC0B3342}"
39+
EndProject
40+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Destinations.S3.UnitTests", "tests\NeoReports.Destinations.S3.UnitTests\NeoReports.Destinations.S3.UnitTests.csproj", "{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}"
41+
EndProject
42+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "02-sql-to-xlsx-s3", "samples\02-sql-to-xlsx-s3\02-sql-to-xlsx-s3.csproj", "{34079216-845C-417E-8511-FDD3F075CDCD}"
43+
EndProject
3444
Global
3545
GlobalSection(SolutionConfigurationPlatforms) = preSolution
3646
Debug|Any CPU = Debug|Any CPU
@@ -161,6 +171,66 @@ Global
161171
{EBCE7C5A-C4D5-45B2-962B-0DE16BE1C8EE}.Release|x64.Build.0 = Release|Any CPU
162172
{EBCE7C5A-C4D5-45B2-962B-0DE16BE1C8EE}.Release|x86.ActiveCfg = Release|Any CPU
163173
{EBCE7C5A-C4D5-45B2-962B-0DE16BE1C8EE}.Release|x86.Build.0 = Release|Any CPU
174+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
175+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Debug|Any CPU.Build.0 = Debug|Any CPU
176+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Debug|x64.ActiveCfg = Debug|Any CPU
177+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Debug|x64.Build.0 = Debug|Any CPU
178+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Debug|x86.ActiveCfg = Debug|Any CPU
179+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Debug|x86.Build.0 = Debug|Any CPU
180+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Release|Any CPU.ActiveCfg = Release|Any CPU
181+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Release|Any CPU.Build.0 = Release|Any CPU
182+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Release|x64.ActiveCfg = Release|Any CPU
183+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Release|x64.Build.0 = Release|Any CPU
184+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Release|x86.ActiveCfg = Release|Any CPU
185+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB}.Release|x86.Build.0 = Release|Any CPU
186+
{66FF3152-38BF-4830-9534-3F77642E651B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
187+
{66FF3152-38BF-4830-9534-3F77642E651B}.Debug|Any CPU.Build.0 = Debug|Any CPU
188+
{66FF3152-38BF-4830-9534-3F77642E651B}.Debug|x64.ActiveCfg = Debug|Any CPU
189+
{66FF3152-38BF-4830-9534-3F77642E651B}.Debug|x64.Build.0 = Debug|Any CPU
190+
{66FF3152-38BF-4830-9534-3F77642E651B}.Debug|x86.ActiveCfg = Debug|Any CPU
191+
{66FF3152-38BF-4830-9534-3F77642E651B}.Debug|x86.Build.0 = Debug|Any CPU
192+
{66FF3152-38BF-4830-9534-3F77642E651B}.Release|Any CPU.ActiveCfg = Release|Any CPU
193+
{66FF3152-38BF-4830-9534-3F77642E651B}.Release|Any CPU.Build.0 = Release|Any CPU
194+
{66FF3152-38BF-4830-9534-3F77642E651B}.Release|x64.ActiveCfg = Release|Any CPU
195+
{66FF3152-38BF-4830-9534-3F77642E651B}.Release|x64.Build.0 = Release|Any CPU
196+
{66FF3152-38BF-4830-9534-3F77642E651B}.Release|x86.ActiveCfg = Release|Any CPU
197+
{66FF3152-38BF-4830-9534-3F77642E651B}.Release|x86.Build.0 = Release|Any CPU
198+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
199+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Debug|Any CPU.Build.0 = Debug|Any CPU
200+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Debug|x64.ActiveCfg = Debug|Any CPU
201+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Debug|x64.Build.0 = Debug|Any CPU
202+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Debug|x86.ActiveCfg = Debug|Any CPU
203+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Debug|x86.Build.0 = Debug|Any CPU
204+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Release|Any CPU.ActiveCfg = Release|Any CPU
205+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Release|Any CPU.Build.0 = Release|Any CPU
206+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Release|x64.ActiveCfg = Release|Any CPU
207+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Release|x64.Build.0 = Release|Any CPU
208+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Release|x86.ActiveCfg = Release|Any CPU
209+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342}.Release|x86.Build.0 = Release|Any CPU
210+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
211+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Debug|Any CPU.Build.0 = Debug|Any CPU
212+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Debug|x64.ActiveCfg = Debug|Any CPU
213+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Debug|x64.Build.0 = Debug|Any CPU
214+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Debug|x86.ActiveCfg = Debug|Any CPU
215+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Debug|x86.Build.0 = Debug|Any CPU
216+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Release|Any CPU.ActiveCfg = Release|Any CPU
217+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Release|Any CPU.Build.0 = Release|Any CPU
218+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Release|x64.ActiveCfg = Release|Any CPU
219+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Release|x64.Build.0 = Release|Any CPU
220+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Release|x86.ActiveCfg = Release|Any CPU
221+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5}.Release|x86.Build.0 = Release|Any CPU
222+
{34079216-845C-417E-8511-FDD3F075CDCD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
223+
{34079216-845C-417E-8511-FDD3F075CDCD}.Debug|Any CPU.Build.0 = Debug|Any CPU
224+
{34079216-845C-417E-8511-FDD3F075CDCD}.Debug|x64.ActiveCfg = Debug|Any CPU
225+
{34079216-845C-417E-8511-FDD3F075CDCD}.Debug|x64.Build.0 = Debug|Any CPU
226+
{34079216-845C-417E-8511-FDD3F075CDCD}.Debug|x86.ActiveCfg = Debug|Any CPU
227+
{34079216-845C-417E-8511-FDD3F075CDCD}.Debug|x86.Build.0 = Debug|Any CPU
228+
{34079216-845C-417E-8511-FDD3F075CDCD}.Release|Any CPU.ActiveCfg = Release|Any CPU
229+
{34079216-845C-417E-8511-FDD3F075CDCD}.Release|Any CPU.Build.0 = Release|Any CPU
230+
{34079216-845C-417E-8511-FDD3F075CDCD}.Release|x64.ActiveCfg = Release|Any CPU
231+
{34079216-845C-417E-8511-FDD3F075CDCD}.Release|x64.Build.0 = Release|Any CPU
232+
{34079216-845C-417E-8511-FDD3F075CDCD}.Release|x86.ActiveCfg = Release|Any CPU
233+
{34079216-845C-417E-8511-FDD3F075CDCD}.Release|x86.Build.0 = Release|Any CPU
164234
EndGlobalSection
165235
GlobalSection(SolutionProperties) = preSolution
166236
HideSolutionNode = FALSE
@@ -176,5 +246,10 @@ Global
176246
{2D69478A-68E9-4FBB-AEDE-40866829609F} = {22222222-2222-2222-2222-222222222222}
177247
{D50FA946-1BA6-4793-8224-2FB629EF0AB5} = {22222222-2222-2222-2222-222222222222}
178248
{EBCE7C5A-C4D5-45B2-962B-0DE16BE1C8EE} = {22222222-2222-2222-2222-222222222222}
249+
{F5E41535-F67C-444D-98CC-C1A1D9DD0ABB} = {11111111-1111-1111-1111-111111111111}
250+
{66FF3152-38BF-4830-9534-3F77642E651B} = {11111111-1111-1111-1111-111111111111}
251+
{A96127D0-8777-4B00-AB8A-B59BAC0B3342} = {22222222-2222-2222-2222-222222222222}
252+
{5A33C69B-40A0-49AF-87F4-40B75D0D53A5} = {22222222-2222-2222-2222-222222222222}
253+
{34079216-845C-417E-8511-FDD3F075CDCD} = {44444444-4444-4444-4444-444444444444}
179254
EndGlobalSection
180255
EndGlobal

plan.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -32,11 +32,11 @@ PRs pequenos e independentes, em ordem. Cada um fecha com testes verdes e fecha
3232
- **Depende de:** PR 2.
3333

3434
## PR 4 — Formats.Xlsx + Destinations.S3
35-
- [ ] `Format.Xlsx(...)` com ClosedXML (aba, auto-filtro, tipos nativos).
36-
- [ ] Multi-output numa passada (CSV + XLSX lendo a source uma vez).
37-
- [ ] `Destination.S3(bucket, keyTemplate)` — upload tudo-ou-nada (sem objeto parcial em falha).
38-
- [ ] Sample `02-sql-to-xlsx-s3`.
39-
- **Aceite:** CA-5, CA-6, CA-8.
35+
- [x] `Format.Xlsx(...)` com ClosedXML (aba, auto-filtro, tipos nativos; formato/data por coluna). Memória cresce com as linhas — ver D14.
36+
- [x] Multi-output numa passada (CSV + XLSX lendo a source uma vez) — provado no E2E `Csv_and_xlsx_are_generated_reading_the_source_once`.
37+
- [x] `Destination.S3(bucket, keyTemplate)` — upload tudo-ou-nada via `PutObject` (sem objeto parcial em falha) — ver D15.
38+
- [x] Sample `02-sql-to-xlsx-s3`.
39+
- **Aceite:** CA-5, CA-6, CA-8. ✅ Total acumulado de testes verdes: 34 (13 Core + 4 CSV + 6 Local + 4 Xlsx + 3 S3 + 4 SQL/E2E).
4040
- **Depende de:** PR 3.
4141

4242
## PR 5 — Memória constante (validação)
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<OutputType>Exe</OutputType>
5+
<TargetFramework>net8.0</TargetFramework>
6+
<Nullable>enable</Nullable>
7+
<ImplicitUsings>enable</ImplicitUsings>
8+
<IsPackable>false</IsPackable>
9+
<GenerateDocumentationFile>false</GenerateDocumentationFile>
10+
</PropertyGroup>
11+
12+
<ItemGroup>
13+
<ProjectReference Include="..\..\src\NeoReports.Core\NeoReports.Core.csproj" />
14+
<ProjectReference Include="..\..\src\Sources\NeoReports.Sources.Sql\NeoReports.Sources.Sql.csproj" />
15+
<ProjectReference Include="..\..\src\Formats\NeoReports.Formats.Csv\NeoReports.Formats.Csv.csproj" />
16+
<ProjectReference Include="..\..\src\Formats\NeoReports.Formats.Xlsx\NeoReports.Formats.Xlsx.csproj" />
17+
<ProjectReference Include="..\..\src\Destinations\NeoReports.Destinations.S3\NeoReports.Destinations.S3.csproj" />
18+
</ItemGroup>
19+
20+
<ItemGroup>
21+
<PackageReference Include="Microsoft.Extensions.DependencyInjection" />
22+
<PackageReference Include="Microsoft.Extensions.Logging" />
23+
<PackageReference Include="Microsoft.Extensions.Logging.Console" />
24+
</ItemGroup>
25+
26+
</Project>
Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
using System.Text;
2+
using Microsoft.Extensions.DependencyInjection;
3+
using Microsoft.Extensions.Logging;
4+
using NeoReports.Core.DependencyInjection;
5+
using NeoReports.Core.Pipeline;
6+
using NeoReports.Destinations.S3;
7+
using NeoReports.Sources.Sql;
8+
using static NeoReports.Core.Building.ReportColumns;
9+
// Import the format entry methods directly so Csv(...) and Xlsx(...) read cleanly and avoid the
10+
// Format class-name collision between the two format packages (ADR D16).
11+
using static NeoReports.Formats.Csv.Format;
12+
using static NeoReports.Formats.Xlsx.Format;
13+
14+
// Sample 02 — SQL Server -> CSV + XLSX (single pass) -> Amazon S3.
15+
//
16+
// Reads the source once and writes BOTH formats, then uploads each to S3.
17+
// Requires AWS credentials/region in the environment and an existing bucket:
18+
// dotnet run --project samples/02-sql-to-xlsx-s3 -- "<connection-string>" "<bucket>"
19+
20+
var connectionString = args.Length > 0
21+
? args[0]
22+
: "Server=localhost;Database=Sales;Trusted_Connection=True;TrustServerCertificate=True";
23+
var bucket = args.Length > 1 ? args[1] : "my-reports-bucket";
24+
25+
var services = new ServiceCollection();
26+
services.AddLogging(b => b.AddConsole().SetMinimumLevel(LogLevel.Information));
27+
28+
services.AddReport<Venda>("vendas-mensal", b => b
29+
.From(Source.Sql(
30+
connectionString,
31+
"SELECT Id, Cliente, Valor, Data FROM Vendas " +
32+
"WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id")
33+
.Keyset<Venda, long>(v => v.Id, pageSize: 1000))
34+
.Filter(v => v.Valor > 0)
35+
.Columns(
36+
Col<Venda, long>(v => v.Id, "ID Venda"),
37+
Col<Venda, string>(v => v.Cliente, "Cliente"),
38+
Col<Venda, decimal>(v => v.Valor, "Valor", format: "C2", culture: "pt-BR"),
39+
Col<Venda, DateTime>(v => v.Data, "Data Venda", format: "yyyy-MM-dd"))
40+
.To(Csv(o => o.Delimiter(';').Encoding(Encoding.UTF8)))
41+
.To(Xlsx(o => o.SheetName("Vendas").AutoFilter()))
42+
.UploadTo(Destination.S3(bucket, "reports/{name}/{date:yyyy-MM-dd}.{ext}")));
43+
44+
var provider = services.BuildServiceProvider();
45+
var runner = provider.GetRequiredService<IReportRunner>();
46+
47+
var result = await runner.RunAsync("vendas-mensal");
48+
49+
Console.WriteLine($"Status: {result.Status}");
50+
Console.WriteLine($"Records read/written: {result.Stats.RecordsRead}/{result.Stats.RecordsWritten}");
51+
foreach (var upload in result.Uploads)
52+
Console.WriteLine($"Uploaded: {upload.Url} (success={upload.Success})");
53+
54+
return result.Status == ReportRunStatus.Failed ? 1 : 0;
55+
56+
internal sealed record Venda(long Id, string Cliente, decimal Valor, DateTime Data);
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
using NeoReports.Core.Building;
2+
3+
namespace NeoReports.Destinations.S3;
4+
5+
/// <summary>Fluent entry point for the Amazon S3 destination.</summary>
6+
public static class Destination
7+
{
8+
/// <summary>Configures an Amazon S3 destination.</summary>
9+
/// <param name="bucket">Target bucket name.</param>
10+
/// <param name="keyTemplate">Key template (e.g. <c>reports/{name}/{date:yyyy-MM-dd}.{ext}</c>).</param>
11+
/// <returns>A <see cref="DestinationSpec"/> to pass to <c>ReportBuilder.UploadTo(...)</c>.</returns>
12+
public static DestinationSpec S3(string bucket, string keyTemplate) =>
13+
new(new S3DestinationFactory(bucket, keyTemplate));
14+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
5+
<Description>Amazon S3 destination for NeoReports.</Description>
6+
<PackageTags>reports;reporting;destination;s3;aws</PackageTags>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<ProjectReference Include="..\..\NeoReports.Abstractions\NeoReports.Abstractions.csproj" />
11+
<ProjectReference Include="..\..\NeoReports.Core\NeoReports.Core.csproj" />
12+
<ProjectReference Include="..\..\Destinations\NeoReports.Destinations.Local\NeoReports.Destinations.Local.csproj" />
13+
</ItemGroup>
14+
15+
<ItemGroup>
16+
<PackageReference Include="AWSSDK.S3" />
17+
</ItemGroup>
18+
19+
</Project>
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
using Amazon.S3;
2+
using Amazon.S3.Model;
3+
using NeoReports.Abstractions;
4+
using NeoReports.Destinations.Local;
5+
6+
namespace NeoReports.Destinations.S3;
7+
8+
/// <summary>
9+
/// Uploads the finished report file to an Amazon S3 bucket at a key resolved from a template.
10+
/// The upload is all-or-nothing: <c>PutObject</c> creates the object only on a fully successful
11+
/// transfer, so a failure never leaves a partial object (S3 PUT is atomic per object).
12+
/// </summary>
13+
public sealed class S3Destination : IReportDestination
14+
{
15+
private readonly IAmazonS3 _client;
16+
private readonly bool _ownsClient;
17+
private readonly string _bucket;
18+
private readonly string _keyTemplate;
19+
20+
/// <summary>Creates the destination with an explicit S3 client (the caller owns its lifetime).</summary>
21+
/// <param name="client">The S3 client.</param>
22+
/// <param name="bucket">Target bucket name.</param>
23+
/// <param name="keyTemplate">Key template (e.g. <c>reports/{name}/{date:yyyy-MM-dd}.{ext}</c>).</param>
24+
public S3Destination(IAmazonS3 client, string bucket, string keyTemplate)
25+
: this(client, ownsClient: false, bucket, keyTemplate)
26+
{
27+
}
28+
29+
private S3Destination(IAmazonS3 client, bool ownsClient, string bucket, string keyTemplate)
30+
{
31+
_client = client ?? throw new ArgumentNullException(nameof(client));
32+
_ownsClient = ownsClient;
33+
if (string.IsNullOrWhiteSpace(bucket))
34+
throw new ArgumentException("Bucket must be provided.", nameof(bucket));
35+
if (string.IsNullOrWhiteSpace(keyTemplate))
36+
throw new ArgumentException("Key template must be provided.", nameof(keyTemplate));
37+
_bucket = bucket;
38+
_keyTemplate = keyTemplate;
39+
}
40+
41+
/// <summary>
42+
/// Creates a destination that builds its own <see cref="AmazonS3Client"/> from ambient AWS
43+
/// credentials/region (resolved by the SDK). The client is disposed with the destination.
44+
/// </summary>
45+
/// <param name="bucket">Target bucket name.</param>
46+
/// <param name="keyTemplate">Key template.</param>
47+
public static S3Destination WithDefaultClient(string bucket, string keyTemplate) =>
48+
new(new AmazonS3Client(), ownsClient: true, bucket, keyTemplate);
49+
50+
/// <inheritdoc />
51+
public string Type => "s3";
52+
53+
/// <inheritdoc />
54+
public async Task<UploadResult> UploadAsync(ReportFile file, DestinationContext context, CancellationToken cancellationToken)
55+
{
56+
ArgumentNullException.ThrowIfNull(file);
57+
ArgumentNullException.ThrowIfNull(context);
58+
59+
var extension = Path.GetExtension(file.FileName).TrimStart('.');
60+
var key = PathTemplate.Expand(
61+
_keyTemplate,
62+
context.Execution.ReportName,
63+
extension,
64+
context.Execution.StartedAt,
65+
context.Execution.Parameters);
66+
67+
try
68+
{
69+
await using var content = file.OpenRead();
70+
var request = new PutObjectRequest
71+
{
72+
BucketName = _bucket,
73+
Key = key,
74+
InputStream = content,
75+
ContentType = file.MimeType,
76+
AutoCloseStream = false,
77+
// Let the SDK compute the length from the seekable stream; disable chunked
78+
// signing so a transient failure aborts the whole PUT (no partial object).
79+
UseChunkEncoding = false,
80+
};
81+
82+
var response = await _client.PutObjectAsync(request, cancellationToken).ConfigureAwait(false);
83+
if ((int)response.HttpStatusCode is < 200 or >= 300)
84+
return UploadResult.Fail($"S3 PutObject returned HTTP {(int)response.HttpStatusCode} for s3://{_bucket}/{key}.");
85+
86+
var url = $"s3://{_bucket}/{key}";
87+
return UploadResult.Ok(url, key);
88+
}
89+
catch (Exception ex)
90+
{
91+
return UploadResult.Fail($"S3 upload to s3://{_bucket}/{key} failed: {ex.Message}");
92+
}
93+
}
94+
95+
/// <summary>Disposes the S3 client if this destination created it.</summary>
96+
public void Dispose()
97+
{
98+
if (_ownsClient)
99+
_client.Dispose();
100+
}
101+
}
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
using Amazon.S3;
2+
using NeoReports.Abstractions;
3+
4+
namespace NeoReports.Destinations.S3;
5+
6+
/// <summary>
7+
/// Creates <see cref="S3Destination"/> instances. Resolves an <see cref="IAmazonS3"/> from the
8+
/// service provider when available; otherwise builds a default client from ambient AWS config.
9+
/// </summary>
10+
public sealed class S3DestinationFactory : IDestinationFactory
11+
{
12+
private readonly string _bucket;
13+
private readonly string _keyTemplate;
14+
15+
/// <summary>Creates the factory.</summary>
16+
/// <param name="bucket">Target bucket name.</param>
17+
/// <param name="keyTemplate">Key template applied to every created destination.</param>
18+
public S3DestinationFactory(string bucket, string keyTemplate)
19+
{
20+
_bucket = bucket;
21+
_keyTemplate = keyTemplate;
22+
}
23+
24+
/// <inheritdoc />
25+
public string Type => "s3";
26+
27+
/// <inheritdoc />
28+
public IReportDestination Create(IReadOnlyDictionary<string, object?> options, IServiceProvider services)
29+
{
30+
var client = services?.GetService(typeof(IAmazonS3)) as IAmazonS3;
31+
return client is not null
32+
? new S3Destination(client, _bucket, _keyTemplate)
33+
: S3Destination.WithDefaultClient(_bucket, _keyTemplate);
34+
}
35+
}

0 commit comments

Comments
 (0)