001/* -*- mode: Java; c-basic-offset: 2; indent-tabs-mode: nil; coding: utf-8-unix -*-
002 *
003 * Copyright © 2017-2019 microBean.
004 *
005 * Licensed under the Apache License, Version 2.0 (the "License");
006 * you may not use this file except in compliance with the License.
007 * You may obtain a copy of the License at
008 *
009 *     http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
014 * implied.  See the License for the specific language governing
015 * permissions and limitations under the License.
016 */
017package org.microbean.maven.cdi;
018
019import java.io.IOException;
020import java.io.InputStream;
021import java.io.File;
022
023import java.lang.annotation.Annotation;
024
025import java.util.ArrayList;
026import java.util.Collection;
027import java.util.Collections;
028import java.util.List;
029import java.util.Map;
030import java.util.Map.Entry;
031import java.util.Objects;
032import java.util.Set;
033
034import javax.enterprise.context.ApplicationScoped;
035
036import javax.enterprise.inject.Alternative;
037
038import javax.inject.Inject;
039
040import org.codehaus.plexus.interpolation.InterpolationException;
041import org.codehaus.plexus.interpolation.Interpolator;
042import org.codehaus.plexus.interpolation.RegexBasedInterpolator;
043import org.codehaus.plexus.interpolation.PropertiesBasedValueSource;
044import org.codehaus.plexus.interpolation.EnvarBasedValueSource;
045
046import org.codehaus.plexus.util.xml.Xpp3Dom;
047
048import org.apache.maven.building.FileSource;
049import org.apache.maven.building.Source;
050
051import org.apache.maven.settings.Server;
052import org.apache.maven.settings.Settings;
053import org.apache.maven.settings.TrackableBase;
054
055import org.apache.maven.settings.building.DefaultSettingsBuilder; // for javadoc only
056import org.apache.maven.settings.building.DefaultSettingsProblem;
057import org.apache.maven.settings.building.SettingsBuilder;
058import org.apache.maven.settings.building.SettingsBuildingException;
059import org.apache.maven.settings.building.SettingsBuildingRequest;
060import org.apache.maven.settings.building.SettingsBuildingResult;
061import org.apache.maven.settings.building.SettingsProblem;
062import org.apache.maven.settings.building.SettingsProblemCollector;
063
064import org.apache.maven.settings.io.SettingsParseException;
065
066import org.apache.maven.settings.merge.MavenSettingsMerger;
067
068import org.apache.maven.settings.validation.SettingsValidator;
069import org.apache.maven.settings.validation.DefaultSettingsValidator;
070
071import org.yaml.snakeyaml.TypeDescription;
072import org.yaml.snakeyaml.Yaml;
073
074import org.yaml.snakeyaml.constructor.Construct;
075import org.yaml.snakeyaml.constructor.Constructor;
076import org.yaml.snakeyaml.constructor.SafeConstructor;
077import org.yaml.snakeyaml.constructor.SafeConstructor.ConstructYamlOmap;
078
079import org.yaml.snakeyaml.error.Mark;
080import org.yaml.snakeyaml.error.MarkedYAMLException;
081import org.yaml.snakeyaml.error.YAMLException;
082
083import org.yaml.snakeyaml.introspector.BeanAccess;
084import org.yaml.snakeyaml.introspector.Property;
085import org.yaml.snakeyaml.introspector.PropertyUtils;
086
087import org.yaml.snakeyaml.nodes.Node;
088import org.yaml.snakeyaml.nodes.MappingNode;
089import org.yaml.snakeyaml.nodes.SequenceNode;
090import org.yaml.snakeyaml.nodes.ScalarNode;
091import org.yaml.snakeyaml.nodes.Tag;
092
093import org.yaml.snakeyaml.reader.ReaderException;
094
095/**
096 * A {@link SettingsBuilder} implementation that behaves like a {@link
097 * DefaultSettingsBuilder} implementation but without needlessly
098 * requiring round-trip serialization and deserialization of the
099 * underlying settings, and that reads YAML files instead of XML
100 * files.
101 *
102 * @author <a href="https://about.me/lairdnelson"
103 * target="_parent">Laird Nelson</a>
104 *
105 * @see #build(SettingsBuildingRequest)
106 *
107 * @see DefaultSettingsBuilder
108 */
109@Alternative
110@ApplicationScoped
111public class YamlSettingsBuilder implements SettingsBuilder {
112
113
114  /*
115   * Instance fields.
116   */
117
118  
119  private final SettingsValidator settingsValidator;
120
121  private final MavenSettingsMerger merger;
122
123
124  /*
125   * Constructors.
126   */
127
128
129  /**
130   * Creates a new {@link YamlSettingsBuilder} with a {@link
131   * DefaultSettingsValidator} and a {@link MavenSettingsMerger}.
132   *
133   * @see #YamlSettingsBuilder(SettingsValidator, MavenSettingsMerger)
134   */
135  public YamlSettingsBuilder() {
136    this(new DefaultSettingsValidator(), new MavenSettingsMerger());
137  }
138
139  /**
140   * Creates a new {@link YamlSettingsBuilder}.
141   *
142   * @param settingsValidator a {@link SettingsValidator}
143   * implementation that will validate the {@link Settings} object
144   * once it has been successfully deserialized; if {@code null} then
145   * a {@link DefaultSettingsValidator} implementation will be used
146   * instead
147   *
148   * @param merger a {@link MavenSettingsMerger} that will be used to
149   * merge global and user-specific {@link Settings} objects; if
150   * {@code null}, then a new {@link MavenSettingsMerger} will be used
151   * instead
152   */
153  @Inject
154  public YamlSettingsBuilder(final SettingsValidator settingsValidator,
155                             final MavenSettingsMerger merger) {
156    super();
157    if (settingsValidator == null) {
158      this.settingsValidator = new DefaultSettingsValidator();
159    } else {
160      this.settingsValidator = settingsValidator;
161    }
162    if (merger == null) {
163      this.merger = new MavenSettingsMerger();
164    } else {
165      this.merger = merger;
166    }
167  }
168
169
170  /*
171   * Instance methods.
172   */
173
174
175  /**
176   * Deserializes a {@link Settings} from a stream-based source
177   * represented by the supplied {@link SettingsBuildingRequest} and
178   * returns it wrapped by a {@link SettingsBuildingResult} in much
179   * the same manner as the {@link
180   * DefaultSettingsBuilder#build(SettingsBuildingRequest)} method,
181   * but using YAML instead of XML, and performing interpolation in a
182   * more efficient manner.
183   *
184   * <p>This method never returns {@code null}.</p>
185   *
186   * <p>Overrides of this method must not return {@code null}.</p>
187   *
188   * @param request the {@link SettingsBuildingRequest} representing
189   * the settings location; must not be {@code null}
190   *
191   * @return a non-{@code null} {@link SettingsBuildingResult}
192   *
193   * @see #read(Source, boolean, Interpolator,
194   * SettingsProblemCollector)
195   *
196   * @exception NullPointerException if {@code request} is {@code null}
197   *
198   * @exception SettingsBuildingException if an error occurred, but
199   * also see the return value of the {@link
200   * SettingsBuildingResult#getProblems()} method
201   */
202  @Override
203  public SettingsBuildingResult build(final SettingsBuildingRequest request) throws SettingsBuildingException {
204    Objects.requireNonNull(request);
205    final List<SettingsProblem> problems = new ArrayList<>();
206
207    Source globalSettingsSource = request.getGlobalSettingsSource();
208    if (globalSettingsSource == null) {
209      final File file = request.getGlobalSettingsFile();
210      if (file != null && file.exists()) {
211        globalSettingsSource = new FileSource(file);
212      }
213    }
214    final Settings globalSettings;
215    if (globalSettingsSource == null) {
216      globalSettings = null;
217    } else {
218      globalSettings = this.readSettings(globalSettingsSource, request, problems);
219    }
220
221    Source userSettingsSource = request.getUserSettingsSource();
222    if (userSettingsSource == null) {
223      final File file = request.getUserSettingsFile();
224      if (file != null && file.exists()) {
225        userSettingsSource = new FileSource(file);
226      }
227    }
228    Settings settings = null;
229    if (userSettingsSource != null) {
230      settings = this.readSettings(userSettingsSource, request, problems);
231    }
232    
233    if (settings == null) {
234      if (globalSettings != null) {
235        settings = globalSettings;
236      }
237    } else if (globalSettings != null) {
238      this.merger.merge(settings, globalSettings, TrackableBase.GLOBAL_LEVEL);
239    }
240
241    if (hasErrors(problems)) {
242      throw new SettingsBuildingException(problems);
243    }
244
245    final SettingsBuildingResult returnValue = new DefaultSettingsBuildingResult(settings, problems);
246    return returnValue;
247  }
248
249  /**
250   * Given a {@link Source}, reads settings information from it and
251   * creates a new {@link Settings} object and returns it.
252   *
253   * <p>This method may return {@code null}.</p>
254   *
255   * <p>Overrides of this method are permitted to return {@code null}.</p>
256   *
257   * @param source the {@link Source} representing the location of the
258   * settings; may be {@code null} in which case {@code null} will be
259   * returned
260   *
261   * @param strict whether or not strictness should be in effect
262   *
263   * @param interpolator an {@link Interpolator} for interpolating
264   * values within the settings information; may be {@code null}
265   *
266   * @param problemCollector a {@link SettingsProblemCollector} to
267   * accumulate error information; must not be {@code null}
268   *
269   * @return a {@link Settings}, or {@code null}
270   *
271   * @exception NullPointerException if {@code problemCollector} is
272   * {@code null}
273   *
274   * @exception IOException if an input or output error occurred
275   *
276   * @exception SettingsParseException if the settings information
277   * could not be parsed as a YAML 1.1 document
278   */
279  public Settings read(final Source source,
280                       final boolean strict,
281                       final Interpolator interpolator,
282                       final SettingsProblemCollector problemCollector)
283    throws IOException, SettingsParseException {
284    Objects.requireNonNull(problemCollector);
285    Settings returnValue = null;
286    if (source != null) {
287      try (final InputStream stream = source.getInputStream()) {
288        if (stream != null) {
289          final Constructor constructor = new InterpolatingConstructor(interpolator, problemCollector);
290          final Yaml yaml = new Yaml(constructor);
291          yaml.addTypeDescription(new TypeDescription(Server.class) {
292
293              @Override
294              public final boolean setupPropertyType(final String key, final Node valueNode) {
295                final boolean returnValue;
296                if ("configuration".equals(key) && valueNode != null) {
297                  valueNode.setType(Xpp3Dom.class);
298                  returnValue = true;
299                } else {
300                  returnValue = super.setupPropertyType(key, valueNode);
301                }
302                return returnValue;
303              }
304
305              @Override
306              public final Object newInstance(final String propertyName, final Node node) {
307                final Object returnValue;
308                if ("configuration".equals(propertyName) && (node instanceof MappingNode || node instanceof SequenceNode)) {
309                  Xpp3Dom dom = new Xpp3Dom("configuration");
310                  returnValue = dom;
311                  final Object contents = new HackyConstructor().construct(node);
312                  if (contents instanceof Map) {
313                    handleConfigurationMap(interpolator, problemCollector, dom, (Map<?, ?>)contents);
314                  } else if (contents instanceof Collection) {
315                    handleConfigurationCollection(interpolator, problemCollector, dom, (Collection<?>)contents);
316                  } else {
317                    throw new MarkedYAMLException("after deserializing configuration",
318                                                  node.getStartMark(),
319                                                  "found invalid scalar configuration: " + contents,
320                                                  node.getStartMark()) {
321                      private static final long serialVersionUID = 1L;
322                    };
323                  }
324                } else {
325                  returnValue = super.newInstance(propertyName, node);
326                }
327                return returnValue;
328              }
329            });
330          returnValue = yaml.loadAs(stream, Settings.class);
331        }
332      } catch (final MarkedYAMLException e) {
333        final Mark mark = e.getProblemMark();
334        final int line;
335        final int column;
336        if (mark == null) {
337          line = -1;
338          column = -1;
339        } else {
340          line = mark.getLine() + 1;
341          column = mark.getColumn() + 1;
342        }
343        throw new SettingsParseException(e.getMessage(), line, column, e);
344      } catch (final ReaderException e) {
345        throw new SettingsParseException(e.getMessage(), -1, e.getPosition() + 1, e);
346      } catch (final YAMLException e) {
347        throw new SettingsParseException(e.getMessage(), -1, -1, e);
348      }
349    }
350    return returnValue;
351  }
352
353  private static final void handleConfigurationScalar(final Interpolator interpolator,
354                                                      final SettingsProblemCollector problemCollector,
355                                                      final Xpp3Dom element,
356                                                      final Object scalarItem) {
357    Objects.requireNonNull(element);
358    Objects.requireNonNull(problemCollector);
359    if (scalarItem != null) {
360      assert !(scalarItem instanceof Collection);
361      assert !(scalarItem instanceof Map);
362      String value = scalarItem.toString();
363      if (interpolator != null) {
364        try {
365          value = interpolator.interpolate(value, "settings");
366        } catch (final InterpolationException interpolationException) {
367          problemCollector.add(SettingsProblem.Severity.ERROR,
368                               "Failed to interpolate settings: " +
369                               interpolationException.getMessage(),
370                               -1,
371                               -1,
372                               interpolationException);
373        }
374      }
375      element.setValue(value);
376    }
377  }
378    
379  private static final void handleConfigurationCollection(final Interpolator interpolator,
380                                                          final SettingsProblemCollector problemCollector,
381                                                          final Xpp3Dom rootElement,
382                                                          final Collection<?> items) {
383    Objects.requireNonNull(rootElement);
384    if (items != null && !items.isEmpty()) {
385      for (final Object item : items) {
386        if (item instanceof Map) {
387          handleConfigurationMap(interpolator, problemCollector, rootElement, (Map<?, ?>)item);
388        } else if (item instanceof Collection) {
389          throw new YAMLException("Invalid configuration element (after deserialization): " + item);
390        } else {
391          handleConfigurationScalar(interpolator, problemCollector, rootElement, item);
392        }
393      }
394    }
395  }
396
397  private static final void handleConfigurationMap(final Interpolator interpolator,
398                                                   final SettingsProblemCollector problemCollector,
399                                                   final Xpp3Dom rootElement,
400                                                   final Map<?, ?> items) {
401    Objects.requireNonNull(rootElement);
402    if (items != null && !items.isEmpty()) {
403      final Set<? extends Entry<?, ?>> entrySet = items.entrySet();
404      if (entrySet != null && !entrySet.isEmpty()) {
405        for (final Entry<?, ?> entry : entrySet) {
406          if (entry != null) {
407            final Object key = entry.getKey();
408            final Xpp3Dom element = new Xpp3Dom(String.valueOf(key));
409            element.setParent(rootElement);
410            rootElement.addChild(element);
411            final Object value = entry.getValue();
412            if (value != null) {
413              if (value instanceof Collection) {
414                handleConfigurationCollection(interpolator, problemCollector, element, (Collection<?>)value);
415              } else if (value instanceof Map) {
416                handleConfigurationMap(interpolator, problemCollector, element, (Map<?, ?>)value); // RECURSIVE
417              } else {
418                handleConfigurationScalar(interpolator, problemCollector, element, value);
419              }
420            }
421          }
422        }
423      }
424    }
425  }
426
427  private final Settings readSettings(final Source source,
428                                      final SettingsBuildingRequest request,
429                                      final Collection<SettingsProblem> problems) {
430    Objects.requireNonNull(problems);
431    Settings returnValue = null;
432    if (source != null) {
433      SettingsProblemCollector collector =
434        (severity, message, line, column, cause) ->
435        add(problems, severity, message, source.getLocation(), line, column, cause);
436      final Interpolator interpolator = new RegexBasedInterpolator();
437      interpolator.addValueSource(new PropertiesBasedValueSource(request.getUserProperties()));
438      interpolator.addValueSource(new PropertiesBasedValueSource(request.getSystemProperties()));
439      try {
440        interpolator.addValueSource(new EnvarBasedValueSource());
441      } catch (final IOException ioException) {
442        collector.add(SettingsProblem.Severity.WARNING,
443                      "Failed to use environment variables for interpolation: " +
444                      ioException.getMessage(),
445                      -1,
446                      -1,
447                      ioException);
448      }
449      try {
450        try {
451          returnValue = this.read(source, true, interpolator, collector);
452          this.settingsValidator.validate(returnValue, collector);
453        } catch (final SettingsParseException strictParsingFailed) {
454          returnValue = this.read(source, false, interpolator, collector);
455          // Record the warning only if lenient reading succeeded.
456          collector.add(SettingsProblem.Severity.WARNING,
457                        strictParsingFailed.getMessage(),
458                        strictParsingFailed.getLineNumber(),
459                        strictParsingFailed.getColumnNumber(),
460                        strictParsingFailed);
461          this.settingsValidator.validate(returnValue, collector);
462        }
463      } catch (final SettingsParseException lenientParsingFailed) {
464        collector.add(SettingsProblem.Severity.FATAL,
465                      "Non-parseable settings " +
466                      source.getLocation() +
467                      ": " +
468                      lenientParsingFailed.getMessage(),
469                      lenientParsingFailed.getLineNumber(),
470                      lenientParsingFailed.getColumnNumber(),
471                      lenientParsingFailed);
472      } catch (final IOException ioException) {
473        collector.add(SettingsProblem.Severity.FATAL,
474                      "Non-readable settings " +
475                      source.getLocation() +
476                      ": " +
477                      ioException.getMessage(),
478                      -1,
479                      -1,
480                      ioException);
481      }
482    }
483    return returnValue;
484  }
485
486
487  /*
488   * Static methods.
489   */
490
491  
492  private static final boolean hasErrors(Collection<? extends SettingsProblem> problems) {
493    boolean returnValue = false;
494    if (problems != null && !problems.isEmpty()) {
495      for (final SettingsProblem problem : problems) {
496        if (problem != null && SettingsProblem.Severity.ERROR.compareTo(problem.getSeverity()) >= 0) {
497          returnValue = true;
498          break;
499        }
500      }
501    }
502    return returnValue;
503  }
504  
505  private static final void add(final Collection<SettingsProblem> problems,
506                                final SettingsProblem.Severity severity,
507                                final String message,
508                                final String source,
509                                int line,
510                                int column,
511                                final Exception cause) {
512    Objects.requireNonNull(problems);
513    if (cause instanceof SettingsParseException && line <= 0 && column <= 0) {
514      final SettingsParseException e = (SettingsParseException)cause;
515      line = e.getLineNumber();
516      column = e.getColumnNumber();
517    }
518    problems.add(new DefaultSettingsProblem(message, severity, source, line, column, cause));
519  }
520
521
522  /*
523   * Inner and nested calsses.
524   */
525
526  
527  private static final class DefaultSettingsBuildingResult implements SettingsBuildingResult {
528
529    private final Settings settings;
530
531    private final List<SettingsProblem> problems;
532    
533    private DefaultSettingsBuildingResult(final Settings settings, final List<SettingsProblem> problems) {
534      super();
535      if (settings == null) {
536        this.settings = new Settings();
537      } else {
538        this.settings = settings;
539      }
540      if (problems == null) {
541        this.problems = Collections.emptyList();
542      } else {
543        this.problems = Collections.unmodifiableList(problems);
544      }
545    }
546
547    @Override
548    public final Settings getEffectiveSettings() {
549      return this.settings;
550    }
551
552    @Override
553    public final List<SettingsProblem> getProblems() {
554      return this.problems;
555    }
556    
557  }
558
559  private static final class InterpolatingConstructor extends Constructor {
560
561    private final Interpolator interpolator;
562
563    private final SettingsProblemCollector problemCollector;
564    
565    private InterpolatingConstructor(final Interpolator interpolator,
566                                     final SettingsProblemCollector problemCollector) {
567      super(Settings.class);
568      this.interpolator = interpolator;
569      if (interpolator == null) {
570        this.problemCollector = problemCollector;
571      } else {
572        this.problemCollector = Objects.requireNonNull(problemCollector);
573      }
574    }
575    
576    @Override
577    protected final String constructScalar(final ScalarNode node) {
578      String returnValue = super.constructScalar(node);
579      if (this.interpolator != null && returnValue != null) {
580        try {
581          returnValue = this.interpolator.interpolate(returnValue, "settings");
582        } catch (final InterpolationException interpolationException) {
583          assert this.problemCollector != null;
584          this.problemCollector.add(SettingsProblem.Severity.ERROR,
585                                    "Failed to interpolate settings: " +
586                                    interpolationException.getMessage(),
587                                    -1,
588                                    -1,
589                                    interpolationException);
590        }
591      }
592      return returnValue;
593    }
594
595  }
596
597  private static final class HackyConstructor extends SafeConstructor {
598
599    private HackyConstructor() {
600      super();
601    }
602
603    private final Object construct(final Node node) {
604      return this.yamlConstructors.get(node.getTag()).construct(node);
605    }
606    
607  }
608
609}