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}