org.microbean.maven.cdi.YamlSettingsBuilder Maven / Gradle / Ivy
/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*-
*
* Copyright © 2017-2018 microBean.
*
* 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.
*/
package org.microbean.maven.cdi;
import java.io.IOException;
import java.io.InputStream;
import java.io.File;
import java.lang.annotation.Annotation;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Map.Entry;
import java.util.Objects;
import java.util.Set;
import javax.enterprise.context.ApplicationScoped;
import javax.enterprise.inject.Alternative;
import javax.inject.Inject;
import org.codehaus.plexus.interpolation.InterpolationException;
import org.codehaus.plexus.interpolation.Interpolator;
import org.codehaus.plexus.interpolation.RegexBasedInterpolator;
import org.codehaus.plexus.interpolation.PropertiesBasedValueSource;
import org.codehaus.plexus.interpolation.EnvarBasedValueSource;
import org.codehaus.plexus.util.xml.Xpp3Dom;
import org.apache.maven.building.FileSource;
import org.apache.maven.building.Source;
import org.apache.maven.settings.Server;
import org.apache.maven.settings.Settings;
import org.apache.maven.settings.TrackableBase;
import org.apache.maven.settings.building.DefaultSettingsBuilder; // for javadoc only
import org.apache.maven.settings.building.DefaultSettingsProblem;
import org.apache.maven.settings.building.SettingsBuilder;
import org.apache.maven.settings.building.SettingsBuildingException;
import org.apache.maven.settings.building.SettingsBuildingRequest;
import org.apache.maven.settings.building.SettingsBuildingResult;
import org.apache.maven.settings.building.SettingsProblem;
import org.apache.maven.settings.building.SettingsProblemCollector;
import org.apache.maven.settings.io.SettingsParseException;
import org.apache.maven.settings.merge.MavenSettingsMerger;
import org.apache.maven.settings.validation.SettingsValidator;
import org.apache.maven.settings.validation.DefaultSettingsValidator;
import org.yaml.snakeyaml.TypeDescription;
import org.yaml.snakeyaml.Yaml;
import org.yaml.snakeyaml.constructor.Construct;
import org.yaml.snakeyaml.constructor.Constructor;
import org.yaml.snakeyaml.constructor.SafeConstructor;
import org.yaml.snakeyaml.constructor.SafeConstructor.ConstructYamlOmap;
import org.yaml.snakeyaml.error.Mark;
import org.yaml.snakeyaml.error.MarkedYAMLException;
import org.yaml.snakeyaml.error.YAMLException;
import org.yaml.snakeyaml.introspector.BeanAccess;
import org.yaml.snakeyaml.introspector.Property;
import org.yaml.snakeyaml.introspector.PropertyUtils;
import org.yaml.snakeyaml.nodes.Node;
import org.yaml.snakeyaml.nodes.MappingNode;
import org.yaml.snakeyaml.nodes.SequenceNode;
import org.yaml.snakeyaml.nodes.ScalarNode;
import org.yaml.snakeyaml.nodes.Tag;
import org.yaml.snakeyaml.reader.ReaderException;
/**
* A {@link SettingsBuilder} implementation that behaves like a {@link
* DefaultSettingsBuilder} implementation but without needlessly
* requiring round-trip serialization and deserialization of the
* underlying settings, and that reads YAML files instead of XML
* files.
*
* @author Laird Nelson
*
* @see #build(SettingsBuildingRequest)
*
* @see DefaultSettingsBuilder
*/
@Alternative
@ApplicationScoped
public class YamlSettingsBuilder implements SettingsBuilder {
/*
* Instance fields.
*/
private final SettingsValidator settingsValidator;
private final MavenSettingsMerger merger;
/*
* Constructors.
*/
/**
* Creates a new {@link YamlSettingsBuilder} with a {@link
* DefaultSettingsValidator} and a {@link MavenSettingsMerger}.
*
* @see #YamlSettingsBuilder(SettingsValidator, MavenSettingsMerger)
*/
public YamlSettingsBuilder() {
this(new DefaultSettingsValidator(), new MavenSettingsMerger());
}
/**
* Creates a new {@link YamlSettingsBuilder}.
*
* @param settingsValidator a {@link SettingsValidator}
* implementation that will validate the {@link Settings} object
* once it has been successfully deserialized; if {@code null} then
* a {@link DefaultSettingsValidator} implementation will be used
* instead
*
* @param merger a {@link MavenSettingsMerger} that will be used to
* merge global and user-specific {@link Settings} objects; if
* {@code null}, then a new {@link MavenSettingsMerger} will be used
* instead
*/
@Inject
public YamlSettingsBuilder(final SettingsValidator settingsValidator,
final MavenSettingsMerger merger) {
super();
if (settingsValidator == null) {
this.settingsValidator = new DefaultSettingsValidator();
} else {
this.settingsValidator = settingsValidator;
}
if (merger == null) {
this.merger = new MavenSettingsMerger();
} else {
this.merger = merger;
}
}
/*
* Instance methods.
*/
/**
* Deserializes a {@link Settings} from a stream-based source
* represented by the supplied {@link SettingsBuildingRequest} and
* returns it wrapped by a {@link SettingsBuildingResult} in much
* the same manner as the {@link
* DefaultSettingsBuilder#build(SettingsBuildingRequest)} method,
* but using YAML instead of XML, and performing interpolation in a
* more efficient manner.
*
* This method never returns {@code null}.
*
* Overrides of this method must not return {@code null}.
*
* @param request the {@link SettingsBuildingRequest} representing
* the settings location; must not be {@code null}
*
* @return a non-{@code null} {@link SettingsBuildingResult}
*
* @see #read(Source, boolean, Interpolator,
* SettingsProblemCollector)
*
* @exception NullPointerException if {@code request} is {@code null}
*
* @exception SettingsBuildingException if an error occurred, but
* also see the return value of the {@link
* SettingsBuildingResult#getProblems()} method
*/
@Override
public SettingsBuildingResult build(final SettingsBuildingRequest request) throws SettingsBuildingException {
Objects.requireNonNull(request);
final List problems = new ArrayList<>();
Source globalSettingsSource = request.getGlobalSettingsSource();
if (globalSettingsSource == null) {
final File file = request.getGlobalSettingsFile();
if (file != null && file.exists()) {
globalSettingsSource = new FileSource(file);
}
}
final Settings globalSettings;
if (globalSettingsSource == null) {
globalSettings = null;
} else {
globalSettings = this.readSettings(globalSettingsSource, request, problems);
}
Source userSettingsSource = request.getUserSettingsSource();
if (userSettingsSource == null) {
final File file = request.getUserSettingsFile();
if (file != null && file.exists()) {
userSettingsSource = new FileSource(file);
}
}
Settings settings = null;
if (userSettingsSource != null) {
settings = this.readSettings(userSettingsSource, request, problems);
}
if (settings == null) {
if (globalSettings != null) {
settings = globalSettings;
}
} else if (globalSettings != null) {
this.merger.merge(settings, globalSettings, TrackableBase.GLOBAL_LEVEL);
}
if (hasErrors(problems)) {
throw new SettingsBuildingException(problems);
}
final SettingsBuildingResult returnValue = new DefaultSettingsBuildingResult(settings, problems);
return returnValue;
}
/**
* Given a {@link Source}, reads settings information from it and
* creates a new {@link Settings} object and returns it.
*
* This method may return {@code null}.
*
* Overrides of this method are permitted to return {@code null}.
*
* @param source the {@link Source} representing the location of the
* settings; may be {@code null} in which case {@code null} will be
* returned
*
* @param strict whether or not strictness should be in effect
*
* @param interpolator an {@link Interpolator} for interpolating
* values within the settings information; may be {@code null}
*
* @param problemCollector a {@link SettingsProblemCollector} to
* accumulate error information; must not be {@code null}
*
* @return a {@link Settings}, or {@code null}
*
* @exception NullPointerException if {@code problemCollector} is
* {@code null}
*
* @exception IOException if an input or output error occurred
*
* @exception SettingsParseException if the settings information
* could not be parsed as a YAML 1.1 document
*/
public Settings read(final Source source,
final boolean strict,
final Interpolator interpolator,
final SettingsProblemCollector problemCollector)
throws IOException, SettingsParseException {
Objects.requireNonNull(problemCollector);
Settings returnValue = null;
if (source != null) {
try (final InputStream stream = source.getInputStream()) {
if (stream != null) {
final Constructor constructor = new InterpolatingConstructor(interpolator, problemCollector);
final Yaml yaml = new Yaml(constructor);
yaml.addTypeDescription(new TypeDescription(Server.class) {
@Override
public final boolean setupPropertyType(final String key, final Node valueNode) {
final boolean returnValue;
if ("configuration".equals(key) && valueNode != null) {
valueNode.setType(Xpp3Dom.class);
returnValue = true;
} else {
returnValue = super.setupPropertyType(key, valueNode);
}
return returnValue;
}
@Override
public final Object newInstance(final String propertyName, final Node node) {
final Object returnValue;
if ("configuration".equals(propertyName) && (node instanceof MappingNode || node instanceof SequenceNode)) {
Xpp3Dom dom = new Xpp3Dom("configuration");
returnValue = dom;
final Object contents = new HackyConstructor().construct(node);
if (contents instanceof Map) {
handleConfigurationMap(interpolator, problemCollector, dom, (Map, ?>)contents);
} else if (contents instanceof Collection) {
handleConfigurationCollection(interpolator, problemCollector, dom, (Collection>)contents);
} else {
throw new MarkedYAMLException("after deserializing configuration",
node.getStartMark(),
"found invalid scalar configuration: " + contents,
node.getStartMark()) {
private static final long serialVersionUID = 1L;
};
}
} else {
returnValue = super.newInstance(propertyName, node);
}
return returnValue;
}
});
returnValue = yaml.loadAs(stream, Settings.class);
}
} catch (final MarkedYAMLException e) {
final Mark mark = e.getProblemMark();
final int line;
final int column;
if (mark == null) {
line = -1;
column = -1;
} else {
line = mark.getLine() + 1;
column = mark.getColumn() + 1;
}
throw new SettingsParseException(e.getMessage(), line, column, e);
} catch (final ReaderException e) {
throw new SettingsParseException(e.getMessage(), -1, e.getPosition() + 1, e);
} catch (final YAMLException e) {
throw new SettingsParseException(e.getMessage(), -1, -1, e);
}
}
return returnValue;
}
private static final void handleConfigurationScalar(final Interpolator interpolator,
final SettingsProblemCollector problemCollector,
final Xpp3Dom element,
final Object scalarItem) {
Objects.requireNonNull(element);
Objects.requireNonNull(problemCollector);
if (scalarItem != null) {
assert !(scalarItem instanceof Collection);
assert !(scalarItem instanceof Map);
String value = scalarItem.toString();
if (interpolator != null) {
try {
value = interpolator.interpolate(value, "settings");
} catch (final InterpolationException interpolationException) {
assert problemCollector != null;
problemCollector.add(SettingsProblem.Severity.ERROR,
"Failed to interpolate settings: " +
interpolationException.getMessage(),
-1,
-1,
interpolationException);
}
}
element.setValue(value);
}
}
private static final void handleConfigurationCollection(final Interpolator interpolator,
final SettingsProblemCollector problemCollector,
final Xpp3Dom rootElement,
final Collection> items) {
Objects.requireNonNull(rootElement);
if (items != null && !items.isEmpty()) {
for (final Object item : items) {
if (item instanceof Map) {
handleConfigurationMap(interpolator, problemCollector, rootElement, (Map, ?>)item);
} else if (item instanceof Collection) {
throw new YAMLException("Invalid configuration element (after deserialization): " + item);
} else {
handleConfigurationScalar(interpolator, problemCollector, rootElement, item);
}
}
}
}
private static final void handleConfigurationMap(final Interpolator interpolator,
final SettingsProblemCollector problemCollector,
final Xpp3Dom rootElement,
final Map, ?> items) {
Objects.requireNonNull(rootElement);
if (items != null && !items.isEmpty()) {
final Set extends Entry, ?>> entrySet = items.entrySet();
if (entrySet != null && !entrySet.isEmpty()) {
for (final Entry, ?> entry : entrySet) {
if (entry != null) {
final Object key = entry.getKey();
final Xpp3Dom element = new Xpp3Dom(String.valueOf(key));
element.setParent(rootElement);
rootElement.addChild(element);
final Object value = entry.getValue();
if (value != null) {
if (value instanceof Collection) {
handleConfigurationCollection(interpolator, problemCollector, element, (Collection>)value);
} else if (value instanceof Map) {
handleConfigurationMap(interpolator, problemCollector, element, (Map, ?>)value); // RECURSIVE
} else {
handleConfigurationScalar(interpolator, problemCollector, element, value);
}
}
}
}
}
}
}
private final Settings readSettings(final Source source,
final SettingsBuildingRequest request,
final Collection problems) {
Objects.requireNonNull(problems);
Settings returnValue = null;
if (source != null) {
SettingsProblemCollector collector =
(severity, message, line, column, cause) ->
add(problems, severity, message, source.getLocation(), line, column, cause);
final Interpolator interpolator = new RegexBasedInterpolator();
interpolator.addValueSource(new PropertiesBasedValueSource(request.getUserProperties()));
interpolator.addValueSource(new PropertiesBasedValueSource(request.getSystemProperties()));
try {
interpolator.addValueSource(new EnvarBasedValueSource());
} catch (final IOException ioException) {
collector.add(SettingsProblem.Severity.WARNING,
"Failed to use environment variables for interpolation: " +
ioException.getMessage(),
-1,
-1,
ioException);
}
try {
try {
returnValue = this.read(source, true, interpolator, collector);
this.settingsValidator.validate(returnValue, collector);
} catch (final SettingsParseException strictParsingFailed) {
returnValue = this.read(source, false, interpolator, collector);
// Record the warning only if lenient reading succeeded.
collector.add(SettingsProblem.Severity.WARNING,
strictParsingFailed.getMessage(),
strictParsingFailed.getLineNumber(),
strictParsingFailed.getColumnNumber(),
strictParsingFailed);
this.settingsValidator.validate(returnValue, collector);
}
} catch (final SettingsParseException lenientParsingFailed) {
collector.add(SettingsProblem.Severity.FATAL,
"Non-parseable settings " +
source.getLocation() +
": " +
lenientParsingFailed.getMessage(),
lenientParsingFailed.getLineNumber(),
lenientParsingFailed.getColumnNumber(),
lenientParsingFailed);
} catch (final IOException ioException) {
collector.add(SettingsProblem.Severity.FATAL,
"Non-readable settings " +
source.getLocation() +
": " +
ioException.getMessage(),
-1,
-1,
ioException);
}
}
return returnValue;
}
/*
* Static methods.
*/
private static final boolean hasErrors(Collection extends SettingsProblem> problems) {
boolean returnValue = false;
if (problems != null && !problems.isEmpty()) {
for (final SettingsProblem problem : problems) {
if (problem != null && SettingsProblem.Severity.ERROR.compareTo(problem.getSeverity()) >= 0) {
returnValue = true;
break;
}
}
}
return returnValue;
}
private static final void add(final Collection problems,
final SettingsProblem.Severity severity,
final String message,
final String source,
int line,
int column,
final Exception cause) {
Objects.requireNonNull(problems);
if (cause instanceof SettingsParseException && line <= 0 && column <= 0) {
final SettingsParseException e = (SettingsParseException)cause;
line = e.getLineNumber();
column = e.getColumnNumber();
}
problems.add(new DefaultSettingsProblem(message, severity, source, line, column, cause));
}
/*
* Inner and nested calsses.
*/
private static final class DefaultSettingsBuildingResult implements SettingsBuildingResult {
private final Settings settings;
private final List problems;
private DefaultSettingsBuildingResult(final Settings settings, final List problems) {
super();
if (settings == null) {
this.settings = new Settings();
} else {
this.settings = settings;
}
if (problems == null) {
this.problems = Collections.emptyList();
} else {
this.problems = Collections.unmodifiableList(problems);
}
}
@Override
public final Settings getEffectiveSettings() {
return this.settings;
}
@Override
public final List getProblems() {
return this.problems;
}
}
private static final class InterpolatingConstructor extends Constructor {
private final Interpolator interpolator;
private final SettingsProblemCollector problemCollector;
private InterpolatingConstructor(final Interpolator interpolator,
final SettingsProblemCollector problemCollector) {
super(Settings.class);
this.interpolator = interpolator;
if (interpolator == null) {
this.problemCollector = problemCollector;
} else {
this.problemCollector = Objects.requireNonNull(problemCollector);
}
}
@Override
protected final Object constructScalar(final ScalarNode node) {
Object returnValue = super.constructScalar(node);
if (this.interpolator != null && returnValue instanceof String) {
final String value = (String)returnValue;
try {
returnValue = this.interpolator.interpolate(value, "settings");
} catch (final InterpolationException interpolationException) {
assert this.problemCollector != null;
this.problemCollector.add(SettingsProblem.Severity.ERROR,
"Failed to interpolate settings: " +
interpolationException.getMessage(),
-1,
-1,
interpolationException);
}
}
return returnValue;
}
}
private static final class HackyConstructor extends SafeConstructor {
private HackyConstructor() {
super();
}
private final Object construct(final Node node) {
return this.yamlConstructors.get(node.getTag()).construct(node);
}
}
}