Skip to content

Commit dc9db0d

Browse files
committed
feat(aspnetcore): HTTP endpoints to trigger and manage reports (PR 7)
NeoReports.AspNetCore — MapNeoReports("/api") (Minimal API): - POST /reports/{name}/run async → 202 + { jobId } - POST /reports/{name}/run?mode=sync streams a single output (Content-Disposition) - GET /reports list registered reports - GET /jobs/{id} status + stats - POST /jobs/{id}/cancel cooperative cancel - GET /jobs/{id}/download finished result (multi-output → zip) Sync rejects multi-output with 400 (CA-10). Authorization inherited from host (optional RequireAuthorization; no auth chain in v1). Core: IReportArtifactStore + FileSystemArtifactStore retain finished files beyond the pipeline's temp dir so download/sync can serve them; ReportRunner saves to it when registered (opt-in via DI). Kept in Core, not Abstractions (engine concern, not a plugin contract) — ADR D20. Sample 03-async-job-hangfire: HTTP + Hangfire single-server (in-memory storage). Tests (8) via TestServer: list, async run → completed → download, sync stream, sync multi-output → 400, unknown report/job 404s, cancel unknown 404. All 58 solution tests green. CPM: add Microsoft.AspNetCore.TestHost, Hangfire.AspNetCore. ADR D20; plan.md PR 7 done.
1 parent e1ba69d commit dc9db0d

13 files changed

Lines changed: 716 additions & 0 deletions

File tree

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

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: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
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.Sources.Sql;
8+
using static NeoReports.Core.Building.ReportColumns;
9+
using static NeoReports.Formats.Csv.Format;
10+
11+
// Sample 03 — trigger reports over HTTP, executed by a single Hangfire server.
12+
//
13+
// dotnet run --project samples/03-async-job-hangfire
14+
// curl -X POST http://localhost:5000/api/reports/vendas-mensal/run # async -> { jobId }
15+
// curl http://localhost:5000/api/jobs/{jobId} # status
16+
// curl -OJ http://localhost:5000/api/jobs/{jobId}/download # result
17+
// curl -X POST "http://localhost:5000/api/reports/vendas-mensal/run?mode=sync" -o out.csv
18+
//
19+
// Uses Hangfire in-memory storage so the sample runs with no external dependencies; swap
20+
// UseInMemoryStorage() for UseSqlServerStorage(connString) for real single-server persistence.
21+
22+
var builder = WebApplication.CreateBuilder(args);
23+
24+
var connectionString = builder.Configuration.GetConnectionString("Sales")
25+
?? "Server=localhost;Database=Sales;Trusted_Connection=True;TrustServerCertificate=True";
26+
27+
builder.Services.AddReport<Venda>("vendas-mensal", b => b
28+
.From(Source.Sql(
29+
connectionString,
30+
"SELECT Id, Cliente, Valor, Data FROM Vendas " +
31+
"WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id")
32+
.Keyset<Venda, long>(v => v.Id, pageSize: 1000))
33+
.Filter(v => v.Valor > 0)
34+
.Columns(
35+
Col<Venda, long>(v => v.Id, "ID Venda"),
36+
Col<Venda, string>(v => v.Cliente, "Cliente"),
37+
Col<Venda, decimal>(v => v.Valor, "Valor", format: "C2", culture: "pt-BR"),
38+
Col<Venda, DateTime>(v => v.Data, "Data Venda", format: "yyyy-MM-dd"))
39+
.To(Csv(o => o.Delimiter(';')))
40+
.UploadTo(Destination.Local("./out/{name}-{date:yyyy-MM-dd}.{ext}")));
41+
42+
// Hangfire single-server (in-memory storage for the sample).
43+
builder.Services.AddHangfire(cfg => cfg.UseInMemoryStorage());
44+
builder.Services.AddHangfireServer();
45+
46+
// NeoReports job backend (Hangfire) + artifact store for download/sync endpoints.
47+
builder.Services.AddNeoReportsHangfireJobs();
48+
builder.Services.AddNeoReportsArtifacts();
49+
50+
var app = builder.Build();
51+
52+
app.MapNeoReports("/api");
53+
54+
app.Run();
55+
56+
internal sealed record Venda(long Id, string Cliente, decimal Valor, DateTime Data);
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
using NeoReports.Abstractions;
2+
3+
namespace NeoReports.AspNetCore;
4+
5+
/// <summary>Request body for triggering a report run.</summary>
6+
/// <param name="Parameters">Run-time parameters passed to the report (e.g. date ranges).</param>
7+
public sealed record RunReportRequest(IReadOnlyDictionary<string, object?>? Parameters);
8+
9+
/// <summary>Response returned when a report is triggered asynchronously.</summary>
10+
/// <param name="JobId">Identifier of the queued job.</param>
11+
/// <param name="Status">Initial job status (typically <c>Queued</c>).</param>
12+
public sealed record RunAcceptedResponse(string JobId, ReportJobStatus Status);
13+
14+
/// <summary>Summary of a registered report.</summary>
15+
/// <param name="Name">The report name.</param>
16+
/// <param name="OutputCount">Number of configured output formats.</param>
17+
/// <param name="Columns">Output column names, in order.</param>
18+
public sealed record ReportSummary(string Name, int OutputCount, IReadOnlyList<string> Columns);
19+
20+
/// <summary>Status and statistics view of a job.</summary>
21+
/// <param name="Id">Job id.</param>
22+
/// <param name="ReportName">Report the job runs.</param>
23+
/// <param name="Status">Current lifecycle status.</param>
24+
/// <param name="CreatedAt">When the job was created.</param>
25+
/// <param name="StartedAt">When processing started, if it has.</param>
26+
/// <param name="CompletedAt">When the job finished, if it has.</param>
27+
/// <param name="Error">Failure reason, when failed.</param>
28+
/// <param name="Stats">Aggregate counters.</param>
29+
public sealed record JobView(
30+
string Id,
31+
string ReportName,
32+
ReportJobStatus Status,
33+
DateTimeOffset CreatedAt,
34+
DateTimeOffset? StartedAt,
35+
DateTimeOffset? CompletedAt,
36+
string? Error,
37+
JobStats Stats)
38+
{
39+
/// <summary>Maps a persisted <see cref="ReportJob"/> to its API view.</summary>
40+
/// <param name="job">The job to map.</param>
41+
public static JobView From(ReportJob job) => new(
42+
job.Id, job.ReportName, job.Status, job.CreatedAt, job.StartedAt, job.CompletedAt, job.Error, job.Stats);
43+
}
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>
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
namespace NeoReports.AspNetCore;
2+
3+
/// <summary>Options for the NeoReports endpoint group.</summary>
4+
public sealed class NeoReportsEndpointOptions
5+
{
6+
/// <summary>
7+
/// When set, the endpoints require authorization (the host's auth is applied via
8+
/// <c>RequireAuthorization</c>). Auth itself is inherited from the host — there is no auth chain
9+
/// in v1. Default <c>false</c> (the host decides via its own middleware/policies).
10+
/// </summary>
11+
public bool RequireAuthorization { get; set; }
12+
13+
/// <summary>Optional authorization policy name applied when <see cref="RequireAuthorization"/> is true.</summary>
14+
public string? AuthorizationPolicy { get; set; }
15+
}

0 commit comments

Comments
 (0)