
001/*- 002 * #%L 003 * HAPI FHIR JPA Server 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.packages; 021 022import ca.uhn.fhir.context.BaseRuntimeChildDefinition; 023import ca.uhn.fhir.context.FhirContext; 024import ca.uhn.fhir.context.FhirVersionEnum; 025import ca.uhn.fhir.i18n.Msg; 026import ca.uhn.fhir.jpa.api.dao.DaoRegistry; 027import ca.uhn.fhir.jpa.api.dao.IFhirResourceDao; 028import ca.uhn.fhir.jpa.api.model.ExpungeOptions; 029import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; 030import ca.uhn.fhir.jpa.binary.svc.NullBinaryStorageSvcImpl; 031import ca.uhn.fhir.jpa.dao.data.INpmPackageDao; 032import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionDao; 033import ca.uhn.fhir.jpa.dao.data.INpmPackageVersionResourceDao; 034import ca.uhn.fhir.jpa.dao.tx.HapiTransactionService; 035import ca.uhn.fhir.jpa.model.config.PartitionSettings; 036import ca.uhn.fhir.jpa.model.dao.JpaPid; 037import ca.uhn.fhir.jpa.model.entity.NpmPackageEntity; 038import ca.uhn.fhir.jpa.model.entity.NpmPackageVersionEntity; 039import ca.uhn.fhir.jpa.model.entity.NpmPackageVersionResourceEntity; 040import ca.uhn.fhir.jpa.model.entity.ResourceTable; 041import ca.uhn.fhir.jpa.model.util.JpaConstants; 042import ca.uhn.fhir.jpa.packages.loader.NpmPackageData; 043import ca.uhn.fhir.jpa.packages.loader.PackageLoaderSvc; 044import ca.uhn.fhir.rest.api.Constants; 045import ca.uhn.fhir.rest.api.EncodingEnum; 046import ca.uhn.fhir.rest.api.server.SystemRequestDetails; 047import ca.uhn.fhir.rest.server.exceptions.InternalErrorException; 048import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException; 049import ca.uhn.fhir.rest.server.exceptions.ResourceNotFoundException; 050import ca.uhn.fhir.util.BinaryUtil; 051import ca.uhn.fhir.util.ResourceUtil; 052import ca.uhn.fhir.util.StringUtil; 053import jakarta.annotation.Nonnull; 054import jakarta.annotation.Nullable; 055import jakarta.persistence.EntityManager; 056import jakarta.persistence.PersistenceContext; 057import jakarta.persistence.TypedQuery; 058import jakarta.persistence.criteria.CriteriaBuilder; 059import jakarta.persistence.criteria.CriteriaQuery; 060import jakarta.persistence.criteria.Join; 061import jakarta.persistence.criteria.JoinType; 062import jakarta.persistence.criteria.Predicate; 063import jakarta.persistence.criteria.Root; 064import org.apache.commons.collections4.comparators.ReverseComparator; 065import org.apache.commons.lang3.Validate; 066import org.hl7.fhir.exceptions.FHIRException; 067import org.hl7.fhir.instance.model.api.IBase; 068import org.hl7.fhir.instance.model.api.IBaseBackboneElement; 069import org.hl7.fhir.instance.model.api.IBaseBinary; 070import org.hl7.fhir.instance.model.api.IBaseResource; 071import org.hl7.fhir.instance.model.api.IIdType; 072import org.hl7.fhir.instance.model.api.IPrimitiveType; 073import org.hl7.fhir.utilities.npm.BasePackageCacheManager; 074import org.hl7.fhir.utilities.npm.NpmPackage; 075import org.hl7.fhir.utilities.npm.PackageServer; 076import org.slf4j.Logger; 077import org.slf4j.LoggerFactory; 078import org.springframework.beans.factory.annotation.Autowired; 079import org.springframework.data.domain.PageRequest; 080import org.springframework.data.domain.Slice; 081import org.springframework.transaction.PlatformTransactionManager; 082import org.springframework.transaction.annotation.Propagation; 083import org.springframework.transaction.annotation.Transactional; 084import org.springframework.transaction.support.TransactionTemplate; 085 086import java.io.ByteArrayInputStream; 087import java.io.IOException; 088import java.io.InputStream; 089import java.nio.charset.StandardCharsets; 090import java.util.ArrayList; 091import java.util.Collection; 092import java.util.Collections; 093import java.util.Date; 094import java.util.HashMap; 095import java.util.List; 096import java.util.Map; 097import java.util.Optional; 098import java.util.regex.Pattern; 099import java.util.stream.Collectors; 100 101import static ca.uhn.fhir.jpa.util.QueryParameterUtils.toPredicateArray; 102import static ca.uhn.fhir.util.StringUtil.toUtf8String; 103import static org.apache.commons.lang3.StringUtils.defaultString; 104import static org.apache.commons.lang3.StringUtils.isNotBlank; 105 106public class JpaPackageCache extends BasePackageCacheManager implements IHapiPackageCacheManager { 107 108 private static final Logger ourLog = LoggerFactory.getLogger(JpaPackageCache.class); 109 private static final Pattern PATTERN_FHIR_VERSION = Pattern.compile("^[0-9]+\\.[0-9]+$"); 110 111 private final Map<FhirVersionEnum, FhirContext> myVersionToContext = Collections.synchronizedMap(new HashMap<>()); 112 113 @PersistenceContext 114 protected EntityManager myEntityManager; 115 116 @Autowired 117 private INpmPackageDao myPackageDao; 118 119 @Autowired 120 private INpmPackageVersionDao myPackageVersionDao; 121 122 @Autowired 123 private INpmPackageVersionResourceDao myPackageVersionResourceDao; 124 125 @Autowired 126 private DaoRegistry myDaoRegistry; 127 128 @Autowired 129 private FhirContext myCtx; 130 131 @Autowired 132 private PlatformTransactionManager myTxManager; 133 134 @Autowired 135 private PartitionSettings myPartitionSettings; 136 137 @Autowired 138 private PackageLoaderSvc myPackageLoaderSvc; 139 140 @Autowired(required = false) // It is possible that some implementers will not create such a bean. 141 private IBinaryStorageSvc myBinaryStorageSvc; 142 143 @Autowired 144 private HapiTransactionService myTransactionService; 145 146 @Override 147 public void addPackageServer(@Nonnull PackageServer thePackageServer) { 148 assert myPackageLoaderSvc != null; 149 myPackageLoaderSvc.addPackageServer(thePackageServer); 150 } 151 152 @Override 153 public String getPackageId(String theS) throws IOException { 154 return myPackageLoaderSvc.getPackageId(theS); 155 } 156 157 @Override 158 public String getPackageUrl(String theS) throws IOException { 159 return myPackageLoaderSvc.getPackageUrl(theS); 160 } 161 162 @Override 163 public List<PackageServer> getPackageServers() { 164 return myPackageLoaderSvc.getPackageServers(); 165 } 166 167 @Override 168 protected BasePackageCacheManager.InputStreamWithSrc loadFromPackageServer(String id, String version) { 169 throw new UnsupportedOperationException(Msg.code(2220) + "Use PackageLoaderSvc for loading packages."); 170 } 171 172 @Override 173 @Transactional 174 public NpmPackage loadPackageFromCacheOnly(String theId, @Nullable String theVersion) { 175 return loadPackageFromCacheOnlyInner(theId, theVersion); 176 } 177 178 @Nullable 179 private NpmPackage loadPackageFromCacheOnlyInner(String theId, @Nullable String theVersion) { 180 Optional<NpmPackageVersionEntity> packageVersion = loadPackageVersionEntity(theId, theVersion); 181 if (theVersion != null && packageVersion.isEmpty() && theVersion.endsWith(".x")) { 182 String lookupVersion = theVersion; 183 do { 184 lookupVersion = lookupVersion.substring(0, lookupVersion.length() - 2); 185 } while (lookupVersion.endsWith(".x")); 186 187 List<String> candidateVersionIds = 188 myPackageVersionDao.findVersionIdsByPackageIdAndLikeVersion(theId, lookupVersion + ".%"); 189 if (!candidateVersionIds.isEmpty()) { 190 candidateVersionIds.sort(PackageVersionComparator.INSTANCE); 191 packageVersion = 192 loadPackageVersionEntity(theId, candidateVersionIds.get(candidateVersionIds.size() - 1)); 193 } 194 } 195 196 return packageVersion.map(this::loadPackage).orElse(null); 197 } 198 199 private Optional<NpmPackageVersionEntity> loadPackageVersionEntity(String theId, @Nullable String theVersion) { 200 Validate.notBlank(theId, "theId must be populated"); 201 202 Optional<NpmPackageVersionEntity> packageVersion = Optional.empty(); 203 if (isNotBlank(theVersion) && !"latest".equals(theVersion)) { 204 packageVersion = myPackageVersionDao.findByPackageIdAndVersion(theId, theVersion); 205 } else { 206 Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(theId); 207 if (pkg.isPresent()) { 208 packageVersion = myPackageVersionDao.findByPackageIdAndVersion( 209 theId, pkg.get().getCurrentVersionId()); 210 } 211 } 212 return packageVersion; 213 } 214 215 private NpmPackage loadPackage(NpmPackageVersionEntity thePackageVersion) { 216 PackageContents content = loadPackageContents(thePackageVersion); 217 ByteArrayInputStream inputStream = new ByteArrayInputStream(content.getBytes()); 218 try { 219 return NpmPackage.fromPackage(inputStream); 220 } catch (IOException e) { 221 throw new InternalErrorException(Msg.code(1294) + e); 222 } 223 } 224 225 private IHapiPackageCacheManager.PackageContents loadPackageContents(NpmPackageVersionEntity thePackageVersion) { 226 IFhirResourceDao<? extends IBaseBinary> binaryDao = getBinaryDao(); 227 IBaseBinary binary = 228 binaryDao.readByPid(thePackageVersion.getPackageBinary().getId()); 229 try { 230 byte[] content = fetchBlobFromBinary(binary); 231 return new PackageContents() 232 .setBytes(content) 233 .setPackageId(thePackageVersion.getPackageId()) 234 .setVersion(thePackageVersion.getVersionId()) 235 .setLastModified(thePackageVersion.getUpdatedTime()); 236 } catch (IOException e) { 237 throw new InternalErrorException( 238 Msg.code(1295) + "Failed to load package. There was a problem reading binaries", e); 239 } 240 } 241 242 /** 243 * Helper method which will attempt to use the IBinaryStorageSvc to resolve the binary blob if available. If 244 * the bean is unavailable, fallback to assuming we are using an embedded base64 in the data element. 245 * @param theBinary the Binary who's `data` blob you want to retrieve 246 * @return a byte array containing the blob. 247 */ 248 private byte[] fetchBlobFromBinary(IBaseBinary theBinary) throws IOException { 249 if (myBinaryStorageSvc != null && !(myBinaryStorageSvc instanceof NullBinaryStorageSvcImpl)) { 250 return myBinaryStorageSvc.fetchDataByteArrayFromBinary(theBinary); 251 } else { 252 byte[] value = BinaryUtil.getOrCreateData(myCtx, theBinary).getValue(); 253 if (value == null) { 254 throw new InternalErrorException( 255 Msg.code(1296) + "Failed to fetch blob from Binary/" + theBinary.getIdElement()); 256 } 257 return value; 258 } 259 } 260 261 @SuppressWarnings("unchecked") 262 private IFhirResourceDao<IBaseBinary> getBinaryDao() { 263 return myDaoRegistry.getResourceDao("Binary"); 264 } 265 266 private NpmPackage addPackageToCacheInternal(NpmPackageData thePackageData) { 267 NpmPackage npmPackage = thePackageData.getPackage(); 268 String packageId = thePackageData.getPackageId(); 269 String initialPackageVersionId = thePackageData.getPackageVersionId(); 270 byte[] bytes = thePackageData.getBytes(); 271 272 if (!npmPackage.id().equalsIgnoreCase(packageId)) { 273 throw new InvalidRequestException( 274 Msg.code(1297) + "Package ID " + npmPackage.id() + " doesn't match expected: " + packageId); 275 } 276 if (!PackageVersionComparator.isEquivalent(initialPackageVersionId, npmPackage.version())) { 277 throw new InvalidRequestException(Msg.code(1298) + "Package ID " + npmPackage.version() 278 + " doesn't match expected: " + initialPackageVersionId); 279 } 280 281 String packageVersionId = npmPackage.version(); 282 FhirVersionEnum fhirVersion = FhirVersionEnum.forVersionString(npmPackage.fhirVersion()); 283 if (fhirVersion == null) { 284 throw new InvalidRequestException(Msg.code(1299) + "Unknown FHIR version: " + npmPackage.fhirVersion()); 285 } 286 FhirContext packageContext = getFhirContext(fhirVersion); 287 288 IBaseBinary binary = createPackageBinary(bytes); 289 290 return newTxTemplate().execute(tx -> { 291 ResourceTable persistedPackage = createResourceBinary(binary); 292 NpmPackageEntity pkg = myPackageDao.findByPackageId(packageId).orElseGet(() -> createPackage(npmPackage)); 293 NpmPackageVersionEntity packageVersion = myPackageVersionDao 294 .findByPackageIdAndVersion(packageId, packageVersionId) 295 .orElse(null); 296 if (packageVersion != null) { 297 NpmPackage existingPackage = 298 loadPackageFromCacheOnlyInner(packageVersion.getPackageId(), packageVersion.getVersionId()); 299 if (existingPackage == null) { 300 return null; 301 } 302 String msg = "Package version already exists in local storage, no action taken: " + packageId + "#" 303 + packageVersionId; 304 getProcessingMessages(existingPackage).add(msg); 305 ourLog.info(msg); 306 return existingPackage; 307 } 308 309 boolean currentVersion = 310 updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion(packageId, packageVersionId); 311 312 String packageDesc = truncateStorageString(npmPackage.description()); 313 String packageAuthor = truncateStorageString(npmPackage.getNpm().asString("author")); 314 315 if (currentVersion) { 316 getProcessingMessages(npmPackage) 317 .add("Marking package " + packageId + "#" + initialPackageVersionId + " as current version"); 318 pkg.setCurrentVersionId(packageVersionId); 319 pkg.setDescription(packageDesc); 320 myPackageDao.save(pkg); 321 } else { 322 getProcessingMessages(npmPackage) 323 .add("Package " + packageId + "#" + initialPackageVersionId + " is not the newest version"); 324 } 325 326 packageVersion = new NpmPackageVersionEntity(); 327 packageVersion.setPackageId(packageId); 328 packageVersion.setVersionId(packageVersionId); 329 packageVersion.setPackage(pkg); 330 packageVersion.setPackageBinary(persistedPackage); 331 packageVersion.setSavedTime(new Date()); 332 packageVersion.setAuthor(packageAuthor); 333 packageVersion.setDescription(packageDesc); 334 packageVersion.setFhirVersionId(npmPackage.fhirVersion()); 335 packageVersion.setFhirVersion(fhirVersion); 336 packageVersion.setCurrentVersion(currentVersion); 337 packageVersion.setPackageSizeBytes(bytes.length); 338 packageVersion = myPackageVersionDao.save(packageVersion); 339 340 String dirName = "package"; 341 NpmPackage.NpmPackageFolder packageFolder = npmPackage.getFolders().get(dirName); 342 Map<String, List<String>> packageFolderTypes; 343 try { 344 packageFolderTypes = packageFolder.getTypes(); 345 } catch (IOException e) { 346 throw new InternalErrorException(Msg.code(2371) + e); 347 } 348 for (Map.Entry<String, List<String>> nextTypeToFiles : packageFolderTypes.entrySet()) { 349 String nextType = nextTypeToFiles.getKey(); 350 for (String nextFile : nextTypeToFiles.getValue()) { 351 352 byte[] contents; 353 String contentsString; 354 try { 355 contents = packageFolder.fetchFile(nextFile); 356 contentsString = toUtf8String(contents); 357 } catch (IOException e) { 358 throw new InternalErrorException(Msg.code(1300) + e); 359 } 360 361 IBaseResource resource; 362 if (nextFile.toLowerCase().endsWith(".xml")) { 363 resource = packageContext.newXmlParser().parseResource(contentsString); 364 } else if (nextFile.toLowerCase().endsWith(".json")) { 365 resource = packageContext.newJsonParser().parseResource(contentsString); 366 } else { 367 getProcessingMessages(npmPackage).add("Not indexing file: " + nextFile); 368 continue; 369 } 370 371 /* 372 * Re-encode the resource as JSON with the narrative removed in order to reduce the footprint. 373 * This is useful since we'll be loading these resources back and hopefully keeping lots of 374 * them in memory in order to speed up validation activities. 375 */ 376 String contentType = Constants.CT_FHIR_JSON_NEW; 377 ResourceUtil.removeNarrative(packageContext, resource); 378 byte[] minimizedContents = packageContext 379 .newJsonParser() 380 .encodeResourceToString(resource) 381 .getBytes(StandardCharsets.UTF_8); 382 383 IBaseBinary resourceBinary = createPackageResourceBinary(minimizedContents, contentType); 384 ResourceTable persistedResource = createResourceBinary(resourceBinary); 385 386 NpmPackageVersionResourceEntity resourceEntity = new NpmPackageVersionResourceEntity(); 387 resourceEntity.setPackageVersion(packageVersion); 388 resourceEntity.setResourceBinary(persistedResource); 389 resourceEntity.setDirectory(dirName); 390 resourceEntity.setFhirVersionId(npmPackage.fhirVersion()); 391 resourceEntity.setFhirVersion(fhirVersion); 392 resourceEntity.setFilename(nextFile); 393 resourceEntity.setResourceType(nextType); 394 resourceEntity.setResSizeBytes(contents.length); 395 BaseRuntimeChildDefinition urlChild = 396 packageContext.getResourceDefinition(nextType).getChildByName("url"); 397 BaseRuntimeChildDefinition versionChild = 398 packageContext.getResourceDefinition(nextType).getChildByName("version"); 399 String url = null; 400 String version = null; 401 if (urlChild != null) { 402 url = urlChild.getAccessor() 403 .getFirstValueOrNull(resource) 404 .map(t -> ((IPrimitiveType<?>) t).getValueAsString()) 405 .orElse(null); 406 resourceEntity.setCanonicalUrl(url); 407 408 Optional<IBase> resourceVersion = 409 versionChild.getAccessor().getFirstValueOrNull(resource); 410 if (resourceVersion.isPresent() && resourceVersion.get() instanceof IPrimitiveType) { 411 version = ((IPrimitiveType<?>) resourceVersion.get()).getValueAsString(); 412 } else if (resourceVersion.isPresent() 413 && resourceVersion.get() instanceof IBaseBackboneElement) { 414 version = String.valueOf(myCtx.newFhirPath() 415 .evaluateFirst(resourceVersion.get(), "value", IPrimitiveType.class) 416 .orElse(null)); 417 } 418 resourceEntity.setCanonicalVersion(version); 419 } 420 myPackageVersionResourceDao.save(resourceEntity); 421 422 String resType = packageContext.getResourceType(resource); 423 String msg = "Indexing " + resType + " Resource[" + dirName + '/' + nextFile + "] with URL: " 424 + defaultString(url) + "|" + defaultString(version); 425 getProcessingMessages(npmPackage).add(msg); 426 ourLog.info("{}: Package[{}#{}] ", msg, packageId, packageVersionId); 427 } 428 } 429 430 getProcessingMessages(npmPackage) 431 .add("Successfully added package " + npmPackage.id() + "#" + npmPackage.version() + " to registry"); 432 433 return npmPackage; 434 }); 435 } 436 437 @Override 438 public NpmPackage addPackageToCache( 439 String thePackageId, String thePackageVersionId, InputStream thePackageTgzInputStream, String theSourceDesc) 440 throws IOException { 441 NpmPackageData npmData = myPackageLoaderSvc.createNpmPackageDataFromData( 442 thePackageId, thePackageVersionId, theSourceDesc, thePackageTgzInputStream); 443 444 return addPackageToCacheInternal(npmData); 445 } 446 447 private ResourceTable createResourceBinary(IBaseBinary theResourceBinary) { 448 449 if (myPartitionSettings.isPartitioningEnabled()) { 450 SystemRequestDetails requestDetails = new SystemRequestDetails(); 451 if (myPartitionSettings.isUnnamedPartitionMode() && myPartitionSettings.getDefaultPartitionId() != null) { 452 requestDetails.setRequestPartitionId(myPartitionSettings.getDefaultRequestPartitionId()); 453 } else { 454 requestDetails.setTenantId(JpaConstants.DEFAULT_PARTITION_NAME); 455 } 456 return (ResourceTable) 457 getBinaryDao().create(theResourceBinary, requestDetails).getEntity(); 458 } else { 459 return (ResourceTable) getBinaryDao() 460 .create(theResourceBinary, new SystemRequestDetails()) 461 .getEntity(); 462 } 463 } 464 465 private boolean updateCurrentVersionFlagForAllPackagesBasedOnNewIncomingVersion( 466 String thePackageId, String thePackageVersion) { 467 Collection<NpmPackageVersionEntity> existingVersions = myPackageVersionDao.findByPackageId(thePackageId); 468 boolean retVal = true; 469 470 for (NpmPackageVersionEntity next : existingVersions) { 471 int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), thePackageVersion); 472 assert cmp != 0; 473 if (cmp < 0) { 474 if (next.isCurrentVersion()) { 475 next.setCurrentVersion(false); 476 myPackageVersionDao.save(next); 477 } 478 } else { 479 retVal = false; 480 } 481 } 482 483 return retVal; 484 } 485 486 @Nonnull 487 public FhirContext getFhirContext(FhirVersionEnum theFhirVersion) { 488 return myVersionToContext.computeIfAbsent(theFhirVersion, FhirContext::new); 489 } 490 491 private IBaseBinary createPackageBinary(byte[] theBytes) { 492 IBaseBinary binary = BinaryUtil.newBinary(myCtx); 493 BinaryUtil.setData(myCtx, binary, theBytes, Constants.CT_APPLICATION_GZIP); 494 return binary; 495 } 496 497 private IBaseBinary createPackageResourceBinary(byte[] theBytes, String theContentType) { 498 IBaseBinary binary = BinaryUtil.newBinary(myCtx); 499 BinaryUtil.setData(myCtx, binary, theBytes, theContentType); 500 return binary; 501 } 502 503 private NpmPackageEntity createPackage(NpmPackage theNpmPackage) { 504 NpmPackageEntity entity = new NpmPackageEntity(); 505 entity.setPackageId(theNpmPackage.id()); 506 entity.setCurrentVersionId(theNpmPackage.version()); 507 return myPackageDao.save(entity); 508 } 509 510 @Override 511 @Transactional 512 public NpmPackage loadPackage(String thePackageId, String thePackageVersion) throws FHIRException, IOException { 513 return loadPackageInner(thePackageId, thePackageVersion); 514 } 515 516 @Nonnull 517 private NpmPackage loadPackageInner(String thePackageId, String thePackageVersion) throws IOException { 518 // check package cache 519 NpmPackage cachedPackage = loadPackageFromCacheOnlyInner(thePackageId, thePackageVersion); 520 if (cachedPackage != null) { 521 return cachedPackage; 522 } 523 524 // otherwise we have to load it from packageloader 525 NpmPackageData pkgData = myPackageLoaderSvc.fetchPackageFromPackageSpec(thePackageId, thePackageVersion); 526 527 try { 528 // and add it to the cache 529 NpmPackage retVal = addPackageToCacheInternal(pkgData); 530 getProcessingMessages(retVal) 531 .add( 532 0, 533 "Package fetched from server at: " 534 + pkgData.getPackage().url()); 535 return retVal; 536 } finally { 537 pkgData.getInputStream().close(); 538 } 539 } 540 541 @Override 542 @Transactional 543 public NpmPackage loadPackage(String theS) throws FHIRException, IOException { 544 return loadPackageInner(theS, null); 545 } 546 547 private TransactionTemplate newTxTemplate() { 548 return new TransactionTemplate(myTxManager); 549 } 550 551 @Override 552 @Transactional(propagation = Propagation.NEVER) 553 public NpmPackage installPackage(PackageInstallationSpec theInstallationSpec) throws IOException { 554 Validate.notBlank(theInstallationSpec.getName(), "thePackageId must not be blank"); 555 Validate.notBlank(theInstallationSpec.getVersion(), "thePackageVersion must not be blank"); 556 557 String sourceDescription = "Embedded content"; 558 if (isNotBlank(theInstallationSpec.getPackageUrl())) { 559 byte[] contents = myPackageLoaderSvc.loadPackageUrlContents(theInstallationSpec.getPackageUrl()); 560 theInstallationSpec.setPackageContents(contents); 561 sourceDescription = theInstallationSpec.getPackageUrl(); 562 } 563 564 if (theInstallationSpec.getPackageContents() != null) { 565 return addPackageToCache( 566 theInstallationSpec.getName(), 567 theInstallationSpec.getVersion(), 568 new ByteArrayInputStream(theInstallationSpec.getPackageContents()), 569 sourceDescription); 570 } 571 572 return newTxTemplate().execute(tx -> { 573 try { 574 return loadPackageInner(theInstallationSpec.getName(), theInstallationSpec.getVersion()); 575 } catch (IOException e) { 576 throw new InternalErrorException(Msg.code(1302) + e, e); 577 } 578 }); 579 } 580 581 @Override 582 @Transactional(readOnly = true) 583 public IBaseResource loadPackageAssetByUrl(FhirVersionEnum theFhirVersion, String theCanonicalUrl) { 584 585 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = 586 loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, 2, null, null); 587 588 if (npmPackageVersionResourceEntities.isEmpty()) { 589 return null; 590 } else { 591 if (npmPackageVersionResourceEntities.size() > 1) { 592 ourLog.warn( 593 "Found multiple package versions for FHIR version: {} and canonical URL: {}", 594 theFhirVersion, 595 theCanonicalUrl); 596 } 597 final NpmPackageVersionResourceEntity contents = npmPackageVersionResourceEntities.get(0); 598 return loadPackageEntity(contents); 599 } 600 } 601 602 @Override 603 @Transactional(readOnly = true) 604 public IBaseResource findPackageAsset(FindPackageAssetRequest theRequest) { 605 606 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = loadPackageInfoByCanonicalUrl( 607 theRequest.getFhirVersion(), 608 theRequest.getCanonicalUrl(), 609 2, // We set it to 2 so that if we get more than one we can warn 610 theRequest.getPackageId(), 611 theRequest.getVersion()); 612 613 if (npmPackageVersionResourceEntities.isEmpty()) { 614 throw new ResourceNotFoundException( 615 "%s Could not find asset for FHIR version: %s, canonical URL: %s, package ID: %s and package version: %s" 616 .formatted( 617 Msg.code(2644), 618 theRequest.getFhirVersion(), 619 theRequest.getCanonicalUrl(), 620 theRequest.getPackageId(), 621 Optional.ofNullable(theRequest.getVersion()).orElse("[none]"))); 622 } else { 623 if (npmPackageVersionResourceEntities.size() > 1) { 624 ourLog.warn( 625 "Found multiple package versions for FHIR version: {} and canonical URL: {}", 626 theRequest.getFhirVersion(), 627 theRequest.getCanonicalUrl()); 628 } 629 final NpmPackageVersionResourceEntity contents = npmPackageVersionResourceEntities.get(0); 630 return loadPackageEntity(contents); 631 } 632 } 633 634 @Override 635 @Transactional(readOnly = true) 636 public List<NpmPackageAssetInfoJson> findPackageAssetInfoByUrl( 637 FhirVersionEnum theFhirVersion, String theCanonicalUrl) { 638 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = 639 loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, 20, null, null); 640 641 return npmPackageVersionResourceEntities.stream() 642 .map(entity -> new NpmPackageAssetInfoJson( 643 entity.getResourceBinary().asTypedFhirResourceId(), 644 entity.getCanonicalUrl(), 645 entity.getFhirVersion(), 646 entity.getPackageId(), 647 entity.getPackageVersion())) 648 .toList(); 649 } 650 651 private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrl( 652 FhirVersionEnum theFhirVersion, 653 String theCanonicalUrl, 654 int thePageSize, 655 @Nullable String thePackageId, 656 @Nullable String theVersionId) { 657 String canonicalUrl = theCanonicalUrl; 658 659 int versionSeparator = canonicalUrl.lastIndexOf('|'); 660 Slice<NpmPackageVersionResourceEntity> slice; 661 662 final PageRequest pageRequest = PageRequest.of(0, thePageSize); 663 664 if (versionSeparator != -1) { 665 String canonicalVersion = canonicalUrl.substring(versionSeparator + 1); 666 canonicalUrl = canonicalUrl.substring(0, versionSeparator); 667 668 if (thePackageId != null) { 669 if (theVersionId != null) { 670 slice = 671 myPackageVersionResourceDao 672 .findCurrentVersionByCanonicalUrlAndVersionAndPackageIdAndVersion( 673 pageRequest, 674 theFhirVersion, 675 canonicalUrl, 676 canonicalVersion, 677 thePackageId, 678 theVersionId); 679 } else { 680 slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersionAndPackageId( 681 pageRequest, theFhirVersion, canonicalUrl, canonicalVersion, thePackageId); 682 } 683 } else { 684 slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndVersion( 685 pageRequest, theFhirVersion, canonicalUrl, canonicalVersion); 686 } 687 688 } else { 689 if (thePackageId != null) { 690 if (theVersionId != null) { 691 slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndPackageIdAndVersion( 692 pageRequest, theFhirVersion, canonicalUrl, thePackageId, theVersionId); 693 } else { 694 slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrlAndPackageId( 695 pageRequest, theFhirVersion, canonicalUrl, thePackageId); 696 } 697 } else { 698 slice = myPackageVersionResourceDao.findCurrentVersionByCanonicalUrl( 699 pageRequest, theFhirVersion, canonicalUrl); 700 } 701 } 702 703 if (slice.isEmpty()) { 704 return List.of(); 705 } else { 706 return slice.getContent(); 707 } 708 } 709 710 private IBaseResource loadPackageEntity(NpmPackageVersionResourceEntity contents) { 711 return loadPackageAssetByVersionAndId( 712 contents.getFhirVersion(), contents.getResourceBinary().getResourceId()); 713 } 714 715 private IBaseResource loadPackageAssetByVersionAndId(FhirVersionEnum theFhirVersion, JpaPid theBinaryPid) { 716 try { 717 IBaseBinary binary = getBinaryDao().readByPid(theBinaryPid); 718 byte[] resourceContentsBytes = fetchBlobFromBinary(binary); 719 String resourceContents = new String(resourceContentsBytes, StandardCharsets.UTF_8); 720 FhirContext packageContext = getFhirContext(theFhirVersion); 721 return EncodingEnum.detectEncoding(resourceContents) 722 .newParser(packageContext) 723 .parseResource(resourceContents); 724 } catch (Exception exception) { 725 throw new InvalidRequestException( 726 String.format( 727 "%sFailed to load package resource for FHIR version: %s and binary PID: %s", 728 Msg.code(1305), theFhirVersion, theBinaryPid), 729 exception); 730 } 731 } 732 733 @Override 734 @Transactional 735 public NpmPackageMetadataJson loadPackageMetadata(String thePackageId) { 736 NpmPackageMetadataJson retVal = new NpmPackageMetadataJson(); 737 738 Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(thePackageId); 739 if (pkg.isEmpty()) { 740 throw new ResourceNotFoundException(Msg.code(1306) + "Unknown package ID: " + thePackageId); 741 } 742 743 List<NpmPackageVersionEntity> packageVersions = 744 new ArrayList<>(myPackageVersionDao.findByPackageId(thePackageId)); 745 packageVersions.sort(new ReverseComparator<>( 746 (o1, o2) -> PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId()))); 747 748 for (NpmPackageVersionEntity next : packageVersions) { 749 if (next.isCurrentVersion()) { 750 retVal.setDistTags(new NpmPackageMetadataJson.DistTags().setLatest(next.getVersionId())); 751 } 752 753 NpmPackageMetadataJson.Version version = new NpmPackageMetadataJson.Version(); 754 version.setFhirVersion(next.getFhirVersionId()); 755 version.setAuthor(next.getAuthor()); 756 version.setDescription(next.getDescription()); 757 version.setName(next.getPackageId()); 758 version.setVersion(next.getVersionId()); 759 version.setBytes(next.getPackageSizeBytes()); 760 retVal.addVersion(version); 761 } 762 763 return retVal; 764 } 765 766 @Override 767 @Transactional 768 public PackageContents loadPackageContents(String thePackageId, @Nullable String theVersion) { 769 Optional<NpmPackageVersionEntity> entity = loadPackageVersionEntity(thePackageId, theVersion); 770 return entity.map(this::loadPackageContents).orElse(null); 771 } 772 773 @Override 774 @Transactional 775 public NpmPackageSearchResultJson search(PackageSearchSpec thePackageSearchSpec) { 776 NpmPackageSearchResultJson retVal = new NpmPackageSearchResultJson(); 777 778 CriteriaBuilder cb = myEntityManager.getCriteriaBuilder(); 779 780 // Query for total 781 queryForTool(thePackageSearchSpec, cb, retVal); 782 783 // Query for results 784 queryForResults(thePackageSearchSpec, cb, retVal); 785 786 return retVal; 787 } 788 789 private void queryForTool( 790 PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) { 791 CriteriaQuery<Long> countCriteriaQuery = cb.createQuery(Long.class); 792 Root<NpmPackageVersionEntity> countCriteriaRoot = countCriteriaQuery.from(NpmPackageVersionEntity.class); 793 countCriteriaQuery.multiselect(cb.countDistinct(countCriteriaRoot.get("myPackageId"))); 794 795 List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, countCriteriaRoot); 796 797 countCriteriaQuery.where(toPredicateArray(predicates)); 798 Long total = myEntityManager.createQuery(countCriteriaQuery).getSingleResult(); 799 retVal.setTotal(Math.toIntExact(total)); 800 } 801 802 private void queryForResults( 803 PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) { 804 CriteriaQuery<NpmPackageVersionEntity> criteriaQuery = cb.createQuery(NpmPackageVersionEntity.class); 805 Root<NpmPackageVersionEntity> root = criteriaQuery.from(NpmPackageVersionEntity.class); 806 807 List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, root); 808 809 criteriaQuery.where(toPredicateArray(predicates)); 810 criteriaQuery.orderBy(cb.asc(root.get("myPackageId"))); 811 TypedQuery<NpmPackageVersionEntity> query = myEntityManager.createQuery(criteriaQuery); 812 query.setFirstResult(thePackageSearchSpec.getStart()); 813 query.setMaxResults(thePackageSearchSpec.getSize()); 814 815 List<NpmPackageVersionEntity> resultList = query.getResultList(); 816 for (NpmPackageVersionEntity next : resultList) { 817 818 if (!retVal.hasPackageWithId(next.getPackageId())) { 819 retVal.addObject() 820 .getPackage() 821 .setName(next.getPackageId()) 822 .setAuthor(next.getAuthor()) 823 .setDescription(next.getDescription()) 824 .setVersion(next.getVersionId()) 825 .addFhirVersion(next.getFhirVersionId()) 826 .setBytes(next.getPackageSizeBytes()); 827 } else { 828 NpmPackageSearchResultJson.Package retPackage = retVal.getPackageWithId(next.getPackageId()); 829 retPackage.addFhirVersion(next.getFhirVersionId()); 830 831 int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), retPackage.getVersion()); 832 if (cmp > 0) { 833 retPackage.setVersion(next.getVersionId()); 834 } 835 } 836 } 837 } 838 839 @Override 840 public PackageDeleteOutcomeJson uninstallPackage(String thePackageId, String theVersion) { 841 SystemRequestDetails requestDetails = 842 new SystemRequestDetails().setRequestPartitionId(myPartitionSettings.getDefaultRequestPartitionId()); 843 return myTransactionService 844 .withRequest(requestDetails) 845 .execute(() -> doUninstallPackage(thePackageId, theVersion)); 846 } 847 848 private PackageDeleteOutcomeJson doUninstallPackage(String thePackageId, String theVersion) { 849 PackageDeleteOutcomeJson retVal = new PackageDeleteOutcomeJson(); 850 851 Optional<NpmPackageVersionEntity> packageVersion = 852 myPackageVersionDao.findByPackageIdAndVersion(thePackageId, theVersion); 853 if (packageVersion.isPresent()) { 854 855 String msg = "Deleting package " + thePackageId + "#" + theVersion; 856 ourLog.info(msg); 857 retVal.getMessage().add(msg); 858 859 for (NpmPackageVersionResourceEntity next : packageVersion.get().getResources()) { 860 msg = "Deleting package +" + thePackageId + "#" + theVersion + "resource: " + next.getCanonicalUrl(); 861 ourLog.info(msg); 862 retVal.getMessage().add(msg); 863 864 myPackageVersionResourceDao.delete(next); 865 866 ExpungeOptions options = new ExpungeOptions(); 867 options.setExpungeDeletedResources(true).setExpungeOldVersions(true); 868 deleteAndExpungeResourceBinary( 869 next.getResourceBinary().getIdDt().toVersionless(), options); 870 } 871 872 myPackageVersionDao.delete(packageVersion.get()); 873 874 ExpungeOptions options = new ExpungeOptions(); 875 options.setExpungeDeletedResources(true).setExpungeOldVersions(true); 876 deleteAndExpungeResourceBinary( 877 packageVersion.get().getPackageBinary().getIdDt().toVersionless(), options); 878 879 Collection<NpmPackageVersionEntity> remainingVersions = myPackageVersionDao.findByPackageId(thePackageId); 880 if (remainingVersions.isEmpty()) { 881 msg = "No versions of package " + thePackageId + " remain"; 882 ourLog.info(msg); 883 retVal.getMessage().add(msg); 884 Optional<NpmPackageEntity> pkgEntity = myPackageDao.findByPackageId(thePackageId); 885 pkgEntity.ifPresent(pkgEntityPresent -> myPackageDao.delete(pkgEntityPresent)); 886 } else { 887 888 List<NpmPackageVersionEntity> versions = remainingVersions.stream() 889 .sorted((o1, o2) -> 890 PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId())) 891 .toList(); 892 for (int i = 0; i < versions.size(); i++) { 893 boolean isCurrent = i == versions.size() - 1; 894 if (isCurrent != versions.get(i).isCurrentVersion()) { 895 versions.get(i).setCurrentVersion(isCurrent); 896 myPackageVersionDao.save(versions.get(i)); 897 } 898 } 899 } 900 901 } else { 902 903 String msg = "No package found with the given ID"; 904 retVal.getMessage().add(msg); 905 } 906 907 return retVal; 908 } 909 910 @Override 911 @Transactional 912 public List<IBaseResource> loadPackageAssetsByType(FhirVersionEnum theFhirVersion, String theResourceType) { 913 Slice<NpmPackageVersionResourceEntity> outcome = myPackageVersionResourceDao.findCurrentVersionByResourceType( 914 PageRequest.of(0, 1000), theFhirVersion, theResourceType); 915 return outcome.stream().map(this::loadPackageEntity).collect(Collectors.toList()); 916 } 917 918 private void deleteAndExpungeResourceBinary(IIdType theResourceBinaryId, ExpungeOptions theOptions) { 919 SystemRequestDetails requestDetails = new SystemRequestDetails() 920 .setRequestPartitionId(HapiTransactionService.getRequestPartitionAssociatedWithThread()); 921 922 getBinaryDao().delete(theResourceBinaryId, requestDetails); 923 getBinaryDao().forceExpungeInExistingTransaction(theResourceBinaryId, theOptions, requestDetails); 924 } 925 926 @Nonnull 927 public List<Predicate> createSearchPredicates( 928 PackageSearchSpec thePackageSearchSpec, CriteriaBuilder theCb, Root<NpmPackageVersionEntity> theRoot) { 929 List<Predicate> predicates = new ArrayList<>(); 930 931 if (isNotBlank(thePackageSearchSpec.getResourceUrl())) { 932 Join<NpmPackageVersionEntity, NpmPackageVersionResourceEntity> resources = 933 theRoot.join("myResources", JoinType.LEFT); 934 935 predicates.add(theCb.equal(resources.get("myCanonicalUrl"), thePackageSearchSpec.getResourceUrl())); 936 } 937 938 if (isNotBlank(thePackageSearchSpec.getVersion())) { 939 String searchTerm = thePackageSearchSpec.getVersion() + "%"; 940 predicates.add(theCb.like(theRoot.get("myVersionId"), searchTerm)); 941 } 942 943 if (isNotBlank(thePackageSearchSpec.getDescription())) { 944 String searchTerm = "%" + thePackageSearchSpec.getDescription() + "%"; 945 searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm); 946 predicates.add(theCb.like(theCb.upper(theRoot.get("myDescriptionUpper")), searchTerm)); 947 } 948 949 if (isNotBlank(thePackageSearchSpec.getAuthor())) { 950 String searchTerm = "%" + thePackageSearchSpec.getAuthor() + "%"; 951 searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm); 952 predicates.add(theCb.like(theRoot.get("myAuthorUpper"), searchTerm)); 953 } 954 955 if (isNotBlank(thePackageSearchSpec.getFhirVersion())) { 956 if (!PATTERN_FHIR_VERSION 957 .matcher(thePackageSearchSpec.getFhirVersion()) 958 .matches()) { 959 FhirVersionEnum versionEnum = FhirVersionEnum.forVersionString(thePackageSearchSpec.getFhirVersion()); 960 if (versionEnum != null) { 961 predicates.add(theCb.equal(theRoot.get("myFhirVersion").as(String.class), versionEnum.name())); 962 } 963 } else { 964 predicates.add(theCb.like(theRoot.get("myFhirVersionId"), thePackageSearchSpec.getFhirVersion() + "%")); 965 } 966 } 967 968 return predicates; 969 } 970 971 @SuppressWarnings("unchecked") 972 public static List<String> getProcessingMessages(NpmPackage thePackage) { 973 return (List<String>) 974 thePackage.getUserData().computeIfAbsent("JpPackageCache_ProcessingMessages", t -> new ArrayList<>()); 975 } 976 977 /** 978 * Truncates a string to {@link NpmPackageVersionEntity#PACKAGE_DESC_LENGTH} which is 979 * the maximum length used on several columns in {@link NpmPackageVersionEntity}. If the 980 * string is longer than the maximum allowed, the last 3 characters are replaced with "..." 981 */ 982 private static String truncateStorageString(String theInput) { 983 String retVal = null; 984 if (theInput != null) { 985 if (theInput.length() > NpmPackageVersionEntity.PACKAGE_DESC_LENGTH) { 986 retVal = theInput.substring(0, NpmPackageVersionEntity.PACKAGE_DESC_LENGTH - 4) + "..."; 987 } else { 988 retVal = theInput; 989 } 990 } 991 return retVal; 992 } 993}