diff --git a/.gitignore b/.gitignore index 6d706b8..9a45dd0 100644 --- a/.gitignore +++ b/.gitignore @@ -15,3 +15,15 @@ buildNumber.properties .project # JDT-specific (Eclipse Java Development Tools) .classpath + +# Temporary local libraries folder +lib/ + +# IntelliJ IDEA +.idea/ + +# IDEA's code coverage report location +htmlReport/ + +# macOS annoying dotfiles. +**/.DS_Store \ No newline at end of file diff --git a/pom.xml b/pom.xml index 74c6e1f..e44a28d 100644 --- a/pom.xml +++ b/pom.xml @@ -3,63 +3,180 @@ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/maven-v4_0_0.xsd"> - 4.0.0 + 4.0.0 - com.tractionsoftware.commons - tractionsoftware-commons - 3.1 - Traction Software Commons - 2016 - - A collection of general helper classes for Java. - - https://tractionsoftware.com/ + com.tractionsoftware.commons + tractionsoftware-commons + 4.0.0-SNAPSHOT + Traction Software Commons + 2016 + + A collection of general helper classes for Java. + + https://tractionsoftware.com/ - - - Dave Shepperton - shep - shep@tractionsoftware.com - Traction Software, Inc. - - Java Developer - - - + + + Dave Shepperton + shep + shep@tractionsoftware.com + Traction Software, Inc. + + Java Developer + + + - - - com.google.guava - guava - 33.4.8-jre - - - org.apache.commons - commons-lang3 - 3.18.0 - - - - 21 - 21 - UTF-8 - UTF-8 - + + 26 + 26 + 26 + UTF-8 + UTF-8 + 2.0.18 + 33.6.0-jre + 3.20.0 + - - - - org.apache.maven.plugins - maven-jar-plugin - 3.4.2 - - - src/main/resources/META-INF/MANIFEST.MF - - - - - + + + + org.slf4j + slf4j-api + ${slf4j.version} + + + com.google.guava + guava + ${guava.version} + + + org.apache.commons + commons-lang3 + ${commons-lang3.version} + + + org.apache.commons + commons-text + 1.15.0 + + + commons-io + commons-io + 2.22.0 + + + commons-codec + commons-codec + 1.22.0 + + + jakarta.annotation + jakarta.annotation-api + 3.0.0 + + + jakarta.activation + jakarta.activation-api + 2.1.4 + + + jakarta.mail + jakarta.mail-api + 2.1.5 + compile + + + org.eclipse.angus + jakarta.mail + 2.0.5 + runtime + + + org.threeten + threeten-extra + 1.8.0 + compile + + + + com.tractionsoftware.heif-reader + heif-reader + 1.0.1 + system + ${project.basedir}/lib/heif-reader-1.0.1.jar + + + com.tractionsoftware.httpclient-wrappers + tractionsoftware-httpclient-wrappers-api + 1.0.1 + system + ${project.basedir}/lib/tractionsoftware-httpclient-wrappers-api-1.0.1.jar + + + com.tractionsoftware.httpclient-wrappers + tractionsoftware-httpclient-wrappers-apache-hc + 1.0.1 + system + ${project.basedir}/lib/tractionsoftware-httpclient-wrappers-apache-hc-1.0.1.jar + + + + + + net.sourceforge.htmlcleaner + htmlcleaner + 2.29 + compile + + + + + org.mp4parser + isoparser + 1.9.56 + runtime + + + + org.junit.jupiter + junit-jupiter-api + 6.1.0 + test + + + org.slf4j + slf4j-simple + ${slf4j.version} + test + + + + + + + org.apache.maven.plugins + maven-jar-plugin + 3.5.0 + + + src/main/resources/META-INF/MANIFEST.MF + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.5.6 + + + warn + + false + + + + diff --git a/src/main/java/com/tractionsoftware/commons/codec/Base64Util.java b/src/main/java/com/tractionsoftware/commons/codec/Base64Util.java new file mode 100644 index 0000000..035fdb1 --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/codec/Base64Util.java @@ -0,0 +1,305 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.codec; + +import com.tractionsoftware.commons.io.IOUtil; +import com.tractionsoftware.commons.lang.StringUtil; +import jakarta.annotation.Nullable; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.nio.charset.Charset; +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +public final class Base64Util { + + private static final Logger LOGGER = LoggerFactory.getLogger(Base64Util.class); + + private Base64Util() { + } + + /** + * Returns the requested {@link Base64.Decoder} instance. + * + * @param mime + * whether the MIME-style {@link Base64.Decoder} (RFC 2045) is required. + * @return the requested {@link Base64.Decoder} instance. + */ + public static final Base64.Decoder getDecoder(boolean mime) { + if (mime) { + return Base64.getMimeDecoder(); + } + return Base64.getDecoder(); + } + + /** + * Returns a MIME-style {@link Base64.Decoder} (RFC 2045) instance. + * + * @return a MIME-style {@link Base64.Decoder} (RFC 2045) instance. + */ + public static final Base64.Decoder getMimeDecoder() { + return Base64.getMimeDecoder(); + } + + /** + * Decodes the base-64 data encoded in the given input String. + * + * @param encodedStr + * the String containing the base-64 encoded representation of the bytes. + * @return the bytes encoded in the base-64 encoding input String, if they represent a valid base-64 encoding; null + * otherwise. + */ + public static final byte[] getDecodedBytes(@Nullable String encodedStr) { + return getDecodedBytes(encodedStr, false); + } + + /** + * Decodes the base-64 data encoded in the given input String. + * + * @param encodedStr + * the String containing the base-64 encoded representation of the bytes. + * @param mime + * indicates whether the MIME-style decoder (RFC 2045) should be used. + * @return the bytes encoded in the base-64 encoding input String, if they represent a valid base-64 encoding; null + * otherwise. + */ + public static final byte[] getDecodedBytes(@Nullable String encodedStr, boolean mime) { + if (encodedStr == null) { + return null; + } + try { + return getDecoder(mime).decode(encodedStr.getBytes()); + } + catch (RuntimeException e) { + LOGGER.warn( + "Failed to decode base 64 string value {}", StringUtil.truncatedToStringForLog(encodedStr, 100), e + ); + } + return null; + } + + public static byte[] getDecodedBytes(byte[] encoding) { + return getDecodedBytes(encoding, false); + } + + public static byte[] getDecodedBytes(byte[] encoding, boolean mime) { + if (encoding == null) { + return null; + } + try { + return getDecoder(mime).decode(encoding); + } + catch (RuntimeException e) { + LOGGER.warn("Failed to decode base 64 bytes", e); + return ArrayUtils.EMPTY_BYTE_ARRAY; + } + } + + public static String getDecodedString(byte[] encoding) { + return getDecodedString(encoding, StandardCharsets.UTF_8, false); + } + + public static String getDecodedString(byte[] encoding, Charset charset, boolean mime) { + byte[] bytes = getDecodedBytes(encoding, mime); + if (bytes == null) { + return null; + } + return new String(bytes, charset); + } + + /** + * Creates a String from the UTF-8 encoded bytes represented by the given base-64 encoded String. + * + * @param encodedStr + * the String containing the base-64 encoded representation of the bytes. + * @return a String from the UTF-8 encoded bytes represented by the given base-64 encoded String. + */ + public static String getUtf8DecodedString(String encodedStr) { + return getUtf8DecodedString(encodedStr, false); + } + + public static String getUtf8DecodedString(String encodedStr, boolean mime) { + return getDecodedString(encodedStr, StandardCharsets.UTF_8, mime); + } + + public static String getDecodedString(String encodedStr, Charset charset, boolean mime) { + if (encodedStr == null) { + return null; + } + return new String(getDecodedBytes(encodedStr, mime), charset); + } + + public static void printUtf8DecodedString(String encodedStr, boolean mime, PrintWriter out) { + printDecodedString(encodedStr, StandardCharsets.UTF_8, mime, out); + } + + /** + * Decodes the given String from Base64 and + * + * @param encodedStr + * representing the Base64 encoding. + * @param charset + * the Charset that should be used to interpret the Base64 bytes. + * @param mime + * whether mime decoding should be used. + * @param out + * to which the decoded String should be written. + */ + public static void printDecodedString(String encodedStr, Charset charset, boolean mime, Writer out) { + if (StringUtils.isEmpty(encodedStr)) { + return; + } + try (Reader reader = getDecodingReader(encodedStr, charset, mime)) { + reader.transferTo(out); + } + catch (IOException e) { + LOGGER.warn("There was a problem attempting to Base64 decode a string ({})", charset, e); + } + } + + public static Base64.Encoder getEncoder(int bytesPerLine) { + if (bytesPerLine <= 0) { + return Base64.getEncoder(); + } + return Base64.getMimeEncoder(bytesPerLine, new byte[] { '\n' }); + } + + public static Base64.Encoder getDefaultMimeEncoder() { + return getEncoder(80); + } + + /** + * Creates a base-64 encoded String representation of the input byte array using the default number of bytes per + * line. + * + * @param bytes + * the bytes to be encoded. + * @return the base-64 encoded String representation of the input byte array using the default number of bytes per + * line. + */ + public static byte[] getEncodedBytes(byte[] bytes) { + if (bytes == null) { + return null; + } + return Base64.getEncoder().encode(bytes); + } + + public static byte[] getEncodedBytes(byte[] bytes, int bytesPerLine) { + if (bytes == null) { + return null; + } + return getEncoder(bytesPerLine).encode(bytes); + } + + public static String getEncodedString(byte[] bytes) { + if (bytes == null) { + return null; + } + return Base64.getEncoder().encodeToString(bytes); + } + + /** + * Creates a base-64 encoded String representation of the input byte array using the requested number of bytes per + * line. + * + * @param bytes + * the bytes to be encoded. + * @param bytesPerLine + * the requested number of bytes per line in the output String. After the given number of bytes, a CR/LF + * sequence will be inserted. Pass Integer.MAX_VALUE to omit CR/LF sequences entirely. The requested number of + * bytes per line may be rounded (e.g., to the nearest multiple of 4). + * @return the base-64 encoded String representation of the input byte array using requested number of bytes per + * line. + */ + public static String getEncodedString(byte[] bytes, int bytesPerLine) { + if (bytes == null) { + return null; + } + return getEncoder(bytesPerLine).encodeToString(bytes); + } + + /** + * Creates a base-64 encoded String representation of the bytes in the given input String as encoded in the UTF-8 + * character set. + * + * @param input + * the input String whose UTF-8 bytes are to be encoded in the output String. + * @return a base-64 encoded String representation of the bytes in the given input String as encoded in the UTF-8 + * character set. + */ + public static String getUtf8EncodedString(String input) { + return getUtf8EncodedString(input, -1); + } + + public static String getUtf8EncodedString(String input, int bytesPerLine) { + if (input == null) { + return null; + } + return getEncodedString(input, StandardCharsets.UTF_8, bytesPerLine); + } + + public static String getEncodedString(String input, Charset charset, int bytesPerLine) { + return getEncodedString(input.getBytes(charset), bytesPerLine); + } + + public static void printUtf8EncodedString(String str, int bytesPerLine, PrintWriter out) { + printEncodedString(str, StandardCharsets.UTF_8, bytesPerLine, out); + } + + public static void printEncodedString(String str, Charset charset, int bytesPerLine, PrintWriter out) { + if (str != null) { + printEncodedString(str.getBytes(charset), bytesPerLine, out); + } + } + + public static void printEncodedString(byte[] strBytes, int bytesPerLine, PrintWriter out) { + if (ArrayUtils.isEmpty(strBytes)) { + return; + } + try (OutputStream outStream = getEncodingOutputStream(bytesPerLine, out)) { + outStream.write(strBytes); + } + catch (IOException e) { + LOGGER.warn("Failed to encode base 64 bytes", e); + } + } + + private static OutputStream getEncodingOutputStream(int bytesPerLine, PrintWriter out) throws IOException { + return getEncoder(bytesPerLine).wrap( + IOUtil.getPrintWriterOutputStream(out, false) + ); + } + + private static InputStream getDecodingInputStream(String encodedStr, Charset charset, boolean mime) { + return getDecoder(mime).wrap(IOUtil.getStringAsInputStream(encodedStr, charset)); + } + + private static Reader getDecodingReader(String encodedStr, Charset charset, boolean mime) throws IOException { + return new InputStreamReader( + getDecodingInputStream(encodedStr, charset, mime), charset + ); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/codec/HtmlEncodingUtil.java b/src/main/java/com/tractionsoftware/commons/codec/HtmlEncodingUtil.java new file mode 100644 index 0000000..be2575c --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/codec/HtmlEncodingUtil.java @@ -0,0 +1,424 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.codec; + +import com.tractionsoftware.commons.io.StringWriteUtil; +import com.tractionsoftware.commons.lang.StringUtil; +import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper; +import com.tractionsoftware.commons.text.TextWrapUtil; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.PrintWriter; +import java.util.Objects; +import java.util.regex.Pattern; + +public final class HtmlEncodingUtil { + + /** + * Not instantiable. + */ + private HtmlEncodingUtil() { + } + + private static final Logger LOGGER = LoggerFactory.getLogger(HtmlEncodingUtil.class); + + public enum SimpleHtmlEntity { + + QUOTATION_MARK('"', "quot"), + + GREATER_THAN('>', "gt"), + + LESS_THAN('<', "lt"), + + AMPERSAND('&', "amp"), + + NON_BREAKING_SPACE(' ', "nbsp"); + + public static final SimpleHtmlEntity getForLiteral(char c) { + return switch (c) { + case '>' -> SimpleHtmlEntity.GREATER_THAN; + case '<' -> SimpleHtmlEntity.LESS_THAN; + case '&' -> SimpleHtmlEntity.AMPERSAND; + default -> null; + }; + } + + public static final SimpleHtmlEntity getForTagAttributeValue(char c) { + return switch (c) { + case '"' -> QUOTATION_MARK; + case '>' -> GREATER_THAN; + case '<' -> LESS_THAN; + case '&' -> AMPERSAND; + default -> null; + }; + } + + public static final SimpleHtmlEntity getForClassicConversion(char c) { + return switch (c) { + case '"' -> QUOTATION_MARK; + case '>' -> GREATER_THAN; + case '<' -> LESS_THAN; + case '&' -> AMPERSAND; + case ' ', StringUtil.CHAR_NON_BREAKING_SPACE -> NON_BREAKING_SPACE; + default -> null; + }; + } + + public static final SimpleHtmlEntity get(char c) { + return switch (c) { + case '"' -> QUOTATION_MARK; + case '>' -> GREATER_THAN; + case '<' -> LESS_THAN; + case '&' -> AMPERSAND; + case StringUtil.CHAR_NON_BREAKING_SPACE -> NON_BREAKING_SPACE; + default -> null; + }; + } + + public static final SimpleHtmlEntity get(String s, int index) { + + char c = s.charAt(index); + if (Character.isSurrogate(c) || c != '&') { + return null; + } + + int remaining = s.length() - index; + if (remaining < 4) { + // Too short for any of these. + return null; + } + + for (SimpleHtmlEntity entity : SimpleHtmlEntity.values()) { + if (s.regionMatches(index, entity.encoding, 0, entity.encoding.length())) { + return entity; + } + } + return null; + + } + + public static final String encodeForLiteral(char c) { + SimpleHtmlEntity entity = getForLiteral(c); + if (entity == null) { + return null; + } + return entity.encoding; + } + + public static final String encodeForTagAttributeValue(char c) { + SimpleHtmlEntity entity = getForTagAttributeValue(c); + if (entity == null) { + return null; + } + return entity.encoding; + } + + public static String encodeForClassicHtmlText(char c) { + SimpleHtmlEntity entity = getForClassicConversion(c); + if (entity == null) { + return null; + } + return entity.encoding; + } + + public static final String escapeAmpersand(char c) { + if (c == '&') { + return AMPERSAND.encoding; + } + return null; + } + + private final char value; + + private final String name; + + private final String encoding; + + private SimpleHtmlEntity(char value, String name) { + this.value = value; + this.name = name; + this.encoding = "&" + name + ";"; + } + + public final void append(StringBuilder buff) { + buff.append(encoding); + } + + public final void print(PrintWriter out) { + out.print(encoding); + } + + public final void appendValue(StringBuilder buff) { + buff.append(value); + } + + public final String getEncoding() { + return encoding; + } + + public final int nameLength() { + return name.length(); + } + + } + + private static final class HtmlLiteralAppendableWrapper implements Appendable { + + private final String preferredZeroWidthSpace; + + private final Appendable out; + + private HtmlLiteralAppendableWrapper(Appendable out, String preferredZeroWidthSpace) { + this.out = out; + this.preferredZeroWidthSpace = preferredZeroWidthSpace; + } + + @Override + public final Appendable append(CharSequence csq) { + appendLiteralText(csq); + return this; + } + + @Override + public final Appendable append(CharSequence csq, int start, int end) { + appendLiteralText(csq.subSequence(start, end)); + return this; + } + + @Override + public final Appendable append(char c) { + String literalReplacement = encodeForLiteral(c); + if (literalReplacement != null) { + StringWriteUtil.safeAppend(out, literalReplacement); + } + else { + StringWriteUtil.safeAppend(out, c); + } + return this; + } + + private final void appendLiteralText(CharSequence text) { + if (StringUtils.isNotEmpty(text)) { + CharBasedFilteringTextMapper.replace(text, out, this::encodeForLiteral); + } + } + + private final String encodeForLiteral(char c) { + if (c == StringUtil.CHAR_ZERO_WIDTH_SPACE) { + return preferredZeroWidthSpace; + } + return SimpleHtmlEntity.encodeForLiteral(c); + } + + } + + + public static final String TAG_BR = "
"; + + public static final String DEFAULT_NON_SPACE_BREAK_HTML = ""; + + public static final String ZERO_WIDTH_SPACE_ENTITY_ENCODING = "​"; + + /** + * , . / \ | - % ) & > < " + */ + private static final Pattern NON_SPACE_BREAK_OPPORTUNITIES = + Pattern.compile("([,./\\\\|\\-%)]|&(amp|gt|lt|quot);)(\\S)"); + + /** + * Applies the minimal amount of entity-encoding necessary for the given plain text to appear in literal form in an + * HTML document. This requires entity-encoding greater than, less than, and ampersand characters. + * + * @param text + * some plain text that will be appearing in an HTML document. + * @return a version of the given plain text with any tag delimiters and ampersands entity-encoded; or null if the + * given text is null. + */ + public static final String getLiteralText(CharSequence text) { + if (text == null) { + return null; + } + if (text.isEmpty()) { + return ""; + } + return CharBasedFilteringTextMapper.replace(text.toString(), SimpleHtmlEntity::encodeForLiteral); + } + + /** + * Converts text to HTML in the same manner as {@link #getLiteralText(CharSequence)}, but including BR tags in place + * of line breaks. The line breaks are identified via {@link StringUtil#getLines(CharSequence)}, and the conversion + * of the individual lines' text is does with getLiteralText. + * + * @param text + * the text to be converted. + * @return some HTML representing the given text converted to HTML-safe text, plus substituting BR tags for line + * breaks. + */ + public static final String getLiteralTextWithLineBreaks(CharSequence text) { + if (StringUtils.isEmpty(text)) { + return ""; + } + return StringUtil.join(StringUtil.getLines(getLiteralText(text)).iterator(), TAG_BR); + } + + public static final void printLiteralTextWithLineBreaks(Appendable out, CharSequence text) { + if (StringUtils.isEmpty(text)) { + return; + } + try { + StringUtil.getNullSkippingJoiner(TAG_BR).appendTo( + out, StringUtil.getLines(getLiteralText(text)).iterator() + ); + } + catch (IOException e) { + // This is not possible. + LOGGER.error("This exception should not happen", e); + } + } + + /** + * Returns a version of the given text that is safe for an HTML attribute value. This requires entity-encoding + * greater than, less than, double quotation mark, and ampersand characters. + * + * @param text + * the text to be encoded in an HTML-safe manner. + * @return a version of the given text that is safe for an HTML attribute value. + */ + public static final String getTagAttributeValue(CharSequence text) { + if (text == null) { + return null; + } + if (text.isEmpty()) { + return text.toString(); + } + return Objects.toString( + CharBasedFilteringTextMapper.replace(text, SimpleHtmlEntity::encodeForTagAttributeValue), null + ); + } + + /** + * Converts the given text to HTML, including the non-literal and usually unnecessary conversion spaces to + * non-breaking spaces. + * + *

+ * This method exists to support a handful of classic forms and a few other old use cases. It should not be used for + * new code. + * + * @param text + * the text to be converted to HTML. + * @return a conversion of the given text to HTML, including the non-literal and usually unnecessary conversion + * spaces to non-breaking spaces. + */ + public static final String getClassicHtmlText(String text) { + if (StringUtils.isBlank(text)) { + return text; + } + return Objects.toString( + CharBasedFilteringTextMapper.replace(text, SimpleHtmlEntity::encodeForClassicHtmlText), null + ); + } + + /** + * Reverses the encoding that is performed by {@link #getClassicHtmlText(String)}, translating the given HTML to + * text, including the non-literal conversion of non-breaking space entities to ordinary spaces. + * + * @param html + * the HTML to be converted to text. + * @return the decoded version of the string. + */ + public static final String getClassicTextHtml(String html) { + + if (StringUtils.isEmpty(html)) { + return html; + } + + int len = html.length(); + StringBuilder buff = null; + for (int i = 0; i < len; i++) { + SimpleHtmlEntity entity = SimpleHtmlEntity.get(html, i); + if (entity == null) { + if (buff != null) { + buff.append(html.charAt(i)); + } + } + else { + if (buff == null) { + buff = new StringBuilder(len); + buff.append(html, 0, i); + } + entity.appendValue(buff); + i += entity.nameLength() + 1; + } + } + + if (buff == null) { + return html; + } + return buff.toString(); + + } + + /** + * Returns the preferred non-space optional break HTML sequence for the UserAgent being used for the current + * request, defaulting to "". + * + * @return the preferred non-space optional break HTML sequence for the UserAgent being used for the current + * request, defaulting to "". + */ + public static final String getNonSpaceBreaksHtml() { + return System.getProperty( + "com.tractionsoftware.commons.codec.non_space_break_html", DEFAULT_NON_SPACE_BREAK_HTML + ); + } + + /** + * Inserts the preferred non-space optional break HTML sequence for the UserAgent being used for the current request + * where appropriate in the given text. + * + * @param text + * the text into which the non-space optional break HTML should be inserted. This must really be pure text and + * not contain any markup, since markup might be corrupted by this insertion process. + * @return the given text with the preferred non-space optional break HTML sequence for the UserAgent being used for + * the current request inserted where appropriate. + */ + public static final String getHtmlWithNonSpaceBreaks(String text) { + if (text == null) { + return null; + } + return NON_SPACE_BREAK_OPPORTUNITIES.matcher(text).replaceAll("$1" + getNonSpaceBreaksHtml() + "$3"); + } + + public static final Appendable getLiteralAppendable(Appendable out, String preferredZeroWidthSpace) { + Objects.requireNonNull(out, "output"); + if (StringUtils.isBlank(preferredZeroWidthSpace)) { + preferredZeroWidthSpace = TextWrapUtil.DEFAULT_ZERO_WIDTH_SPACE; + } + if (out instanceof HtmlLiteralAppendableWrapper literal && + Objects.equals(literal.preferredZeroWidthSpace, preferredZeroWidthSpace)) { + return literal; + } + return new HtmlLiteralAppendableWrapper(out, preferredZeroWidthSpace); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/codec/JavaScriptEncodingUtil.java b/src/main/java/com/tractionsoftware/commons/codec/JavaScriptEncodingUtil.java new file mode 100644 index 0000000..30c1014 --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/codec/JavaScriptEncodingUtil.java @@ -0,0 +1,247 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.codec; + +import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper; +import org.apache.commons.lang3.StringUtils; + +/** + * Provides a handful of helpers related to JavaScript encoding. + * + * @author Andy Keller, Dave Shepperton + */ +public final class JavaScriptEncodingUtil { + + public static final String JAVASCRIPT_FILE_EXTENSION = ".js"; + + public enum CharacterRequiringEscaping { + + APOSTROPHE('\'', true), + + QUOTATION_MARK('"', true), + + BACKSLASH('\\'), + + SLASH('/'), + + NEW_LINE('n'), + + CARRIAGE_RETURN('r'), + + TAB('t'), + + FORM_FEED('f'), + + BACKSPACE('b'); + + public static CharacterRequiringEscaping get(char c) { + return switch (c) { + case '\'' -> APOSTROPHE; + case '"' -> QUOTATION_MARK; + case '\\' -> BACKSLASH; + case '/' -> SLASH; + case '\n' -> NEW_LINE; + case '\r' -> CARRIAGE_RETURN; + case '\t' -> TAB; + case '\f' -> FORM_FEED; + default -> null; + }; + } + + public static String getRequiredEscapeSequence(char c) { + CharacterRequiringEscaping value = get(c); + if (value == null) { + return null; + } + return value.escapeSequence; + } + + private final String escapeSequence; + + private final boolean quotationMark; + + CharacterRequiringEscaping(char escapingChar) { + this(escapingChar, false); + } + + CharacterRequiringEscaping(char escapingChar, boolean quotationMark) { + this.escapeSequence = "\\" + escapingChar; + this.quotationMark = quotationMark; + } + + @Override + public final String toString() { + return name() + " (" + escapeSequence + ")"; + } + + public final String getEscapeSequence() { + return escapeSequence; + } + + public final boolean isQuotationMark() { + return quotationMark; + } + + } + + private JavaScriptEncodingUtil() { + } + + /** + * Returns a version of the given text with all occurrences of certain characters escaped to ensure that the result + * is a valid JavaScript literal. Specifically, the following characters are escaped: + * + *

+ * + * @param text + * the input text. + * @return a version of the given text with a transformation applied escaping any appearances of the following + * characters to make the result a valid JavaScript literal. + */ + public static String getJavascriptLiteral(CharSequence text) { + return getJavascriptLiteral(text, false); + } + + /** + * Returns a version of the given text with all occurrences of certain characters escaped or entity encoded to + * ensure that the result is both a valid JavaScript literal and suitable for inclusion in an HTML document. + * + *

+ * Specifically, the following characters are escaped: + * + *

+ * + *

And the following characters are entity encoded: + * + *

+ * + * @param text + * the input text. + * @return a version of the given text with all occurrences of certain characters escaped or entity encoded the + * following characters to make the result both a valid JavaScript literal and suitable for inclusion in an HTML + * document. + */ + public static String getHtmlCompatibleJavascriptLiteral(CharSequence text) { + return getJavascriptLiteral(text, true); + } + + /** + * Returns a version of the given text with all occurrences of certain characters escaped to ensure that the result + * is a valid JSON literal; and, if an optional HTML-compatible version is requested, entity encoded to ensure that + * the result is also suitable for inclusion in an HTML document. + * + *

+ * Specifically, the following characters are escaped: + * + *

+ * + *

And the following characters are entity encoded if an HTML-compatible version is requested: + * + *

+ * + * @param text + * the input text. + * @param htmlCompatible + * indicating whether the + * @return a version of the given text with all occurrences of certain characters escaped or entity encoded the + * following characters to make the result both a valid JSON literal and suitable for inclusion in an HTML + * document. + */ + public static String getJavascriptLiteral(CharSequence text, boolean htmlCompatible) { + if (text == null) { + return null; + } + if (text.isEmpty()) { + return ""; + } + if (htmlCompatible) { + return CharBasedFilteringTextMapper.replace(text, JavaScriptEncodingUtil::getHtmlCompatibleLiteralReplacement); + } + return CharBasedFilteringTextMapper.replace(text, CharacterRequiringEscaping::getRequiredEscapeSequence); + } + + public static void printJavascriptLiteral(Appendable out, CharSequence text) { + printJavascriptLiteral(out, text, false); + } + + public static void printHtmlCompatibleJavascriptLiteral(Appendable out, CharSequence text) { + printJavascriptLiteral(out, text, true); + } + + public static void printJavascriptLiteral(Appendable out, CharSequence text, boolean htmlCompatible) { + if (StringUtils.isNotEmpty(text)) { + if (htmlCompatible) { + CharBasedFilteringTextMapper.replace(text, out, JavaScriptEncodingUtil::getHtmlCompatibleLiteralReplacement); + } + else { + CharBasedFilteringTextMapper.replace(text, out, CharacterRequiringEscaping::getRequiredEscapeSequence); + } + } + } + + private static String getHtmlCompatibleLiteralReplacement(char c) { + return switch (c) { + case '<' -> HtmlEncodingUtil.SimpleHtmlEntity.LESS_THAN.getEncoding(); + case '>' -> HtmlEncodingUtil.SimpleHtmlEntity.GREATER_THAN.getEncoding(); + case '&' -> HtmlEncodingUtil.SimpleHtmlEntity.AMPERSAND.getEncoding(); + default -> CharacterRequiringEscaping.getRequiredEscapeSequence(c); + }; + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/codec/MD5Util.java b/src/main/java/com/tractionsoftware/commons/codec/MD5Util.java new file mode 100644 index 0000000..a132ebd --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/codec/MD5Util.java @@ -0,0 +1,295 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.codec; + +import com.tractionsoftware.commons.io.FileResource; +import com.tractionsoftware.commons.io.IOUtil; +import com.tractionsoftware.commons.io.LocalFileResource; +import com.tractionsoftware.commons.lang.ObjectUtil; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import org.apache.commons.lang3.ArrayUtils; +import org.apache.commons.lang3.StringUtils; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.*; +import java.math.BigInteger; +import java.security.DigestInputStream; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; +import java.util.HexFormat; + +public final class MD5Util { + + private static final Logger LOGGER = LoggerFactory.getLogger(MD5Util.class); + + /* + * Not instantiable. + */ + private MD5Util() { + } + + public static final String ALGORITHM_NAME = "MD5"; + + public interface DigestResult { + + public boolean wasSuccessful(); + + public boolean isEmpty(); + + @Nullable + public byte[] hashBytes(); + + @Nullable + public String hashString(); + + @Nullable + public String paddedHashString(); + + } + + private static final DigestResult DIGEST_RESULT_EMPTY_SUCCESS = new DigestResult() { + + @Override + public boolean wasSuccessful() { + return true; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Nonnull + @Override + public byte[] hashBytes() { + return ArrayUtils.EMPTY_BYTE_ARRAY; + } + + @Nonnull + @Override + public String hashString() { + return ""; + } + + @Nonnull + @Override + public String paddedHashString() { + return ""; + } + + }; + + private static final DigestResult DIGEST_RESULT_EMPTY_FAILURE = new DigestResult() { + + @Override + public boolean wasSuccessful() { + return false; + } + + @Override + public boolean isEmpty() { + return true; + } + + @Override + public byte[] hashBytes() { + return null; + } + + @Override + public String hashString() { + return null; + } + + @Override + public String paddedHashString() { + return null; + } + + }; + + private static DigestResult getResult(byte[] bytes) { + if (bytes == null) { + return DIGEST_RESULT_EMPTY_FAILURE; + } + if (bytes.length == 0) { + return DIGEST_RESULT_EMPTY_SUCCESS; + } + return new NormalDigestResult(bytes); + } + + private static final class NormalDigestResult implements DigestResult { + + private final byte[] bytes; + + private String hash; + + private String paddedHash; + + private NormalDigestResult(byte[] bytes) { + this(bytes, null, null); + } + + private NormalDigestResult(byte[] bytes, String hash, String paddedHash) { + this.bytes = bytes; + this.hash = hash; + this.paddedHash = paddedHash; + } + + @Override + public boolean wasSuccessful() { + return true; + } + + @Override + public boolean isEmpty() { + return false; + } + + @Override + public byte[] hashBytes() { + return bytes; + } + + @Override + public String hashString() { + if (hash == null) { + hash = MD5Util.getHashString(bytes); + } + return hash; + } + + @Override + public String paddedHashString() { + if (paddedHash == null) { + paddedHash = getPaddedHashString(bytes); + } + return paddedHash; + } + + } + + public static MessageDigest getMessageDigest() { + try { + return MessageDigest.getInstance(ALGORITHM_NAME); + } + catch (NoSuchAlgorithmException e) { + throw new RuntimeException("This server is missing a required component."); + } + } + + public static final DigestInputStream createDigestInputStream(FileResource file) throws IOException { + return createDigestInputStream(file.getInputStream()); + } + + public static final DigestInputStream createDigestInputStream(InputStream input) throws IOException { + return new DigestInputStream(input, getMessageDigest()); + } + + public static final DigestResult digest(FileResource file) { + if (file.isDirectory()) { + return DIGEST_RESULT_EMPTY_SUCCESS; + } + return digestNonDirectoryFile(file); + } + + public static final DigestResult digest(File file) { + if (file.isDirectory()) { + return DIGEST_RESULT_EMPTY_SUCCESS; + } + return digestNonDirectoryFile(LocalFileResource.createInstance(file)); + } + + public static final DigestResult digest(byte[] data) { + try (DigestInputStream input = createDigestInputStream(new ByteArrayInputStream(data))) { + return digestImpl(input); + } + catch (IOException e) { + LOGGER.warn("Unexpected error attempting to compute an MD5 hash for bytes.", e); + return DIGEST_RESULT_EMPTY_FAILURE; + } + } + + public static final DigestResult digest(InputStream input) { + try { + if (input instanceof DigestInputStream digestStream && + digestStream.getMessageDigest().getAlgorithm().equalsIgnoreCase(ALGORITHM_NAME)) { + return digestImpl(digestStream); + } + return digestImpl(createDigestInputStream(input)); + } + catch (IOException e) { + LOGGER.warn( + "Unexpected error attempting to compute an MD5 hash for stream {}", + ObjectUtil.safeToStringObject(input), + e + ); + return DIGEST_RESULT_EMPTY_FAILURE; + } + } + + public static final String getHashString(byte[] digestedBytes) { + return HexFormat.of().formatHex(digestedBytes); + } + + public static final String getPaddedHashString(byte[] digestedBytes) { + if (digestedBytes == null) { + return null; + } + return String.format("%1$032x", new BigInteger(1, digestedBytes)); + } + + private static final DigestResult digestNonDirectoryFile(FileResource file) { + + if (file.isEmpty()) { + try { + return getInstanceForEmptyFile(file.getContentType()); + } + catch (IOException e) { + LOGGER.warn("Failed to determine MD5 for empty file {}", file, e); + return DIGEST_RESULT_EMPTY_FAILURE; + } + } + + try (DigestInputStream input = file.getDigestInputStream()) { + return digestImpl(input); + } + catch (IOException e) { + LOGGER.warn("Failed to compute MD5 for file {}", file, e); + return DIGEST_RESULT_EMPTY_FAILURE; + } + + } + + public static final DigestResult getInstanceForEmptyFile(String contentType) throws IOException { + return digest(IOUtil.getStringAsUtf8InputStream(StringUtils.trimToEmpty(contentType).toLowerCase())); + } + + private static DigestResult digestImpl(DigestInputStream digestInput) throws IOException { + try (OutputStream out = OutputStream.nullOutputStream()) { + digestInput.transferTo(out); + } + return getResult(digestInput.getMessageDigest().digest()); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/codegen/java/JavaGeneratorStringUtil.java b/src/main/java/com/tractionsoftware/commons/codegen/java/JavaGeneratorStringUtil.java new file mode 100644 index 0000000..274bc02 --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/codegen/java/JavaGeneratorStringUtil.java @@ -0,0 +1,95 @@ +package com.tractionsoftware.commons.codegen.java; + +import com.google.common.annotations.Beta; +import com.tractionsoftware.commons.text.CharBasedFilteringTextMapper; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; + +import java.io.PrintWriter; + +/** + * Helper methods for code that generates .java source files. + * + * @author Dave Shepperton + */ +@Beta +public final class JavaGeneratorStringUtil { + + private JavaGeneratorStringUtil() { + } + + public static enum CharacterRequiringEscaping { + + LINE_FEED('n'), + + CARRIAGE_RETURN('r'), + + BACKSLASH('\\'), + + DOUBLE_QUOTATION_MARK('"', true); + + @Nullable + public static final CharacterRequiringEscaping get(char c) { + return switch (c) { + case '\n' -> LINE_FEED; + case '\r' -> CARRIAGE_RETURN; + case '\\' -> BACKSLASH; + case '"' -> DOUBLE_QUOTATION_MARK; + default -> null; + }; + } + + @Nullable + public static final String getReplacement(char c) { + CharacterRequiringEscaping value = get(c); + if (value == null) { + return null; + } + return value.getEscapeSequence(); + } + + private final String escapeSequence; + + private final boolean isQuotationMark; + + CharacterRequiringEscaping(char escapingChar) { + this(escapingChar, false); + } + + CharacterRequiringEscaping(char escapingChar, boolean isQuotationMark) { + this.escapeSequence = "\\" + escapingChar; + this.isQuotationMark = isQuotationMark; + } + + @Nonnull + @Override + public final String toString() { + return name() + " (" + escapeSequence + ")"; + } + + @Nonnull + public final String getEscapeSequence() { + return escapeSequence; + } + + public final boolean isQuotationMark() { + return isQuotationMark; + } + + } + + public static final String getStringLiteral(@Nullable String str) { + if (StringUtils.isEmpty(str)) { + return str; + } + return CharBasedFilteringTextMapper.replace(str, CharacterRequiringEscaping::getReplacement); + } + + public static final void printStringLiteral(PrintWriter out, String str) { + if (StringUtils.isNotEmpty(str)) { + CharBasedFilteringTextMapper.replace(str, out, CharacterRequiringEscaping::getReplacement); + } + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/config/Configuration.java b/src/main/java/com/tractionsoftware/commons/config/Configuration.java new file mode 100644 index 0000000..0d750c2 --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/config/Configuration.java @@ -0,0 +1,162 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.config; + +import com.tractionsoftware.commons.properties.GetProperty; + +import java.util.Map; +import java.util.Objects; + +/** + * An interface representing an object type along with an associated set of configuration properties. + * + *

+ * The properties may determine the specific type itself, and may modify various behaviors of the instances to which + * they are applied. + * + * @author Dave Shepperton + */ +public interface Configuration extends GetProperty { + + /** + * Returns the logical name for this configuration. + * + * @return the logical name for this configuration. + */ + public String getName(); + + /** + * Returns the path to the file from which the Configuration properties were loaded, if applicable. + */ + public String getPath(); + + /** + * Returns the templated setting values stored in the configuration, if present. + * + * @return the templated setting values stored in the configuration, if present; null otherwise. + */ + public Map getTemplateSettings(); + + /** + * Returns a Configuration object which is identical to this one, but whose {@link #getName()} method will return + * the given name instead of this Configuration's name. + * + *

+ * If the requested name matches this Configuration's existing name, this method should generally return this + * instance. Otherwise, a new instance may be created. + * + *

+ * This default implementation uses {@link ForwardingConfiguration#withNewName(Configuration, String)}, which should + * be suitable for almost all cases. + * + * @param newName + * the requested new name. + * @return a new Configuration object which is a copy of this one, using the given name in place of this instance's + * name. + */ + public default Configuration withNewName(String newName) { + if (Objects.equals(getName(), newName)) { + return this; + } + return ForwardingConfiguration.withNewName(this, newName); + } + + /** + * This default implementation creates a {@link ConfigurationLocator}. + */ + @Override + public default Configuration withDefaults(GetProperty defaults) { + return ConfigurationLocator.getSingleConfigurationOrLocator(this, defaults); + } + + /** + * Returns a view of this Configuration that is guaranteed to supply access to the Configuration API methods only, + * or at least to methods that are deemed safe for general read-only access. For example, to pass a subclass of + * Configuration to an alien method that accepts a Configuration, and to ensure that that method cannot cast the + * argument back to the subtype in order to a to gain access to other sensitive methods, a client may invoke + * toReadOnly on that object before passing it along to that method. + * + *

+ * This default implementation returns this Configuration itself, which is a suitable implementation for subclasses + * that do not have any public methods beyond those declared in Configuration. Implementations that do have public + * methods that should not be accessible to clients that arbitrary clients when a Configuration would do should + * override it, perhaps using {@link ForwardingConfiguration#wrap(Configuration)}. Likewise, this method may be + * re-declared in subclasses using a different return type if there is a more specific safe read-only subtype that + * could be offered in place of Configuration, as this method itself overrides the declaration from + * {@link GetProperty#toReadOnly()}. + * + * @return a view of this Configuration that is guaranteed to supply access to the Configuration API methods only, + * or at least to methods that are deemed safe for general read-only access. + */ + @Override + public default Configuration toReadOnly() { + return this; + } + + /** + * This default implementation returns this Configuration instance itself, which should be suitable for all cases. + */ + @Override + public default Configuration asConfiguration() { + return this; + } + + /** + * This implementation uses {@link PropertyNameMappingConfiguration#wrapInNamespace(Configuration, String)} , which + * should be adequate for all Configuration implementations that do not need to return another specific sub-type of + * Configuration. + */ + @Override + public default Configuration getNamespace(String space) { + return PropertyNameMappingConfiguration.wrapInNamespace(this, space); + } + + /** + * This implementation uses {@link PropertyNameMappingConfiguration#wrapInNamespace(Configuration, String, char)} , + * which should be adequate for all Configuration implementations that do not need to return another specific + * sub-type of Configuration. + */ + @Override + public default Configuration getNamespace(String space, char separator) { + return PropertyNameMappingConfiguration.wrapInNamespace(this, space, separator); + } + + /** + * This implementation uses {@link PropertyNameMappingConfiguration#wrapInPrefix(Configuration, String)} , which + * should be adequate for all Configuration implementations that do not need to return another specific sub-type of + * Configuration. + */ + @Override + public default Configuration getPrefix(String prefix) { + return PropertyNameMappingConfiguration.wrapInPrefix(this, prefix); + } + + /** + * This implementation uses {@link PropertyNameMappingConfiguration#wrapInPrefix(Configuration, String, char)} , + * which should be adequate for all GetProperty implementations that do not need to return another specific sub-type + * of GetProperty. + */ + @Override + public default Configuration getPrefix(String prefix, char separator) { + return PropertyNameMappingConfiguration.wrapInPrefix(this, prefix, separator); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/config/ConfigurationException.java b/src/main/java/com/tractionsoftware/commons/config/ConfigurationException.java new file mode 100644 index 0000000..7a7748f --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/config/ConfigurationException.java @@ -0,0 +1,83 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.config; + +import java.io.Serial; + +public class ConfigurationException extends RuntimeException { + + @Serial + private static final long serialVersionUID = 3006693526768433933L; + + public static final ConfigurationException forInvalidPropertyValue(String name, String value) { + throw new ConfigurationException( + "The " + + name + + " property value (\"" + + value + + "\") does not appear to be a valid content-type specification." + ); + } + + /** + * Constructs a new ConfigurationException with no detail message. + */ + public ConfigurationException() { + super(); + } + + /** + * Constructs a new ConfigurationException with the specified detail message. + * + * @param message + * the detail message. The detail message is saved for later retrieval by the {@link #getMessage()} method. + */ + public ConfigurationException(String message) { + super(message); + } + + /** + * Constructs a new ConfigurationException with the specified cause and a detail message of + * (cause==null ? null : cause.toString()) (which + * typically contains the class and detail message of + * cause). This constructor is useful for exceptions that + * are little more than wrappers for other throwables. + * + * @param cause + * the underlying cause. + */ + public ConfigurationException(Throwable cause) { + super(cause); + } + + /** + * Constructs a new ConfigurationException with the specified detail message and cause. + * + * @param message + * the detail message. + * @param cause + * the underlying cause. + */ + public ConfigurationException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/config/ConfigurationLocator.java b/src/main/java/com/tractionsoftware/commons/config/ConfigurationLocator.java new file mode 100644 index 0000000..7f64839 --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/config/ConfigurationLocator.java @@ -0,0 +1,66 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.config; + +import java.util.Map; +import java.util.Objects; + +import com.tractionsoftware.commons.properties.AbstractGetPropertyLocator; +import com.tractionsoftware.commons.properties.GetProperty; +import jakarta.annotation.Nonnull; + +/** + * Like a PropLocator, a ConfigurationLocator allows property lookups via the getProperty method to fall back from one + * store to another. This class provides an implementation of both the Configuration interface as well as an + * implementation of PropLocator that in which a Configuration is either the primary source or the fall back source, + * depending upon which constructor is used. + */ +public final class ConfigurationLocator extends AbstractGetPropertyLocator implements Configuration { + + public static final Configuration getSingleConfigurationOrLocator(Configuration locals, GetProperty defaults) { + Objects.requireNonNull(locals, "main Configuration"); + if (defaults == null) { + return locals; + } + return new ConfigurationLocator(locals, defaults); + } + + public ConfigurationLocator(Configuration locals, GetProperty defaults) { + super(locals, defaults); + } + + @Nonnull + @Override + public final String toString() { + return "Configuration: " + super.toString(); + } + + @Override + public final String getPath() { + return locals.getPath(); + } + + @Override + public final Map getTemplateSettings() { + return locals.getTemplateSettings(); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/config/ConfiguredObject.java b/src/main/java/com/tractionsoftware/commons/config/ConfiguredObject.java new file mode 100644 index 0000000..3cd9635 --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/config/ConfiguredObject.java @@ -0,0 +1,529 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.config; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.tractionsoftware.commons.lang.EnumUtil; +import com.tractionsoftware.commons.lang.StringUtil; +import com.tractionsoftware.commons.properties.AbstractGetPropertyLocator; +import com.tractionsoftware.commons.properties.GetProperty; +import com.tractionsoftware.commons.properties.SimpleProperties; +import jakarta.annotation.Nonnull; +import jakarta.annotation.Nullable; +import org.apache.commons.lang3.StringUtils; + +import java.text.MessageFormat; +import java.util.*; + +/** + * Something that is associated with a configuration, usually in the form of a configuration file. Access is provided to + * its {@link Configuration}, and to other properties. + * + * @property display_name= + * + * This optional property is used by the default implementation of the {@link #getDisplayName()} method to provide + * the display name for this Configurable as it appears in various contexts. + * @property display_name_short= + * + * This optional property is used by the default implementation of the {@link #getShortDisplayName()} method to + * provide a short version of the display name for this Configurable to be used in certain contexts. + * @property description= and description_arg= + * + * These optional properties are used by the default implementation of the {@link #getDescription()} method to + * provide the description text and arguments for any formatting parameters that may appear in the text. + */ +public interface ConfiguredObject extends Comparable { + + /** + * The default name of the property that appears in a configuration (usually an individual .properties file, or an + * object from a .json file) to indicate the specification for the class for which the file represents a + * configuration. + */ + public static final String PROP_NAME_CLASS = "class"; + + public static final String DISPLAY_PROP_NAME_NAME = "name"; + + public static final String PROP_NAME_DISPLAY_NAME = "display_name"; + + public static final String PROP_NAME_DISPLAY_NAME_SHORT = "display_name_short"; + + public static final String PROP_NAME_DESCRIPTION = "description"; + + public static final String DESCRIPTION_ARG = "description_arg"; + + /** + * When supported, this property may be set to a comma separated list of names representing aliases for this + * Configurables. + */ + public static final String PROP_NAME_ALIASES = "aliases"; + + public static final String PROP_NAME_PRIORITY = "priority"; + + public static final int DEFAULT_PRIORITY = 0; + + public static final String PROP_NAMESPACE_PERFORMANCE_CONCERN_THRESHOLD = "performance_concern_threshold"; + + public static interface DisplayProperties extends GetProperty { + + public String getDisplayName(); + + public String getShortDisplayName(); + + public String getDescription(); + + /** + * Returns a SequencedSet of aliases -- alternative logical names -- from the given {@link Configuration}. + * + * @return a SequencedSet of aliases -- alternative logical names -- for the given {@link Configuration}. + */ + @Nonnull + public SequencedSet getAliases(); + + } + + public static class LocalDisplayProperties implements DisplayProperties { + + protected final ConfiguredObject element; + + public LocalDisplayProperties(@Nonnull ConfiguredObject element) { + Objects.requireNonNull(element, "Configurable"); + this.element = element; + } + + @Nonnull + @Override + public String toString() { + return "DisplayProperties for " + element; + } + + @Override + public String getProperty(String propName) { + return switch (propName) { + case DISPLAY_PROP_NAME_NAME -> element.getName(); + case PROP_NAME_DISPLAY_NAME -> getDisplayName(); + case PROP_NAME_DISPLAY_NAME_SHORT -> getShortDisplayName(); + case PROP_NAME_DESCRIPTION -> getDescription(); + case PROP_NAME_ALIASES -> StringUtil.join(getAliases(), ','); + case null, default -> null; + }; + } + + @Override + public final Set getPropertyNames() { + return ImmutableSet.of( + DISPLAY_PROP_NAME_NAME, PROP_NAME_DISPLAY_NAME, PROP_NAME_DISPLAY_NAME_SHORT, PROP_NAME_DESCRIPTION + ); + } + + @Override + public String getDisplayName() { + return Objects.requireNonNullElseGet( + SimpleProperties.loadString(element.getConfiguration(), PROP_NAME_DISPLAY_NAME), element::getName + ); + } + + @Override + public String getShortDisplayName() { + return Objects.requireNonNullElseGet( + SimpleProperties.loadString(element.getConfiguration(), PROP_NAME_DISPLAY_NAME_SHORT), + this::getDisplayName + ); + } + + @Override + public String getDescription() { + String description = SimpleProperties.loadString(element.getConfiguration(), PROP_NAME_DESCRIPTION); + if (description != null && hasProperty(DESCRIPTION_ARG + "0")) { + List args = SimpleProperties.loadListSeparateProperties( + element.getConfiguration(), DESCRIPTION_ARG + ); + return (new MessageFormat(description)).format(args.toArray()); + } + return description; + } + + @Nonnull + @Override + public SequencedSet getAliases() { + return SimpleProperties.loadSetSingleProperty(element.getConfiguration(), PROP_NAME_ALIASES); + } + + } + + public static class DisplayPropertiesAll extends AbstractGetPropertyLocator + implements DisplayProperties { + + public static DisplayPropertiesAll createInstance(ConfiguredObject configured) { + return new DisplayPropertiesAll( + new LocalDisplayProperties(configured), + configured.getConfiguration() + ); + } + + public DisplayPropertiesAll(LocalDisplayProperties locals, Configuration all) { + super(locals, all); + } + + @Override + public String getDisplayName() { + return locals.getDisplayName(); + } + + @Override + public String getShortDisplayName() { + return locals.getShortDisplayName(); + } + + @Override + public String getDescription() { + return locals.getDescription(); + } + + @Nonnull + @Override + public SequencedSet getAliases() { + return locals.getAliases(); + } + + } + + public static final class ByIntegerProperty implements Comparator { + + private final String propName; + + private final int defaultValue; + + private final boolean descending; + + public ByIntegerProperty(String propName, int defaultValue, boolean descending) { + this.propName = propName; + this.defaultValue = defaultValue; + this.descending = descending; + } + + @Override + public final int compare(ConfiguredObject c1, ConfiguredObject c2) { + int ret = compareByIntPropValue(c1, c2); + if (descending) { + return -ret; + } + return ret; + } + + private final int compareByIntPropValue(ConfiguredObject c1, ConfiguredObject c2) { + return Integer.compare( + SimpleProperties.loadInt(c1.getConfiguration(), propName, defaultValue), + SimpleProperties.loadInt(c2.getConfiguration(), propName, defaultValue) + ); + } + + @Override + public final boolean equals(Object other) { + if (this == other) { + return true; + } + if (other instanceof ByIntegerProperty otherComparator) { + if (propName == null) { + if (otherComparator.propName == null) { + return true; + } + return false; + } + if (otherComparator.propName == null) { + return false; + } + return propName.equals(otherComparator.propName); + } + return false; + } + + @Override + public final int hashCode() { + return Objects.hash(propName); + } + + } + + /** + * Returns the elements in the map that are instances of the given test type. Since the test type can be an + * interface, therefore not extending the main type, the test type is not bound. + * + * @param + * narrows the allowable types for the {@link ConfiguredObject}s. + * @param name2element + * the name-to-Configurable mappings to be filtered. + * @param mainType + * the super-type for all the Configurables. + * @param testType + * the sub-type for the desired Configurables. + * @return the elements in the map that are instances of the given test type. + */ + public static List getFilteredElementsByType(Map name2element, Class mainType, Class testType) { + List ret = new ArrayList<>(); + for (C e : name2element.values()) { + if (testType.isInstance(e)) { + ret.add(e); + } + } + return ret; + } + + /** + * Returns the value of the named property from the given {@link Configuration} if one is defined, and otherwise + * throws a {@link ConfigurationException}. + * + * @param config + * the {@link Configuration} from which to load the property value. + * @param name + * the name of the property to retrieve. + * @return the value of the named property from the given {@link Configuration} if one is defined. + * @throws ConfigurationException + * if the named property is not defined in the given {@link Configuration}. + */ + public static String getRequiredProperty(Configuration config, String name) { + String value = config.getProperty(name); + if (value == null) { + throw missingRequiredPropertyException(config, name); + } + return value; + } + + /** + * Returns the value of the named property from the given {@link Configuration} if one is defined and has a + * non-blank value, and otherwise throws a {@link ConfigurationException}. + * + * @param config + * the {@link Configuration} from which to load the property value. + * @param name + * the name of the property to retrieve. + * @return the value of the named property from the given {@link Configuration} if one is defined and not blank. + * @throws ConfigurationException + * if the named property is not defined in the given {@link Configuration}, or if its value is blank. + */ + public static String getRequiredNonBlankProperty(Configuration config, String name) { + String value = config.getProperty(name); + if (StringUtils.isBlank(value)) { + throw missingOrBlankRequiredPropertyException(config, name); + } + return value; + } + + /** + * Returns an Enum object of the given type, whose name corresponds to the named property from the given + * {@link Configuration}, as long as the property is defined with a non-blank value, and a matching enum value can + * be identified. Otherwise, it throws a {@link ConfigurationException}. + * + * @param config + * the {@link Configuration} from which to load the property value. + * @param name + * the name of the property to retrieve. + * @param type + * the type of Enum to be loaded. + * @return an Enum object of the given type, whose name corresponds to the named property from the given + * {@link Configuration}, as long as the property is defined with a non-blank value, and a matching enum value + * can be identified + * @throws ConfigurationException + * if the named property is not defined in the given {@link Configuration}, or if its value is blank or does not + * correspond to any of the values of the given enum. + */ + public static > E getRequiredEnumPropertyValue(Configuration config, String name, Class type) { + String rawValue = getRequiredNonBlankProperty(config, name); + E value = EnumUtil.enumFromString(type, rawValue, null); + if (value == null) { + throw noRecognizedEnumValueException(config, name, type, rawValue); + } + return value; + } + + public static List getRequiredNonEmptyListProperty(Configuration config, String name) { + List list = SimpleProperties.loadListSingleProperty(config, name); + if (list.isEmpty()) { + throw missingOrBlankRequiredPropertyException(config, name); + } + return ImmutableList.copyOf(list); + } + + public static ConfigurationException missingRequiredPropertyException(Configuration config, String name) { + return new ConfigurationException("The required property '" + config.fullyQualify(name) + "' is missing."); + } + + public static ConfigurationException missingOrBlankRequiredPropertyException(Configuration config, String name) { + return new ConfigurationException("The required property '" + + config.fullyQualify(name) + + "' is missing or empty/whitespace."); + } + + public static ConfigurationException noRecognizedEnumValueException(Configuration config, String name, Class> type, String rawValue) { + throw new ConfigurationException("No " + + type + + " value could be determined for the raw property " + + config.fullyQualify(name) + + "=" + + rawValue + + "."); + } + + public static Comparator getIntegerPropertyComparator(String propName, int defaultValue, boolean descending) { + return new ByIntegerProperty(propName, defaultValue, descending); + } + + public static Comparator getAscendingPriorityOrderComparator() { + return Comparator.comparingInt(ConfiguredObject::getPriority); + } + + public static Comparator getDescendingPriorityOrderComparator() { + return getAscendingPriorityOrderComparator().reversed(); + } + + /** + * Returns a priority, with a higher value representing a higher priority, for this Configurable. The scale with + * respect to which a priority value is produced will generally be dependent upon what type of Configurable is + * involved, and is outside the specification of this method. Not all types of Configurables will include a built-in + * concept of priority. But for those that do, the value should generally indicate a relative importance level so + * that a family of Configurables of a particular type may be sorted (in ascending or descending order, as may be + * appropriate) by priority. + * + * @return a priority, with a higher value representing a higher priority, for this Configurable. + */ + public default int getPriority() { + return SimpleProperties.loadInt(getConfiguration(), PROP_NAME_PRIORITY, DEFAULT_PRIORITY); + } + + /** + * Retrieves the {@link Configuration} associated with this Configurable, providing access to its configuration + * properties. + * + *

+ * This implementation simply returns the Configuration object supplied to the constructor. Subclasses may override + * this method if necessary, but it must never return null. + * + * @return the {@link Configuration} associated with this Configurable. + */ + @Nonnull + public Configuration getConfiguration(); + + /** + * Retrieves the name associated with this Configurable, which is usually the name of a configuration file (usually + * a .properties file) in which the Configuration corresponding to this Configurable is stored (without the file + * extension). Although Configurations can exist in different namespaces with the same name -- e.g., there can be a + * skin whose configuration is named "simple" and a digest skin whose configuration is also named "simple" -- the + * name retrieved here is not namespace qualified, as the namespace is usually known to the caller, and the name is + * merely retrieved to identify the Configurable's Configuration within whatever namespace it is known to inhabit. + * + * @return the name to be used in configuration files to identify this Configurable. + */ + public default String getName() { + return getConfiguration().getName(); + } + + /** + * Retrieves the user-friendly name that can be displayed to identify this Configurable. It should be sufficiently + * specific that a user viewing the name somewhere in a user interface can unambiguously differentiate it from all + * the others in the same collection. + * + *

+ * This default implementation returns uses the value of the display_name= configuration property, defaulting to + * {@link #getName() the logical name of this Configurable}. Subclasses should override it as necessary. + * + * @return a user-friendly name that can be displayed to identify this Configurable. + */ + public default String getDisplayName() { + return getDisplayProperties().getDisplayName(); + } + + /** + * Retrieves the short version of the user-friendly name that can be displayed to identify this Configurable. In + * contrast to {@link #getDisplayName()}, this name does not necessarily need to be completely unique across all + * Configurables in the collection to which this instance belongs. Some types of Configurables are only likely to + * appear as part of a set of other elements in which a shorter display name is enough to uniquely identify it to + * the viewing user. For types that only appear in, e.g., lists of all the elements in the collection, the short + * display name would generally be identical to the full display name. + * + *

+ * This default implementation uses the value of the display_name_short= configuration property, defaulting to + * {@link #getDisplayName()}. Subclasses should override it as necessary. + * + * @return the short version of a user-friendly name that can be displayed to identify this Configurable. + */ + public default String getShortDisplayName() { + return getDisplayProperties().getShortDisplayName(); + } + + /** + * Returns a description of the Configurable, if one is available. + * + * @return a description of the Configurable, if one is available; null otherwise. + */ + public default String getDescription() { + return getDisplayProperties().getDescription(); + } + + /** + * Represents the default ordering for Configurables in the same namespace, which is based upon a case-insensitive + * alphabetical ordering of their display names. In case of display names with the same order, the plain names are + * compared instead, which will not be equal unless either the Configurables really are the same, or the + * Configurables come from two different namespaces and happen to have the same Configuration name. (Note that + * Configurables from different namespaces are not generally -- nor should they be -- part of a single collection or + * grouping for the reason that there may be two completely different Configurables from different namespaces that + * have the same name, which would then be indistinguishable.) + * + * @param other + * the other {@link ConfiguredObject} to compare this to. + */ + @Override + public default int compareTo(@Nullable ConfiguredObject other) { + if (other == null) { + return -1; + } + int testCompare = String.CASE_INSENSITIVE_ORDER.compare(getDisplayName(), other.getDisplayName()); + if (testCompare != 0) { + return testCompare; + } + testCompare = String.CASE_INSENSITIVE_ORDER.compare(getName(), other.getName()); + if (testCompare != 0) { + return testCompare; + } + return String.CASE_INSENSITIVE_ORDER.compare(getClass().getName(), other.getClass().getName()); + } + + public default String defaultToString() { + return getClass().getName() + ":" + getName(); + } + + /** + * Returns a {@link DisplayProperties} that reflects display properties rather than raw properties. + * + *

+ * This default implementation returns a GetProperty which handles "name", "display_name", "description" and + * "configuration_file_path" by deferring to {@link #getName()}, {@link #getDisplayName()}, + * {@link #getDescription()}, and {@link Configuration#getPath()}, respectively; and otherwise, which defers to the + * {@link Configuration} provided by {@link #getConfiguration()}. Subclasses that offer more display properties + * should override this method, most likely with an implementation that uses this implementation for its default + * values (see {@link GetProperty#withDefaults(GetProperty)}). + * + * @return a {@link DisplayProperties} that reflects display properties rather than raw properties. + */ + public default DisplayProperties getDisplayProperties() { + return new DisplayPropertiesAll(new LocalDisplayProperties(this), getConfiguration()); + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/config/DynamicForwardingConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/DynamicForwardingConfiguration.java new file mode 100644 index 0000000..6fc088a --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/config/DynamicForwardingConfiguration.java @@ -0,0 +1,50 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.config; + +import jakarta.annotation.Nonnull; + +import java.util.Objects; +import java.util.function.Supplier; + +public class DynamicForwardingConfiguration extends ForwardingConfiguration { + + protected final Supplier provider; + + public DynamicForwardingConfiguration(Supplier provider) { + this.provider = provider; + } + + @Nonnull + @Override + public String toString() { + return "Configuration dyn fwd {" + provider + " -> " + provider.get() + "}"; + } + + @Nonnull + @Override + protected final Configuration delegate() { + Configuration delegate = provider.get(); + Objects.requireNonNull(delegate, "dynamic delegate Configuration"); + return delegate; + } + +} diff --git a/src/main/java/com/tractionsoftware/commons/config/ForwardingConfiguration.java b/src/main/java/com/tractionsoftware/commons/config/ForwardingConfiguration.java new file mode 100644 index 0000000..3e1dd8a --- /dev/null +++ b/src/main/java/com/tractionsoftware/commons/config/ForwardingConfiguration.java @@ -0,0 +1,129 @@ +/* + * + * Copyright 1996-2026 Traction Software, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +// PLEASE DO NOT DELETE THIS LINE - make copyright depends on it. + +package com.tractionsoftware.commons.config; + +import java.util.Map; +import java.util.Objects; +import java.util.function.Supplier; + +import com.tractionsoftware.commons.properties.ForwardingGetProperty; +import com.tractionsoftware.commons.properties.GetProperty; +import jakarta.annotation.Nonnull; + +/** + * A base {@link Configuration} implementation for implementing the decorator pattern. The {@link #delegate()} method + * returns the Configuration being decorated. + * + *

+ * Methods that offer basic Configuration functionality, such as {@link #getProperty(String)}, + * {@link #getPropertyNames()} and {@link #getName()}, defer directly the corresponding method of the backing + * Configuration. + * + *

+ * Methods that return Configuration instances are guaranteed to never directly expose either the delegate or any object + * obtained directly from the delegate. They are also guaranteed not to retrieve and use the delegate instance until one + * of the basic methods is invoked. Each of these guarantees imply that such methods cannot be implemented via direct + * deference to the delegate. The resulting implementations fall into two categories: + * + *