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.dao;
021
022import ca.uhn.fhir.i18n.Msg;
023import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
024import ca.uhn.fhir.jpa.api.dao.IJpaDao;
025import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome;
026import ca.uhn.fhir.jpa.api.model.DeleteMethodOutcome;
027import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService;
028import ca.uhn.fhir.jpa.model.cross.IBasePersistedResource;
029import ca.uhn.fhir.jpa.patch.FhirPatch;
030import ca.uhn.fhir.jpa.patch.JsonPatchUtils;
031import ca.uhn.fhir.jpa.patch.XmlPatchUtils;
032import ca.uhn.fhir.parser.StrictErrorHandler;
033import ca.uhn.fhir.rest.api.DeleteCascadeModeEnum;
034import ca.uhn.fhir.rest.api.PatchTypeEnum;
035import ca.uhn.fhir.rest.api.RestOperationTypeEnum;
036import ca.uhn.fhir.rest.api.server.RequestDetails;
037import ca.uhn.fhir.rest.api.server.storage.IDeleteExpungeJobSubmitter;
038import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
039import ca.uhn.fhir.rest.api.server.storage.TransactionDetails;
040import ca.uhn.fhir.rest.server.RestfulServerUtils;
041import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
042import ca.uhn.fhir.rest.server.exceptions.MethodNotAllowedException;
043import ca.uhn.fhir.rest.server.exceptions.PreconditionFailedException;
044import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException;
045import ca.uhn.fhir.rest.server.exceptions.ResourceVersionConflictException;
046import ca.uhn.fhir.rest.server.exceptions.UnprocessableEntityException;
047import jakarta.annotation.Nonnull;
048import org.hl7.fhir.instance.model.api.IBaseParameters;
049import org.hl7.fhir.instance.model.api.IBaseResource;
050import org.hl7.fhir.instance.model.api.IIdType;
051import org.springframework.beans.factory.annotation.Autowired;
052import org.springframework.transaction.support.TransactionSynchronizationManager;
053
054import java.util.Collections;
055import java.util.List;
056import java.util.Set;
057
058import static org.apache.commons.lang3.StringUtils.isNotBlank;
059
060public abstract class BaseStorageResourceDao<T extends IBaseResource> extends BaseStorageDao
061                implements IFhirResourceDao<T>, IJpaDao<T> {
062        public static final StrictErrorHandler STRICT_ERROR_HANDLER = new StrictErrorHandler();
063
064        @Autowired
065        protected abstract HapiTransactionService getTransactionService();
066
067        @Autowired
068        protected abstract MatchResourceUrlService getMatchResourceUrlService();
069
070        @Autowired
071        protected abstract IStorageResourceParser getStorageResourceParser();
072
073        @Autowired
074        protected abstract IDeleteExpungeJobSubmitter getDeleteExpungeJobSubmitter();
075
076        @Override
077        public DaoMethodOutcome patch(
078                        IIdType theId,
079                        String theConditionalUrl,
080                        PatchTypeEnum thePatchType,
081                        String thePatchBody,
082                        IBaseParameters theFhirPatchBody,
083                        RequestDetails theRequestDetails) {
084                TransactionDetails transactionDetails = new TransactionDetails();
085                return getTransactionService()
086                                .execute(
087                                                theRequestDetails,
088                                                transactionDetails,
089                                                tx -> patchInTransaction(
090                                                                theId,
091                                                                theConditionalUrl,
092                                                                true,
093                                                                thePatchType,
094                                                                thePatchBody,
095                                                                theFhirPatchBody,
096                                                                theRequestDetails,
097                                                                transactionDetails));
098        }
099
100        @Override
101        public DaoMethodOutcome patchInTransaction(
102                        IIdType theId,
103                        String theConditionalUrl,
104                        boolean thePerformIndexing,
105                        PatchTypeEnum thePatchType,
106                        String thePatchBody,
107                        IBaseParameters theFhirPatchBody,
108                        RequestDetails theRequestDetails,
109                        TransactionDetails theTransactionDetails) {
110                assert TransactionSynchronizationManager.isActualTransactionActive();
111
112                IBasePersistedResource entityToUpdate;
113                IIdType resourceId;
114                if (isNotBlank(theConditionalUrl)) {
115
116                        Set<IResourcePersistentId> match = getMatchResourceUrlService()
117                                        .processMatchUrl(theConditionalUrl, getResourceType(), theTransactionDetails, theRequestDetails);
118                        if (match.size() > 1) {
119                                String msg = getContext()
120                                                .getLocalizer()
121                                                .getMessageSanitized(
122                                                                BaseStorageDao.class,
123                                                                "transactionOperationWithMultipleMatchFailure",
124                                                                "PATCH",
125                                                                theConditionalUrl,
126                                                                match.size());
127                                throw new PreconditionFailedException(Msg.code(972) + msg);
128                        } else if (match.size() == 1) {
129                                IResourcePersistentId pid = match.iterator().next();
130                                entityToUpdate = readEntityLatestVersion(pid, theRequestDetails, theTransactionDetails);
131                                resourceId = entityToUpdate.getIdDt();
132                        } else {
133                                String msg = getContext()
134                                                .getLocalizer()
135                                                .getMessageSanitized(BaseStorageDao.class, "invalidMatchUrlNoMatches", theConditionalUrl);
136                                throw new ResourceNotFoundException(Msg.code(973) + msg);
137                        }
138
139                } else {
140                        resourceId = theId;
141                        entityToUpdate = readEntityLatestVersion(theId, theRequestDetails, theTransactionDetails);
142                        if (theId.hasVersionIdPart()) {
143                                if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) {
144                                        throw new ResourceVersionConflictException(Msg.code(974) + "Version " + theId.getVersionIdPart()
145                                                        + " is not the most recent version of this resource, unable to apply patch");
146                                }
147                        }
148                }
149
150                validateResourceType(entityToUpdate, getResourceName());
151
152                if (entityToUpdate.isDeleted()) {
153                        throw createResourceGoneException(entityToUpdate);
154                }
155
156                IBaseResource resourceToUpdate = getStorageResourceParser().toResource(entityToUpdate, false);
157                if (resourceToUpdate == null) {
158                        // If this is null, we are presumably in a FHIR transaction bundle with both a create and a patch on the
159                        // same
160                        // resource. This is weird but not impossible.
161                        resourceToUpdate = theTransactionDetails.getResolvedResource(resourceId);
162                }
163
164                IBaseResource destination;
165                switch (thePatchType) {
166                        case JSON_PATCH:
167                                destination = JsonPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody);
168                                break;
169                        case XML_PATCH:
170                                destination = XmlPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody);
171                                break;
172                        case FHIR_PATCH_XML:
173                        case FHIR_PATCH_JSON:
174                        default:
175                                IBaseParameters fhirPatchJson = theFhirPatchBody;
176                                new FhirPatch(getContext()).apply(resourceToUpdate, fhirPatchJson);
177                                destination = resourceToUpdate;
178                                break;
179                }
180
181                @SuppressWarnings("unchecked")
182                T destinationCasted = (T) destination;
183                myFhirContext
184                                .newJsonParser()
185                                .setParserErrorHandler(STRICT_ERROR_HANDLER)
186                                .encodeResourceToString(destinationCasted);
187
188                preProcessResourceForStorage(destinationCasted, theRequestDetails, theTransactionDetails, true);
189
190                return doUpdateForUpdateOrPatch(
191                                theRequestDetails,
192                                resourceId,
193                                theConditionalUrl,
194                                thePerformIndexing,
195                                false,
196                                destinationCasted,
197                                entityToUpdate,
198                                RestOperationTypeEnum.PATCH,
199                                theTransactionDetails);
200        }
201
202        @Override
203        @Nonnull
204        public abstract Class<T> getResourceType();
205
206        @Override
207        @Nonnull
208        protected abstract String getResourceName();
209
210        protected abstract IBasePersistedResource readEntityLatestVersion(
211                        IResourcePersistentId thePersistentId,
212                        RequestDetails theRequestDetails,
213                        TransactionDetails theTransactionDetails);
214
215        protected abstract IBasePersistedResource readEntityLatestVersion(
216                        IIdType theId, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails);
217
218        protected DaoMethodOutcome doUpdateForUpdateOrPatch(
219                        RequestDetails theRequest,
220                        IIdType theResourceId,
221                        String theMatchUrl,
222                        boolean thePerformIndexing,
223                        boolean theForceUpdateVersion,
224                        T theResource,
225                        IBasePersistedResource theEntity,
226                        RestOperationTypeEnum theOperationType,
227                        TransactionDetails theTransactionDetails) {
228                if (theResourceId.hasVersionIdPart()
229                                && Long.parseLong(theResourceId.getVersionIdPart()) != theEntity.getVersion()) {
230                        throw new ResourceVersionConflictException(
231                                        Msg.code(989) + "Trying to update " + theResourceId + " but this is not the current version");
232                }
233
234                if (theResourceId.hasResourceType() && !theResourceId.getResourceType().equals(getResourceName())) {
235                        throw new UnprocessableEntityException(Msg.code(990) + "Invalid resource ID["
236                                        + theEntity.getIdDt().toUnqualifiedVersionless() + "] of type[" + theEntity.getResourceType()
237                                        + "] - Does not match expected [" + getResourceName() + "]");
238                }
239
240                IBaseResource oldResource;
241                if (getStorageSettings().isMassIngestionMode()) {
242                        oldResource = null;
243                } else {
244                        oldResource = getStorageResourceParser().toResource(theEntity, false);
245                }
246
247                /*
248                 * Mark the entity as not deleted - This is also done in the actual updateInternal()
249                 * method later on so it usually doesn't matter whether we do it here, but in the
250                 * case of a transaction with multiple PUTs we don't get there until later so
251                 * having this here means that a transaction can have a reference in one
252                 * resource to another resource in the same transaction that is being
253                 * un-deleted by the transaction. Wacky use case, sure. But it's real.
254                 *
255                 * See SystemProviderR4Test#testTransactionReSavesPreviouslyDeletedResources
256                 * for a test that needs this.
257                 */
258                boolean wasDeleted = theEntity.isDeleted();
259                theEntity.setNotDeleted();
260
261                /*
262                 * If we aren't indexing, that means we're doing this inside a transaction.
263                 * The transaction will do the actual storage to the database a bit later on,
264                 * after placeholder IDs have been replaced, by calling {@link #updateInternal}
265                 * directly. So we just bail now.
266                 */
267                if (!thePerformIndexing) {
268                        theResource.setId(theEntity.getIdDt().getValue());
269                        DaoMethodOutcome outcome = toMethodOutcome(
270                                                        theRequest, theEntity, theResource, theMatchUrl, theOperationType)
271                                        .setCreated(wasDeleted);
272                        outcome.setPreviousResource(oldResource);
273                        if (!outcome.isNop()) {
274                                // Technically this may not end up being right since we might not increment if the
275                                // contents turn out to be the same
276                                outcome.setId(outcome.getId()
277                                                .withVersion(Long.toString(outcome.getId().getVersionIdPartAsLong() + 1)));
278                        }
279                        return outcome;
280                }
281
282                /*
283                 * Otherwise, we're not in a transaction
284                 */
285                return updateInternal(
286                                theRequest,
287                                theResource,
288                                theMatchUrl,
289                                thePerformIndexing,
290                                theForceUpdateVersion,
291                                theEntity,
292                                theResourceId,
293                                oldResource,
294                                theOperationType,
295                                theTransactionDetails);
296        }
297
298        public static void validateResourceType(IBasePersistedResource theEntity, String theResourceName) {
299                if (!theResourceName.equals(theEntity.getResourceType())) {
300                        throw new ResourceNotFoundException(Msg.code(935) + "Resource with ID "
301                                        + theEntity.getIdDt().getIdPart() + " exists but it is not of type " + theResourceName
302                                        + ", found resource of type " + theEntity.getResourceType());
303                }
304        }
305
306        protected DeleteMethodOutcome deleteExpunge(String theUrl, RequestDetails theRequest) {
307                if (!getStorageSettings().canDeleteExpunge()) {
308                        throw new MethodNotAllowedException(Msg.code(963) + "_expunge is not enabled on this server: "
309                                        + getStorageSettings().cannotDeleteExpungeReason());
310                }
311
312                RestfulServerUtils.DeleteCascadeDetails cascadeDelete =
313                                RestfulServerUtils.extractDeleteCascadeParameter(theRequest);
314                boolean cascade = false;
315                Integer cascadeMaxRounds = null;
316                if (cascadeDelete.getMode() == DeleteCascadeModeEnum.DELETE) {
317                        cascade = true;
318                        cascadeMaxRounds = cascadeDelete.getMaxRounds();
319                        if (cascadeMaxRounds == null) {
320                                cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount();
321                        } else if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null
322                                        && myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) {
323                                cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount();
324                        }
325                }
326
327                List<String> urlsToDeleteExpunge = Collections.singletonList(theUrl);
328                try {
329                        String jobId = getDeleteExpungeJobSubmitter()
330                                        .submitJob(
331                                                        getStorageSettings().getExpungeBatchSize(),
332                                                        urlsToDeleteExpunge,
333                                                        cascade,
334                                                        cascadeMaxRounds,
335                                                        theRequest);
336                        return new DeleteMethodOutcome(createInfoOperationOutcome("Delete job submitted with id " + jobId));
337                } catch (InvalidRequestException e) {
338                        throw new InvalidRequestException(Msg.code(965) + "Invalid Delete Expunge Request: " + e.getMessage(), e);
339                }
340        }
341}