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.IBaseResource; 037import org.hl7.fhir.r5.model.BooleanType; 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.getPayload(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(Object appContext, String name, boolean beforeContext) { 137 if ("current".equalsIgnoreCase(name)) return List.of(myResource); 138 139 if ("previous".equalsIgnoreCase(name)) { 140 Optional previousResource = myPreviousVersionReader.readPreviousVersion(myResource); 141 if (previousResource.isPresent()) return List.of((IBase) previousResource.get()); 142 } 143 144 return null; 145 } 146 }); 147 try { 148 IFhirPath.IParsedExpression expression = myMemoryCacheService.get( 149 MemoryCacheService.CacheEnum.FHIRPATH_EXPRESSION, theFhirPathCriteria, exp -> { 150 try { 151 return fhirPathEngine.parse(exp); 152 } catch (FHIRException e) { 153 throw e; 154 } catch (Exception e) { 155 throw new RuntimeException(Msg.code(2534) + e.getMessage(), e); 156 } 157 }); 158 159 List<IBase> result = fhirPathEngine.evaluate(myResource, expression, IBase.class); 160 161 return parseResult(theFhirPathCriteria, result); 162 163 } catch (FHIRException fhirException) { 164 ourLog.warn( 165 "Subscription topic {} has a fhirPathCriteria that is not valid: {}", 166 myTrigger.getId(), 167 theFhirPathCriteria, 168 fhirException); 169 return InMemoryMatchResult.unsupportedFromReason(fhirException.getMessage()); 170 } 171 } 172 return InMemoryMatchResult.fromBoolean(true); 173 } 174 175 private InMemoryMatchResult parseResult(String theFhirPathCriteria, List<IBase> result) { 176 if (result == null) { 177 return InMemoryMatchResult.unsupportedFromReason(MessageFormatter.format( 178 "FhirPath evaluation criteria '{}' from Subscription topic: '{}' resulted in null results.", 179 theFhirPathCriteria, 180 myTrigger.getId()) 181 .getMessage()); 182 } 183 184 if (result.size() != 1) { 185 return InMemoryMatchResult.unsupportedFromReason(MessageFormatter.arrayFormat( 186 "FhirPath evaluation criteria '{}' from Subscription topic: '{}' resulted in '{}' results. Expected 1.", 187 new String[] {theFhirPathCriteria, myTrigger.getId(), String.valueOf(result.size())}) 188 .getMessage()); 189 } 190 191 if (!(result.get(0) instanceof BooleanType)) { 192 return InMemoryMatchResult.unsupportedFromReason(MessageFormatter.arrayFormat( 193 "FhirPath evaluation criteria '{}' from Subscription topic: '{}' resulted in a non-boolean result: '{}'", 194 new String[] { 195 theFhirPathCriteria, 196 myTrigger.getId(), 197 result.get(0).getClass().getName() 198 }) 199 .getMessage()); 200 } 201 return InMemoryMatchResult.fromBoolean(((BooleanType) result.get(0)).booleanValue()); 202 } 203 204 private InMemoryMatchResult matchResource(IBaseResource theResource, String theCriteria) { 205 InMemoryMatchResult result = 206 mySubscriptionTopicSupport.getSearchParamMatcher().match(theCriteria, theResource, mySrd); 207 if (!result.supported()) { 208 ourLog.warn( 209 "Subscription topic {} has a query criteria that is not supported in-memory: {}", 210 myTrigger.getId(), 211 theCriteria); 212 } 213 return result; 214 } 215}