MavenArtifactLoader.java

/*
 * 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 de.softwareforge.testing.maven;

import static java.lang.String.format;
import static java.util.Objects.requireNonNull;

import java.io.File;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.SortedSet;
import java.util.TreeSet;

import org.apache.maven.repository.internal.MavenRepositorySystemUtils;
import org.apache.maven.settings.Profile;
import org.apache.maven.settings.Repository;
import org.apache.maven.settings.Settings;
import org.apache.maven.settings.building.DefaultSettingsBuilder;
import org.apache.maven.settings.building.DefaultSettingsBuilderFactory;
import org.apache.maven.settings.building.DefaultSettingsBuildingRequest;
import org.apache.maven.settings.building.SettingsBuildingException;
import org.apache.maven.settings.building.SettingsBuildingRequest;
import org.apache.maven.settings.building.SettingsBuildingResult;
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositoryException;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.RepositorySystemSession;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory;
import org.eclipse.aether.impl.DefaultServiceLocator;
import org.eclipse.aether.repository.LocalRepository;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.resolution.ArtifactRequest;
import org.eclipse.aether.resolution.ArtifactResult;
import org.eclipse.aether.resolution.VersionRangeRequest;
import org.eclipse.aether.resolution.VersionRangeResolutionException;
import org.eclipse.aether.resolution.VersionRangeResult;
import org.eclipse.aether.spi.connector.RepositoryConnectorFactory;
import org.eclipse.aether.spi.connector.transport.TransporterFactory;
import org.eclipse.aether.spi.locator.ServiceLocator;
import org.eclipse.aether.transport.file.FileTransporterFactory;
import org.eclipse.aether.transport.http.HttpTransporterFactory;
import org.eclipse.aether.version.Version;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

/**
 * A quick and dirty artifact loader. Downloads published artifacts from the Maven repository system.
 * <p>
 * The loader respects the local maven settings (repositories, mirrors etc.) if present. If no configuration is found, a hard-coded reference to <a
 * href="https://repo.maven.apache.org/maven2/">Maven Central</a> is used.
 */
public final class MavenArtifactLoader {

    private static final Logger LOG = LoggerFactory.getLogger(MavenArtifactLoader.class);

    static final RemoteRepository CENTRAL_REPO = new RemoteRepository.Builder("central", "default", "https://repo.maven.apache.org/maven2/").build();

    private static final String USER_HOME = System.getProperty("user.home");
    private static final File USER_MAVEN_HOME = new File(USER_HOME, ".m2");
    private static final String ENV_M2_HOME = System.getenv("M2_HOME");

    private static final File DEFAULT_USER_SETTINGS_FILE = new File(USER_MAVEN_HOME, "settings.xml");
    private static final File DEFAULT_USER_REPOSITORY = new File(USER_MAVEN_HOME, "repository");
    private static final File DEFAULT_GLOBAL_SETTINGS_FILE =
            new File(System.getProperty("maven.home", Objects.requireNonNullElse(ENV_M2_HOME, "")), "conf/settings.xml");

    private final RepositorySystem repositorySystem;
    private final RepositorySystemSession mavenSession;
    private final List<RemoteRepository> remoteRepositories;

    private final String extension;

    /**
     * Creates a new artifact loader for 'jar' artifacts.
     */
    public MavenArtifactLoader() {
        this("jar");
    }

    /**
     * Creates a new artifact loader for artifacts.
     *
     * @param extension The artifact extension. Must not be null.
     */
    public MavenArtifactLoader(String extension) {
        this(extension, null);
    }

    MavenArtifactLoader(String extension, List<RemoteRepository> remoteRepositoriesForTesting) {
        this.extension = requireNonNull(extension, "extension is null");

        @SuppressWarnings("deprecation")
        ServiceLocator serviceLocator = createServiceLocator();
        this.repositorySystem = serviceLocator.getService(RepositorySystem.class);

        try {
            Settings settings = createSettings();
            File localRepositoryLocation = settings.getLocalRepository() != null ? new File(settings.getLocalRepository()) : DEFAULT_USER_REPOSITORY;
            LocalRepository localRepository = new LocalRepository(localRepositoryLocation);

            if (remoteRepositoriesForTesting != null) {
                this.remoteRepositories = remoteRepositoriesForTesting;
            } else {
                this.remoteRepositories = extractRemoteRepositories(settings);
            }

            DefaultRepositorySystemSession mavenSession = MavenRepositorySystemUtils.newSession();

            this.mavenSession = mavenSession.setLocalRepositoryManager(repositorySystem.newLocalRepositoryManager(mavenSession, localRepository));

        } catch (SettingsBuildingException e) {
            throw new IllegalStateException("Could not load maven settings:", e);
        }
    }

    /**
     * Create a new version match builder to retrieve an artifact.
     *
     * @param groupId    The Apache Maven Group Id. Must not be null.
     * @param artifactId The Apache Maven Artifact Id. Must not be null.
     * @return A {@link MavenVersionMatchBuilder} instance
     */
    public MavenVersionMatchBuilder builder(String groupId, String artifactId) {
        requireNonNull(groupId, "groupId is null");
        requireNonNull(artifactId, "artifactId is null");

        return new MavenVersionMatchBuilder(this, groupId, artifactId);
    }

    /**
     * Download an artifact file from the Maven repository system.
     *
     * @param groupId    The Apache Maven Group Id. Must not be null.
     * @param artifactId The Apache Maven Artifact Id. Must not be null.
     * @param version    The Apache Maven Artifact version. Must not be null.
     * @return A file representing a successfully downloaded artifact.
     * @throws IOException If the artifact could not be found or an IO problem happened while locating or downloading the artifact.
     */
    public File getArtifactFile(String groupId, String artifactId, String version) throws IOException {
        requireNonNull(groupId, "groupId is null");
        requireNonNull(artifactId, "artifactId is null");
        requireNonNull(version, "version is null");

        ArtifactRequest artifactRequest = new ArtifactRequest();
        artifactRequest.setArtifact(new DefaultArtifact(groupId, artifactId, extension, version));
        artifactRequest.setRepositories(this.remoteRepositories);
        try {
            ArtifactResult artifactResult = this.repositorySystem.resolveArtifact(mavenSession, artifactRequest);
            Artifact artifact = artifactResult.getArtifact();
            return artifact.getFile();
        } catch (RepositoryException e) {
            throw new IOException(e);
        }
    }

    /**
     * Find a matching artifact version from a partially defined artifact version.
     * <p>
     * Any located artifact in the repository system is compared to the version given.
     * <br>
     * Using this method is equivalent to calling
     * <pre>
     *  builder(groupId, artifactId)
     *                 .partialMatch(version)
     *                 .extension(extension)
     *                 .includeSnapshots(true)
     *                 .findBestMatch();
     * </pre>
     *
     * but will throw an IOException if no version could be found.
     *
     * @param groupId    The Apache Maven Group Id. Must not be null.
     * @param artifactId The Apache Maven Artifact Id. Must not be null.
     * @param version    A partial version string. Must not be null. An empty string matches any version.
     * @return The latest version that matches the partial version string. It either starts with the partial version string given (an empty version string
     * matches any version) or is exactly the provided version.
     * @throws IOException If an IO problem happened during artifact download or no versions were found during resolution.
     */
    public String findLatestVersion(String groupId, String artifactId, String version) throws IOException {
        requireNonNull(groupId, "groupId is null");
        requireNonNull(artifactId, "artifactId is null");
        requireNonNull(version, "version is null");
        return builder(groupId, artifactId)
                .partialMatch(version)
                .extension(extension)
                .includeSnapshots(true)
                .findBestMatch()
                .orElseThrow(() -> new IOException(format("No suitable candidate for %s:%s:%s found!", groupId, artifactId, version)));
    }

    SortedSet<Version> findAllVersions(MavenVersionMatchBuilder builder) throws IOException {

        Artifact artifact = new DefaultArtifact(builder.groupId(), builder.artifactId(), builder.extension(), "[0,)");

        VersionRangeRequest rangeRequest = new VersionRangeRequest();
        rangeRequest.setArtifact(artifact);
        rangeRequest.setRepositories(this.remoteRepositories);

        try {
            VersionRangeResult rangeResult = this.repositorySystem.resolveVersionRange(mavenSession, rangeRequest);
            SortedSet<Version> resultBuilder = new TreeSet<>();
            List<Version> artifactVersions = rangeResult.getVersions();
            VersionStrategy versionStrategy = builder.versionStrategy();
            if (artifactVersions != null) {
                for (Version artifactVersion : artifactVersions) {
                    boolean isSnapshot = artifactVersion.toString().endsWith("-SNAPSHOT");
                    boolean match = versionStrategy.matchVersion(artifactVersion);

                    // remove match if snapshots are not requested but the version is a snapshot
                    if (isSnapshot) {
                        match &= builder.includeSnapshots();
                    }

                    if (match) {
                        resultBuilder.add(artifactVersion);
                    }
                }
            }
            return Collections.unmodifiableSortedSet(resultBuilder);
        } catch (VersionRangeResolutionException e) {
            throw new IOException(format("Could not resolve version range: %s", rangeRequest), e);
        }
    }


    @SuppressWarnings("deprecation")
    private static ServiceLocator createServiceLocator() {
        DefaultServiceLocator locator = MavenRepositorySystemUtils.newServiceLocator();

        locator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class);
        locator.addService(TransporterFactory.class, FileTransporterFactory.class);
        locator.addService(TransporterFactory.class, HttpTransporterFactory.class);

        locator.setErrorHandler(new DefaultServiceLocator.ErrorHandler() {
            @Override
            public void serviceCreationFailed(Class<?> type, Class<?> impl, Throwable e) {
                LOG.error(format("Could not create instance of %s (implementation %s): ", type.getSimpleName(), impl.getSimpleName()), e);
            }
        });

        return locator;
    }

    private static Settings createSettings() throws SettingsBuildingException {
        SettingsBuildingRequest settingsBuildingRequest = new DefaultSettingsBuildingRequest()
                .setSystemProperties(System.getProperties())
                .setUserSettingsFile(DEFAULT_USER_SETTINGS_FILE)
                .setGlobalSettingsFile(DEFAULT_GLOBAL_SETTINGS_FILE);

        DefaultSettingsBuilderFactory settingBuilderFactory = new DefaultSettingsBuilderFactory();
        DefaultSettingsBuilder settingsBuilder = settingBuilderFactory.newInstance();
        SettingsBuildingResult settingsBuildingResult = settingsBuilder.build(settingsBuildingRequest);

        return settingsBuildingResult.getEffectiveSettings();
    }

    private static List<RemoteRepository> extractRemoteRepositories(Settings settings) {
        Map<String, Profile> profiles = settings.getProfilesAsMap();
        List<RemoteRepository> builder = new ArrayList<>();

        boolean foundRepository = false;
        for (String profileName : settings.getActiveProfiles()) {
            Profile profile = profiles.get(profileName);
            if (profile != null) {
                List<Repository> repositories = profile.getRepositories();
                if (repositories != null) {
                    for (Repository repo : repositories) {
                        builder.add(new RemoteRepository.Builder(repo.getId(), "default", repo.getUrl()).build());
                        foundRepository = true;
                    }
                }
            }
        }

        if (!foundRepository && !settings.isOffline()) {
            builder.add(CENTRAL_REPO);
        }

        return Collections.unmodifiableList(builder);
    }
}