Developing Custom Output Renderers

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 ReportRenderer interface

  • Return 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 WebReportRenderer interface

  • Return 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.xml

Creating 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 Properties

Post-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=100MB

Examples 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.