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                                                                getResourceName(),
126                                                                theConditionalUrl,
127                                                                match.size());
128                                throw new PreconditionFailedException(Msg.code(972) + msg);
129                        } else if (match.size() == 1) {
130                                IResourcePersistentId pid = match.iterator().next();
131                                entityToUpdate = readEntityLatestVersion(pid, theRequestDetails, theTransactionDetails);
132                                resourceId = entityToUpdate.getIdDt();
133                        } else {
134                                String msg = getContext()
135                                                .getLocalizer()
136                                                .getMessageSanitized(BaseStorageDao.class, "invalidMatchUrlNoMatches", theConditionalUrl);
137                                throw new ResourceNotFoundException(Msg.code(973) + msg);
138                        }
139
140                } else {
141                        resourceId = theId;
142                        entityToUpdate = readEntityLatestVersion(theId, theRequestDetails, theTransactionDetails);
143                        if (theId.hasVersionIdPart()) {
144                                if (theId.getVersionIdPartAsLong() != entityToUpdate.getVersion()) {
145                                        throw new ResourceVersionConflictException(Msg.code(974) + "Version " + theId.getVersionIdPart()
146                                                        + " is not the most recent version of this resource, unable to apply patch");
147                                }
148                        }
149                }
150
151                validateResourceType(entityToUpdate, getResourceName());
152
153                if (entityToUpdate.isDeleted()) {
154                        throw createResourceGoneException(entityToUpdate);
155                }
156
157                IBaseResource resourceToUpdate = getStorageResourceParser().toResource(entityToUpdate, false);
158                if (resourceToUpdate == null) {
159                        // If this is null, we are presumably in a FHIR transaction bundle with both a create and a patch on the
160                        // same
161                        // resource. This is weird but not impossible.
162                        resourceToUpdate = theTransactionDetails.getResolvedResource(resourceId);
163                }
164
165                IBaseResource destination;
166                switch (thePatchType) {
167                        case JSON_PATCH:
168                                destination = JsonPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody);
169                                break;
170                        case XML_PATCH:
171                                destination = XmlPatchUtils.apply(getContext(), resourceToUpdate, thePatchBody);
172                                break;
173                        case FHIR_PATCH_XML:
174                        case FHIR_PATCH_JSON:
175                        default:
176                                IBaseParameters fhirPatchJson = theFhirPatchBody;
177                                new FhirPatch(getContext()).apply(resourceToUpdate, fhirPatchJson);
178                                destination = resourceToUpdate;
179                                break;
180                }
181
182                @SuppressWarnings("unchecked")
183                T destinationCasted = (T) destination;
184                myFhirContext
185                                .newJsonParser()
186                                .setParserErrorHandler(STRICT_ERROR_HANDLER)
187                                .encodeResourceToString(destinationCasted);
188
189                preProcessResourceForStorage(destinationCasted, theRequestDetails, theTransactionDetails, true);
190
191                return doUpdateForUpdateOrPatch(
192                                theRequestDetails,
193                                resourceId,
194                                theConditionalUrl,
195                                thePerformIndexing,
196                                false,
197                                destinationCasted,
198                                entityToUpdate,
199                                RestOperationTypeEnum.PATCH,
200                                theTransactionDetails);
201        }
202
203        @Override
204        @Nonnull
205        public abstract Class<T> getResourceType();
206
207        @Override
208        @Nonnull
209        protected abstract String getResourceName();
210
211        protected abstract IBasePersistedResource readEntityLatestVersion(
212                        IResourcePersistentId thePersistentId,
213                        RequestDetails theRequestDetails,
214                        TransactionDetails theTransactionDetails);
215
216        protected abstract IBasePersistedResource readEntityLatestVersion(
217                        IIdType theId, RequestDetails theRequestDetails, TransactionDetails theTransactionDetails);
218
219        protected DaoMethodOutcome doUpdateForUpdateOrPatch(
220                        RequestDetails theRequest,
221                        IIdType theResourceId,
222                        String theMatchUrl,
223                        boolean thePerformIndexing,
224                        boolean theForceUpdateVersion,
225                        T theResource,
226                        IBasePersistedResource theEntity,
227                        RestOperationTypeEnum theOperationType,
228                        TransactionDetails theTransactionDetails) {
229                if (theResourceId.hasVersionIdPart()
230                                && Long.parseLong(theResourceId.getVersionIdPart()) != theEntity.getVersion()) {
231                        throw new ResourceVersionConflictException(
232                                        Msg.code(989) + "Trying to update " + theResourceId + " but this is not the current version");
233                }
234
235                if (theResourceId.hasResourceType() && !theResourceId.getResourceType().equals(getResourceName())) {
236                        throw new UnprocessableEntityException(Msg.code(990) + "Invalid resource ID["
237                                        + theEntity.getIdDt().toUnqualifiedVersionless() + "] of type[" + theEntity.getResourceType()
238                                        + "] - Does not match expected [" + getResourceName() + "]");
239                }
240
241                IBaseResource oldResource;
242                if (getStorageSettings().isMassIngestionMode()) {
243                        oldResource = null;
244                } else {
245                        oldResource = getStorageResourceParser().toResource(theEntity, false);
246                }
247
248                /*
249                 * Mark the entity as not deleted - This is also done in the actual updateInternal()
250                 * method later on so it usually doesn't matter whether we do it here, but in the
251                 * case of a transaction with multiple PUTs we don't get there until later so
252                 * having this here means that a transaction can have a reference in one
253                 * resource to another resource in the same transaction that is being
254                 * un-deleted by the transaction. Wacky use case, sure. But it's real.
255                 *
256                 * See SystemProviderR4Test#testTransactionReSavesPreviouslyDeletedResources
257                 * for a test that needs this.
258                 */
259                boolean wasDeleted = theEntity.isDeleted();
260                theEntity.setNotDeleted();
261
262                /*
263                 * If we aren't indexing, that means we're doing this inside a transaction.
264                 * The transaction will do the actual storage to the database a bit later on,
265                 * after placeholder IDs have been replaced, by calling {@link #updateInternal}
266                 * directly. So we just bail now.
267                 */
268                if (!thePerformIndexing) {
269                        theResource.setId(theEntity.getIdDt().getValue());
270                        DaoMethodOutcome outcome = toMethodOutcome(
271                                                        theRequest, theEntity, theResource, theMatchUrl, theOperationType)
272                                        .setCreated(wasDeleted);
273                        outcome.setPreviousResource(oldResource);
274                        if (!outcome.isNop()) {
275                                // Technically this may not end up being right since we might not increment if the
276                                // contents turn out to be the same
277                                outcome.setId(outcome.getId()
278                                                .withVersion(Long.toString(outcome.getId().getVersionIdPartAsLong() + 1)));
279                        }
280                        return outcome;
281                }
282
283                /*
284                 * Otherwise, we're not in a transaction
285                 */
286                return updateInternal(
287                                theRequest,
288                                theResource,
289                                theMatchUrl,
290                                thePerformIndexing,
291                                theForceUpdateVersion,
292                                theEntity,
293                                theResourceId,
294                                oldResource,
295                                theOperationType,
296                                theTransactionDetails);
297        }
298
299        public static void validateResourceType(IBasePersistedResource theEntity, String theResourceName) {
300                if (!theResourceName.equals(theEntity.getResourceType())) {
301                        throw new ResourceNotFoundException(Msg.code(935) + "Resource with ID "
302                                        + theEntity.getIdDt().getIdPart() + " exists but it is not of type " + theResourceName
303                                        + ", found resource of type " + theEntity.getResourceType());
304                }
305        }
306
307        protected DeleteMethodOutcome deleteExpunge(String theUrl, RequestDetails theRequest) {
308                if (!getStorageSettings().canDeleteExpunge()) {
309                        throw new MethodNotAllowedException(Msg.code(963) + "_expunge is not enabled on this server: "
310                                        + getStorageSettings().cannotDeleteExpungeReason());
311                }
312
313                RestfulServerUtils.DeleteCascadeDetails cascadeDelete =
314                                RestfulServerUtils.extractDeleteCascadeParameter(theRequest);
315                boolean cascade = false;
316                Integer cascadeMaxRounds = null;
317                if (cascadeDelete.getMode() == DeleteCascadeModeEnum.DELETE) {
318                        cascade = true;
319                        cascadeMaxRounds = cascadeDelete.getMaxRounds();
320                        if (cascadeMaxRounds == null) {
321                                cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount();
322                        } else if (myStorageSettings.getMaximumDeleteConflictQueryCount() != null
323                                        && myStorageSettings.getMaximumDeleteConflictQueryCount() < cascadeMaxRounds) {
324                                cascadeMaxRounds = myStorageSettings.getMaximumDeleteConflictQueryCount();
325                        }
326                }
327
328                List<String> urlsToDeleteExpunge = Collections.singletonList(theUrl);
329                try {
330                        String jobId = getDeleteExpungeJobSubmitter()
331                                        .submitJob(
332                                                        getStorageSettings().getExpungeBatchSize(),
333                                                        urlsToDeleteExpunge,
334                                                        cascade,
335                                                        cascadeMaxRounds,
336                                                        theRequest);
337                        return new DeleteMethodOutcome(createInfoOperationOutcome("Delete job submitted with id " + jobId));
338                } catch (InvalidRequestException e) {
339                        throw new InvalidRequestException(Msg.code(965) + "Invalid Delete Expunge Request: " + e.getMessage(), e);
340                }
341        }
342}