Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -189,13 +189,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
is used here, but log4j would also be fine. -->
<!-- Fixes CVE-2025-11226 -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.19</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-core</artifactId>
<version>1.5.19</version>
</dependency>

<!-- Needed for JEE/Servlet support -->
Expand Down Expand Up @@ -428,6 +431,14 @@

<build>

<!-- Enabling resource filtering -->
<resources>
<resource>
<directory>src/main/resources</directory>
<filtering>true</filtering>
</resource>
</resources>

<pluginManagement>
<plugins>
<plugin>
Expand Down Expand Up @@ -470,6 +481,19 @@
</executions>
</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 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
Expand Down
22 changes: 22 additions & 0 deletions src/main/java/ca/uhn/fhir/jpa/starter/AppProperties.java
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,7 @@ public enum BinaryStorageMode {
private Boolean mark_resources_for_reindexing_upon_search_parameter_change = true;
private Integer reindex_thread_count = null;
private Integer expunge_thread_count = null;
private Elasticsearch elasticsearch = null;

public List<String> getCustomInterceptorClasses() {
return custom_interceptor_classes;
Expand Down Expand Up @@ -847,6 +848,14 @@ public void setStore_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 {
private Boolean allow_Credentials = true;
private List<String> allowed_origin = List.of("*");
Expand Down Expand Up @@ -1196,4 +1205,17 @@ public void setQuitWait(Boolean quitWait) {
}
}
}

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;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,6 +303,12 @@ public JpaStorageSettings jpaStorageSettings(AppProperties appProperties) {
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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import ca.uhn.fhir.jpa.search.lastn.ElasticsearchSvcImpl;
import ca.uhn.fhir.jpa.search.lastn.IElasticsearchSvc;
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.rest.api.server.storage.IResourcePersistentId;
import ca.uhn.fhir.rest.server.exceptions.InvalidRequestException;
Expand Down Expand Up @@ -33,8 +34,8 @@
public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {

// Index Constants
public static final String OBSERVATION_INDEX = "observation_index";
public static final String OBSERVATION_CODE_INDEX = "code_index";
public static final String OBSERVATION_INDEX_BASE_NAME = "observation_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_CODE_INDEX_SCHEMA_FILE = "ObservationCodeIndexSchema.json";

Expand All @@ -53,11 +54,25 @@ public class ElasticsearchBootSvcImpl implements IElasticsearchSvc {

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;
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 {
createObservationIndexIfMissing();
createObservationCodeIndexIfMissing();
Expand All @@ -66,6 +81,34 @@ public ElasticsearchBootSvcImpl(ElasticsearchClient client, FhirContext fhirCont
}
}

/**
* 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 {
InputStreamReader input =
new InputStreamReader(ElasticsearchSvcImpl.class.getResourceAsStream(theSchemaFileName));
Expand All @@ -80,21 +123,21 @@ private String getIndexSchema(String theSchemaFileName) throws IOException {
}

private void createObservationIndexIfMissing() throws IOException {
if (indexExists(OBSERVATION_INDEX)) {
if (indexExists(observationIndexName)) {
return;
}
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");
}
}

private void createObservationCodeIndexIfMissing() throws IOException {
if (indexExists(OBSERVATION_CODE_INDEX)) {
if (indexExists(observationCodeIndexName)) {
return;
}
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");
}
}
Expand Down Expand Up @@ -147,7 +190,7 @@ private SearchRequest buildObservationResourceSearchRequest(Collection<? extends
.map(v -> FieldValue.of(v))
.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 -> {
bbm.terms(terms ->
terms.field(OBSERVATION_IDENTIFIER_FIELD_NAME).terms(termsb -> termsb.value(values)));
Expand All @@ -160,4 +203,20 @@ private SearchRequest buildObservationResourceSearchRequest(Collection<? extends
public void refreshIndex(String theIndexName) throws IOException {
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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
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);
}
}
Loading