Skip to content

Commit a578150

Browse files
authored
Merge pull request #32 from thiagoluga/feat/aspnetcore
feat(aspnetcore): HTTP endpoints to trigger and manage reports (PR 7)
2 parents 8822130 + 283735d commit a578150

19 files changed

Lines changed: 1058 additions & 5 deletions

.gitignore

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,9 @@
88
[Oo]ut/
99
[Ll]og/
1010
[Ll]ogs/
11-
artifacts/
11+
# Only the .NET build output dir at the solution root — anchored so it does not
12+
# match source folders named Artifacts/ (Windows git matches case-insensitively).
13+
/artifacts/
1214

1315
# Visual Studio / Rider / VS Code
1416
.vs/
@@ -66,3 +68,5 @@ secrets.json
6668
_*.log
6769
_*.txt
6870
*.flag
71+
_*/
72+
*.trx

NeoReports.sln

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Jobs.Hangfire",
4949
EndProject
5050
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Jobs.UnitTests", "tests\NeoReports.Jobs.UnitTests\NeoReports.Jobs.UnitTests.csproj", "{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}"
5151
EndProject
52+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.AspNetCore", "src\Integrations\NeoReports.AspNetCore\NeoReports.AspNetCore.csproj", "{DC55DB23-7998-47DC-9088-FA1B7B3821D1}"
53+
EndProject
54+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.AspNetCore.IntegrationTests", "tests\NeoReports.AspNetCore.IntegrationTests\NeoReports.AspNetCore.IntegrationTests.csproj", "{9477CA22-21CE-453F-9DC9-B99D874B2CD0}"
55+
EndProject
56+
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "03-async-job-hangfire", "samples\03-async-job-hangfire\03-async-job-hangfire.csproj", "{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}"
57+
EndProject
5258
Global
5359
GlobalSection(SolutionConfigurationPlatforms) = preSolution
5460
Debug|Any CPU = Debug|Any CPU
@@ -287,6 +293,42 @@ Global
287293
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}.Release|x64.Build.0 = Release|Any CPU
288294
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}.Release|x86.ActiveCfg = Release|Any CPU
289295
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}.Release|x86.Build.0 = Release|Any CPU
296+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
297+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
298+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x64.ActiveCfg = Debug|Any CPU
299+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x64.Build.0 = Debug|Any CPU
300+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x86.ActiveCfg = Debug|Any CPU
301+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x86.Build.0 = Debug|Any CPU
302+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
303+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|Any CPU.Build.0 = Release|Any CPU
304+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x64.ActiveCfg = Release|Any CPU
305+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x64.Build.0 = Release|Any CPU
306+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x86.ActiveCfg = Release|Any CPU
307+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x86.Build.0 = Release|Any CPU
308+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
309+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
310+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x64.ActiveCfg = Debug|Any CPU
311+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x64.Build.0 = Debug|Any CPU
312+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x86.ActiveCfg = Debug|Any CPU
313+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x86.Build.0 = Debug|Any CPU
314+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
315+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|Any CPU.Build.0 = Release|Any CPU
316+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x64.ActiveCfg = Release|Any CPU
317+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x64.Build.0 = Release|Any CPU
318+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x86.ActiveCfg = Release|Any CPU
319+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x86.Build.0 = Release|Any CPU
320+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
321+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|Any CPU.Build.0 = Debug|Any CPU
322+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x64.ActiveCfg = Debug|Any CPU
323+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x64.Build.0 = Debug|Any CPU
324+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x86.ActiveCfg = Debug|Any CPU
325+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x86.Build.0 = Debug|Any CPU
326+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|Any CPU.ActiveCfg = Release|Any CPU
327+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|Any CPU.Build.0 = Release|Any CPU
328+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x64.ActiveCfg = Release|Any CPU
329+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x64.Build.0 = Release|Any CPU
330+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x86.ActiveCfg = Release|Any CPU
331+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x86.Build.0 = Release|Any CPU
290332
EndGlobalSection
291333
GlobalSection(SolutionProperties) = preSolution
292334
HideSolutionNode = FALSE
@@ -311,5 +353,8 @@ Global
311353
{55D991E5-0FB3-4989-8A1F-D9071F82280C} = {11111111-1111-1111-1111-111111111111}
312354
{CDE09705-9C9A-4D5B-B8EF-A86BCD81CF28} = {11111111-1111-1111-1111-111111111111}
313355
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA} = {22222222-2222-2222-2222-222222222222}
356+
{DC55DB23-7998-47DC-9088-FA1B7B3821D1} = {11111111-1111-1111-1111-111111111111}
357+
{9477CA22-21CE-453F-9DC9-B99D874B2CD0} = {22222222-2222-2222-2222-222222222222}
358+
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD} = {44444444-4444-4444-4444-444444444444}
314359
EndGlobalSection
315360
EndGlobal

PLAN.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,10 @@ Small, independent PRs, in order. Each one closes with green tests and closes on
5353
- **Depends on:** PR 2.
5454

5555
## PR 7 — Integrations.AspNetCore: trigger endpoints
56-
- [ ] `MapNeoReports("/api")`: `run` (async/sync), `GET /reports`, `GET /jobs/{id}`, `cancel`, `download`.
57-
- [ ] Validation: sync rejects multi-output (`400`); auth inherited from the host.
58-
- [ ] Sample `03-async-job-hangfire`.
59-
- **Acceptance:** AC-9, AC-10. **Demonstrable MVP.**
56+
- [x] `MapNeoReports("/api")` (Minimal API): `run` (async 202+jobId / `?mode=sync` stream), `GET /reports`, `GET /jobs/{id}`, `POST /jobs/{id}/cancel`, `GET /jobs/{id}/download` (multi-output → zip).
57+
- [x] Validation: sync rejects multi-output (`400`); auth inherited from the host (optional `RequireAuthorization`). Artifact store in Core; status as string — see D20.
58+
- [x] Sample `03-async-job-hangfire` (Hangfire single-server, in-memory storage).
59+
- **Acceptance:** AC-9, AC-10. **Demonstrable MVP.** ✅ 8 integration tests via TestServer.
6060
- **Depends on:** PR 6, PR 4.
6161

6262
## PR 8 — OSS release polish

build/Directory.Packages.props

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@
2222
<!-- Jobs -->
2323
<PackageVersion Include="Hangfire.Core" Version="1.8.14" />
2424
<PackageVersion Include="Hangfire.SqlServer" Version="1.8.14" />
25+
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.14" />
2526
<PackageVersion Include="Hangfire.InMemory" Version="1.0.0" />
2627

2728
<!-- Tests -->
@@ -32,6 +33,7 @@
3233
<PackageVersion Include="NSubstitute" Version="5.3.0" />
3334
<PackageVersion Include="Testcontainers.MsSql" Version="4.12.0" />
3435
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
36+
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="8.0.11" />
3537

3638
<!-- Benchmarks -->
3739
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
<Project Sdk="Microsoft.NET.Sdk.Web">
2+
3+
<PropertyGroup>
4+
<TargetFramework>net8.0</TargetFramework>
5+
<Nullable>enable</Nullable>
6+
<ImplicitUsings>enable</ImplicitUsings>
7+
<IsPackable>false</IsPackable>
8+
<GenerateDocumentationFile>false</GenerateDocumentationFile>
9+
</PropertyGroup>
10+
11+
<ItemGroup>
12+
<ProjectReference Include="..\..\src\NeoReports.Core\NeoReports.Core.csproj" />
13+
<ProjectReference Include="..\..\src\Sources\NeoReports.Sources.Sql\NeoReports.Sources.Sql.csproj" />
14+
<ProjectReference Include="..\..\src\Formats\NeoReports.Formats.Csv\NeoReports.Formats.Csv.csproj" />
15+
<ProjectReference Include="..\..\src\Destinations\NeoReports.Destinations.Local\NeoReports.Destinations.Local.csproj" />
16+
<ProjectReference Include="..\..\src\Jobs\NeoReports.Jobs\NeoReports.Jobs.csproj" />
17+
<ProjectReference Include="..\..\src\Jobs\NeoReports.Jobs.Hangfire\NeoReports.Jobs.Hangfire.csproj" />
18+
<ProjectReference Include="..\..\src\Integrations\NeoReports.AspNetCore\NeoReports.AspNetCore.csproj" />
19+
</ItemGroup>
20+
21+
<ItemGroup>
22+
<PackageReference Include="Hangfire.Core" />
23+
<PackageReference Include="Hangfire.AspNetCore" />
24+
<PackageReference Include="Hangfire.InMemory" />
25+
</ItemGroup>
26+
27+
</Project>
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
using Hangfire;
2+
using NeoReports.AspNetCore;
3+
using NeoReports.AspNetCore.DependencyInjection;
4+
using NeoReports.Core.DependencyInjection;
5+
using NeoReports.Destinations.Local;
6+
using NeoReports.Jobs.Hangfire.DependencyInjection;
7+
using NeoReports.Samples.AsyncJobHangfire;
8+
using NeoReports.Sources.Sql;
9+
using static NeoReports.Core.Building.ReportColumns;
10+
using static NeoReports.Formats.Csv.Format;
11+
12+
// Sample 03 — trigger reports over HTTP, executed by a single Hangfire server.
13+
//
14+
// dotnet run --project samples/03-async-job-hangfire
15+
// curl -X POST http://localhost:5000/api/reports/monthly-sales/run # async -> { jobId }
16+
// curl http://localhost:5000/api/jobs/{jobId} # status
17+
// curl -OJ http://localhost:5000/api/jobs/{jobId}/download # result
18+
// curl -X POST "http://localhost:5000/api/reports/monthly-sales/run?mode=sync" -o out.csv
19+
//
20+
// Uses Hangfire in-memory storage so the sample runs with no external dependencies; swap
21+
// UseInMemoryStorage() for UseSqlServerStorage(connString) for real single-server persistence.
22+
23+
var builder = WebApplication.CreateBuilder(args);
24+
25+
var connectionString = builder.Configuration.GetConnectionString("Sales")
26+
?? "Server=localhost;Database=Sales;Trusted_Connection=True;TrustServerCertificate=True";
27+
28+
builder.Services.AddReport<Sale>("monthly-sales", b => b
29+
.From(Source.Sql(
30+
connectionString,
31+
"SELECT Id, Customer, Amount, Date FROM Sales " +
32+
"WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id")
33+
.Keyset<Sale, long>(v => v.Id, pageSize: 1000))
34+
.Filter(v => v.Amount > 0)
35+
.Columns(
36+
Col<Sale, long>(v => v.Id, "Sale ID"),
37+
Col<Sale, string>(v => v.Customer, "Customer"),
38+
Col<Sale, decimal>(v => v.Amount, "Amount", format: "C2", culture: "pt-BR"),
39+
Col<Sale, DateTime>(v => v.Date, "Sale Date", format: "yyyy-MM-dd"))
40+
.To(Csv(o => o.Delimiter(';')))
41+
.UploadTo(Destination.Local("./out/{name}-{date:yyyy-MM-dd}.{ext}")));
42+
43+
// Hangfire single-server (in-memory storage for the sample).
44+
builder.Services.AddHangfire(cfg => cfg.UseInMemoryStorage());
45+
builder.Services.AddHangfireServer();
46+
47+
// NeoReports job backend (Hangfire) + artifact store for download/sync endpoints.
48+
builder.Services.AddNeoReportsHangfireJobs();
49+
builder.Services.AddNeoReportsArtifacts();
50+
51+
var app = builder.Build();
52+
53+
app.MapNeoReports("/api");
54+
55+
await app.RunAsync();
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
namespace NeoReports.Samples.AsyncJobHangfire;
2+
3+
/// <summary>The report row type for the sample. Lives in a named namespace (not as a top-level
4+
/// type) so it is properly addressable and passes static analysis.</summary>
5+
public sealed record Sale(long Id, string Customer, decimal Amount, DateTime Date);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
using System.Text.Json.Serialization;
2+
using NeoReports.Abstractions;
3+
4+
namespace NeoReports.AspNetCore;
5+
6+
/// <summary>Request body for triggering a report run.</summary>
7+
/// <param name="Parameters">Run-time parameters passed to the report (e.g. date ranges).</param>
8+
public sealed record RunReportRequest(IReadOnlyDictionary<string, object?>? Parameters);
9+
10+
/// <summary>Response returned when a report is triggered asynchronously.</summary>
11+
/// <param name="JobId">Identifier of the queued job.</param>
12+
/// <param name="Status">Initial job status (typically <c>Queued</c>).</param>
13+
public sealed record RunAcceptedResponse(
14+
string JobId,
15+
[property: JsonConverter(typeof(JsonStringEnumConverter))] ReportJobStatus Status);
16+
17+
/// <summary>Summary of a registered report.</summary>
18+
/// <param name="Name">The report name.</param>
19+
/// <param name="OutputCount">Number of configured output formats.</param>
20+
/// <param name="Columns">Output column names, in order.</param>
21+
public sealed record ReportSummary(string Name, int OutputCount, IReadOnlyList<string> Columns);
22+
23+
/// <summary>Status and statistics view of a job.</summary>
24+
/// <param name="Id">Job id.</param>
25+
/// <param name="ReportName">Report the job runs.</param>
26+
/// <param name="Status">Current lifecycle status.</param>
27+
/// <param name="CreatedAt">When the job was created.</param>
28+
/// <param name="StartedAt">When processing started, if it has.</param>
29+
/// <param name="CompletedAt">When the job finished, if it has.</param>
30+
/// <param name="Error">Failure reason, when failed.</param>
31+
/// <param name="Stats">Aggregate counters.</param>
32+
public sealed record JobView(
33+
string Id,
34+
string ReportName,
35+
[property: JsonConverter(typeof(JsonStringEnumConverter))] ReportJobStatus Status,
36+
DateTimeOffset CreatedAt,
37+
DateTimeOffset? StartedAt,
38+
DateTimeOffset? CompletedAt,
39+
string? Error,
40+
JobStats Stats)
41+
{
42+
/// <summary>Maps a persisted <see cref="ReportJob"/> to its API view.</summary>
43+
/// <param name="job">The job to map.</param>
44+
public static JobView From(ReportJob job) => new(
45+
job.Id, job.ReportName, job.Status, job.CreatedAt, job.StartedAt, job.CompletedAt, job.Error, job.Stats);
46+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
using Microsoft.Extensions.DependencyInjection;
2+
using Microsoft.Extensions.DependencyInjection.Extensions;
3+
using NeoReports.Core.Artifacts;
4+
5+
namespace NeoReports.AspNetCore.DependencyInjection;
6+
7+
/// <summary>DI helpers for the ASP.NET Core integration.</summary>
8+
public static class ServiceCollectionExtensions
9+
{
10+
/// <summary>
11+
/// Registers the artifact store that backs the download and sync-streaming endpoints. The
12+
/// pipeline saves finished output files here when a report runs, and the endpoints read from it.
13+
/// Uses <see cref="FileSystemArtifactStore"/> (default temp root) unless overridden.
14+
/// </summary>
15+
/// <param name="services">The service collection.</param>
16+
public static IServiceCollection AddNeoReportsArtifacts(this IServiceCollection services)
17+
{
18+
ArgumentNullException.ThrowIfNull(services);
19+
services.TryAddSingleton<IReportArtifactStore, FileSystemArtifactStore>();
20+
return services;
21+
}
22+
23+
/// <summary>
24+
/// Registers the artifact store rooted at <paramref name="rootPath"/>.
25+
/// </summary>
26+
/// <param name="services">The service collection.</param>
27+
/// <param name="rootPath">Directory under which per-job artifact folders are created.</param>
28+
public static IServiceCollection AddNeoReportsArtifacts(this IServiceCollection services, string rootPath)
29+
{
30+
ArgumentNullException.ThrowIfNull(services);
31+
services.TryAddSingleton<IReportArtifactStore>(_ => new FileSystemArtifactStore(rootPath));
32+
return services;
33+
}
34+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
<Project Sdk="Microsoft.NET.Sdk">
2+
3+
<PropertyGroup>
4+
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
5+
<Description>ASP.NET Core endpoints to trigger and manage NeoReports reports and jobs.</Description>
6+
<PackageTags>reports;reporting;aspnetcore;api;endpoints</PackageTags>
7+
</PropertyGroup>
8+
9+
<ItemGroup>
10+
<FrameworkReference Include="Microsoft.AspNetCore.App" />
11+
</ItemGroup>
12+
13+
<ItemGroup>
14+
<ProjectReference Include="..\..\NeoReports.Abstractions\NeoReports.Abstractions.csproj" />
15+
<ProjectReference Include="..\..\NeoReports.Core\NeoReports.Core.csproj" />
16+
</ItemGroup>
17+
18+
</Project>

0 commit comments

Comments
 (0)