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}