2

Preface: This will not be available publicly or to third parties, so I am not concerned about users having the background knowledge to properly form GET requests. These are also not only analytical reports, but also documents, think of an invoice. Because of the sensitivity of the data, I cannot warehouse this or put the data anywhere that is not contained locally on our network. I am also unable to use paid libraries or technologies, but if recommended, I can pass them along for consideration.

I am currently building out a PDF reporting API using QuestPDF. We have numerous reports that all vary in their needs, resulting in many different parameters being available. At the moment, I have an abstract report object that all the reports inherit off of to handle shared functionality:

using Microsoft.Data.SqlClient;
using Oracle.ManagedDataAccess.Client;
using QuestPDF.Fluent;
using QuestPDF.Helpers;
using QuestPDF.Infrastructure;
using ReportsAPI.Models.Parameters;
using System.Data;
namespace ReportsAPI.Models.Reports
{
 public abstract class Report : IDocument
 {
 /* ***** Properties ***** */
 public int FontSize { get; set; } = 10;
 public int TitleFontSize { get; set; } = 12;
 public bool BoldTitle { get; set; } = true;
 public string FontFamily { get; set; } = "Arial";
 public float MarginSize { get; set; } = 0.25f;
 public PageSize PaperSize { get; set; } = PageSizes.Letter;
 public bool Landscape { get; set; } = false;
 public string? Title { get; set; }
 public string? SubTitle { get; set; }
 // TODO: These handle DB interactions, think of something to clean this up
 public CommandType? QueryType { get; set; }
 public DataTable ReportTable { get; set; } = new();
 public RequestParameters? RequestParameters { get; set; }
 public string? Query { get; set; }
 public bool RequiresSql { get; set; } = false;
 public List<SqlParameter> SqlParameters { get; set; } = [];
 public List<OracleParameter> OracleParameters { get; set; } = [];
 /* ***** Methods ***** */
 // Require override to handle db interaction, etc.
 public abstract void Configure();
 // Require override on each report to generate their specific body
 protected abstract void ComposeBody(IContainer container);
 // Basic header, available to override but most will have a similar header
 protected virtual void ComposeHeader(IContainer container)
 {
 // TODO: Finish header
 container.Column(column =>
 {
 column.Item().Text(Title).FontSize(TitleFontSize).Bold().AlignCenter();
 column.Item().Text(SubTitle).FontSize(FontSize).AlignCenter();
 });
 }
 protected virtual void ComposeFooter(IContainer container)
 {
 // Not required, so not abstract, but available for overriding
 }
 // Set some general report settings such as margin, text font/size and the orientation
 private void ApplySettings(PageDescriptor page)
 {
 page.Margin(MarginSize, Unit.Inch);
 page.DefaultTextStyle(t => t.FontFamily(FontFamily).FontSize(FontSize));
 if (Landscape)
 page.Size(PaperSize.Landscape());
 else
 page.Size(PaperSize);
 }
 /* ***** Interface Implementation ***** */
 public void Compose(IDocumentContainer container)
 {
 container.Page(page =>
 {
 ApplySettings(page);
 page.Header().Element(ComposeHeader);
 page.Content().Element(ComposeBody);
 page.Footer().Element(ComposeFooter);
 });
 }
 }
}

An example of a child class:

using Oracle.ManagedDataAccess.Client;
using QuestPDF.Fluent;
using QuestPDF.Infrastructure;
using System.Data;
namespace ReportsAPI.Models.Reports.Examples
{
 public class ExampleReport: Report
 {
 // Handle setting all of the required db interaction stuff
 // This is part of the TODO on Report.cs cleanup
 public override void Configure()
 {
 Title = "Example Report Name";
 Query = "StoredProcedureName";
 QueryType = CommandType.StoredProcedure;
 OracleParameter refCursor = new("StoredProcedureCursor", OracleDbType.RefCursor)
 {
 Direction = ParameterDirection.Output
 };
 OracleParameter startDate = new("p_startdate", RequestParameters.StartDate);
 OracleParameter endDate = new("p_enddate", RequestParameters.EndDate);
 OracleParameters.Add(refCursor);
 OracleParameters.Add(startDate);
 OracleParameters.Add(endDate);
 }
 // Example of body generation, actual reports will have cleaner handling
 protected override void ComposeBody(IContainer container)
 {
 container.Column(col =>
 {
 foreach (DataRow tableRow in ReportTable.Rows)
 {
 col.Item().Row(row =>
 {
 foreach (DataColumn tableCol in ReportTable.Columns)
 {
 row.ConstantItem(0.51f, Unit.Inch).Text(tableRow[tableCol.ColumnName].ToString());
 }
 });
 }
 });
 }
 }
}

Because all of these reports will have the same basic functionality and the only differences between each one will be the report body generation logic and setting the db interactions to pull the data, I have a single endpoint that generates them dynamically based on the requested report:

using Microsoft.AspNetCore.Mvc;
using ReportsAPI.Models.Parameters;
using ReportsAPI.Services;
namespace ReportsAPI.Controllers.v1
{
 [Route("api/[controller]")]
 [ApiController]
 public class ExamplesController : ControllerBase
 {
 private readonly ReportingService _reportingService; // DI for the ReportingService
 public ExamplesController(ReportingService reportingService)
 {
 _reportingService = reportingService;
 // This must be set here to handle fully qualifying the .cs file for Activator to generate an instance of the report
 _reportingService.ReportingDepartment = ReportingService.Department.Examples; // Set the department for the reporting service
 }
 [HttpGet("{reportName}")]
 public async Task<IActionResult> GetReport(string reportName, [FromQuery] RequestParameters parameters)
 {
 var reportStream = await _reportingService.GetReportStream(reportName, parameters);
 if (reportStream is null)
 return BadRequest($"Report '{reportName}' not found.");
 return Ok(reportStream);
 }
 }
}

Because there is only a single endpoint, but these reports could have various required parameters for the db interactions, I have a query string model that holds every possible parameter for all reports:

namespace ReportsAPI.Models.Parameters
{
 public class RequestParameters
 {
 // Add more properties as parameters are needed
 public DateTime? Date { get; set; }
 public DateTime? DateTime { get; set; }
 public DateTime? StartDate { get; set; }
 public DateTime? StartDateTime { get; set; }
 public DateTime? EndDate { get; set; }
 public DateTime? EndDateTime { get; set; }
 public string? RoomName { get; set; }
 public string? PersonName { get; set; }
 public string? BuildingName { get; set; }
 }
}

This will capture any of the set values in our query string, such as ?buildingName=someBuilding&roomName=303. Because each report will be configured to handle setting their required parameters in .Configure(), additional parameters that are set will be ignored anyways, so there is no harm in a report requiring BuildingName and RoomName also containing a value in StartDate.

The reports themselves are generated by a reporting service:

using QuestPDF.Fluent;
using ReportsAPI.Models.Parameters;
using ReportsAPI.Models.Reports;
namespace ReportsAPI.Services
{
 public class ReportingService(DatabaseService databaseService)
 {
 /* ***** Enums ***** */
 public enum Department
 { 
 Examples,
 }
 /* ***** Properties ***** */
 public Department? ReportingDepartment { get; set; }
 /* ***** Fields ***** */
 private DatabaseService _databaseService = databaseService;
 /* ***** Methods ***** */
 public async Task<MemoryStream?> GetReportStream(string reportName, RequestParameters? requestParameters)
 {
 if (ReportingDepartment is null)
 {
 throw new InvalidOperationException("Reporting department is not set.");
 }
 var report = GetReportInstance(reportName);
 if (report is null)
 {
 return null; // Early return if we have no report, TODO: handle this better
 }
 // Provide the query string parameters
 report.RequestParameters = requestParameters;
 // Handle setting all the required values for db interactions, etc.
 report.Configure();
 // TODO: This needs to be modified away from testing purposes and include error handling
 var dataTask = _databaseService.GetQueryResults(report.Query, report.QueryType.Value, report.OracleParameters, DatabaseService.Database.DATABASENAME);
 // End of the testing zone
 report.ReportTable = await dataTask;
 var pdfStream = new MemoryStream(report.GeneratePdf());
 return pdfStream;
 }
 private Report? GetReportInstance(string reportName)
 {
 Report? report = null;
 var qualifiedReportName = $"ReportsAPI.Models.Reports.{ReportingDepartment}.{reportName}";
 var type = Type.GetType(qualifiedReportName, throwOnError: false, ignoreCase: true);
 if (type is not null)
 {
 report = (Report?)Activator.CreateInstance(type);
 }
 return report;
 }
 }
}

The database service literally just executes the query and returns the results as a DataTable, so for brevity, I won't include it.

Should I break these endpoints out into discrete parameter combinations, or is a single endpoint with a query string model viable? Or am I going down the wrong rabbit hole entirely?

asked Apr 29 at 15:13

1 Answer 1

7

There isn't an industry standard practice here. Choose the solution that you, as the maintainer of these reports, will find easiest to maintain.

One piece of advice I can give is to stop thinking in terms of "these reports have similar parameters" and think in terms of the semantic meaning behind these reports. The fact they have overlapping parameters is a coincidence. It is probably not meaningful from a business perspective, so don't feel like you are violating DRY because you have multiple endpoints that contain similar parameters. As you said, the report body contains the bulk of the differences.

Another advantage I can think of for different endpoints is you make each endpoint and report isolated from the others. This limits the blast radius of bug fixes and enhancements; something that becomes more beneficial as time marches on.

Of course, the downside to separate endpoints is that you have more code to maintain. You'll need to make that judgement for yourself. And don't think you need to choose one option or the other. The particularly large and complex reports might benefit from being isolated from the others, but perhaps the smaller and simpler reports would benefit from consolidation so you have less code to maintain.

Try to find a balance between the amount of code you maintain and the cost of changing any given report. The only best practice here is to analyze these relative costs yourself and make your best judgement.

answered Apr 29 at 15:56
2
  • Thank you for the sanity check. For your point about isolation, would you suggest isolating each report into it's own endpoint, or an endpoint for each parameter combination? I will definitely have to do some more cost/benefit analysis regardless, but having someone else's thoughts put into words for me to reflect on feels helpful. Commented Apr 29 at 16:21
  • 3
    @Austin, I would encourage you to stop thinking about parameters and stay focused on the business meaning of these reports. Don't put put the hammer and screwdriver in with the silverware drawer simply because they are all made of steel. They are used for different things, but composed of similar parts. Group these things together based on purpose, not on what they are made of. Commented Apr 29 at 16:33

Your Answer

Draft saved
Draft discarded

Sign up or log in

Sign up using Google
Sign up using Email and Password

Post as a guest

Required, but never shown

Post as a guest

Required, but never shown

By clicking "Post Your Answer", you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.