Maven.java

/*-
 * #%L
 * io.earcam.instrumental.archive.jpms
 * %%
 * Copyright (C) 2018 earcam
 * %%
 * SPDX-License-Identifier: (BSD-3-Clause OR EPL-1.0 OR Apache-2.0 OR MIT)
 * 
 * You <b>must</b> choose to accept, in full - any individual or combination of 
 * the following licenses:
 * <ul>
 * 	<li><a href="https://opensource.org/licenses/BSD-3-Clause">BSD-3-Clause</a></li>
 * 	<li><a href="https://www.eclipse.org/legal/epl-v10.html">EPL-1.0</a></li>
 * 	<li><a href="https://www.apache.org/licenses/LICENSE-2.0">Apache-2.0</a></li>
 * 	<li><a href="https://opensource.org/licenses/MIT">MIT</a></li>
 * </ul>
 * #L%
 */
package io.earcam.instrumental.archive.maven;

import static io.earcam.instrumental.archive.Archive.archive;
import static io.earcam.instrumental.archive.ArchiveConstruction.contentFrom;
import static java.nio.charset.StandardCharsets.UTF_8;
import static java.util.regex.Pattern.DOTALL;
import static java.util.regex.Pattern.MULTILINE;
import static java.util.stream.Collectors.toList;
import static org.eclipse.aether.util.artifact.JavaScopes.COMPILE;
import static org.eclipse.aether.util.filter.DependencyFilterUtils.classpathFilter;

import java.io.File;
import java.io.FileOutputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;

import org.apache.maven.repository.internal.MavenRepositorySystemUtils;
import org.eclipse.aether.DefaultRepositorySystemSession;
import org.eclipse.aether.RepositorySystem;
import org.eclipse.aether.artifact.Artifact;
import org.eclipse.aether.artifact.DefaultArtifact;
import org.eclipse.aether.collection.CollectRequest;
import org.eclipse.aether.connector.basic.BasicRepositoryConnectorFactory;
import org.eclipse.aether.graph.Dependency;
import org.eclipse.aether.impl.DefaultServiceLocator;
import org.eclipse.aether.installation.InstallRequest;
import org.eclipse.aether.repository.LocalRepository;
import org.eclipse.aether.repository.RemoteRepository;
import org.eclipse.aether.resolution.ArtifactResult;
import org.eclipse.aether.resolution.DependencyRequest;
import org.eclipse.aether.spi.connector.RepositoryConnectorFactory;
import org.eclipse.aether.spi.connector.transport.TransporterFactory;
import org.eclipse.aether.transport.file.FileTransporterFactory;
import org.eclipse.aether.transport.http.HttpTransporterFactory;
import org.eclipse.aether.util.artifact.SubArtifact;

import io.earcam.instrumental.archive.Archive;
import io.earcam.unexceptional.Closing;
import io.earcam.unexceptional.Exceptional;

public final class Maven {

	static final String ID_LOCAL_AS_REMOTE = "local-as-remote";
	static final String CENTRAL_URL = "http://central.maven.org/maven2/";
	static final String DEFAULT_TYPE = "default";
	static final String CENTRAL_ID = "central";

	private static final Pattern LOCAL_IN_SETTINGS = Pattern.compile(".*<localRepository>(.*?)</localRepository>.*", MULTILINE | DOTALL);
	private final RepositorySystem system;
	final List<RemoteRepository> remotes = new ArrayList<>();
	LocalRepository local;


	private Maven(RepositorySystem system)
	{
		this.system = system;
	}


	public static Maven maven()
	{
		return new Maven(createRepositorySystem());
	}


	public Maven usingCentral()
	{
		return usingRemote(CENTRAL_ID, DEFAULT_TYPE, CENTRAL_URL);
	}


	public Maven usingRemote(String id, String type, String url)
	{
		remotes.add(new RemoteRepository.Builder(id, type, url).build());
		return this;
	}


	public Maven usingDefaultLocal()
	{
		return usingLocal(findLocalRepoPath());
	}


	public Maven usingDefaultLocalAsARemote()
	{
		return usingRemote(ID_LOCAL_AS_REMOTE, DEFAULT_TYPE, findLocalRepoPath().toUri().toString());
	}


	private Path findLocalRepoPath()
	{
		Path m2 = Paths.get(System.getProperty("user.home")).resolve(".m2");
		Path settingsXml = m2.resolve("settings.xml");
		if(settingsXml.toFile().isFile()) {
			Matcher matcher = localInSettingsMatcher(settingsXml);
			if(matcher.matches()) {
				Path localPath = Paths.get(matcher.group(1));
				if(!localPath.isAbsolute()) {
					localPath = m2.resolve(localPath);
				}
				return checkLocalPath(localPath,
						"<localRepository> in " + settingsXml + " is not an existing directory: " + localPath.toAbsolutePath());
			}
		}
		Path localPath = m2.resolve("repository");
		return checkLocalPath(localPath, "No localRepository at " + localPath);
	}


	private Matcher localInSettingsMatcher(Path settingsXml)
	{
		String contents = new String(Exceptional.apply(Files::readAllBytes, settingsXml), UTF_8);
		return LOCAL_IN_SETTINGS.matcher(contents);
	}


	private Path checkLocalPath(Path localPath, String message)
	{
		if(!localPath.toFile().isDirectory()) {
			throw new IllegalStateException(message);
		}
		return localPath.toAbsolutePath();
	}


	public Maven usingLocal(Path path)
	{
		if(path.toFile().isFile()) {
			throw new IllegalArgumentException("local repository '" + path.toAbsolutePath() + "' exists and is not a directory");
		}
		local = new LocalRepository(path.toAbsolutePath().toString());
		return this;
	}


	private static RepositorySystem createRepositorySystem()
	{
		DefaultServiceLocator locator = MavenRepositorySystemUtils.newServiceLocator();
		locator.addService(RepositoryConnectorFactory.class, BasicRepositoryConnectorFactory.class);
		locator.addService(TransporterFactory.class, FileTransporterFactory.class);
		locator.addService(TransporterFactory.class, HttpTransporterFactory.class);
		return locator.getService(RepositorySystem.class);
	}


	private DefaultRepositorySystemSession createSession(RepositorySystem system)
	{
		DefaultRepositorySystemSession session = MavenRepositorySystemUtils.newSession();
		session.setLocalRepositoryManager(system.newLocalRepositoryManager(session, local));

		// TODO wrap up TransferListener and RepositoryListener so can be easily be
		// exposed for a Consumer<String> (client debug logging etc)
		return session;
	}


	/**
	 * Resolves compile-scoped dependencies (including transitive)
	 * 
	 * @param gavs the groupId:artifactId:version:type:classifier coordinates for Maven artifacts
	 * @return the dependencies converted to {@link Archive} instances
	 */
	public Map<MavenArtifact, Archive> dependencies(String... gavs)
	{
		List<ArtifactResult> response = resolve(gavs);

		Map<MavenArtifact, Archive> archives = new HashMap<>();

		for(ArtifactResult result : response) {
			File artifactFile = result.getArtifact().getFile();
			Archive archive = archive().sourcing(contentFrom(artifactFile)).toObjectModel();
			archives.put(new MavenArtifact(result.getArtifact()), archive);
		}
		return archives;
	}


	private List<ArtifactResult> resolve(String... gavs)
	{
		DefaultRepositorySystemSession session = createSession(system);

		List<Dependency> dependencies = Arrays.stream(gavs)
				.map(DefaultArtifact::new)
				.map(a -> new Dependency(a, COMPILE))
				.collect(toList());

		CollectRequest collect = new CollectRequest();
		collect.setDependencies(dependencies);

		collect.setRepositories(remotes);

		DependencyRequest request = new DependencyRequest(collect, classpathFilter(COMPILE));

		return Exceptional.apply(system::resolveDependencies, session, request).getArtifactResults();
	}


	public List<Path> classpath(String... gavs)
	{
		return resolve(gavs).stream()
				.map(ArtifactResult::getArtifact)
				.map(Artifact::getFile)
				.map(File::toPath)
				.collect(Collectors.toList());
	}


	public void install(MavenArtifact artifact, Archive archive, MavenArtifact... dependencies)
	{
		Path artifactPath = local.getBasedir().toPath()
				.resolve(Paths.get(".", artifact.groupId().split("\\.")))
				.resolve(Paths.get(artifact.artifactId(), artifact.baseVersion()));

		String fileName = artifact.artifactId() + '-' + artifact.baseVersion();

		// We get away with writing JAR directly into local repo,
		// but attempting same trick with POM results in overwrite of zero bytes

		Path jar = artifactPath.resolve(fileName + ".jar");
		Path pom = artifactPath.resolve(fileName + ".pom.X");

		archive.to(jar);

		Closing.closeAfterAccepting(FileOutputStream::new, pom.toFile(),
				o -> FakePom.createPom(artifact, dependencies, o));

		Artifact aether = artifact.toAether()
				.setFile(jar.toFile());

		Artifact pomAether = new SubArtifact(aether, "", "pom")
				.setFile(pom.toFile());

		InstallRequest installRequest = new InstallRequest();
		installRequest.addArtifact(aether).addArtifact(pomAether);

		DefaultRepositorySystemSession session = createSession(system);

		Exceptional.accept(system::install, session, installRequest);
	}
}