001/*-
002 * #%L
003 * HAPI FHIR Storage api
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.binary.provider;
021
022import ca.uhn.fhir.context.BaseRuntimeElementDefinition;
023import ca.uhn.fhir.context.FhirContext;
024import ca.uhn.fhir.i18n.Msg;
025import ca.uhn.fhir.jpa.api.dao.DaoRegistry;
026import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao;
027import ca.uhn.fhir.jpa.api.model.DaoMethodOutcome;
028import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc;
029import ca.uhn.fhir.jpa.binary.api.IBinaryTarget;
030import ca.uhn.fhir.jpa.binary.api.StoredDetails;
031import ca.uhn.fhir.jpa.model.util.JpaConstants;
032import ca.uhn.fhir.rest.annotation.IdParam;
033import ca.uhn.fhir.rest.annotation.Operation;
034import ca.uhn.fhir.rest.annotation.OperationParam;
035import ca.uhn.fhir.rest.api.Constants;
036import ca.uhn.fhir.rest.api.EncodingEnum;
037import ca.uhn.fhir.rest.server.RestfulServer;
038import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
039import ca.uhn.fhir.rest.server.servlet.ServletRequestDetails;
040import ca.uhn.fhir.util.AttachmentUtil;
041import ca.uhn.fhir.util.BinaryUtil;
042import ca.uhn.fhir.util.DateUtils;
043import ca.uhn.fhir.util.HapiExtensions;
044import com.google.common.annotations.VisibleForTesting;
045import com.google.common.hash.HashFunction;
046import com.google.common.hash.Hashing;
047import jakarta.annotation.Nonnull;
048import jakarta.servlet.http.HttpServletRequest;
049import jakarta.servlet.http.HttpServletResponse;
050import org.apache.commons.lang3.StringUtils;
051import org.apache.commons.lang3.Validate;
052import org.hl7.fhir.instance.model.api.IBase;
053import org.hl7.fhir.instance.model.api.IBaseBinary;
054import org.hl7.fhir.instance.model.api.IBaseExtension;
055import org.hl7.fhir.instance.model.api.IBaseHasExtensions;
056import org.hl7.fhir.instance.model.api.IBaseResource;
057import org.hl7.fhir.instance.model.api.ICompositeType;
058import org.hl7.fhir.instance.model.api.IIdType;
059import org.hl7.fhir.instance.model.api.IPrimitiveType;
060import org.slf4j.Logger;
061import org.slf4j.LoggerFactory;
062import org.springframework.beans.factory.annotation.Autowired;
063
064import java.io.ByteArrayInputStream;
065import java.io.IOException;
066import java.io.InputStream;
067import java.util.Optional;
068
069import static ca.uhn.fhir.jpa.binary.interceptor.BinaryStorageInterceptor.AUTO_INFLATE_BINARY_CONTENT_KEY;
070import static ca.uhn.fhir.util.UrlUtil.sanitizeUrlPart;
071import static org.apache.commons.lang3.StringUtils.isBlank;
072
073/**
074 * This plain provider class can be registered with a JPA RestfulServer
075 * to provide the <code>$binary-access-read</code> and <code>$binary-access-write</code>
076 * operations that can be used to access attachment data as a raw binary.
077 */
078public class BinaryAccessProvider {
079
080        private static final Logger ourLog = LoggerFactory.getLogger(BinaryAccessProvider.class);
081
082        private static final HashFunction SHA_256 = Hashing.sha256();
083
084        @Autowired
085        private FhirContext myCtx;
086
087        @Autowired
088        private DaoRegistry myDaoRegistry;
089
090        @Autowired(required = false)
091        private IBinaryStorageSvc myBinaryStorageSvc;
092
093        private Boolean addTargetAttachmentIdForTest = false;
094
095        /**
096         * $binary-access-read
097         */
098        @Operation(
099                        name = JpaConstants.OPERATION_BINARY_ACCESS_READ,
100                        global = true,
101                        manualResponse = true,
102                        idempotent = true)
103        public void binaryAccessRead(
104                        @IdParam IIdType theResourceId,
105                        @OperationParam(name = "path", min = 1, max = 1) IPrimitiveType<String> thePath,
106                        ServletRequestDetails theRequestDetails,
107                        HttpServletRequest theServletRequest,
108                        HttpServletResponse theServletResponse)
109                        throws IOException {
110
111                String path = validateResourceTypeAndPath(theResourceId, thePath);
112                IFhirResourceDao dao = getDaoForRequest(theResourceId);
113                IBaseResource resource = dao.read(theResourceId, theRequestDetails, false);
114
115                IBinaryTarget target = findAttachmentForRequest(resource, path, theRequestDetails);
116                Optional<String> attachmentId = target.getAttachmentId();
117
118                // for unit test only
119                if (addTargetAttachmentIdForTest) {
120                        attachmentId = Optional.of("1");
121                }
122
123                if (attachmentId.isPresent()) {
124
125                        String blobId = attachmentId.get();
126
127                        StoredDetails blobDetails = myBinaryStorageSvc.fetchBinaryContentDetails(theResourceId, blobId);
128                        if (blobDetails == null) {
129                                String msg = myCtx.getLocalizer().getMessage(BinaryAccessProvider.class, "unknownBlobId");
130                                throw new InvalidRequestException(Msg.code(1331) + msg);
131                        }
132
133                        theServletResponse.setStatus(200);
134                        theServletResponse.setContentType(blobDetails.getContentType());
135                        if (blobDetails.getBytes() <= Integer.MAX_VALUE) {
136                                theServletResponse.setContentLength((int) blobDetails.getBytes());
137                        }
138
139                        RestfulServer server = theRequestDetails.getServer();
140                        server.addHeadersToResponse(theServletResponse);
141
142                        theServletResponse.addHeader(Constants.HEADER_CACHE_CONTROL, Constants.CACHE_CONTROL_PRIVATE);
143                        theServletResponse.addHeader(Constants.HEADER_ETAG, '"' + blobDetails.getHash() + '"');
144                        theServletResponse.addHeader(
145                                        Constants.HEADER_LAST_MODIFIED, DateUtils.formatDate(blobDetails.getPublished()));
146
147                        myBinaryStorageSvc.writeBinaryContent(theResourceId, blobId, theServletResponse.getOutputStream());
148                        theServletResponse.getOutputStream().close();
149
150                } else {
151                        String contentType = target.getContentType();
152                        contentType = StringUtils.defaultIfBlank(contentType, Constants.CT_OCTET_STREAM);
153
154                        byte[] data = target.getData();
155                        if (data == null) {
156                                String msg = myCtx.getLocalizer()
157                                                .getMessage(
158                                                                BinaryAccessProvider.class,
159                                                                "noAttachmentDataPresent",
160                                                                sanitizeUrlPart(theResourceId),
161                                                                sanitizeUrlPart(thePath));
162                                throw new InvalidRequestException(Msg.code(1332) + msg);
163                        }
164
165                        theServletResponse.setStatus(200);
166                        theServletResponse.setContentType(contentType);
167                        theServletResponse.setContentLength(data.length);
168
169                        RestfulServer server = theRequestDetails.getServer();
170                        server.addHeadersToResponse(theServletResponse);
171
172                        theServletResponse.getOutputStream().write(data);
173                        theServletResponse.getOutputStream().close();
174                }
175        }
176
177        /**
178         * $binary-access-write
179         */
180        @SuppressWarnings("unchecked")
181        @Operation(
182                        name = JpaConstants.OPERATION_BINARY_ACCESS_WRITE,
183                        global = true,
184                        manualRequest = true,
185                        idempotent = false)
186        public IBaseResource binaryAccessWrite(
187                        @IdParam IIdType theResourceId,
188                        @OperationParam(name = "path", min = 1, max = 1) IPrimitiveType<String> thePath,
189                        ServletRequestDetails theRequestDetails,
190                        HttpServletRequest theServletRequest,
191                        HttpServletResponse theServletResponse)
192                        throws IOException {
193
194                String path = validateResourceTypeAndPath(theResourceId, thePath);
195                IFhirResourceDao dao = getDaoForRequest(theResourceId);
196                // disable auto-inflation temporarily as binary content will be replaced anyway
197                Optional.ofNullable(theRequestDetails)
198                                .ifPresent(rd -> rd.getUserData().put(AUTO_INFLATE_BINARY_CONTENT_KEY, Boolean.FALSE));
199                IBaseResource resource = dao.read(theResourceId, theRequestDetails, false);
200                Optional.ofNullable(theRequestDetails)
201                                .ifPresent(rd -> theRequestDetails.getUserData().remove(AUTO_INFLATE_BINARY_CONTENT_KEY));
202
203                IBinaryTarget target = findAttachmentForRequest(resource, path, theRequestDetails);
204
205                String requestContentType = theServletRequest.getContentType();
206                validateRequestContentType(requestContentType);
207
208                long size = theServletRequest.getContentLength();
209                ourLog.trace("Request specified content length: {}", size);
210
211                String blobId = null;
212                StoredDetails storedDetails = null;
213                byte[] bytes = theRequestDetails.loadRequestContents();
214                String hash = null;
215
216                if (size > 0 && myBinaryStorageSvc != null) {
217                        validateBinaryContent(bytes);
218
219                        if (myBinaryStorageSvc.shouldStoreBinaryContent(size, theResourceId, requestContentType)) {
220                                hash = getBinaryContentHash(bytes);
221                                storedDetails = storeBinaryContentIfRequired(
222                                                theResourceId, theRequestDetails, theServletRequest, target, hash, bytes, requestContentType);
223                        }
224                }
225
226                if (storedDetails != null) {
227                        size = storedDetails.getBytes();
228                        blobId = storedDetails.getBinaryContentId();
229                }
230
231                if (blobId == null) {
232                        size = bytes.length;
233                        target.setData(bytes);
234                } else {
235                        replaceDataWithExtension(target, blobId);
236                        addHashExtension(target, hash);
237                }
238
239                target.setContentType(requestContentType);
240                target.setSize(null);
241                if (size <= Integer.MAX_VALUE) {
242                        target.setSize((int) size);
243                }
244
245                DaoMethodOutcome outcome = dao.update(resource, theRequestDetails);
246                return outcome.getResource();
247        }
248
249        /**
250         * This method checks if the given binary content (based on its SHA-256 hash) is already stored in previous
251         * resource version. If it is, it reuses the existing attachment ID to avoid saving the same content again.
252         * If it's not found, it stores the new content and returns the newly generated attachment ID.
253         */
254        private StoredDetails storeBinaryContentIfRequired(
255                        IIdType theResourceId,
256                        ServletRequestDetails theRequestDetails,
257                        HttpServletRequest theServletRequest,
258                        IBinaryTarget theTarget,
259                        String theBinaryContentHash,
260                        byte[] theBinaryContent,
261                        String theRequestContentType)
262                        throws IOException {
263                StoredDetails storedDetails;
264                String existingHash = theTarget.getHashExtension().orElse(null);
265                String existingAttachmentId = theTarget.getAttachmentId().orElse(null);
266
267                boolean isNoOp = existingAttachmentId != null && theBinaryContentHash.equals(existingHash);
268                if (isNoOp) {
269                        // input binary content is the same as existing binary content, reuse existing binaryId
270                        storedDetails = new StoredDetails();
271                        storedDetails.setHash(theBinaryContentHash);
272                        storedDetails.setBinaryContentId(existingAttachmentId);
273                        storedDetails.setBytes(theBinaryContent.length);
274                } else {
275                        // there is no existing binary content or content is different, store new content in binary storage
276                        storedDetails = storeBinaryContent(
277                                        theResourceId, theRequestDetails, theServletRequest, theRequestContentType, theBinaryContent);
278                }
279                return storedDetails;
280        }
281
282        private void validateBinaryContent(byte[] theBinaryContent) {
283                if (theBinaryContent == null || theBinaryContent.length == 0) {
284                        throw new IllegalStateException(
285                                        Msg.code(2073)
286                                                        + "Input stream is empty! Ensure that you are uploading data, and if so, ensure that no interceptors are in use that may be consuming the input stream");
287                }
288        }
289
290        private void validateRequestContentType(String theRequestContentType) {
291                if (isBlank(theRequestContentType)) {
292                        throw new InvalidRequestException(Msg.code(1333) + "No content-target supplied");
293                }
294                if (EncodingEnum.forContentTypeStrict(theRequestContentType) != null) {
295                        throw new InvalidRequestException(
296                                        Msg.code(1334) + "This operation is for binary content, got: " + theRequestContentType);
297                }
298        }
299
300        private StoredDetails storeBinaryContent(
301                        IIdType theResourceId,
302                        ServletRequestDetails theRequestDetails,
303                        HttpServletRequest theServletRequest,
304                        String theRequestContentType,
305                        byte[] theBinaryContent)
306                        throws IOException {
307                InputStream inputStream = new ByteArrayInputStream(theBinaryContent);
308                StoredDetails storedDetails = myBinaryStorageSvc.storeBinaryContent(
309                                theResourceId, null, theRequestContentType, inputStream, theRequestDetails);
310                Validate.notBlank(
311                                storedDetails.getBinaryContentId(), "BinaryStorageSvc returned a null blob ID"); // should not happen
312                Validate.isTrue(
313                                storedDetails.getBytes() == theServletRequest.getContentLength(),
314                                "Unexpected stored size"); // Sanity check
315                return storedDetails;
316        }
317
318        public String getBinaryContentHash(byte[] binaryContent) {
319                return SHA_256.hashBytes(binaryContent).toString();
320        }
321
322        public void replaceDataWithExtension(IBinaryTarget theTarget, String theBlobId) {
323                removeExtensionFromBinaryTarget(theTarget, HapiExtensions.EXT_EXTERNALIZED_BINARY_ID);
324                theTarget.setData(null);
325
326                addExtensionToBinaryTarget(theTarget, HapiExtensions.EXT_EXTERNALIZED_BINARY_ID, theBlobId);
327        }
328
329        public void addHashExtension(IBinaryTarget theTarget, String theHash) {
330                removeExtensionFromBinaryTarget(theTarget, HapiExtensions.EXT_EXTERNALIZED_BINARY_HASH_SHA_256);
331                addExtensionToBinaryTarget(theTarget, HapiExtensions.EXT_EXTERNALIZED_BINARY_HASH_SHA_256, theHash);
332        }
333
334        private void removeExtensionFromBinaryTarget(IBinaryTarget theTarget, String theExtension) {
335                theTarget.getTarget().getExtension().removeIf(t -> theExtension.equals(t.getUrl()));
336        }
337
338        private void addExtensionToBinaryTarget(IBinaryTarget theTarget, String theExtension, String theValue) {
339                IBaseExtension<?, ?> ext = theTarget.getTarget().addExtension();
340                ext.setUrl(theExtension);
341                ext.setUserData(JpaConstants.EXTENSION_EXT_SYSTEMDEFINED, Boolean.TRUE);
342                IPrimitiveType<String> valueString =
343                                (IPrimitiveType<String>) myCtx.getElementDefinition("string").newInstance();
344                valueString.setValueAsString(theValue);
345                ext.setValue(valueString);
346        }
347
348        @Nonnull
349        private IBinaryTarget findAttachmentForRequest(
350                        IBaseResource theResource, String thePath, ServletRequestDetails theRequestDetails) {
351                Optional<IBase> type = myCtx.newFluentPath().evaluateFirst(theResource, thePath, IBase.class);
352                String resType = this.myCtx.getResourceType(theResource);
353                if (type.isEmpty()) {
354                        String msg = this.myCtx
355                                        .getLocalizer()
356                                        .getMessageSanitized(BinaryAccessProvider.class, "unknownPath", resType, thePath);
357                        throw new InvalidRequestException(Msg.code(1335) + msg);
358                }
359                IBase element = type.get();
360
361                Optional<IBinaryTarget> binaryTarget = toBinaryTarget(element);
362
363                if (binaryTarget.isEmpty()) {
364                        BaseRuntimeElementDefinition<?> def2 = myCtx.getElementDefinition(element.getClass());
365                        String msg = this.myCtx
366                                        .getLocalizer()
367                                        .getMessageSanitized(BinaryAccessProvider.class, "unknownType", resType, thePath, def2.getName());
368                        throw new InvalidRequestException(Msg.code(1336) + msg);
369                } else {
370                        return binaryTarget.get();
371                }
372        }
373
374        public Optional<IBinaryTarget> toBinaryTarget(IBase theElement) {
375                IBinaryTarget binaryTarget = null;
376
377                // Path is attachment
378                BaseRuntimeElementDefinition<?> def = myCtx.getElementDefinition(theElement.getClass());
379                if (def.getName().equals("Attachment")) {
380                        ICompositeType attachment = (ICompositeType) theElement;
381                        binaryTarget = new IBinaryTarget() {
382                                @Override
383                                public void setSize(Integer theSize) {
384                                        AttachmentUtil.setSize(BinaryAccessProvider.this.myCtx, attachment, theSize);
385                                }
386
387                                @Override
388                                public String getContentType() {
389                                        return AttachmentUtil.getOrCreateContentType(BinaryAccessProvider.this.myCtx, attachment)
390                                                        .getValueAsString();
391                                }
392
393                                @Override
394                                public byte[] getData() {
395                                        IPrimitiveType<byte[]> dataDt = AttachmentUtil.getOrCreateData(myCtx, attachment);
396                                        return dataDt.getValue();
397                                }
398
399                                @Override
400                                public IBaseHasExtensions getTarget() {
401                                        return (IBaseHasExtensions) AttachmentUtil.getOrCreateData(myCtx, attachment);
402                                }
403
404                                @Override
405                                public void setContentType(String theContentType) {
406                                        AttachmentUtil.setContentType(BinaryAccessProvider.this.myCtx, attachment, theContentType);
407                                }
408
409                                @Override
410                                public void setData(byte[] theBytes) {
411                                        AttachmentUtil.setData(myCtx, attachment, theBytes);
412                                }
413                        };
414                }
415
416                // Path is Binary
417                if (def.getName().equals("Binary")) {
418                        IBaseBinary binary = (IBaseBinary) theElement;
419                        binaryTarget = new IBinaryTarget() {
420                                @Override
421                                public void setSize(Integer theSize) {
422                                        // ignore
423                                }
424
425                                @Override
426                                public String getContentType() {
427                                        return binary.getContentType();
428                                }
429
430                                @Override
431                                public byte[] getData() {
432                                        return binary.getContent();
433                                }
434
435                                @Override
436                                public IBaseHasExtensions getTarget() {
437                                        return (IBaseHasExtensions) BinaryUtil.getOrCreateData(BinaryAccessProvider.this.myCtx, binary);
438                                }
439
440                                @Override
441                                public void setContentType(String theContentType) {
442                                        binary.setContentType(theContentType);
443                                }
444
445                                @Override
446                                public void setData(byte[] theBytes) {
447                                        binary.setContent(theBytes);
448                                }
449                        };
450                }
451
452                return Optional.ofNullable(binaryTarget);
453        }
454
455        private String validateResourceTypeAndPath(
456                        @IdParam IIdType theResourceId,
457                        @OperationParam(name = "path", min = 1, max = 1) IPrimitiveType<String> thePath) {
458                if (isBlank(theResourceId.getResourceType())) {
459                        throw new InvalidRequestException(Msg.code(1337) + "No resource type specified");
460                }
461                if (isBlank(theResourceId.getIdPart())) {
462                        throw new InvalidRequestException(Msg.code(1338) + "No ID specified");
463                }
464                if (thePath == null || isBlank(thePath.getValue())) {
465                        if ("Binary".equals(theResourceId.getResourceType())) {
466                                return "Binary";
467                        }
468                        throw new InvalidRequestException(Msg.code(1339) + "No path specified");
469                }
470
471                return thePath.getValue();
472        }
473
474        @Nonnull
475        private IFhirResourceDao getDaoForRequest(@IdParam IIdType theResourceId) {
476                String resourceType = theResourceId.getResourceType();
477                IFhirResourceDao dao = myDaoRegistry.getResourceDao(resourceType);
478                if (dao == null) {
479                        throw new InvalidRequestException(
480                                        Msg.code(1340) + "Unknown/unsupported resource type: " + sanitizeUrlPart(resourceType));
481                }
482                return dao;
483        }
484
485        @VisibleForTesting
486        public void setDaoRegistryForUnitTest(DaoRegistry theDaoRegistry) {
487                myDaoRegistry = theDaoRegistry;
488        }
489
490        @VisibleForTesting
491        public void setBinaryStorageSvcForUnitTest(IBinaryStorageSvc theBinaryStorageSvc) {
492                myBinaryStorageSvc = theBinaryStorageSvc;
493        }
494
495        @VisibleForTesting
496        public void setFhirContextForUnitTest(FhirContext theCtx) {
497                myCtx = theCtx;
498        }
499
500        @VisibleForTesting
501        public void setTargetAttachmentIdForUnitTest(Boolean theTargetAttachmentIdForTest) {
502                addTargetAttachmentIdForTest = theTargetAttachmentIdForTest;
503        }
504}