
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 // This is the only API where we're loading by "currentVersion = true" because that's the current behaviour 585 // in production and this is a widely used APIs 586 // The other APIs are newer and were introduced with newer NPM functionality in mind, including more refined 587 // handling of duplicate canonical URLs across packages 588 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = 589 loadPackageInfoByCanonicalUrlCurrentVersionOnly(theFhirVersion, theCanonicalUrl, PageRequest.of(0, 2)); 590 591 final List<IBaseResource> resources = npmPackageVersionResourceEntities.stream() 592 .map(this::loadPackageEntity) 593 .toList(); 594 595 if (resources.size() > 1) { 596 ourLog.warn( 597 "Found multiple package versions for FHIR version: {} and canonical URL: {}", 598 theFhirVersion, 599 theCanonicalUrl); 600 } else if (resources.isEmpty()) { 601 return null; 602 } 603 return resources.get(0); 604 } 605 606 @Override 607 @Transactional(readOnly = true) 608 public List<IBaseResource> loadPackageAssetsByUrl( 609 FhirVersionEnum theFhirVersion, String theCanonicalUrl, PageRequest thePageRequest) { 610 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = 611 loadPackageInfoByCanonicalUrlAnyVersion(theFhirVersion, theCanonicalUrl, thePageRequest, null, null); 612 613 if (npmPackageVersionResourceEntities.isEmpty()) { 614 return List.of(); 615 } else { 616 return npmPackageVersionResourceEntities.stream() 617 .map(this::loadPackageEntity) 618 .collect(Collectors.toList()); 619 } 620 } 621 622 @Override 623 @Transactional(readOnly = true) 624 public IBaseResource findPackageAsset(FindPackageAssetRequest theRequest) { 625 List<IBaseResource> assets = findPackageAssets(theRequest); 626 if (assets.size() > 1) { 627 ourLog.warn( 628 "Found multiple package versions for FHIR version: {} and canonical URL: {}", 629 theRequest.getFhirVersion(), 630 theRequest.getCanonicalUrl()); 631 } 632 // assets will always have a single element because findPackageAssets throws if nothing is found 633 return assets.get(0); 634 } 635 636 @Override 637 @Transactional(readOnly = true) 638 public List<IBaseResource> findPackageAssets(FindPackageAssetRequest theRequest) { 639 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = 640 loadPackageInfoByCanonicalUrlAnyVersion( 641 theRequest.getFhirVersion(), 642 theRequest.getCanonicalUrl(), 643 theRequest.getPageRequest(), 644 theRequest.getPackageId(), 645 theRequest.getVersion()); 646 647 if (npmPackageVersionResourceEntities.isEmpty()) { 648 throw new ResourceNotFoundException( 649 "%s Could not find asset(s) for FHIR version: %s, canonical URL: %s, package ID: %s and package version: %s" 650 .formatted( 651 Msg.code(2644), 652 theRequest.getFhirVersion(), 653 theRequest.getCanonicalUrl(), 654 theRequest.getPackageId(), 655 Optional.ofNullable(theRequest.getVersion()).orElse("[none]"))); 656 } else { 657 return npmPackageVersionResourceEntities.stream() 658 .map(this::loadPackageEntity) 659 .collect(Collectors.toList()); 660 } 661 } 662 663 @Override 664 @Transactional(readOnly = true) 665 public List<NpmPackageAssetInfoJson> findPackageAssetInfoByUrl( 666 FhirVersionEnum theFhirVersion, String theCanonicalUrl) { 667 final List<NpmPackageVersionResourceEntity> npmPackageVersionResourceEntities = 668 loadPackageInfoByCanonicalUrlAnyVersion( 669 theFhirVersion, theCanonicalUrl, PageRequest.of(0, 20), null, null); 670 671 return npmPackageVersionResourceEntities.stream() 672 .map(entity -> new NpmPackageAssetInfoJson( 673 entity.getResourceBinary().asTypedFhirResourceId(), 674 entity.getCanonicalUrl(), 675 entity.getFhirVersion(), 676 entity.getPackageId(), 677 entity.getPackageVersion())) 678 .toList(); 679 } 680 681 // We want to load the packages marked as current version true only 682 private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrlCurrentVersionOnly( 683 FhirVersionEnum theFhirVersion, String theCanonicalUrl, PageRequest thePageRequest) { 684 return loadPackageInfoByCanonicalUrl(theFhirVersion, theCanonicalUrl, thePageRequest, null, null, true); 685 } 686 687 // We want to load the packages whether they're marked as current version or not 688 private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrlAnyVersion( 689 FhirVersionEnum theFhirVersion, 690 String theCanonicalUrl, 691 PageRequest thePageRequest, 692 @Nullable String thePackageId, 693 @Nullable String theVersionId) { 694 return loadPackageInfoByCanonicalUrl( 695 theFhirVersion, theCanonicalUrl, thePageRequest, thePackageId, theVersionId, null); 696 } 697 698 private List<NpmPackageVersionResourceEntity> loadPackageInfoByCanonicalUrl( 699 FhirVersionEnum theFhirVersion, 700 String theCanonicalUrl, 701 PageRequest thePageRequest, 702 @Nullable String thePackageId, 703 @Nullable String theVersionId, 704 Boolean theIsCurrentVersion) { 705 String canonicalUrl = theCanonicalUrl; 706 707 int versionSeparator = canonicalUrl.lastIndexOf('|'); 708 Slice<NpmPackageVersionResourceEntity> slice; 709 710 if (versionSeparator != -1) { 711 String canonicalVersion = canonicalUrl.substring(versionSeparator + 1); 712 canonicalUrl = canonicalUrl.substring(0, versionSeparator); 713 714 if (thePackageId != null) { 715 if (theVersionId != null) { 716 slice = myPackageVersionResourceDao.findByCanonicalUrlAndVersionAndPackageIdAndVersion( 717 thePageRequest, 718 theFhirVersion, 719 canonicalUrl, 720 canonicalVersion, 721 thePackageId, 722 theVersionId, 723 theIsCurrentVersion); 724 } else { 725 slice = myPackageVersionResourceDao.findByCanonicalUrlAndVersionAndPackageId( 726 thePageRequest, 727 theFhirVersion, 728 canonicalUrl, 729 canonicalVersion, 730 thePackageId, 731 theIsCurrentVersion); 732 } 733 } else { 734 slice = myPackageVersionResourceDao.findByCanonicalUrlAndVersion( 735 thePageRequest, theFhirVersion, canonicalUrl, canonicalVersion, theIsCurrentVersion); 736 } 737 738 } else { 739 if (thePackageId != null) { 740 if (theVersionId != null) { 741 slice = myPackageVersionResourceDao.findByCanonicalUrlAndPackageIdAndVersion( 742 thePageRequest, 743 theFhirVersion, 744 canonicalUrl, 745 thePackageId, 746 theVersionId, 747 theIsCurrentVersion); 748 } else { 749 slice = myPackageVersionResourceDao.findByCanonicalUrlAndPackageId( 750 thePageRequest, theFhirVersion, canonicalUrl, thePackageId, theIsCurrentVersion); 751 } 752 } else { 753 slice = myPackageVersionResourceDao.findByCanonicalUrl( 754 thePageRequest, theFhirVersion, canonicalUrl, theIsCurrentVersion); 755 } 756 } 757 758 if (slice.isEmpty()) { 759 return List.of(); 760 } else { 761 return slice.getContent(); 762 } 763 } 764 765 private IBaseResource loadPackageEntity(NpmPackageVersionResourceEntity contents) { 766 return loadPackageAssetByVersionAndId( 767 contents.getFhirVersion(), contents.getResourceBinary().getResourceId()); 768 } 769 770 private IBaseResource loadPackageAssetByVersionAndId(FhirVersionEnum theFhirVersion, JpaPid theBinaryPid) { 771 try { 772 IBaseBinary binary = getBinaryDao().readByPid(theBinaryPid); 773 byte[] resourceContentsBytes = fetchBlobFromBinary(binary); 774 String resourceContents = new String(resourceContentsBytes, StandardCharsets.UTF_8); 775 FhirContext packageContext = getFhirContext(theFhirVersion); 776 return EncodingEnum.detectEncoding(resourceContents) 777 .newParser(packageContext) 778 .parseResource(resourceContents); 779 } catch (Exception exception) { 780 throw new InvalidRequestException( 781 String.format( 782 "%sFailed to load package resource for FHIR version: %s and binary PID: %s", 783 Msg.code(1305), theFhirVersion, theBinaryPid), 784 exception); 785 } 786 } 787 788 @Override 789 @Transactional 790 public NpmPackageMetadataJson loadPackageMetadata(String thePackageId) { 791 NpmPackageMetadataJson retVal = new NpmPackageMetadataJson(); 792 793 Optional<NpmPackageEntity> pkg = myPackageDao.findByPackageId(thePackageId); 794 if (pkg.isEmpty()) { 795 throw new ResourceNotFoundException(Msg.code(1306) + "Unknown package ID: " + thePackageId); 796 } 797 798 List<NpmPackageVersionEntity> packageVersions = 799 new ArrayList<>(myPackageVersionDao.findByPackageId(thePackageId)); 800 packageVersions.sort(new ReverseComparator<>( 801 (o1, o2) -> PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId()))); 802 803 for (NpmPackageVersionEntity next : packageVersions) { 804 if (next.isCurrentVersion()) { 805 retVal.setDistTags(new NpmPackageMetadataJson.DistTags().setLatest(next.getVersionId())); 806 } 807 808 NpmPackageMetadataJson.Version version = new NpmPackageMetadataJson.Version(); 809 version.setFhirVersion(next.getFhirVersionId()); 810 version.setAuthor(next.getAuthor()); 811 version.setDescription(next.getDescription()); 812 version.setName(next.getPackageId()); 813 version.setVersion(next.getVersionId()); 814 version.setBytes(next.getPackageSizeBytes()); 815 retVal.addVersion(version); 816 } 817 818 return retVal; 819 } 820 821 @Override 822 @Transactional 823 public PackageContents loadPackageContents(String thePackageId, @Nullable String theVersion) { 824 Optional<NpmPackageVersionEntity> entity = loadPackageVersionEntity(thePackageId, theVersion); 825 return entity.map(this::loadPackageContents).orElse(null); 826 } 827 828 @Override 829 @Transactional 830 public NpmPackageSearchResultJson search(PackageSearchSpec thePackageSearchSpec) { 831 NpmPackageSearchResultJson retVal = new NpmPackageSearchResultJson(); 832 833 CriteriaBuilder cb = myEntityManager.getCriteriaBuilder(); 834 835 // Query for total 836 queryForTool(thePackageSearchSpec, cb, retVal); 837 838 // Query for results 839 queryForResults(thePackageSearchSpec, cb, retVal); 840 841 return retVal; 842 } 843 844 private void queryForTool( 845 PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) { 846 CriteriaQuery<Long> countCriteriaQuery = cb.createQuery(Long.class); 847 Root<NpmPackageVersionEntity> countCriteriaRoot = countCriteriaQuery.from(NpmPackageVersionEntity.class); 848 countCriteriaQuery.multiselect(cb.countDistinct(countCriteriaRoot.get("myPackageId"))); 849 850 List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, countCriteriaRoot); 851 852 countCriteriaQuery.where(toPredicateArray(predicates)); 853 Long total = myEntityManager.createQuery(countCriteriaQuery).getSingleResult(); 854 retVal.setTotal(Math.toIntExact(total)); 855 } 856 857 private void queryForResults( 858 PackageSearchSpec thePackageSearchSpec, CriteriaBuilder cb, NpmPackageSearchResultJson retVal) { 859 CriteriaQuery<NpmPackageVersionEntity> criteriaQuery = cb.createQuery(NpmPackageVersionEntity.class); 860 Root<NpmPackageVersionEntity> root = criteriaQuery.from(NpmPackageVersionEntity.class); 861 862 List<Predicate> predicates = createSearchPredicates(thePackageSearchSpec, cb, root); 863 864 criteriaQuery.where(toPredicateArray(predicates)); 865 criteriaQuery.orderBy(cb.asc(root.get("myPackageId"))); 866 TypedQuery<NpmPackageVersionEntity> query = myEntityManager.createQuery(criteriaQuery); 867 query.setFirstResult(thePackageSearchSpec.getStart()); 868 query.setMaxResults(thePackageSearchSpec.getSize()); 869 870 List<NpmPackageVersionEntity> resultList = query.getResultList(); 871 for (NpmPackageVersionEntity next : resultList) { 872 873 if (!retVal.hasPackageWithId(next.getPackageId())) { 874 retVal.addObject() 875 .getPackage() 876 .setName(next.getPackageId()) 877 .setAuthor(next.getAuthor()) 878 .setDescription(next.getDescription()) 879 .setVersion(next.getVersionId()) 880 .addFhirVersion(next.getFhirVersionId()) 881 .setBytes(next.getPackageSizeBytes()); 882 } else { 883 NpmPackageSearchResultJson.Package retPackage = retVal.getPackageWithId(next.getPackageId()); 884 retPackage.addFhirVersion(next.getFhirVersionId()); 885 886 int cmp = PackageVersionComparator.INSTANCE.compare(next.getVersionId(), retPackage.getVersion()); 887 if (cmp > 0) { 888 retPackage.setVersion(next.getVersionId()); 889 } 890 } 891 } 892 } 893 894 @Override 895 public PackageDeleteOutcomeJson uninstallPackage(String thePackageId, String theVersion) { 896 SystemRequestDetails requestDetails = 897 new SystemRequestDetails().setRequestPartitionId(myPartitionSettings.getDefaultRequestPartitionId()); 898 return myTransactionService 899 .withRequest(requestDetails) 900 .execute(() -> doUninstallPackage(thePackageId, theVersion)); 901 } 902 903 private PackageDeleteOutcomeJson doUninstallPackage(String thePackageId, String theVersion) { 904 PackageDeleteOutcomeJson retVal = new PackageDeleteOutcomeJson(); 905 906 Optional<NpmPackageVersionEntity> packageVersion = 907 myPackageVersionDao.findByPackageIdAndVersion(thePackageId, theVersion); 908 if (packageVersion.isPresent()) { 909 910 String msg = "Deleting package " + thePackageId + "#" + theVersion; 911 ourLog.info(msg); 912 retVal.getMessage().add(msg); 913 914 for (NpmPackageVersionResourceEntity next : packageVersion.get().getResources()) { 915 msg = "Deleting package +" + thePackageId + "#" + theVersion + "resource: " + next.getCanonicalUrl(); 916 ourLog.info(msg); 917 retVal.getMessage().add(msg); 918 919 myPackageVersionResourceDao.delete(next); 920 921 ExpungeOptions options = new ExpungeOptions(); 922 options.setExpungeDeletedResources(true).setExpungeOldVersions(true); 923 deleteAndExpungeResourceBinary( 924 next.getResourceBinary().getIdDt().toVersionless(), options); 925 } 926 927 myPackageVersionDao.delete(packageVersion.get()); 928 929 ExpungeOptions options = new ExpungeOptions(); 930 options.setExpungeDeletedResources(true).setExpungeOldVersions(true); 931 deleteAndExpungeResourceBinary( 932 packageVersion.get().getPackageBinary().getIdDt().toVersionless(), options); 933 934 Collection<NpmPackageVersionEntity> remainingVersions = myPackageVersionDao.findByPackageId(thePackageId); 935 if (remainingVersions.isEmpty()) { 936 msg = "No versions of package " + thePackageId + " remain"; 937 ourLog.info(msg); 938 retVal.getMessage().add(msg); 939 Optional<NpmPackageEntity> pkgEntity = myPackageDao.findByPackageId(thePackageId); 940 pkgEntity.ifPresent(pkgEntityPresent -> myPackageDao.delete(pkgEntityPresent)); 941 } else { 942 943 List<NpmPackageVersionEntity> versions = remainingVersions.stream() 944 .sorted((o1, o2) -> 945 PackageVersionComparator.INSTANCE.compare(o1.getVersionId(), o2.getVersionId())) 946 .toList(); 947 for (int i = 0; i < versions.size(); i++) { 948 boolean isCurrent = i == versions.size() - 1; 949 if (isCurrent != versions.get(i).isCurrentVersion()) { 950 versions.get(i).setCurrentVersion(isCurrent); 951 myPackageVersionDao.save(versions.get(i)); 952 } 953 } 954 } 955 956 } else { 957 958 String msg = "No package found with the given ID"; 959 retVal.getMessage().add(msg); 960 } 961 962 return retVal; 963 } 964 965 @Override 966 @Transactional 967 public List<IBaseResource> loadPackageAssetsByType(FhirVersionEnum theFhirVersion, String theResourceType) { 968 Slice<NpmPackageVersionResourceEntity> outcome = myPackageVersionResourceDao.findByResourceType( 969 PageRequest.of(0, 1000), theFhirVersion, theResourceType, true); 970 return outcome.stream().map(this::loadPackageEntity).collect(Collectors.toList()); 971 } 972 973 private void deleteAndExpungeResourceBinary(IIdType theResourceBinaryId, ExpungeOptions theOptions) { 974 SystemRequestDetails requestDetails = new SystemRequestDetails() 975 .setRequestPartitionId(HapiTransactionService.getRequestPartitionAssociatedWithThread()); 976 977 getBinaryDao().delete(theResourceBinaryId, requestDetails); 978 getBinaryDao().forceExpungeInExistingTransaction(theResourceBinaryId, theOptions, requestDetails); 979 } 980 981 @Nonnull 982 public List<Predicate> createSearchPredicates( 983 PackageSearchSpec thePackageSearchSpec, CriteriaBuilder theCb, Root<NpmPackageVersionEntity> theRoot) { 984 List<Predicate> predicates = new ArrayList<>(); 985 986 if (isNotBlank(thePackageSearchSpec.getResourceUrl())) { 987 Join<NpmPackageVersionEntity, NpmPackageVersionResourceEntity> resources = 988 theRoot.join("myResources", JoinType.LEFT); 989 990 predicates.add(theCb.equal(resources.get("myCanonicalUrl"), thePackageSearchSpec.getResourceUrl())); 991 } 992 993 if (isNotBlank(thePackageSearchSpec.getVersion())) { 994 String searchTerm = thePackageSearchSpec.getVersion() + "%"; 995 predicates.add(theCb.like(theRoot.get("myVersionId"), searchTerm)); 996 } 997 998 if (isNotBlank(thePackageSearchSpec.getDescription())) { 999 String searchTerm = "%" + thePackageSearchSpec.getDescription() + "%"; 1000 searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm); 1001 predicates.add(theCb.like(theCb.upper(theRoot.get("myDescriptionUpper")), searchTerm)); 1002 } 1003 1004 if (isNotBlank(thePackageSearchSpec.getAuthor())) { 1005 String searchTerm = "%" + thePackageSearchSpec.getAuthor() + "%"; 1006 searchTerm = StringUtil.normalizeStringForSearchIndexing(searchTerm); 1007 predicates.add(theCb.like(theRoot.get("myAuthorUpper"), searchTerm)); 1008 } 1009 1010 if (isNotBlank(thePackageSearchSpec.getFhirVersion())) { 1011 if (!PATTERN_FHIR_VERSION 1012 .matcher(thePackageSearchSpec.getFhirVersion()) 1013 .matches()) { 1014 FhirVersionEnum versionEnum = FhirVersionEnum.forVersionString(thePackageSearchSpec.getFhirVersion()); 1015 if (versionEnum != null) { 1016 predicates.add(theCb.equal(theRoot.get("myFhirVersion").as(String.class), versionEnum.name())); 1017 } 1018 } else { 1019 predicates.add(theCb.like(theRoot.get("myFhirVersionId"), thePackageSearchSpec.getFhirVersion() + "%")); 1020 } 1021 } 1022 1023 return predicates; 1024 } 1025 1026 @SuppressWarnings("unchecked") 1027 public static List<String> getProcessingMessages(NpmPackage thePackage) { 1028 return (List<String>) 1029 thePackage.getUserData().computeIfAbsent("JpPackageCache_ProcessingMessages", t -> new ArrayList<>()); 1030 } 1031 1032 /** 1033 * Truncates a string to {@link NpmPackageVersionEntity#PACKAGE_DESC_LENGTH} which is 1034 * the maximum length used on several columns in {@link NpmPackageVersionEntity}. If the 1035 * string is longer than the maximum allowed, the last 3 characters are replaced with "..." 1036 */ 1037 private static String truncateStorageString(String theInput) { 1038 String retVal = null; 1039 if (theInput != null) { 1040 if (theInput.length() > NpmPackageVersionEntity.PACKAGE_DESC_LENGTH) { 1041 retVal = theInput.substring(0, NpmPackageVersionEntity.PACKAGE_DESC_LENGTH - 4) + "..."; 1042 } else { 1043 retVal = theInput; 1044 } 1045 } 1046 return retVal; 1047 } 1048}