de.sayayi.lib.message.adopter.AbstractAnnotationAdopter Maven / Gradle / Ivy
Show all versions of message-format Show documentation
/*
* Copyright 2023 Jeroen Gremmen
*
* 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
*
* https://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.
*/
package de.sayayi.lib.message.adopter;
import de.sayayi.lib.message.MessageFactory;
import de.sayayi.lib.message.MessageSupport.ConfigurableMessageSupport;
import de.sayayi.lib.message.MessageSupport.MessagePublisher;
import de.sayayi.lib.message.annotation.MessageDef;
import de.sayayi.lib.message.annotation.TemplateDef;
import de.sayayi.lib.message.annotation.Text;
import de.sayayi.lib.message.exception.DuplicateMessageException;
import de.sayayi.lib.message.exception.DuplicateTemplateException;
import de.sayayi.lib.message.exception.MessageAdopterException;
import de.sayayi.lib.message.exception.MessageParserException;
import de.sayayi.lib.message.internal.EmptyMessage;
import de.sayayi.lib.message.internal.EmptyMessageWithCode;
import org.intellij.lang.annotations.Language;
import org.jetbrains.annotations.NotNull;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
import java.lang.annotation.Annotation;
import java.net.*;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.*;
import java.util.jar.JarFile;
import java.util.zip.ZipEntry;
import java.util.zip.ZipException;
import java.util.zip.ZipFile;
import static java.nio.file.Files.newInputStream;
import static java.util.Locale.forLanguageTag;
import static java.util.Objects.requireNonNull;
/**
* This abstract class defines various methods for adopting messages and templates defined by
* annotations.
*
* Messages are analysed per class (see {@link #parseClass(InputStream)}). If there is a
* requirement to select a part of the messages provided by a class, the message support must
* be configured with an appropriate
* {@link de.sayayi.lib.message.MessageSupport.MessageFilter MessageFilter} or
* {@link de.sayayi.lib.message.MessageSupport.TemplateFilter TemplateFilter}.
*
* Even though the annotations all have class retention, 2 adopt methods
* ({@link #adopt(MessageDef)} and {@link #adopt(TemplateDef)}) are available to analyse
* synthesized/mocked annotations.
*
* @author Jeroen Gremmen
* @since 0.8.0
*/
@SuppressWarnings({"DuplicatedCode", "UnusedReturnValue", "unused", "UnknownLanguage"})
public abstract class AbstractAnnotationAdopter extends AbstractMessageAdopter
{
private static final Set ZIP_PROTOCOLS = Set.of("zip", "jar", "war");
private final Set indexedClasses = new HashSet<>();
/**
* Create an annotation adopter for the given {@code configurableMessageSupport}.
*
* @param configurableMessageSupport configurable message support, not {@code null}
*/
protected AbstractAnnotationAdopter(
@NotNull ConfigurableMessageSupport configurableMessageSupport) {
super(configurableMessageSupport);
}
/**
* Create an annotation adopter for the given {@code messageFactory} and {@code publisher}.
*
* @param messageFactory message factory, not {@code null}
* @param publisher message publisher, not {@code null}
*/
protected AbstractAnnotationAdopter(@NotNull MessageFactory messageFactory,
@NotNull MessagePublisher publisher) {
super(messageFactory, publisher);
}
/**
* Scan the classpath (with the given packages) for message annotations and adopt them.
*
* @param classLoader classloader for locating classes, not {@code null}
* @param packageNames package names to scan, not {@code null}
*
* @return this annotation adopter, never {@code null}
*
* @throws MessageParserException in case the template could not be parsed
*/
public @NotNull AbstractAnnotationAdopter adopt(@NotNull ClassLoader classLoader,
@NotNull Set packageNames)
{
try {
for(String packageName: packageNames)
adopt_scan(classLoader, packageName);
} catch(Exception ex) {
throw new MessageAdopterException("failed to scan class path for messages and templates", ex);
}
return this;
}
private void adopt_scan(@NotNull ClassLoader classLoader, @NotNull String packageName)
throws Exception
{
String classPathPrefix = packageName.replace('.', '/');
if (!classPathPrefix.endsWith("/"))
classPathPrefix = classPathPrefix + '/';
for(final Enumeration urls = classLoader.getResources(classPathPrefix);
urls.hasMoreElements();)
{
final URL url = urls.nextElement();
if (ZIP_PROTOCOLS.contains(url.getProtocol()))
adopt_scan_zipEntries(url, classPathPrefix);
else
{
final String directory = url.getFile();
final File baseDirectory = new File(directory.endsWith(classPathPrefix)
? directory.substring(0, directory.length() - classPathPrefix.length()) : directory);
if (baseDirectory.isDirectory())
adopt_scan_directory(baseDirectory, new File(directory));
}
}
}
private void adopt_scan_directory(@NotNull File baseDirectory, @NotNull File directory)
throws IOException
{
final File[] files = directory.listFiles();
if (files != null)
{
final Path baseDirectoryPath = baseDirectory.toPath();
for(File file: files)
if (file.isDirectory())
adopt_scan_directory(baseDirectory, file);
else
{
final String classNamePath = baseDirectoryPath
.relativize(file.toPath()).toString().replace('\\', '/');
if (classNamePath.endsWith(".class") && scan_checkVisited(classNamePath))
{
try(InputStream classInputStream = newInputStream(file.toPath())) {
parseClass(classInputStream);
}
}
}
}
}
private void adopt_scan_zipEntries(@NotNull URL zipUrl, @NotNull String classPathPrefix)
throws IOException
{
final URLConnection con = zipUrl.openConnection();
final ZipFile zipFile;
if (con instanceof JarURLConnection)
zipFile = ((JarURLConnection)con).getJarFile();
else
{
final String urlFile = zipUrl.getFile();
try {
int separatorIndex = urlFile.indexOf("*/");
if (separatorIndex == -1)
separatorIndex = urlFile.indexOf("!/");
zipFile = adopt_scan_createZipFileFromUrl(separatorIndex != -1
? urlFile.substring(0, separatorIndex) : urlFile);
} catch(ZipException ex) {
return;
}
}
try {
for(Enumeration extends ZipEntry> entries = zipFile.entries(); entries.hasMoreElements();)
{
final ZipEntry zipEntry = entries.nextElement();
final String classPathName = zipEntry.getName();
if (classPathName.endsWith(".class") &&
classPathName.startsWith(classPathPrefix) &&
scan_checkVisited(classPathName))
{
try(InputStream classInputStream = zipFile.getInputStream(zipEntry)) {
parseClass(classInputStream);
}
}
}
} finally {
zipFile.close();
}
}
private @NotNull ZipFile adopt_scan_createZipFileFromUrl(@NotNull String zipFileUrl)
throws IOException
{
if (zipFileUrl.startsWith("file:"))
{
try {
return new JarFile(
new URI(zipFileUrl.replace(" ", "%20")).getSchemeSpecificPart());
} catch(URISyntaxException ex) {
return new JarFile(zipFileUrl.substring(5));
}
}
return new JarFile(zipFileUrl);
}
private boolean scan_checkVisited(@NotNull String classPathName) {
return indexedClasses.add(classPathName);
}
/**
* Adopt messages for the given {@code classFile}.
*
* @param classFile location of the class file to analyse for messages, not {@code null}
*
* @return this annotation adopter instance, never {@code null}
*
* @throws MessageParserException in case the template could not be parsed
*/
public @NotNull AbstractAnnotationAdopter adopt(@NotNull File classFile)
{
final Path classPath = classFile.toPath().toAbsolutePath();
if (!indexedClasses.contains(classPath.toString()))
{
try(final InputStream inputStream = Files.newInputStream(classPath)) {
parseClass(inputStream);
indexedClasses.add(classPath.toString());
} catch(Exception ex) {
throw new MessageAdopterException(
"failed to adopt messages and templates from class file " + classFile, ex);
}
}
return this;
}
/**
* Adopt messages for the given {@code type}.
*
* @param type type to analyse for messages, not {@code null}
*
* @return this annotation adopter instance, never {@code null}
*
* @throws MessageParserException in case the template could not be parsed
*/
public @NotNull AbstractAnnotationAdopter adopt(@NotNull Class> type)
{
final String typeName = type.getName();
if (!indexedClasses.contains(typeName))
{
final ClassLoader classLoader = type.getClassLoader();
if (classLoader != null)
{
final String classResourceName = typeName.replace('.', '/') + ".class";
try(final InputStream inputStream = classLoader.getResourceAsStream(classResourceName)) {
parseClass(requireNonNull(inputStream));
indexedClasses.add(typeName);
} catch(Exception ex) {
throw new MessageAdopterException(
"failed to adopt messages and templates from type " + typeName, ex);
}
}
}
return this;
}
/**
* Publish the message defined in the given {@link MessageDef} annotation.
*
* @param messageDef {@code MessageDef} annotation, not {@code null}
*
* @return this annotation adopter instance, never {@code null}
*
* @throws DuplicateMessageException if different messages are provided for the same locale
* @throws MessageParserException in case the template could not be parsed
*/
public @NotNull AbstractAnnotationAdopter adopt(@NotNull MessageDef messageDef)
{
final Text[] texts = messageDef.texts();
final String code = messageDef.code();
if (texts.length == 0)
{
@Language("MessageFormat") final String text = messageDef.text();
messagePublisher.addMessage(text.isEmpty()
? new EmptyMessageWithCode(code)
: messageFactory.parseMessage(code, text));
}
else
{
final Map localizedTexts = new LinkedHashMap<>();
for(final Text text: texts)
{
final String value = text.locale().isEmpty() &&
text.text().isEmpty() ? text.value() : text.text();
localizedTexts.compute(forLanguageTag(text.locale()), (locale,mappedValue) -> {
if (mappedValue == null || mappedValue.equals(value))
return value;
// if message text differs from previous definition -> throw
throw new DuplicateMessageException(code,
"different message definition for same locale '" + locale + "'");
});
}
messagePublisher.addMessage(messageFactory.parseMessage(code, localizedTexts));
}
return this;
}
/**
* Publish the template defined in the given {@link TemplateDef} annotation.
*
* @param templateDef {@code TemplateDef} annotation, not {@code null}
*
* @return this annotation adopter instance, never {@code null}
*
* @throws DuplicateTemplateException if different template messages are provided for the
* same locale
* @throws MessageParserException in case the template could not be parsed
*/
public @NotNull AbstractAnnotationAdopter adopt(@NotNull TemplateDef templateDef)
{
final Text[] texts = templateDef.texts();
final String name = templateDef.name();
if (texts.length == 0)
{
@Language("MessageFormat") final String text = templateDef.text();
messagePublisher.addTemplate(name, text.isEmpty()
? EmptyMessage.INSTANCE
: messageFactory.parseTemplate(text));
}
else
{
final Map localizedTexts = new LinkedHashMap<>();
for(final Text text: texts)
{
final String value = text.locale().isEmpty() &&
text.text().isEmpty() ? text.value() : text.text();
localizedTexts.compute(forLanguageTag(text.locale()), (locale, mappedValue) -> {
if (mappedValue == null || mappedValue.equals(value))
return value;
// if template text differs from previous definition -> throw
throw new DuplicateTemplateException(name,
"different template definition for same locale '" + locale + "'");
});
}
try {
messagePublisher.addTemplate(name, messageFactory.parseTemplate(localizedTexts));
} catch(MessageParserException ex) {
throw ex.withTemplate(name);
}
}
return this;
}
/**
* Scan contents of class provided by {@code classInputStream}.
*
* @param classInputStream input stream of class file, not {@code null}
*
* @throws IOException in case of an I/O exception
*/
protected abstract void parseClass(@NotNull InputStream classInputStream) throws IOException;
/**
* {@code MessageDef} annotation implementation.
*/
@SuppressWarnings("ClassExplicitlyAnnotation")
static final class MessageDefImpl implements MessageDef
{
private final @NotNull String code;
@Language("MessageFormat")
private final @NotNull String text;
private final @NotNull Text[] texts;
MessageDefImpl(@NotNull String code, String text, @NotNull Text[] texts)
{
this.code = code.trim();
this.text = text == null ? "" : text.trim();
this.texts = texts;
}
@Override
public String code() {
return code;
}
@Override
public String text() {
return text;
}
@Override
public Text[] texts() {
return texts;
}
@Override
public Class extends Annotation> annotationType() {
return MessageDef.class;
}
@Override
public String toString() {
return "MessageDef(code=" + code + ",text=" + text + ",texts=" + Arrays.toString(texts) + ')';
}
}
/**
* {@code TemplateDef} annotation implementation.
*
* @author Jeroen Gremmen
* @since 0.8.0
*/
@SuppressWarnings("ClassExplicitlyAnnotation")
static final class TemplateDefImpl implements TemplateDef
{
private final @NotNull String name;
@Language("MessageFormat")
private final @NotNull String text;
private final @NotNull Text[] texts;
@SuppressWarnings("ConstantValue")
TemplateDefImpl(@NotNull String name, String text, @NotNull Text[] texts)
{
if ((this.name = name == null ? "" : name.trim()).isEmpty())
throw new IllegalArgumentException("name must not be empty");
this.text = text == null ? "" : text.trim();
this.texts = texts;
}
@Override
public String name() {
return name;
}
@Override
public String text() {
return text;
}
@Override
public Text[] texts() {
return texts;
}
@Override
public Class extends Annotation> annotationType() {
return TemplateDef.class;
}
@Override
public String toString() {
return "TemplateDef(name=" + name + ",text=" + text + ",texts=" + Arrays.toString(texts) + ')';
}
}
/**
* {@code Text} annotation implementation.
*
* @author Jeroen Gremmen
*/
@SuppressWarnings("ClassExplicitlyAnnotation")
static final class TextImpl implements Text
{
private final @NotNull String locale;
@Language("MessageFormat")
private final @NotNull String text;
@Language("MessageFormat")
private final @NotNull String value;
TextImpl(String locale, String text, String value)
{
this.locale = locale == null ? "" : locale.trim();
this.text = text == null ? "" : text.trim();
this.value = value == null ? "" : value.trim();
}
@Override
public String locale() {
return locale;
}
@Override
public String text() {
return text;
}
@Override
public String value() {
return value;
}
@Override
public Class extends Annotation> annotationType() {
return Text.class;
}
@Override
public String toString() {
return "Text(locale=" + locale + ",text=" + text + ",value=" + value + ')';
}
}
}