
001/*- 002 * #%L 003 * HAPI FHIR Subscription Server 004 * %% 005 * Copyright (C) 2014 - 2025 Smile CDR, Inc. 006 * %% 007 * Licensed under the Apache License, Version 2.0 (the "License"); 008 * you may not use this file except in compliance with the License. 009 * You may obtain a copy of the License at 010 * 011 * http://www.apache.org/licenses/LICENSE-2.0 012 * 013 * Unless required by applicable law or agreed to in writing, software 014 * distributed under the License is distributed on an "AS IS" BASIS, 015 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 016 * See the License for the specific language governing permissions and 017 * limitations under the License. 018 * #L% 019 */ 020package ca.uhn.fhir.jpa.topic; 021 022import ca.uhn.fhir.fhirpath.IFhirPath; 023import ca.uhn.fhir.fhirpath.IFhirPathEvaluationContext; 024import ca.uhn.fhir.i18n.Msg; 025import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 026import ca.uhn.fhir.jpa.searchparam.matcher.InMemoryMatchResult; 027import ca.uhn.fhir.jpa.subscription.model.ResourceModifiedMessage; 028import ca.uhn.fhir.jpa.util.MemoryCacheService; 029import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 030import ca.uhn.fhir.rest.server.messaging.BaseResourceMessage; 031import ca.uhn.fhir.storage.PreviousVersionReader; 032import ca.uhn.fhir.util.Logs; 033import com.google.common.base.Strings; 034import org.hl7.fhir.exceptions.FHIRException; 035import org.hl7.fhir.instance.model.api.IBase; 036import org.hl7.fhir.instance.model.api.IBaseBooleanDatatype; 037import org.hl7.fhir.instance.model.api.IBaseResource; 038import org.hl7.fhir.r5.model.Enumeration; 039import org.hl7.fhir.r5.model.SubscriptionTopic; 040import org.slf4j.Logger; 041import org.slf4j.helpers.MessageFormatter; 042 043import java.util.List; 044import java.util.Optional; 045 046public class SubscriptionTriggerMatcher { 047 private static final Logger ourLog = Logs.getSubscriptionTopicLog(); 048 049 private final SubscriptionTopicSupport mySubscriptionTopicSupport; 050 private final BaseResourceMessage.OperationTypeEnum myOperation; 051 private final SubscriptionTopic.SubscriptionTopicResourceTriggerComponent myTrigger; 052 private final String myResourceName; 053 private final IBaseResource myResource; 054 private final IFhirResourceDao myDao; 055 private final PreviousVersionReader myPreviousVersionReader; 056 private final SystemRequestDetails mySrd; 057 private final MemoryCacheService myMemoryCacheService; 058 059 public SubscriptionTriggerMatcher( 060 SubscriptionTopicSupport theSubscriptionTopicSupport, 061 ResourceModifiedMessage theMsg, 062 SubscriptionTopic.SubscriptionTopicResourceTriggerComponent theTrigger, 063 MemoryCacheService theMemoryCacheService) { 064 mySubscriptionTopicSupport = theSubscriptionTopicSupport; 065 myOperation = theMsg.getOperationType(); 066 myResource = theMsg.getResource(theSubscriptionTopicSupport.getFhirContext()); 067 myResourceName = myResource.fhirType(); 068 myDao = mySubscriptionTopicSupport.getDaoRegistry().getResourceDao(myResourceName); 069 myTrigger = theTrigger; 070 myPreviousVersionReader = new PreviousVersionReader(myDao); 071 mySrd = new SystemRequestDetails(); 072 myMemoryCacheService = theMemoryCacheService; 073 } 074 075 public InMemoryMatchResult match() { 076 List<Enumeration<SubscriptionTopic.InteractionTrigger>> supportedInteractions = 077 myTrigger.getSupportedInteraction(); 078 if (SubscriptionTopicUtil.matches(myOperation, supportedInteractions)) { 079 SubscriptionTopic.SubscriptionTopicResourceTriggerQueryCriteriaComponent queryCriteria = 080 myTrigger.getQueryCriteria(); 081 String fhirPathCriteria = myTrigger.getFhirPathCriteria(); 082 return match(queryCriteria, fhirPathCriteria); 083 } 084 return InMemoryMatchResult.noMatch(); 085 } 086 087 private InMemoryMatchResult match( 088 SubscriptionTopic.SubscriptionTopicResourceTriggerQueryCriteriaComponent theQueryCriteria, 089 String theFhirPathCriteria) { 090 String previousCriteria = theQueryCriteria.getPrevious(); 091 String currentCriteria = theQueryCriteria.getCurrent(); 092 InMemoryMatchResult previousMatches = InMemoryMatchResult.fromBoolean(previousCriteria == null); 093 InMemoryMatchResult currentMatches = InMemoryMatchResult.fromBoolean(currentCriteria == null); 094 095 InMemoryMatchResult fhirPathCriteriaEvaluationResult = evaluateFhirPathCriteria(theFhirPathCriteria); 096 097 // WIP STR5 implement fhirPathCriteria per https://build.fhir.org/subscriptiontopic.html#fhirpath-criteria 098 if (currentCriteria != null) { 099 currentMatches = matchResource(myResource, currentCriteria); 100 } 101 if (myOperation == ResourceModifiedMessage.OperationTypeEnum.CREATE) { 102 return currentMatches; 103 } 104 105 if (previousCriteria != null) { 106 if (myOperation == ResourceModifiedMessage.OperationTypeEnum.UPDATE 107 || myOperation == ResourceModifiedMessage.OperationTypeEnum.DELETE) { 108 109 Optional<IBaseResource> oPreviousVersion = myPreviousVersionReader.readPreviousVersion(myResource); 110 if (oPreviousVersion.isPresent()) { 111 previousMatches = matchResource(oPreviousVersion.get(), previousCriteria); 112 } else { 113 ourLog.warn( 114 "Resource {} has a version of 1, which should not be the case for a create or delete operation", 115 myResource.getIdElement().toUnqualifiedVersionless()); 116 } 117 } 118 } 119 // WIP STR5 implement resultForCreate and resultForDelete 120 if (theQueryCriteria.getRequireBoth()) { 121 return InMemoryMatchResult.and( 122 InMemoryMatchResult.and(previousMatches, currentMatches), fhirPathCriteriaEvaluationResult); 123 } else { 124 return InMemoryMatchResult.and( 125 InMemoryMatchResult.or(previousMatches, currentMatches), fhirPathCriteriaEvaluationResult); 126 } 127 } 128 129 private InMemoryMatchResult evaluateFhirPathCriteria(String theFhirPathCriteria) { 130 if (!Strings.isNullOrEmpty(theFhirPathCriteria)) { 131 IFhirPath fhirPathEngine = 132 mySubscriptionTopicSupport.getFhirContext().newFhirPath(); 133 fhirPathEngine.setEvaluationContext(new IFhirPathEvaluationContext() { 134 135 @Override 136 public List<IBase> resolveConstant( 137 Object theAppContext, 138 String theName, 139 IFhirPathEvaluationContext.ConstantEvaluationMode theConstantEvaluationMode) { 140 if ("current".equalsIgnoreCase(theName)) return List.of(myResource); 141 142 if ("previous".equalsIgnoreCase(theName)) { 143 Optional previousResource = myPreviousVersionReader.readPreviousVersion(myResource); 144 if (previousResource.isPresent()) return List.of((IBase) previousResource.get()); 145 } 146 147 return null; 148 } 149 }); 150 try { 151 IFhirPath.IParsedExpression expression = myMemoryCacheService.get( 152 MemoryCacheService.CacheEnum.FHIRPATH_EXPRESSION, theFhirPathCriteria, exp -> { 153 try { 154 return fhirPathEngine.parse(exp); 155 } catch (FHIRException e) { 156 throw e; 157 } catch (Exception e) { 158 throw new RuntimeException(Msg.code(2534) + e.getMessage(), e); 159 } 160 }); 161 162 List<IBase> result = fhirPathEngine.evaluate(myResource, expression, IBase.class); 163 164 return parseResult(theFhirPathCriteria, result); 165 166 } catch (FHIRException fhirException) { 167 ourLog.warn( 168 "Subscription topic {} has a fhirPathCriteria that is not valid: {}", 169 myTrigger.getId(), 170 theFhirPathCriteria, 171 fhirException); 172 return InMemoryMatchResult.unsupportedFromReason(fhirException.getMessage()); 173 } 174 } 175 return InMemoryMatchResult.fromBoolean(true); 176 } 177 178 private InMemoryMatchResult parseResult(String theFhirPathCriteria, List<IBase> result) { 179 if (result == null) { 180 return InMemoryMatchResult.unsupportedFromReason(MessageFormatter.format( 181 "FhirPath evaluation criteria '{}' from Subscription topic: '{}' resulted in null results.", 182 theFhirPathCriteria, 183 myTrigger.getId()) 184 .getMessage()); 185 } 186 187 if (result.size() != 1) { 188 return InMemoryMatchResult.unsupportedFromReason(MessageFormatter.arrayFormat( 189 "FhirPath evaluation criteria '{}' from Subscription topic: '{}' resulted in '{}' results. Expected 1.", 190 new String[] {theFhirPathCriteria, myTrigger.getId(), String.valueOf(result.size())}) 191 .getMessage()); 192 } 193 194 if (!(result.get(0) instanceof IBaseBooleanDatatype)) { 195 return InMemoryMatchResult.unsupportedFromReason(MessageFormatter.arrayFormat( 196 "FhirPath evaluation criteria '{}' from Subscription topic: '{}' resulted in a non-boolean result: '{}'", 197 new String[] { 198 theFhirPathCriteria, 199 myTrigger.getId(), 200 result.get(0).getClass().getName() 201 }) 202 .getMessage()); 203 } 204 return InMemoryMatchResult.fromBoolean(((IBaseBooleanDatatype) result.get(0)).getValue()); 205 } 206 207 private InMemoryMatchResult matchResource(IBaseResource theResource, String theCriteria) { 208 InMemoryMatchResult result = 209 mySubscriptionTopicSupport.getSearchParamMatcher().match(theCriteria, theResource, mySrd); 210 if (!result.supported()) { 211 ourLog.warn( 212 "Subscription topic {} has a query criteria that is not supported in-memory: {}", 213 myTrigger.getId(), 214 theCriteria); 215 } 216 return result; 217 } 218}