001/*
002 * Licensed under the Apache License, Version 2.0 (the "License");
003 * you may not use this file except in compliance with the License.
004 * You may obtain a copy of the License at
005 *
006 * http://www.apache.org/licenses/LICENSE-2.0
007 *
008 * Unless required by applicable law or agreed to in writing, software
009 * distributed under the License is distributed on an "AS IS" BASIS,
010 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
011 * See the License for the specific language governing permissions and
012 * limitations under the License.
013 */
014
015package de.softwareforge.testing.maven;
016
017import static java.lang.String.format;
018import static java.util.Objects.requireNonNull;
019
020import java.io.File;
021import java.io.IOException;
022import java.util.ArrayList;
023import java.util.Collections;
024import java.util.List;
025import java.util.Map;
026import java.util.Objects;
027import java.util.SortedSet;
028import java.util.TreeSet;
029
030import org.apache.maven.repository.internal.MavenRepositorySystemUtils;
031import org.apache.maven.settings.Profile;
032import org.apache.maven.settings.Repository;
033import org.apache.maven.settings.Settings;
034import org.apache.maven.settings.building.DefaultSettingsBuilder;
035import org.apache.maven.settings.building.DefaultSettingsBuilderFactory;
036import org.apache.maven.settings.building.DefaultSettingsBuildingRequest;
037import org.apache.maven.settings.building.SettingsBuildingException;
038import org.apache.maven.settings.building.SettingsBuildingRequest;
039import org.apache.maven.settings.building.SettingsBuildingResult;
040import org.eclipse.aether.DefaultRepositorySystemSession;
041import org.eclipse.aether.RepositoryException;
042import org.eclipse.aether.RepositorySystem;
043import org.eclipse.aether.RepositorySystemSession;
044import org.eclipse.aether.artifact.Artifact;
045import org.eclipse.aether.artifact.DefaultArtifact;
046import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory;
047import org.eclipse.aether.impl.DefaultServiceLocator;
048import org.eclipse.aether.repository.LocalRepository;
049import org.eclipse.aether.repository.RemoteRepository;
050import org.eclipse.aether.resolution.ArtifactRequest;
051import org.eclipse.aether.resolution.ArtifactResult;
052import org.eclipse.aether.resolution.VersionRangeRequest;
053import org.eclipse.aether.resolution.VersionRangeResolutionException;
054import org.eclipse.aether.resolution.VersionRangeResult;
055import org.eclipse.aether.spi.connector.RepositoryConnectorFactory;
056import org.eclipse.aether.spi.connector.transport.TransporterFactory;
057import org.eclipse.aether.spi.locator.ServiceLocator;
058import org.eclipse.aether.transport.file.FileTransporterFactory;
059import org.eclipse.aether.transport.http.HttpTransporterFactory;
060import org.eclipse.aether.version.Version;
061import org.slf4j.Logger;
062import org.slf4j.LoggerFactory;
063
064/**
065 * A quick and dirty artifact loader. Downloads published artifacts from the Maven repository system.
066 * <p>
067 * The loader respects the local maven settings (repositories, mirrors etc.) if present. If no configuration is found, a hard-coded reference to <a
068 * href="https://repo.maven.apache.org/maven2/">Maven Central</a> is used.
069 */
070public final class MavenArtifactLoader {
071
072    private static final Logger LOG = LoggerFactory.getLogger(MavenArtifactLoader.class);
073
074    private static final RemoteRepository CENTRAL_REPO = new RemoteRepository.Builder("central", "default", "https://repo.maven.apache.org/maven2/").build();
075
076    private static final List<RemoteRepository> knownRemoteRepos = List.of(
077            CENTRAL_REPO,
078            new RemoteRepository.Builder("snapshots", "default", "https://central.sonatype.com/repository/maven-snapshots/").build()
079    );
080
081    private static final String USER_HOME = System.getProperty("user.home");
082    private static final File USER_MAVEN_HOME = new File(USER_HOME, ".m2");
083    private static final String ENV_M2_HOME = System.getenv("M2_HOME");
084
085    private static final File DEFAULT_USER_SETTINGS_FILE = new File(USER_MAVEN_HOME, "settings.xml");
086    private static final File DEFAULT_USER_REPOSITORY = new File(USER_MAVEN_HOME, "repository");
087    private static final File DEFAULT_GLOBAL_SETTINGS_FILE =
088            new File(System.getProperty("maven.home", Objects.requireNonNullElse(ENV_M2_HOME, "")), "conf/settings.xml");
089
090    private final RepositorySystem repositorySystem;
091    private final RepositorySystemSession mavenSession;
092    private final List<RemoteRepository> remoteRepositories;
093
094    private final String extension;
095
096    /**
097     * Creates a new artifact loader for 'jar' artifacts.
098     */
099    public MavenArtifactLoader() {
100        this("jar");
101    }
102
103    /**
104     * Creates a new artifact loader for artifacts.
105     *
106     * @param extension The artifact extension. Must not be null.
107     */
108    public MavenArtifactLoader(String extension) {
109        this(extension, null);
110    }
111
112    static MavenArtifactLoader forTesting() {
113        return new MavenArtifactLoader("jar", knownRemoteRepos);
114    }
115
116    private MavenArtifactLoader(String extension, List<RemoteRepository> remoteRepositoriesForTesting) {
117        this.extension = requireNonNull(extension, "extension is null");
118
119        @SuppressWarnings("deprecation")
120        ServiceLocator serviceLocator = createServiceLocator();
121        this.repositorySystem = serviceLocator.getService(RepositorySystem.class);
122
123        try {
124            Settings settings = createSettings();
125            File localRepositoryLocation = settings.getLocalRepository() != null ? new File(settings.getLocalRepository()) : DEFAULT_USER_REPOSITORY;
126            LocalRepository localRepository = new LocalRepository(localRepositoryLocation);
127
128            if (remoteRepositoriesForTesting != null) {
129                this.remoteRepositories = remoteRepositoriesForTesting;
130            } else {
131                this.remoteRepositories = extractRemoteRepositories(settings);
132            }
133
134            DefaultRepositorySystemSession mavenSession = MavenRepositorySystemUtils.newSession();
135
136            this.mavenSession = mavenSession.setLocalRepositoryManager(repositorySystem.newLocalRepositoryManager(mavenSession, localRepository));
137
138        } catch (SettingsBuildingException e) {
139            throw new IllegalStateException("Could not load maven settings:", e);
140        }
141    }
142
143    /**
144     * Create a new version match builder to retrieve an artifact.
145     *
146     * @param groupId    The Apache Maven Group Id. Must not be null.
147     * @param artifactId The Apache Maven Artifact Id. Must not be null.
148     * @return A {@link MavenVersionMatchBuilder} instance
149     */
150    public MavenVersionMatchBuilder builder(String groupId, String artifactId) {
151        requireNonNull(groupId, "groupId is null");
152        requireNonNull(artifactId, "artifactId is null");
153
154        return new MavenVersionMatchBuilder(this, groupId, artifactId);
155    }
156
157    /**
158     * Download an artifact file from the Maven repository system.
159     *
160     * @param groupId    The Apache Maven Group Id. Must not be null.
161     * @param artifactId The Apache Maven Artifact Id. Must not be null.
162     * @param version    The Apache Maven Artifact version. Must not be null.
163     * @return A file representing a successfully downloaded artifact.
164     * @throws IOException If the artifact could not be found or an IO problem happened while locating or downloading the artifact.
165     */
166    public File getArtifactFile(String groupId, String artifactId, String version) throws IOException {
167        requireNonNull(groupId, "groupId is null");
168        requireNonNull(artifactId, "artifactId is null");
169        requireNonNull(version, "version is null");
170
171        ArtifactRequest artifactRequest = new ArtifactRequest();
172        artifactRequest.setArtifact(new DefaultArtifact(groupId, artifactId, extension, version));
173        artifactRequest.setRepositories(this.remoteRepositories);
174        try {
175            ArtifactResult artifactResult = this.repositorySystem.resolveArtifact(mavenSession, artifactRequest);
176            Artifact artifact = artifactResult.getArtifact();
177            return artifact.getFile();
178        } catch (RepositoryException e) {
179            throw new IOException(e);
180        }
181    }
182
183    /**
184     * Find a matching artifact version from a partially defined artifact version.
185     * <p>
186     * Any located artifact in the repository system is compared to the version given.
187     * <br>
188     * Using this method is equivalent to calling
189     * <pre>
190     *  builder(groupId, artifactId)
191     *                 .partialMatch(version)
192     *                 .extension(extension)
193     *                 .includeSnapshots(true)
194     *                 .findBestMatch();
195     * </pre>
196     *
197     * but will throw an IOException if no version could be found.
198     *
199     * @param groupId    The Apache Maven Group Id. Must not be null.
200     * @param artifactId The Apache Maven Artifact Id. Must not be null.
201     * @param version    A partial version string. Must not be null. An empty string matches any version.
202     * @return The latest version that matches the partial version string. It either starts with the partial version string given (an empty version string
203     * matches any version) or is exactly the provided version.
204     * @throws IOException If an IO problem happened during artifact download or no versions were found during resolution.
205     */
206    public String findLatestVersion(String groupId, String artifactId, String version) throws IOException {
207        requireNonNull(groupId, "groupId is null");
208        requireNonNull(artifactId, "artifactId is null");
209        requireNonNull(version, "version is null");
210        return builder(groupId, artifactId)
211                .partialMatch(version)
212                .extension(extension)
213                .includeSnapshots(true)
214                .findBestMatch()
215                .orElseThrow(() -> new IOException(format("No suitable candidate for %s:%s:%s found!", groupId, artifactId, version)));
216    }
217
218    SortedSet<Version> findAllVersions(MavenVersionMatchBuilder builder) throws IOException {
219
220        Artifact artifact = new DefaultArtifact(builder.groupId(), builder.artifactId(), builder.extension(), "[0,)");
221
222        VersionRangeRequest rangeRequest = new VersionRangeRequest();
223        rangeRequest.setArtifact(artifact);
224        rangeRequest.setRepositories(this.remoteRepositories);
225
226        try {
227            VersionRangeResult rangeResult = this.repositorySystem.resolveVersionRange(mavenSession, rangeRequest);
228            SortedSet<Version> resultBuilder = new TreeSet<>();
229            List<Version> artifactVersions = rangeResult.getVersions();
230            VersionStrategy versionStrategy = builder.versionStrategy();
231            if (artifactVersions != null) {
232                for (Version artifactVersion : artifactVersions) {
233                    boolean isSnapshot = artifactVersion.toString().endsWith("-SNAPSHOT");
234                    boolean match = versionStrategy.matchVersion(artifactVersion);
235
236                    // remove match if snapshots are not requested but the version is a snapshot
237                    if (isSnapshot) {
238                        match &= builder.includeSnapshots();
239                    }
240
241                    if (match) {
242                        resultBuilder.add(artifactVersion);
243                    }
244                }
245            }
246            return Collections.unmodifiableSortedSet(resultBuilder);
247        } catch (VersionRangeResolutionException e) {
248            throw new IOException(format("Could not resolve version range: %s", rangeRequest), e);
249        }
250    }
251
252
253    @SuppressWarnings("deprecation")
254    private static ServiceLocator createServiceLocator() {
255        DefaultServiceLocator locator = MavenRepositorySystemUtils.newServiceLocator();
256
257        locator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class);
258        locator.addService(TransporterFactory.class, FileTransporterFactory.class);
259        locator.addService(TransporterFactory.class, HttpTransporterFactory.class);
260
261        locator.setErrorHandler(new DefaultServiceLocator.ErrorHandler() {
262            @Override
263            public void serviceCreationFailed(Class<?> type, Class<?> impl, Throwable e) {
264                LOG.error(format("Could not create instance of %s (implementation %s): ", type.getSimpleName(), impl.getSimpleName()), e);
265            }
266        });
267
268        return locator;
269    }
270
271    private static Settings createSettings() throws SettingsBuildingException {
272        SettingsBuildingRequest settingsBuildingRequest = new DefaultSettingsBuildingRequest()
273                .setSystemProperties(System.getProperties())
274                .setUserSettingsFile(DEFAULT_USER_SETTINGS_FILE)
275                .setGlobalSettingsFile(DEFAULT_GLOBAL_SETTINGS_FILE);
276
277        DefaultSettingsBuilderFactory settingBuilderFactory = new DefaultSettingsBuilderFactory();
278        DefaultSettingsBuilder settingsBuilder = settingBuilderFactory.newInstance();
279        SettingsBuildingResult settingsBuildingResult = settingsBuilder.build(settingsBuildingRequest);
280
281        return settingsBuildingResult.getEffectiveSettings();
282    }
283
284    private static List<RemoteRepository> extractRemoteRepositories(Settings settings) {
285        Map<String, Profile> profiles = settings.getProfilesAsMap();
286        List<RemoteRepository> builder = new ArrayList<>();
287
288        boolean foundRepository = false;
289        for (String profileName : settings.getActiveProfiles()) {
290            Profile profile = profiles.get(profileName);
291            if (profile != null) {
292                List<Repository> repositories = profile.getRepositories();
293                if (repositories != null) {
294                    for (Repository repo : repositories) {
295                        builder.add(new RemoteRepository.Builder(repo.getId(), "default", repo.getUrl()).build());
296                        foundRepository = true;
297                    }
298                }
299            }
300        }
301
302        if (!foundRepository && !settings.isOffline()) {
303            builder.add(CENTRAL_REPO);
304        }
305
306        return Collections.unmodifiableList(builder);
307    }
308}