-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathNeoReportsEndpointRouteBuilderExtensions.cs
More file actions
1758 lines (1544 loc) · 90.2 KB
/
Copy pathNeoReportsEndpointRouteBuilderExtensions.cs
File metadata and controls
1758 lines (1544 loc) · 90.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
using System.Globalization;
using System.IO.Compression;
using System.Text.Json;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Routing;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
using Microsoft.Extensions.Primitives;
using NeoReports.Abstractions;
using NeoReports.Core;
using NeoReports.Core.Artifacts;
using NeoReports.Core.Configuration;
using NeoReports.Core.Events;
using NeoReports.Core.Pipeline;
using NeoReports.Core.Preview;
using NeoReports.Core.QueryBuilder;
using NeoReports.Core.Registry;
using NeoReports.Core.Scheduling;
using NeoReports.Core.Schema;
using NeoReports.Core.SourceRegistry;
namespace NeoReports.AspNetCore;
/// <summary>Maps the NeoReports HTTP endpoints (trigger, status, cancel, download).</summary>
public static class NeoReportsEndpointRouteBuilderExtensions
{
/// <summary>
/// Maps the NeoReports API under <paramref name="prefix"/>:
/// <list type="bullet">
/// <item><c>POST {prefix}/reports/{name}/run</c> — async (202 + jobId) or <c>?mode=sync</c> (streams a single output)</item>
/// <item><c>POST {prefix}/reports/{name}/preview</c> — read-only sample of one page, no output writing, no job record (ADR D45); optional structured filters, SQL-family dynamic reports only</item>
/// <item><c>GET {prefix}/reports</c> — list registered reports</item>
/// <item><c>GET {prefix}/reports/{name}</c> — full report definition (columns, formats, destinations, retry/failure strategy, origin)</item>
/// <item><c>POST {prefix}/reports</c> — register a report at runtime from a config document (ADR D33)</item>
/// <item><c>POST {prefix}/reports/validate</c> — dry-run compile a config document; never registers or persists</item>
/// <item><c>DELETE {prefix}/reports/{name}</c> — remove a runtime-registered report (code-first reports return 409)</item>
/// <item><c>GET {prefix}/capabilities</c> — source/format/destination type ids the host has registered</item>
/// <item><c>GET {prefix}/jobs</c> — list jobs, filterable by status/report/since, paged</item>
/// <item><c>GET {prefix}/jobs/{id}</c> — job status + stats</item>
/// <item><c>POST {prefix}/jobs/{id}/cancel</c> — request cancellation</item>
/// <item><c>GET {prefix}/jobs/{id}/download</c> — download the finished result</item>
/// <item><c>GET {prefix}/jobs/{id}/artifacts</c> — list finished output files (name/mime/size, never the on-disk path)</item>
/// <item><c>GET {prefix}/jobs/{id}/events</c> — structured per-job lifecycle events (ADR D38); <c>[]</c> when no event store is registered</item>
/// <item><c>GET {prefix}/system/memory</c> — process-level memory reading + running-job count (ADR D39)</item>
/// <item><c>GET {prefix}/jobs/{id}/partial-artifacts</c> — best-effort partial output of a Failed/Cancelled job (ADR D40); completely separate from the completed-artifacts surface</item>
/// <item><c>GET {prefix}/jobs/{id}/partial-artifacts/download</c> — download the partial output</item>
/// <item><c>PUT {prefix}/reports/{name}/schedule</c> — set a runtime recurring-schedule override (ADR D41); works for both origins; 409 when no recurring scheduler is registered</item>
/// <item><c>DELETE {prefix}/reports/{name}/schedule</c> — clear the runtime override (tombstones a declared schedule, or removes a prior override)</item>
/// <item><c>GET {prefix}/sources</c> / <c>GET {prefix}/sources/{name}</c> — list/read registered sources (ADR D42); never returns properties</item>
/// <item><c>POST {prefix}/sources</c> / <c>PUT {prefix}/sources/{name}</c> — register / full-replace a source; 409 when a report still references it on delete</item>
/// <item><c>DELETE {prefix}/sources/{name}</c> — remove a source; blocked (409) while any registered report references it</item>
/// <item><c>POST {prefix}/sources/{name}/health</c> — run an on-demand health check now; cached + timestamped; 422 when the type has no registered check</item>
/// <item><c>GET {prefix}/sources/{name}/catalog</c> — introspect the source's schema (tables/columns/PK/FK, ADR D49); 422 when the type has no registered explorer</item>
/// <item><c>GET {prefix}/sources/{name}/preview?schema=&table=</c> — first rows of one table (ADR D49); row count is fixed server-side</item>
/// <item><c>POST {prefix}/sources/{name}/query-sql</c> — generate keyset-safe report SQL from a visual query model (ADR D49, Pro); 422 when no query-builder generator is registered</item>
/// <item><c>POST {prefix}/sources/{name}/query-preview</c> — bounded, read-only sample of a visual query's result (ADR D49, K6); generates the SQL server-side, runs one capped page, never executes raw caller SQL</item>
/// </list>
/// Authorization is inherited from the host; set <see cref="NeoReportsEndpointOptions.RequireAuthorization"/>
/// to apply <c>RequireAuthorization</c> to the group.
/// </summary>
/// <param name="endpoints">The endpoint route builder.</param>
/// <param name="prefix">URL prefix (default <c>/api</c>).</param>
/// <param name="configure">Optional options callback.</param>
/// <returns>The route group, for further customization.</returns>
public static RouteGroupBuilder MapNeoReports(
this IEndpointRouteBuilder endpoints,
string prefix = "/api",
Action<NeoReportsEndpointOptions>? configure = null)
{
ArgumentNullException.ThrowIfNull(endpoints);
var options = new NeoReportsEndpointOptions();
configure?.Invoke(options);
var group = endpoints.MapGroup(prefix);
// A handler that returns a Location must point back at the prefix this group was actually
// mapped under; hardcoding "/api" makes the 202's Location a 404 under MapNeoReports("/v2").
// The handlers are static method groups with no closure over `prefix`, so the mapped prefix
// rides along on the request instead. Trailing '/' is trimmed so MapNeoReports("/") does not
// produce a protocol-relative "//jobs/..." URL.
string mappedPrefix = prefix.TrimEnd('/');
group.AddEndpointFilter(async (context, next) =>
{
context.HttpContext.Items[MappedPrefixKey] = mappedPrefix;
return await next(context).ConfigureAwait(false);
});
if (options.RequireAuthorization)
{
if (string.IsNullOrEmpty(options.AuthorizationPolicy))
group.RequireAuthorization();
else
group.RequireAuthorization(options.AuthorizationPolicy);
}
else if (endpoints.ServiceProvider.GetService<IAuthenticationSchemeProvider>() is null)
{
// Auth inherits from the host (ADR D20) — the engine does not impose a default. But when
// no authentication is configured on the host at all AND RequireAuthorization was not set,
// this management surface (trigger runs, register reports, store source connection
// strings, mutate schedules, download artifacts) is reachable unauthenticated. That is a
// valid deployment only behind a trusted boundary; warn once at startup so it is a
// deliberate choice, not a silent default.
endpoints.ServiceProvider.GetService<ILoggerFactory>()?
.CreateLogger("NeoReports.AspNetCore")
.LogWarning(
"NeoReports endpoints mapped at '{Prefix}' with no authentication configured on the host and " +
"NeoReportsEndpointOptions.RequireAuthorization not set — the report management API is reachable " +
"unauthenticated. Configure the host's authentication/authorization (or set RequireAuthorization) " +
"before exposing it beyond a trusted network.",
prefix);
}
// Compiling a report — Create and Validate below — must resolve IConfigSourceProvider
// (and, for a Ref-based source, ISourceRegistry) through the app's ROOT provider, never
// http.RequestServices: Create's compiled report is registered into the singleton
// IMutableReportRegistry and outlives the request, so a RefBatchSource that captured the
// request's scoped IServiceProvider throws ObjectDisposedException the first time a later,
// fully-async job run tries to resolve its source — the scope is long gone by then.
// IEndpointRouteBuilder.ServiceProvider is the app's root provider, captured once here.
IServiceProvider rootServices = endpoints.ServiceProvider;
group.MapPost("/reports/{name}/run", RunReportAsync);
group.MapPost("/reports/{name}/preview", PreviewReportAsync);
group.MapGet("/reports", ListReports);
group.MapGet("/reports/{name}", GetReportDetailAsync);
group.MapGet("/reports/{name}/config", GetReportConfigAsync);
group.MapPost("/reports", (HttpContext http, [FromServices] IMutableReportRegistry registry,
[FromServices] IReportConfigStore configStore, CancellationToken cancellationToken) =>
CreateReportAsync(http, registry, configStore, rootServices, cancellationToken));
group.MapPut("/reports/{name}", (string name, HttpContext http, [FromServices] IMutableReportRegistry registry,
[FromServices] IReportConfigStore configStore, CancellationToken cancellationToken) =>
ReplaceReportAsync(name, http, registry, configStore, rootServices, cancellationToken));
group.MapPost("/reports/validate", (HttpContext http, [FromServices] IReportRegistry registry,
CancellationToken cancellationToken) =>
ValidateReportAsync(http, registry, rootServices, cancellationToken));
group.MapDelete("/reports/{name}", DeleteReportAsync);
group.MapGet("/capabilities", GetCapabilities);
group.MapGet("/jobs", ListJobsAsync);
group.MapGet("/jobs/{id}", GetJobAsync);
group.MapPost("/jobs/{id}/cancel", CancelJobAsync);
group.MapGet("/jobs/{id}/download", DownloadAsync);
group.MapGet("/jobs/{id}/artifacts", GetJobArtifactsAsync);
group.MapGet("/jobs/{id}/events", GetJobEventsAsync);
group.MapGet("/system/memory", GetMemoryAsync);
group.MapGet("/jobs/{id}/partial-artifacts", GetPartialArtifactsAsync);
group.MapGet("/jobs/{id}/partial-artifacts/download", DownloadPartialArtifactsAsync);
group.MapPut("/reports/{name}/schedule", SetScheduleAsync);
group.MapDelete("/reports/{name}/schedule", ClearScheduleAsync);
group.MapGet("/sources", ListSourcesAsync);
group.MapGet("/sources/{name}", GetSourceAsync);
group.MapPost("/sources", CreateSourceAsync);
group.MapPut("/sources/{name}", ReplaceSourceAsync);
group.MapDelete("/sources/{name}", DeleteSourceAsync);
group.MapPost("/sources/{name}/health", CheckSourceHealthAsync);
group.MapGet("/sources/{name}/catalog", GetSourceCatalogAsync);
group.MapGet("/sources/{name}/preview", PreviewSourceTableAsync);
group.MapPost("/sources/{name}/query-sql", GenerateSourceQuerySqlAsync);
group.MapPost("/sources/{name}/query-preview", PreviewSourceQueryAsync);
return group;
}
private static async Task<IResult> RunReportAsync(
string name,
string? mode,
RunReportRequest? body,
IReportRegistry registry,
IReportJobScheduler scheduler,
IReportRunner runner,
IReportArtifactStore artifactStore,
HttpContext http,
CancellationToken cancellationToken)
{
var report = registry.Find(name);
if (report is null)
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
if (body?.Filters is { Count: > 0 })
{
// Applying filters to a full run (not just a preview sample) needs a temporary compiled
// variant threaded through the job/scheduler pipeline — a larger, separate piece of work
// deferred past this pass; POST .../preview already supports filters fully.
return Results.BadRequest(new
{
error = "Filters are not yet supported on a full run — use POST /reports/{name}/preview instead.",
});
}
IReadOnlyDictionary<string, object?>? parameters = NormalizeJsonValues(body?.Parameters);
if (FirstComplexParameter(parameters) is { } complexParameter)
{
return Results.BadRequest(new
{
error = $"Parameter '{complexParameter}' is an array or object. Run parameters must be " +
"scalars (string, number, boolean, null) — v1 has no way to bind a structured " +
"value to a source.",
});
}
if (string.Equals(mode, "sync", StringComparison.OrdinalIgnoreCase))
{
// Sync streams a single file in the response; multi-output cannot be streamed (CA-10).
if (report.OutputCount != 1)
return Results.BadRequest(new
{
error = "Synchronous mode supports single-output reports only. " +
$"Report '{name}' has {report.OutputCount} outputs; use async mode and download.",
});
var jobId = Guid.NewGuid().ToString("N");
var result = await runner.RunAsync(name, parameters, jobId, cancellationToken).ConfigureAwait(false);
if (result.Status == ReportRunStatus.Failed)
{
await artifactStore.DeleteAsync(jobId, CancellationToken.None).ConfigureAwait(false);
// result.Error is already scrubbed at the source (a driver exception is reduced to its
// type name, only NeoReports' own curated messages survive). Log it and still return a
// generic detail — defence in depth, the same scrub-and-log stance as the schema
// endpoints — so the response never carries even the run's own reason string.
http.RequestServices.GetRequiredService<ILoggerFactory>()
.CreateLogger("NeoReports.Run")
.LogWarning("Synchronous run of report '{Report}' (job {JobId}) failed: {Reason}", name, jobId, result.Error);
return Results.Problem(
title: "Report run failed.",
detail: "The report run failed. See the server logs for details.",
statusCode: StatusCodes.Status500InternalServerError);
}
var artifacts = await artifactStore.ListAsync(jobId, cancellationToken).ConfigureAwait(false);
if (artifacts.Count == 0)
return Results.Problem(
title: "Report produced no output.", statusCode: StatusCodes.Status500InternalServerError);
var artifact = artifacts[0];
// Stream the file by path (ASP.NET opens and disposes it), then delete the stored copy
// once the response finishes.
http.Response.OnCompleted(async () => await artifactStore.DeleteAsync(jobId, CancellationToken.None).ConfigureAwait(false));
return Results.File(artifact.Path, artifact.MimeType, artifact.FileName);
}
var enqueuedId = await scheduler.EnqueueAsync(
new ReportJobRequest(name, parameters), cancellationToken).ConfigureAwait(false);
return Results.Accepted(
ApiUrl(http, $"/jobs/{enqueuedId}"),
new RunAcceptedResponse(enqueuedId, ReportJobStatus.Queued));
}
private static async Task<IResult> PreviewReportAsync(
string name,
PreviewRequest? body,
IReportRegistry registry,
HttpContext http,
CancellationToken cancellationToken)
{
CompiledReport? report = registry.Find(name);
if (report is null)
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
IReadOnlyList<PreviewFilter> filters;
try
{
filters = ParseFilters(body?.Filters);
}
catch (ConfigurationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
var jobId = Guid.NewGuid().ToString("N");
ILogger logger = http.RequestServices.GetRequiredService<ILoggerFactory>().CreateLogger("NeoReports.Preview");
var execution = new ReportExecutionContext(jobId, name, null, logger, cancellationToken);
try
{
PreviewResult result = await ReportPreviewRunner.PreviewAsync(
report, filters, body?.PageSize ?? ReportPreviewRunner.MaxPageSize, execution, http.RequestServices, cancellationToken)
.ConfigureAwait(false);
ReportColumnView[] columns = result.Schema.Columns
.Select(c => new ReportColumnView(c.Name, c.Type.ToString(), c.DisplayName, c.Format, c.Nullable))
.ToArray();
return Results.Ok(new PreviewResponse(result.Rows, columns, result.FiltersApplied, result.HasMore));
}
catch (ConfigurationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
catch (Exception ex) when (ex is not OperationCanceledException)
{
// Preview opens a live connection and runs SQL, so a bad filter value surfaces the raw
// driver exception — which names the host, port and database. Every sibling endpoint that
// touches a source routes failures through SchemaProblem (logged server-side, generic to
// the caller); this one used to let them escape as an unhandled 500, which on a host
// running in Development also renders the full stack trace.
return SchemaProblem(http, ex, name, "Could not run the report preview.");
}
}
private static IReadOnlyList<PreviewFilter> ParseFilters(IReadOnlyList<PreviewFilterRequest>? filters)
{
if (filters is null || filters.Count == 0)
return Array.Empty<PreviewFilter>();
var result = new List<PreviewFilter>(filters.Count);
foreach (PreviewFilterRequest f in filters)
{
if (!Enum.TryParse(f.Operator, ignoreCase: true, out PreviewFilterOperator op))
throw new ConfigurationException($"Unknown filter operator '{f.Operator}'.");
result.Add(new PreviewFilter(f.Column, op, f.Value));
}
return result;
}
private static IResult ListReports(IReportRegistry registry)
{
var reports = registry.Reports
.Select(r => new ReportSummary(
r.Name, r.OutputCount, r.Schema.Columns.Select(c => c.Name).ToArray(), r.OutputFormats, r.DestinationTypes))
.OrderBy(r => r.Name, StringComparer.Ordinal)
.ToArray();
return Results.Ok(reports);
}
private static async Task<IResult> GetReportDetailAsync(
string name, HttpContext http, [FromServices] IReportRegistry registry, CancellationToken cancellationToken)
{
CompiledReport? report = registry.Find(name);
if (report is null)
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
// The config store is optional: hosts that never call AddDynamicReports() have no
// IReportConfigStore registered, and every report is origin "code" there.
IReportConfigStore? configStore = http.RequestServices.GetService<IReportConfigStore>();
bool isConfigOrigin = configStore is not null
&& DynamicReportName.IsValid(name)
&& await configStore.ExistsAsync(name, cancellationToken).ConfigureAwait(false);
ReportColumnView[] columns = report.Schema.Columns
.Select(c => new ReportColumnView(c.Name, c.Type.ToString(), c.DisplayName, c.Format, c.Nullable))
.ToArray();
(string? scheduleCron, DateTimeOffset? nextRunAt, bool scheduleOverridden) =
await ResolveScheduleAsync(report, http, cancellationToken).ConfigureAwait(false);
var detail = new ReportDetailView(
Name: report.Name,
Columns: columns,
PageSize: report.PageSize,
Formats: report.OutputFormats,
Destinations: report.DestinationTypes,
FailureStrategy: report.FailureStrategy.Name,
RetryMaxAttempts: report.Retry.Attempts,
RetryBackoff: report.Retry.Backoff.ToString(),
RetryBaseDelaySeconds: report.Retry.BaseDelay.TotalSeconds,
RetryUseJitter: report.Retry.UseJitter,
Origin: isConfigOrigin ? "config" : "code",
Deletable: isConfigOrigin,
AbortAfterConsecutiveFailures: report.AbortThresholds?.ConsecutiveFailures,
AbortAfterTotalFailures: report.AbortThresholds?.TotalFailures,
AbortAtFailureRate: report.AbortThresholds?.FailureRate,
ScheduleCron: scheduleCron,
NextRunAt: nextRunAt,
ScheduleOverridden: scheduleOverridden,
SourceRef: report.SourceRef);
return Results.Ok(detail);
}
/// <summary>
/// Returns the stored configuration document for a config-origin report, with credential-bearing
/// property values replaced by <see cref="ReportConfigSecrets.RedactedValue"/> (ADR D86).
/// <para>
/// This is the one place a property bag leaves the engine, and it is what makes editing possible
/// at all: <c>GET /reports/{name}</c> deliberately exposes none of the source's configuration
/// (D33(c)), so an editor built on it could only ever offer the user a blank form. Sending the
/// placeholder back on <c>PUT</c> restores the stored value, so a secret still never leaves the
/// host — which is precisely the round-trip story D33(f) deferred report editing for.
/// </para>
/// </summary>
private static async Task<IResult> GetReportConfigAsync(
string name, HttpContext http, [FromServices] IReportRegistry registry, CancellationToken cancellationToken)
{
if (registry.Find(name) is null)
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
IReportConfigStore? configStore = http.RequestServices.GetService<IReportConfigStore>();
string? document = configStore is not null && DynamicReportName.IsValid(name)
? await configStore.TryGetAsync(name, cancellationToken).ConfigureAwait(false)
: null;
if (document is null)
{
// Code-registered reports have no document to return — their definition lives in the
// host's source, which is also where it has to be changed.
return Results.NotFound(new
{
error = $"Report '{name}' is code-registered and has no stored configuration document.",
});
}
try
{
string redacted = ReportConfigSecrets.Redact(document);
// ADR D87. Deliberately a validator over what the CLIENT can see: hashing the stored
// document instead would let a caller confirm a guessed connection string offline, since
// the two differ only in the redacted values.
http.Response.Headers.ETag = ReportConfigETag.For(document);
return Results.Text(redacted, "application/json");
}
catch (ConfigurationException ex)
{
// A document that no longer parses is a corrupt store, not a bad request — say so
// rather than handing the client half a configuration it would silently save back.
return Results.Problem(
title: $"The stored configuration for '{name}' could not be read.",
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError);
}
}
/// <summary>
/// Resolves a report's effective schedule for display: the override store and recurring
/// scheduler are both optional (ADR D41) — a host that never called <c>AddScheduling</c>/a Jobs
/// recurring scheduler simply shows "not scheduled" for every report, never a fabricated value.
/// </summary>
private static async Task<(string? Cron, DateTimeOffset? NextRunAt, bool Overridden)> ResolveScheduleAsync(
CompiledReport report, HttpContext http, CancellationToken cancellationToken)
{
IScheduleOverrideStore? overrides = http.RequestServices.GetService<IScheduleOverrideStore>();
ScheduleOverrideEntry? overrideEntry = null;
if (overrides is not null && DynamicReportName.IsValid(report.Name))
overrideEntry = await overrides.GetAsync(report.Name, cancellationToken).ConfigureAwait(false);
string? effectiveCron = EffectiveSchedule.Resolve(report.Schedule, overrideEntry);
if (effectiveCron is null)
return (null, null, EffectiveSchedule.IsOverridden(overrideEntry));
IRecurringReportScheduler? scheduler = http.RequestServices.GetService<IRecurringReportScheduler>();
DateTimeOffset? nextRunAt = scheduler is null
? null
: await scheduler.GetNextOccurrenceAsync(report.Name, cancellationToken).ConfigureAwait(false);
return (effectiveCron, nextRunAt, EffectiveSchedule.IsOverridden(overrideEntry));
}
private static async Task<IResult> CreateReportAsync(
HttpContext http,
IMutableReportRegistry registry,
IReportConfigStore configStore,
IServiceProvider rootServices,
CancellationToken cancellationToken)
{
string document = await ReadBodyAsync(http, cancellationToken).ConfigureAwait(false);
ReportConfig config;
try
{
config = new JsonReportConfigParser().Parse(document);
}
catch (ConfigurationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
if (!DynamicReportName.IsValid(config.Name))
{
return Results.BadRequest(new
{
error = $"'{config.Name}' is not a valid report name. Names must match {DynamicReportName.Pattern}.",
});
}
if (registry.Contains(config.Name))
return Results.Conflict(new { error = $"A report named '{config.Name}' already exists." });
// A redaction placeholder only means anything against a stored document to restore from
// (ADR D86), and a create has none. Rejected rather than passed through, which would
// otherwise persist the literal sentinel as if it were a connection string.
if (ReportConfigSecrets.ContainsRedactedValue(document))
{
return Results.BadRequest(new
{
error = $"This configuration contains the redaction placeholder '{ReportConfigSecrets.RedactedValue}', " +
"which can only be resolved when replacing an existing report (PUT /reports/{name}). " +
"Send the real value instead.",
});
}
if (config.Schedule is not null && http.RequestServices.GetService<IRecurringReportScheduler>() is null)
{
return Results.BadRequest(new
{
error = "This report declares a schedule, but no recurring scheduler is registered on this host. " +
"Register one (e.g. AddNeoReportsInMemoryJobs/AddNeoReportsHangfireJobs) or omit 'schedule'.",
});
}
CompiledReport compiled;
try
{
ReportConfig substituted = ReportConfigEnvironment.Substitute(config);
compiled = ReportConfigCompiler.Compile(substituted, rootServices);
}
catch (ConfigurationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
try
{
registry.Register(compiled);
}
catch (ConfigurationException ex)
{
// A concurrent request registered the same name between the Contains check above and
// here; the registry's own duplicate-name guard is the source of truth.
return Results.Conflict(new { error = ex.Message });
}
try
{
// The store persists the ORIGINAL document (with any ${VAR} placeholders unresolved),
// never the substituted one — a secret must never reach disk.
await configStore.SaveAsync(config.Name, document, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
registry.Unregister(config.Name);
return Results.Problem(
title: "Failed to persist the dynamic report.",
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError);
}
// Effective at registration (ADR D41): a declared schedule starts firing immediately,
// without waiting for the next host restart's reconciliation pass.
if (compiled.Schedule is { } declaredSchedule)
{
IRecurringReportScheduler? scheduler = http.RequestServices.GetService<IRecurringReportScheduler>();
if (scheduler is not null)
await scheduler.RegisterRecurringAsync(config.Name, declaredSchedule.Cron, cancellationToken).ConfigureAwait(false);
}
var columns = compiled.Schema.Columns.Select(c => c.Name).ToArray();
return Results.Created(
ApiUrl(http, $"/reports/{config.Name}"), new ReportCreatedResponse(config.Name, columns));
}
/// <summary>
/// Replaces a config-origin report's definition in place (ADR D86), resolving any
/// <see cref="ReportConfigSecrets.RedactedValue"/> the client sent back against the stored
/// document.
/// <para>
/// Editing used to be delete-then-create from the client, which fails in the worst possible
/// direction: a configuration the engine rejects arrives *after* the original is already gone,
/// and the user is left with no report at all. Here nothing is mutated until the replacement has
/// compiled, so a rejected edit leaves the existing report exactly as it was.
/// </para>
/// </summary>
private static async Task<IResult> ReplaceReportAsync(
string name,
HttpContext http,
IMutableReportRegistry registry,
IReportConfigStore configStore,
IServiceProvider rootServices,
CancellationToken cancellationToken)
{
CompiledReport? existing = registry.Find(name);
if (existing is null)
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
string? stored = DynamicReportName.IsValid(name)
? await configStore.TryGetAsync(name, cancellationToken).ConfigureAwait(false)
: null;
if (stored is null)
{
return Results.Conflict(new
{
error = $"Report '{name}' is code-registered and cannot be changed at runtime.",
});
}
// Checked before Restore merges the two, so a corrupt document on disk is not reported as a
// bad request — the same condition GET .../config already answers with a 500.
try
{
ReportConfigSecrets.EnsureReadable(stored);
}
catch (ConfigurationException ex)
{
return Results.Problem(
title: $"The stored configuration for '{name}' could not be read.",
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError);
}
// Optimistic concurrency (ADR D87), checked against the very document Restore is about to
// resolve against — re-reading here would reopen the window it exists to close. A request
// without If-Match states no precondition and behaves exactly as it did before D87.
if (!ReportConfigETag.Allows(http.Request.Headers.IfMatch, stored))
{
// Shaped like every other rejection this endpoint returns, not as ProblemDetails: the
// client reads `error`, so a ProblemDetails body left it with nothing to show and the
// user was told the configuration was invalid instead of being told to reload — which is
// the entire point of answering 412 rather than saving.
return Results.Json(
new
{
error = $"'{name}' changed since you opened it — another editor saved it in the " +
"meantime. Reload the report and apply your change again. Saving now could " +
"resolve a held-back value against a section that is no longer the one it " +
"came from.",
},
statusCode: StatusCodes.Status412PreconditionFailed);
}
string document = await ReadBodyAsync(http, cancellationToken).ConfigureAwait(false);
ReportConfig config;
try
{
document = ReportConfigSecrets.Restore(document, stored);
config = new JsonReportConfigParser().Parse(document);
}
catch (ConfigurationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
if (!string.Equals(config.Name, name, StringComparison.Ordinal))
{
return Results.BadRequest(new
{
error = $"The configuration is named '{config.Name}' but the route targets '{name}'. " +
"A report cannot be renamed in place — delete it and create the new name.",
});
}
IRecurringReportScheduler? scheduler = http.RequestServices.GetService<IRecurringReportScheduler>();
if (config.Schedule is not null && scheduler is null)
{
return Results.BadRequest(new
{
error = "This report declares a schedule, but no recurring scheduler is registered on this host. " +
"Register one (e.g. AddNeoReportsInMemoryJobs/AddNeoReportsHangfireJobs) or omit 'schedule'.",
});
}
CompiledReport compiled;
try
{
ReportConfig substituted = ReportConfigEnvironment.Substitute(config);
compiled = ReportConfigCompiler.Compile(substituted, rootServices);
}
catch (ConfigurationException ex)
{
// Nothing has been touched yet, which is the entire point of compiling first.
return Results.BadRequest(new { error = ex.Message });
}
registry.Replace(compiled);
try
{
// Same rule as create: the ORIGINAL document is persisted, ${VAR} placeholders intact.
await configStore.SaveAsync(name, document, cancellationToken).ConfigureAwait(false);
}
catch (Exception ex)
{
// Put the previous definition back rather than leaving the process running a report the
// next restart will not rehydrate. Every failure rolls back, not only the file-system
// ones a create has to worry about: an IReportConfigStore is an interface, a custom one
// can throw anything, and a registry that disagrees with the store is a report that
// quietly reverts on the next restart — the hardest kind of bug to trace back to an edit.
registry.Replace(existing);
if (ex is OperationCanceledException)
throw;
return Results.Problem(
title: "Failed to persist the dynamic report.",
detail: ex.Message,
statusCode: StatusCodes.Status500InternalServerError);
}
await ReconcileScheduleAsync(name, compiled, scheduler, http, cancellationToken).ConfigureAwait(false);
// The validator for what was just stored (ADR D87). Without it an editor's captured tag goes
// stale the instant its own save succeeds, so any second save from the same page — a retry
// after "Run now" failed to start, a double-click — would be refused with a 412 that names a
// conflict with itself.
http.Response.Headers.ETag = ReportConfigETag.For(document);
var columns = compiled.Schema.Columns.Select(c => c.Name).ToArray();
return Results.Ok(new ReportCreatedResponse(name, columns));
}
/// <summary>
/// Brings the recurring registration in line with a replaced report's declared schedule.
/// <para>
/// A runtime schedule override still wins (ADR D41): editing a definition is not the same act as
/// changing when it runs, so an override set through <c>PUT /reports/{name}/schedule</c> survives
/// the edit and stays effective. Without one, the declared schedule takes effect immediately —
/// including its <em>removal</em>, which has to unregister the recurring job rather than leave it
/// firing for a report that no longer declares it.
/// </para>
/// </summary>
private static async Task ReconcileScheduleAsync(
string name,
CompiledReport compiled,
IRecurringReportScheduler? scheduler,
HttpContext http,
CancellationToken cancellationToken)
{
if (scheduler is null)
return;
IScheduleOverrideStore? overrides = http.RequestServices.GetService<IScheduleOverrideStore>();
ScheduleOverrideEntry? overrideEntry = overrides is null
? null
: await overrides.GetAsync(name, cancellationToken).ConfigureAwait(false);
string? effectiveCron = EffectiveSchedule.Resolve(compiled.Schedule, overrideEntry);
if (effectiveCron is null)
await scheduler.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
else
await scheduler.RegisterRecurringAsync(name, effectiveCron, cancellationToken).ConfigureAwait(false);
}
private static async Task<IResult> ValidateReportAsync(
HttpContext http, IReportRegistry registry, IServiceProvider rootServices, CancellationToken cancellationToken)
{
string document = await ReadBodyAsync(http, cancellationToken).ConfigureAwait(false);
if (string.IsNullOrWhiteSpace(document))
return Results.BadRequest(new { error = "Report configuration document is empty." });
string? name = null;
string? editingFor = null;
try
{
// ?for={name} dry-runs an *edit* of an existing report: redaction placeholders (ADR D86)
// are resolved against that report's stored document first, so the Builder's "Validate"
// button means the same thing while editing as it does while creating. Without this the
// placeholder would reach a provider as a literal connection string and fail for a
// reason that has nothing to do with the configuration under test.
if (http.Request.Query.TryGetValue("for", out StringValues editing)
&& editing.ToString() is { Length: > 0 } editingName)
{
string? stored = DynamicReportName.IsValid(editingName)
&& http.RequestServices.GetService<IReportConfigStore>() is { } store
? await store.TryGetAsync(editingName, cancellationToken).ConfigureAwait(false)
: null;
// Saying so beats silently skipping the restore: the caller would otherwise get
// "still holds the redaction placeholder" about a document they sent correctly, plus a
// nameTaken flag, for the single real problem that the report is gone.
if (stored is null)
{
return Results.Ok(new ValidateReportResponse(
Valid: false,
Error: $"There is no stored configuration for '{editingName}' to validate an edit against. " +
"It may have been deleted, or it is code-registered.",
Name: null,
Columns: null,
NameTaken: false));
}
editingFor = editingName;
document = ReportConfigSecrets.Restore(document, stored);
}
ReportConfig config = new JsonReportConfigParser().Parse(document);
name = config.Name;
// ?for= means "dry-run an edit of this report", so the document has to BE that report —
// the same check PUT enforces. Without it an arbitrary document could be compiled with
// another report's restored credentials, which is not what a dry run is for.
if (editingFor is not null && !string.Equals(config.Name, editingFor, StringComparison.Ordinal))
{
return Results.Ok(new ValidateReportResponse(
Valid: false,
Error: $"The configuration is named '{config.Name}' but '?for=' targets '{editingFor}'. " +
"Validating an edit requires the document to be the report being edited.",
Name: config.Name,
Columns: null,
NameTaken: registry.Contains(config.Name)));
}
if (!DynamicReportName.IsValid(config.Name))
{
return Results.Ok(new ValidateReportResponse(
Valid: false,
Error: $"'{config.Name}' is not a valid report name. Names must match {DynamicReportName.Pattern}.",
Name: config.Name,
Columns: null,
NameTaken: registry.Contains(config.Name)));
}
ReportConfig substituted = ReportConfigEnvironment.Substitute(config);
CompiledReport compiled = ReportConfigCompiler.Compile(substituted, rootServices);
var columns = compiled.Schema.Columns.Select(c => c.Name).ToArray();
// Its own name is not "taken" when the dry run IS an edit of that report — reporting it
// as taken put "name already taken" under every successful edit validation.
return Results.Ok(new ValidateReportResponse(
Valid: true, Error: null, Name: config.Name, Columns: columns,
NameTaken: registry.Contains(config.Name) && !string.Equals(config.Name, editingFor, StringComparison.Ordinal)));
}
catch (ConfigurationException ex)
{
return Results.Ok(new ValidateReportResponse(
Valid: false, Error: ex.Message, Name: name, Columns: null,
NameTaken: name is not null && registry.Contains(name)
&& !string.Equals(name, editingFor, StringComparison.Ordinal)));
}
}
private static async Task<IResult> DeleteReportAsync(
string name,
HttpContext http,
[FromServices] IMutableReportRegistry registry,
[FromServices] IReportConfigStore configStore,
CancellationToken cancellationToken)
{
if (!registry.Contains(name))
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
bool inStore = DynamicReportName.IsValid(name) &&
await configStore.ExistsAsync(name, cancellationToken).ConfigureAwait(false);
if (!inStore)
{
return Results.Conflict(new
{
error = $"Report '{name}' is code-registered and cannot be deleted at runtime.",
});
}
// Recurring registration and any override are removed first (ADR D41), before the report
// itself disappears, so no new firing can race the delete.
IRecurringReportScheduler? scheduler = http.RequestServices.GetService<IRecurringReportScheduler>();
if (scheduler is not null)
await scheduler.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
IScheduleOverrideStore? overrides = http.RequestServices.GetService<IScheduleOverrideStore>();
if (overrides is not null)
await overrides.RemoveAsync(name, cancellationToken).ConfigureAwait(false);
// Store first: if the process dies between the two calls, the report stays registered
// until restart but won't rehydrate on the next one — self-healing. The opposite order
// would resurrect a "deleted" report on the next rehydration.
await configStore.DeleteAsync(name, cancellationToken).ConfigureAwait(false);
registry.Unregister(name);
return Results.NoContent();
}
private static IResult GetCapabilities(HttpContext http)
{
var sources = http.RequestServices.GetServices<IConfigSourceProvider>().Select(p => p.Type)
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s, StringComparer.Ordinal).ToArray();
var formats = http.RequestServices.GetServices<IWriterFactory>().Select(f => f.Format)
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s, StringComparer.Ordinal).ToArray();
var destinations = http.RequestServices.GetServices<IDestinationFactory>().Select(f => f.Type)
.Distinct(StringComparer.OrdinalIgnoreCase).OrderBy(s => s, StringComparer.Ordinal).ToArray();
bool scheduling = http.RequestServices.GetService<IRecurringReportScheduler>() is not null;
return Results.Ok(new CapabilitiesResponse(sources, formats, destinations, scheduling));
}
/// <summary><see cref="HttpContext.Items"/> key carrying the prefix this group was mapped at.</summary>
private const string MappedPrefixKey = "NeoReports.MappedPrefix";
/// <summary>
/// Builds a URL for another endpoint of this API, honouring both the host's path base and the
/// prefix <see cref="MapNeoReports"/> was called with. Falls back to the documented default only
/// if the filter that stashes the prefix somehow did not run.
/// </summary>
/// <param name="http">The current request.</param>
/// <param name="relativePath">Path below the prefix, starting with <c>/</c>.</param>
private static string ApiUrl(HttpContext http, string relativePath)
{
string prefix = http.Items.TryGetValue(MappedPrefixKey, out object? mapped) && mapped is string s
? s
: "/api";
return $"{http.Request.PathBase}{prefix}{relativePath}";
}
/// <summary>
/// Rejects a schedule write for a report whose name an override store cannot key.
/// <para>
/// A schedule override is stored by report name, and a store persists it as a file name, so it
/// only accepts <see cref="DynamicReportName.Pattern"/>. A <b>code-first</b> report is under no
/// such constraint — <c>sales.daily</c> is perfectly legal — so a legitimately registered report
/// can reach the store with a name it refuses, which surfaced as an <see cref="ArgumentException"/>
/// and a <b>500</b>. The read path (<c>ResolveScheduleAsync</c>) already skips the lookup for such
/// a name, which is what makes the missing guard here an oversight rather than a design.
/// </para>
/// </summary>
/// <param name="name">The report name from the route.</param>
/// <returns><see langword="null"/> when the name is storable; otherwise the response to return.</returns>
private static IResult? ScheduleOverridesUnsupportedFor(string name) =>
DynamicReportName.IsValid(name)
? null
: Results.Conflict(new
{
error = $"The schedule for '{name}' cannot be changed over HTTP: overrides are stored " +
$"by report name, and a name must match {DynamicReportName.Pattern}. Declare " +
"this report's schedule in code, or rename it.",
});
private static async Task<IResult> SetScheduleAsync(
string name, SetScheduleRequest? body, HttpContext http,
[FromServices] IReportRegistry registry, CancellationToken cancellationToken)
{
if (!registry.Contains(name))
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
if (body is null || string.IsNullOrWhiteSpace(body.Cron))
return Results.BadRequest(new { error = "A 'cron' expression must be provided." });
IRecurringReportScheduler? scheduler = http.RequestServices.GetService<IRecurringReportScheduler>();
IScheduleOverrideStore? overrides = http.RequestServices.GetService<IScheduleOverrideStore>();
if (scheduler is null || overrides is null)
{
return Results.Conflict(new
{
error = "No recurring scheduler is registered on this host. Register one " +
"(e.g. AddNeoReportsInMemoryJobs/AddNeoReportsHangfireJobs) and AddScheduling to use schedules.",
});
}
if (ScheduleOverridesUnsupportedFor(name) is { } unsupported)
return unsupported;
try
{
CronValidation.Validate(body.Cron);
}
catch (ConfigurationException ex)
{
return Results.BadRequest(new { error = ex.Message });
}
// The override is durable; registering with the scheduler is what actually starts firing.
// Neither the declared schedule nor the config document is ever patched (ADR D41).
await overrides.SaveAsync(name, new ScheduleOverrideEntry(body.Cron), cancellationToken).ConfigureAwait(false);
await scheduler.RegisterRecurringAsync(name, body.Cron, cancellationToken).ConfigureAwait(false);
return Results.Ok();
}
private static async Task<IResult> ClearScheduleAsync(
string name, HttpContext http,
[FromServices] IReportRegistry registry, CancellationToken cancellationToken)
{
CompiledReport? report = registry.Find(name);
if (report is null)
return Results.NotFound(new { error = $"No report named '{name}' is registered." });
IRecurringReportScheduler? scheduler = http.RequestServices.GetService<IRecurringReportScheduler>();
IScheduleOverrideStore? overrides = http.RequestServices.GetService<IScheduleOverrideStore>();
if (scheduler is null || overrides is null)
return Results.Conflict(new { error = "No recurring scheduler is registered on this host." });
if (ScheduleOverridesUnsupportedFor(name) is { } unsupported)
return unsupported;
// A declared schedule needs an explicit "unscheduled" tombstone — merely removing any prior
// override would let the declaration re-apply on the next reconciliation. A report with no
// declaration has nothing to tombstone, so the override entry (if any) is just removed —
// "delete" always means "stops firing" either way (ADR D41).
if (report.Schedule is not null)
await overrides.SaveAsync(name, new ScheduleOverrideEntry(null), cancellationToken).ConfigureAwait(false);
else
await overrides.RemoveAsync(name, cancellationToken).ConfigureAwait(false);
await scheduler.RemoveRecurringAsync(name, cancellationToken).ConfigureAwait(false);
return Results.NoContent();
}
private static async Task<string> ReadBodyAsync(HttpContext http, CancellationToken cancellationToken)
{
using var reader = new StreamReader(http.Request.Body);
return await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false);
}
private static async Task<IResult> ListJobsAsync(
string? status,
string? report,
string? since,
int? limit,