TaxonomyCatalogueMetadataService.java
package com.taxonomy.architecture.decision;
import com.taxonomy.catalog.service.CatalogueOverlayService;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.core.io.Resource;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Service;
import java.io.InputStream;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Locale;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Supplies stable catalogue identity for report provenance.
*
* <p>The human-readable version is derived from the bundled catalogue filename.
* The effective SHA-256 digest combines the exact Excel bytes, the versioned JSON
* overlay bytes and its mapping version, so provenance changes whenever either
* catalogue layer changes.</p>
*/
@Service
public class TaxonomyCatalogueMetadataService {
private static final Logger log = LoggerFactory.getLogger(TaxonomyCatalogueMetadataService.class);
private static final Pattern VERSION_PATTERN = Pattern.compile("([0-9]{1,2}[A-Z]{3}[0-9]{4})");
private final ResourceLoader resourceLoader;
private final CatalogueOverlayService overlayService;
private final String catalogueResource;
private volatile CatalogueMetadata cached;
public TaxonomyCatalogueMetadataService(
ResourceLoader resourceLoader,
CatalogueOverlayService overlayService,
@Value("${taxonomy.catalogue.resource:classpath:data/C3_Taxonomy_Catalogue_25AUG2025.xlsx}")
String catalogueResource) {
this.resourceLoader = resourceLoader;
this.overlayService = overlayService;
this.catalogueResource = catalogueResource;
}
public CatalogueMetadata getMetadata() {
CatalogueMetadata current = cached;
if (current != null) {
return current;
}
synchronized (this) {
if (cached == null) {
cached = loadMetadata();
}
return cached;
}
}
private CatalogueMetadata loadMetadata() {
Resource resource = resourceLoader.getResource(catalogueResource);
String filename = resource.getFilename() != null
? resource.getFilename() : catalogueResource;
String version = extractVersion(filename);
String baseSha256 = "unavailable";
try (InputStream input = resource.getInputStream()) {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] buffer = new byte[16 * 1024];
int read;
while ((read = input.read(buffer)) >= 0) {
if (read > 0) {
digest.update(buffer, 0, read);
}
}
baseSha256 = HexFormat.of().formatHex(digest.digest());
} catch (Exception exception) {
log.warn("Could not compute taxonomy catalogue digest for '{}': {}",
catalogueResource, exception.getMessage());
}
CatalogueOverlayService.OverlayMetadata overlay = overlayService.getOverlayMetadata();
String overlaySha256 = overlay.sha256() != null ? overlay.sha256() : "disabled";
String effectiveSha256 = compositeDigest(
baseSha256, overlaySha256, overlay.mappingVersion());
return new CatalogueMetadata(
filename,
version,
effectiveSha256,
overlay.enabled()
? "C3 Taxonomy Catalogue + versioned JSON overlay"
: "C3 Taxonomy Catalogue",
baseSha256,
overlay.resource(),
overlay.mappingVersion(),
overlay.sha256());
}
private String compositeDigest(String baseSha256, String overlaySha256, String mappingVersion) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
String material = baseSha256 + ":" + overlaySha256 + ":"
+ (mappingVersion != null ? mappingVersion : "unspecified");
return HexFormat.of().formatHex(
digest.digest(material.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception exception) {
log.warn("Could not compute effective catalogue digest: {}", exception.getMessage());
return baseSha256;
}
}
private String extractVersion(String filename) {
Matcher matcher = VERSION_PATTERN.matcher(filename.toUpperCase(Locale.ROOT));
return matcher.find() ? matcher.group(1) : "unspecified";
}
public record CatalogueMetadata(
String filename,
String version,
String sha256,
String source,
String baseSha256,
String overlayResource,
String overlayMappingVersion,
String overlaySha256) {
}
}