001package ca.uhn.fhir.jpa.interceptor;
002
003/*-
004 * #%L
005 * HAPI FHIR JPA Server
006 * %%
007 * Copyright (C) 2014 - 2022 Smile CDR, Inc.
008 * %%
009 * Licensed under the Apache License, Version 2.0 (the "License");
010 * you may not use this file except in compliance with the License.
011 * You may obtain a copy of the License at
012 *
013 *      http://www.apache.org/licenses/LICENSE-2.0
014 *
015 * Unless required by applicable law or agreed to in writing, software
016 * distributed under the License is distributed on an "AS IS" BASIS,
017 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
018 * See the License for the specific language governing permissions and
019 * limitations under the License.
020 * #L%
021 */
022
023import ca.uhn.fhir.i18n.Msg;
024import ca.uhn.fhir.context.FhirContext;
025import ca.uhn.fhir.context.RuntimeResourceDefinition;
026import ca.uhn.fhir.context.RuntimeSearchParam;
027import ca.uhn.fhir.interceptor.api.Hook;
028import ca.uhn.fhir.interceptor.api.Interceptor;
029import ca.uhn.fhir.interceptor.api.Pointcut;
030import ca.uhn.fhir.interceptor.model.ReadPartitionIdRequestDetails;
031import ca.uhn.fhir.interceptor.model.RequestPartitionId;
032import ca.uhn.fhir.jpa.searchparam.SearchParameterMap;
033import ca.uhn.fhir.jpa.searchparam.extractor.BaseSearchParamExtractor;
034import ca.uhn.fhir.jpa.searchparam.extractor.ISearchParamExtractor;
035import ca.uhn.fhir.model.api.IQueryParameterType;
036import ca.uhn.fhir.rest.api.RestSearchParameterTypeEnum;
037import ca.uhn.fhir.rest.api.server.RequestDetails;
038import ca.uhn.fhir.rest.param.ReferenceParam;
039import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
040import org.apache.commons.lang3.StringUtils;
041import org.hl7.fhir.instance.model.api.IBaseReference;
042import org.hl7.fhir.instance.model.api.IBaseResource;
043import org.hl7.fhir.r4.model.IdType;
044import org.springframework.beans.factory.annotation.Autowired;
045
046import javax.annotation.Nonnull;
047import java.util.Arrays;
048import java.util.List;
049import java.util.stream.Collectors;
050
051import static org.apache.commons.lang3.StringUtils.isBlank;
052import static org.apache.commons.lang3.StringUtils.isNotBlank;
053
054/**
055 * This interceptor allows JPA servers to be partitioned by Patient ID. It selects the compartment for read/create operations
056 * based on the patient ID associated with the resource (and uses a default partition ID for any resources
057 * not in the patient compartment).
058 */
059@Interceptor
060public class PatientIdPartitionInterceptor {
061
062        @Autowired
063        private FhirContext myFhirContext;
064
065        @Autowired
066        private ISearchParamExtractor mySearchParamExtractor;
067
068        /**
069         * Constructor
070         */
071        public PatientIdPartitionInterceptor() {
072                super();
073        }
074
075        /**
076         * Constructor
077         */
078        public PatientIdPartitionInterceptor(FhirContext theFhirContext, ISearchParamExtractor theSearchParamExtractor) {
079                this();
080                myFhirContext = theFhirContext;
081                mySearchParamExtractor = theSearchParamExtractor;
082        }
083
084        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_CREATE)
085        public RequestPartitionId identifyForCreate(IBaseResource theResource, RequestDetails theRequestDetails) {
086                RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theResource);
087                List<RuntimeSearchParam> compartmentSps = getCompartmentSearchParams(resourceDef);
088                if (compartmentSps.isEmpty()) {
089                        return provideNonCompartmentMemberTypeResponse(theResource);
090                }
091
092                String compartmentIdentity;
093                if (resourceDef.getName().equals("Patient")) {
094                        compartmentIdentity = theResource.getIdElement().getIdPart();
095                        if (isBlank(compartmentIdentity)) {
096                                throw new MethodNotAllowedException(Msg.code(1321) + "Patient resource IDs must be client-assigned in patient compartment mode");
097                        }
098                } else {
099                        compartmentIdentity = compartmentSps
100                                .stream()
101                                .flatMap(param -> Arrays.stream(BaseSearchParamExtractor.splitPathsR4(param.getPath())))
102                                .filter(StringUtils::isNotBlank)
103                                .map(path -> mySearchParamExtractor.getPathValueExtractor(theResource, path).get())
104                                .filter(t -> !t.isEmpty())
105                                .map(t -> t.get(0))
106                                .filter(t -> t instanceof IBaseReference)
107                                .map(t -> (IBaseReference) t)
108                                .map(t -> t.getReferenceElement().getValue())
109                                .map(t -> new IdType(t).getIdPart())
110                                .filter(StringUtils::isNotBlank)
111                                .findFirst()
112                                .orElse(null);
113                        if (isBlank(compartmentIdentity)) {
114                                return provideNonCompartmentMemberInstanceResponse(theResource);
115                        }
116                }
117
118
119                return provideCompartmentMemberInstanceResponse(theRequestDetails, compartmentIdentity);
120        }
121
122        @Hook(Pointcut.STORAGE_PARTITION_IDENTIFY_READ)
123        public RequestPartitionId identifyForRead(ReadPartitionIdRequestDetails theReadDetails, RequestDetails theRequestDetails) {
124                RuntimeResourceDefinition resourceDef = myFhirContext.getResourceDefinition(theReadDetails.getResourceType());
125                List<RuntimeSearchParam> compartmentSps = getCompartmentSearchParams(resourceDef);
126                if (compartmentSps.isEmpty()) {
127                        return provideNonCompartmentMemberTypeResponse(null);
128                }
129
130                //noinspection EnumSwitchStatementWhichMissesCases
131                switch (theReadDetails.getRestOperationType()) {
132                        case READ:
133                        case VREAD:
134                                if ("Patient".equals(theReadDetails.getResourceType())) {
135                                        return provideCompartmentMemberInstanceResponse(theRequestDetails, theReadDetails.getReadResourceId().getIdPart());
136                                }
137                                break;
138                        case SEARCH_TYPE:
139                                SearchParameterMap params = (SearchParameterMap) theReadDetails.getSearchParams();
140                                String idPart = null;
141                                if ("Patient".equals(theReadDetails.getResourceType())) {
142                                        idPart = getSingleResourceIdValueOrNull(params, "_id", "Patient");
143                                } else {
144                                        for (RuntimeSearchParam nextCompartmentSp : compartmentSps) {
145                                                idPart = getSingleResourceIdValueOrNull(params, nextCompartmentSp.getName(), "Patient");
146                                                if (idPart != null) {
147                                                        break;
148                                                }
149                                        }
150                                }
151
152                                if (isNotBlank(idPart)) {
153                                        return provideCompartmentMemberInstanceResponse(theRequestDetails, idPart);
154                                }
155
156                                break;
157
158                        default:
159                                // nothing
160                }
161
162                // If we couldn't identify a patient ID by the URL, let's try using the
163                // conditional target if we have one
164                if (theReadDetails.getConditionalTargetOrNull() != null) {
165                        return identifyForCreate(theReadDetails.getConditionalTargetOrNull(), theRequestDetails);
166                }
167
168                return provideNonPatientSpecificQueryResponse(theReadDetails);
169        }
170
171        @Nonnull
172        private List<RuntimeSearchParam> getCompartmentSearchParams(RuntimeResourceDefinition resourceDef) {
173                return resourceDef
174                        .getSearchParams()
175                        .stream()
176                        .filter(param -> param.getParamType() == RestSearchParameterTypeEnum.REFERENCE)
177                        .filter(param -> param.getProvidesMembershipInCompartments() != null && param.getProvidesMembershipInCompartments().contains("Patient"))
178                        .collect(Collectors.toList());
179        }
180
181        private String getSingleResourceIdValueOrNull(SearchParameterMap theParams, String theParamName, String theResourceType) {
182                String idPart = null;
183                List<List<IQueryParameterType>> idParamAndList = theParams.get(theParamName);
184                if (idParamAndList != null && idParamAndList.size() == 1) {
185                        List<IQueryParameterType> idParamOrList = idParamAndList.get(0);
186                        if (idParamOrList.size() == 1) {
187                                IQueryParameterType idParam = idParamOrList.get(0);
188                                if (isNotBlank(idParam.getQueryParameterQualifier())) {
189                                        throw new MethodNotAllowedException(Msg.code(1322) + "The parameter " + theParamName + idParam.getQueryParameterQualifier() + " is not supported in patient compartment mode");
190                                }
191                                if (idParam instanceof ReferenceParam) {
192                                        String chain = ((ReferenceParam) idParam).getChain();
193                                        if (chain != null) {
194                                                throw new MethodNotAllowedException(Msg.code(1323) + "The parameter " + theParamName + "." + chain + " is not supported in patient compartment mode");
195                                        }
196                                }
197
198                                IdType id = new IdType(idParam.getValueAsQueryToken(myFhirContext));
199                                if (!id.hasResourceType() || id.getResourceType().equals(theResourceType)) {
200                                        idPart = id.getIdPart();
201                                }
202                        } else if (idParamOrList.size() > 1) {
203                                throw new MethodNotAllowedException(Msg.code(1324) + "Multiple values for parameter " + theParamName + " is not supported in patient compartment mode");
204                        }
205                } else if (idParamAndList != null && idParamAndList.size() > 1) {
206                        throw new MethodNotAllowedException(Msg.code(1325) + "Multiple values for parameter " + theParamName + " is not supported in patient compartment mode");
207                }
208                return idPart;
209        }
210
211
212        /**
213         * Return a partition or throw an error for FHIR operations that can not be used with this interceptor
214         */
215        protected RequestPartitionId provideNonPatientSpecificQueryResponse(ReadPartitionIdRequestDetails theRequestDetails) {
216                return RequestPartitionId.allPartitions();
217        }
218
219
220        /**
221         * Generate the partition for a given patient resource ID. This method may be overridden in subclasses, but it
222         * may be easier to override {@link #providePartitionIdForPatientId(RequestDetails, String)} instead.
223         */
224        @Nonnull
225        protected RequestPartitionId provideCompartmentMemberInstanceResponse(RequestDetails theRequestDetails, String theResourceIdPart) {
226                int partitionId = providePartitionIdForPatientId(theRequestDetails, theResourceIdPart);
227                return RequestPartitionId.fromPartitionId(partitionId);
228        }
229
230        /**
231         * Translates an ID (e.g. "ABC") into a compartment ID number.
232         * <p>
233         * The default implementation of this method returns:
234         * <code>Math.abs(theResourceIdPart.hashCode()) % 15000</code>.
235         * <p>
236         * This logic can be replaced with other logic of your choosing.
237         */
238        @SuppressWarnings("unused")
239        protected int providePartitionIdForPatientId(RequestDetails theRequestDetails, String theResourceIdPart) {
240                return Math.abs(theResourceIdPart.hashCode() % 15000);
241        }
242
243        /**
244         * Return a compartment ID (or throw an exception) when an attempt is made to search for a resource that is
245         * in the patient compartment, but without any search parameter identifying which compartment to search.
246         * <p>
247         * E.g. this method will be called for the search <code>Observation?code=foo</code> since the patient
248         * is not identified in the URL.
249         */
250        @Nonnull
251        protected RequestPartitionId provideNonCompartmentMemberInstanceResponse(IBaseResource theResource) {
252                throw new MethodNotAllowedException(Msg.code(1326) + "Resource of type " + myFhirContext.getResourceType(theResource) + " has no values placing it in the Patient compartment");
253        }
254
255        /**
256         * Return a compartment ID (or throw an exception) when storing/reading resource types that
257         * are not in the patient compartment (e.g. ValueSet).
258         */
259        @SuppressWarnings("unused")
260        @Nonnull
261        protected RequestPartitionId provideNonCompartmentMemberTypeResponse(IBaseResource theResource) {
262                return RequestPartitionId.defaultPartition();
263        }
264
265
266}