All Downloads are FREE. Search and download functionalities are using the official Maven repository.

org.kiwiproject.test.mongo.MongoTestProperties Maven / Gradle / Ivy

package org.kiwiproject.test.mongo;

import static com.google.common.base.Preconditions.checkArgument;
import static com.google.common.base.Verify.verify;
import static java.util.Objects.isNull;
import static java.util.stream.Collectors.joining;
import static org.apache.commons.lang3.StringUtils.containsAny;
import static org.apache.commons.lang3.StringUtils.replaceChars;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotBlank;
import static org.kiwiproject.base.KiwiPreconditions.checkArgumentNotNull;
import static org.kiwiproject.base.KiwiPreconditions.requireNotBlank;
import static org.kiwiproject.base.KiwiStrings.f;
import static org.kiwiproject.base.KiwiStrings.splitOnCommas;

import com.google.common.annotations.VisibleForTesting;
import com.mongodb.client.MongoClient;
import com.mongodb.client.MongoClients;

import lombok.Builder;
import lombok.Value;
import org.apache.commons.lang3.math.NumberUtils;
import org.kiwiproject.net.KiwiUrls;

import javax.annotation.Nullable;

/**
 * Simple value class that contains properties related to connecting to Mongo in the context of a service that uses
 * MongoDB for persistence. The constructor and builder accept a service/application name and host, which helps to
 * build easily identifiable and unique database names.
 * 

* For example, if you are testing an "order-service" on your continuous integration server that resides on * {@code ci-1.acme.com}, the test database name will include this information which can be useful debugging errors as * well as to ensure multiple tests of the same service/application can run concurrently on different computers. The * current timestamp is also included in the generated test database names to provide additional uniqueness in addition * to the service/application name and host. *

* Intended to be used in conjunction with {@link org.kiwiproject.test.junit.jupiter.MongoDbExtension} though there is * no reason it cannot be used standalone to generate database names for test purposes as well as the MongoDB * client URI. *

* Per MongoDB Naming Restrictions, * "Database names cannot be empty and must have fewer than 64 characters". Thus the maximum length of database names * is 63 characters. The generated test database names include the service/application and host name as well as either * the {@link #UNIT_TEST_ID} (by default) or {@link #UNIT_TEST_ID_SHORT} (when the name needs to be truncated). The name * always ends with the current time in millis. If the service/application name and host are long and including them * in their entirety would cause the generated test name to exceed the Mongo name length limit, then various techniques * to shorten the name are tried until the name is 63 characters or less. */ @Value public class MongoTestProperties { /** * The default ID used in generated database names to indicate the purpose is for testing. */ public static final String UNIT_TEST_ID = "_unit_test_"; /** * The short ID used in generated database names to indicate the purpose is for testing. Used in cases where * the service/application name and host are lengthy and cause the default generated name to exceed the Mongo * name length limit. */ public static final String UNIT_TEST_ID_SHORT = "_ut_"; private static final String DEFAULT_DB_NAME_TEMPLATE = "{}" + UNIT_TEST_ID + "{}_{}"; private static final String DB_NAME_TEMPLATE = "{}" + UNIT_TEST_ID + "{}"; private static final String DB_SHORT_NAME_TEMPLATE = "{}" + UNIT_TEST_ID_SHORT + "{}"; // Invalid characters for MongoDB database names (union of invalid characters for Windows and Linux/Unix) // Linux/Unix invalid characters: /\. "$ // Windows invalid characters: /\. "$*<>:|? // See https://docs.mongodb.com/manual/reference/limits/#naming-restrictions private static final String INVALID_DB_NAME_CHARS = "/\\. \"$*<>:|?"; // Minimum expected length for timestamps returned by System.currentTimeMillis() private static final int MIN_TIMESTAMP_LENGTH = 13; String hostName; int port; String serviceName; String serviceHost; ServiceHostDomain serviceHostDomain; String databaseName; String uri; /** * Should the domain be kept or stripped in service host names? */ public enum ServiceHostDomain { /** * Keep the domain name, e.g. {@code service1.acme.com} stays as-is. */ KEEP, /** * Strip the domain name, e.g. {@code service1.acme.com} and {@code service1.test} both become {@code service1} */ STRIP } /** * Constructs a new instance. *

* Use the fluent builder as an alternative to this constructor. * * @param hostName the host where MongoDB is located * @param port the port that MongoDB is listening on * @param serviceName the name of the service/application being tested * @param serviceHost the host of the service/application being tested * @param serviceHostDomain how to handle domains in the given {@code serviceHost} * (defaults to {@link ServiceHostDomain#STRIP STRIP} if this argument is {@code null}) */ @Builder public MongoTestProperties(String hostName, int port, String serviceName, String serviceHost, @Nullable ServiceHostDomain serviceHostDomain) { this.hostName = requireNotBlank(hostName); this.port = requireValidPort(port); this.serviceName = requireNotBlank(serviceName); var nonNullServiceHostDomain = isNull(serviceHostDomain) ? ServiceHostDomain.STRIP : serviceHostDomain; this.serviceHostDomain = nonNullServiceHostDomain; var normalizedServiceHost = serviceHost(requireNotBlank(serviceHost), nonNullServiceHostDomain); this.serviceHost = normalizedServiceHost; this.databaseName = unitTestDatabaseName(serviceName, normalizedServiceHost); this.uri = mongoUri(hostName, port, databaseName); } private static int requireValidPort(int port) { checkArgument(port >= 0 && port <= 65_535, "invalid port: must be in range [0, 65535]"); return port; } private static String serviceHost(String serviceHost, ServiceHostDomain serviceHostDomain) { checkArgumentNotNull(serviceHostDomain); if (serviceHostDomain == ServiceHostDomain.KEEP) { return serviceHost; } return KiwiUrls.extractSubDomainNameFrom(serviceHost).orElseThrow(); } @VisibleForTesting static String unitTestDatabaseName(String serviceName, String serviceHost) { var now = System.currentTimeMillis(); var dbName = f(DEFAULT_DB_NAME_TEMPLATE, serviceName, serviceHost, now); // If name exceeds the limit of 63 characters, try removing the subdomain from hostname if (nameExceedsMongoLength(dbName)) { var subDomain = KiwiUrls.extractSubDomainNameFrom(serviceHost).orElse(""); dbName = f(DEFAULT_DB_NAME_TEMPLATE, serviceName, subDomain, now); } // If name still exceeds the limit, try removing the entire hostname if (nameExceedsMongoLength(dbName)) { dbName = f(DB_NAME_TEMPLATE, serviceName, now); } // If name still exceeds limit, try using the short ID if (nameExceedsMongoLength(dbName)) { dbName = f(DB_SHORT_NAME_TEMPLATE, serviceName, now); } // If name still exceeds limit, chop service name down to 46 characters. This also assumes the short test ID // is 4 characters long, such that the total length is (46 + 4 + 13) = 63 // NOTE: 46 characters is accurate only so long as System.currentTimeMillis() returns a number 13 digits long, // which means we're good through Sat Nov 20 2286 17:46:39 GMT+0000 (GMT) if (nameExceedsMongoLength(dbName)) { dbName = f(DB_SHORT_NAME_TEMPLATE, serviceName.substring(0, 46), now); } dbName = replaceInvalidDatabaseNameCharactersIfPresent(dbName); verifyDatabaseNameLength(dbName); return dbName; } private static String replaceInvalidDatabaseNameCharactersIfPresent(String dbName) { if (containsAny(dbName, INVALID_DB_NAME_CHARS)) { // Replace any invalid character with an underscore return replaceChars(dbName, INVALID_DB_NAME_CHARS, "____________"); } return dbName; } @VisibleForTesting static void verifyDatabaseNameLength(String dbName) { verify(!nameExceedsMongoLength(dbName), "Unexpected error: DB name must be less than 64 characters in length, but was %s: %s", dbName.length(), dbName); } private static boolean nameExceedsMongoLength(String dbName) { return dbName.length() >= 64; } private static String mongoUri(String dbHostName, int dbPort, String databaseName) { var hostAndPort = splitOnCommas(dbHostName) .stream() .map(hostname -> f("{}:{}", hostname, dbPort)) .collect(joining(",")); var optionalReplicaSet = dbHostName.contains(",") ? "?replicaSet=rs0" : ""; return f("mongodb://{}/{}{}", hostAndPort, databaseName, optionalReplicaSet); } /** * Create a new Mongo client for the test database described by the properties in this instance. * * @return a new {@link MongoClient} */ public MongoClient newMongoClient() { return MongoClients.create(uri); } /** * Get the database name without the trailing underscore plus timestamp. *

* Example: If the database name is {@code test-service_unit_test_host1_1602375491864}, then this method * returns {@code test-service_unit_test_host1}. * * @return the database name without the timestamp */ public String getDatabaseNameWithoutTimestamp() { return databaseNameWithoutTimestamp(databaseName); } /** * Do a basic check whether the given database name contains either {@link #UNIT_TEST_ID} or * {@link #UNIT_TEST_ID_SHORT}, which is a pretty good indicator that the database name was created * by this class. If the name contains either of those, then also check for a timestamp as the last * part in the name following the last underscore, i.e. find the last underscore and check if the remainder * of the name contains only digits. * * @param databaseName the database name to check * @return true if the given database name looks like a name that was created by this class */ public static boolean looksLikeTestDatabaseName(String databaseName) { var containsUnitTestInName = databaseName.contains(UNIT_TEST_ID) || databaseName.contains(UNIT_TEST_ID_SHORT); if (!containsUnitTestInName) { return false; } var index = databaseName.lastIndexOf('_'); verify(index >= 0, "database name should have two or more underscores unless UNIT_TEST_ID or UNIT_TEST_ID_SHORT were changed!"); var lastNameSegment = databaseName.substring(index + 1); // Check the last segment is all digits and its length is at least 13 digits, since System.currentTimeMillis // currently returns a 13 digit number, and will continue to do so until the year 2286. After that it will // be 14 digits long! return lastNameSegment.length() >= MIN_TIMESTAMP_LENGTH && NumberUtils.isDigits(lastNameSegment); } /** * Static utility to get the database name without the timestamp. Performs minimal error checking on the * database name. Expects the timestamp to be immediately after the last underscore. *

* Example: If the database name is {@code test-service_unit_test_host1_1602375491864}, then this method * returns {@code test-service_unit_test_host1}. * * @param databaseName the database name * @return the database name without the timestamp * @throws IllegalArgumentException if the databaseName is blank or does not have any underscores */ public static String databaseNameWithoutTimestamp(String databaseName) { var lastUnderscoreIndex = getLastUnderscoreIndex(databaseName); return databaseName.substring(0, lastUnderscoreIndex); } /** * Get the database timestamp. *

* Example: If the database name is {@code test-service_unit_test_host1_1602375491864}, then this method * returns {@code 1602375491864}. * * @return the timestamp */ public long getDatabaseTimestamp() { return extractDatabaseTimestamp(databaseName); } /** * Static utility to extract the database timestamp from the given database name. Performs minimal error checking * on the database name. Expects the timestamp to be immediately after the last underscore. *

* Example: If the database name is {@code test-service_unit_test_host1_1602375491864}, then this method * returns {@code 1602375491864}. * * @param databaseName the database name * @return the timestamp * @throws IllegalArgumentException if the databaseName is blank or does not have any underscores * @throws NumberFormatException if the extracted "timestamp" cannot be parsed into a {@code long} */ public static long extractDatabaseTimestamp(String databaseName) { var lastUnderscoreIndex = getLastUnderscoreIndex(databaseName); return Long.parseLong(databaseName.substring(lastUnderscoreIndex + 1)); } private static int getLastUnderscoreIndex(String databaseName) { checkArgumentNotBlank(databaseName, "databaseName cannot be blank"); checkArgument(!databaseName.endsWith("_"), "databaseName cannot end with an underscore"); var lastUnderscoreIndex = databaseName.lastIndexOf('_'); checkArgument(lastUnderscoreIndex > -1, "databaseName does not have correct format"); return lastUnderscoreIndex; } }





© 2015 - 2024 Weber Informatics LLC | Privacy Policy