diff --git a/pom.xml b/pom.xml index 7c233abaa..4b12e3dcb 100644 --- a/pom.xml +++ b/pom.xml @@ -508,6 +508,25 @@ sd-jwt 1.5 + + + + io.mosip.injivcrenderer + injivcrenderer-jvm + 0.1.0-SNAPSHOT + + + + org.jetbrains.kotlin + kotlin-stdlib + 2.0.0 + + + + com.github.jknack + handlebars + 4.3.1 + diff --git a/src/main/java/io/mosip/mimoto/config/InjiVcRendererConfig.java b/src/main/java/io/mosip/mimoto/config/InjiVcRendererConfig.java new file mode 100644 index 000000000..dd55cb3b2 --- /dev/null +++ b/src/main/java/io/mosip/mimoto/config/InjiVcRendererConfig.java @@ -0,0 +1,13 @@ +package io.mosip.mimoto.config; + +import io.mosip.injivcrenderer.InjiVcRenderer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class InjiVcRendererConfig { + @Bean + public InjiVcRenderer injiVcRenderer() { + return new InjiVcRenderer(); + } +} \ No newline at end of file diff --git a/src/main/java/io/mosip/mimoto/controller/CredentialsController.java b/src/main/java/io/mosip/mimoto/controller/CredentialsController.java index cd1e62ae2..95a6ddb10 100644 --- a/src/main/java/io/mosip/mimoto/controller/CredentialsController.java +++ b/src/main/java/io/mosip/mimoto/controller/CredentialsController.java @@ -2,6 +2,7 @@ import io.mosip.mimoto.constant.SwaggerLiteralConstants; import io.mosip.mimoto.core.http.ResponseWrapper; +import io.mosip.mimoto.dto.CredentialResponse; import io.mosip.mimoto.dto.idp.TokenResponseDTO; import io.mosip.mimoto.exception.ApiNotAccessibleException; import io.mosip.mimoto.exception.InvalidCredentialResourceException; @@ -63,12 +64,12 @@ public ResponseEntity downloadCredentialAsPDF(@RequestParam Map getVerifiableCredential( String dispositionType = "download".equalsIgnoreCase(action) ? "attachment" : "inline"; String contentDisposition = String.format("%s; filename=\"%s\"", dispositionType, walletCredentialResponseDTO.getFileName()); + MediaType contentType = walletCredentialResponseDTO.getFileName().endsWith(".svg") ? + MediaType.valueOf("image/svg+xml") : MediaType.APPLICATION_PDF; + return ResponseEntity.ok() .header(HttpHeaders.ACCESS_CONTROL_EXPOSE_HEADERS, HttpHeaders.CONTENT_DISPOSITION) .header(HttpHeaders.CONTENT_DISPOSITION, contentDisposition) - .contentType(MediaType.APPLICATION_PDF) + .contentType(contentType) .body(walletCredentialResponseDTO.getFileContentStream()); } catch (CredentialNotFoundException e) { log.error("Credential not found for walletId: {} and credentialId: {}", walletId, credentialId, e); diff --git a/src/main/java/io/mosip/mimoto/dto/CredentialResponse.java b/src/main/java/io/mosip/mimoto/dto/CredentialResponse.java new file mode 100644 index 000000000..974350be9 --- /dev/null +++ b/src/main/java/io/mosip/mimoto/dto/CredentialResponse.java @@ -0,0 +1,16 @@ +package io.mosip.mimoto.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; +import org.springframework.http.MediaType; +import java.io.ByteArrayInputStream; + + +@Data +@AllArgsConstructor +@NoArgsConstructor +public class CredentialResponse { + private ByteArrayInputStream content; + private MediaType mediaType; +} \ No newline at end of file diff --git a/src/main/java/io/mosip/mimoto/service/CredentialPDFGeneratorService.java b/src/main/java/io/mosip/mimoto/service/CredentialPDFGeneratorService.java index ae3b4112e..a30303b03 100644 --- a/src/main/java/io/mosip/mimoto/service/CredentialPDFGeneratorService.java +++ b/src/main/java/io/mosip/mimoto/service/CredentialPDFGeneratorService.java @@ -4,6 +4,8 @@ import com.authlete.sd.SDJWT; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.core.type.TypeReference; +import com.github.jknack.handlebars.Handlebars; import com.google.zxing.BarcodeFormat; import com.google.zxing.WriterException; import com.google.zxing.client.j2se.MatrixToImageWriter; @@ -27,18 +29,20 @@ import io.mosip.pixelpass.PixelPass; import lombok.extern.slf4j.Slf4j; import org.apache.commons.collections.CollectionUtils; +import org.apache.commons.collections.MapUtils; import org.apache.commons.lang.StringUtils; import org.apache.velocity.VelocityContext; import org.apache.velocity.app.Velocity; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; +import io.mosip.injivcrenderer.InjiVcRenderer; +import org.springframework.web.client.RestTemplate; +import io.mosip.mimoto.dto.CredentialResponse; import java.awt.image.BufferedImage; -import java.io.ByteArrayInputStream; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.StringWriter; +import java.io.*; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.*; @@ -68,6 +72,12 @@ private record SelectedFace(String key, String face) {} @Autowired private CredentialFormatHandlerFactory credentialFormatHandlerFactory; + @Autowired + private InjiVcRenderer injiVcRenderer; + + @Autowired + private RestTemplate restTemplate; + @Value("${mosip.inji.ovp.qrdata.pattern}") private String ovpQRDataPattern; @@ -86,7 +96,13 @@ private record SelectedFace(String key, String face) {} @Value("${mosip.injiweb.mask.disclosures:true}") private boolean maskDisclosures; - public ByteArrayInputStream generatePdfForVerifiableCredential(String credentialConfigurationId, VCCredentialResponse vcCredentialResponse, IssuerDTO issuerDTO, CredentialsSupportedResponse credentialsSupportedResponse, String dataShareUrl, String credentialValidity, String locale) throws Exception { + public CredentialResponse generatePdfForVerifiableCredential(String credentialConfigurationId, VCCredentialResponse vcCredentialResponse, IssuerDTO issuerDTO, CredentialsSupportedResponse credentialsSupportedResponse, String dataShareUrl, String credentialValidity, String locale) throws Exception { + // ByteArrayInputStream renderedVcStream = renderVcWithHandlebars(vcCredentialResponse); + CredentialResponse credentialResponse = renderVcWithInjiRender(vcCredentialResponse); + if (credentialResponse != null) { + return credentialResponse; + } + // Get the appropriate processor based on format CredentialFormatHandler processor = credentialFormatHandlerFactory.getHandler(vcCredentialResponse.getFormat()); @@ -214,17 +230,25 @@ private String formatValue(Object val, String locale) { return String.join(", ", (List) list); } else if (list.getFirst() instanceof Map) { return list.stream() + .filter(Objects::nonNull) .map(item -> (Map) item) - .filter(m -> LocaleUtils.matchesLocale(m.get("language").toString(), locale)) - .map(m -> m.get("value").toString()) + .filter(m -> { + Object lang = m.get("language"); // Safely get language + return lang != null && LocaleUtils.matchesLocale(lang.toString(), locale); + }) + .map(m -> { + Object value = m.get("value"); // Safely get value + return value != null ? value.toString() : null; + }) + .filter(Objects::nonNull) .findFirst() .orElse(""); } } - return val.toString(); + return val != null ? val.toString() : ""; } - private ByteArrayInputStream renderVCInCredentialTemplate(Map data, String issuerId, String credentialConfigurationId) { + private CredentialResponse renderVCInCredentialTemplate(Map data, String issuerId, String credentialConfigurationId) { String credentialTemplate = utilities.getCredentialSupportedTemplateString(issuerId, credentialConfigurationId); Properties props = new Properties(); props.setProperty("resource.loader", "class"); @@ -243,7 +267,7 @@ private ByteArrayInputStream renderVCInCredentialTemplate(Map da ConverterProperties converterProperties = new ConverterProperties(); converterProperties.setFontProvider(defaultFont); HtmlConverter.convertToPdf(mergedHtml, pdfwriter, converterProperties); - return new ByteArrayInputStream(outputStream.toByteArray()); + return new CredentialResponse(new ByteArrayInputStream(outputStream.toByteArray()), MediaType.APPLICATION_PDF); } private String constructQRCodeWithVCData(VCCredentialResponse vcCredentialResponse) throws JsonProcessingException, WriterException { @@ -267,5 +291,219 @@ private String constructQRCode(String qrData) throws WriterException { BufferedImage qrImage = MatrixToImageWriter.toBufferedImage(bitMatrix); return Utilities.encodeToString(qrImage, "png"); } + + private CredentialResponse renderVcWithInjiRender(VCCredentialResponse vcCredentialResponse) throws JsonProcessingException { + // Parsing renderMethod with strict typing + if (vcCredentialResponse.getCredential() == null) { + return null; + } + // Convert VCCredentialResponse.credential to JSON string + String vcJson = objectMapper.writeValueAsString(vcCredentialResponse.getCredential()); + + @SuppressWarnings("unchecked") + Map credentialMap = objectMapper.convertValue(vcCredentialResponse.getCredential(), + new TypeReference>() { + }); + List> renderMethod = objectMapper.convertValue(credentialMap.get("renderMethod"), + new TypeReference>>() { + }); + + if (CollectionUtils.isEmpty(renderMethod)) { + return null; + } + + // Process first render method (assuming it's the primary one) + Map method = renderMethod.getFirst(); + String renderSuite = (String) method.get("renderSuite"); + + @SuppressWarnings("unchecked") + Map template = (Map) method.get("template"); + + if (template != null && "svg-mustache".equals(renderSuite)) { + List svgImage = injiVcRenderer.renderSvg(vcJson); + // considering only first svg in the list + String svgContent = svgImage.getFirst(); + + return new CredentialResponse(new ByteArrayInputStream(svgContent.getBytes(StandardCharsets.UTF_8)), MediaType.valueOf("image/svg+xml")); + } else if (template != null && "pdf-mustache".equals(renderSuite)) { + // pdf template + return new CredentialResponse(); + } + return null; + } + + private CredentialResponse convertSvgToPdf(String svgContent, MediaType mediaType) { + if (StringUtils.isEmpty(svgContent)) { + return null; + } + + // Convert SVG to PDF + try (ByteArrayOutputStream pdfOutputStream = new ByteArrayOutputStream(); + PdfWriter pdfWriter = new PdfWriter(pdfOutputStream)) { + + String html = "" + svgContent + ""; + ConverterProperties converterProperties = new ConverterProperties(); + converterProperties.setFontProvider(new DefaultFontProvider(true, false, false)); + HtmlConverter.convertToPdf(html, pdfWriter, converterProperties); + + return new CredentialResponse(new ByteArrayInputStream(pdfOutputStream.toByteArray()), mediaType); + } catch (IOException e) { + log.error("Error converting SVG to PDF: {}", e.getMessage()); + throw new RuntimeException("Failed to convert SVG to PDF", e); + } + } + + private CredentialResponse renderVcWithHandlebars(VCCredentialResponse vcCredentialResponse) { + // Parsing renderMethod with strict typing + if (vcCredentialResponse.getCredential() == null) { + return null; + } + + @SuppressWarnings("unchecked") + Map credentialMap = objectMapper.convertValue(vcCredentialResponse.getCredential(), + new TypeReference>() { + }); + List> renderMethod = objectMapper.convertValue(credentialMap.get("renderMethod"), + new TypeReference>>() { + }); + Map credentialSubject = objectMapper.convertValue(credentialMap.get("credentialSubject"), + new TypeReference>() { + }); + + if (CollectionUtils.isEmpty(renderMethod) || MapUtils.isEmpty(credentialSubject)) { + return null; + } + + // Process first render method (assuming it's the primary one) + Map method = renderMethod.getFirst(); + @SuppressWarnings("unchecked") + Map template = (Map) method.get("template"); + String renderSuite = (String) method.get("renderSuite"); + + if (template != null && "svg-mustache".equals(renderSuite)) { + String svgTemplateUri = (String) template.get("id"); + String svgTemplate = getSvgTemplate(svgTemplateUri); +// String svgTemplate = """ +// Crops: {{/credentialSubject/crops/*/cropName}} +// +// Single line access: {{/credentialSubject/phoneNumber}} +// """; + Map templateData = prepareTemplateData(credentialSubject); + + String renderedSvg = renderSvgWithHandlebars(svgTemplate, templateData); + return convertSvgToPdf(renderedSvg, MediaType.valueOf("image/svg+xml")); + } + return null; + } + + private String getSvgTemplate(String svgTemplateUri) { + try { + String svgTemplate = restTemplate.getForObject(svgTemplateUri, String.class); + if (svgTemplate == null) { + log.error("Failed to fetch SVG template from URI: {}", svgTemplateUri); + return null; + } + return svgTemplate; + } catch (Exception e) { + log.error("Error fetching SVG template from URI {}: {}", svgTemplateUri, e.getMessage()); + throw new RuntimeException("Failed to fetch SVG template", e); + } + } + + private Map prepareTemplateData(Map credentialSubject) { + Map credential = Map.of("credentialSubject", credentialSubject); + return credential; + /* + // Add all credential subject data directly + data.putAll(credentialSubject); + + // todo: generalize based on type and flatten + // Handle special cases for nested objects + if (credentialSubject.get("address") instanceof Map) { + Map address = (Map) credentialSubject.get("address"); + // Flatten address fields or keep as is based on template needs + data.put("address", address); + } + + // Handle arrays/lists + if (credentialSubject.get("crops") instanceof List) { + List crops = (List) credentialSubject.get("crops"); + data.put("crops", crops); + } + + // Handle nested objects with units + if (credentialSubject.get("totalLandArea") instanceof Map) { + Map landArea = (Map) credentialSubject.get("totalLandArea"); + // You might want to format this as a string with unit + data.put("totalLandArea", landArea); + } + + // Handle special cases like face image + if (credentialSubject.containsKey("face")) { + data.put("face", credentialSubject.get("face")); + } + + return data; + */ + } + + private String renderSvgWithHandlebars(String template, Map data) { + try { + Handlebars handlebars = new Handlebars(); + handlebars.setStartDelimiter("{{"); + handlebars.setEndDelimiter("}}"); + + // Register helper to handle forward slash notation + handlebars.registerHelper("get", (context, options) -> { + String path = options.fn.text(); + if (path.startsWith("/")) { + path = path.substring(1); // Remove leading slash + } + String[] parts = path.split("/"); + Object current = context; + + for (String part : parts) { + if (part.equals("*")) { + // Handle array wildcard + if (current instanceof List) { + List list = (List) current; + return list.stream() + .map(item -> String.valueOf(getValueForPath(item, + Arrays.copyOfRange(parts, + Arrays.asList(parts).indexOf("*") + 1, + parts.length)))) + .filter(Objects::nonNull) + .collect(Collectors.joining(", ")); + } + } else if (current instanceof Map) { + current = ((Map) current).get(part); + } + } + return current != null ? current.toString() : ""; + }); + + // Convert template to use the custom helper + String modifiedTemplate = template.replaceAll("\\{\\{(/[^}]+)\\}\\}", "{{#get}}$1{{/get}}"); + + com.github.jknack.handlebars.Template hbsTemplate = handlebars.compileInline(modifiedTemplate); + return hbsTemplate.apply(data); + } catch (IOException e) { + log.error("Error rendering Handlebars template: {}", e.getMessage()); + throw new RuntimeException("Failed to render Handlebars template", e); + } + } + + private Object getValueForPath(Object obj, String[] remainingPath) { + Object current = obj; + for (String part : remainingPath) { + if (current instanceof Map) { + current = ((Map) current).get(part); + } else { + return null; + } + } + return current; + } + } diff --git a/src/main/java/io/mosip/mimoto/service/CredentialService.java b/src/main/java/io/mosip/mimoto/service/CredentialService.java index ec1635012..4446b933f 100644 --- a/src/main/java/io/mosip/mimoto/service/CredentialService.java +++ b/src/main/java/io/mosip/mimoto/service/CredentialService.java @@ -1,5 +1,6 @@ package io.mosip.mimoto.service; +import io.mosip.mimoto.dto.CredentialResponse; import io.mosip.mimoto.dto.idp.TokenResponseDTO; import io.mosip.mimoto.dto.mimoto.VCCredentialRequest; import io.mosip.mimoto.dto.mimoto.VCCredentialResponse; @@ -21,7 +22,7 @@ public interface CredentialService { * @return ByteArrayInputStream containing the PDF * @throws Exception If any error occurs during processing */ - ByteArrayInputStream downloadCredentialAsPDF(String issuerId, String credentialType, TokenResponseDTO response, String credentialValidity, String locale) throws Exception; + CredentialResponse downloadCredentialAsPDF(String issuerId, String credentialType, TokenResponseDTO response, String credentialValidity, String locale) throws Exception; /** * Downloads credential from the issuer endpoint. @@ -29,7 +30,7 @@ public interface CredentialService { * @param credentialEndpoint The credential endpoint * @param vcCredentialRequest The credential request * @param accessToken The access token - * @return VCCredentialResponse containing the credential + * @return CredentialResponse containing the credential * @throws InvalidCredentialResourceException If the credential resource is invalid */ VCCredentialResponse downloadCredential(String credentialEndpoint, VCCredentialRequest vcCredentialRequest, String accessToken) throws InvalidCredentialResourceException; diff --git a/src/main/java/io/mosip/mimoto/service/impl/CredentialServiceImpl.java b/src/main/java/io/mosip/mimoto/service/impl/CredentialServiceImpl.java index 506d0ca42..439650e1f 100644 --- a/src/main/java/io/mosip/mimoto/service/impl/CredentialServiceImpl.java +++ b/src/main/java/io/mosip/mimoto/service/impl/CredentialServiceImpl.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.mosip.mimoto.dto.CredentialResponse; import io.mosip.mimoto.dto.IssuerDTO; import io.mosip.mimoto.dto.idp.TokenResponseDTO; import io.mosip.mimoto.dto.mimoto.*; @@ -67,7 +68,7 @@ public CredentialServiceImpl( @Override - public ByteArrayInputStream downloadCredentialAsPDF(String issuerId, String credentialConfigurationId, TokenResponseDTO response, String credentialValidity, String locale) throws Exception { + public CredentialResponse downloadCredentialAsPDF(String issuerId, String credentialConfigurationId, TokenResponseDTO response, String credentialValidity, String locale) throws Exception { IssuerDTO issuerDTO = issuersService.getIssuerDetails(issuerId); CredentialIssuerConfiguration credentialIssuerConfiguration = issuersService.getIssuerConfiguration(issuerId); CredentialIssuerWellKnownResponse credentialIssuerWellKnownResponse = new CredentialIssuerWellKnownResponse( diff --git a/src/main/java/io/mosip/mimoto/service/impl/WalletCredentialServiceImpl.java b/src/main/java/io/mosip/mimoto/service/impl/WalletCredentialServiceImpl.java index 55479a19c..3fc3519df 100644 --- a/src/main/java/io/mosip/mimoto/service/impl/WalletCredentialServiceImpl.java +++ b/src/main/java/io/mosip/mimoto/service/impl/WalletCredentialServiceImpl.java @@ -2,6 +2,7 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import io.mosip.mimoto.dto.CredentialResponse; import io.mosip.mimoto.dto.IssuerDTO; import io.mosip.mimoto.dto.idp.TokenResponseDTO; import io.mosip.mimoto.dto.mimoto.CredentialsSupportedResponse; @@ -24,6 +25,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.core.io.InputStreamResource; +import org.springframework.http.MediaType; import org.springframework.stereotype.Service; import java.io.ByteArrayInputStream; @@ -186,7 +188,7 @@ private WalletCredentialResponseDTO generateCredentialResponse(String decryptedC // Generate PDF // keep the datashare url and credential validity as defaults in downloading VC as PDF as logged-in user // This is because generatePdfForVerifiableCredentials will be used by both logged-in and non-logged-in users - ByteArrayInputStream pdfStream = credentialPDFGeneratorService.generatePdfForVerifiableCredential( + CredentialResponse pdfStream = credentialPDFGeneratorService.generatePdfForVerifiableCredential( credentialMetadata.getCredentialType(), vcCredentialResponse, issuerDTO, @@ -197,10 +199,14 @@ private WalletCredentialResponseDTO generateCredentialResponse(String decryptedC ); // Construct response - String fileName = String.format("%s_credential.pdf", credentialMetadata.getCredentialType()); + String fileExtension = "pdf"; + if (MediaType.valueOf("image/svg+xml").equals(pdfStream.getMediaType())) { + fileExtension = "svg"; + } + String fileName = String.format("%s_credential.%s", credentialMetadata.getCredentialType(), fileExtension); return WalletCredentialResponseDTO.builder() .fileName(fileName) - .fileContentStream(new InputStreamResource(pdfStream)) + .fileContentStream(new InputStreamResource(pdfStream.getContent())) .build(); } catch (JsonProcessingException e) { log.error("Failed to parse decrypted credential for issuerId: {}, credentialType: {}", credentialMetadata.getIssuerId(), credentialMetadata.getCredentialType(), e);