
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}