Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
0ce7430
Initial commit - changelog operation is working
Chris0296 Feb 2, 2026
c59a5bf
Refactor - clean up sonar issues
Chris0296 Feb 5, 2026
9633732
Cleanup
Chris0296 Feb 19, 2026
a086425
Remove annotation
Chris0296 Feb 19, 2026
9b594a8
Sonar fixes
Chris0296 Feb 20, 2026
182e6fc
Spotless
Chris0296 Feb 20, 2026
a819236
Refactor - sonar
Chris0296 Feb 20, 2026
4aeca78
Sonar refactor
Chris0296 Feb 20, 2026
c0bc189
Sonar refactor
Chris0296 Feb 20, 2026
60ab431
Sonar refactor
Chris0296 Feb 20, 2026
65e4cff
Refactor
Chris0296 Feb 20, 2026
9b8f1f9
Cleanup remaining warnings and suppress where appropriate
Chris0296 Feb 23, 2026
1c563c7
Fix bug where added or removed resources would be excluded from chang…
Chris0296 Feb 24, 2026
0fe0463
Add Changelog Tests
Chris0296 Feb 24, 2026
dbde3d5
Spotless
Chris0296 Feb 24, 2026
fff5a28
Update changelog tests
Chris0296 Feb 27, 2026
44ef8cd
Merge branch 'master' into cor-changelog-operation
Chris0296 Mar 6, 2026
482b567
Replace HashMaps with ConcurrentHashMaps in ResourceMatchers
Chris0296 Mar 12, 2026
b529df7
Merge branch 'master' into cor-changelog-operation
Chris0296 Mar 12, 2026
5bf243c
Merge branch 'master' into cor-changelog-operation
Chris0296 Mar 23, 2026
092f99e
Update test data
Chris0296 Mar 31, 2026
6facb09
Merge branch 'main' into cor-changelog-operation
Chris0296 Mar 31, 2026
bb42e0f
Apply Sonar suggestions
Chris0296 Mar 31, 2026
ff2f99a
Refactor to use shared thread pool & improve exceptions
Chris0296 Apr 2, 2026
eabf514
Replace use of BeanWrapperImpl with ModelResolver
Chris0296 Apr 2, 2026
2121278
Make ChangelogProcessor version agnostic & extract inner classes to c…
Chris0296 Apr 2, 2026
019febc
Merge branch 'main' into cor-changelog-operation
Chris0296 Apr 2, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,300 @@
package org.opencds.cqf.fhir.cr.hapi.common;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.parser.IParser;
import ca.uhn.fhir.parser.path.EncodeContextPath;
import ca.uhn.fhir.parser.path.EncodeContextPathElement;
import ca.uhn.fhir.repository.IRepository;
import ca.uhn.fhir.rest.server.exceptions.InternalErrorException;
import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
import com.fasterxml.jackson.annotation.JsonInclude.Include;
import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.core.Version;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.databind.SerializerProvider;
import com.fasterxml.jackson.databind.module.SimpleModule;
import com.fasterxml.jackson.databind.ser.std.StdSerializer;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
import org.hl7.fhir.instance.model.api.IBase;
import org.hl7.fhir.instance.model.api.IBaseBundle;
import org.hl7.fhir.instance.model.api.IBaseResource;
import org.hl7.fhir.instance.model.api.IPrimitiveType;
import org.hl7.fhir.r4.model.Binary;
import org.hl7.fhir.r4.model.Bundle;
import org.hl7.fhir.r4.model.Library;
import org.hl7.fhir.r4.model.MetadataResource;
import org.hl7.fhir.r4.model.Parameters;
import org.hl7.fhir.r4.model.Parameters.ParametersParameterComponent;
import org.hl7.fhir.r4.model.PlanDefinition;
import org.hl7.fhir.r4.model.Resource;
import org.hl7.fhir.r4.model.ValueSet;
import org.opencds.cqf.fhir.cr.common.ArtifactDiffProcessor.DiffCache;
import org.opencds.cqf.fhir.cr.common.ArtifactDiffProcessor.DiffCache.DiffCacheResource;
import org.opencds.cqf.fhir.cr.common.ICreateChangelogProcessor;
import org.opencds.cqf.fhir.cr.common.PackageProcessor;
import org.opencds.cqf.fhir.cr.crmi.KnowledgeArtifactProcessor;
import org.opencds.cqf.fhir.cr.crmi.changelog.ChangeLog;
import org.opencds.cqf.fhir.cr.crmi.changelog.Page;
import org.opencds.cqf.fhir.utility.Canonicals;
import org.opencds.cqf.fhir.utility.adapter.IAdapterFactory;
import org.opencds.cqf.fhir.utility.model.FhirModelResolverCache;

@SuppressWarnings("UnstableApiUsage")
public class HapiCreateChangelogProcessor implements ICreateChangelogProcessor {

private final FhirVersionEnum fhirVersion;
private final PackageProcessor packageProcessor;

private final HapiArtifactDiffProcessor hapiArtifactDiffProcessor;

private static final ExecutorService EXECUTOR_SERVICE = Executors.newFixedThreadPool(10);

public HapiCreateChangelogProcessor(IRepository repository) {
this.fhirVersion = repository.fhirContext().getVersion().getVersion();
this.packageProcessor = new PackageProcessor(repository);
this.hapiArtifactDiffProcessor = new HapiArtifactDiffProcessor(repository);
}

static {
Runtime.getRuntime().addShutdownHook(new Thread(() -> {
try {
EXECUTOR_SERVICE.shutdown();
if (!EXECUTOR_SERVICE.awaitTermination(30, TimeUnit.SECONDS)) {
EXECUTOR_SERVICE.shutdownNow();
}
} catch (InterruptedException e) {
EXECUTOR_SERVICE.shutdownNow();
Thread.currentThread().interrupt();
}
}));
}

@Override
public IBaseResource createChangelog(
IBaseResource source, IBaseResource target, IBaseResource terminologyEndpoint) {

// 1) Use package to get a pair of bundles
List<Future<IBaseBundle>> packages;
Bundle sourceBundle;
Bundle targetBundle;
Parameters params = new Parameters();
params.addParameter().setName("terminologyEndpoint").setResource((Resource) terminologyEndpoint);
try {
packages = EXECUTOR_SERVICE.invokeAll(Arrays.asList(
() -> packageProcessor.packageResource(source, params),
() -> packageProcessor.packageResource(target, params)));
sourceBundle = (Bundle) packages.get(0).get();
targetBundle = (Bundle) packages.get(1).get();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new InternalErrorException(e.getMessage());
} catch (ExecutionException e) {
throw new InternalErrorException(e.getMessage());
}

// 2) Fill the cache with the bundle contents
var cache = populateCache(source, sourceBundle, target, targetBundle);

// 3) Use cached resources to create diff and changelog
var targetResource = cache.getTargetResourceForUrl(((MetadataResource) target).getUrl());
var sourceResource = cache.getSourceResourceForUrl(((MetadataResource) source).getUrl());
if (targetResource.isPresent() && sourceResource.isPresent()) {
var targetAdapter = IAdapterFactory.forFhirVersion(FhirVersionEnum.R4)
.createKnowledgeArtifactAdapter(targetResource.get().resource);
var diffParameters = hapiArtifactDiffProcessor.getArtifactDiff(
sourceResource.get().resource,
targetResource.get().resource,
true,
true,
cache,
terminologyEndpoint);
var manifestUrl = targetAdapter.getUrl();
var changelog = new ChangeLog(manifestUrl);
processChanges(((Parameters) diffParameters).getParameter(), changelog, cache, manifestUrl);

// 4) Handle the Conditions and Priorities which are in RelatedArtifact changes
changelog.handleRelatedArtifacts();

// 5) Generate the output JSON
var bin = new Binary();
var mapper = createSerializer();
try {
bin.setContent(mapper.writeValueAsString(changelog).getBytes(StandardCharsets.UTF_8));
} catch (JsonProcessingException e) {
throw new UnprocessableEntityException(e.getMessage());
}

return bin;
}

throw new UnprocessableEntityException("Could not find source or target resource in cached package responses");
}

private DiffCache populateCache(
IBaseResource source, Bundle sourceBundle, IBaseResource target, Bundle targetBundle) {
var cache = new DiffCache();
for (final var entry : sourceBundle.getEntry()) {
if (entry.hasResource() && entry.getResource() instanceof MetadataResource metadataResource) {
cache.addSource(metadataResource.getUrl() + "|" + metadataResource.getVersion(), metadataResource);
if (metadataResource.getIdPart().equals(source.getIdElement().getIdPart())) {
cache.addSource(metadataResource.getUrl(), metadataResource);
}
}
}
for (final var entry : targetBundle.getEntry()) {
if (entry.hasResource() && entry.getResource() instanceof MetadataResource metadataResource) {
cache.addTarget(metadataResource.getUrl() + "|" + metadataResource.getVersion(), metadataResource);
if (metadataResource.getIdPart().equals(target.getIdElement().getIdPart())) {
cache.addTarget(metadataResource.getUrl(), metadataResource);
}
}
}
return cache;
}

private ObjectMapper createSerializer() {
var mapper = new ObjectMapper()
.setDefaultPropertyInclusion(Include.NON_NULL)
.disable(SerializationFeature.FAIL_ON_EMPTY_BEANS);
SimpleModule module = new SimpleModule("IBaseSerializer", new Version(1, 0, 0, null, null, null));
module.addSerializer(IBase.class, new IBaseSerializer(FhirContext.forVersion(this.fhirVersion)));
mapper.registerModule(module);
return mapper;
}

private void processChanges(
List<Parameters.ParametersParameterComponent> changes, ChangeLog changelog, DiffCache cache, String url) {
// 1) Get the source and target resources so we can pull additional info as necessary
var resourceType = Canonicals.getResourceType(url);
// Check if the resource pair was already processed
var wasPageAlreadyProcessed = changelog.getPage(url).isPresent();
if (!wasPageAlreadyProcessed
&& (cache.getSourceResourceForUrl(url).isPresent()
|| cache.getTargetResourceForUrl(url).isPresent())) {
final Optional<DiffCacheResource> sourceCacheResource = cache.getSourceResourceForUrl(url);
final Optional<DiffCacheResource> targetCacheResource = cache.getTargetResourceForUrl(url);
if (resourceType != null) {
MetadataResource sourceResource = sourceCacheResource
.map(diffCacheResource -> diffCacheResource.resource)
.orElse(null);
MetadataResource targetResource = targetCacheResource
.map(diffCacheResource -> diffCacheResource.resource)
.orElse(null);
// don't generate changeLog pages for non-grouper ValueSets
if (resourceType.equals("ValueSet")
&& ((sourceResource != null && !KnowledgeArtifactProcessor.isGrouper(sourceResource))
|| (targetResource != null && !KnowledgeArtifactProcessor.isGrouper(targetResource)))) {
return;
}
// 2) Generate a page for each resource pair based on ResourceType
var page = changelog.getPage(url).orElseGet(() -> switch (resourceType) {
case "ValueSet" -> changelog.addPage((ValueSet) sourceResource, (ValueSet) targetResource, cache);
case "Library" -> changelog.addPage((Library) sourceResource, (Library) targetResource);
case "PlanDefinition" ->
changelog.addPage((PlanDefinition) sourceResource, (PlanDefinition) targetResource);
default -> changelog.addPage(sourceResource, targetResource, url);
});
// 3) Process each change
for (var change : changes) {
processChange(changelog, cache, change, sourceResource, page);
}
}
}
}

private void processChange(
ChangeLog changelog,
DiffCache cache,
ParametersParameterComponent change,
MetadataResource sourceResource,
Page<?> page) {
if (change.hasName()
&& !change.getName().equals("operation")
&& change.hasResource()
&& change.getResource() instanceof Parameters parameters) {
// Nested Parameters objects get recursively processed
processChanges(parameters.getParameter(), changelog, cache, change.getName());
} else if (change.getName().equals("operation")) {
// 1) For each operation get the relevant parameters
var type = getStringParameter(change, "type")
.orElseThrow(() -> new UnprocessableEntityException(
"Type must be provided when adding an operation to the ChangeLog"));
var newValue = getParameter(change, "value");
var path = getPathParameterNoBase(change);
var originalValue = getParameter(change, "previousValue").map(o -> (Object) o);
// try to extract the original value from the
// source object if not present in the Diff
// Parameters object
try {
if (originalValue.isEmpty() && !type.equals("insert") && sourceResource != null && path.isPresent()) {
originalValue = Optional.of(FhirModelResolverCache.resolverForVersion(fhirVersion)
.resolvePath(sourceResource, path.get()));
}
} catch (Exception e) {
throw new InternalErrorException("Could not process path: " + path + ": " + e.getMessage());
}

// 2) Add a new operation to the ChangeLog
page.addOperation(type, path.orElse(null), newValue.orElse(null), originalValue.orElse(null));
}
}

private Optional<String> getPathParameterNoBase(Parameters.ParametersParameterComponent change) {
return getStringParameter(change, "path").map(p -> {
var e = new EncodeContextPath(p);
return removeBase(e);
});
}

private String removeBase(EncodeContextPath path) {
return path.getPath().subList(1, path.getPath().size()).stream()
.map(EncodeContextPathElement::toString)
.collect(Collectors.joining("."));
}

private Optional<String> getStringParameter(Parameters.ParametersParameterComponent part, String name) {
return part.getPart().stream()
.filter(p -> p.getName().equalsIgnoreCase(name))
.filter(p -> p.getValue() instanceof IPrimitiveType)
.map(p -> (IPrimitiveType<?>) p.getValue())
.map(s -> (String) s.getValue())
.findAny();
}

private Optional<IBase> getParameter(Parameters.ParametersParameterComponent part, String name) {
return part.getPart().stream()
.filter(p -> p.getName().equalsIgnoreCase(name))
.filter(ParametersParameterComponent::hasValue)
.map(p -> (IBase) p.getValue())
.findAny();
}

public static class IBaseSerializer extends StdSerializer<IBase> {
private final transient IParser parser;

public IBaseSerializer(FhirContext fhirCtx) {
super(IBase.class);
parser = fhirCtx.newJsonParser().setPrettyPrint(true);
}

@Override
public void serialize(IBase resource, JsonGenerator jsonGenerator, SerializerProvider provider)
throws IOException {
String resourceJson = parser.encodeToString(resource);
jsonGenerator.writeRawValue(resourceJson);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
import org.opencds.cqf.fhir.cr.graphdefinition.GraphDefinitionProcessor;
import org.opencds.cqf.fhir.cr.graphdefinition.apply.ApplyRequestBuilder;
import org.opencds.cqf.fhir.cr.hapi.common.HapiArtifactDiffProcessor;
import org.opencds.cqf.fhir.cr.hapi.common.HapiCreateChangelogProcessor;
import org.opencds.cqf.fhir.cr.hapi.common.IActivityDefinitionProcessorFactory;
import org.opencds.cqf.fhir.cr.hapi.common.ICqlProcessorFactory;
import org.opencds.cqf.fhir.cr.hapi.common.IGraphDefinitionApplyRequestBuilderFactory;
Expand Down Expand Up @@ -72,7 +73,10 @@ IQuestionnaireResponseProcessorFactory questionnaireResponseProcessorFactory(
ILibraryProcessorFactory libraryProcessorFactory(IRepositoryFactory repositoryFactory, CrSettings crSettings) {
return rd -> {
var repository = repositoryFactory.create(rd);
return new LibraryProcessor(repository, crSettings, List.of(new HapiArtifactDiffProcessor(repository)));
return new LibraryProcessor(
repository,
crSettings,
List.of(new HapiArtifactDiffProcessor(repository), new HapiCreateChangelogProcessor(repository)));
};
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@
RetireOperationConfig.class,
WithdrawOperationConfig.class,
ReviseOperationConfig.class,
ArtifactDiffOperationConfig.class
ArtifactDiffOperationConfig.class,
CreateChangelogOperationConfig.class
})
public class CrR4Config {

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package org.opencds.cqf.fhir.cr.hapi.config.r4;

import ca.uhn.fhir.context.FhirContext;
import ca.uhn.fhir.context.FhirVersionEnum;
import ca.uhn.fhir.rest.server.RestfulServer;
import java.util.Arrays;
import java.util.Map;
import org.opencds.cqf.fhir.cr.hapi.common.ILibraryProcessorFactory;
import org.opencds.cqf.fhir.cr.hapi.config.CrProcessorConfig;
import org.opencds.cqf.fhir.cr.hapi.config.ProviderLoader;
import org.opencds.cqf.fhir.cr.hapi.config.ProviderSelector;
import org.opencds.cqf.fhir.cr.hapi.r4.library.LibraryCreateChangelogProvider;
import org.springframework.context.ApplicationContext;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Import;

@Configuration
@Import(CrProcessorConfig.class)
public class CreateChangelogOperationConfig {

@Bean
LibraryCreateChangelogProvider r4LibraryCreateChangelogProvider(ILibraryProcessorFactory libraryProcessorFactory) {
return new LibraryCreateChangelogProvider(libraryProcessorFactory);
}

@Bean(name = "createChangelogOperationLoader")
public ProviderLoader createChangelogOperationLoader(
ApplicationContext applicationContext, FhirContext fhirContext, RestfulServer restfulServer) {
var selector = new ProviderSelector(
fhirContext, Map.of(FhirVersionEnum.R4, Arrays.asList(LibraryCreateChangelogProvider.class)));

return new ProviderLoader(restfulServer, applicationContext, selector);
}
}
Loading
Loading