diff --git a/.mvn/wrapper/maven-wrapper.properties b/.mvn/wrapper/maven-wrapper.properties new file mode 100644 index 0000000..c0bcafe --- /dev/null +++ b/.mvn/wrapper/maven-wrapper.properties @@ -0,0 +1,3 @@ +wrapperVersion=3.3.4 +distributionType=only-script +distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.11/apache-maven-3.9.11-bin.zip diff --git a/Dockerfile b/Dockerfile index cc41292..dbeaca0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,11 @@ -FROM eclipse-temurin:25-jdk AS build -WORKDIR /workspace +FROM eclipse-temurin:21-jdk RUN apt-get update \ - && apt-get install -y --no-install-recommends maven \ - && rm -rf /var/lib/apt/lists/* + && apt-get install -y maven \ + && rm -rf /var/lib/apt/lists/* -COPY pom.xml . -COPY src ./src - -RUN mvn -q -DskipTests package - -FROM eclipse-temurin:25-jre -WORKDIR /app - -COPY --from=build /workspace/target/*.jar app.jar +COPY methodologist-setup-service-0.0.1-SNAPSHOT.jar /app/app.jar EXPOSE 8090 -ENTRYPOINT ["java", "-jar", "/app/app.jar"] - +ENTRYPOINT ["java", "-jar", "/app/app.jar"] \ No newline at end of file diff --git a/pom.xml b/pom.xml index ac85398..c9505fd 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ org.springframework.boot spring-boot-starter-parent 4.0.6 - + tools.vitruv methodologist-setup-service @@ -28,8 +28,17 @@ 21 + + https://sonarcloud.io + vitruv-tools + vitruv-tools_Methodologist-SetupService + + + org.freemarker + freemarker + org.springframework.boot spring-boot-starter-actuator @@ -109,7 +118,7 @@ - 1.22.0 + 1.28.0 diff --git a/src/main/java/tools/vitruv/methodologist/setup/config/CustomClassLoader.java b/src/main/java/tools/vitruv/methodologist/setup/config/CustomClassLoader.java new file mode 100644 index 0000000..5bd3253 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/config/CustomClassLoader.java @@ -0,0 +1,26 @@ +package tools.vitruv.methodologist.setup.config; + +import java.net.URL; +import java.net.URLClassLoader; + +/** The CustomClassLoader class is used to load classes from a custom classpath. */ +public class CustomClassLoader extends URLClassLoader { + /** + * The constructor of the CustomClassLoader class. + * + * @param urls The URLs of the classpath. + * @param parent The parent class loader. + */ + public CustomClassLoader(URL[] urls, ClassLoader parent) { + super(urls, parent); + } + + /** + * Adds a JAR file to the classpath. + * + * @param url The URL of the JAR file. + */ + public void addJar(URL url) { + this.addURL(url); + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java b/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java index 302dc01..ca097db 100644 --- a/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java +++ b/src/main/java/tools/vitruv/methodologist/setup/config/GlobalExceptionHandler.java @@ -1,5 +1,6 @@ package tools.vitruv.methodologist.setup.config; +import java.nio.file.NoSuchFileException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -69,6 +70,30 @@ public ResponseEntity handleGenmodelException( return new ResponseEntity<>(errorResponse, HttpStatus.UNPROCESSABLE_ENTITY); } + /** + * Handles NoSuchFileException raised when an expected build artifact is missing. + * + * @param ex the exception + * @param request the web request + * @return error response with bad request status + */ + @ExceptionHandler(NoSuchFileException.class) + public ResponseEntity handleNoSuchFileException( + NoSuchFileException ex, WebRequest request) { + log.error("NoSuchFileException occurred: {}", ex.getMessage(), ex); + + ErrorResponseDTO errorResponse = + ErrorResponseDTO.builder() + .errorCode("VSUM_ARTIFACT_NOT_FOUND") + .message(ex.getMessage()) + .status(HttpStatus.BAD_REQUEST.value()) + .timestamp(System.currentTimeMillis()) + .path(request.getDescription(false).replace("uri=", "")) + .build(); + + return new ResponseEntity<>(errorResponse, HttpStatus.BAD_REQUEST); + } + /** * Handles all other exceptions. * diff --git a/src/main/java/tools/vitruv/methodologist/setup/config/MetamodelLocation.java b/src/main/java/tools/vitruv/methodologist/setup/config/MetamodelLocation.java new file mode 100644 index 0000000..ad11253 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/config/MetamodelLocation.java @@ -0,0 +1,10 @@ +package tools.vitruv.methodologist.setup.config; + +import java.io.File; + +/** + * The MetamodelLocation class is used to store the location of a metamodel and its corresponding + * genmodel. + */ +public record MetamodelLocation( + File metamodel, File genmodel, String genmodelUri, String modelDirectory) {} diff --git a/src/main/java/tools/vitruv/methodologist/setup/config/VitruvConfiguration.java b/src/main/java/tools/vitruv/methodologist/setup/config/VitruvConfiguration.java new file mode 100644 index 0000000..c740c04 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/config/VitruvConfiguration.java @@ -0,0 +1,145 @@ +package tools.vitruv.methodologist.setup.config; + +import java.io.File; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.eclipse.emf.codegen.ecore.genmodel.GenModel; +import org.eclipse.emf.codegen.ecore.genmodel.GenModelPackage; +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.EPackage; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.resource.ResourceSet; +import org.eclipse.emf.ecore.resource.impl.ResourceSetImpl; +import org.eclipse.emf.ecore.xmi.impl.XMIResourceFactoryImpl; + +/** The VitruvConfiguration class is used to store the configuration of the Vitruv CLI. */ +@Slf4j +public class VitruvConfiguration { + + /** -- GETTER -- Returns the model names. */ + @Getter private final List modelNames = new ArrayList<>(); + + private final List metamodelLocations = new ArrayList<>(); + + /** -- GETTER -- Returns the reaction file locations used by the CLI. */ + @Getter private final List reactionLocations = new ArrayList<>(); + + /** + * -- SETTER -- Sets the local path of the configuration. + * + *

-- GETTER -- Returns the local path of the configuration. + */ + @Getter @Setter private Path localPath; + + /** -- GETTER -- Returns the package name. */ + @Getter private String packageName; + + /** + * -- SETTER -- Sets the workflow of the configuration. + * + *

-- GETTER -- Returns the workflow of the configuration. + */ + @Getter @Setter private File workflow; + + /** + * Removes the last segment of a string. + * + * @param input The input string. + * @return The input string without the last segment. + */ + public static String removeLastSegment(String input) { + int lastDotIndex = input.lastIndexOf('.'); + if (lastDotIndex == -1) { + return input; + } + return input.substring(0, lastDotIndex); + } + + /** + * Adds a metamodel location to the configuration. + * + * @param metamodelLocation The metamodel location to add. + */ + public void addMetamodelLocations(MetamodelLocation metamodelLocation) { + this.metamodelLocations.add(metamodelLocation); + } + + /** + * Sets the reaction file locations used by the CLI. + * + * @param reactionLocations list of paths to reaction files. + */ + public void setReactionLocations(List reactionLocations) { + this.reactionLocations.clear(); + if (reactionLocations != null) { + this.reactionLocations.addAll(reactionLocations); + } + } + + /** + * Returns the metamodel locations. + * + * @return The metamodel locations. + */ + public List getMetaModelLocations() { + return this.metamodelLocations; + } + + /** + * Sets the metamodel locations using a semicolon-separated list of {@code ecore,genmodel} pairs. + * + * @param paths The metamodel argument string. + */ + public void setMetaModelLocations(String paths) { + Resource.Factory.Registry reg = Resource.Factory.Registry.INSTANCE; + reg.getExtensionToFactoryMap().put("ecore", new XMIResourceFactoryImpl()); + reg.getExtensionToFactoryMap().put("genmodel", new XMIResourceFactoryImpl()); + + GenModelPackage.eINSTANCE.eClass(); + + for (String modelPaths : paths.split(";")) { + String metamodelPath = modelPaths.split(",")[0]; + String genmodelPath = modelPaths.split(",")[1]; + + File metamodel = new File(metamodelPath); + File genmodel = new File(genmodelPath); + + String localModelDirectory = ""; + + ResourceSet resourceSet = new ResourceSetImpl(); + URI uri = URI.createFileURI(metamodel.getAbsolutePath().trim()); + Resource resource = resourceSet.getResource(uri, true); + String nsUri = ""; + if (!resource.getContents().isEmpty() + && resource.getContents().get(0) instanceof EPackage ePackage) { + URI genmodelURI = URI.createFileURI(genmodel.getAbsolutePath()); + nsUri = genmodelURI.toString(); + Resource genmodelResource = resourceSet.getResource(genmodelURI, true); + modelNames.add(ePackage.getName()); + if (!genmodelResource.getContents().isEmpty() + && genmodelResource.getContents().get(0) instanceof GenModel genModel) { + String packageString = removeLastSegment(genModel.getModelPluginID()); + log.info("--------------------->>>> " + packageString); + this.setPackageName(packageString); + localModelDirectory = genModel.getModelDirectory(); + } + } + + this.addMetamodelLocations( + new MetamodelLocation(metamodel, genmodel, nsUri, localModelDirectory)); + } + } + + /** + * Sets the package name. + * + * @param packageName The package name. + */ + public void setPackageName(String packageName) { + this.packageName = packageName.replace("\\s", ""); + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/emf/EMFModelInitializer.java b/src/main/java/tools/vitruv/methodologist/setup/emf/EMFModelInitializer.java new file mode 100644 index 0000000..6f0ef01 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/emf/EMFModelInitializer.java @@ -0,0 +1,176 @@ +package tools.vitruv.methodologist.setup.emf; + +import java.io.File; +import java.lang.reflect.Field; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashMap; +import java.util.Map; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import java.util.stream.Stream; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; +import org.eclipse.emf.ecore.EPackage; +import tools.vitruv.methodologist.setup.messages.ErrorMessages; +import tools.vitruv.methodologist.setup.messages.InfoMessages; + +/** + * Utility class for initializing EMF packages before Xtext validation. + * + *

This class loads metamodel classes from a generated model JAR and registers them with EMF's + * package registry to ensure Xtext can resolve nsURIs during validation. + */ +public class EMFModelInitializer { + private static final Logger logger = LogManager.getLogger(EMFModelInitializer.class); + + /** + * Initializes EMF packages from a model JAR file. + * + * @param modelJarPath path to the generated model JAR + * @return map of nsURI to EPackage for all registered packages + */ + public static Map initializeFromJar(String modelJarPath) { + Map packages = new HashMap<>(); + + File jarFile = new File(modelJarPath); + if (!jarFile.exists()) { + logger.warn("Model JAR not found: {}", modelJarPath); + return packages; + } + + try (JarFile jar = new JarFile(jarFile)) { + Collection entries = jar.stream().toList(); + + entries.stream() + .filter(entry -> entry.getName().endsWith(".ecore")) + .forEach( + entry -> { + try { + loadEcorePackage(jar.getInputStream(entry), packages); + } catch (Exception e) { + logger.warn("Failed to load ecore from {}: {}", entry.getName(), e.getMessage()); + } + }); + + entries.stream() + .filter(entry -> entry.getName().endsWith("FactoryImpl.class")) + .map(entry -> entry.getName().replace('/', '.').replace(".class", "")) + .forEach( + className -> { + try { + loadMetamodelPackage(className, packages); + } catch (Exception e) { + logger.debug("Could not load package from {}: {}", className, e.getMessage()); + } + }); + + } catch (Exception e) { + logger.error("Error initializing EMF packages from JAR: {}", modelJarPath, e); + } + + logger.info("Initialized {} EMF packages from model JAR", packages.size()); + return packages; + } + + /** + * Initializes EMF packages from a classes directory. + * + * @param classesPath path to the generated classes directory + * @param classLoader classloader to use for loading classes + * @return map of nsURI to EPackage for all registered packages + */ + public static Map initializeFromClasses( + String classesPath, ClassLoader classLoader) { + Map packages = new HashMap<>(); + + try { + Path classesDir = Path.of(classesPath); + if (!Files.exists(classesDir)) { + logger.warn(ErrorMessages.EMF_CLASSES_DIR_NOT_FOUND, classesPath); + return packages; + } + + try (Stream paths = Files.walk(classesDir)) { + paths + .filter(Files::isRegularFile) + .filter(p -> p.toString().endsWith("FactoryImpl.class")) + .forEach( + classPath -> { + try { + String className = + classesDir + .relativize(classPath) + .toString() + .replace(File.separator, ".") + .replace(".class", ""); + loadMetamodelPackage(className, packages); + } catch (Exception e) { + logger.debug( + ErrorMessages.EMF_CLASSES_LOAD_PACKAGE_ERROR, classPath, e.getMessage()); + } + }); + } + + } catch (Exception e) { + logger.error(ErrorMessages.EMF_CLASSES_INIT_ERROR, classesPath, e); + } + + logger.info(InfoMessages.EMF_CLASSES_INIT_SUCCESS, packages.size()); + return packages; + } + + /** + * Registers an EPackage with EMF's package registry. + * + * @param nsUri the nsURI for the package + * @param ePackage the EPackage instance + */ + public static void registerPackage(String nsUri, EPackage ePackage) { + if (nsUri != null && ePackage != null) { + EPackage.Registry.INSTANCE.put(nsUri, ePackage); + logger.debug("Registered EPackage: {} -> {}", nsUri, ePackage.getName()); + } + } + + /** + * Loads an ecore model and registers its packages. + * + * @param ecoreStream input stream containing an EMF ecore file + * @param packages map to collect registered packages + */ + private static void loadEcorePackage( + java.io.InputStream ecoreStream, Map packages) {} + + /** + * Loads a metamodel package class and registers it. + * + * @param className fully qualified name of a FactoryImpl class + * @param packages map to collect registered packages + */ + private static void loadMetamodelPackage(String className, Map packages) { + try { + Class factoryClass = Class.forName(className); + + Field instanceField = factoryClass.getField("eINSTANCE"); + Object factoryInstance = instanceField.get(null); + + String packageClassName = + className.substring(0, className.lastIndexOf("FactoryImpl")) + "Package"; + Class packageClass = Class.forName(packageClassName); + Field instance = packageClass.getField("eINSTANCE"); + EPackage ePackage = (EPackage) instance.get(null); + + if (ePackage != null) { + String nsUri = ePackage.getNsURI(); + packages.put(nsUri, ePackage); + registerPackage(nsUri, ePackage); + } + } catch (ClassNotFoundException e) { + logger.debug("Metamodel class not found on this classpath: {}", className); + } catch (Exception e) { + logger.trace("Error loading metamodel package {}: {}", className, e.getMessage()); + } + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/exception/MissingModelException.java b/src/main/java/tools/vitruv/methodologist/setup/exception/MissingModelException.java new file mode 100644 index 0000000..543196a --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/exception/MissingModelException.java @@ -0,0 +1,20 @@ +package tools.vitruv.methodologist.setup.exception; + +/** Exception thrown when a required model is missing. */ +public class MissingModelException extends Exception { + + /** Exception thrown when a required model is missing. */ + public MissingModelException(String message) { + super(message); + } + + /** Exception thrown when a required model is missing. */ + public MissingModelException(String message, Throwable cause) { + super(message, cause); + } + + /** Exception thrown when a required model is missing. */ + public MissingModelException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/messages/ErrorMessages.java b/src/main/java/tools/vitruv/methodologist/setup/messages/ErrorMessages.java index 6a064b8..b43e2b7 100644 --- a/src/main/java/tools/vitruv/methodologist/setup/messages/ErrorMessages.java +++ b/src/main/java/tools/vitruv/methodologist/setup/messages/ErrorMessages.java @@ -18,6 +18,14 @@ public final class ErrorMessages { public static final String MULTIPART_FILE_UPLOAD_ERROR = "Failed to save uploaded file"; public static final String XML_STREAM_ERROR = "Failed to process XML stream"; public static final String UNEXPECTED_ERROR = "An unexpected error occurred"; + public static final String VSUM_JAR_NOT_FOUND = + "Expected VSUM jar was not produced by the build: %s"; + public static final String VSUM_REACTION_FILES_REQUIRED = + "At least one reaction file is required"; + public static final String EMF_CLASSES_DIR_NOT_FOUND = "Classes directory not found: {}"; + public static final String EMF_CLASSES_LOAD_PACKAGE_ERROR = "Could not load package from {}: {}"; + public static final String EMF_CLASSES_INIT_ERROR = + "Error initializing EMF packages from classes directory: {}"; private ErrorMessages() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); diff --git a/src/main/java/tools/vitruv/methodologist/setup/messages/InfoMessages.java b/src/main/java/tools/vitruv/methodologist/setup/messages/InfoMessages.java index 5ba7120..830870f 100644 --- a/src/main/java/tools/vitruv/methodologist/setup/messages/InfoMessages.java +++ b/src/main/java/tools/vitruv/methodologist/setup/messages/InfoMessages.java @@ -26,6 +26,8 @@ public final class InfoMessages { public static final String FOREIGN_MODEL_ADDED = "Added missing foreignModel entry: %s"; public static final String FOREIGN_MODEL_WOULD_ADD = "Would add missing foreignModel entry: %s"; public static final String UNNAMED_PACKAGE = ""; + public static final String EMF_CLASSES_INIT_SUCCESS = + "Initialized {} EMF packages from classes directory"; private InfoMessages() { throw new UnsupportedOperationException("This is a utility class and cannot be instantiated"); diff --git a/src/main/java/tools/vitruv/methodologist/setup/model/controller/GenmodelController.java b/src/main/java/tools/vitruv/methodologist/setup/model/controller/GenmodelController.java index 5b9e2d6..135a71e 100644 --- a/src/main/java/tools/vitruv/methodologist/setup/model/controller/GenmodelController.java +++ b/src/main/java/tools/vitruv/methodologist/setup/model/controller/GenmodelController.java @@ -156,6 +156,6 @@ private String generateProcessedFilename(String originalFilename) { originalFilename.contains(".") ? originalFilename.substring(0, originalFilename.lastIndexOf(".")) : originalFilename; - return nameWithoutExt + "_processed.genmodel"; + return nameWithoutExt + ".genmodel"; } } diff --git a/src/main/java/tools/vitruv/methodologist/setup/util/FileUtils.java b/src/main/java/tools/vitruv/methodologist/setup/util/FileUtils.java new file mode 100644 index 0000000..39d8737 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/util/FileUtils.java @@ -0,0 +1,176 @@ +package tools.vitruv.methodologist.setup.util; + +import java.io.File; +import java.io.IOException; +import java.net.URL; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Enumeration; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; +import lombok.extern.slf4j.Slf4j; +import tools.vitruv.methodologist.setup.config.CustomClassLoader; + +/** The FileUtils class provides utility methods for file operations. */ +@Slf4j +public final class FileUtils { + + /** + * The CLASS_LOADER is used to load classes from JAR files at runtime. It is used to load the + * classes of the virtual model builder. + */ + public static final CustomClassLoader CLASS_LOADER = + new CustomClassLoader(new URL[] {}, ClassLoader.getSystemClassLoader()); + + private FileUtils() {} + + /** + * Copy a file to a new location. + * + * @param filePath The path of the file that should be copied. + * @param folderPath The path of the folder to which the file should be copied. + * @param relativeSubfolder The relative subfolder in which the file should be copied. + * @return The target file. + */ + public static File copyFile(String filePath, Path folderPath, String relativeSubfolder) { + File source; + File target; + if (new File(filePath).isAbsolute()) { + source = Path.of(filePath).toFile(); + } else { + source = Path.of(new File("").getAbsolutePath().trim() + "/" + filePath.trim()).toFile(); + } + if (folderPath.isAbsolute()) { + target = + Path.of(folderPath.toString().trim() + "/" + relativeSubfolder + source.getName().trim()) + .toFile(); + } else { + + target = + Path.of( + new File("").getAbsolutePath().trim() + + "/" + + folderPath.toString().trim() + + "/" + + relativeSubfolder + + source.getName().trim()) + .toFile(); + } + // Files.copy throws a misleading Exception if the target File and/or the + // folders of the target file are not existing. + log.info("Copying file " + source.getAbsolutePath() + " to " + target.getAbsolutePath()); + target.getParentFile().mkdirs(); + try { + target.createNewFile(); + Files.copy(source.toPath(), target.toPath(), StandardCopyOption.REPLACE_EXISTING); + } catch (IOException e) { + e.printStackTrace(); + } + return target; + } + + /** + * Create a new file in the given path. + * + * @param filePath The path of the file that should be created. + */ + public static void createFile(String filePath) { + File file = new File(filePath); + try { + // Ensure the directory exists + File parentDir = file.getParentFile(); + if (parentDir != null && !parentDir.exists()) { + parentDir.mkdirs(); + } + // Create the file + if (file.createNewFile()) { + log.info("File created: " + file.getAbsolutePath()); + } else { + log.info("File already exists: " + file.getAbsolutePath()); + } + } catch (IOException e) { + log.error("An error occurred while creating the file: " + e.getMessage()); + e.printStackTrace(); + } + } + + /** + * Create a new folder in the given path. + * + * @param path The path of the folder that should be created. + * @param folder The name of the folder that should be created. + * @return The created folder. + */ + public static Path createNewFolder(Path path, String folder) { + Path folderPath = path.resolve(folder); + File file = folderPath.toFile(); + if (file.mkdirs()) { + log.info("Directory created: " + file.getAbsolutePath()); + } else { + log.info("Directory already exists: " + file.getAbsolutePath()); + } + return folderPath; + } + + /** + * Finds the value of an option in the given file by scanning for the first line that starts with + * the option prefix and returning the trimmed remainder of that line. + * + * @param file The file to search. + * @param option The option prefix to look for at the start of a line. + * @return The trimmed value following the option prefix. + * @throws IllegalArgumentException when no line starting with the option is found. + */ + public static String findOption(File file, String option) { + try { + for (String line : Files.readAllLines(file.toPath())) { + if (line.startsWith(option)) { + return line.substring(option.length()).trim(); + } + } + } catch (IOException e) { + e.printStackTrace(); + } + throw new IllegalArgumentException("Option: " + option + "not found in given file!"); + } + + /** + * Adding Jar to a class path. + * + * @param jarPath The path of the JAR file that should be added to the class path. + */ + public static void addJarToClassPath(String jarPath) { + try { + URL jarUrl = new URL("file:///" + jarPath); + CLASS_LOADER.addJar(jarUrl); + } catch (Exception e) { + e.printStackTrace(); + } + + try { + // Open the JAR file + JarFile jarFile = new JarFile(new File(jarPath)); + + // Get the entries in the JAR file + Enumeration entries = jarFile.entries(); + + // Iterate through the entries + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + + // Check if the entry is a class file + if (entry.getName().endsWith(".class")) { + // Print the class name + String className = entry.getName().replace("/", ".").replace(".class", ""); + log.info(className); + } + } + + // Close the JAR file + jarFile.close(); + } catch (IOException e) { + e.printStackTrace(); + } + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/vsum/controller/VsumController.java b/src/main/java/tools/vitruv/methodologist/setup/vsum/controller/VsumController.java new file mode 100644 index 0000000..b2e187c --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/vsum/controller/VsumController.java @@ -0,0 +1,146 @@ +package tools.vitruv.methodologist.setup.vsum.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import java.nio.charset.StandardCharsets; +import java.nio.file.NoSuchFileException; +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ContentDisposition; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import tools.vitruv.methodologist.setup.vsum.service.VsumProjectBuildService; + +/** REST API for VSUM project generation and packaging. */ +@RestController +@RequestMapping("/api/vsum") +@RequiredArgsConstructor +@Tag(name = "VSUM", description = "VSUM project generation endpoints") +public class VsumController { + + /** Download filename for the executable VSUM jar-with-dependencies. */ + private static final String VSUM_JAR_FILENAME = + "tools.vitruv.methodologisttemplate.vsum-0.1.0-SNAPSHOT-jar-with-dependencies.jar"; + + private final VsumProjectBuildService vsumProjectBuildService; + + /** + * Accepts model and reaction files, builds a VSUM project, and returns the built project zip. + * + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files paired by index with metamodel files + * @param reactionFiles reaction files + * @return zip file response containing generated project + * @throws NoSuchFileException when the build does not produce the expected artifact + */ + @PostMapping(value = "/build", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Build VSUM project", + description = + "Uploads metamodel/genmodel files," + + " builds the project from templates," + + " and returns a zip archive", + responses = { + @ApiResponse( + responseCode = "200", + description = "Project built successfully", + content = @Content(mediaType = "application/zip")), + @ApiResponse(responseCode = "400", description = "Invalid input files"), + @ApiResponse(responseCode = "500", description = "Build failed") + }) + public ResponseEntity buildProject( + @Parameter(description = "Metamodel files", required = true) @RequestPart("metamodelFiles") + List metamodelFiles, + @Parameter( + description = "Genmodel files paired by index with metamodel files", + required = true) + @RequestPart("genmodelFiles") + List genmodelFiles, + @Parameter(description = "Reaction files", required = true) @RequestPart("reactionFiles") + List reactionFiles) + throws NoSuchFileException { + + byte[] archive = + vsumProjectBuildService.buildProjectArchive(metamodelFiles, genmodelFiles, reactionFiles); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/zip")); + headers.setContentDisposition( + ContentDisposition.attachment() + .filename(generateArchiveFilename(), StandardCharsets.UTF_8) + .build()); + headers.setContentLength(archive.length); + + return ResponseEntity.ok().headers(headers).body(archive); + } + + /** + * Accepts model and reaction files, builds a VSUM project, and returns only the executable VSUM + * jar-with-dependencies produced under {@code vsum/target}, instead of the whole project archive. + * + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files paired by index with metamodel files + * @param reactionFiles reaction files + * @return response containing the built VSUM jar + */ + @PostMapping(value = "/jar", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + @Operation( + summary = "Build VSUM jar", + description = + "Uploads metamodel/genmodel files," + + " builds the project from templates," + + " and returns only the executable VSUM jar-with-dependencies", + responses = { + @ApiResponse( + responseCode = "200", + description = "Jar built successfully", + content = @Content(mediaType = "application/java-archive")), + @ApiResponse(responseCode = "400", description = "Invalid input files"), + @ApiResponse(responseCode = "500", description = "Build failed") + }) + public ResponseEntity buildJar( + @Parameter(description = "Metamodel files", required = true) @RequestPart("metamodelFiles") + List metamodelFiles, + @Parameter( + description = "Genmodel files paired by index with metamodel files", + required = true) + @RequestPart("genmodelFiles") + List genmodelFiles, + @Parameter(description = "Reaction files", required = true) @RequestPart("reactionFiles") + List reactionFiles) { + + byte[] jar = + vsumProjectBuildService.buildProjectJar(metamodelFiles, genmodelFiles, reactionFiles); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.parseMediaType("application/java-archive")); + headers.setContentDisposition( + ContentDisposition.attachment() + .filename(VSUM_JAR_FILENAME, StandardCharsets.UTF_8) + .build()); + headers.setContentLength(jar.length); + + return ResponseEntity.ok().headers(headers).body(jar); + } + + /** + * Creates a deterministic archive filename prefix for build downloads. + * + * @return zip filename + */ + private String generateArchiveFilename() { + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + return "vsum-project-" + timestamp + ".zip"; + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/vsum/service/GenerateFromTemplate.java b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/GenerateFromTemplate.java new file mode 100644 index 0000000..ea5b4cb --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/GenerateFromTemplate.java @@ -0,0 +1,328 @@ +package tools.vitruv.methodologist.setup.vsum.service; + +import freemarker.template.Configuration; +import freemarker.template.Template; +import freemarker.template.TemplateException; +import freemarker.template.TemplateExceptionHandler; +import java.io.File; +import java.io.FileWriter; +import java.io.IOException; +import java.io.Writer; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import tools.vitruv.methodologist.setup.config.MetamodelLocation; +import tools.vitruv.methodologist.setup.config.VitruvConfiguration; +import tools.vitruv.methodologist.setup.exception.MissingModelException; +import tools.vitruv.methodologist.setup.util.FileUtils; + +/** This class is responsible for generating files from templates. */ +@Slf4j +@Service +public class GenerateFromTemplate { + private static final String PACKAGE_NAME = "packageName"; + + /** Constructor. */ + public GenerateFromTemplate() {} + + private Configuration getConfiguration() { + Configuration cfg = new Configuration(Configuration.VERSION_2_3_31); + cfg.setDefaultEncoding("UTF-8"); + cfg.setClassForTemplateLoading(this.getClass(), "/templates"); + cfg.setTemplateExceptionHandler(TemplateExceptionHandler.RETHROW_HANDLER); + cfg.setLogTemplateExceptions(false); + cfg.setWrapUncheckedExceptions(true); + return cfg; + } + + private void writeTemplate(Template template, File filePath, Map data) + throws IOException { + FileUtils.createFile(filePath.getAbsolutePath()); + try (Writer fileWriter = new FileWriter(filePath.getAbsolutePath(), false)) { + template.process(data, fileWriter); + fileWriter.flush(); + log.info("writing to " + filePath.getAbsolutePath()); + } catch (TemplateException e) { + throw new IOException( + "Failed to process template for file: " + filePath.getAbsolutePath(), e); + } + } + + private Template loadTemplate(Configuration cfg, String templateName) throws IOException { + try { + return cfg.getTemplate(templateName); + } catch (IOException e) { + throw new IOException("Could not load Freemarker template: " + templateName, e); + } + } + + /** + * Generates the root pom file. + * + * @param filePath The file path to write the root pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + * @throws MissingModelException If the package name is missing. + */ + public void generateRootPom(File filePath, String packageName) + throws IOException, MissingModelException { + + if (packageName == null || packageName.isEmpty()) { + throw new MissingModelException("-m ModelOption is missing the PackageName"); + } + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "rootPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the vsum pom file. + * + * @param filePath The file path to write the vsum pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateVsumPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "vsumPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the vsum example file. + * + * @param filePath The file path to write the vsum example file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateVsumExample(File filePath, String packageName, List models) + throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + data.put("models", models); + + Template template = loadTemplate(cfg, "vsumExample.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the p2wrappers pom file. + * + * @param filePath The file path to write the p2wrappers pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateP2WrappersPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "p2wrappersPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the javautils pom file. + * + * @param filePath The file path to write the javautils pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateJavaUtilsPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "javautilsPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the xannotations pom file. + * + * @param filePath The file path to write the xannotations pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateXAnnotationsPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "xannotationsPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the emfutils pom file. + * + * @param filePath The file path to write the emfutils pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateEMFUtilsPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "emfutilsPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the vsum test file. + * + * @param filePath The file path to write the vsum test file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateVsumTest(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "vsumTest.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the project file. + * + * @param filePath The file path to write the project file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateProjectFile(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put("packageName", packageName.trim()); + + Template template = loadTemplate(cfg, "project.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the model pom file. + * + * @param filePath The file path to write the model pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateModelPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put(PACKAGE_NAME, packageName); + + Template template = loadTemplate(cfg, "modelPom.ftl"); + writeTemplate(template, filePath, data); + } + + /** + * Generates the consistency pom file. + * + * @param filePath The file path to write the consistency pom file to. + * @param packageName The package name from the genmodel. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateConsistencyPom(File filePath, String packageName) throws IOException { + Configuration cfg = getConfiguration(); + + Map data = new HashMap<>(); + data.put(PACKAGE_NAME, packageName); + + Template template = loadTemplate(cfg, "consistencyPom.ftl"); + writeTemplate(template, filePath, data); + } + + private String getNormalizedDirectoryString(String targetDir) { + return targetDir.replace("\\", "/").replaceAll("//+", "/"); + } + + /** + * Generates the mwe2 file. + * + * @param filePath the file path to write the mwe2 file to. + * @param models the list of metamodel locations. + * @param config the vitruv cli configuration. + * @throws java.io.IOException If the file cannot be written. + */ + public void generateMwe2( + File filePath, List models, VitruvConfiguration config) + throws IOException { + + Configuration cfg = getConfiguration(); + List> items = new ArrayList<>(); + for (MetamodelLocation model : models) { + items.add( + Map.of( + "targetDir", + getNormalizedDirectoryString(config.getLocalPath().toString().trim()), + "modelName", + model.genmodel().getName(), + "modelDirectory", + getNormalizedDirectoryString(model.modelDirectory().trim()), + "packageName", + config.getPackageName().trim().concat(".model"))); + } + Template template = loadTemplate(cfg, "generator.ftl"); + Map data = new HashMap<>(); + data.put("items", items); + + writeTemplate(template, filePath, data); + } + + /** + * Generates the plugin file. + * + * @param filePath the file path to write the plugin file to. + * @param config the vitruv cli configuration. + * @param models the list of metamodel locations. + * @throws java.io.IOException If the file cannot be written. + */ + public void generatePlugin( + File filePath, VitruvConfiguration config, List models) + throws IOException { + Configuration cfg = getConfiguration(); + List> items = new ArrayList<>(); + for (MetamodelLocation model : models) { + items.add( + Map.of( + PACKAGE_NAME, + config.getPackageName(), + "modelUri", + model.genmodelUri(), + "modelNameCap", + model.genmodel().getName().substring(0, 1).toUpperCase() + + model + .genmodel() + .getName() + .substring(1, model.genmodel().getName().indexOf('.')), + "genmodelName", + model.genmodel().getName())); + } + Template template = loadTemplate(cfg, "plugin.ftl"); + Map data = new HashMap<>(); + data.put("items", items); + writeTemplate(template, filePath, data); + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java new file mode 100644 index 0000000..d5342d4 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildService.java @@ -0,0 +1,441 @@ +package tools.vitruv.methodologist.setup.vsum.service; + +import java.io.File; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashMap; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; +import java.util.stream.Stream; +import javax.xml.parsers.DocumentBuilderFactory; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import tools.vitruv.methodologist.setup.exception.MethodologistSetupException; +import tools.vitruv.methodologist.setup.exception.MissingModelException; +import tools.vitruv.methodologist.setup.messages.ErrorMessages; + +/** Coordinates VSUM project build operations from uploaded files. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VsumProjectBuildService { + + private static final String VSUM_BUILD_ERROR_CODE = "VSUM_BUILD_ERROR"; + private static final String VSUM_INPUT_ERROR_CODE = "VSUM_INPUT_ERROR"; + private static final Pattern REACTION_IMPORT_PATTERN = Pattern.compile("import\\s+\"([^\"]+)\""); + + private final VsumService vsumService; + + /** + * Builds a VSUM project from uploaded files and returns a zip archive of the whole project. + * + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files in the same order as metamodel files + * @param reactionFiles reaction files + * @return built project archive bytes + */ + public byte[] buildProjectArchive( + List metamodelFiles, + List genmodelFiles, + List reactionFiles) { + return buildArtifact( + metamodelFiles, genmodelFiles, reactionFiles, vsumService::generateProjectArchive); + } + + /** + * Builds a VSUM project from uploaded files and returns only the executable VSUM + * jar-with-dependencies produced under the project's {@code vsum/target} directory, rather than + * the whole project archive. + * + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files in the same order as metamodel files + * @param reactionFiles reaction files + * @return the bytes of the built VSUM jar + */ + public byte[] buildProjectJar( + List metamodelFiles, + List genmodelFiles, + List reactionFiles) { + return buildArtifact( + metamodelFiles, genmodelFiles, reactionFiles, vsumService::generateProjectJar); + } + + /** + * Validates the uploaded files, materializes them into a temporary workspace, and delegates to + * the supplied generator to produce the requested build artifact, cleaning up the workspace + * afterwards regardless of outcome. + * + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files in the same order as metamodel files + * @param reactionFiles reaction files + * @param generator the VSUM generation strategy producing the artifact bytes + * @return the produced artifact bytes + */ + private byte[] buildArtifact( + List metamodelFiles, + List genmodelFiles, + List reactionFiles, + ProjectArtifactGenerator generator) { + validateInputs(metamodelFiles, genmodelFiles, reactionFiles); + + Path uploadWorkspace = null; + try { + uploadWorkspace = Files.createTempDirectory("vsum-upload-"); + List modelPairs = + toModelPairs(uploadWorkspace, metamodelFiles, genmodelFiles); + List copiedReactionFiles = toReactionFiles(uploadWorkspace, reactionFiles); + normalizeReactionImports(modelPairs, copiedReactionFiles); + Map metamodelNamespaceMap = extractMetamodelNamespaceMap(modelPairs); + return generator.generate(modelPairs, copiedReactionFiles, metamodelNamespaceMap); + } catch (IOException | MissingModelException e) { + throw new MethodologistSetupException( + VSUM_BUILD_ERROR_CODE, "Failed to build VSUM project archive", e); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new MethodologistSetupException( + VSUM_BUILD_ERROR_CODE, "VSUM project build was interrupted", e); + } finally { + deleteRecursively(uploadWorkspace); + } + } + + /** + * Validates uploaded metamodel, genmodel, and reaction lists. Metamodel, genmodel, and reaction + * files are all mandatory. + * + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files + * @param reactionFiles reaction files + */ + private void validateInputs( + List metamodelFiles, + List genmodelFiles, + List reactionFiles) { + if (metamodelFiles == null + || genmodelFiles == null + || metamodelFiles.isEmpty() + || genmodelFiles.isEmpty()) { + throw new MethodologistSetupException( + VSUM_INPUT_ERROR_CODE, "At least one metamodel and one genmodel file are required"); + } + + if (metamodelFiles.size() != genmodelFiles.size()) { + throw new MethodologistSetupException( + VSUM_INPUT_ERROR_CODE, + "Metamodel and genmodel file counts must be identical to build file pairs"); + } + + for (MultipartFile file : metamodelFiles) { + validateUploadedFile(file, "metamodel file"); + } + for (MultipartFile file : genmodelFiles) { + validateUploadedFile(file, "genmodel file"); + } + + if (reactionFiles == null || reactionFiles.isEmpty()) { + throw new MethodologistSetupException( + VSUM_INPUT_ERROR_CODE, ErrorMessages.VSUM_REACTION_FILES_REQUIRED); + } + for (MultipartFile file : reactionFiles) { + validateUploadedFile(file, "reaction file"); + } + } + + /** + * Converts uploaded metamodel/genmodel files into local file pairs. + * + * @param root upload root directory + * @param metamodelFiles metamodel files + * @param genmodelFiles genmodel files + * @return model file pairs + * @throws IOException when writing files fails + */ + private List toModelPairs( + Path root, List metamodelFiles, List genmodelFiles) + throws IOException { + Path modelUploadPath = Files.createDirectories(root.resolve("models")); + List modelPairs = new ArrayList<>(); + + for (int index = 0; index < metamodelFiles.size(); index++) { + File metamodel = + writeMultipartFile(metamodelFiles.get(index), modelUploadPath, "metamodel", index); + File genmodel = + writeMultipartFile(genmodelFiles.get(index), modelUploadPath, "genmodel", index); + modelPairs.add(new VsumService.ModelFiles(metamodel, genmodel)); + } + + return modelPairs; + } + + /** + * Converts uploaded reaction files into local files. + * + * @param root upload root directory + * @param reactionFiles reaction files + * @return copied reaction files + * @throws IOException when writing files fails + */ + private List toReactionFiles(Path root, List reactionFiles) + throws IOException { + if (reactionFiles == null || reactionFiles.isEmpty()) { + return List.of(); + } + + Path reactionUploadPath = Files.createDirectories(root.resolve("reactions")); + List files = new ArrayList<>(); + for (int index = 0; index < reactionFiles.size(); index++) { + MultipartFile reactionFile = reactionFiles.get(index); + validateUploadedFile(reactionFile, "reaction file"); + files.add(writeMultipartFile(reactionFile, reactionUploadPath, "reaction", index)); + } + return files; + } + + /** + * Normalizes imported model URIs in reaction files to match uploaded metamodel nsURIs. + * + * @param modelPairs metamodel/genmodel file pairs + * @param reactionFiles copied reaction files + * @throws IOException when reading or rewriting a reaction file fails + */ + private void normalizeReactionImports( + List modelPairs, List reactionFiles) throws IOException { + if (reactionFiles == null || reactionFiles.isEmpty()) { + return; + } + + Map packageNameToNsUri = new HashMap<>(); + Set nsUris = new HashSet<>(); + for (VsumService.ModelFiles pair : modelPairs) { + MetamodelInfo info = readMetamodelInfo(pair.metamodelFile()); + if (info != null) { + if (info.packageName != null && !info.packageName.isBlank()) { + packageNameToNsUri.put(info.packageName, info.nsUri); + } + nsUris.add(info.nsUri); + } + } + + for (File reactionFile : reactionFiles) { + String content = Files.readString(reactionFile.toPath(), StandardCharsets.UTF_8); + String updated = normalizeReactionImports(content, packageNameToNsUri, nsUris); + if (!updated.equals(content)) { + Files.writeString(reactionFile.toPath(), updated, StandardCharsets.UTF_8); + } + } + } + + /** + * Rewrites each {@code import "..."} statement in a single reaction file's content, replacing + * imports that do not match a known nsURI with the nsURI mapped from the import's last path + * segment. + * + * @param content the reaction file content to rewrite + * @param packageNameToNsUri map of metamodel package names to their nsURIs + * @param knownNsUris the set of nsURIs that are already valid and should be left unchanged + * @return the rewritten content, identical to the input when no import required replacement + */ + private String normalizeReactionImports( + String content, Map packageNameToNsUri, Set knownNsUris) { + Matcher matcher = REACTION_IMPORT_PATTERN.matcher(content); + StringBuffer rewritten = new StringBuffer(); + while (matcher.find()) { + String importUri = matcher.group(1); + String replacementUri = importUri; + if (!knownNsUris.contains(importUri)) { + String candidatePackageName = extractLastSegment(importUri); + String mappedUri = packageNameToNsUri.get(candidatePackageName); + if (mappedUri != null && !mappedUri.isBlank()) { + replacementUri = mappedUri; + } + } + + matcher.appendReplacement( + rewritten, "import \"" + Matcher.quoteReplacement(replacementUri) + "\""); + } + matcher.appendTail(rewritten); + return rewritten.toString(); + } + + /** + * Stores metamodel nsURI information for build-time EMF initialization. + * + * @param modelPairs metamodel/genmodel file pairs + * @return map of model names to nsURIs + */ + Map extractMetamodelNamespaceMap(List modelPairs) { + Map modelNameToNsUri = new HashMap<>(); + for (VsumService.ModelFiles pair : modelPairs) { + MetamodelInfo info = readMetamodelInfo(pair.metamodelFile()); + if (info != null && info.nsUri != null && !info.nsUri.isBlank()) { + modelNameToNsUri.put(info.packageName != null ? info.packageName : "model", info.nsUri); + } + } + return modelNameToNsUri; + } + + /** + * Extracts the last segment of a URI, i.e. the substring following the final {@code /} or {@code + * #}. + * + * @param uri the URI to inspect + * @return the substring after the last separator, or the whole URI when no separator is present + */ + private String extractLastSegment(String uri) { + int slash = uri.lastIndexOf('/'); + int hash = uri.lastIndexOf('#'); + int separator = Math.max(slash, hash); + return separator >= 0 ? uri.substring(separator + 1) : uri; + } + + /** + * Parses an ecore metamodel file and extracts the root element's {@code name} and {@code nsURI} + * attributes. + * + * @param metamodelFile the metamodel (ecore) file to parse + * @return the extracted metamodel info, or {@code null} when the file cannot be parsed or its + * nsURI is missing or blank + */ + private MetamodelInfo readMetamodelInfo(File metamodelFile) { + try { + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setNamespaceAware(true); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + factory.setFeature("http://xml.org/sax/features/external-general-entities", false); + factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false); + factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false); + factory.setXIncludeAware(false); + factory.setExpandEntityReferences(false); + Document document = factory.newDocumentBuilder().parse(metamodelFile); + Element root = document.getDocumentElement(); + String nsUri = root.getAttribute("nsURI"); + String packageName = root.getAttribute("name"); + if (nsUri == null || nsUri.isBlank()) { + return null; + } + return new MetamodelInfo(packageName, nsUri); + } catch (Exception exception) { + return null; + } + } + + /** + * Writes a multipart file to disk. + * + * @param file multipart file + * @param directory target directory + * @param type logical file type label + * @param index index for deterministic fallback naming + * @return copied file + * @throws IOException when writing file fails + */ + private File writeMultipartFile(MultipartFile file, Path directory, String type, int index) + throws IOException { + String originalName = file.getOriginalFilename(); + String safeFileName = + originalName == null || originalName.isBlank() + ? type + "-" + index + : Path.of(originalName).getFileName().toString(); + + Path targetFile = directory.resolve(safeFileName); + file.transferTo(targetFile); + return targetFile.toFile(); + } + + /** + * Ensures an uploaded file is present and non-empty. + * + * @param file uploaded file + * @param label logical file label + */ + private void validateUploadedFile(MultipartFile file, String label) { + if (file == null || file.isEmpty()) { + throw new MethodologistSetupException( + VSUM_INPUT_ERROR_CODE, "Uploaded " + label + " must not be empty"); + } + } + + /** + * Deletes a directory tree if it exists. + * + * @param root root directory + */ + private void deleteRecursively(Path root) { + if (root == null || !Files.exists(root)) { + return; + } + + try (Stream paths = Files.walk(root)) { + paths.sorted(Comparator.reverseOrder()).forEach(this::deleteQuietly); + } catch (IOException e) { + log.error(e.getMessage()); + } + } + + /** + * Deletes a single path while ignoring IO errors. + * + * @param path file system path + */ + private void deleteQuietly(Path path) { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.error(e.getMessage()); + } + } + + /** + * Strategy selecting which VSUM build artifact to generate from the prepared model inputs, so the + * shared upload-and-build pipeline can produce either the whole project archive or a single + * artifact. + */ + @FunctionalInterface + private interface ProjectArtifactGenerator { + /** + * Generates the requested artifact from the prepared model inputs. + * + * @param modelPairs metamodel/genmodel file pairs + * @param reactionFiles copied reaction files + * @param metamodelNamespaceMap map of model names to nsURIs + * @return the produced artifact bytes + * @throws IOException when file IO or artifact extraction fails + * @throws InterruptedException when the build process is interrupted + * @throws MissingModelException when the model configuration is invalid + */ + byte[] generate( + List modelPairs, + List reactionFiles, + Map metamodelNamespaceMap) + throws IOException, InterruptedException, MissingModelException; + } + + /** Immutable holder for a metamodel's package name and nsURI. */ + private static class MetamodelInfo { + private final String packageName; + private final String nsUri; + + /** + * Creates a holder for the given metamodel attributes. + * + * @param packageName the metamodel's package name (root element {@code name} attribute) + * @param nsUri the metamodel's namespace URI (root element {@code nsURI} attribute) + */ + private MetamodelInfo(String packageName, String nsUri) { + this.packageName = packageName; + this.nsUri = nsUri; + } + } +} diff --git a/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumService.java b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumService.java new file mode 100644 index 0000000..2ef6a61 --- /dev/null +++ b/src/main/java/tools/vitruv/methodologist/setup/vsum/service/VsumService.java @@ -0,0 +1,513 @@ +package tools.vitruv.methodologist.setup.vsum.service; + +import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.IOException; +import java.io.InputStreamReader; +import java.io.StringWriter; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.stream.Collectors; +import java.util.stream.Stream; +import java.util.zip.ZipEntry; +import java.util.zip.ZipOutputStream; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import tools.vitruv.methodologist.setup.config.VitruvConfiguration; +import tools.vitruv.methodologist.setup.exception.MissingModelException; +import tools.vitruv.methodologist.setup.messages.ErrorMessages; + +/** Business service for building and packaging VSUM projects from uploaded model files. */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VsumService { + + /** + * Path, relative to the generated project root, of the executable VSUM jar produced by the Maven + * build. + */ + static final String VSUM_JAR_RELATIVE_PATH = + "vsum/target/tools.vitruv.methodologisttemplate." + + "vsum-0.1.0-SNAPSHOT-jar-with-dependencies.jar"; + + private final GenerateFromTemplate generateFromTemplate; + + /** + * Generates a full VSUM project, builds it with Maven, and returns the project archive as bytes. + * + * @param modelFiles paired metamodel/genmodel files + * @param reactionFiles optional reaction files + * @param metamodelNamespaceMap map of model names to nsURIs + * @return zip archive bytes containing generated project and build artifacts + * @throws IOException when file IO or build execution fails + * @throws InterruptedException when the build process is interrupted + * @throws MissingModelException when the model configuration is invalid + */ + public byte[] generateProjectArchive( + List modelFiles, + List reactionFiles, + Map metamodelNamespaceMap) + throws IOException, InterruptedException, MissingModelException { + return buildProject(modelFiles, reactionFiles, metamodelNamespaceMap, this::zipDirectory); + } + + /** + * Generates a full VSUM project, builds it with Maven, and returns the project archive as bytes. + * + * @param modelFiles paired metamodel/genmodel files + * @param reactionFiles optional reaction files + * @return zip archive bytes containing generated project and build artifacts + * @throws IOException when file IO or build execution fails + * @throws InterruptedException when the build process is interrupted + * @throws MissingModelException when the model configuration is invalid + */ + public byte[] generateProjectArchive(List modelFiles, List reactionFiles) + throws IOException, InterruptedException, MissingModelException { + return generateProjectArchive(modelFiles, reactionFiles, Map.of()); + } + + /** + * Generates a full VSUM project, builds it with Maven, and returns only the executable VSUM jar + * (the {@value #VSUM_JAR_RELATIVE_PATH} produced under the project's {@code vsum/target} + * directory) as bytes, instead of the whole project archive. + * + * @param modelFiles paired metamodel/genmodel files + * @param reactionFiles optional reaction files + * @param metamodelNamespaceMap map of model names to nsURIs + * @return the bytes of the built VSUM jar-with-dependencies + * @throws IOException when file IO, build execution, or jar extraction fails + * @throws InterruptedException when the build process is interrupted + * @throws MissingModelException when the model configuration is invalid + */ + public byte[] generateProjectJar( + List modelFiles, + List reactionFiles, + Map metamodelNamespaceMap) + throws IOException, InterruptedException, MissingModelException { + return buildProject(modelFiles, reactionFiles, metamodelNamespaceMap, this::extractVsumJar); + } + + /** + * Generates a full VSUM project, builds it with Maven, and returns only the executable VSUM jar + * as bytes. + * + * @param modelFiles paired metamodel/genmodel files + * @param reactionFiles optional reaction files + * @return the bytes of the built VSUM jar-with-dependencies + * @throws IOException when file IO, build execution, or jar extraction fails + * @throws InterruptedException when the build process is interrupted + * @throws MissingModelException when the model configuration is invalid + */ + public byte[] generateProjectJar(List modelFiles, List reactionFiles) + throws IOException, InterruptedException, MissingModelException { + return generateProjectJar(modelFiles, reactionFiles, Map.of()); + } + + /** + * Generates the project from templates, runs the Maven build, and extracts a single build + * artifact from the workspace, deleting the workspace afterwards regardless of outcome. + * + * @param modelFiles paired metamodel/genmodel files + * @param reactionFiles optional reaction files + * @param metamodelNamespaceMap map of model names to nsURIs passed to the build + * @param artifactExtractor extracts the resulting bytes from the built workspace + * @return the extracted artifact bytes + * @throws IOException when file IO, build execution, or artifact extraction fails + * @throws InterruptedException when the build process is interrupted + * @throws MissingModelException when the model configuration is invalid + */ + private byte[] buildProject( + List modelFiles, + List reactionFiles, + Map metamodelNamespaceMap, + ArtifactExtractor artifactExtractor) + throws IOException, InterruptedException, MissingModelException { + validateInputs(modelFiles, reactionFiles); + + Path workspace = Files.createTempDirectory("vitruv-cli-project-"); + try { + VitruvConfiguration configuration = new VitruvConfiguration(); + configuration.setLocalPath(workspace); + + List copiedModelFiles = copyModelFiles(workspace, modelFiles); + configuration.setMetaModelLocations(buildModelLocations(copiedModelFiles)); + + List copiedReactionFiles = copyReactionFiles(workspace, reactionFiles); + configuration.setReactionLocations(copiedReactionFiles); + + generateProjectFiles(configuration); + runMavenBuild(workspace, metamodelNamespaceMap); + return artifactExtractor.extract(workspace); + } finally { + deleteRecursively(workspace); + } + } + + /** + * Validates that the supplied model and reaction files are present and usable. + * + * @param modelFiles paired metamodel/genmodel files; must contain at least one pair + * @param reactionFiles optional reaction files; may be {@code null} + * @throws IllegalArgumentException when no model pairs are provided or any file is invalid + */ + private void validateInputs(List modelFiles, List reactionFiles) { + if (modelFiles == null || modelFiles.isEmpty()) { + throw new IllegalArgumentException("At least one metamodel/genmodel pair is required."); + } + + for (ModelFiles pair : modelFiles) { + validateFile(pair.metamodelFile(), "metamodelFile"); + validateFile(pair.genmodelFile(), "genmodelFile"); + } + + if (reactionFiles != null) { + for (File reactionFile : reactionFiles) { + validateFile(reactionFile, "reactionFile"); + } + } + } + + /** + * Validates that a single file is non-null and refers to an existing regular file. + * + * @param file the file to validate + * @param label human-readable name used in error messages + * @throws IllegalArgumentException when the file is null, missing, or not a regular file + */ + private void validateFile(File file, String label) { + if (file == null) { + throw new IllegalArgumentException(label + " must not be null."); + } + if (!file.exists()) { + throw new IllegalArgumentException(label + " does not exist: " + file.getAbsolutePath()); + } + if (!file.isFile()) { + throw new IllegalArgumentException(label + " is not a file: " + file.getAbsolutePath()); + } + } + + /** + * Copies each metamodel/genmodel pair into the workspace's ecore source directory. + * + * @param workspace root directory of the generated project + * @param modelFiles paired metamodel/genmodel files to copy + * @return the copied pairs pointing at their new locations within the workspace + * @throws IOException when creating the target directory or copying a file fails + */ + private List copyModelFiles(Path workspace, List modelFiles) + throws IOException { + Path targetDirectory = workspace.resolve("model/src/main/ecore"); + Files.createDirectories(targetDirectory); + + List copied = new ArrayList<>(); + for (ModelFiles pair : modelFiles) { + File copiedMetamodel = copyFile(pair.metamodelFile(), targetDirectory); + File copiedGenmodel = copyFile(pair.genmodelFile(), targetDirectory); + copied.add(new ModelFiles(copiedMetamodel, copiedGenmodel)); + } + return copied; + } + + /** + * Copies any reaction files into the workspace's reactions source directory. + * + * @param workspace root directory of the generated project + * @param reactionFiles reaction files to copy; may be {@code null} or empty + * @return the paths of the copied reaction files, or an empty list when none were provided + * @throws IOException when creating the target directory or copying a file fails + */ + private List copyReactionFiles(Path workspace, List reactionFiles) + throws IOException { + if (reactionFiles == null || reactionFiles.isEmpty()) { + return List.of(); + } + + Path targetDirectory = workspace.resolve("consistency/src/main/reactions"); + Files.createDirectories(targetDirectory); + + List copied = new ArrayList<>(); + for (File reactionFile : reactionFiles) { + copied.add(copyFile(reactionFile, targetDirectory).toPath()); + } + return copied; + } + + /** + * Copies a single file into the target directory, overwriting any existing file with the same + * name. + * + * @param source the file to copy + * @param targetDirectory the directory to copy the file into + * @return the copied file at its new location + * @throws IOException when the copy operation fails + */ + private File copyFile(File source, Path targetDirectory) throws IOException { + Path target = targetDirectory.resolve(source.getName()); + Files.copy(source.toPath(), target, StandardCopyOption.REPLACE_EXISTING); + return target.toFile(); + } + + /** + * Builds the metamodel locations string consumed by the Vitruv configuration, pairing each + * metamodel with its genmodel. + * + * @param copiedModelFiles the copied metamodel/genmodel pairs + * @return a string of comma-separated metamodel/genmodel paths, with pairs separated by + * semicolons + */ + private String buildModelLocations(List copiedModelFiles) { + return copiedModelFiles.stream() + .map( + pair -> + pair.metamodelFile().getAbsolutePath() + + "," + + pair.genmodelFile().getAbsolutePath()) + .collect(Collectors.joining(";")); + } + + /** + * Generates all project files from templates, including the POMs for each module, the VSUM + * example and test sources, the Eclipse project descriptor, the MWE2 workflow, and the plugin + * descriptor. + * + * @param configuration the Vitruv configuration describing the project to generate + * @throws IOException when writing a generated file fails + * @throws MissingModelException when the model configuration is invalid + */ + private void generateProjectFiles(VitruvConfiguration configuration) + throws IOException, MissingModelException { + generateFromTemplate.generateRootPom( + new File((configuration.getLocalPath() + "/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateConsistencyPom( + new File((configuration.getLocalPath() + "/consistency/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateModelPom( + new File((configuration.getLocalPath() + "/model/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateVsumPom( + new File((configuration.getLocalPath() + "/vsum/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateP2WrappersPom( + new File((configuration.getLocalPath() + "/p2wrappers/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateJavaUtilsPom( + new File((configuration.getLocalPath() + "/p2wrappers/javautils/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateXAnnotationsPom( + new File( + (configuration.getLocalPath() + "/p2wrappers/activextendannotations/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateEMFUtilsPom( + new File((configuration.getLocalPath() + "/p2wrappers/emfutils/pom.xml").trim()), + configuration.getPackageName()); + generateFromTemplate.generateVsumExample( + new File((configuration.getLocalPath() + "/vsum/src/main/java/VSUMExample.java").trim()), + configuration.getPackageName(), + configuration.getModelNames()); + generateFromTemplate.generateVsumTest( + new File( + (configuration.getLocalPath() + "/vsum/src/test/java/VSUMExampleTest.java").trim()), + configuration.getPackageName()); + generateFromTemplate.generateProjectFile( + new File((configuration.getLocalPath() + "/model/.project").trim()), + configuration.getPackageName()); + + File workflow = + new File((configuration.getLocalPath() + "/model/workflow/generate.mwe2").trim()); + configuration.setWorkflow(workflow); + generateFromTemplate.generateMwe2( + workflow, configuration.getMetaModelLocations(), configuration); + generateFromTemplate.generatePlugin( + new File((configuration.getLocalPath() + "/model/plugin.xml").trim()), + configuration, + configuration.getMetaModelLocations()); + } + + /** + * Runs the Maven build for the generated project without any metamodel namespace properties. + * + * @param projectRoot root directory of the generated project + * @throws IOException when the build fails or its output cannot be read + * @throws InterruptedException when the build process is interrupted while waiting + */ + protected void runMavenBuild(Path projectRoot) throws IOException, InterruptedException { + runMavenBuild(projectRoot, Map.of()); + } + + /** + * Runs the Maven {@code clean verify} build for the generated project, passing metamodel + * namespace information as system properties, and fails when the build exits with a non-zero + * status. + * + * @param projectRoot root directory of the generated project + * @param metamodelNamespaceMap map of model names to nsURIs passed as build properties + * @throws IOException when the build fails or its output cannot be read + * @throws InterruptedException when the build process is interrupted while waiting + */ + protected void runMavenBuild(Path projectRoot, Map metamodelNamespaceMap) + throws IOException, InterruptedException { + ProcessBuilder processBuilder = createMavenProcessBuilder(projectRoot, metamodelNamespaceMap); + Process process = processBuilder.start(); + + StringWriter output = new StringWriter(); + try (BufferedReader reader = + new BufferedReader( + new InputStreamReader(process.getInputStream(), StandardCharsets.UTF_8))) { + reader.transferTo(output); + } + + process.waitFor(); + if (process.exitValue() != 0) { + throw new IOException( + "Maven build failed with exit code " + process.exitValue() + ". Output: " + output); + } + } + + /** + * Creates the {@link ProcessBuilder} for the Maven build, appending each metamodel namespace + * entry as a {@code -Dmetamodel..nsuri=} system property and redirecting the error + * stream into standard output. + * + * @param projectRoot root directory of the generated project, used as the working directory + * @param metamodelNamespaceMap map of model names to nsURIs to append as system properties + * @return a configured process builder ready to be started + */ + private ProcessBuilder createMavenProcessBuilder( + Path projectRoot, Map metamodelNamespaceMap) { + ProcessBuilder processBuilder = new ProcessBuilder("mvn", "clean", "verify", "-DskipTests"); + + // Pass metamodel namespace information as system properties + if (metamodelNamespaceMap != null && !metamodelNamespaceMap.isEmpty()) { + for (var entry : metamodelNamespaceMap.entrySet()) { + String propName = "metamodel." + entry.getKey() + ".nsuri"; + String propValue = "-D" + propName + "=" + entry.getValue(); + processBuilder.command().add(propValue); + } + } + + processBuilder.directory(projectRoot.toFile()); + processBuilder.redirectErrorStream(true); + return processBuilder; + } + + /** + * Extracts the executable VSUM jar produced by the Maven build from the built workspace. + * + * @param workspace root directory of the generated and built project + * @return the bytes of the {@value #VSUM_JAR_RELATIVE_PATH} artifact + * @throws IOException when the expected jar is missing or cannot be read + */ + private byte[] extractVsumJar(Path workspace) throws IOException { + Path jarPath = workspace.resolve(VSUM_JAR_RELATIVE_PATH); + if (!Files.isRegularFile(jarPath)) { + throw new NoSuchFileException(String.format(ErrorMessages.VSUM_JAR_NOT_FOUND, jarPath)); + } + return Files.readAllBytes(jarPath); + } + + /** + * Zips the entire contents of the given directory into an in-memory archive, preserving the + * directory structure with forward-slash separators and deterministic entry ordering. + * + * @param root the directory whose contents should be archived + * @return the bytes of the resulting zip archive + * @throws IOException when walking the directory tree or writing an entry fails + */ + private byte[] zipDirectory(Path root) throws IOException { + ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); + try (ZipOutputStream zipOutputStream = new ZipOutputStream(outputStream); + Stream paths = Files.walk(root)) { + for (Path path : paths.sorted(Comparator.naturalOrder()).toList()) { + if (root.equals(path)) { + continue; + } + + String entryName = root.relativize(path).toString().replace('\\', '/'); + if (Files.isDirectory(path)) { + zipOutputStream.putNextEntry(new ZipEntry(entryName + "/")); + zipOutputStream.closeEntry(); + continue; + } + + zipOutputStream.putNextEntry(new ZipEntry(entryName)); + Files.copy(path, zipOutputStream); + zipOutputStream.closeEntry(); + } + } + return outputStream.toByteArray(); + } + + /** + * Recursively deletes the given directory and all of its contents on a best-effort basis, + * silently ignoring any individual deletion failures. + * + * @param root the directory to delete; ignored when {@code null} or non-existent + */ + private void deleteRecursively(Path root) { + if (root == null || !Files.exists(root)) { + return; + } + + try (Stream paths = Files.walk(root)) { + paths + .sorted(Comparator.reverseOrder()) + .forEach( + path -> { + try { + Files.deleteIfExists(path); + } catch (IOException e) { + log.error(e.getMessage()); + } + }); + } catch (IOException e) { + log.error(e.getMessage()); + } + } + + /** + * Strategy for extracting the bytes to return from a fully built project workspace, allowing the + * same generate-and-build pipeline to produce either the whole project archive or a single + * artifact. + */ + @FunctionalInterface + private interface ArtifactExtractor { + /** + * Extracts the result bytes from the built workspace. + * + * @param workspace root directory of the generated and built project + * @return the bytes to return to the caller + * @throws IOException when reading or assembling the artifact fails + */ + byte[] extract(Path workspace) throws IOException; + } + + /** + * A paired metamodel (ecore) file and its corresponding genmodel file. + * + * @param metamodelFile the metamodel (ecore) file; must not be {@code null} + * @param genmodelFile the genmodel file; must not be {@code null} + */ + public record ModelFiles(File metamodelFile, File genmodelFile) { + /** + * Validates that both files are present. + * + * @throws NullPointerException when either file is {@code null} + */ + public ModelFiles { + Objects.requireNonNull(metamodelFile, "metamodelFile must not be null"); + Objects.requireNonNull(genmodelFile, "genmodelFile must not be null"); + } + } +} diff --git a/src/main/resources/templates/consistencyPom.ftl b/src/main/resources/templates/consistencyPom.ftl new file mode 100644 index 0000000..4c2b6af --- /dev/null +++ b/src/main/resources/templates/consistencyPom.ftl @@ -0,0 +1,143 @@ + + + + 4.0.0 + + tools.vitruv + ${packageName} + 0.1.0-SNAPSHOT + + ${packageName}.consistency + <#noparse> + Consistency + + + + + + ${project.groupId} + + ${packageName}.model + <#noparse> + ${project.version} + compile + + + + + tools.vitruv + tools.vitruv.change.composite + + + tools.vitruv + tools.vitruv.change.propagation + + + tools.vitruv + tools.vitruv.dsls.reactions.runtime + + + + + org.eclipse.emf + org.eclipse.emf.ecore + + + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + add-source-reactions + generate-sources + + add-source + + + + ${project.basedir}/src/main/reactions + ${project.build.directory}/generated-sources/reactions + + + + + + + org.apache.maven.plugins + maven-dependency-plugin + 3.5.0 + + + copy-model-dependencies + generate-sources + + copy-dependencies + + + ${project.groupId} + ${packageName}.model + ${project.build.directory}/model-lib + + + + + + org.eclipse.xtext + xtext-maven-plugin + 2.39.0 + + + + tools.vitruv.dsls.reactions.ReactionsLanguageStandaloneSetup + + + + ${project.build.directory}/generated-sources/reactions + + + + + org.eclipse.xtend.core.XtendStandaloneSetup + + + + ${project.build.directory}/generated-sources/xtend + + + + + + + + tools.vitruv + tools.vitruv.dsls.reactions.language + 3.1.0 + + + tools.vitruv + tools.vitruv.dsls.reactions.runtime + 3.1.0 + + + tools.vitruv + tools.vitruv.dsls.common + 3.1.0 + + + ${project.groupId} + + ${packageName}.model + <#noparse> + ${project.version} + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/depencyReducedEmfUtilPom.ftl b/src/main/resources/templates/depencyReducedEmfUtilPom.ftl new file mode 100644 index 0000000..3307aa8 --- /dev/null +++ b/src/main/resources/templates/depencyReducedEmfUtilPom.ftl @@ -0,0 +1,43 @@ + + + 4.0.0 + tools.vitruv + tools.vitruv.change.p2wrappers.emfutils + p2 Dependency Wrapper EMF Utils + 3.2.0-SNAPSHOT + wrapper for the p2 dependency sdq-commons:edu.kit.ipd.sdq.commons.util.emf + https://github.com/vitruv-tools/Vitruv-Change/tools.vitruv.change.p2wrappers/tools.vitruv.change.p2wrappers.emfutils + + + Vitruvius Developers + vitruv-dev@lists.kit.edu + Karlsruhe Institute of Technology (KIT), Germany + https://kit.edu + + + + + Eclipse Public License - v 1.0 + https://www.eclipse.org/org/documents/epl-v10.php + + + + scm:git:git://github.com/vitruv-tools/Vitruv-Change.git/tools.vitruv.change.p2wrappers/tools.vitruv.change.p2wrappers.emfutils + scm:git:https://github.com/vitruv-tools/Vitruv-Change.git/tools.vitruv.change.p2wrappers/tools.vitruv.change.p2wrappers.emfutils + https://github.com/vitruv-tools/Vitruv-Change/tree/main/tools.vitruv.change.p2wrappers/tools.vitruv.change.p2wrappers.emfutils + + + Vitruvius Tools + https://vitruv.tools/ + + + + ossrh + https://oss.sonatype.org/service/local/staging/deploy/maven2/ + + + ossrh + https://oss.sonatype.org/content/repositories/snapshots + + + \ No newline at end of file diff --git a/src/main/resources/templates/emfutilsPom.ftl b/src/main/resources/templates/emfutilsPom.ftl new file mode 100644 index 0000000..9c7fddc --- /dev/null +++ b/src/main/resources/templates/emfutilsPom.ftl @@ -0,0 +1,67 @@ + + + + 4.0.0 + + + tools.vitruv + ${packageName}.p2wrappers + 0.1.0-SNAPSHOT + + + ${packageName}.p2wrappers.emfutils + + p2 Dependency Wrapper EMF Utils + wrapper for the p2 dependency sdq-commons:edu.kit.ipd.sdq.commons.util.emf + + <#noparse> + + + + org.openntf.maven + p2-layout-resolver + + + org.codehaus.mojo + flatten-maven-plugin + + + maven-shade-plugin + + + maven-jar-plugin + + + + + + 2.2.0 + + + + + sdq-commons + SDQ Commons + https://kit-sdq.github.io/updatesite/release/commons/${repo.sdq-commons.version} + p2 + + + + + + sdq-commons + edu.kit.ipd.sdq.commons.util.emf + 2.3.0.202304271319 + + + + * + * + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/generator.ftl b/src/main/resources/templates/generator.ftl new file mode 100644 index 0000000..f4d68bb --- /dev/null +++ b/src/main/resources/templates/generator.ftl @@ -0,0 +1,18 @@ +module tools.vitruv.framework.cli + +import org.eclipse.emf.mwe2.ecore.EcoreGenerator +import org.eclipse.emf.mwe.utils.StandaloneSetup + +var workspaceRoot = ".." + +Workflow { + + bean = StandaloneSetup { + scanClassPath = true + platformUri = workspaceRoot + } + <#list items as item> + <#include "modelComponent.ftl"> + + +} \ No newline at end of file diff --git a/src/main/resources/templates/javautilsPom.ftl b/src/main/resources/templates/javautilsPom.ftl new file mode 100644 index 0000000..1fe2e5c --- /dev/null +++ b/src/main/resources/templates/javautilsPom.ftl @@ -0,0 +1,66 @@ + + + + 4.0.0 + + + tools.vitruv + ${packageName}.p2wrappers + 0.1.0-SNAPSHOT + + + ${packageName}.p2wrappers.javautils + + p2 Dependency Wrapper Java Utils + wrapper for the p2 dependency sdq-commons:edu.kit.ipd.sdq.commons.util.java + <#noparse> + + + + org.openntf.maven + p2-layout-resolver + + + org.codehaus.mojo + flatten-maven-plugin + + + maven-shade-plugin + + + maven-jar-plugin + + + + + + 2.2.0 + + + + + sdq-commons + SDQ Commons + https://kit-sdq.github.io/updatesite/release/commons/${repo.sdq-commons.version} + p2 + + + + + + sdq-commons + edu.kit.ipd.sdq.commons.util.java + 2.3.0.202304271319 + + + + * + * + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/modelComponent.ftl b/src/main/resources/templates/modelComponent.ftl new file mode 100644 index 0000000..365ba90 --- /dev/null +++ b/src/main/resources/templates/modelComponent.ftl @@ -0,0 +1,6 @@ + component = EcoreGenerator { + genModel = "platform:/resource/${item.packageName}/src/main/ecore/${item.modelName}" + srcPath = "platform:/resource/${item.targetDir}/target/${item.modelDirectory}" + generateCustomClasses = false + } + \ No newline at end of file diff --git a/src/main/resources/templates/modelPom.ftl b/src/main/resources/templates/modelPom.ftl new file mode 100644 index 0000000..8f49f88 --- /dev/null +++ b/src/main/resources/templates/modelPom.ftl @@ -0,0 +1,51 @@ + + + + 4.0.0 + + + tools.vitruv + ${packageName} + 0.1.0-SNAPSHOT + + + ${packageName}.model + <#noparse> + Model + Model package of the Vitruv cli thing + + + + + org.codehaus.mojo + build-helper-maven-plugin + + + org.codehaus.mojo + exec-maven-plugin + + + maven-jar-plugin + + + ${project.basedir}/META-INF/MANIFEST.MF + + + + + + + + + org.eclipse.emf + org.eclipse.emf.common + + + org.eclipse.emf + org.eclipse.emf.ecore + + + + \ No newline at end of file diff --git a/src/main/resources/templates/p2wrappersPom.ftl b/src/main/resources/templates/p2wrappersPom.ftl new file mode 100644 index 0000000..e9f7402 --- /dev/null +++ b/src/main/resources/templates/p2wrappersPom.ftl @@ -0,0 +1,68 @@ + + + + 4.0.0 + + + tools.vitruv + ${packageName} + 0.1.0-SNAPSHOT + + + ${packageName}.p2wrappers + pom + + p2 Dependency Wrappers + + + + activextendannotations + emfutils + javautils + + + + + + + + org.openntf.maven + p2-layout-resolver + 1.9.0 + true + + + + maven-jar-plugin + 3.4.2 + + + javadoc-jar + package + + jar + + + javadoc + + + + + + + + + + + + artifactory.openntf.org + artifactory.openntf.org + https://artifactory.openntf.org/openntf + + false + + + + \ No newline at end of file diff --git a/src/main/resources/templates/plugin.ftl b/src/main/resources/templates/plugin.ftl new file mode 100644 index 0000000..47add64 --- /dev/null +++ b/src/main/resources/templates/plugin.ftl @@ -0,0 +1,13 @@ + + + + + + + + <#list items as item> + <#include "pluginExtension.ftl"> + + + \ No newline at end of file diff --git a/src/main/resources/templates/pluginExtension.ftl b/src/main/resources/templates/pluginExtension.ftl new file mode 100644 index 0000000..ee73f4f --- /dev/null +++ b/src/main/resources/templates/pluginExtension.ftl @@ -0,0 +1,7 @@ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/project.ftl b/src/main/resources/templates/project.ftl new file mode 100644 index 0000000..2e5faac --- /dev/null +++ b/src/main/resources/templates/project.ftl @@ -0,0 +1,29 @@ + + + ${packageName}.model + + + + + + org.eclipse.m2e.core.maven2Builder + + + + + + org.eclipse.m2e.core.maven2Nature + org.eclipse.jdt.core.javanature + + + + 1700568570080 + + 30 + + org.eclipse.core.resources.regexFilterMatcher + node_modules|\.git|__CREATED_BY_JAVA_LANGUAGE_SERVER__ + + + + \ No newline at end of file diff --git a/src/main/resources/templates/rootPom.ftl b/src/main/resources/templates/rootPom.ftl new file mode 100644 index 0000000..1c0876a --- /dev/null +++ b/src/main/resources/templates/rootPom.ftl @@ -0,0 +1,100 @@ + + + + 4.0.0 + + + + tools.vitruv + parent + 3.2.0 + + + + ${packageName} + 0.1.0-SNAPSHOT + pom + <#noparse> + Methodologist Template + + + + + + + model + consistency + vsum + p2wrappers + + + + 3.2.3 + + + + + + + tools.vitruv + tools.vitruv.change.atomic + ${vitruv.version} + + + tools.vitruv + tools.vitruv.change.composite + ${vitruv.version} + + + tools.vitruv + tools.vitruv.change.interaction + ${vitruv.version} + + + tools.vitruv + tools.vitruv.change.propagation + ${vitruv.version} + + + tools.vitruv + tools.vitruv.change.testutils.integration + ${vitruv.version} + + + tools.vitruv + tools.vitruv.dsls.reactions.runtime + ${vitruv.version} + + + tools.vitruv + tools.vitruv.framework.views + ${vitruv.version} + + + tools.vitruv + tools.vitruv.framework.vsum + ${vitruv.version} + + + + + org.eclipse.emf + org.eclipse.emf.common + 2.42.0 + + + org.eclipse.emf + org.eclipse.emf.ecore + 2.39.0 + + + org.junit.jupiter + junit-jupiter-api + 5.12.0 + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/viewTypes.ftl b/src/main/resources/templates/viewTypes.ftl new file mode 100644 index 0000000..b40bf84 --- /dev/null +++ b/src/main/resources/templates/viewTypes.ftl @@ -0,0 +1 @@ +viewTypes.add(ViewTypeFactory.createIdentityMappingViewType("${model}")); diff --git a/src/main/resources/templates/vsumExample.ftl b/src/main/resources/templates/vsumExample.ftl new file mode 100644 index 0000000..908f400 --- /dev/null +++ b/src/main/resources/templates/vsumExample.ftl @@ -0,0 +1,71 @@ +package ${packageName}.vsum; + +import tools.vitruv.framework.vsum.VirtualModelBuilder; +import ${packageName}.model.model.ModelFactory; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +import java.nio.file.Path; +import java.util.function.Consumer; +import mir.reactions.model2Model2.Model2Model2ChangePropagationSpecification; +import tools.vitruv.change.testutils.TestUserInteraction; +import tools.vitruv.framework.views.CommittableView; +import tools.vitruv.framework.views.View; +import tools.vitruv.framework.views.ViewTypeFactory; +import tools.vitruv.framework.views.ViewType; +import tools.vitruv.framework.vsum.VirtualModel; +import tools.vitruv.framework.remote.server.*; +import java.util.Scanner; +import java.io.IOException; + +/** + * This class provides an example how to define and use a VSUM. + */ +public class VSUMExample { + public static void main(String[] args) { + try { + VitruvServer server = new VitruvServer(VSUMExample::createDefaultVirtualModel); + server.start(); + Scanner scanner = new Scanner(System.in); + while (!scanner.nextLine().equals("quit")) { + + } + scanner.close(); + server.stop(); + } catch (IOException e) { + System.out.println("Something went wrong " + e); + } + } + + private static VirtualModel createDefaultVirtualModel() { + Collection> viewTypes = createDefaultViewTypes(); + VirtualModel model = new VirtualModelBuilder() + .withStorageFolder(Path.of("vsumexample")) + .withUserInteractorForResultProvider(new TestUserInteraction.ResultProvider(new TestUserInteraction())) + .withChangePropagationSpecifications(new Model2Model2ChangePropagationSpecification()).withViewTypes(viewTypes) + .buildAndInitialize(); + getDefaultView(model); + return model; + } + + private static View getDefaultView(VirtualModel vsum) { + var selector = vsum.createSelector(ViewTypeFactory.createIdentityMappingViewType("default")); + selector.getSelectableElements().forEach(it -> selector.setSelected(it, true)); + return selector.createView(); + } + + private static void modifyView(CommittableView view, Consumer modificationFunction) { + modificationFunction.accept(view); + view.commitChanges(); + } + + private static Collection> createDefaultViewTypes() { + List> viewTypes = new ArrayList<>(); + <#list models as model> + <#include "viewTypes.ftl"> + + return viewTypes; + } + +} \ No newline at end of file diff --git a/src/main/resources/templates/vsumPom.ftl b/src/main/resources/templates/vsumPom.ftl new file mode 100644 index 0000000..f10fa3d --- /dev/null +++ b/src/main/resources/templates/vsumPom.ftl @@ -0,0 +1,109 @@ + + + +4.0.0 + + + tools.vitruv + ${packageName} + 0.1.0-SNAPSHOT + + +${packageName}.vsum + +Vsum + + + + + + maven-assembly-plugin + + + jar-with-dependencies + + + + tools.vitruv.methodologisttemplate.vsum.VSUMExample + + + + + + server-jar + package + + single + + + + + + + + + 3.2.3 + + + + + + + <#noparse> + ${project.groupId} + + ${packageName}.model + <#noparse> + ${project.version} + + +${project.groupId} + +${packageName}.consistency +<#noparse> + ${project.version} + + + tools.vitruv + tools.vitruv.server.remote + ${vitruv.version} + + + + + tools.vitruv + tools.vitruv.change.interaction + + + tools.vitruv + tools.vitruv.change.propagation + + + tools.vitruv + tools.vitruv.change.testutils.integration + + + tools.vitruv + tools.vitruv.framework.views + + + tools.vitruv + tools.vitruv.framework.vsum + + + + + org.eclipse.emf + org.eclipse.emf.ecore + + + + org.junit.jupiter + junit-jupiter-api + test + + + + \ No newline at end of file diff --git a/src/main/resources/templates/vsumTest.ftl b/src/main/resources/templates/vsumTest.ftl new file mode 100644 index 0000000..484eb07 --- /dev/null +++ b/src/main/resources/templates/vsumTest.ftl @@ -0,0 +1,68 @@ +package ${packageName}.vsum; + +import tools.vitruv.framework.vsum.VirtualModelBuilder; +import ${packageName}.model.model.ModelFactory; + +import java.nio.file.Path; +import java.util.function.Consumer; + +import org.eclipse.emf.common.util.URI; +import org.eclipse.emf.ecore.resource.Resource; +import org.eclipse.emf.ecore.xmi.impl.XMIResourceFactoryImpl; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; + +import mir.reactions.model2Model2.Model2Model2ChangePropagationSpecification; +import tools.vitruv.change.testutils.TestUserInteraction; +import tools.vitruv.framework.views.CommittableView; +import tools.vitruv.framework.views.View; +import tools.vitruv.framework.views.ViewTypeFactory; +import tools.vitruv.framework.vsum.VirtualModel; +import org.junit.jupiter.api.Disabled; + +/** + * This class provides an example how to define and use a VSUM. + */ +public class VSUMExampleTest { + + static final Path projectPath = Path.of("target/vsumexample"); + + @BeforeAll + static void setup() { + Resource.Factory.Registry.INSTANCE.getExtensionToFactoryMap().put("model", new XMIResourceFactoryImpl()); + } + + @Disabled + @Test + void test() { + VirtualModel vsum = createDefaultVirtualModel(); + CommittableView view = getDefaultView(vsum).withChangeDerivingTrait(); + modifyView(view, (CommittableView v) -> { + v.registerRoot( + ModelFactory.eINSTANCE.createSystem(), + URI.createURI(projectPath.resolve("example.model").toString())); + }); + Assertions.assertFalse(getDefaultView(vsum).getRootObjects().isEmpty(),"Modification of view failed"); + } + + private VirtualModel createDefaultVirtualModel() { + return new VirtualModelBuilder() + .withStorageFolder(projectPath) + .withUserInteractorForResultProvider(new TestUserInteraction.ResultProvider(new TestUserInteraction())) + .withChangePropagationSpecifications(new Model2Model2ChangePropagationSpecification()) + .buildAndInitialize(); + } + + private View getDefaultView(VirtualModel vsum) { + var selector = vsum.createSelector(ViewTypeFactory.createIdentityMappingViewType("default")); + selector.getSelectableElements().forEach(it -> selector.setSelected(it, true)); + return selector.createView(); + } + + private void modifyView(CommittableView view, Consumer modificationFunction) { + modificationFunction.accept(view); + view.commitChanges(); + } + +} \ No newline at end of file diff --git a/src/main/resources/templates/xannotationsPom.ftl b/src/main/resources/templates/xannotationsPom.ftl new file mode 100644 index 0000000..b29b573 --- /dev/null +++ b/src/main/resources/templates/xannotationsPom.ftl @@ -0,0 +1,59 @@ + + + + 4.0.0 + + + tools.vitruv + ${packageName}.p2wrappers + 0.1.0-SNAPSHOT + + + ${packageName}.p2wrappers.activextendannotations + + p2 Dependency Wrapper Active Xtend Annotations + wrapper for the p2 dependency xannotations:edu.kit.ipd.sdq.activextendannotations + <#noparse> + + + + org.openntf.maven + p2-layout-resolver + + + org.codehaus.mojo + flatten-maven-plugin + + + maven-shade-plugin + + + maven-jar-plugin + + + + + + 1.6.0 + + + + + xannotations + XAnnotations + p2 + https://kit-sdq.github.io/updatesite/release/xannotations/${repo.xannotations.version} + + + + + + xannotations + edu.kit.ipd.sdq.activextendannotations + 1.6.0 + + + + \ No newline at end of file diff --git a/src/test/java/tools/vitruv/methodologist/setup/vsum/controller/VsumControllerTest.java b/src/test/java/tools/vitruv/methodologist/setup/vsum/controller/VsumControllerTest.java new file mode 100644 index 0000000..eb6c2f2 --- /dev/null +++ b/src/test/java/tools/vitruv/methodologist/setup/vsum/controller/VsumControllerTest.java @@ -0,0 +1,170 @@ +package tools.vitruv.methodologist.setup.vsum.controller; + +import static org.junit.jupiter.api.Assertions.assertArrayEquals; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.nio.file.Files; +import java.nio.file.NoSuchFileException; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.ResponseEntity; +import org.springframework.mock.web.MockMultipartFile; +import tools.vitruv.methodologist.setup.exception.MethodologistSetupException; +import tools.vitruv.methodologist.setup.vsum.service.VsumProjectBuildService; +import tools.vitruv.methodologist.setup.vsum.service.VsumService; + +/** Unit tests for VSUM project build orchestration logic. */ +@ExtendWith(MockitoExtension.class) +class VsumControllerTest { + + @Mock private VsumService vsumService; + + @InjectMocks private VsumProjectBuildService vsumProjectBuildService; + + /** Verifies uploaded files are transformed and delegated to VSUM generation service. */ + @Test + void buildProjectArchiveDelegatesToVsumService() throws Exception { + byte[] archive = "zip-content".getBytes(); + when(vsumService.generateProjectArchive(anyList(), anyList(), any())).thenReturn(archive); + + MockMultipartFile metamodel = + new MockMultipartFile( + "metamodelFiles", "model.ecore", "application/octet-stream", "meta".getBytes()); + MockMultipartFile genmodel = + new MockMultipartFile( + "genmodelFiles", "model.genmodel", "application/octet-stream", "gen".getBytes()); + MockMultipartFile reaction = + new MockMultipartFile( + "reactionFiles", "rules.reactions", "text/plain", "import \"x\"".getBytes()); + + byte[] result = + vsumProjectBuildService.buildProjectArchive( + List.of(metamodel), List.of(genmodel), List.of(reaction)); + + assertArrayEquals(archive, result); + verify(vsumService).generateProjectArchive(anyList(), anyList(), any()); + } + + /** Ensures mismatched metamodel/genmodel upload counts are rejected. */ + @Test + void buildProjectArchiveRejectsMismatchedPairs() { + MockMultipartFile metamodel = + new MockMultipartFile( + "metamodelFiles", "model.ecore", "application/octet-stream", "meta".getBytes()); + + MethodologistSetupException exception = + assertThrows( + MethodologistSetupException.class, + () -> + vsumProjectBuildService.buildProjectArchive( + List.of(metamodel), List.of(), List.of())); + + org.junit.jupiter.api.Assertions.assertEquals("VSUM_INPUT_ERROR", exception.getErrorCode()); + } + + /** Verifies reaction imports are rewritten to the uploaded metamodel nsURI when needed. */ + @Test + void buildProjectArchiveNormalizesReactionImportUris() throws Exception { + byte[] archive = "zip-content".getBytes(); + doAnswer( + invocation -> { + @SuppressWarnings("unchecked") + List reactionFiles = invocation.getArgument(1); + String rewrittenContent = Files.readString(reactionFiles.getFirst().toPath()); + assertTrue(rewrittenContent.contains("http://example.org/model")); + return archive; + }) + .when(vsumService) + .generateProjectArchive(anyList(), anyList(), any()); + + String ecore = + "" + + ""; + String reaction = "import \"http://vitruv.tools/methodologisttemplate/model\" as m"; + + MockMultipartFile metamodel = + new MockMultipartFile("metamodelFiles", "model.ecore", "application/xml", ecore.getBytes()); + MockMultipartFile genmodel = + new MockMultipartFile( + "genmodelFiles", "model.genmodel", "application/xml", "genmodel".getBytes()); + MockMultipartFile reactionFile = + new MockMultipartFile( + "reactionFiles", "rules.reactions", "text/plain", reaction.getBytes()); + + vsumProjectBuildService.buildProjectArchive( + List.of(metamodel), List.of(genmodel), List.of(reactionFile)); + + verify(vsumService).generateProjectArchive(anyList(), anyList(), any()); + } + + /** Verifies the build endpoint returns the zip archive with the expected download headers. */ + @Test + void buildProjectReturnsZipResponse() throws Exception { + VsumProjectBuildService buildService = mock(VsumProjectBuildService.class); + byte[] archive = {1, 2, 3, 4}; + when(buildService.buildProjectArchive(anyList(), anyList(), anyList())).thenReturn(archive); + + VsumController controller = new VsumController(buildService); + + MockMultipartFile metamodel = + new MockMultipartFile( + "metamodelFiles", "model.ecore", "application/octet-stream", "meta".getBytes()); + MockMultipartFile genmodel = + new MockMultipartFile( + "genmodelFiles", "model.genmodel", "application/octet-stream", "gen".getBytes()); + + ResponseEntity response = + controller.buildProject(List.of(metamodel), List.of(genmodel), List.of()); + + assertEquals(200, response.getStatusCode().value()); + assertArrayEquals(archive, response.getBody()); + assertEquals("application/zip", response.getHeaders().getContentType().toString()); + assertEquals(archive.length, response.getHeaders().getContentLength()); + String filename = response.getHeaders().getContentDisposition().getFilename(); + assertTrue(filename.startsWith("vsum-project-")); + assertTrue(filename.endsWith(".zip")); + } + + /** Verifies the jar endpoint returns the jar bytes with the expected download headers. */ + @Test + void buildJarReturnsJarResponse() throws NoSuchFileException { + VsumProjectBuildService buildService = mock(VsumProjectBuildService.class); + byte[] jar = {9, 8, 7}; + when(buildService.buildProjectJar(anyList(), anyList(), any())).thenReturn(jar); + + VsumController controller = new VsumController(buildService); + + MockMultipartFile metamodel = + new MockMultipartFile( + "metamodelFiles", "model.ecore", "application/octet-stream", "meta".getBytes()); + MockMultipartFile genmodel = + new MockMultipartFile( + "genmodelFiles", "model.genmodel", "application/octet-stream", "gen".getBytes()); + + ResponseEntity response = + controller.buildJar(List.of(metamodel), List.of(genmodel), null); + + assertEquals(200, response.getStatusCode().value()); + assertArrayEquals(jar, response.getBody()); + assertEquals("application/java-archive", response.getHeaders().getContentType().toString()); + assertEquals(jar.length, response.getHeaders().getContentLength()); + assertEquals( + "tools.vitruv.methodologisttemplate.vsum-0.1.0-SNAPSHOT-jar-with-dependencies.jar", + response.getHeaders().getContentDisposition().getFilename()); + + verify(buildService).buildProjectJar(anyList(), anyList(), any()); + } +} diff --git a/src/test/java/tools/vitruv/methodologist/setup/vsum/service/GenerateFromTemplateTest.java b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/GenerateFromTemplateTest.java new file mode 100644 index 0000000..c2ad7a4 --- /dev/null +++ b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/GenerateFromTemplateTest.java @@ -0,0 +1,361 @@ +package tools.vitruv.methodologist.setup.vsum.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import tools.vitruv.methodologist.setup.config.MetamodelLocation; +import tools.vitruv.methodologist.setup.config.VitruvConfiguration; +import tools.vitruv.methodologist.setup.exception.MissingModelException; + +/** + * Unit tests for {@link GenerateFromTemplate}. + * + *

The templates are loaded from the production classpath ({@code /templates}), so every test + * exercises the real FreeMarker rendering pipeline against the bundled template files. + */ +@DisplayName("GenerateFromTemplate Tests") +class GenerateFromTemplateTest { + + private static final String PACKAGE_NAME = "tools.vitruv.generated"; + + @TempDir Path tempDir; + + private GenerateFromTemplate generator; + + /** Creates a fresh generator instance before each test. */ + @BeforeEach + void setUp() { + generator = new GenerateFromTemplate(); + } + + /** + * Reads the rendered file content for a target produced by the generator. + * + * @param target the rendered file + * @return the file content as a string + * @throws IOException when the file cannot be read + */ + private String read(Path target) throws IOException { + return Files.readString(target); + } + + /** + * Builds a configuration with a local path and package name suitable for the mwe2 and plugin + * generators. + * + * @return a populated configuration + */ + private VitruvConfiguration configuration() { + VitruvConfiguration config = new VitruvConfiguration(); + config.setLocalPath(tempDir); + config.setPackageName(PACKAGE_NAME); + return config; + } + + /** + * Builds a metamodel location whose genmodel file name is {@code model.genmodel}. + * + * @param modelDirectory the model directory used by the mwe2 generator + * @return a metamodel location + */ + private MetamodelLocation metamodelLocation(String modelDirectory) { + File metamodel = tempDir.resolve("model.ecore").toFile(); + File genmodel = tempDir.resolve("model.genmodel").toFile(); + return new MetamodelLocation(metamodel, genmodel, "http://example.org/model", modelDirectory); + } + + /** Verifies the placeholder for the unused private field assertion utility. */ + @Test + @DisplayName("Should expose a no-argument constructor") + void exposesNoArgConstructor() { + assertEquals(GenerateFromTemplate.class, new GenerateFromTemplate().getClass()); + } + + @Nested + @DisplayName("generateRootPom") + class GenerateRootPom { + + /** Verifies the root pom is rendered and contains the supplied package name. */ + @Test + @DisplayName("Should render root pom containing the package name") + void rendersRootPom() throws Exception { + Path target = tempDir.resolve("pom.xml"); + + generator.generateRootPom(target.toFile(), PACKAGE_NAME); + + assertTrue(Files.exists(target)); + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies a surrounding-whitespace package name is trimmed before rendering. */ + @Test + @DisplayName("Should trim the package name before rendering") + void trimsPackageName() throws Exception { + Path target = tempDir.resolve("pom.xml"); + + generator.generateRootPom(target.toFile(), " " + PACKAGE_NAME + " "); + + String content = read(target); + assertTrue(content.contains("" + PACKAGE_NAME + "")); + } + + /** Verifies a {@code null} package name is rejected with a {@link MissingModelException}. */ + @Test + @DisplayName("Should reject a null package name") + void rejectsNullPackageName() { + File target = tempDir.resolve("pom.xml").toFile(); + + assertThrows(MissingModelException.class, () -> generator.generateRootPom(target, null)); + } + + /** Verifies an empty package name is rejected with a {@link MissingModelException}. */ + @Test + @DisplayName("Should reject an empty package name") + void rejectsEmptyPackageName() { + File target = tempDir.resolve("pom.xml").toFile(); + + assertThrows(MissingModelException.class, () -> generator.generateRootPom(target, "")); + } + + /** Verifies parent directories are created automatically for a nested target path. */ + @Test + @DisplayName("Should create missing parent directories") + void createsParentDirectories() throws Exception { + Path target = tempDir.resolve("nested/deep/pom.xml"); + + generator.generateRootPom(target.toFile(), PACKAGE_NAME); + + assertTrue(Files.exists(target)); + } + } + + @Nested + @DisplayName("single package-name pom generators") + class SinglePackageNamePoms { + + /** Verifies the vsum pom is rendered with the package name. */ + @Test + @DisplayName("Should render vsum pom") + void rendersVsumPom() throws Exception { + Path target = tempDir.resolve("vsum/pom.xml"); + + generator.generateVsumPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the p2wrappers pom is rendered with the package name. */ + @Test + @DisplayName("Should render p2wrappers pom") + void rendersP2WrappersPom() throws Exception { + Path target = tempDir.resolve("p2wrappers/pom.xml"); + + generator.generateP2WrappersPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the javautils pom is rendered with the package name. */ + @Test + @DisplayName("Should render javautils pom") + void rendersJavaUtilsPom() throws Exception { + Path target = tempDir.resolve("javautils/pom.xml"); + + generator.generateJavaUtilsPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the xannotations pom is rendered with the package name. */ + @Test + @DisplayName("Should render xannotations pom") + void rendersXAnnotationsPom() throws Exception { + Path target = tempDir.resolve("xannotations/pom.xml"); + + generator.generateXAnnotationsPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the emfutils pom is rendered with the package name. */ + @Test + @DisplayName("Should render emfutils pom") + void rendersEmfUtilsPom() throws Exception { + Path target = tempDir.resolve("emfutils/pom.xml"); + + generator.generateEMFUtilsPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the model pom is rendered with the package name. */ + @Test + @DisplayName("Should render model pom") + void rendersModelPom() throws Exception { + Path target = tempDir.resolve("model/pom.xml"); + + generator.generateModelPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the consistency pom is rendered with the package name. */ + @Test + @DisplayName("Should render consistency pom") + void rendersConsistencyPom() throws Exception { + Path target = tempDir.resolve("consistency/pom.xml"); + + generator.generateConsistencyPom(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + + /** Verifies the vsum test class is rendered with the package declaration. */ + @Test + @DisplayName("Should render vsum test class") + void rendersVsumTest() throws Exception { + Path target = tempDir.resolve("vsum/src/test/java/VSUMExampleTest.java"); + + generator.generateVsumTest(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains("package " + PACKAGE_NAME + ".vsum;")); + } + + /** Verifies the Eclipse {@code .project} file is rendered with the package name. */ + @Test + @DisplayName("Should render project file") + void rendersProjectFile() throws Exception { + Path target = tempDir.resolve("model/.project"); + + generator.generateProjectFile(target.toFile(), PACKAGE_NAME); + + assertTrue(read(target).contains(PACKAGE_NAME)); + } + } + + @Nested + @DisplayName("generateVsumExample") + class GenerateVsumExample { + + /** Verifies the example renders one view type entry per supplied model name. */ + @Test + @DisplayName("Should render a view type for every model name") + void rendersViewTypesPerModel() throws Exception { + Path target = tempDir.resolve("vsum/src/main/java/VSUMExample.java"); + + generator.generateVsumExample(target.toFile(), PACKAGE_NAME, List.of("Alpha", "Beta")); + + String content = read(target); + assertTrue(content.contains("package " + PACKAGE_NAME + ".vsum;")); + assertTrue(content.contains("createIdentityMappingViewType(\"Alpha\")")); + assertTrue(content.contains("createIdentityMappingViewType(\"Beta\")")); + } + + /** Verifies the example still renders when no model names are supplied. */ + @Test + @DisplayName("Should render with an empty model list") + void rendersWithEmptyModelList() throws Exception { + Path target = tempDir.resolve("vsum/src/main/java/VSUMExample.java"); + + generator.generateVsumExample(target.toFile(), PACKAGE_NAME, List.of()); + + assertTrue(read(target).contains("class VSUMExample")); + } + } + + @Nested + @DisplayName("generateMwe2") + class GenerateMwe2 { + + /** Verifies the mwe2 workflow embeds model, package and target directory information. */ + @Test + @DisplayName("Should render a component per metamodel location") + void rendersComponentsPerModel() throws Exception { + Path target = tempDir.resolve("model/workflow/generate.mwe2"); + VitruvConfiguration config = configuration(); + List models = List.of(metamodelLocation("src/main/ecore")); + + generator.generateMwe2(target.toFile(), models, config); + + String content = read(target); + assertTrue(content.contains("model.genmodel")); + assertTrue(content.contains(PACKAGE_NAME + ".model")); + assertTrue(content.contains("src/main/ecore")); + } + + /** Verifies backslashes and duplicated slashes in the model directory are normalized. */ + @Test + @DisplayName("Should normalize the model directory separators") + void normalizesModelDirectory() throws Exception { + Path target = tempDir.resolve("model/workflow/generate.mwe2"); + VitruvConfiguration config = configuration(); + List models = List.of(metamodelLocation("a\\b//c")); + + generator.generateMwe2(target.toFile(), models, config); + + String content = read(target); + assertTrue(content.contains("a/b/c")); + assertFalse(content.contains("a\\b")); + } + } + + @Nested + @DisplayName("generatePlugin") + class GeneratePlugin { + + /** Verifies the plugin descriptor embeds the model URI, package and capitalized model name. */ + @Test + @DisplayName("Should render an extension per metamodel location") + void rendersExtensionPerModel() throws Exception { + Path target = tempDir.resolve("model/plugin.xml"); + VitruvConfiguration config = configuration(); + List models = List.of(metamodelLocation("src/main/ecore")); + + generator.generatePlugin(target.toFile(), config, models); + + String content = read(target); + assertTrue(content.contains("http://example.org/model")); + assertTrue(content.contains(PACKAGE_NAME + ".model.model.ModelPackage")); + assertTrue(content.contains("src/main/ecore/model.genmodel")); + } + + /** Verifies the plugin descriptor renders without extensions when no models are supplied. */ + @Test + @DisplayName("Should render an empty plugin descriptor") + void rendersEmptyPlugin() throws Exception { + Path target = tempDir.resolve("model/plugin.xml"); + + generator.generatePlugin(target.toFile(), configuration(), List.of()); + + assertTrue(read(target).contains("")); + } + } + + @Nested + @DisplayName("error handling") + class ErrorHandling { + + /** Verifies an {@link IOException} is raised when the target path is a directory. */ + @Test + @DisplayName("Should fail when the target path is a directory") + void failsWhenTargetIsDirectory() throws Exception { + Path directoryTarget = Files.createDirectory(tempDir.resolve("collision")); + + assertThrows( + IOException.class, + () -> generator.generateVsumPom(directoryTarget.toFile(), PACKAGE_NAME)); + } + } +} diff --git a/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java new file mode 100644 index 0000000..dd2bb08 --- /dev/null +++ b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumProjectBuildServiceTest.java @@ -0,0 +1,355 @@ +package tools.vitruv.methodologist.setup.vsum.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.io.File; +import java.nio.charset.StandardCharsets; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.web.multipart.MultipartFile; +import tools.vitruv.methodologist.setup.exception.MethodologistSetupException; +import tools.vitruv.methodologist.setup.exception.MissingModelException; + +class VsumProjectBuildServiceTest { + + private final VsumService vsumService = mock(VsumService.class); + private final VsumProjectBuildService service = new VsumProjectBuildService(vsumService); + @TempDir java.nio.file.Path tempDir; + + @Test + void buildProjectArchive_shouldThrowWhenMetamodelFilesNull() { + + assertThatThrownBy( + () -> + service.buildProjectArchive( + null, List.of(mockMultipart("model.genmodel")), List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("At least one metamodel"); + } + + @Test + void buildProjectArchive_shouldThrowWhenGenmodelFilesNull() { + + assertThatThrownBy( + () -> + service.buildProjectArchive(List.of(mockMultipart("model.ecore")), null, List.of())) + .isInstanceOf(MethodologistSetupException.class); + } + + @Test + void buildProjectArchive_shouldThrowWhenCountsDoNotMatch() { + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(mockMultipart("a.ecore"), mockMultipart("b.ecore")), + List.of(mockMultipart("a.genmodel")), + List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("counts must be identical"); + } + + @Test + void buildProjectArchive_shouldThrowWhenMetamodelEmpty() { + + MultipartFile empty = + new MockMultipartFile("file", "model.ecore", "application/xml", new byte[0]); + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(empty), List.of(mockMultipart("model.genmodel")), List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("metamodel file"); + } + + @Test + void buildProjectArchive_shouldThrowWhenGenmodelEmpty() { + + MultipartFile empty = + new MockMultipartFile("file", "model.genmodel", "application/xml", new byte[0]); + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(mockMultipart("model.ecore")), List.of(empty), List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("genmodel file"); + } + + @Test + void buildProjectArchive_shouldBuildArchive() throws Exception { + + byte[] expected = {1, 2, 3}; + + when(vsumService.generateProjectArchive(anyList(), anyList(), anyMap())).thenReturn(expected); + + byte[] archive = + service.buildProjectArchive( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile())); + + assertThat(archive).isEqualTo(expected); + + verify(vsumService).generateProjectArchive(anyList(), anyList(), anyMap()); + } + + @Test + void buildProjectArchive_shouldThrowWhenReactionFilesNull() { + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(validMetamodel()), List.of(mockMultipart("model.genmodel")), null)) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("reaction file is required"); + } + + @Test + void buildProjectArchive_shouldThrowWhenReactionFilesEmpty() { + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(validMetamodel()), List.of(mockMultipart("model.genmodel")), List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("reaction file is required"); + } + + @Test + void buildProjectArchive_shouldThrowWhenReactionFileEmpty() { + + MultipartFile empty = + new MockMultipartFile("file", "sample.reactions", "text/plain", new byte[0]); + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(empty))) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("reaction file"); + } + + @Test + void buildProjectArchive_shouldWrapIOException() throws Exception { + + when(vsumService.generateProjectArchive(anyList(), anyList(), anyMap())) + .thenThrow(new java.io.IOException("boom")); + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile()))) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("Failed to build VSUM project archive"); + } + + @Test + void buildProjectArchive_shouldWrapMissingModelException() throws Exception { + + when(vsumService.generateProjectArchive(anyList(), anyList(), anyMap())) + .thenThrow(new MissingModelException("missing")); + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile()))) + .isInstanceOf(MethodologistSetupException.class); + } + + @Test + void buildProjectArchive_shouldWrapInterruptedException() throws Exception { + + when(vsumService.generateProjectArchive(anyList(), anyList(), anyMap())) + .thenThrow(new InterruptedException()); + + assertThatThrownBy( + () -> + service.buildProjectArchive( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile()))) + .isInstanceOf(MethodologistSetupException.class); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + + Thread.interrupted(); + } + + @Test + void buildProjectJar_shouldThrowWhenCountsDoNotMatch() { + + assertThatThrownBy( + () -> + service.buildProjectJar( + List.of(mockMultipart("a.ecore"), mockMultipart("b.ecore")), + List.of(mockMultipart("a.genmodel")), + List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("counts must be identical"); + } + + @Test + void buildProjectJar_shouldReturnJarBytes() throws Exception { + + byte[] expected = {7, 8, 9}; + + when(vsumService.generateProjectJar(anyList(), anyList(), anyMap())).thenReturn(expected); + + byte[] jar = + service.buildProjectJar( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile())); + + assertThat(jar).isEqualTo(expected); + + verify(vsumService).generateProjectJar(anyList(), anyList(), anyMap()); + } + + @Test + void buildProjectJar_shouldThrowWhenReactionFilesEmpty() { + + assertThatThrownBy( + () -> + service.buildProjectJar( + List.of(validMetamodel()), List.of(mockMultipart("model.genmodel")), List.of())) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("reaction file is required"); + } + + @Test + void buildProjectJar_shouldWrapIOException() throws Exception { + + when(vsumService.generateProjectJar(anyList(), anyList(), anyMap())) + .thenThrow(new java.io.IOException("boom")); + + assertThatThrownBy( + () -> + service.buildProjectJar( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile()))) + .isInstanceOf(MethodologistSetupException.class) + .hasMessageContaining("Failed to build VSUM project"); + } + + @Test + void buildProjectJar_shouldWrapInterruptedException() throws Exception { + + when(vsumService.generateProjectJar(anyList(), anyList(), anyMap())) + .thenThrow(new InterruptedException()); + + assertThatThrownBy( + () -> + service.buildProjectJar( + List.of(validMetamodel()), + List.of(mockMultipart("model.genmodel")), + List.of(reactionFile()))) + .isInstanceOf(MethodologistSetupException.class); + + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + + Thread.interrupted(); + } + + @Test + void extractMetamodelNamespaceMap_shouldExtractNamespaceInformation() { + + File ecore = + writeTempEcore( + """ + + + """); + + VsumService.ModelFiles pair = + new VsumService.ModelFiles(ecore, tempDir.resolve("test.genmodel").toFile()); + + Map result = service.extractMetamodelNamespaceMap(List.of(pair)); + + assertThat(result).containsEntry("pcm", "http://pcm"); + } + + @Test + void extractMetamodelNamespaceMap_shouldIgnoreInvalidMetamodel() { + + File invalid = writeTempFile("broken.ecore", "not xml"); + + VsumService.ModelFiles pair = + new VsumService.ModelFiles(invalid, tempDir.resolve("test.genmodel").toFile()); + + Map result = service.extractMetamodelNamespaceMap(List.of(pair)); + + assertThat(result).isEmpty(); + } + + private MockMultipartFile mockMultipart(String filename) { + + return new MockMultipartFile( + "file", filename, "application/octet-stream", "content".getBytes(StandardCharsets.UTF_8)); + } + + private MockMultipartFile reactionFile() { + + return new MockMultipartFile( + "file", + "sample.reactions", + "text/plain", + """ + import "pcm" + """ + .getBytes(StandardCharsets.UTF_8)); + } + + private MockMultipartFile validMetamodel() { + + return new MockMultipartFile( + "file", + "model.ecore", + "application/xml", + """ + + + """ + .getBytes(StandardCharsets.UTF_8)); + } + + private File writeTempEcore(String content) { + + return writeTempFile("model.ecore", content); + } + + private File writeTempFile(String name, String content) { + + try { + File file = tempDir.resolve(name).toFile(); + java.nio.file.Files.writeString(file.toPath(), content); + return file; + } catch (Exception e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumServiceTest.java b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumServiceTest.java new file mode 100644 index 0000000..f516e1c --- /dev/null +++ b/src/test/java/tools/vitruv/methodologist/setup/vsum/service/VsumServiceTest.java @@ -0,0 +1,341 @@ +package tools.vitruv.methodologist.setup.vsum.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyMap; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doReturn; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.spy; +import static org.mockito.Mockito.verify; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; + +class VsumServiceTest { + + private final GenerateFromTemplate generateFromTemplate = mock(GenerateFromTemplate.class); + @TempDir Path tempDir; + + @Test + void generateProjectArchive_shouldThrowWhenModelFilesNull() { + VsumService service = spy(new VsumService(generateFromTemplate)); + + assertThatThrownBy(() -> service.generateProjectArchive(null, List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one metamodel/genmodel pair"); + } + + @Test + void generateProjectArchive_shouldThrowWhenModelFilesEmpty() { + VsumService service = spy(new VsumService(generateFromTemplate)); + + assertThatThrownBy(() -> service.generateProjectArchive(List.of(), List.of())) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void generateProjectArchive_shouldThrowWhenMetamodelMissing() throws IOException { + File missing = tempDir.resolve("missing.ecore").toFile(); + + File genmodel = Files.createFile(tempDir.resolve("model.genmodel")).toFile(); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(missing, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + assertThatThrownBy(() -> service.generateProjectArchive(List.of(modelFiles), List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("metamodelFile does not exist"); + } + + @Test + void generateProjectArchive_shouldThrowWhenReactionFileMissing() throws IOException { + File ecore = Files.createFile(tempDir.resolve("model.ecore")).toFile(); + File genmodel = Files.createFile(tempDir.resolve("model.genmodel")).toFile(); + + File missingReaction = tempDir.resolve("missing.reactions").toFile(); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(ecore, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + assertThatThrownBy( + () -> service.generateProjectArchive(List.of(modelFiles), List.of(missingReaction))) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("reactionFile does not exist"); + } + + @Test + void generateProjectArchive_shouldGenerateArchive() throws Exception { + + File ecore = Files.createFile(tempDir.resolve("model.ecore")).toFile(); + Files.writeString( + ecore.toPath(), + """ + + + + """); + File genmodel = Files.createFile(tempDir.resolve("model.genmodel")).toFile(); + Files.writeString( + genmodel.toPath(), + """ + + + + """); + File reaction = Files.createFile(tempDir.resolve("sample.reactions")).toFile(); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(ecore, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + doAnswer( + invocation -> { + Path workspace = ((File) invocation.getArgument(0)).toPath().getParent().getParent(); + + Files.createDirectories(workspace); + return null; + }) + .when(generateFromTemplate) + .generateRootPom(any(File.class), anyString()); + + doNothing().when(service).runMavenBuild(any(Path.class), anyMap()); + + byte[] archive = + service.generateProjectArchive( + List.of(modelFiles), List.of(reaction), Map.of("pcm", "http://pcm/ns")); + + assertThat(archive).isNotNull(); + assertThat(archive.length).isGreaterThan(0); + + verify(service).runMavenBuild(any(Path.class), eq(Map.of("pcm", "http://pcm/ns"))); + } + + @Test + void generateProjectArchive_shouldDelegateToThreeArgumentMethod() throws Exception { + + File ecore = mock(File.class); + File genmodel = mock(File.class); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(ecore, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + byte[] expected = {1, 2, 3}; + + doReturn(expected) + .when(service) + .generateProjectArchive(eq(List.of(modelFiles)), eq(List.of()), eq(Map.of())); + + byte[] result = service.generateProjectArchive(List.of(modelFiles), List.of()); + + assertThat(result).isEqualTo(expected); + } + + @Test + void generateProjectJar_shouldReturnBuiltJarBytes() throws Exception { + + File ecore = writeValidEcore(); + File genmodel = writeValidGenmodel(); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(ecore, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + byte[] jarBytes = {10, 20, 30, 40}; + stubProjectGeneration(service, jarBytes); + + byte[] result = + service.generateProjectJar(List.of(modelFiles), List.of(), Map.of("pcm", "http://pcm/ns")); + + assertThat(result).isEqualTo(jarBytes); + verify(service).runMavenBuild(any(Path.class), eq(Map.of("pcm", "http://pcm/ns"))); + } + + @Test + void generateProjectJar_shouldThrowWhenJarMissing() throws Exception { + + File ecore = writeValidEcore(); + File genmodel = writeValidGenmodel(); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(ecore, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + doAnswer( + invocation -> { + Path workspace = ((File) invocation.getArgument(0)).toPath().getParent().getParent(); + Files.createDirectories(workspace); + return null; + }) + .when(generateFromTemplate) + .generateRootPom(any(File.class), anyString()); + doNothing().when(service).runMavenBuild(any(Path.class), anyMap()); + + assertThatThrownBy(() -> service.generateProjectJar(List.of(modelFiles), List.of())) + .isInstanceOf(java.nio.file.NoSuchFileException.class) + .hasMessageContaining("jar-with-dependencies.jar"); + } + + @Test + void generateProjectJar_shouldDelegateToThreeArgumentMethod() throws Exception { + + File ecore = mock(File.class); + File genmodel = mock(File.class); + + VsumService.ModelFiles modelFiles = new VsumService.ModelFiles(ecore, genmodel); + + VsumService service = spy(new VsumService(generateFromTemplate)); + + byte[] expected = {4, 5, 6}; + + doReturn(expected) + .when(service) + .generateProjectJar(eq(List.of(modelFiles)), eq(List.of()), eq(Map.of())); + + byte[] result = service.generateProjectJar(List.of(modelFiles), List.of()); + + assertThat(result).isEqualTo(expected); + } + + @Test + void generateProjectJar_shouldThrowWhenModelFilesNull() { + VsumService service = spy(new VsumService(generateFromTemplate)); + + assertThatThrownBy(() -> service.generateProjectJar(null, List.of())) + .isInstanceOf(IllegalArgumentException.class) + .hasMessageContaining("At least one metamodel/genmodel pair"); + } + + @Test + void modelFiles_shouldRejectNullMetamodel() { + File genmodel = new File("test.genmodel"); + + assertThatThrownBy(() -> new VsumService.ModelFiles(null, genmodel)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("metamodelFile"); + } + + @Test + void modelFiles_shouldRejectNullGenmodel() { + File ecore = new File("test.ecore"); + + assertThatThrownBy(() -> new VsumService.ModelFiles(ecore, null)) + .isInstanceOf(NullPointerException.class) + .hasMessageContaining("genmodelFile"); + } + + @Test + void runMavenBuild_shouldFailWhenExitCodeNonZero() throws Exception { + + VsumService service = new VsumService(generateFromTemplate); + + Path projectRoot = tempDir; + + assertThatThrownBy(() -> service.runMavenBuild(projectRoot, Map.of("invalid", "value"))) + .isInstanceOf(IOException.class); + } + + @Test + void createMavenProcessBuilderNamespaceProperties_shouldBePassed() throws Exception { + + VsumService service = new VsumService(generateFromTemplate); + + var method = + VsumService.class.getDeclaredMethod("createMavenProcessBuilder", Path.class, Map.class); + + method.setAccessible(true); + + ProcessBuilder builder = + (ProcessBuilder) + method.invoke( + service, + tempDir, + Map.of( + "pcm", "http://pcm", + "uml", "http://uml")); + + assertThat(builder.command()) + .contains("-Dmetamodel.pcm.nsuri=http://pcm") + .contains("-Dmetamodel.uml.nsuri=http://uml"); + + assertThat(builder.directory()).isEqualTo(tempDir.toFile()); + } + + private File writeValidGenmodel() throws IOException { + File genmodel = Files.createFile(tempDir.resolve("model.genmodel")).toFile(); + Files.writeString( + genmodel.toPath(), + """ + + + + """); + return genmodel; + } + + private File writeValidEcore() throws IOException { + File ecore = Files.createFile(tempDir.resolve("model.ecore")).toFile(); + Files.writeString( + ecore.toPath(), + """ + + + """); + return ecore; + } + + /** + * Stubs project generation so that invoking the root POM generator materializes the expected VSUM + * jar inside the workspace, and skips the real Maven build. + * + * @param service the spied service whose Maven build should be stubbed + * @param jarBytes the bytes to write as the built VSUM jar + * @throws Exception when stubbing fails + */ + private void stubProjectGeneration(VsumService service, byte[] jarBytes) throws Exception { + doAnswer( + invocation -> { + Path workspace = ((File) invocation.getArgument(0)).toPath().getParent(); + Path jar = workspace.resolve(VsumService.VSUM_JAR_RELATIVE_PATH); + Files.createDirectories(jar.getParent()); + Files.write(jar, jarBytes); + return null; + }) + .when(generateFromTemplate) + .generateRootPom(any(File.class), anyString()); + doNothing().when(service).runMavenBuild(any(Path.class), anyMap()); + } +}