TransformMojo.java

/*
 * #%L
 * wcm.io
 * %%
 * Copyright (C) 2021 wcm.io
 * %%
 * 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.
 * #L%
 */
package io.wcm.maven.plugins.slinginitialcontenttransform;

import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.util.List;
import java.util.Map;
import java.util.jar.Attributes;
import java.util.jar.Manifest;
import java.util.stream.Collectors;
import java.util.zip.ZipEntry;
import java.util.zip.ZipOutputStream;

import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
import org.apache.commons.lang3.StringUtils;
import org.apache.jackrabbit.vault.packaging.PackageType;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugin.MojoFailureException;
import org.apache.maven.plugins.annotations.Component;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.project.MavenProject;
import org.apache.maven.project.MavenProjectHelper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import io.wcm.tooling.commons.contentpackagebuilder.ContentPackage;
import io.wcm.tooling.commons.contentpackagebuilder.ContentPackageBuilder;
import io.wcm.tooling.commons.contentpackagebuilder.PackageFilter;

/**
 * Extracts Sling-Initial-Content from an OSGi bundle and attaches two artifacts with classifiers:
 * <ul>
 * <li><code>bundle</code>: OSGi bundle without the Sling-Initial-Content</li>
 * <li><code>content</code>: Content packages with the Sling-Initial-Content transformed to FileVault</li>
 * </ul>
 */
@Mojo(name = "transform", requiresProject = true, threadSafe = true, defaultPhase = LifecyclePhase.PACKAGE)
public class TransformMojo extends AbstractMojo {

  private static final String CLASSIFIER_CONTENT = "content";
  private static final String CLASSIFIER_BUNDLE = "bundle";
  private static final String MANIFEST_FILE = "META-INF/MANIFEST.MF";

  private static final Logger log = LoggerFactory.getLogger(TransformMojo.class);

  /**
   * The name of the OSGi bundle file to process.
   */
  @Parameter(defaultValue = "${project.build.directory}/${project.build.finalName}.jar", required = true)
  private File file;

  /**
   * The group of the content package.
   */
  @Parameter(defaultValue = "${project.groupId}", required = true)
  private String group;

  /**
   * Generate attached "content" artifact with content package with Sling-Initial-Content.
   */
  @Parameter(defaultValue = "true", required = true)
  private boolean generateContent;

  /**
   * Generate attached "bundle" artifact with OSGi bundle without Sling-Initial-Content.
   */
  @Parameter(defaultValue = "true", required = true)
  private boolean generateBundle;

  /**
   * Additional XML namespace mappings.
   */
  @Parameter
  private Map<String, String> xmlNamespaces;

  /**
   * Allows to skip the plugin execution.
   */
  @Parameter(property = "slinginitialcontenttransform.skip", defaultValue = "false")
  private boolean skip;

  @Parameter(property = "project", required = true, readonly = true)
  private MavenProject project;
  @Component
  private MavenProjectHelper projectHelper;

  @Override
  public void execute() throws MojoExecutionException, MojoFailureException {
    if (skip) {
      log.debug("Skipping execution.");
      return;
    }
    if (!StringUtils.equals(project.getPackaging(), "jar")) {
      log.debug("Skipping execution - not a jar project: {}", project.getPackaging());
      return;
    }
    if (!file.exists()) {
      log.warn("File does not exist: {}", file.getPath());
      return;
    }

    try (OsgiBundleFile osgiBundle = new OsgiBundleFile(file)) {
      if (!osgiBundle.hasContent()) {
        log.debug("Skipping execution - bundle does not contain Sling-Initial-Content.");
        return;
      }
      transformBundle(osgiBundle);
    }
    catch (IOException ex) {
      throw new MojoExecutionException("Unable to transform bundle.", ex);
    }
  }

  /**
   * Transform OSGi bundle with Sling-Initial-Content to two separate artifacts with classifier "content" and "bundle".
   * @throws IOException I/O exception
   */
  private void transformBundle(OsgiBundleFile osgiBundle) throws IOException {
    if (generateContent) {
      File contentPackageFile = createContentPackage(osgiBundle);
      projectHelper.attachArtifact(project, "zip", CLASSIFIER_CONTENT, contentPackageFile);
    }
    if (generateBundle) {
      File bundleFile = createBundleWithoutContent(osgiBundle);
      projectHelper.attachArtifact(project, "jar", CLASSIFIER_BUNDLE, bundleFile);
    }
  }

  /**
   * Extract Sling-Initial-Content to a content package.
   * @param osgiBundle OSGi bundle
   * @return Content package file
   * @throws IOException I/O exception
   */
  private File createContentPackage(OsgiBundleFile osgiBundle) throws IOException {
    String contentPackageName = project.getBuild().getFinalName() + "-" + CLASSIFIER_CONTENT + ".zip";
    File contentPackageFile = new File(project.getBuild().getDirectory(), contentPackageName);
    if (contentPackageFile.exists()) {
      Files.delete(contentPackageFile.toPath());
    }

    ContentPackageBuilder contentPackageBuilder = new ContentPackageBuilder()
        .group(this.group)
        .name(project.getArtifactId() + "-" + CLASSIFIER_CONTENT)
        .version(project.getVersion())
        .packageType(PackageType.APPLICATION.name().toLowerCase());
    for (ContentMapping mapping : osgiBundle.getContentMappings()) {
      contentPackageBuilder.filter(new PackageFilter(mapping.getContentPath()));
    }
    for (Map.Entry<String, String> namespace : osgiBundle.getNamespaces().entrySet()) {
      contentPackageBuilder.xmlNamespace(namespace.getKey(), namespace.getValue());
    }
    if (xmlNamespaces != null) {
      for (Map.Entry<String, String> namespace : xmlNamespaces.entrySet()) {
        contentPackageBuilder.xmlNamespace(namespace.getKey(), namespace.getValue());
      }
    }
    try (ContentPackage contentPackage = contentPackageBuilder.build(contentPackageFile)) {
      for (ContentMapping mapping : osgiBundle.getContentMappings()) {
        List<BundleEntry> entries = osgiBundle.getContentEntries(mapping).collect(Collectors.toList());

        // first collect all paths we do not need to create explicit directories for
        CollectNonDirectoryPathsProcessor nonDirectoryPaths = new CollectNonDirectoryPathsProcessor();
        for (BundleEntry entry : entries) {
          processContent(contentPackage, entry, mapping, nonDirectoryPaths);
        }

        // then generate the actual content in content package
        WriteContentProcessor writeContent = new WriteContentProcessor(nonDirectoryPaths.getPaths());
        for (BundleEntry entry : entries) {
          processContent(contentPackage, entry, mapping, writeContent);
        }
      }
    }

    log.info("Created package with Sling-Initial-Content: {}", contentPackageFile.getName());
    return contentPackageFile;
  }

  /**
   * Processes a JAR file entry in the OSGi bundle.
   * @param contentPackage Content package
   * @param entry Entry
   * @param mapping Content mapping that is currently processed
   * @param processor Processor to do the actual work
   * @throws IOException I/O exception
   */
  private void processContent(ContentPackage contentPackage, BundleEntry entry, ContentMapping mapping,
      BundleEntryProcessor processor) throws IOException {
    String extension = FilenameUtils.getExtension(entry.getPath());
    if (entry.isDirectory()) {
      String path = StringUtils.removeEnd(entry.getPath(), "/");
      processor.directory(path, contentPackage);
    }
    else if (mapping.isJson() && StringUtils.equals(extension, "json")) {
      String path = StringUtils.substringBeforeLast(entry.getPath(), ".json");
      processor.jsonContent(path, entry, contentPackage);
    }
    else if (mapping.isXml() && StringUtils.equals(extension, "xml")) {
      String path = StringUtils.substringBeforeLast(entry.getPath(), ".xml");
      processor.xmlContent(path, entry, contentPackage);
    }
    else {
      String path = entry.getPath();
      processor.binaryContent(path, entry, contentPackage);
    }
  }

  /**
   * Create OSGi bundle JAR file without Sling-Initial-Content.
   * @param osgiBundle OSGi bundle
   * @return OSGi bundle file
   * @throws IOException I/O exception
   */
  private File createBundleWithoutContent(OsgiBundleFile osgiBundle) throws IOException {
    String bundleFileName = project.getBuild().getFinalName() + "-" + CLASSIFIER_BUNDLE + ".jar";
    File bundleFile = new File(project.getBuild().getDirectory(), bundleFileName);
    if (bundleFile.exists()) {
      Files.delete(bundleFile.toPath());
    }

    try (FileOutputStream fos = new FileOutputStream(bundleFile);
        ZipOutputStream zos = new ZipOutputStream(fos)) {
      List<BundleEntry> entries = osgiBundle.getNonContentEntries().collect(Collectors.toList());
      for (BundleEntry entry : entries) {
        zos.putNextEntry(new ZipEntry(entry.getPath()));
        if (!entry.isDirectory()) {
          try (InputStream is = entry.getInputStream()) {
            if (StringUtils.equals(entry.getPath(), MANIFEST_FILE)) {
              Manifest transformedManifest = getManifestWithoutSlingInitialContentHeader(is);
              transformedManifest.write(zos);
            }
            else {
              IOUtils.copy(is, zos);
            }
          }
        }
      }
    }

    log.info("Created bundle without content: {}", bundleFile.getName());
    return bundleFile;
  }

  /**
   * Removes Sling-Initial-Content header of manifest.
   * @param is Inputstream for manifest file
   * @return Manifest
   * @throws IOException I/O exception
   */
  private Manifest getManifestWithoutSlingInitialContentHeader(InputStream is) throws IOException {
    Manifest manifest = new Manifest(is);
    manifest.getMainAttributes().remove(new Attributes.Name(OsgiBundleFile.HEADER_INITIAL_CONTENT));
    return manifest;
  }

}