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
13 changes: 13 additions & 0 deletions server/src/main/java/com/mirth/connect/client/core/Client.java
Original file line number Diff line number Diff line change
Expand Up @@ -809,6 +809,19 @@ public void setConfigurationMap(Map<String, ConfigurationProperty> map) throws C
getServlet(ConfigurationServletInterface.class).setConfigurationMap(map);
}

/**
* Updates a single entry in the configuration map, leaving the rest alone. If expectedVersion is
* not null and doesn't match the entry's current version, nothing is changed and false is
* returned.
*
* @see ConfigurationServletInterface#setConfigurationProperty
* @see ConfigurationProperty#versionOf
*/
@Override
public boolean setConfigurationProperty(String key, ConfigurationProperty property, String expectedVersion) throws ClientException {
return getServlet(ConfigurationServletInterface.class).setConfigurationProperty(key, property, expectedVersion);
}

/**
* Returns the database driver list.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -306,6 +306,22 @@ public void setConfigurationMap(@Param("map") @RequestBody(description = "The ne
@Content(mediaType = MediaType.APPLICATION_JSON, examples = {
@ExampleObject(name = "configurationMap", ref = "../apiexamples/configuration_map_json") }) }) Map<String, ConfigurationProperty> map) throws ClientException;

@PUT
@Path("/configurationMap/{key}")
@Operation(summary = "Updates a single entry in the configuration map, leaving the rest alone.")
@MirthOperation(name = "setConfigurationProperty", display = "Set configuration map entry", permission = Permissions.CONFIGURATION_MAP_EDIT)
@Produces({ MediaType.APPLICATION_JSON, MediaType.TEXT_PLAIN })
public boolean setConfigurationProperty(
@Param("key") @Parameter(description = "The key of the entry to set. It can't be blank, and a / in the key has to be encoded as %2F.", required = true) @PathParam("key") String key,
@Param("property") @RequestBody(description = "The value and comment to store under the key.", required = true, content = {
@Content(mediaType = MediaType.APPLICATION_XML, examples = {
@ExampleObject(name = "configurationProperty", ref = "../apiexamples/configuration_property_xml") }),
@Content(mediaType = MediaType.APPLICATION_JSON, examples = {
@ExampleObject(name = "configurationProperty", ref = "../apiexamples/configuration_property_json") }) }) ConfigurationProperty property,
@Param("expectedVersion") @Parameter(description = "If set, the entry is only updated if its current version matches. Otherwise nothing is changed and false is returned. "
+ "An entry's version is the hex SHA-256 of the UTF-8 bytes of its value, a unit separator (U+001F), and its comment, with a null value and a null or blank comment treated as empty strings. The comparison ignores case. "
+ "Use an empty string to require that the key doesn't exist yet. If not set, the entry is updated unconditionally.") @QueryParam("expectedVersion") String expectedVersion) throws ClientException;

@GET
@Path("/databaseDrivers")
@Operation(summary = "Returns the database driver list.")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@

import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.core.Context;
import javax.ws.rs.core.Response;
import javax.ws.rs.core.SecurityContext;

import org.apache.commons.lang3.ArrayUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;

Expand Down Expand Up @@ -306,6 +308,19 @@ public void setConfigurationMap(Map<String, ConfigurationProperty> map) {
}
}

@Override
public boolean setConfigurationProperty(String key, ConfigurationProperty property, String expectedVersion) {
if (StringUtils.isBlank(key)) {
throw new MirthApiException(Response.status(Response.Status.BAD_REQUEST).entity("The configuration map key cannot be blank.").build());
}

try {
return configurationController.setConfigurationProperty(key, property, expectedVersion);
} catch (ControllerException e) {
throw new MirthApiException(e);
}
}

@Override
public List<DriverInfo> getDatabaseDrivers() {
try {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,24 @@ public static ConfigurationController getInstance() {
*/
public abstract void setConfigurationProperties(Map<String, ConfigurationProperty> map, boolean persist) throws ControllerException;

/**
* Sets a single configuration map entry, leaving every other entry alone. Doing the
* read-modify-write here rather than in the caller is what makes it atomic: a client can only
* replace the whole map, so two clients updating different keys at once would otherwise drop
* each other's writes.
*
* @param key
* The key of the entry to set.
* @param property
* The value and comment to store under the key.
* @param expectedVersion
* If not null, the entry is only set if this matches the current entry's version,
* as returned by {@link ConfigurationProperty#versionOf}, ignoring case. An empty
* string requires that the key doesn't exist yet.
* @return false if expectedVersion didn't match and nothing was changed, otherwise true.
*/
public abstract boolean setConfigurationProperty(String key, ConfigurationProperty property, String expectedVersion) throws ControllerException;

// properties
public Properties getPropertiesForGroup(String group) {
return getPropertiesForGroup(group, null);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -938,6 +938,18 @@ public synchronized void setConfigurationProperties(Map<String, ConfigurationPro
this.commentMap = Collections.unmodifiableMap(commentMap);
}

@Override
public synchronized boolean setConfigurationProperty(String key, ConfigurationProperty property, String expectedVersion) throws ControllerException {
Map<String, ConfigurationProperty> properties = getConfigurationProperties();
if (expectedVersion != null && !expectedVersion.equalsIgnoreCase(ConfigurationProperty.versionOf(properties.get(key)))) {
return false;
}

properties.put(key, property);
setConfigurationProperties(properties, true);
return true;
}

@Override
public void setStatus(int status) {
this.status = status;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,8 @@ protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws Se
requestedObject = getChannelSummaryListExample();
} else if (exampleRequested.equals("configuration_map")) {
requestedObject = getConfigurationMapExample();
} else if (exampleRequested.equals("configuration_property")) {
requestedObject = getConfigurationPropertyExample();
} else if (exampleRequested.equals("connection_test_response")) {
requestedObject = getConnectionTestResponseExample();
} else if (exampleRequested.equals("dashboard_channel_info")) {
Expand Down Expand Up @@ -728,6 +730,10 @@ private Map<String, ConfigurationProperty> getConfigurationMapExample() {
return configurationMap;
}

private ConfigurationProperty getConfigurationPropertyExample() {
return new ConfigurationProperty("exampleValue", "Example comment");
}

private Map<String, List<Integer>> getConnectorMapExample(boolean includeNull) {
Map<String, List<Integer>> connectorMap = new HashMap<>();
List<Integer> connectorList = new ArrayList<>();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
package com.mirth.connect.util;

import java.io.Serializable;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.util.HexFormat;

public class ConfigurationProperty implements Serializable {

Expand Down Expand Up @@ -37,6 +41,39 @@ public void setComment(String comment) {
this.comment = comment;
}

/**
* Returns the version of a configuration map entry, for use as the expected version when
* updating that entry. The version depends only on the entry's contents: it is the lowercase
* hex SHA-256 of the UTF-8 bytes of the value, a unit separator (U+001F), and the comment. A null value
* and a null or blank comment are treated as empty strings. A missing entry has the empty
* string as its version.
*
* The version is calculated from what the server currently holds, so a client can calculate it
* from the entries returned by the server. Saving the map to a file can change some values and
* comments when the server next starts (for example by trimming whitespace), so a version
* calculated before a restart may no longer match.
*
* @param property
* The entry, or null if there is no entry for the key.
* @return The entry's version.
*/
public static String versionOf(ConfigurationProperty property) {
if (property == null) {
return "";
}

String value = property.getValue() == null ? "" : property.getValue();
String comment = property.getComment() == null || property.getComment().isBlank() ? "" : property.getComment();

try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(digest.digest((value + '\u001F' + comment).getBytes(StandardCharsets.UTF_8)));
} catch (NoSuchAlgorithmException e) {
// Every Java platform is required to support SHA-256
throw new IllegalStateException(e);
}
}

@Override
public String toString() {
StringBuilder builder = new StringBuilder();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,12 @@
package com.mirth.connect.server.controllers;

import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertTrue;
import static org.junit.Assert.fail;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.doAnswer;
import static org.mockito.Mockito.doNothing;
import static org.mockito.Mockito.doReturn;
import static org.mockito.Mockito.mock;
Expand All @@ -26,8 +29,11 @@
import java.io.Writer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Properties;
import java.util.concurrent.atomic.AtomicReference;

import javax.xml.XMLConstants;
import javax.xml.transform.Source;
Expand All @@ -36,6 +42,7 @@
import javax.xml.transform.stream.StreamSource;

import org.apache.commons.io.FileUtils;
import org.junit.After;
import org.junit.BeforeClass;
import org.junit.Test;
import org.mockito.invocation.Invocation;
Expand All @@ -46,6 +53,8 @@
import com.google.inject.Injector;
import com.mirth.connect.client.core.ControllerException;
import com.mirth.connect.model.DriverInfo;
import com.mirth.connect.model.converters.ObjectXMLSerializer;
import com.mirth.connect.util.ConfigurationProperty;

public class DefaultConfigurationControllerTest {

Expand All @@ -66,6 +75,11 @@ protected void configure() {
injector.getInstance(ControllerFactory.class);
}

@After
public void resetConfigurationMapLocation() {
DefaultConfigurationController.mirthConfig.clearProperty(DefaultConfigurationController.CONFIGURATION_MAP_LOCATION);
}

@Test
public void getDatabaseDrivers_NotBlank() throws Exception {
DefaultConfigurationController configurationController = spy(new DefaultConfigurationController());
Expand Down Expand Up @@ -275,6 +289,98 @@ public void testParseDbdriversXmlWithExternalDtd() {
assertTrue(exceptionCaught);
}

@Test
public void setConfigurationProperty_KeepsOtherEntries() throws Exception {
AtomicReference<String> database = new AtomicReference<String>(INITIAL_CONFIGURATION_MAP_XML);
DefaultConfigurationController configurationController = databaseBackedConfigurationController(database);

assertTrue(configurationController.setConfigurationProperty("B", new ConfigurationProperty("2", "second"), null));

Map<String, String> expected = new HashMap<String, String>();
expected.put("A", "1");
expected.put("B", "2");
assertEquals(expected, configurationController.getConfigurationMap());

@SuppressWarnings("unchecked")
Map<String, ConfigurationProperty> saved = ObjectXMLSerializer.getInstance().deserialize(database.get(), HashMap.class);
assertEquals("1", saved.get("A").getValue());
assertEquals("comment", saved.get("A").getComment());
assertEquals("2", saved.get("B").getValue());
assertEquals("second", saved.get("B").getComment());
}

@Test
public void setConfigurationProperty_ExpectedVersionMatches() throws Exception {
AtomicReference<String> database = new AtomicReference<String>(INITIAL_CONFIGURATION_MAP_XML);
DefaultConfigurationController configurationController = databaseBackedConfigurationController(database);
String version = ConfigurationProperty.versionOf(new ConfigurationProperty("1", "comment"));

assertTrue(configurationController.setConfigurationProperty("A", new ConfigurationProperty("2", "comment"), version));
assertEquals("2", configurationController.getConfigurationMap().get("A"));
}

@Test
public void setConfigurationProperty_ExpectedVersionMatchesIgnoringCase() throws Exception {
AtomicReference<String> database = new AtomicReference<String>(INITIAL_CONFIGURATION_MAP_XML);
DefaultConfigurationController configurationController = databaseBackedConfigurationController(database);
String version = ConfigurationProperty.versionOf(new ConfigurationProperty("1", "comment")).toUpperCase();

assertTrue(configurationController.setConfigurationProperty("A", new ConfigurationProperty("2", "comment"), version));
assertEquals("2", configurationController.getConfigurationMap().get("A"));
}

@Test
public void setConfigurationProperty_ExpectedVersionMismatch() throws Exception {
assertConflict("A", ConfigurationProperty.versionOf(new ConfigurationProperty("0", "comment")));
}

@Test
public void setConfigurationProperty_ExpectedMissing_KeyExists() throws Exception {
assertConflict("A", "");
}

@Test
public void setConfigurationProperty_ExpectedVersion_KeyMissing() throws Exception {
assertConflict("B", ConfigurationProperty.versionOf(new ConfigurationProperty("1", "comment")));
}

@Test
public void setConfigurationProperty_ExpectedMissing_KeyMissing() throws Exception {
AtomicReference<String> database = new AtomicReference<String>(INITIAL_CONFIGURATION_MAP_XML);
DefaultConfigurationController configurationController = databaseBackedConfigurationController(database);

assertTrue(configurationController.setConfigurationProperty("B", new ConfigurationProperty("2", null), ""));

Map<String, String> expected = new HashMap<String, String>();
expected.put("A", "1");
expected.put("B", "2");
assertEquals(expected, configurationController.getConfigurationMap());
}

private void assertConflict(String key, String expectedVersion) throws Exception {
AtomicReference<String> database = new AtomicReference<String>(INITIAL_CONFIGURATION_MAP_XML);
DefaultConfigurationController configurationController = databaseBackedConfigurationController(database);
Map<String, String> before = configurationController.getConfigurationMap();
String savedBefore = database.get();

assertFalse(configurationController.setConfigurationProperty(key, new ConfigurationProperty("2", "second"), expectedVersion));

assertEquals(savedBefore, database.get());
assertEquals(before, configurationController.getConfigurationMap());
}

private DefaultConfigurationController databaseBackedConfigurationController(AtomicReference<String> database) {
DefaultConfigurationController.mirthConfig.setProperty(DefaultConfigurationController.CONFIGURATION_MAP_LOCATION, "database");

DefaultConfigurationController configurationController = spy(new DefaultConfigurationController());
doAnswer(invocation -> database.get()).when(configurationController).getProperty(anyString(), anyString());
doAnswer(invocation -> {
database.set(invocation.getArgument(2));
return null;
}).when(configurationController).saveProperty(anyString(), anyString(), any());
return configurationController;
}

private void assertDefaultDrivers(List<DriverInfo> drivers, boolean includeODBC) {
assertEquals(includeODBC ? 7 : 6, drivers.size());
int i = 0;
Expand Down Expand Up @@ -341,6 +447,18 @@ private String normalizeXml(String xml) throws Exception {
return writer.toString();
}

private static final String INITIAL_CONFIGURATION_MAP_XML = """
<map>
<entry>
<string>A</string>
<com.mirth.connect.util.ConfigurationProperty>
<value>1</value>
<comment>comment</comment>
</com.mirth.connect.util.ConfigurationProperty>
</entry>
</map>
""";

// @formatter:off
private String DEFAULT_DRIVERS_XML = "<list>\n" +
" <driverInfo>\n" +
Expand Down
Loading
Loading