diff --git a/README.md b/README.md index 5c2e2207df..96b11b8c1f 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,21 @@ docker run -p 8080:8080 -e hapi.fhir.default_encoding=xml hapiproject/hapi:lates HAPI looks in the environment variables for properties in the [application.yaml](https://github.com/hapifhir/hapi-fhir-jpaserver-starter/blob/master/src/main/resources/application.yaml) file for defaults. +### Binary storage configuration + +To stream large `Binary` payloads to disk instead of the database, configure the starter with filesystem storage properties: + +``` +hapi: + fhir: + binary_storage_enabled: true + binary_storage_mode: FILESYSTEM + binary_storage_filesystem_base_directory: /binstore + # inline_resource_storage_below_size: 131072 # optional override +``` + +When `binary_storage_mode` is set to `FILESYSTEM` and `inline_resource_storage_below_size` is omitted, the starter automatically applies a 102400 byte (100 KB) inline threshold so smaller payloads remain in the database. Ensure the directory you point to is writable by the process (for Docker builds, mount it into the container with appropriate permissions). + ### Configuration via overridden application.yaml file and using Docker You can customize HAPI by telling HAPI to look for the configuration file in a different location, e.g.: diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java index f10f69333b..91b3ded5e7 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java @@ -61,7 +61,15 @@ public class AppProperties { private Boolean filter_search_enabled = true; private Boolean graphql_enabled = false; private Boolean binary_storage_enabled = false; - private Integer inline_resource_storage_below_size = 0; + + public enum BinaryStorageMode { + DATABASE, + FILESYSTEM + } + + private BinaryStorageMode binary_storage_mode = BinaryStorageMode.DATABASE; + private String binary_storage_filesystem_base_directory; + private Integer inline_resource_storage_below_size; private Boolean bulk_export_enabled = false; private Boolean bulk_import_enabled = false; private Boolean default_pretty_print = true; @@ -483,6 +491,22 @@ public void setBinary_storage_enabled(Boolean binary_storage_enabled) { this.binary_storage_enabled = binary_storage_enabled; } + public BinaryStorageMode getBinary_storage_mode() { + return binary_storage_mode; + } + + public void setBinary_storage_mode(BinaryStorageMode binary_storage_mode) { + this.binary_storage_mode = binary_storage_mode; + } + + public String getBinary_storage_filesystem_base_directory() { + return binary_storage_filesystem_base_directory; + } + + public void setBinary_storage_filesystem_base_directory(String binary_storage_filesystem_base_directory) { + this.binary_storage_filesystem_base_directory = binary_storage_filesystem_base_directory; + } + public Integer getInline_resource_storage_below_size() { return inline_resource_storage_below_size; } diff --git a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java index f28a2c6c36..a9f82af668 100644 --- a/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java +++ b/src/main/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommon.java @@ -1,8 +1,8 @@ package ca.uhn.fhir.jpa.starter.common; import ca.uhn.fhir.jpa.api.config.JpaStorageSettings; -import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; import ca.uhn.fhir.jpa.binstore.DatabaseBinaryContentStorageSvcImpl; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; import ca.uhn.fhir.jpa.config.HibernatePropertiesProvider; import ca.uhn.fhir.jpa.model.config.PartitionSettings; import ca.uhn.fhir.jpa.model.config.PartitionSettings.CrossPartitionReferenceMode; @@ -19,10 +19,12 @@ import org.hl7.fhir.r4.model.Bundle.BundleType; import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.boot.env.YamlPropertySourceLoader; import org.springframework.context.annotation.*; import org.springframework.orm.jpa.LocalContainerEntityManagerFactoryBean; import org.springframework.transaction.annotation.EnableTransactionManagement; +import org.springframework.util.Assert; import java.util.HashSet; import java.util.stream.Collectors; @@ -38,6 +40,7 @@ public class FhirServerConfigCommon { private static final Logger ourLog = LoggerFactory.getLogger(FhirServerConfigCommon.class); + private static final int DEFAULT_FILESYSTEM_INLINE_THRESHOLD = 102_400; public FhirServerConfigCommon(AppProperties appProperties) { ourLog.info( @@ -222,8 +225,9 @@ public JpaStorageSettings jpaStorageSettings(AppProperties appProperties) { jpaStorageSettings.setLastNEnabled(true); } - if (appProperties.getInline_resource_storage_below_size() != 0) { - jpaStorageSettings.setInlineResourceTextBelowSize(appProperties.getInline_resource_storage_below_size()); + Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); + if (inlineResourceThreshold != null && inlineResourceThreshold != 0) { + jpaStorageSettings.setInlineResourceTextBelowSize(inlineResourceThreshold); } jpaStorageSettings.setStoreResourceInHSearchIndex(appProperties.getStore_resource_in_lucene_index_enabled()); @@ -339,16 +343,50 @@ public HibernatePropertiesProvider jpaStarterDialectProvider( return new JpaHibernatePropertiesProvider(myEntityManagerFactory); } - @Lazy @Bean - public IBinaryStorageSvc binaryStorageSvc(AppProperties appProperties) { - DatabaseBinaryContentStorageSvcImpl binaryStorageSvc = new DatabaseBinaryContentStorageSvcImpl(); + @ConditionalOnProperty(prefix = "hapi.fhir", name = "binary_storage_mode", havingValue = "FILESYSTEM") + public FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc(AppProperties appProperties) { + String baseDirectory = appProperties.getBinary_storage_filesystem_base_directory(); + Assert.hasText( + baseDirectory, + "binary_storage_filesystem_base_directory must be provided when binary_storage_mode=FILESYSTEM"); + + FilesystemBinaryStorageSvcImpl filesystemSvc = new FilesystemBinaryStorageSvcImpl(baseDirectory); + Integer inlineResourceThreshold = resolveInlineResourceThreshold(appProperties); + int minimumBinarySize = + inlineResourceThreshold == null ? DEFAULT_FILESYSTEM_INLINE_THRESHOLD : inlineResourceThreshold; + filesystemSvc.setMinimumBinarySize(minimumBinarySize); + + Integer maxBinarySize = appProperties.getMax_binary_size(); + if (maxBinarySize != null) { + filesystemSvc.setMaximumBinarySize(maxBinarySize.longValue()); + } + + return filesystemSvc; + } - if (appProperties.getMax_binary_size() != null) { - binaryStorageSvc.setMaximumBinarySize(appProperties.getMax_binary_size()); + @Bean + @ConditionalOnProperty( + prefix = "hapi.fhir", + name = "binary_storage_mode", + havingValue = "DATABASE", + matchIfMissing = true) + public DatabaseBinaryContentStorageSvcImpl databaseBinaryStorageSvc(AppProperties appProperties) { + DatabaseBinaryContentStorageSvcImpl databaseSvc = new DatabaseBinaryContentStorageSvcImpl(); + Integer maxBinarySize = appProperties.getMax_binary_size(); + if (maxBinarySize != null) { + databaseSvc.setMaximumBinarySize(maxBinarySize.longValue()); } + return databaseSvc; + } - return binaryStorageSvc; + private Integer resolveInlineResourceThreshold(AppProperties appProperties) { + Integer inlineResourceThreshold = appProperties.getInline_resource_storage_below_size(); + if (inlineResourceThreshold == null + && appProperties.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { + return DEFAULT_FILESYSTEM_INLINE_THRESHOLD; + } + return inlineResourceThreshold; } @Bean diff --git a/src/main/resources/application.yaml b/src/main/resources/application.yaml index 967ba37889..f68b11c323 100644 --- a/src/main/resources/application.yaml +++ b/src/main/resources/application.yaml @@ -381,6 +381,14 @@ hapi: # max_page_size: 200 # retain_cached_searches_mins: 60 # reuse_cached_search_results_millis: 60000 + # validation: + # requests_enabled: true + # responses_enabled: true + # binary_storage_enabled: true + # binary_storage_mode: FILESYSTEM + # binary_storage_filesystem_base_directory: /binstore + # When binary_storage_mode is FILESYSTEM and this value is not set, + # the starter defaults to 102400 bytes so smaller binaries stay inline. inline_resource_storage_below_size: 4000 # ------------------------------------------------------------------------------- diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java new file mode 100644 index 0000000000..f40368e18f --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/BinaryStorageIntegrationTest.java @@ -0,0 +1,324 @@ +package ca.uhn.fhir.jpa.starter; + +import ca.uhn.fhir.context.FhirContext; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; +import ca.uhn.fhir.jpa.dao.data.IBinaryStorageEntityDao; +import ca.uhn.fhir.jpa.model.entity.BinaryStorageEntity; +import ca.uhn.fhir.rest.client.api.IGenericClient; +import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum; +import org.hl7.fhir.instance.model.api.IIdType; +import org.hl7.fhir.r4.model.Patient; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.web.server.LocalServerPort; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.support.TransactionTemplate; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; +import java.util.stream.Collectors; +import java.util.stream.Stream; + +import static org.assertj.core.api.Assertions.assertThat; + +abstract class BaseBinaryStorageIntegrationTest { + protected static final String COMMON_CONFIG_LOCATION = "spring.config.location=classpath:/binary-storage-test-empty.yaml"; + protected static final String COMMON_H2_USERNAME = "spring.datasource.username=sa"; + protected static final String COMMON_H2_PASSWORD = "spring.datasource.password="; + protected static final String COMMON_JPA_DDL = "spring.jpa.hibernate.ddl-auto=create-drop"; + protected static final String COMMON_HIBERNATE_DIALECT = + "spring.jpa.properties.hibernate.dialect=ca.uhn.fhir.jpa.model.dialect.HapiFhirH2Dialect"; + protected static final String COMMON_HIBERNATE_SEARCH_DISABLED = "spring.jpa.properties.hibernate.search.enabled=false"; + protected static final String COMMON_FLYWAY_DISABLED = "spring.flyway.enabled=false"; + protected static final String COMMON_FHIR_VERSION = "hapi.fhir.fhir_version=r4"; + protected static final String COMMON_REPO_VALIDATION_DISABLED = + "hapi.fhir.enable_repository_validating_interceptor=false"; + protected static final String COMMON_MDM_DISABLED = "hapi.fhir.mdm_enabled=false"; + protected static final String COMMON_CR_DISABLED = "hapi.fhir.cr_enabled=false"; + protected static final String COMMON_SUBSCRIPTION_WS_DISABLED = "hapi.fhir.subscription.websocket_enabled=false"; + protected static final String COMMON_BEAN_OVERRIDE_ALLOWED = "spring.main.allow-bean-definition-overriding=true"; + protected static final String COMMON_CIRCULAR_REFERENCES = "spring.main.allow-circular-references=true"; + protected static final String COMMON_MCP_DISABLED = "spring.ai.mcp.server.enabled=false"; + protected static final String CONTENT_TYPE = "application/octet-stream"; + + @LocalServerPort + protected int port; + + protected FhirContext fhirContext; + protected IGenericClient client; + private final List resourcesToDelete = new ArrayList<>(); + + @BeforeEach + void setUpClient() { + fhirContext = FhirContext.forR4(); + fhirContext.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER); + fhirContext.getRestfulClientFactory().setSocketTimeout(1200 * 1000); + String serverBase = "http://localhost:" + port + "/fhir/"; + client = fhirContext.newRestfulGenericClient(serverBase); + resourcesToDelete.clear(); + } + + @AfterEach + void deleteCreatedResources() { + for (IIdType id : resourcesToDelete) { + try { + client.delete().resourceById(id).execute(); + } catch (Exception ignored) { + // Ignore cleanup failures to keep tests resilient + } + } + } + + protected IIdType createPatientWithPhoto(String label, byte[] payload) { + Patient patient = new Patient(); + patient.addIdentifier().setSystem("urn:binary-storage-test").setValue(label); + patient.addName().setFamily(label); + patient.addPhoto().setContentType(CONTENT_TYPE).setData(payload); + IIdType id = client.create().resource(patient).execute().getId().toUnqualifiedVersionless(); + resourcesToDelete.add(id); + return id; + } + + protected String uniqueLabel(String prefix) { + return prefix + "-" + UUID.randomUUID(); + } + + protected byte[] randomBytes(int size) { + byte[] payload = new byte[size]; + ThreadLocalRandom.current().nextBytes(payload); + return payload; + } + + protected void assertRegularFileCount(Path baseDir, long expectedFileCount) throws IOException { + assertThat(regularFileCount(baseDir)).isEqualTo(expectedFileCount); + } + + protected void assertRegularFileCountGreaterThan(Path baseDir, long minimumFileCount) throws IOException { + assertThat(regularFileCount(baseDir)).isGreaterThan(minimumFileCount); + } + + protected Path ensureDirectory(Path directory) throws IOException { + Files.createDirectories(directory); + return directory; + } + + protected void deleteDirectoryContents(Path baseDir) throws IOException { + if (Files.notExists(baseDir)) { + return; + } + try (Stream files = Files.walk(baseDir)) { + files.sorted(Comparator.reverseOrder()) + .filter(path -> !path.equals(baseDir)) + .forEach(path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + }); + } + } + + private long regularFileCount(Path baseDir) throws IOException { + if (Files.notExists(baseDir)) { + return 0; + } + try (Stream files = Files.walk(baseDir)) { + return files.filter(Files::isRegularFile).count(); + } + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-db;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=DATABASE" + } +) +class BinaryStorageDatabaseModeIT extends BaseBinaryStorageIntegrationTest { + + @Autowired + private IBinaryStorageEntityDao binaryStorageEntityDao; + + @Autowired + private PlatformTransactionManager transactionManager; + + private TransactionTemplate transactionTemplate; + + @BeforeEach + void initTemplate() { + transactionTemplate = new TransactionTemplate(transactionManager); + } + + @Test + void largeAttachmentStoredInDatabase() { + Set beforeIds = captureContentIds(); + + createPatientWithPhoto(uniqueLabel("database"), randomBytes(150_000)); + + Set afterIds = captureContentIds(); + afterIds.removeAll(beforeIds); + assertThat(afterIds).hasSize(1); + + String binaryId = afterIds.iterator().next(); + BinaryStorageEntity entity = transactionTemplate.execute(status -> + binaryStorageEntityDao.findById(binaryId).orElseThrow()); + + assertThat(entity.hasStorageContent()).isTrue(); + assertThat(entity.getStorageContentBin()).hasSize(150_000); + + transactionTemplate.execute(status -> { + binaryStorageEntityDao.deleteById(binaryId); + return null; + }); + } + + private Set captureContentIds() { + return transactionTemplate.execute(status -> + binaryStorageEntityDao.findAll().stream() + .map(BinaryStorageEntity::getContentId) + .collect(Collectors.toCollection(LinkedHashSet::new))); + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-fs-default;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=FILESYSTEM", + "hapi.fhir.binary_storage_filesystem_base_directory=target/test-binary-storage/filesystem-default" + } +) +class BinaryStorageFilesystemDefaultIT extends BaseBinaryStorageIntegrationTest { + static final Path BASE_DIRECTORY = Paths.get("target/test-binary-storage/filesystem-default").toAbsolutePath(); + + @Autowired + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc; + + @BeforeEach + void prepareDirectory() throws IOException { + ensureDirectory(BASE_DIRECTORY); + deleteDirectoryContents(BASE_DIRECTORY); + } + + @Test + void filesystemModeUsesDefaultThreshold() throws IOException { + assertThat(filesystemBinaryStorageSvc.getMinimumBinarySize()).isEqualTo(102_400); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-default-inline"), randomBytes(50_000)); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-default-offload"), randomBytes(150_000)); + assertRegularFileCountGreaterThan(BASE_DIRECTORY, 0); + } + + @AfterEach + void cleanUpDirectory() throws IOException { + deleteDirectoryContents(BASE_DIRECTORY); + } +} + +@SpringBootTest( + webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, + classes = Application.class, + properties = { + BaseBinaryStorageIntegrationTest.COMMON_CONFIG_LOCATION, + "spring.datasource.url=jdbc:h2:mem:binary-storage-fs-custom;DB_CLOSE_DELAY=-1", + BaseBinaryStorageIntegrationTest.COMMON_H2_USERNAME, + BaseBinaryStorageIntegrationTest.COMMON_H2_PASSWORD, + BaseBinaryStorageIntegrationTest.COMMON_JPA_DDL, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_DIALECT, + BaseBinaryStorageIntegrationTest.COMMON_HIBERNATE_SEARCH_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FLYWAY_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_FHIR_VERSION, + BaseBinaryStorageIntegrationTest.COMMON_REPO_VALIDATION_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_MDM_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_CR_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_SUBSCRIPTION_WS_DISABLED, + BaseBinaryStorageIntegrationTest.COMMON_BEAN_OVERRIDE_ALLOWED, + BaseBinaryStorageIntegrationTest.COMMON_CIRCULAR_REFERENCES, + BaseBinaryStorageIntegrationTest.COMMON_MCP_DISABLED, + "hapi.fhir.binary_storage_enabled=true", + "hapi.fhir.binary_storage_mode=FILESYSTEM", + "hapi.fhir.binary_storage_filesystem_base_directory=target/test-binary-storage/filesystem-custom", + "hapi.fhir.inline_resource_storage_below_size=32768" + } +) +class BinaryStorageFilesystemCustomThresholdIT extends BaseBinaryStorageIntegrationTest { + static final Path BASE_DIRECTORY = Paths.get("target/test-binary-storage/filesystem-custom").toAbsolutePath(); + + @Autowired + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc; + + @BeforeEach + void prepareDirectory() throws IOException { + ensureDirectory(BASE_DIRECTORY); + deleteDirectoryContents(BASE_DIRECTORY); + } + + @Test + void filesystemModeHonoursCustomThreshold() throws IOException { + assertThat(filesystemBinaryStorageSvc.getMinimumBinarySize()).isEqualTo(32_768); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-custom-inline"), randomBytes(30_000)); + assertRegularFileCount(BASE_DIRECTORY, 0); + + createPatientWithPhoto(uniqueLabel("fs-custom-offload"), randomBytes(40_000)); + assertRegularFileCountGreaterThan(BASE_DIRECTORY, 0); + } + + @AfterEach + void cleanUpDirectory() throws IOException { + deleteDirectoryContents(BASE_DIRECTORY); + } +} diff --git a/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java new file mode 100644 index 0000000000..bad6417ae1 --- /dev/null +++ b/src/test/java/ca/uhn/fhir/jpa/starter/common/FhirServerConfigCommonBinaryStorageTest.java @@ -0,0 +1,96 @@ +package ca.uhn.fhir.jpa.starter.common; + +import ca.uhn.fhir.jpa.binstore.DatabaseBinaryContentStorageSvcImpl; +import ca.uhn.fhir.jpa.binstore.FilesystemBinaryStorageSvcImpl; +import ca.uhn.fhir.jpa.binary.api.IBinaryStorageSvc; +import ca.uhn.fhir.jpa.starter.AppProperties; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class FhirServerConfigCommonBinaryStorageTest { + + @TempDir + Path tempDir; + + private FhirServerConfigCommon newConfig() { + return new FhirServerConfigCommon(new AppProperties()); + } + + @Test + void defaultsToDatabaseImplementation() { + AppProperties props = new AppProperties(); + + IBinaryStorageSvc svc = binaryStorageSvc(props); + + assertThat(svc).isInstanceOf(DatabaseBinaryContentStorageSvcImpl.class); + } + + @Test + void filesystemModeUsesDefaultMinimumWhenUnspecified() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + Path baseDir = tempDir.resolve("fs-default"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isEqualTo(102_400); + } + + @Test + void filesystemModeHonoursExplicitMinimum() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + props.setInline_resource_storage_below_size(4096); + Path baseDir = tempDir.resolve("fs-min-explicit"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isEqualTo(4096); + } + + @Test + void filesystemModeSupportsZeroMinimumWhenExplicit() throws Exception { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + props.setInline_resource_storage_below_size(0); + Path baseDir = tempDir.resolve("fs-zero"); + Files.createDirectories(baseDir); + props.setBinary_storage_filesystem_base_directory(baseDir.toString()); + + FilesystemBinaryStorageSvcImpl svc = filesystemBinaryStorageSvc(props); + + assertThat(svc.getMinimumBinarySize()).isZero(); + } + + @Test + void filesystemModeRequiresBaseDirectory() { + AppProperties props = new AppProperties(); + props.setBinary_storage_mode(AppProperties.BinaryStorageMode.FILESYSTEM); + + assertThatThrownBy(() -> filesystemBinaryStorageSvc(props)) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("binary_storage_filesystem_base_directory"); + } + + private IBinaryStorageSvc binaryStorageSvc(AppProperties props) { + FhirServerConfigCommon config = newConfig(); + if (props.getBinary_storage_mode() == AppProperties.BinaryStorageMode.FILESYSTEM) { + return config.filesystemBinaryStorageSvc(props); + } + return config.databaseBinaryStorageSvc(props); + } + + private FilesystemBinaryStorageSvcImpl filesystemBinaryStorageSvc(AppProperties props) { + return (FilesystemBinaryStorageSvcImpl) binaryStorageSvc(props); + } +} diff --git a/src/test/resources/binary-storage-test-empty.yaml b/src/test/resources/binary-storage-test-empty.yaml new file mode 100644 index 0000000000..d889d02848 --- /dev/null +++ b/src/test/resources/binary-storage-test-empty.yaml @@ -0,0 +1 @@ +# Empty config to bypass default application.yaml during BinaryStorageIntegrationTest