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