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.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 jakarta.annotation.Nonnull;
046import jakarta.servlet.http.HttpServletRequest;
047import jakarta.servlet.http.HttpServletResponse;
048import org.apache.commons.lang3.StringUtils;
049import org.apache.commons.lang3.Validate;
050import org.hl7.fhir.instance.model.api.IBase;
051import org.hl7.fhir.instance.model.api.IBaseBinary;
052import org.hl7.fhir.instance.model.api.IBaseExtension;
053import org.hl7.fhir.instance.model.api.IBaseHasExtensions;
054import org.hl7.fhir.instance.model.api.IBaseResource;
055import org.hl7.fhir.instance.model.api.ICompositeType;
056import org.hl7.fhir.instance.model.api.IIdType;
057import org.hl7.fhir.instance.model.api.IPrimitiveType;
058import org.slf4j.Logger;
059import org.slf4j.LoggerFactory;
060import org.springframework.beans.factory.annotation.Autowired;
061
062import java.io.ByteArrayInputStream;
063import java.io.IOException;
064import java.util.Optional;
065
066import static ca.uhn.fhir.util.UrlUtil.sanitizeUrlPart;
067import static org.apache.commons.lang3.StringUtils.isBlank;
068
069/**
070 * This plain provider class can be registered with a JPA RestfulServer
071 * to provide the <code>$binary-access-read</code> and <code>$binary-access-write</code>
072 * operations that can be used to access attachment data as a raw binary.
073 */
074public class BinaryAccessProvider {
075
076        private static final Logger ourLog = LoggerFactory.getLogger(BinaryAccessProvider.class);
077
078        @Autowired
079        private FhirContext myCtx;
080
081        @Autowired
082        private DaoRegistry myDaoRegistry;
083
084        @Autowired(required = false)
085        private IBinaryStorageSvc myBinaryStorageSvc;
086
087        private Boolean addTargetAttachmentIdForTest = false;
088
089        /**
090         * $binary-access-read
091         */
092        @Operation(
093                        name = JpaConstants.OPERATION_BINARY_ACCESS_READ,
094                        global = true,
095                        manualResponse = true,
096                        idempotent = true)
097        public void binaryAccessRead(
098                        @IdParam IIdType theResourceId,
099                        @OperationParam(name = "path", min = 1, max = 1) IPrimitiveType<String> thePath,
100                        ServletRequestDetails theRequestDetails,
101                        HttpServletRequest theServletRequest,
102                        HttpServletResponse theServletResponse)
103                        throws IOException {
104
105                String path = validateResourceTypeAndPath(theResourceId, thePath);
106                IFhirResourceDao dao = getDaoForRequest(theResourceId);
107                IBaseResource resource = dao.read(theResourceId, theRequestDetails, false);
108
109                IBinaryTarget target = findAttachmentForRequest(resource, path, theRequestDetails);
110                Optional<String> attachmentId = target.getAttachmentId();
111
112                // for unit test only
113                if (addTargetAttachmentIdForTest) {
114                        attachmentId = Optional.of("1");
115                }
116
117                if (attachmentId.isPresent()) {
118
119                        String blobId = attachmentId.get();
120
121                        StoredDetails blobDetails = myBinaryStorageSvc.fetchBinaryContentDetails(theResourceId, blobId);
122                        if (blobDetails == null) {
123                                String msg = myCtx.getLocalizer().getMessage(BinaryAccessProvider.class, "unknownBlobId");
124                                throw new InvalidRequestException(Msg.code(1331) + msg);
125                        }
126
127                        theServletResponse.setStatus(200);
128                        theServletResponse.setContentType(blobDetails.getContentType());
129                        if (blobDetails.getBytes() <= Integer.MAX_VALUE) {
130                                theServletResponse.setContentLength((int) blobDetails.getBytes());
131                        }
132
133                        RestfulServer server = theRequestDetails.getServer();
134                        server.addHeadersToResponse(theServletResponse);
135
136                        theServletResponse.addHeader(Constants.HEADER_CACHE_CONTROL, Constants.CACHE_CONTROL_PRIVATE);
137                        theServletResponse.addHeader(Constants.HEADER_ETAG, '"' + blobDetails.getHash() + '"');
138                        theServletResponse.addHeader(
139                                        Constants.HEADER_LAST_MODIFIED, DateUtils.formatDate(blobDetails.getPublished()));
140
141                        myBinaryStorageSvc.writeBinaryContent(theResourceId, blobId, theServletResponse.getOutputStream());
142                        theServletResponse.getOutputStream().close();
143
144                } else {
145                        String contentType = target.getContentType();
146                        contentType = StringUtils.defaultIfBlank(contentType, Constants.CT_OCTET_STREAM);
147
148                        byte[] data = target.getData();
149                        if (data == null) {
150                                String msg = myCtx.getLocalizer()
151                                                .getMessage(
152                                                                BinaryAccessProvider.class,
153                                                                "noAttachmentDataPresent",
154                                                                sanitizeUrlPart(theResourceId),
155                                                                sanitizeUrlPart(thePath));
156                                throw new InvalidRequestException(Msg.code(1332) + msg);
157                        }
158
159                        theServletResponse.setStatus(200);
160                        theServletResponse.setContentType(contentType);
161                        theServletResponse.setContentLength(data.length);
162
163                        RestfulServer server = theRequestDetails.getServer();
164                        server.addHeadersToResponse(theServletResponse);
165
166                        theServletResponse.getOutputStream().write(data);
167                        theServletResponse.getOutputStream().close();
168                }
169        }
170
171        /**
172         * $binary-access-write
173         */
174        @SuppressWarnings("unchecked")
175        @Operation(
176                        name = JpaConstants.OPERATION_BINARY_ACCESS_WRITE,
177                        global = true,
178                        manualRequest = true,
179                        idempotent = false)
180        public IBaseResource binaryAccessWrite(
181                        @IdParam IIdType theResourceId,
182                        @OperationParam(name = "path", min = 1, max = 1) IPrimitiveType<String> thePath,
183                        ServletRequestDetails theRequestDetails,
184                        HttpServletRequest theServletRequest,
185                        HttpServletResponse theServletResponse)
186                        throws IOException {
187
188                String path = validateResourceTypeAndPath(theResourceId, thePath);
189                IFhirResourceDao dao = getDaoForRequest(theResourceId);
190                IBaseResource resource = dao.read(theResourceId, theRequestDetails, false);
191
192                IBinaryTarget target = findAttachmentForRequest(resource, path, theRequestDetails);
193
194                String requestContentType = theServletRequest.getContentType();
195                if (isBlank(requestContentType)) {
196                        throw new InvalidRequestException(Msg.code(1333) + "No content-target supplied");
197                }
198                if (EncodingEnum.forContentTypeStrict(requestContentType) != null) {
199                        throw new InvalidRequestException(
200                                        Msg.code(1334) + "This operation is for binary content, got: " + requestContentType);
201                }
202
203                long size = theServletRequest.getContentLength();
204                ourLog.trace("Request specified content length: {}", size);
205
206                String blobId = null;
207                byte[] bytes = theRequestDetails.loadRequestContents();
208
209                if (size > 0 && myBinaryStorageSvc != null) {
210                        if (bytes == null || bytes.length == 0) {
211                                throw new IllegalStateException(
212                                                Msg.code(2073)
213                                                                + "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");
214                        }
215                        if (myBinaryStorageSvc.shouldStoreBinaryContent(size, theResourceId, requestContentType)) {
216                                StoredDetails storedDetails = myBinaryStorageSvc.storeBinaryContent(
217                                                theResourceId, null, requestContentType, new ByteArrayInputStream(bytes), theRequestDetails);
218                                size = storedDetails.getBytes();
219                                blobId = storedDetails.getBinaryContentId();
220                                Validate.notBlank(blobId, "BinaryStorageSvc returned a null blob ID"); // should not happen
221                                Validate.isTrue(size == theServletRequest.getContentLength(), "Unexpected stored size"); // Sanity check
222                        }
223                }
224
225                if (blobId == null) {
226                        size = bytes.length;
227                        target.setData(bytes);
228                } else {
229                        replaceDataWithExtension(target, blobId);
230                }
231
232                target.setContentType(requestContentType);
233                target.setSize(null);
234                if (size <= Integer.MAX_VALUE) {
235                        target.setSize((int) size);
236                }
237
238                DaoMethodOutcome outcome = dao.update(resource, theRequestDetails);
239                return outcome.getResource();
240        }
241
242        public void replaceDataWithExtension(IBinaryTarget theTarget, String theBlobId) {
243                theTarget
244                                .getTarget()
245                                .getExtension()
246                                .removeIf(t -> HapiExtensions.EXT_EXTERNALIZED_BINARY_ID.equals(t.getUrl()));
247                theTarget.setData(null);
248
249                IBaseExtension<?, ?> ext = theTarget.getTarget().addExtension();
250                ext.setUrl(HapiExtensions.EXT_EXTERNALIZED_BINARY_ID);
251                ext.setUserData(JpaConstants.EXTENSION_EXT_SYSTEMDEFINED, Boolean.TRUE);
252                IPrimitiveType<String> blobIdString =
253                                (IPrimitiveType<String>) myCtx.getElementDefinition("string").newInstance();
254                blobIdString.setValueAsString(theBlobId);
255                ext.setValue(blobIdString);
256        }
257
258        @Nonnull
259        private IBinaryTarget findAttachmentForRequest(
260                        IBaseResource theResource, String thePath, ServletRequestDetails theRequestDetails) {
261                Optional<IBase> type = myCtx.newFluentPath().evaluateFirst(theResource, thePath, IBase.class);
262                String resType = this.myCtx.getResourceType(theResource);
263                if (type.isEmpty()) {
264                        String msg = this.myCtx
265                                        .getLocalizer()
266                                        .getMessageSanitized(BinaryAccessProvider.class, "unknownPath", resType, thePath);
267                        throw new InvalidRequestException(Msg.code(1335) + msg);
268                }
269                IBase element = type.get();
270
271                Optional<IBinaryTarget> binaryTarget = toBinaryTarget(element);
272
273                if (binaryTarget.isEmpty()) {
274                        BaseRuntimeElementDefinition<?> def2 = myCtx.getElementDefinition(element.getClass());
275                        String msg = this.myCtx
276                                        .getLocalizer()
277                                        .getMessageSanitized(BinaryAccessProvider.class, "unknownType", resType, thePath, def2.getName());
278                        throw new InvalidRequestException(Msg.code(1336) + msg);
279                } else {
280                        return binaryTarget.get();
281                }
282        }
283
284        public Optional<IBinaryTarget> toBinaryTarget(IBase theElement) {
285                IBinaryTarget binaryTarget = null;
286
287                // Path is attachment
288                BaseRuntimeElementDefinition<?> def = myCtx.getElementDefinition(theElement.getClass());
289                if (def.getName().equals("Attachment")) {
290                        ICompositeType attachment = (ICompositeType) theElement;
291                        binaryTarget = new IBinaryTarget() {
292                                @Override
293                                public void setSize(Integer theSize) {
294                                        AttachmentUtil.setSize(BinaryAccessProvider.this.myCtx, attachment, theSize);
295                                }
296
297                                @Override
298                                public String getContentType() {
299                                        return AttachmentUtil.getOrCreateContentType(BinaryAccessProvider.this.myCtx, attachment)
300                                                        .getValueAsString();
301                                }
302
303                                @Override
304                                public byte[] getData() {
305                                        IPrimitiveType<byte[]> dataDt = AttachmentUtil.getOrCreateData(myCtx, attachment);
306                                        return dataDt.getValue();
307                                }
308
309                                @Override
310                                public IBaseHasExtensions getTarget() {
311                                        return (IBaseHasExtensions) AttachmentUtil.getOrCreateData(myCtx, attachment);
312                                }
313
314                                @Override
315                                public void setContentType(String theContentType) {
316                                        AttachmentUtil.setContentType(BinaryAccessProvider.this.myCtx, attachment, theContentType);
317                                }
318
319                                @Override
320                                public void setData(byte[] theBytes) {
321                                        AttachmentUtil.setData(myCtx, attachment, theBytes);
322                                }
323                        };
324                }
325
326                // Path is Binary
327                if (def.getName().equals("Binary")) {
328                        IBaseBinary binary = (IBaseBinary) theElement;
329                        binaryTarget = new IBinaryTarget() {
330                                @Override
331                                public void setSize(Integer theSize) {
332                                        // ignore
333                                }
334
335                                @Override
336                                public String getContentType() {
337                                        return binary.getContentType();
338                                }
339
340                                @Override
341                                public byte[] getData() {
342                                        return binary.getContent();
343                                }
344
345                                @Override
346                                public IBaseHasExtensions getTarget() {
347                                        return (IBaseHasExtensions) BinaryUtil.getOrCreateData(BinaryAccessProvider.this.myCtx, binary);
348                                }
349
350                                @Override
351                                public void setContentType(String theContentType) {
352                                        binary.setContentType(theContentType);
353                                }
354
355                                @Override
356                                public void setData(byte[] theBytes) {
357                                        binary.setContent(theBytes);
358                                }
359                        };
360                }
361
362                return Optional.ofNullable(binaryTarget);
363        }
364
365        private String validateResourceTypeAndPath(
366                        @IdParam IIdType theResourceId,
367                        @OperationParam(name = "path", min = 1, max = 1) IPrimitiveType<String> thePath) {
368                if (isBlank(theResourceId.getResourceType())) {
369                        throw new InvalidRequestException(Msg.code(1337) + "No resource type specified");
370                }
371                if (isBlank(theResourceId.getIdPart())) {
372                        throw new InvalidRequestException(Msg.code(1338) + "No ID specified");
373                }
374                if (thePath == null || isBlank(thePath.getValue())) {
375                        if ("Binary".equals(theResourceId.getResourceType())) {
376                                return "Binary";
377                        }
378                        throw new InvalidRequestException(Msg.code(1339) + "No path specified");
379                }
380
381                return thePath.getValue();
382        }
383
384        @Nonnull
385        private IFhirResourceDao getDaoForRequest(@IdParam IIdType theResourceId) {
386                String resourceType = theResourceId.getResourceType();
387                IFhirResourceDao dao = myDaoRegistry.getResourceDao(resourceType);
388                if (dao == null) {
389                        throw new InvalidRequestException(
390                                        Msg.code(1340) + "Unknown/unsupported resource type: " + sanitizeUrlPart(resourceType));
391                }
392                return dao;
393        }
394
395        @VisibleForTesting
396        public void setDaoRegistryForUnitTest(DaoRegistry theDaoRegistry) {
397                myDaoRegistry = theDaoRegistry;
398        }
399
400        @VisibleForTesting
401        public void setBinaryStorageSvcForUnitTest(IBinaryStorageSvc theBinaryStorageSvc) {
402                myBinaryStorageSvc = theBinaryStorageSvc;
403        }
404
405        @VisibleForTesting
406        public void setFhirContextForUnitTest(FhirContext theCtx) {
407                myCtx = theCtx;
408        }
409
410        @VisibleForTesting
411        public void setTargetAttachmentIdForUnitTest(Boolean theTargetAttachmentIdForTest) {
412                addTargetAttachmentIdForTest = theTargetAttachmentIdForTest;
413        }
414}