Developing Custom Output Renderers
This page is derived from older reporting documentation and may not reflect the current behavior of the reporting module. Contributions to align it with the latest version are welcome.
Advanced guide for developers who need to create custom output formats beyond the built-in renderers.
When the standard OpenMRS report renderers don't meet your specific requirements, you can develop custom renderers to generate specialized output formats, integrate with external systems, or create unique presentation styles. This guide covers the technical implementation of custom renderers for experienced Java developers.
Table of Contents
When to Create Custom Renderers
Scenarios Requiring Custom Development
🏛️ Government/Regulatory Compliance
Specific XML schemas required by health ministries
Proprietary formats for regulatory submissions
Digital signatures and encryption requirements
Audit trails and compliance documentation
🔗 System Integration
API integrations with external health information systems
Real-time data feeds to dashboards or monitoring systems
Legacy system formats that can't be changed
Multi-system workflows requiring specific data structures
📊 Specialized Visualizations
Interactive dashboards with custom JavaScript libraries
Geographic mapping with spatial data integration
Custom charting beyond standard Excel capabilities
Mobile-optimized responsive layouts
🎯 Organization-Specific Requirements
Unique branding and styling requirements
Custom calculations not possible in templates
Complex data transformations requiring programming logic
Performance optimization for very large datasets
Alternatives to Consider First
Before developing custom renderers, evaluate:
Template-Based Solutions:
Excel templates for custom layouts and branding
Text templates for HTML/XML generation with scripting
Parameter customization for flexible standard renderers
Configuration Options:
Report Design Manager for linking multiple formats
Standard renderer configuration for CSV delimiters, formatting
Multiple report definitions for different data presentations
Third-Party Tools:
External reporting tools like JasperReports, BIRT
Business intelligence platforms with OpenMRS connectors
Data visualization tools with API access
Renderer Architecture
Core Interfaces and Classes
Primary Interface: ReportRenderer
public interface ReportRenderer {
/**
* @return the type of Report this renderer supports
*/
public Class<? extends ReportDefinition> getRenderedReportType();
/**
* @return the filename extension for reports rendered by this renderer
*/
public String getFilenameExtension(ReportRequest request);
/**
* @return the content type for reports rendered by this renderer
*/
public String getContentType(ReportRequest request);
/**
* @return a user-friendly label to describe this renderer
*/
public String getLabel();
/**
* Renders the passed ReportData to an OutputStream
*/
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException;
/**
* @return Collection of RenderingModes that this renderer supports
*/
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition);
}
Key Supporting Classes
RenderingMode
public class RenderingMode {
private ReportRenderer renderer;
private String label; // User-visible name
private String argument; // Renderer-specific configuration
private Integer sortWeight; // Display order
// Constructor and methods
public RenderingMode(ReportRenderer renderer, String label,
String argument, Integer sortWeight) {
this.renderer = renderer;
this.label = label;
this.argument = argument;
this.sortWeight = sortWeight;
}
}ReportData Structure
public class ReportData {
private ReportDefinition definition;
private EvaluationContext context;
private Map<String, DataSet> dataSets;
// Access methods
public DataSet getDataSets().get(String key);
public EvaluationContext getContext();
// ... other methods
}Renderer Types
File-Based Renderers
Generate downloadable files:
Extend
ReportRendererinterfaceReturn appropriate content type and file extension
Write binary/text content to OutputStream
Examples: PDF generators, custom Excel formats, specialized XML
Web-Based Renderers
Display content in browser:
Implement
WebReportRendererinterfaceReturn content type "text/html"
Generate interactive web content
Examples: Custom dashboards, interactive charts, responsive layouts
Stream-Based Renderers
Send data to external systems:
Implement real-time data transmission
Handle API authentication and protocols
Manage error handling and retry logic
Examples: HL7 FHIR feeds, REST API submissions, messaging queues
Development Environment Setup
Prerequisites
Technical Requirements
Java 8+ development environment
OpenMRS Platform source code or SDK
Reporting Module source code
Maven for dependency management
IDE with OpenMRS development setup
Module Development Setup
<!-- pom.xml dependencies -->
<dependencies>
<dependency>
<groupId>org.openmrs.module</groupId>
<artifactId>reporting-api</artifactId>
<version>${reportingVersion}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openmrs.api</groupId>
<artifactId>openmrs-api</artifactId>
<version>${openMRSVersion}</version>
<scope>provided</scope>
</dependency>
</dependencies>Project Structure
custom-renderer-module/
├── api/
│ ├── src/main/java/
│ │ └── org/openmrs/module/customrenderer/
│ │ ├── renderer/
│ │ │ ├── CustomPdfRenderer.java
│ │ │ └── CustomXmlRenderer.java
│ │ └── util/
│ │ └── RenderingUtils.java
│ └── src/main/resources/
│ ├── moduleApplicationContext.xml
│ └── config.xml
├── omod/
│ └── src/main/resources/
│ ├── config.xml
│ └── web/
│ └── module/
│ └── customrenderer/
└── pom.xmlCreating Basic Custom Renderers
Example 1: Simple CSV Renderer
Basic Implementation
package org.openmrs.module.customrenderer.renderer;
import org.openmrs.module.reporting.report.ReportData;
import org.openmrs.module.reporting.report.definition.ReportDefinition;
import org.openmrs.module.reporting.report.renderer.ReportRenderer;
import org.openmrs.module.reporting.report.renderer.RenderingException;
import org.openmrs.module.reporting.report.RenderingMode;
@Component
public class CustomCSVRenderer implements ReportRenderer {
public Class<? extends ReportDefinition> getRenderedReportType() {
return ReportDefinition.class; // Supports all report types
}
public String getLabel() {
return "Custom CSV Export";
}
public String getFilenameExtension(ReportRequest request) {
return "csv";
}
public String getContentType(ReportRequest request) {
return "text/csv";
}
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
PrintWriter writer = new PrintWriter(new OutputStreamWriter(out, "UTF-8"));
try {
// Write CSV header
writer.println("Report Generated: " + new Date());
writer.println("Facility: " + reportData.getContext().getParameterValue("location"));
writer.println(); // Empty line
// Process each dataset
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
// Write dataset header
writer.println("Dataset: " + key);
// Write column headers
List<DataSetColumn> columns = dataset.getMetaData().getColumns();
String[] headers = columns.stream()
.map(col -> col.getLabel())
.toArray(String[]::new);
writer.println(String.join(",", headers));
// Write data rows
for (DataSetRow row : dataset) {
String[] values = new String[columns.size()];
for (int i = 0; i < columns.size(); i++) {
Object value = row.getColumnValue(columns.get(i));
values[i] = formatValue(value);
}
writer.println(String.join(",", values));
}
writer.println(); // Empty line between datasets
}
} finally {
writer.flush();
}
}
private String formatValue(Object value) {
if (value == null) {
return "";
}
String str = value.toString();
// Escape commas and quotes for CSV
if (str.contains(",") || str.contains("\"") || str.contains("\n")) {
str = "\"" + str.replace("\"", "\"\"") + "\"";
}
return str;
}
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition) {
return Arrays.asList(
new RenderingMode(this, getLabel(), null, Integer.MAX_VALUE)
);
}
}Example 2: PDF Report Renderer
Using iText for PDF Generation
package org.openmrs.module.customrenderer.renderer;
import com.itextpdf.text.*;
import com.itextpdf.text.pdf.*;
@Component
public class CustomPdfRenderer implements ReportRenderer {
public String getLabel() {
return "Professional PDF Report";
}
public String getFilenameExtension(ReportRequest request) {
return "pdf";
}
public String getContentType(ReportRequest request) {
return "application/pdf";
}
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
try {
Document document = new Document(PageSize.A4);
PdfWriter.getInstance(document, out);
document.open();
// Add title
Font titleFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 18, BaseColor.BLUE);
Paragraph title = new Paragraph("Health Facility Report", titleFont);
title.setAlignment(Element.ALIGN_CENTER);
document.add(title);
// Add metadata
document.add(new Paragraph(" ")); // Space
document.add(new Paragraph("Generated: " + new Date()));
document.add(new Paragraph("Facility: " +
reportData.getContext().getParameterValue("location")));
document.add(new Paragraph(" ")); // Space
// Process datasets
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
// Dataset title
Font headerFont = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 14);
document.add(new Paragraph(key, headerFont));
// Create table
PdfPTable table = new PdfPTable(dataset.getMetaData().getColumns().size());
table.setWidthPercentage(100);
// Add headers
for (DataSetColumn column : dataset.getMetaData().getColumns()) {
PdfPCell cell = new PdfPCell(new Phrase(column.getLabel()));
cell.setBackgroundColor(BaseColor.LIGHT_GRAY);
table.addCell(cell);
}
// Add data
for (DataSetRow row : dataset) {
for (DataSetColumn column : dataset.getMetaData().getColumns()) {
Object value = row.getColumnValue(column);
table.addCell(value != null ? value.toString() : "");
}
}
document.add(table);
document.add(new Paragraph(" ")); // Space
}
document.close();
} catch (DocumentException e) {
throw new RenderingException("Error generating PDF", e);
}
}
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition) {
return Arrays.asList(
new RenderingMode(this, getLabel(), null, Integer.MAX_VALUE)
);
}
}Registration and Configuration
Spring Configuration
<!-- moduleApplicationContext.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<beans xmlns="http://www.springframework.org/schema/beans"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:context="http://www.springframework.org/schema/context"
xsi:schemaLocation="http://www.springframework.org/schema/beans
http://www.springframework.org/schema/beans/spring-beans-3.0.xsd
http://www.springframework.org/schema/context
http://www.springframework.org/schema/context/spring-context-3.0.xsd">
<!-- Enable component scanning -->
<context:component-scan base-package="org.openmrs.module.customrenderer" />
<!-- Manual bean registration if needed -->
<bean id="customCSVRenderer"
class="org.openmrs.module.customrenderer.renderer.CustomCSVRenderer" />
<bean id="customPdfRenderer"
class="org.openmrs.module.customrenderer.renderer.CustomPdfRenderer" />
</beans>Extending Existing Renderers
Extending Standard Renderers
Enhanced Excel Renderer
public class EnhancedExcelRenderer extends XlsReportRenderer {
@Override
public String getLabel() {
return "Enhanced Excel Export";
}
@Override
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
// Create workbook
HSSFWorkbook workbook = new HSSFWorkbook();
// Create styles
HSSFCellStyle headerStyle = createHeaderStyle(workbook);
HSSFCellStyle dataStyle = createDataStyle(workbook);
// Add summary sheet
createSummarySheet(workbook, reportData);
// Add data sheets using parent functionality
super.render(reportData, argument, out);
// Add charts and formatting
enhanceWorkbook(workbook, reportData);
// Write to output
workbook.write(out);
workbook.close();
}
private HSSFCellStyle createHeaderStyle(HSSFWorkbook workbook) {
HSSFCellStyle style = workbook.createCellStyle();
HSSFFont font = workbook.createFont();
font.setBold(true);
font.setColor(HSSFColor.WHITE.index);
style.setFont(font);
style.setFillForegroundColor(HSSFColor.BLUE.index);
style.setFillPattern(HSSFCellStyle.SOLID_FOREGROUND);
style.setBorderBottom(HSSFCellStyle.BORDER_THIN);
style.setBorderTop(HSSFCellStyle.BORDER_THIN);
style.setBorderRight(HSSFCellStyle.BORDER_THIN);
style.setBorderLeft(HSSFCellStyle.BORDER_THIN);
return style;
}
private void createSummarySheet(HSSFWorkbook workbook, ReportData reportData) {
HSSFSheet sheet = workbook.createSheet("Summary");
// Add report metadata
int rowNum = 0;
HSSFRow row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("Report Summary");
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("Generated:");
row.createCell(1).setCellValue(new Date().toString());
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("Facility:");
row.createCell(1).setCellValue(
reportData.getContext().getParameterValue("location").toString()
);
// Add dataset summaries
rowNum++; // Empty row
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
row = sheet.createRow(rowNum++);
row.createCell(0).setCellValue("Dataset: " + key);
row.createCell(1).setCellValue("Rows: " + dataset.size());
}
// Auto-size columns
sheet.autoSizeColumn(0);
sheet.autoSizeColumn(1);
}
}Custom Web Renderer
public class DashboardWebRenderer implements WebReportRenderer {
public String getLabel() {
return "Interactive Dashboard";
}
public String getLinkUrl(ReportDefinition reportDefinition) {
return "module/customrenderer/dashboard.form";
}
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition) {
// Only show for specific report types
if (reportDefinition instanceof PeriodIndicatorReportDefinition) {
return Arrays.asList(
new RenderingMode(this, getLabel(), null, Integer.MAX_VALUE - 10)
);
}
return Collections.emptyList();
}
}Renderer Configuration Options
Parameterized Renderers
public class ConfigurableCSVRenderer implements ReportRenderer {
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
// Parse configuration from argument
CSVConfig config = parseConfiguration(argument);
PrintWriter writer = new PrintWriter(new OutputStreamWriter(out, config.getEncoding()));
// Use configuration for formatting
String delimiter = config.getDelimiter();
String dateFormat = config.getDateFormat();
boolean includeHeaders = config.isIncludeHeaders();
// Render based on configuration...
}
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition) {
return Arrays.asList(
new RenderingMode(this, "CSV (Standard)", "standard", 100),
new RenderingMode(this, "CSV (Pipe Delimited)", "pipe", 101),
new RenderingMode(this, "CSV (No Headers)", "noheaders", 102)
);
}
private CSVConfig parseConfiguration(String argument) {
CSVConfig config = new CSVConfig();
switch (argument) {
case "pipe":
config.setDelimiter("|");
break;
case "noheaders":
config.setIncludeHeaders(false);
break;
default:
// Standard configuration
break;
}
return config;
}
}Advanced Integration Patterns
REST API Integration
Real-time Data Submission
@Component
public class HL7FHIRRenderer implements ReportRenderer {
@Autowired
private FHIRClient fhirClient;
public String getLabel() {
return "Submit to FHIR Server";
}
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
try {
// Convert report data to FHIR Bundle
Bundle bundle = convertToFHIRBundle(reportData);
// Submit to FHIR server
MethodOutcome outcome = fhirClient.create()
.resource(bundle)
.execute();
// Write response to output
String response = createResponseMessage(outcome);
out.write(response.getBytes("UTF-8"));
} catch (Exception e) {
throw new RenderingException("Failed to submit to FHIR server", e);
}
}
private Bundle convertToFHIRBundle(ReportData reportData) {
Bundle bundle = new Bundle();
bundle.setType(Bundle.BundleType.COLLECTION);
// Add report metadata
Composition composition = new Composition();
composition.setStatus(Composition.CompositionStatus.FINAL);
composition.setType(createCodeableConcept("health-quality-report"));
composition.setTitle("OpenMRS Health Report");
composition.setDate(new Date());
bundle.addEntry().setResource(composition);
// Convert patient data to FHIR Patient resources
DataSet patients = reportData.getDataSets().get("patientList");
if (patients != null) {
for (DataSetRow row : patients) {
Patient patient = convertToFHIRPatient(row);
bundle.addEntry().setResource(patient);
}
}
return bundle;
}
private Patient convertToFHIRPatient(DataSetRow row) {
Patient patient = new Patient();
// Set identifier
String patientId = row.getColumnValue("patientId").toString();
patient.addIdentifier()
.setSystem("http://openmrs.org/patient-id")
.setValue(patientId);
// Set name
String givenName = (String) row.getColumnValue("givenName");
String familyName = (String) row.getColumnValue("familyName");
patient.addName()
.addGiven(givenName)
.setFamily(familyName);
// Set gender
String gender = (String) row.getColumnValue("gender");
if ("M".equals(gender)) {
patient.setGender(Enumerations.AdministrativeGender.MALE);
} else if ("F".equals(gender)) {
patient.setGender(Enumerations.AdministrativeGender.FEMALE);
}
// Set birth date
Date birthDate = (Date) row.getColumnValue("birthdate");
if (birthDate != null) {
patient.setBirthDate(birthDate);
}
return patient;
}
}
Message Queue Integration
Asynchronous Report Processing
@Component
public class QueuedReportRenderer implements ReportRenderer {
@Autowired
private JmsTemplate jmsTemplate;
@Value("${custom.renderer.queue.name}")
private String queueName;
public String getLabel() {
return "Queue for Processing";
}
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
try {
// Serialize report data
String serializedData = serializeReportData(reportData);
// Create message
ReportMessage message = new ReportMessage();
message.setReportId(reportData.getDefinition().getUuid());
message.setTimestamp(new Date());
message.setData(serializedData);
message.setProcessingType(argument);
// Send to queue
jmsTemplate.convertAndSend(queueName, message);
// Write confirmation
String confirmation = "Report queued for processing. ID: " +
message.getReportId();
out.write(confirmation.getBytes("UTF-8"));
} catch (Exception e) {
throw new RenderingException("Failed to queue report", e);
}
}
private String serializeReportData(ReportData reportData) {
// Implement serialization logic
// Could use JSON, XML, or custom format
ObjectMapper mapper = new ObjectMapper();
try {
return mapper.writeValueAsString(convertToTransferObject(reportData));
} catch (JsonProcessingException e) {
throw new RuntimeException("Serialization failed", e);
}
}
}
Database Integration
Direct Database Export
@Component
public class DatabaseExportRenderer implements ReportRenderer {
@Autowired
private DataSource externalDataSource;
public String getLabel() {
return "Export to External Database";
}
public void render(ReportData reportData, String argument,
OutputStream out) throws IOException, RenderingException {
Connection connection = null;
try {
connection = externalDataSource.getConnection();
connection.setAutoCommit(false);
// Process each dataset
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
exportDataSet(connection, key, dataset);
}
connection.commit();
String result = "Successfully exported " +
reportData.getDataSets().size() + " datasets";
out.write(result.getBytes("UTF-8"));
} catch (SQLException e) {
if (connection != null) {
try {
connection.rollback();
} catch (SQLException rollbackEx) {
// Log rollback error
}
}
throw new RenderingException("Database export failed", e);
} finally {
if (connection != null) {
try {
connection.close();
} catch (SQLException e) {
// Log close error
}
}
}
}
private void exportDataSet(Connection connection, String datasetName,
DataSet dataset) throws SQLException {
// Dynamic table creation based on dataset structure
String tableName = "openmrs_" + datasetName.toLowerCase();
createTableIfNotExists(connection, tableName, dataset.getMetaData());
// Prepare insert statement
String insertSQL = buildInsertSQL(tableName, dataset.getMetaData());
PreparedStatement statement = connection.prepareStatement(insertSQL);
try {
// Insert data rows
for (DataSetRow row : dataset) {
int paramIndex = 1;
for (DataSetColumn column : dataset.getMetaData().getColumns()) {
Object value = row.getColumnValue(column);
statement.setObject(paramIndex++, value);
}
statement.addBatch();
}
statement.executeBatch();
} finally {
statement.close();
}
}
}
Testing and Deployment
Unit Testing Custom Renderers
Test Framework Setup
@RunWith(MockitoJUnitRunner.class)
public class CustomCSVRendererTest {
@Mock
private ReportData mockReportData;
@Mock
private DataSet mockDataSet;
@Mock
private EvaluationContext mockContext;
@InjectMocks
private CustomCSVRenderer renderer;
@Test
public void shouldRenderBasicCSV() throws Exception {
// Setup test data
setupMockReportData();
// Render to byte array
ByteArrayOutputStream output = new ByteArrayOutputStream();
renderer.render(mockReportData, null, output);
// Verify output
String csv = output.toString("UTF-8");
assertThat(csv, containsString("Patient ID,Name,Age"));
assertThat(csv, containsString("12345,John Doe,45"));
}
@Test
public void shouldHandleEmptyDataSet() throws Exception {
// Setup empty dataset
setupEmptyMockData();
ByteArrayOutputStream output = new ByteArrayOutputStream();
renderer.render(mockReportData, null, output);
String csv = output.toString("UTF-8");
assertThat(csv, not(isEmptyString()));
assertThat(csv, containsString("Report Generated:"));
}
@Test
public void shouldEscapeCSVSpecialCharacters() throws Exception {
// Setup data with commas and quotes
setupSpecialCharacterData();
ByteArrayOutputStream output = new ByteArrayOutputStream();
renderer.render(mockReportData, null, output);
String csv = output.toString("UTF-8");
assertThat(csv, containsString("\"Doe, John\""));
assertThat(csv, containsString("\"Says \"\"Hello\"\"\""));
}
private void setupMockReportData() {
// Mock dataset structure
List<DataSetColumn> columns = Arrays.asList(
new DataSetColumn("patientId", "Patient ID", String.class),
new DataSetColumn("name", "Name", String.class),
new DataSetColumn("age", "Age", Integer.class)
);
DataSetMetaData metaData = new DataSetMetaData();
metaData.setColumns(columns);
// Mock data rows
List<DataSetRow> rows = Arrays.asList(
createMockRow("12345", "John Doe", 45),
createMockRow("12346", "Jane Smith", 32)
);
when(mockDataSet.getMetaData()).thenReturn(metaData);
when(mockDataSet.iterator()).thenReturn(rows.iterator());
when(mockDataSet.size()).thenReturn(rows.size());
Map<String, DataSet> dataSets = new HashMap<>();
dataSets.put("patients", mockDataSet);
when(mockReportData.getDataSets()).thenReturn(dataSets);
when(mockReportData.getContext()).thenReturn(mockContext);
when(mockContext.getParameterValue("location")).thenReturn("Main Clinic");
}
}
Integration Testing
Testing with Real OpenMRS Environment
@SpringBootTest
@DirtiesContext
public class CustomRendererIntegrationTest extends BaseModuleContextSensitiveTest {
@Autowired
private ReportService reportService;
@Autowired
private CustomCSVRenderer customRenderer;
@Test
public void shouldIntegrateWithReportingFramework() throws Exception {
// Create test report definition
PeriodIndicatorReportDefinition report = createTestReport();
reportService.saveDefinition(report);
// Create test parameters
EvaluationContext context = new EvaluationContext();
context.addParameterValue("startDate", DateUtil.parseDate("2024-01-01", "yyyy-MM-dd"));
context.addParameterValue("endDate", DateUtil.parseDate("2024-01-31", "yyyy-MM-dd"));
context.addParameterValue("location", Context.getLocationService().getLocation(1));
// Generate report data
ReportData reportData = reportService.evaluate(report, context);
// Test custom renderer
ByteArrayOutputStream output = new ByteArrayOutputStream();
customRenderer.render(reportData, null, output);
// Verify integration works
String result = output.toString("UTF-8");
assertThat(result, not(isEmptyString()));
assertThat(result, containsString("Dataset:"));
}
@Test
public void shouldRegisterWithReportDesignManager() throws Exception {
// Verify renderer is available in system
Collection<ReportRenderer> renderers = Context.getService(ReportService.class)
.getAllReportRenderers();
boolean found = renderers.stream()
.anyMatch(r -> r instanceof CustomCSVRenderer);
assertTrue("Custom renderer should be registered", found);
}
}
Performance Testing
@Test
@Timeout(value = 30, unit = TimeUnit.SECONDS)
public void shouldRenderLargeDatasetWithinTimeLimit() throws Exception {
// Create large test dataset (10,000 patients)
ReportData largeReportData = createLargeTestData(10000);
// Measure rendering time
long startTime = System.currentTimeMillis();
ByteArrayOutputStream output = new ByteArrayOutputStream();
customRenderer.render(largeReportData, null, output);
long endTime = System.currentTimeMillis();
long renderTime = endTime - startTime;
// Verify performance
assertTrue("Rendering should complete within 30 seconds", renderTime < 30000);
assertTrue("Output should not be empty", output.size() > 0);
// Log performance metrics
System.out.println("Rendered " + largeReportData.getDataSets().get("patients").size() +
" patients in " + renderTime + "ms");
}
Deployment Process
Module Packaging
<!-- config.xml -->
<?xml version="1.0" encoding="UTF-8"?>
<module configVersion="1.2">
<id>customrenderer</id>
<name>Custom Renderer Module</name>
<version>1.0.0</version>
<package>org.openmrs.module.customrenderer</package>
<author>Your Organization</author>
<description>
Provides custom report renderers for specialized output formats
</description>
<require_version>1.9.0</require_version>
<require_modules>
<require_module version="0.9.0">
org.openmrs.module.reporting
</require_module>
</require_modules>
<!-- Global properties for configuration -->
<globalProperty>
<property>customrenderer.fhir.server.url</property>
<defaultValue>http://localhost:8080/fhir</defaultValue>
<description>FHIR server URL for data submission</description>
</globalProperty>
<globalProperty>
<property>customrenderer.queue.enabled</property>
<defaultValue>false</defaultValue>
<description>Enable queue-based report processing</description>
</globalProperty>
</module>
Installation and Configuration
# Build module
mvn clean package
# Deploy to OpenMRS
cp target/customrenderer-1.0.0.omod ${OPENMRS_HOME}/modules/
# Restart OpenMRS
systemctl restart openmrs
# Configure global properties
# Via web interface: Administration > Maintenance > Global PropertiesPost-Deployment Verification
@Component
public class RendererHealthCheck {
@PostConstruct
public void verifyInstallation() {
try {
// Test renderer registration
ReportService reportService = Context.getService(ReportService.class);
Collection<ReportRenderer> renderers = reportService.getAllReportRenderers();
long customRendererCount = renderers.stream()
.filter(r -> r.getClass().getPackage().getName().contains("customrenderer"))
.count();
if (customRendererCount == 0) {
log.error("No custom renderers found after installation");
} else {
log.info("Successfully registered {} custom renderers", customRendererCount);
}
// Test dependencies
verifyDependencies();
} catch (Exception e) {
log.error("Custom renderer health check failed", e);
}
}
private void verifyDependencies() {
// Check external dependencies
if (isHL7FHIRRendererEnabled()) {
try {
// Test FHIR server connectivity
String fhirUrl = Context.getAdministrationService()
.getGlobalProperty("customrenderer.fhir.server.url");
// Perform connectivity test
log.info("FHIR integration verified for URL: {}", fhirUrl);
} catch (Exception e) {
log.warn("FHIR server connectivity test failed", e);
}
}
}
}
Best Practices
Code Quality and Architecture
Separation of Concerns
// ❌ Poor: Everything in one class
public class MonolithicRenderer implements ReportRenderer {
public void render(ReportData reportData, String argument, OutputStream out) {
// Data transformation logic
// Formatting logic
// Output generation logic
// Error handling
// Configuration parsing
// All mixed together
}
}
// ✅ Good: Separated responsibilities
public class WellStructuredRenderer implements ReportRenderer {
@Autowired
private DataTransformer dataTransformer;
@Autowired
private OutputFormatter formatter;
@Autowired
private ConfigurationParser configParser;
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
try {
RenderConfig config = configParser.parse(argument);
TransformedData transformed = dataTransformer.transform(reportData, config);
formatter.writeToStream(transformed, out, config);
} catch (Exception e) {
handleRenderingError(e, out);
}
}
}
Error Handling Patterns
public class RobustRenderer implements ReportRenderer {
private static final Logger log = LoggerFactory.getLogger(RobustRenderer.class);
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
// Validate inputs
validateInputs(reportData, out);
try {
performRendering(reportData, argument, out);
} catch (IOException e) {
log.error("IO error during rendering", e);
writeErrorResponse(out, "Output generation failed");
throw e;
} catch (Exception e) {
log.error("Unexpected error during rendering", e);
writeErrorResponse(out, "Rendering failed due to system error");
throw new RenderingException("Rendering failed", e);
}
}
private void validateInputs(ReportData reportData, OutputStream out)
throws RenderingException {
if (reportData == null) {
throw new RenderingException("Report data cannot be null");
}
if (out == null) {
throw new RenderingException("Output stream cannot be null");
}
if (reportData.getDataSets() == null || reportData.getDataSets().isEmpty()) {
log.warn("No data sets found in report data");
}
}
private void writeErrorResponse(OutputStream out, String message) {
try {
String errorMsg = "Error: " + message + "\n";
out.write(errorMsg.getBytes("UTF-8"));
out.flush();
} catch (IOException e) {
log.error("Failed to write error response", e);
}
}
}
Performance Optimization
public class OptimizedRenderer implements ReportRenderer {
// Cache for expensive operations
private final Map<String, CompiledTemplate> templateCache = new ConcurrentHashMap<>();
// Connection pooling for external integrations
@Autowired
private PooledConnectionFactory connectionFactory;
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
// Use buffered output for better performance
BufferedOutputStream bufferedOut = new BufferedOutputStream(out, 8192);
try {
// Stream processing for large datasets
streamRenderDataSets(reportData, argument, bufferedOut);
} finally {
bufferedOut.flush();
}
}
private void streamRenderDataSets(ReportData reportData, String argument,
OutputStream out) throws IOException {
// Process datasets one at a time to minimize memory usage
for (Map.Entry<String, DataSet> entry : reportData.getDataSets().entrySet()) {
String dataSetName = entry.getKey();
DataSet dataSet = entry.getValue();
// Write dataset header
writeDataSetHeader(out, dataSetName);
// Stream rows without loading all into memory
try (DataSetIterator iterator = new DataSetIterator(dataSet)) {
while (iterator.hasNext()) {
DataSetRow row = iterator.next();
writeDataSetRow(out, row);
// Flush periodically for large datasets
if (iterator.getRowCount() % 1000 == 0) {
out.flush();
}
}
}
}
}
}
Security Considerations
Input Validation
public class SecureRenderer implements ReportRenderer {
// Whitelist of allowed file extensions
private static final Set<String> ALLOWED_EXTENSIONS =
Set.of("csv", "txt", "xml", "json");
// Maximum output size to prevent DoS
private static final long MAX_OUTPUT_SIZE = 50 * 1024 * 1024; // 50MB
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
// Validate configuration argument
validateArgument(argument);
// Wrap output stream with size limit
LimitedOutputStream limitedOut = new LimitedOutputStream(out, MAX_OUTPUT_SIZE);
// Perform rendering with security checks
performSecureRendering(reportData, argument, limitedOut);
}
private void validateArgument(String argument) throws RenderingException {
if (argument != null) {
// Prevent injection attacks
if (argument.contains("../") || argument.contains("..\\")) {
throw new RenderingException("Invalid argument: path traversal detected");
}
// Validate file extension if specified
if (argument.contains(".")) {
String extension = argument.substring(argument.lastIndexOf(".") + 1);
if (!ALLOWED_EXTENSIONS.contains(extension.toLowerCase())) {
throw new RenderingException("Invalid file extension: " + extension);
}
}
}
}
private void sanitizeTextContent(String input) {
// Remove potentially dangerous content
return input.replaceAll("<script[^>]*>.*?</script>", "")
.replaceAll("javascript:", "")
.replaceAll("on\\w+=\"[^\"]*\"", "");
}
}
Access Control Integration
@Component
public class RoleBasedRenderer implements ReportRenderer {
@Autowired
private UserContext userContext;
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition) {
User currentUser = userContext.getAuthenticatedUser();
// Different rendering options based on user role
List<RenderingMode> modes = new ArrayList<>();
if (currentUser.hasRole("Data Manager")) {
modes.add(new RenderingMode(this, "Full Data Export", "full", 100));
modes.add(new RenderingMode(this, "Statistical Export", "stats", 101));
}
if (currentUser.hasRole("Clinician")) {
modes.add(new RenderingMode(this, "Patient Summary", "clinical", 200));
}
if (currentUser.hasRole("Administrator")) {
modes.add(new RenderingMode(this, "System Export", "system", 300));
}
return modes;
}
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
// Verify user still has permission to use this rendering mode
if (!userHasPermissionForMode(argument)) {
throw new RenderingException("Insufficient permissions for rendering mode");
}
// Proceed with rendering
performAuthorizedRendering(reportData, argument, out);
}
}
Configuration Management
Externalized Configuration
@Component
@ConfigurationProperties(prefix = "customrenderer")
public class RendererConfiguration {
// FHIR integration settings
private String fhirServerUrl;
private String fhirClientId;
private String fhirClientSecret;
private int fhirTimeout = 30000;
// Performance settings
private int maxConcurrentRenders = 5;
private long maxOutputSize = 50 * 1024 * 1024;
private int bufferSize = 8192;
// Security settings
private boolean enableAuditLogging = true;
private Set<String> allowedFileExtensions = Set.of("csv", "txt", "xml");
// Getters and setters...
@PostConstruct
public void validateConfiguration() {
if (fhirServerUrl != null && !isValidUrl(fhirServerUrl)) {
throw new IllegalArgumentException("Invalid FHIR server URL: " + fhirServerUrl);
}
if (maxConcurrentRenders <= 0) {
throw new IllegalArgumentException("Max concurrent renders must be positive");
}
}
}
Environment-Specific Settings
# application-dev.properties
customrenderer.fhir.server.url=http://localhost:8080/fhir
customrenderer.max-concurrent-renders=2
customrenderer.enable-audit-logging=false
# application-prod.properties
customrenderer.fhir.server.url=https://fhir.production.org
customrenderer.max-concurrent-renders=10
customrenderer.enable-audit-logging=true
customrenderer.max-output-size=100MBExamples and Code Samples
Complete Example: JSON API Renderer
Full Implementation
@Component
public class JSONAPIRenderer implements ReportRenderer {
private static final Logger log = LoggerFactory.getLogger(JSONAPIRenderer.class);
private final ObjectMapper objectMapper;
public JSONAPIRenderer() {
this.objectMapper = new ObjectMapper();
this.objectMapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
this.objectMapper.setDateFormat(new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'"));
}
@Override
public Class<? extends ReportDefinition> getRenderedReportType() {
return ReportDefinition.class;
}
@Override
public String getLabel() {
return "JSON API Format";
}
@Override
public String getFilenameExtension(ReportRequest request) {
return "json";
}
@Override
public String getContentType(ReportRequest request) {
return "application/json";
}
@Override
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
try {
// Create JSON structure
Map<String, Object> jsonResponse = new HashMap<>();
// Add metadata
jsonResponse.put("metadata", createMetadata(reportData));
// Add data
jsonResponse.put("data", convertDataSets(reportData));
// Add links (HATEOAS)
jsonResponse.put("links", createLinks(reportData));
// Write JSON to output
objectMapper.writeValue(out, jsonResponse);
} catch (Exception e) {
log.error("Failed to render JSON API response", e);
throw new RenderingException("JSON rendering failed", e);
}
}
private Map<String, Object> createMetadata(ReportData reportData) {
Map<String, Object> metadata = new HashMap<>();
metadata.put("reportId", reportData.getDefinition().getUuid());
metadata.put("reportName", reportData.getDefinition().getName());
metadata.put("generatedAt", new Date());
metadata.put("generatedBy", Context.getAuthenticatedUser().getUsername());
// Add parameters
if (reportData.getContext() != null) {
Map<String, Object> parameters = new HashMap<>();
for (String param : reportData.getContext().getParameterNames()) {
Object value = reportData.getContext().getParameterValue(param);
parameters.put(param, value);
}
metadata.put("parameters", parameters);
}
// Add dataset info
Map<String, Object> datasetInfo = new HashMap<>();
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
Map<String, Object> info = new HashMap<>();
info.put("rowCount", dataset.size());
info.put("columnCount", dataset.getMetaData().getColumns().size());
datasetInfo.put(key, info);
}
metadata.put("datasets", datasetInfo);
return metadata;
}
private Map<String, Object> convertDataSets(ReportData reportData) {
Map<String, Object> dataSets = new HashMap<>();
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
dataSets.put(key, convertDataSet(dataset));
}
return dataSets;
}
private Map<String, Object> convertDataSet(DataSet dataset) {
Map<String, Object> result = new HashMap<>();
// Add column definitions
List<Map<String, Object>> columns = new ArrayList<>();
for (DataSetColumn column : dataset.getMetaData().getColumns()) {
Map<String, Object> columnDef = new HashMap<>();
columnDef.put("name", column.getName());
columnDef.put("label", column.getLabel());
columnDef.put("datatype", column.getDataType().getSimpleName());
columns.add(columnDef);
}
result.put("columns", columns);
// Add rows
List<Map<String, Object>> rows = new ArrayList<>();
for (DataSetRow row : dataset) {
Map<String, Object> rowData = new HashMap<>();
for (DataSetColumn column : dataset.getMetaData().getColumns()) {
Object value = row.getColumnValue(column);
rowData.put(column.getName(), value);
}
rows.add(rowData);
}
result.put("rows", rows);
return result;
}
private Map<String, Object> createLinks(ReportData reportData) {
Map<String, Object> links = new HashMap<>();
String baseUrl = getBaseUrl();
String reportId = reportData.getDefinition().getUuid();
links.put("self", baseUrl + "/reports/" + reportId + ".json");
links.put("html", baseUrl + "/reports/" + reportId + ".html");
links.put("csv", baseUrl + "/reports/" + reportId + ".csv");
return links;
}
@Override
public Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition) {
return Arrays.asList(
new RenderingMode(this, getLabel(), null, Integer.MAX_VALUE - 50)
);
}
}
Example: Webhook Notification Renderer
Real-time Integration
@Component
public class WebhookRenderer implements ReportRenderer {
@Autowired
private RestTemplate restTemplate;
@Autowired
private RendererConfiguration config;
@Override
public String getLabel() {
return "Webhook Notification";
}
@Override
public void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException {
try {
// Parse webhook configuration from argument
WebhookConfig webhookConfig = parseWebhookConfig(argument);
// Create notification payload
WebhookPayload payload = createPayload(reportData, webhookConfig);
// Send to webhook URL
HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
if (webhookConfig.getAuthToken() != null) {
headers.setBearerAuth(webhookConfig.getAuthToken());
}
HttpEntity<WebhookPayload> request = new HttpEntity<>(payload, headers);
ResponseEntity<String> response = restTemplate.postForEntity(
webhookConfig.getUrl(), request, String.class);
// Write response to output
Map<String, Object> result = new HashMap<>();
result.put("status", "sent");
result.put("webhookUrl", webhookConfig.getUrl());
result.put("responseCode", response.getStatusCodeValue());
result.put("timestamp", new Date());
ObjectMapper mapper = new ObjectMapper();
mapper.writeValue(out, result);
} catch (Exception e) {
throw new RenderingException("Webhook notification failed", e);
}
}
private WebhookPayload createPayload(ReportData reportData, WebhookConfig config) {
WebhookPayload payload = new WebhookPayload();
payload.setEventType("report.generated");
payload.setTimestamp(new Date());
payload.setReportId(reportData.getDefinition().getUuid());
payload.setReportName(reportData.getDefinition().getName());
// Add summary statistics
Map<String, Object> summary = new HashMap<>();
for (String key : reportData.getDataSets().keySet()) {
DataSet dataset = reportData.getDataSets().get(key);
summary.put(key + ".rowCount", dataset.size());
}
payload.setSummary(summary);
// Add download links if configured
if (config.isIncludeDownloadLinks()) {
Map<String, String> downloads = new HashMap<>();
String baseUrl = getBaseUrl();
String reportId = reportData.getDefinition().getUuid();
downloads.put("csv", baseUrl + "/reports/" + reportId + ".csv");
downloads.put("excel", baseUrl + "/reports/" + reportId + ".xls");
downloads.put("json", baseUrl + "/reports/" + reportId + ".json");
payload.setDownloadLinks(downloads);
}
return payload;
}
}
Quick Reference
Essential Interfaces
Basic Renderer Interface
public interface ReportRenderer {
Class<? extends ReportDefinition> getRenderedReportType();
String getFilenameExtension(ReportRequest request);
String getContentType(ReportRequest request);
String getLabel();
void render(ReportData reportData, String argument, OutputStream out)
throws IOException, RenderingException;
Collection<RenderingMode> getRenderingModes(ReportDefinition reportDefinition);
}Web Renderer Interface
public interface WebReportRenderer extends ReportRenderer {
String getLinkUrl(ReportDefinition reportDefinition);
}Common Patterns
Renderer Registration
<!-- Spring configuration -->
<bean id="customRenderer"
class="org.openmrs.module.custom.CustomRenderer" />Error Handling
try {
// Rendering logic
} catch (IOException e) {
throw e; // Re-throw IO exceptions
} catch (Exception e) {
throw new RenderingException("Rendering failed", e);
}Performance Monitoring
long startTime = System.currentTimeMillis();
// Perform rendering
long duration = System.currentTimeMillis() - startTime;
log.info("Rendering completed in {}ms", duration);Configuration Templates
Module Dependencies
<require_modules>
<require_module version="0.9.0">
org.openmrs.module.reporting
</require_module>
</require_modules>Global Properties
<globalProperty>
<property>custom.renderer.enabled</property>
<defaultValue>true</defaultValue>
<description>Enable custom renderers</description>
</globalProperty>Ready to implement? Start with a simple file-based renderer, then gradually add more sophisticated features like external integrations and custom web interfaces. Remember to follow OpenMRS development best practices and thoroughly test your renderers before deployment.