Should I split endpoints by parameter requirements?

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 SqlParameters { get; set; } = []; public List 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) {

Apr 29, 2025 - 17:38
 0
Should I split endpoints by parameter requirements?

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 SqlParameters { get; set; } = [];
        public List 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 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 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?