Merge branch 'master' into rel_8_7-tracking
This commit is contained in:
25
pom.xml
25
pom.xml
@@ -204,13 +204,16 @@
|
|||||||
|
|
||||||
<!-- HAPI-FHIR uses Logback for logging support. The logback library is included automatically by Maven as a part of the hapi-fhir-base dependency, but you also need to include a logging library. Logback
|
<!-- HAPI-FHIR uses Logback for logging support. The logback library is included automatically by Maven as a part of the hapi-fhir-base dependency, but you also need to include a logging library. Logback
|
||||||
is used here, but log4j would also be fine. -->
|
is used here, but log4j would also be fine. -->
|
||||||
|
<!-- Fixes CVE-2025-11226 -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>ch.qos.logback</groupId>
|
<groupId>ch.qos.logback</groupId>
|
||||||
<artifactId>logback-classic</artifactId>
|
<artifactId>logback-classic</artifactId>
|
||||||
|
<version>1.5.19</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>ch.qos.logback</groupId>
|
<groupId>ch.qos.logback</groupId>
|
||||||
<artifactId>logback-core</artifactId>
|
<artifactId>logback-core</artifactId>
|
||||||
|
<version>1.5.19</version>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
<!-- Needed for JEE/Servlet support -->
|
<!-- Needed for JEE/Servlet support -->
|
||||||
@@ -308,6 +311,7 @@
|
|||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.testcontainers</groupId>
|
<groupId>org.testcontainers</groupId>
|
||||||
<artifactId>testcontainers</artifactId>
|
<artifactId>testcontainers</artifactId>
|
||||||
|
<version>2.0.3</version>
|
||||||
<scope>test</scope>
|
<scope>test</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
<dependency>
|
<dependency>
|
||||||
@@ -443,6 +447,14 @@
|
|||||||
|
|
||||||
<build>
|
<build>
|
||||||
|
|
||||||
|
<!-- Enabling resource filtering -->
|
||||||
|
<resources>
|
||||||
|
<resource>
|
||||||
|
<directory>src/main/resources</directory>
|
||||||
|
<filtering>true</filtering>
|
||||||
|
</resource>
|
||||||
|
</resources>
|
||||||
|
|
||||||
<pluginManagement>
|
<pluginManagement>
|
||||||
<plugins>
|
<plugins>
|
||||||
<plugin>
|
<plugin>
|
||||||
@@ -485,6 +497,19 @@
|
|||||||
</executions>
|
</executions>
|
||||||
</plugin>
|
</plugin>
|
||||||
|
|
||||||
|
<!-- Resources plugin: sets custom delimiter '@' for placeholder substitutions -->
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-resources-plugin</artifactId>
|
||||||
|
<version>3.3.1</version>
|
||||||
|
<configuration>
|
||||||
|
<delimiters>
|
||||||
|
<delimiter>@</delimiter>
|
||||||
|
</delimiters>
|
||||||
|
<useDefaultDelimiters>false</useDefaultDelimiters>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
|
||||||
<!-- Tell Maven which Java source version you want to use -->
|
<!-- Tell Maven which Java source version you want to use -->
|
||||||
<plugin>
|
<plugin>
|
||||||
<groupId>org.apache.maven.plugins</groupId>
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
|||||||
@@ -129,6 +129,7 @@ public class AppProperties {
|
|||||||
private Boolean mark_resources_for_reindexing_upon_search_parameter_change = true;
|
private Boolean mark_resources_for_reindexing_upon_search_parameter_change = true;
|
||||||
private Integer reindex_thread_count = null;
|
private Integer reindex_thread_count = null;
|
||||||
private Integer expunge_thread_count = null;
|
private Integer expunge_thread_count = null;
|
||||||
|
private Elasticsearch elasticsearch = null;
|
||||||
|
|
||||||
public List<String> getCustomInterceptorClasses() {
|
public List<String> getCustomInterceptorClasses() {
|
||||||
return custom_interceptor_classes;
|
return custom_interceptor_classes;
|
||||||
@@ -847,6 +848,14 @@ public class AppProperties {
|
|||||||
this.store_meta_source_information = store_meta_source_information;
|
this.store_meta_source_information = store_meta_source_information;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public Elasticsearch getElasticsearch() {
|
||||||
|
return elasticsearch;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setElasticsearch(Elasticsearch elasticsearch) {
|
||||||
|
this.elasticsearch = elasticsearch;
|
||||||
|
}
|
||||||
|
|
||||||
public static class Cors {
|
public static class Cors {
|
||||||
private Boolean allow_Credentials = true;
|
private Boolean allow_Credentials = true;
|
||||||
private List<String> allowed_origin = List.of("*");
|
private List<String> allowed_origin = List.of("*");
|
||||||
@@ -1196,4 +1205,17 @@ public class AppProperties {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static class Elasticsearch {
|
||||||
|
|
||||||
|
private String index_prefix = "";
|
||||||
|
|
||||||
|
public String getIndex_prefix() {
|
||||||
|
return index_prefix;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setIndex_prefix(String index_prefix) {
|
||||||
|
this.index_prefix = index_prefix;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -296,6 +296,12 @@ public class FhirServerConfigCommon {
|
|||||||
appProperties.getExpunge_thread_count());
|
appProperties.getExpunge_thread_count());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine index prefix from configuration
|
||||||
|
if (appProperties.getElasticsearch() != null) {
|
||||||
|
String indexPrefix = appProperties.getElasticsearch().getIndex_prefix();
|
||||||
|
jpaStorageSettings.setHSearchIndexPrefix(indexPrefix != null ? indexPrefix : "");
|
||||||
|
}
|
||||||
|
|
||||||
return jpaStorageSettings;
|
return jpaStorageSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import ca.uhn.fhir.jpa.dao.TolerantJsonParser;
|
|||||||
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
|
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
|
||||||
import ca.uhn.fhir.jpa.search.lastn.IElasticsearchSvc;
|
import ca.uhn.fhir.jpa.search.lastn.IElasticsearchSvc;
|
||||||
import ca.uhn.fhir.jpa.search.lastn.json.ObservationJson;
|
import ca.uhn.fhir.jpa.search.lastn.json.ObservationJson;
|
||||||
|
import ca.uhn.fhir.jpa.starter.AppProperties;
|
||||||
import ca.uhn.fhir.parser.IParser;
|
import ca.uhn.fhir.parser.IParser;
|
||||||
import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
|
import ca.uhn.fhir.rest.api.server.storage.IResourcePersistentId;
|
||||||
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
|
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
|
||||||
@@ -33,8 +34,8 @@ import java.util.stream.Collectors;
|
|||||||
public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
||||||
|
|
||||||
// Index Constants
|
// Index Constants
|
||||||
public static final String OBSERVATION_INDEX = "observation_index";
|
public static final String OBSERVATION_INDEX_BASE_NAME = "observation_index";
|
||||||
public static final String OBSERVATION_CODE_INDEX = "code_index";
|
public static final String OBSERVATION_CODE_INDEX_BASE_NAME = "code_index";
|
||||||
public static final String OBSERVATION_INDEX_SCHEMA_FILE = "ObservationIndexSchema.json";
|
public static final String OBSERVATION_INDEX_SCHEMA_FILE = "ObservationIndexSchema.json";
|
||||||
public static final String OBSERVATION_CODE_INDEX_SCHEMA_FILE = "ObservationCodeIndexSchema.json";
|
public static final String OBSERVATION_CODE_INDEX_SCHEMA_FILE = "ObservationCodeIndexSchema.json";
|
||||||
|
|
||||||
@@ -53,11 +54,26 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
|||||||
|
|
||||||
private final FhirContext myContext;
|
private final FhirContext myContext;
|
||||||
|
|
||||||
public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirContext) {
|
// Prefixed index names
|
||||||
|
private String observationIndexName = OBSERVATION_INDEX_BASE_NAME;
|
||||||
|
private String observationCodeIndexName = OBSERVATION_CODE_INDEX_BASE_NAME;
|
||||||
|
|
||||||
|
public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirContext, AppProperties appProperties) {
|
||||||
|
|
||||||
myContext = fhirContext;
|
myContext = fhirContext;
|
||||||
myRestHighLevelClient = client;
|
myRestHighLevelClient = client;
|
||||||
|
|
||||||
|
// Determine index prefix from configuration
|
||||||
|
if (appProperties.getElasticsearch() != null) {
|
||||||
|
String indexPrefix = appProperties.getElasticsearch().getIndex_prefix();
|
||||||
|
if (indexPrefix != null
|
||||||
|
&& !sanitizeElasticsearchIndexName(indexPrefix).isEmpty()) {
|
||||||
|
// Set prefixed index names
|
||||||
|
this.observationIndexName = indexPrefix + "-" + OBSERVATION_INDEX_BASE_NAME;
|
||||||
|
this.observationCodeIndexName = indexPrefix + "-" + OBSERVATION_CODE_INDEX_BASE_NAME;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
createObservationIndexIfMissing();
|
createObservationIndexIfMissing();
|
||||||
createObservationCodeIndexIfMissing();
|
createObservationCodeIndexIfMissing();
|
||||||
@@ -66,6 +82,34 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sanitizes a string to be a valid Elasticsearch index name.
|
||||||
|
* <p>
|
||||||
|
* Elasticsearch index name requirements:
|
||||||
|
* - Must be lowercase
|
||||||
|
* - Can only contain: lowercase letters, numbers, hyphens (-), and underscores (_)
|
||||||
|
* - Cannot start with: -, _, or +
|
||||||
|
* - Cannot exceed 255 characters
|
||||||
|
* <p>
|
||||||
|
* This method performs the following transformations:
|
||||||
|
* 1. Converts to lowercase
|
||||||
|
* 2. Replaces any invalid characters with underscores
|
||||||
|
* 3. Removes leading -, _, or + characters
|
||||||
|
* 4. Truncates to 255 characters if necessary
|
||||||
|
* 5. Trims any remaining whitespace
|
||||||
|
*
|
||||||
|
* @param name the string to sanitize
|
||||||
|
* @return a valid Elasticsearch index name
|
||||||
|
*/
|
||||||
|
private String sanitizeElasticsearchIndexName(String name) {
|
||||||
|
String cleaned = name.toLowerCase().replaceAll("[^a-z0-9\\-_]", "_");
|
||||||
|
cleaned = cleaned.replaceAll("^[\\-_.]+", "");
|
||||||
|
if (cleaned.length() > 255) {
|
||||||
|
cleaned = cleaned.substring(0, 255);
|
||||||
|
}
|
||||||
|
return cleaned.trim();
|
||||||
|
}
|
||||||
|
|
||||||
private String getIndexSchema(String theSchemaFileName) throws IOException {
|
private String getIndexSchema(String theSchemaFileName) throws IOException {
|
||||||
InputStreamReader input =
|
InputStreamReader input =
|
||||||
new InputStreamReader(ElasticsearchSvcImpl.class.getResourceAsStream(theSchemaFileName));
|
new InputStreamReader(ElasticsearchSvcImpl.class.getResourceAsStream(theSchemaFileName));
|
||||||
@@ -80,21 +124,21 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private void createObservationIndexIfMissing() throws IOException {
|
private void createObservationIndexIfMissing() throws IOException {
|
||||||
if (indexExists(OBSERVATION_INDEX)) {
|
if (indexExists(observationIndexName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String observationMapping = getIndexSchema(OBSERVATION_INDEX_SCHEMA_FILE);
|
String observationMapping = getIndexSchema(OBSERVATION_INDEX_SCHEMA_FILE);
|
||||||
if (!createIndex(OBSERVATION_INDEX, observationMapping)) {
|
if (!createIndex(observationIndexName, observationMapping)) {
|
||||||
throw new RuntimeException(Msg.code(1176) + "Failed to create observation index");
|
throw new RuntimeException(Msg.code(1176) + "Failed to create observation index");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void createObservationCodeIndexIfMissing() throws IOException {
|
private void createObservationCodeIndexIfMissing() throws IOException {
|
||||||
if (indexExists(OBSERVATION_CODE_INDEX)) {
|
if (indexExists(observationCodeIndexName)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String observationCodeMapping = getIndexSchema(OBSERVATION_CODE_INDEX_SCHEMA_FILE);
|
String observationCodeMapping = getIndexSchema(OBSERVATION_CODE_INDEX_SCHEMA_FILE);
|
||||||
if (!createIndex(OBSERVATION_CODE_INDEX, observationCodeMapping)) {
|
if (!createIndex(observationCodeIndexName, observationCodeMapping)) {
|
||||||
throw new RuntimeException(Msg.code(1177) + "Failed to create observation code index");
|
throw new RuntimeException(Msg.code(1177) + "Failed to create observation code index");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -147,7 +191,7 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
|||||||
.map(v -> FieldValue.of(v))
|
.map(v -> FieldValue.of(v))
|
||||||
.collect(Collectors.toList());
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
return SearchRequest.of(sr -> sr.index(OBSERVATION_INDEX)
|
return SearchRequest.of(sr -> sr.index(observationIndexName)
|
||||||
.query(qb -> qb.bool(bb -> bb.must(bbm -> {
|
.query(qb -> qb.bool(bb -> bb.must(bbm -> {
|
||||||
bbm.terms(terms ->
|
bbm.terms(terms ->
|
||||||
terms.field(OBSERVATION_IDENTIFIER_FIELD_NAME).terms(termsb -> termsb.value(values)));
|
terms.field(OBSERVATION_IDENTIFIER_FIELD_NAME).terms(termsb -> termsb.value(values)));
|
||||||
@@ -160,4 +204,20 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {
|
|||||||
public void refreshIndex(String theIndexName) throws IOException {
|
public void refreshIndex(String theIndexName) throws IOException {
|
||||||
myRestHighLevelClient.indices().refresh(fn -> fn.index(theIndexName));
|
myRestHighLevelClient.indices().refresh(fn -> fn.index(theIndexName));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the observation index name (with prefix if configured)
|
||||||
|
* @return the observation index name
|
||||||
|
*/
|
||||||
|
public String getObservationIndexName() {
|
||||||
|
return observationIndexName;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the observation code index name (with prefix if configured)
|
||||||
|
* @return the observation code index name
|
||||||
|
*/
|
||||||
|
public String getObservationCodeIndexName() {
|
||||||
|
return observationCodeIndexName;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package ca.uhn.fhir.jpa.starter.elastic;
|
||||||
|
|
||||||
|
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||||
|
import co.elastic.clients.json.jackson.JacksonJsonpMapper;
|
||||||
|
import co.elastic.clients.transport.rest_client.RestClientTransport;
|
||||||
|
import org.apache.http.HttpHost;
|
||||||
|
import org.apache.http.auth.AuthScope;
|
||||||
|
import org.apache.http.auth.UsernamePasswordCredentials;
|
||||||
|
import org.apache.http.impl.client.BasicCredentialsProvider;
|
||||||
|
import org.elasticsearch.client.RestClient;
|
||||||
|
import org.elasticsearch.client.RestClientBuilder;
|
||||||
|
import org.springframework.boot.autoconfigure.elasticsearch.ElasticsearchProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Conditional;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Custom Elasticsearch configuration that creates the ElasticsearchClient bean
|
||||||
|
* without the sniffer. This is used when the default Spring Boot autoconfiguration
|
||||||
|
* is excluded.
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@Conditional(ElasticConfigCondition.class)
|
||||||
|
public class ElasticsearchConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public RestClient elasticsearchRestClient(ElasticsearchProperties properties) {
|
||||||
|
List<String> uris = properties.getUris();
|
||||||
|
|
||||||
|
HttpHost[] hosts = uris.stream()
|
||||||
|
.map(URI::create)
|
||||||
|
.map(uri -> new HttpHost(uri.getHost(), uri.getPort(), uri.getScheme()))
|
||||||
|
.toArray(HttpHost[]::new);
|
||||||
|
|
||||||
|
RestClientBuilder builder = RestClient.builder(hosts);
|
||||||
|
|
||||||
|
// Configure authentication if credentials are provided
|
||||||
|
if (properties.getUsername() != null && properties.getPassword() != null) {
|
||||||
|
BasicCredentialsProvider credentialsProvider = new BasicCredentialsProvider();
|
||||||
|
credentialsProvider.setCredentials(
|
||||||
|
AuthScope.ANY, new UsernamePasswordCredentials(properties.getUsername(), properties.getPassword()));
|
||||||
|
|
||||||
|
builder.setHttpClientConfigCallback(
|
||||||
|
httpClientBuilder -> httpClientBuilder.setDefaultCredentialsProvider(credentialsProvider));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Configure connection and socket timeouts if needed
|
||||||
|
builder.setRequestConfigCallback(requestConfigBuilder -> requestConfigBuilder
|
||||||
|
.setConnectTimeout(
|
||||||
|
properties.getConnectionTimeout() != null
|
||||||
|
? (int) properties.getConnectionTimeout().toMillis()
|
||||||
|
: 5000)
|
||||||
|
.setSocketTimeout(
|
||||||
|
properties.getSocketTimeout() != null
|
||||||
|
? (int) properties.getSocketTimeout().toMillis()
|
||||||
|
: 60000));
|
||||||
|
|
||||||
|
return builder.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public ElasticsearchClient elasticsearchClient(RestClient restClient) {
|
||||||
|
RestClientTransport transport = new RestClientTransport(restClient, new JacksonJsonpMapper());
|
||||||
|
return new ElasticsearchClient(transport);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,6 +37,12 @@ management:
|
|||||||
enabled: true
|
enabled: true
|
||||||
|
|
||||||
spring:
|
spring:
|
||||||
|
# -------------------------------------------------------------------------------
|
||||||
|
# Application Name
|
||||||
|
# -------------------------------------------------------------------------------
|
||||||
|
application:
|
||||||
|
name: "@project.artifactId@"
|
||||||
|
|
||||||
# -------------------------------------------------------------------------------
|
# -------------------------------------------------------------------------------
|
||||||
# A. Spring AI — Model Context Protocol (MCP)
|
# A. Spring AI — Model Context Protocol (MCP)
|
||||||
# -------------------------------------------------------------------------------
|
# -------------------------------------------------------------------------------
|
||||||
@@ -81,6 +87,7 @@ spring:
|
|||||||
main:
|
main:
|
||||||
allow-bean-definition-overriding: false
|
allow-bean-definition-overriding: false
|
||||||
allow-circular-references: true
|
allow-circular-references: true
|
||||||
|
banner-mode: off
|
||||||
|
|
||||||
autoconfigure:
|
autoconfigure:
|
||||||
# This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed.
|
# This exclude is only needed for setups not using Elasticsearch where the elasticsearch sniff is not needed.
|
||||||
@@ -252,6 +259,12 @@ hapi:
|
|||||||
# enable_index_contained_resource: false
|
# enable_index_contained_resource: false
|
||||||
# store_resource_in_lucene_index_enabled: true
|
# store_resource_in_lucene_index_enabled: true
|
||||||
|
|
||||||
|
# -------------------------------------------------------------------------------
|
||||||
|
# Elasticsearch Configuration
|
||||||
|
# -------------------------------------------------------------------------------
|
||||||
|
# elasticsearch:
|
||||||
|
# index_prefix: "myprefix" # Prefix for all Elasticsearch indexes (e.g., myprefix_observation_index)
|
||||||
|
|
||||||
# -------------------------------------------------------------------------------
|
# -------------------------------------------------------------------------------
|
||||||
# E. Bulk Operations
|
# E. Bulk Operations
|
||||||
# -------------------------------------------------------------------------------
|
# -------------------------------------------------------------------------------
|
||||||
@@ -443,4 +456,4 @@ hapi:
|
|||||||
name: Local Tester
|
name: Local Tester
|
||||||
server_address: 'http://localhost:8080/fhir'
|
server_address: 'http://localhost:8080/fhir'
|
||||||
refuse_to_fetch_third_party_urls: false
|
refuse_to_fetch_third_party_urls: false
|
||||||
fhir_version: R4
|
fhir_version: R4
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ class CdsHooksServletIT implements IServerSupport {
|
|||||||
@BeforeEach
|
@BeforeEach
|
||||||
void beforeEach() {
|
void beforeEach() {
|
||||||
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
|
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
|
||||||
ourCtx.getRestfulClientFactory().setSocketTimeout(1200 * 1000);
|
ourCtx.getRestfulClientFactory().setSocketTimeout(1200 * 3000);
|
||||||
ourServerBase = "http://localhost:" + port + "/fhir/";
|
ourServerBase = "http://localhost:" + port + "/fhir/";
|
||||||
ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
|
ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
|
||||||
ourCdsBase = "http://localhost:" + port + "/cds-services";
|
ourCdsBase = "http://localhost:" + port + "/cds-services";
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package ca.uhn.fhir.jpa.starter;
|
|||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
|
||||||
import ca.uhn.fhir.context.FhirContext;
|
import ca.uhn.fhir.context.FhirContext;
|
||||||
|
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchRestClientFactory;
|
||||||
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
|
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
|
||||||
import ca.uhn.fhir.jpa.starter.elastic.ElasticsearchBootSvcImpl;
|
import ca.uhn.fhir.jpa.starter.elastic.ElasticsearchBootSvcImpl;
|
||||||
import ca.uhn.fhir.jpa.test.config.TestElasticsearchContainerHelper;
|
import ca.uhn.fhir.jpa.test.config.TestElasticsearchContainerHelper;
|
||||||
@@ -14,6 +15,8 @@ import java.io.IOException;
|
|||||||
import java.util.Date;
|
import java.util.Date;
|
||||||
import java.util.GregorianCalendar;
|
import java.util.GregorianCalendar;
|
||||||
|
|
||||||
|
import co.elastic.clients.elasticsearch.ElasticsearchClient;
|
||||||
|
import co.elastic.clients.elasticsearch.indices.IndexSettings;
|
||||||
import jakarta.annotation.PreDestroy;
|
import jakarta.annotation.PreDestroy;
|
||||||
import org.hl7.fhir.instance.model.api.IIdType;
|
import org.hl7.fhir.instance.model.api.IIdType;
|
||||||
import org.hl7.fhir.r4.model.Bundle;
|
import org.hl7.fhir.r4.model.Bundle;
|
||||||
@@ -25,7 +28,6 @@ import org.hl7.fhir.r4.model.Patient;
|
|||||||
import org.hl7.fhir.r4.model.StringType;
|
import org.hl7.fhir.r4.model.StringType;
|
||||||
import org.junit.jupiter.api.BeforeAll;
|
import org.junit.jupiter.api.BeforeAll;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Disabled;
|
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.springframework.beans.factory.annotation.Autowired;
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
@@ -35,6 +37,8 @@ import org.springframework.boot.test.web.server.LocalServerPort;
|
|||||||
import org.springframework.context.ApplicationContextInitializer;
|
import org.springframework.context.ApplicationContextInitializer;
|
||||||
import org.springframework.context.ConfigurableApplicationContext;
|
import org.springframework.context.ConfigurableApplicationContext;
|
||||||
import org.springframework.test.context.ActiveProfiles;
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
import org.springframework.test.context.ContextConfiguration;
|
import org.springframework.test.context.ContextConfiguration;
|
||||||
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
import org.springframework.test.context.junit.jupiter.SpringExtension;
|
||||||
import org.testcontainers.elasticsearch.ElasticsearchContainer;
|
import org.testcontainers.elasticsearch.ElasticsearchContainer;
|
||||||
@@ -43,9 +47,8 @@ import org.testcontainers.junit.jupiter.Testcontainers;
|
|||||||
|
|
||||||
@ExtendWith(SpringExtension.class)
|
@ExtendWith(SpringExtension.class)
|
||||||
@Testcontainers
|
@Testcontainers
|
||||||
@Disabled
|
|
||||||
@ActiveProfiles("test")
|
@ActiveProfiles("test")
|
||||||
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class}, properties =
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class, ElasticsearchLastNR4IT.TestConfig.class}, properties =
|
||||||
{
|
{
|
||||||
"spring.datasource.url=jdbc:h2:mem:dbr4",
|
"spring.datasource.url=jdbc:h2:mem:dbr4",
|
||||||
"hapi.fhir.fhir_version=r4",
|
"hapi.fhir.fhir_version=r4",
|
||||||
@@ -81,21 +84,20 @@ class ElasticsearchLastNR4IT {
|
|||||||
@BeforeAll
|
@BeforeAll
|
||||||
public static void beforeClass() throws IOException {
|
public static void beforeClass() throws IOException {
|
||||||
//Given
|
//Given
|
||||||
// ElasticsearchClient elasticsearchHighLevelRestClient = ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient(
|
ElasticsearchClient elasticsearchHighLevelRestClient = ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient(
|
||||||
// "http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", "");
|
"http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", "");
|
||||||
|
|
||||||
/* As of 2023-08-10, HAPI FHIR sets SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS to 50000
|
/* As of 2023-08-10, HAPI FHIR sets SubscriptionConstants.MAX_SUBSCRIPTION_RESULTS to 50000
|
||||||
which is in excess of elastic's default max_result_window. If MAX_SUBSCRIPTION_RESULTS is changed
|
which is in excess of elastic's default max_result_window. If MAX_SUBSCRIPTION_RESULTS is changed
|
||||||
to a value <= 10000, the following will no longer be necessary. - dotasek
|
to a value <= 10000, the following will no longer be necessary. - dotasek
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* elasticsearchHighLevelRestClient.indices().putTemplate(t->{
|
elasticsearchHighLevelRestClient.indices().putTemplate(t->{
|
||||||
t.name("hapi_fhir_template");
|
t.name("hapi_fhir_template");
|
||||||
t.indexPatterns("*");
|
t.indexPatterns("*");
|
||||||
t.settings(new IndexSettings.Builder().maxResultWindow(50000).build());
|
t.settings(new IndexSettings.Builder().maxNgramDiff(50).maxResultWindow(50000).build());
|
||||||
return t;
|
return t;
|
||||||
});
|
});
|
||||||
*/
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreDestroy
|
@PreDestroy
|
||||||
@@ -151,6 +153,15 @@ class ElasticsearchLastNR4IT {
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
static class TestConfig {
|
||||||
|
@Bean
|
||||||
|
public ElasticsearchClient elasticsearchClient() throws IOException {
|
||||||
|
return ElasticsearchRestClientFactory.createElasticsearchHighLevelRestClient(
|
||||||
|
"http", embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200), "", "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static class Initializer
|
static class Initializer
|
||||||
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
|
implements ApplicationContextInitializer<ConfigurableApplicationContext> {
|
||||||
|
|
||||||
@@ -158,7 +169,7 @@ class ElasticsearchLastNR4IT {
|
|||||||
public void initialize(
|
public void initialize(
|
||||||
ConfigurableApplicationContext configurableApplicationContext) {
|
ConfigurableApplicationContext configurableApplicationContext) {
|
||||||
// Since the port is dynamically generated, replace the URL with one that has the correct port
|
// Since the port is dynamically generated, replace the URL with one that has the correct port
|
||||||
TestPropertyValues.of("spring.elasticsearch.uris=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200))
|
TestPropertyValues.of("spring.elasticsearch.uris=http://" + embeddedElastic.getHost() + ":" + embeddedElastic.getMappedPort(9200))
|
||||||
.applyTo(configurableApplicationContext.getEnvironment());
|
.applyTo(configurableApplicationContext.getEnvironment());
|
||||||
TestPropertyValues.of("spring.jpa.properties.hibernate.search.backend.hosts=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200))
|
TestPropertyValues.of("spring.jpa.properties.hibernate.search.backend.hosts=" + embeddedElastic.getHost() +":" + embeddedElastic.getMappedPort(9200))
|
||||||
.applyTo(configurableApplicationContext.getEnvironment());
|
.applyTo(configurableApplicationContext.getEnvironment());
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
package ca.uhn.fhir.jpa.starter;
|
||||||
|
|
||||||
|
import ca.uhn.fhir.context.FhirContext;
|
||||||
|
import ca.uhn.fhir.rest.client.api.IGenericClient;
|
||||||
|
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
|
||||||
|
import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
|
||||||
|
import org.hl7.fhir.instance.model.api.IIdType;
|
||||||
|
import org.hl7.fhir.r4.model.Bundle;
|
||||||
|
import org.hl7.fhir.r4.model.Patient;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.elasticsearch.ElasticsearchContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.awaitility.Awaitility.await;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
@Testcontainers
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@TestPropertySource(locations = "classpath:test-postgres-elasticsearch.yaml")
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class})
|
||||||
|
class PostgresElasticsearchPatientIT {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||||
|
.withDatabaseName("hapi")
|
||||||
|
.withUsername("fhiruser")
|
||||||
|
.withPassword("fhirpass");
|
||||||
|
|
||||||
|
@Container
|
||||||
|
private static final ElasticsearchContainer ELASTICSEARCH = new ElasticsearchContainer(
|
||||||
|
"docker.elastic.co/elasticsearch/elasticsearch:8.11.0"
|
||||||
|
)
|
||||||
|
.withEnv("xpack.security.enabled", "false")
|
||||||
|
.withEnv("discovery.type", "single-node")
|
||||||
|
.withEnv("ES_JAVA_OPTS", "-Xms512m -Xmx512m");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void registerDatasourceProperties(DynamicPropertyRegistry registry) {
|
||||||
|
// PostgreSQL configuration
|
||||||
|
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||||
|
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||||
|
registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
|
||||||
|
registry.add("spring.jpa.properties.hibernate.dialect", () -> "ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect");
|
||||||
|
|
||||||
|
// Elasticsearch configuration
|
||||||
|
registry.add("spring.jpa.properties.hibernate.search.backend.hosts", ELASTICSEARCH::getHttpHostAddress);
|
||||||
|
registry.add("spring.jpa.properties.hibernate.search.backend.protocol", () -> "http");
|
||||||
|
registry.add("spring.jpa.properties.hibernate.search.backend.username", () -> "");
|
||||||
|
registry.add("spring.jpa.properties.hibernate.search.backend.password", () -> "");
|
||||||
|
}
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
private IGenericClient ourClient;
|
||||||
|
private FhirContext ourCtx;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void beforeEach() {
|
||||||
|
ourCtx = FhirContext.forR4();
|
||||||
|
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
|
||||||
|
ourCtx.getRestfulClientFactory().setSocketTimeout((int) Duration.ofMinutes(20).toMillis());
|
||||||
|
String ourServerBase = "http://localhost:" + port + "/fhir/";
|
||||||
|
ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
|
||||||
|
ourClient.registerInterceptor(new LoggingInterceptor(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateAndSearchPatientByFamilyName() {
|
||||||
|
String givenName = "Jane";
|
||||||
|
String familyName = "Smith Doe";
|
||||||
|
|
||||||
|
Patient patient = new Patient();
|
||||||
|
patient.addName().setFamily(familyName).addGiven(givenName);
|
||||||
|
IIdType id = ourClient.create().resource(patient).execute().getId().toUnqualifiedVersionless();
|
||||||
|
assertNotNull(id);
|
||||||
|
|
||||||
|
await().atMost(Duration.ofSeconds(30)).until(() -> searchByFamily(familyName).getTotal() == 1);
|
||||||
|
|
||||||
|
Bundle results = searchByFamily(familyName);
|
||||||
|
assertEquals(1, results.getTotal());
|
||||||
|
Patient found = (Patient) results.getEntry().get(0).getResource();
|
||||||
|
assertEquals(familyName, found.getNameFirstRep().getFamily());
|
||||||
|
assertEquals(id, found.getIdElement().toUnqualifiedVersionless());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bundle searchByFamily(String family) {
|
||||||
|
return ourClient
|
||||||
|
.search()
|
||||||
|
.forResource(Patient.class)
|
||||||
|
.where(Patient.FAMILY.matches().value(family))
|
||||||
|
.returnBundle(Bundle.class)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
package ca.uhn.fhir.jpa.starter;
|
||||||
|
|
||||||
|
import ca.uhn.fhir.context.FhirContext;
|
||||||
|
import ca.uhn.fhir.rest.client.api.IGenericClient;
|
||||||
|
import ca.uhn.fhir.rest.client.api.ServerValidationModeEnum;
|
||||||
|
import ca.uhn.fhir.rest.client.interceptor.LoggingInterceptor;
|
||||||
|
import org.hl7.fhir.instance.model.api.IIdType;
|
||||||
|
import org.hl7.fhir.r4.model.Bundle;
|
||||||
|
import org.hl7.fhir.r4.model.Patient;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.boot.test.web.server.LocalServerPort;
|
||||||
|
import org.springframework.test.context.ActiveProfiles;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.springframework.test.context.TestPropertySource;
|
||||||
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
|
||||||
|
import static org.awaitility.Awaitility.await;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
@Testcontainers
|
||||||
|
@ActiveProfiles("test")
|
||||||
|
@TestPropertySource(locations = "classpath:test-postgres-lucene.yaml")
|
||||||
|
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT, classes = {Application.class})
|
||||||
|
class PostgresLucenePatientIT {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
private static final PostgreSQLContainer<?> POSTGRES = new PostgreSQLContainer<>("postgres:16-alpine")
|
||||||
|
.withDatabaseName("hapi")
|
||||||
|
.withUsername("fhiruser")
|
||||||
|
.withPassword("fhirpass");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void registerDatasourceProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", POSTGRES::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", POSTGRES::getUsername);
|
||||||
|
registry.add("spring.datasource.password", POSTGRES::getPassword);
|
||||||
|
registry.add("spring.datasource.driver-class-name", POSTGRES::getDriverClassName);
|
||||||
|
registry.add("spring.jpa.properties.hibernate.dialect", () -> "ca.uhn.fhir.jpa.model.dialect.HapiFhirPostgresDialect");
|
||||||
|
}
|
||||||
|
|
||||||
|
@LocalServerPort
|
||||||
|
private int port;
|
||||||
|
|
||||||
|
private IGenericClient ourClient;
|
||||||
|
private FhirContext ourCtx;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void beforeEach() {
|
||||||
|
ourCtx = FhirContext.forR4();
|
||||||
|
ourCtx.getRestfulClientFactory().setServerValidationMode(ServerValidationModeEnum.NEVER);
|
||||||
|
ourCtx.getRestfulClientFactory().setSocketTimeout((int) Duration.ofMinutes(20).toMillis());
|
||||||
|
String ourServerBase = "http://localhost:" + port + "/fhir/";
|
||||||
|
ourClient = ourCtx.newRestfulGenericClient(ourServerBase);
|
||||||
|
ourClient.registerInterceptor(new LoggingInterceptor(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void testCreateAndSearchPatientByFamilyName() {
|
||||||
|
String givenName = "Jane";
|
||||||
|
String familyName = "Smith Doe";
|
||||||
|
|
||||||
|
Patient patient = new Patient();
|
||||||
|
patient.addName().setFamily(familyName).addGiven(givenName);
|
||||||
|
IIdType id = ourClient.create().resource(patient).execute().getId().toUnqualifiedVersionless();
|
||||||
|
assertNotNull(id);
|
||||||
|
|
||||||
|
await().atMost(Duration.ofSeconds(30)).until(() -> searchByFamily(familyName).getTotal() == 1);
|
||||||
|
|
||||||
|
Bundle results = searchByFamily(familyName);
|
||||||
|
assertEquals(1, results.getTotal());
|
||||||
|
Patient found = (Patient) results.getEntry().get(0).getResource();
|
||||||
|
assertEquals(familyName, found.getNameFirstRep().getFamily());
|
||||||
|
assertEquals(id, found.getIdElement().toUnqualifiedVersionless());
|
||||||
|
}
|
||||||
|
|
||||||
|
private Bundle searchByFamily(String family) {
|
||||||
|
return ourClient
|
||||||
|
.search()
|
||||||
|
.forResource(Patient.class)
|
||||||
|
.where(Patient.FAMILY.matches().value(family))
|
||||||
|
.returnBundle(Bundle.class)
|
||||||
|
.execute();
|
||||||
|
}
|
||||||
|
}
|
||||||
21
src/test/resources/test-postgres-elasticsearch.yaml
Normal file
21
src/test/resources/test-postgres-elasticsearch.yaml
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
hapi:
|
||||||
|
fhir:
|
||||||
|
fhir_version: r4
|
||||||
|
cr_enabled: false
|
||||||
|
advanced_lucene_indexing: true
|
||||||
|
store_resource_in_lucene_index_enabled: true
|
||||||
|
search_index_full_text_enabled: true
|
||||||
|
|
||||||
|
spring:
|
||||||
|
main:
|
||||||
|
allow-bean-definition-overriding: true
|
||||||
|
jpa:
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
search:
|
||||||
|
enabled: true
|
||||||
|
backend:
|
||||||
|
type: elasticsearch
|
||||||
|
analysis:
|
||||||
|
configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiElasticAnalysisConfigurer
|
||||||
|
# Connection properties will be set dynamically by the test via @DynamicPropertySource
|
||||||
22
src/test/resources/test-postgres-lucene.yaml
Normal file
22
src/test/resources/test-postgres-lucene.yaml
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
hapi:
|
||||||
|
fhir:
|
||||||
|
fhir_version: r4
|
||||||
|
cr_enabled: false
|
||||||
|
advanced_lucene_indexing: true
|
||||||
|
store_resource_in_lucene_index_enabled: true
|
||||||
|
search_index_full_text_enabled: true
|
||||||
|
|
||||||
|
spring:
|
||||||
|
main:
|
||||||
|
allow-bean-definition-overriding: true
|
||||||
|
jpa:
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
search:
|
||||||
|
enabled: true
|
||||||
|
backend:
|
||||||
|
type: lucene
|
||||||
|
analysis:
|
||||||
|
configurer: ca.uhn.fhir.jpa.search.HapiHSearchAnalysisConfigurers$HapiLuceneAnalysisConfigurer
|
||||||
|
directory:
|
||||||
|
type: local-heap
|
||||||
Reference in New Issue
Block a user