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
6 changes: 5 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@
[Oo]ut/
[Ll]og/
[Ll]ogs/
artifacts/
# Only the .NET build output dir at the solution root — anchored so it does not
# match source folders named Artifacts/ (Windows git matches case-insensitively).
/artifacts/

# Visual Studio / Rider / VS Code
.vs/
Expand Down Expand Up @@ -66,3 +68,5 @@ secrets.json
_*.log
_*.txt
*.flag
_*/
*.trx
45 changes: 45 additions & 0 deletions NeoReports.sln
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Jobs.Hangfire",
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.Jobs.UnitTests", "tests\NeoReports.Jobs.UnitTests\NeoReports.Jobs.UnitTests.csproj", "{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.AspNetCore", "src\Integrations\NeoReports.AspNetCore\NeoReports.AspNetCore.csproj", "{DC55DB23-7998-47DC-9088-FA1B7B3821D1}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "NeoReports.AspNetCore.IntegrationTests", "tests\NeoReports.AspNetCore.IntegrationTests\NeoReports.AspNetCore.IntegrationTests.csproj", "{9477CA22-21CE-453F-9DC9-B99D874B2CD0}"
EndProject
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}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Expand Down Expand Up @@ -287,6 +293,42 @@ Global
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}.Release|x64.Build.0 = Release|Any CPU
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}.Release|x86.ActiveCfg = Release|Any CPU
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA}.Release|x86.Build.0 = Release|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x64.ActiveCfg = Debug|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x64.Build.0 = Debug|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x86.ActiveCfg = Debug|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Debug|x86.Build.0 = Debug|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|Any CPU.Build.0 = Release|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x64.ActiveCfg = Release|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x64.Build.0 = Release|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x86.ActiveCfg = Release|Any CPU
{DC55DB23-7998-47DC-9088-FA1B7B3821D1}.Release|x86.Build.0 = Release|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x64.ActiveCfg = Debug|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x64.Build.0 = Debug|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x86.ActiveCfg = Debug|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Debug|x86.Build.0 = Debug|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|Any CPU.Build.0 = Release|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x64.ActiveCfg = Release|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x64.Build.0 = Release|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x86.ActiveCfg = Release|Any CPU
{9477CA22-21CE-453F-9DC9-B99D874B2CD0}.Release|x86.Build.0 = Release|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x64.ActiveCfg = Debug|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x64.Build.0 = Debug|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x86.ActiveCfg = Debug|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Debug|x86.Build.0 = Debug|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|Any CPU.Build.0 = Release|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x64.ActiveCfg = Release|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x64.Build.0 = Release|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x86.ActiveCfg = Release|Any CPU
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
Expand All @@ -311,5 +353,8 @@ Global
{55D991E5-0FB3-4989-8A1F-D9071F82280C} = {11111111-1111-1111-1111-111111111111}
{CDE09705-9C9A-4D5B-B8EF-A86BCD81CF28} = {11111111-1111-1111-1111-111111111111}
{98F4D154-BE1B-4CC5-9F75-51419E6F75BA} = {22222222-2222-2222-2222-222222222222}
{DC55DB23-7998-47DC-9088-FA1B7B3821D1} = {11111111-1111-1111-1111-111111111111}
{9477CA22-21CE-453F-9DC9-B99D874B2CD0} = {22222222-2222-2222-2222-222222222222}
{21CDAB18-7D2A-4926-B27C-B29F82C01ECD} = {44444444-4444-4444-4444-444444444444}
EndGlobalSection
EndGlobal
8 changes: 4 additions & 4 deletions PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,10 @@ Small, independent PRs, in order. Each one closes with green tests and closes on
- **Depends on:** PR 2.

## PR 7 — Integrations.AspNetCore: trigger endpoints
- [ ] `MapNeoReports("/api")`: `run` (async/sync), `GET /reports`, `GET /jobs/{id}`, `cancel`, `download`.
- [ ] Validation: sync rejects multi-output (`400`); auth inherited from the host.
- [ ] Sample `03-async-job-hangfire`.
- **Acceptance:** AC-9, AC-10. **Demonstrable MVP.**
- [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).
- [x] Validation: sync rejects multi-output (`400`); auth inherited from the host (optional `RequireAuthorization`). Artifact store in Core; status as string — see D20.
- [x] Sample `03-async-job-hangfire` (Hangfire single-server, in-memory storage).
- **Acceptance:** AC-9, AC-10. **Demonstrable MVP.** ✅ 8 integration tests via TestServer.
- **Depends on:** PR 6, PR 4.

## PR 8 — OSS release polish
Expand Down
2 changes: 2 additions & 0 deletions build/Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
<!-- Jobs -->
<PackageVersion Include="Hangfire.Core" Version="1.8.14" />
<PackageVersion Include="Hangfire.SqlServer" Version="1.8.14" />
<PackageVersion Include="Hangfire.AspNetCore" Version="1.8.14" />
<PackageVersion Include="Hangfire.InMemory" Version="1.0.0" />

<!-- Tests -->
Expand All @@ -32,6 +33,7 @@
<PackageVersion Include="NSubstitute" Version="5.3.0" />
<PackageVersion Include="Testcontainers.MsSql" Version="4.12.0" />
<PackageVersion Include="Xunit.SkippableFact" Version="1.5.61" />
<PackageVersion Include="Microsoft.AspNetCore.TestHost" Version="8.0.11" />

<!-- Benchmarks -->
<PackageVersion Include="BenchmarkDotNet" Version="0.14.0" />
Expand Down
27 changes: 27 additions & 0 deletions samples/03-async-job-hangfire/03-async-job-hangfire.csproj
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
<Project Sdk="Microsoft.NET.Sdk.Web">

<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<IsPackable>false</IsPackable>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
</PropertyGroup>

<ItemGroup>
<ProjectReference Include="..\..\src\NeoReports.Core\NeoReports.Core.csproj" />
<ProjectReference Include="..\..\src\Sources\NeoReports.Sources.Sql\NeoReports.Sources.Sql.csproj" />
<ProjectReference Include="..\..\src\Formats\NeoReports.Formats.Csv\NeoReports.Formats.Csv.csproj" />
<ProjectReference Include="..\..\src\Destinations\NeoReports.Destinations.Local\NeoReports.Destinations.Local.csproj" />
<ProjectReference Include="..\..\src\Jobs\NeoReports.Jobs\NeoReports.Jobs.csproj" />
<ProjectReference Include="..\..\src\Jobs\NeoReports.Jobs.Hangfire\NeoReports.Jobs.Hangfire.csproj" />
<ProjectReference Include="..\..\src\Integrations\NeoReports.AspNetCore\NeoReports.AspNetCore.csproj" />
</ItemGroup>

<ItemGroup>
<PackageReference Include="Hangfire.Core" />
<PackageReference Include="Hangfire.AspNetCore" />
<PackageReference Include="Hangfire.InMemory" />
</ItemGroup>

</Project>
55 changes: 55 additions & 0 deletions samples/03-async-job-hangfire/Program.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
using Hangfire;
using NeoReports.AspNetCore;
using NeoReports.AspNetCore.DependencyInjection;
using NeoReports.Core.DependencyInjection;
using NeoReports.Destinations.Local;
using NeoReports.Jobs.Hangfire.DependencyInjection;
using NeoReports.Samples.AsyncJobHangfire;
using NeoReports.Sources.Sql;
using static NeoReports.Core.Building.ReportColumns;
using static NeoReports.Formats.Csv.Format;

// Sample 03 — trigger reports over HTTP, executed by a single Hangfire server.
//
// dotnet run --project samples/03-async-job-hangfire
// curl -X POST http://localhost:5000/api/reports/monthly-sales/run # async -> { jobId }
// curl http://localhost:5000/api/jobs/{jobId} # status
// curl -OJ http://localhost:5000/api/jobs/{jobId}/download # result
// curl -X POST "http://localhost:5000/api/reports/monthly-sales/run?mode=sync" -o out.csv
//
// Uses Hangfire in-memory storage so the sample runs with no external dependencies; swap
// UseInMemoryStorage() for UseSqlServerStorage(connString) for real single-server persistence.

var builder = WebApplication.CreateBuilder(args);

var connectionString = builder.Configuration.GetConnectionString("Sales")
?? "Server=localhost;Database=Sales;Trusted_Connection=True;TrustServerCertificate=True";

builder.Services.AddReport<Sale>("monthly-sales", b => b
.From(Source.Sql(
connectionString,
"SELECT Id, Customer, Amount, Date FROM Sales " +
"WHERE (@cursor IS NULL OR Id > @cursor) ORDER BY Id")
.Keyset<Sale, long>(v => v.Id, pageSize: 1000))
.Filter(v => v.Amount > 0)
.Columns(
Col<Sale, long>(v => v.Id, "Sale ID"),
Col<Sale, string>(v => v.Customer, "Customer"),
Col<Sale, decimal>(v => v.Amount, "Amount", format: "C2", culture: "pt-BR"),
Col<Sale, DateTime>(v => v.Date, "Sale Date", format: "yyyy-MM-dd"))
.To(Csv(o => o.Delimiter(';')))
.UploadTo(Destination.Local("./out/{name}-{date:yyyy-MM-dd}.{ext}")));

// Hangfire single-server (in-memory storage for the sample).
builder.Services.AddHangfire(cfg => cfg.UseInMemoryStorage());
builder.Services.AddHangfireServer();

// NeoReports job backend (Hangfire) + artifact store for download/sync endpoints.
builder.Services.AddNeoReportsHangfireJobs();
builder.Services.AddNeoReportsArtifacts();

var app = builder.Build();

app.MapNeoReports("/api");

await app.RunAsync();
5 changes: 5 additions & 0 deletions samples/03-async-job-hangfire/Sale.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
namespace NeoReports.Samples.AsyncJobHangfire;

/// <summary>The report row type for the sample. Lives in a named namespace (not as a top-level
/// type) so it is properly addressable and passes static analysis.</summary>
public sealed record Sale(long Id, string Customer, decimal Amount, DateTime Date);
46 changes: 46 additions & 0 deletions src/Integrations/NeoReports.AspNetCore/Contracts.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
using System.Text.Json.Serialization;
using NeoReports.Abstractions;

namespace NeoReports.AspNetCore;

/// <summary>Request body for triggering a report run.</summary>
/// <param name="Parameters">Run-time parameters passed to the report (e.g. date ranges).</param>
public sealed record RunReportRequest(IReadOnlyDictionary<string, object?>? Parameters);

/// <summary>Response returned when a report is triggered asynchronously.</summary>
/// <param name="JobId">Identifier of the queued job.</param>
/// <param name="Status">Initial job status (typically <c>Queued</c>).</param>
public sealed record RunAcceptedResponse(
string JobId,
[property: JsonConverter(typeof(JsonStringEnumConverter))] ReportJobStatus Status);

/// <summary>Summary of a registered report.</summary>
/// <param name="Name">The report name.</param>
/// <param name="OutputCount">Number of configured output formats.</param>
/// <param name="Columns">Output column names, in order.</param>
public sealed record ReportSummary(string Name, int OutputCount, IReadOnlyList<string> Columns);

/// <summary>Status and statistics view of a job.</summary>
/// <param name="Id">Job id.</param>
/// <param name="ReportName">Report the job runs.</param>
/// <param name="Status">Current lifecycle status.</param>
/// <param name="CreatedAt">When the job was created.</param>
/// <param name="StartedAt">When processing started, if it has.</param>
/// <param name="CompletedAt">When the job finished, if it has.</param>
/// <param name="Error">Failure reason, when failed.</param>
/// <param name="Stats">Aggregate counters.</param>
public sealed record JobView(
string Id,
string ReportName,
[property: JsonConverter(typeof(JsonStringEnumConverter))] ReportJobStatus Status,
DateTimeOffset CreatedAt,
DateTimeOffset? StartedAt,
DateTimeOffset? CompletedAt,
string? Error,
JobStats Stats)
{
/// <summary>Maps a persisted <see cref="ReportJob"/> to its API view.</summary>
/// <param name="job">The job to map.</param>
public static JobView From(ReportJob job) => new(
job.Id, job.ReportName, job.Status, job.CreatedAt, job.StartedAt, job.CompletedAt, job.Error, job.Stats);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.DependencyInjection.Extensions;
using NeoReports.Core.Artifacts;

namespace NeoReports.AspNetCore.DependencyInjection;

/// <summary>DI helpers for the ASP.NET Core integration.</summary>
public static class ServiceCollectionExtensions
{
/// <summary>
/// Registers the artifact store that backs the download and sync-streaming endpoints. The
/// pipeline saves finished output files here when a report runs, and the endpoints read from it.
/// Uses <see cref="FileSystemArtifactStore"/> (default temp root) unless overridden.
/// </summary>
/// <param name="services">The service collection.</param>
public static IServiceCollection AddNeoReportsArtifacts(this IServiceCollection services)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IReportArtifactStore, FileSystemArtifactStore>();
return services;
}

/// <summary>
/// Registers the artifact store rooted at <paramref name="rootPath"/>.
/// </summary>
/// <param name="services">The service collection.</param>
/// <param name="rootPath">Directory under which per-job artifact folders are created.</param>
public static IServiceCollection AddNeoReportsArtifacts(this IServiceCollection services, string rootPath)
{
ArgumentNullException.ThrowIfNull(services);
services.TryAddSingleton<IReportArtifactStore>(_ => new FileSystemArtifactStore(rootPath));
return services;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
<Project Sdk="Microsoft.NET.Sdk">

<PropertyGroup>
<TargetFrameworks>net8.0;net9.0</TargetFrameworks>
<Description>ASP.NET Core endpoints to trigger and manage NeoReports reports and jobs.</Description>
<PackageTags>reports;reporting;aspnetcore;api;endpoints</PackageTags>
</PropertyGroup>

<ItemGroup>
<FrameworkReference Include="Microsoft.AspNetCore.App" />
</ItemGroup>

<ItemGroup>
<ProjectReference Include="..\..\NeoReports.Abstractions\NeoReports.Abstractions.csproj" />
<ProjectReference Include="..\..\NeoReports.Core\NeoReports.Core.csproj" />
</ItemGroup>

</Project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
namespace NeoReports.AspNetCore;

/// <summary>Options for the NeoReports endpoint group.</summary>
public sealed class NeoReportsEndpointOptions
{
/// <summary>
/// When set, the endpoints require authorization (the host's auth is applied via
/// <c>RequireAuthorization</c>). Auth itself is inherited from the host — there is no auth chain
/// in v1. Default <c>false</c> (the host decides via its own middleware/policies).
/// </summary>
public bool RequireAuthorization { get; set; }

/// <summary>Optional authorization policy name applied when <see cref="RequireAuthorization"/> is true.</summary>
public string? AuthorizationPolicy { get; set; }
}
Loading