001/*-
002 * #%L
003 * HAPI FHIR Storage api
004 * %%
005 * Copyright (C) 2014 - 2024 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.interceptor.validation;
021
022import ca.uhn.fhir.context.FhirContext;
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.interceptor.api.Hook;
025import ca.uhn.fhir.interceptor.api.Interceptor;
026import ca.uhn.fhir.interceptor.api.Pointcut;
027import ca.uhn.fhir.rest.api.server.RequestDetails;
028import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
029import ca.uhn.fhir.util.ExtensionUtil;
030import ca.uhn.fhir.util.OperationOutcomeUtil;
031import com.google.common.collect.ArrayListMultimap;
032import com.google.common.collect.Multimap;
033import jakarta.annotation.Nonnull;
034import org.apache.commons.lang3.Validate;
035import org.hl7.fhir.instance.model.api.IBaseResource;
036import org.slf4j.Logger;
037import org.slf4j.LoggerFactory;
038
039import java.util.Collection;
040import java.util.List;
041import java.util.stream.Collectors;
042
043import static ca.uhn.fhir.util.HapiExtensions.EXT_RESOURCE_PLACEHOLDER;
044
045/**
046 * This interceptor enforces validation rules on any data saved in a HAPI FHIR JPA repository.
047 * See <a href="https://hapifhir.io/hapi-fhir/docs/validation/repository_validating_interceptor.html">Repository Validating Interceptor</a>
048 * in the HAPI FHIR documentation for more information on how to use this.
049 */
050@Interceptor
051public class RepositoryValidatingInterceptor {
052
053        private static final Logger ourLog = LoggerFactory.getLogger(RepositoryValidatingInterceptor.class);
054        private final Multimap<String, IRepositoryValidatingRule> myRules = ArrayListMultimap.create();
055        private FhirContext myFhirContext;
056
057        /**
058         * Constructor
059         * <p>
060         * If this constructor is used, {@link #setFhirContext(FhirContext)} and {@link #setRules(List)} must be called
061         * manually before the interceptor is used.
062         */
063        public RepositoryValidatingInterceptor() {
064                super();
065        }
066
067        /**
068         * Constructor
069         *
070         * @param theFhirContext The FHIR Context (must not be <code>null</code>)
071         * @param theRules       The rule list (must not be <code>null</code>)
072         */
073        public RepositoryValidatingInterceptor(FhirContext theFhirContext, List<IRepositoryValidatingRule> theRules) {
074                setFhirContext(theFhirContext);
075                setRules(theRules);
076        }
077
078        /**
079         * Provide the FHIR Context (mandatory)
080         */
081        public void setFhirContext(FhirContext theFhirContext) {
082                myFhirContext = theFhirContext;
083        }
084
085        /**
086         * Provide the rules to use for validation (mandatory)
087         */
088        public void setRules(List<IRepositoryValidatingRule> theRules) {
089                Validate.notNull(theRules, "theRules must not be null");
090                myRules.clear();
091                for (IRepositoryValidatingRule next : theRules) {
092                        myRules.put(next.getResourceType(), next);
093                }
094
095                String rulesDescription = "RepositoryValidatingInterceptor has rules:\n" + describeRules();
096                ourLog.info(rulesDescription);
097        }
098
099        /**
100         * Returns a multiline string describing the rules in place for this interceptor.
101         * This is mostly intended for troubleshooting, and the format returned is only
102         * semi-human-consumable.
103         */
104        @Nonnull
105        public String describeRules() {
106                return " * "
107                                + myRules.values().stream()
108                                                .distinct()
109                                                .map(t -> t.toString())
110                                                .sorted()
111                                                .collect(Collectors.joining("\n * "));
112        }
113
114        /**
115         * Interceptor hook method. This method should not be called directly.
116         */
117        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_CREATED)
118        void create(RequestDetails theRequestDetails, IBaseResource theResource) {
119                handle(theRequestDetails, theResource);
120        }
121
122        /**
123         * Interceptor hook method. This method should not be called directly.
124         */
125        @Hook(Pointcut.STORAGE_PRESTORAGE_RESOURCE_UPDATED)
126        void update(RequestDetails theRequestDetails, IBaseResource theOldResource, IBaseResource theNewResource) {
127                handle(theRequestDetails, theNewResource);
128        }
129
130        private void handle(RequestDetails theRequestDetails, IBaseResource theNewResource) {
131
132                Validate.notNull(myFhirContext, "No FhirContext has been set for this interceptor of type: %s", getClass());
133                if (!isPlaceholderResource(theNewResource)) {
134                        String resourceType = myFhirContext.getResourceType(theNewResource);
135                        Collection<IRepositoryValidatingRule> rules = myRules.get(resourceType);
136                        for (IRepositoryValidatingRule nextRule : rules) {
137                                IRepositoryValidatingRule.RuleEvaluation outcome = nextRule.evaluate(theRequestDetails, theNewResource);
138                                if (!outcome.isPasses()) {
139                                        handleFailure(outcome);
140                                }
141                        }
142                }
143        }
144
145        /**
146         * Return true if the given resource is a placeholder resource, as identified by a specific extension
147         * @param theNewResource the {@link IBaseResource} to check
148         * @return whether or not this resource is a placeholder.
149         */
150        private boolean isPlaceholderResource(IBaseResource theNewResource) {
151                return ExtensionUtil.hasExtension(theNewResource, EXT_RESOURCE_PLACEHOLDER);
152        }
153
154        protected void handleFailure(IRepositoryValidatingRule.RuleEvaluation theOutcome) {
155                if (theOutcome.getOperationOutcome() != null) {
156                        String firstIssue =
157                                        OperationOutcomeUtil.getFirstIssueDetails(myFhirContext, theOutcome.getOperationOutcome());
158                        throw new PreconditionFailedException(Msg.code(574) + firstIssue, theOutcome.getOperationOutcome());
159                }
160                throw new PreconditionFailedException(Msg.code(575) + theOutcome.getFailureDescription());
161        }
162}