diff --git a/besser/generators/spring/__init__.py b/besser/generators/spring/__init__.py new file mode 100644 index 000000000..2d275179f --- /dev/null +++ b/besser/generators/spring/__init__.py @@ -0,0 +1,20 @@ +"""Spring Boot backend generator. + +Only :class:`SpringBackendGenerator` is public: the entity, repository, service, +controller and HTTP writers are internal helpers it composes, and they are not +usable on their own. +""" + +from .spring_backend_generator import ( + DEFAULT_JAVA_VERSION, + DEFAULT_SPRING_APP_NAME, + DEFAULT_SPRING_BOOT_VERSION, + SpringBackendGenerator, +) + +__all__ = [ + "SpringBackendGenerator", + "DEFAULT_SPRING_BOOT_VERSION", + "DEFAULT_JAVA_VERSION", + "DEFAULT_SPRING_APP_NAME", +] diff --git a/besser/generators/spring/_sub_generator.py b/besser/generators/spring/_sub_generator.py new file mode 100644 index 000000000..23eeaa0f7 --- /dev/null +++ b/besser/generators/spring/_sub_generator.py @@ -0,0 +1,113 @@ +"""Internal base class shared by the Spring sub-generators. + +The entity, repository, service, controller and HTTP writers are *not* stand-alone +generators: they are never registered in ``SUPPORTED_GENERATORS`` and they only +make sense inside the directory layout that +:class:`~besser.generators.spring.spring_backend_generator.SpringBackendGenerator` +lays out. They therefore deliberately do not implement ``GeneratorInterface`` — +they are plain helpers composed by the public generator, in the same way +``WebAppGenerator`` composes its own writers. +""" + +import os +from pathlib import Path + +from jinja2 import Environment, FileSystemLoader + +from besser.BUML.metamodel.structural import ( + Class, + DateTimeType, + DateType, + DomainModel, + Enumeration, + TimeType, +) +from besser.generators.spring.java_types import ( + is_many, + java_type_for, + java_type_import, + to_java_accessor_suffix, + to_java_class_name, + to_java_field_name, +) + +TEMPLATES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "templates") + +#: B-UML types that get an extra ``findAllByBetween`` range finder. +RANGE_FINDER_TYPES = (DateType.name, DateTimeType.name, TimeType.name) + + +def build_environment(**options) -> Environment: + """Environment: A Jinja2 environment bound to the Spring template folder.""" + return Environment(loader=FileSystemLoader(TEMPLATES_PATH), **options) + + +class SpringSubGenerator: + """Common plumbing for the Spring sub-generators (internal helper).""" + + def __init__(self, model: DomainModel, output_dir: str | Path): + self.model: DomainModel = model + self.output_dir: Path = Path(output_dir) + self.enumerations: set[Enumeration] = model.get_enumerations() + self.classes: list[Class] = model.classes_sorted_by_inheritance() + # Names of every user-defined type, used to tell a class/enum reference + # apart from a primitive type when mapping to Java. + self.model_type_names: set[str] = ( + {cls.name for cls in self.classes} | {enum.name for enum in self.enumerations} + ) + + def concrete_classes(self) -> list[Class]: + """list[Class]: The non-abstract classes, in a deterministic order.""" + return [cls for cls in self.classes if not cls.is_abstract] + + def derived_finder_methods(self, cls: Class, entity_package_name: str, + imports: set[str]) -> list[dict]: + """Build the ``findAllBy`` query methods derived from a class. + + The repository interface, the service interface and the service + implementation all render the very same list, so that the delegation in + the implementation always matches the repository signature. ``imports`` + is extended in place with whatever the signatures need. + """ + class_name = to_java_class_name(cls.name) + methods: list[dict] = [] + + for attr in sorted(cls.attributes, key=lambda a: a.name): + if is_many(attr.multiplicity) or attr.is_id: + continue + + parameter_type = java_type_for(attr.type, self.model_type_names) + if attr.type.name in self.model_type_names: + imports.add(f"{entity_package_name}.{parameter_type}") + type_import = java_type_import(parameter_type) + if type_import: + imports.add(type_import) + + finder_suffix = to_java_accessor_suffix(attr.name) + + if attr.type.name in RANGE_FINDER_TYPES: + methods.append({ + "return_value": f"ArrayList<{class_name}>", + "name": f"findAllBy{finder_suffix}Between", + "parameter": f"{parameter_type} start, {parameter_type} end", + }) + + methods.append({ + "return_value": f"ArrayList<{class_name}>", + "name": f"findAllBy{finder_suffix}", + "parameter": f"{parameter_type} {to_java_field_name(attr.name)}", + }) + + if methods: + imports.add("java.util.ArrayList") + + return sorted(methods, key=lambda method: method["name"]) + + def write(self, relative_path: str | Path, content: str) -> str: + """str: Write ``content`` under the output directory, creating parents.""" + file_path = self.output_dir / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + # newline="\n" keeps the generated Java byte-identical across platforms. + with open(file_path, mode="w", encoding="utf-8", newline="\n") as file: + file.write(content) + return str(file_path) diff --git a/besser/generators/spring/java_types.py b/besser/generators/spring/java_types.py new file mode 100644 index 000000000..07558f24d --- /dev/null +++ b/besser/generators/spring/java_types.py @@ -0,0 +1,237 @@ +"""Shared Java type mapping and naming helpers for the Spring Boot generator. + +Every sub-generator of the Spring package (entity, repository, service, +controller) resolves B-UML types and B-UML names through this module, so that a +single attribute is never rendered with one Java type in the entity and another +one in the repository signature that reads it. + +The naming helpers also act as the security boundary of the generator: +``NamedElement`` only rejects whitespace and hyphens, so a model may legally +contain a class called ``../../evil``. Every B-UML name that ends up as a file +name, a Java type, a field or a SQL identifier is therefore funnelled through +:func:`to_java_identifier` (or one of its wrappers) first. +""" + +import re + +from besser.BUML.metamodel.structural import ( + UNLIMITED_MAX_MULTIPLICITY, + AnyType, + BooleanType, + Class, + DateTimeType, + DateType, + Enumeration, + FloatType, + IntegerType, + Multiplicity, + StringType, + TimeDeltaType, + TimeType, + Type, +) + +#: Single source of truth for the B-UML primitive type -> Java type mapping. +#: Shared by the entity, repository, service and controller generators so that +#: a ``time`` attribute is a ``LocalTime`` everywhere it appears. +JAVA_TYPES: dict[str, str] = { + StringType.name: "String", + BooleanType.name: "Boolean", + IntegerType.name: "Integer", + FloatType.name: "Float", + DateType.name: "LocalDate", + DateTimeType.name: "LocalDateTime", + TimeType.name: "LocalTime", + TimeDeltaType.name: "Duration", + AnyType.name: "Object", +} + +#: Java type used for any B-UML type that has no explicit mapping. +DEFAULT_JAVA_TYPE: str = "Object" + +#: ``java.*`` imports required by the Java types that are not in ``java.lang``. +JAVA_TYPE_IMPORTS: dict[str, str] = { + "LocalDate": "java.time.LocalDate", + "LocalDateTime": "java.time.LocalDateTime", + "LocalTime": "java.time.LocalTime", + "Duration": "java.time.Duration", +} + +#: Reserved words and literals that cannot be used as Java identifiers. +JAVA_KEYWORDS: frozenset[str] = frozenset({ + "abstract", "assert", "boolean", "break", "byte", "case", "catch", "char", + "class", "const", "continue", "default", "do", "double", "else", "enum", + "extends", "final", "finally", "float", "for", "goto", "if", "implements", + "import", "instanceof", "int", "interface", "long", "native", "new", + "package", "private", "protected", "public", "return", "short", "static", + "strictfp", "super", "switch", "synchronized", "this", "throw", "throws", + "transient", "try", "void", "volatile", "while", + "true", "false", "null", "_", +}) + +#: A Java package is a dot-separated list of lowercase identifiers. +JAVA_PACKAGE_PATTERN = re.compile(r"^[a-z_][a-z0-9_]*(\.[a-z_][a-z0-9_]*)*$") + +_ILLEGAL_IDENTIFIER_CHARS = re.compile(r"[^0-9A-Za-z_$]+") +_SNAKE_BOUNDARY_1 = re.compile(r"(.)([A-Z][a-z]+)") +_SNAKE_BOUNDARY_2 = re.compile(r"([a-z0-9])([A-Z])") + +#: Fallback used when a name sanitizes down to nothing at all (e.g. ``"../.."``). +_FALLBACK_IDENTIFIER = "Unnamed" + + +def to_java_identifier(name: str, capitalize: bool = False) -> str: + """Turn an arbitrary B-UML name into a legal Java identifier. + + Every character that Java does not accept in an identifier is replaced by an + underscore, which is what stops path separators and ``..`` segments from + escaping the output directory. Identifiers that would start with a digit are + prefixed with an underscore, and Java keywords get a trailing underscore. + + Args: + name: The B-UML name to sanitize. + capitalize: Whether the first character should be upper-cased + (used for type names). + + Returns: + A legal, non-empty Java identifier. + """ + cleaned = _ILLEGAL_IDENTIFIER_CHARS.sub("_", str(name)) + # Underscores introduced by the substitution at the edges carry no meaning + # (``../../evil`` -> ``_evil``) and only make the generated code noisier. + cleaned = cleaned.strip("_") + if not cleaned: + cleaned = _FALLBACK_IDENTIFIER + if cleaned[0].isdigit(): + cleaned = f"_{cleaned}" + if capitalize: + # Capitalizing first is what makes a class called "new" legal as "New"; + # only what is left after it can still collide with a keyword. + cleaned = cleaned[0].upper() + cleaned[1:] + if cleaned in JAVA_KEYWORDS: + cleaned = f"{cleaned}_" + return cleaned + + +def to_java_class_name(name: str) -> str: + """str: The sanitized, capitalized Java type name for a B-UML name.""" + return to_java_identifier(name, capitalize=True) + + +def to_java_field_name(name: str) -> str: + """str: The sanitized Java field/parameter name for a B-UML name.""" + return to_java_identifier(name) + + +def to_java_accessor_suffix(name: str) -> str: + """str: The capitalized suffix of the ``getX``/``setX`` pair for a field.""" + field = to_java_field_name(name) + return field[0].upper() + field[1:] + + +def to_snake_case(name: str) -> str: + """str: The sanitized ``snake_case`` form of a name (column/table names).""" + sanitized = to_java_identifier(name) + partial = _SNAKE_BOUNDARY_1.sub(r"\1_\2", sanitized) + return _SNAKE_BOUNDARY_2.sub(r"\1_\2", partial).lower() + + +def pluralize(name: str) -> str: + """str: A naive English plural, used for ``@Table`` names.""" + if name.endswith("y"): + return name[:-1] + "ies" + if name.endswith(("s", "x", "z", "ch", "sh")): + return name + "es" + return name + "s" + + +#: B-UML visibility -> Java access modifier. ``package`` is the default access +#: in Java and has no keyword, so it maps to the empty modifier. +JAVA_VISIBILITY: dict[str, str] = { + "public": "public", + "private": "private", + "protected": "protected", + "package": "", +} + + +def java_visibility(visibility: str) -> str: + """str: The Java access modifier for a B-UML visibility.""" + return JAVA_VISIBILITY.get(visibility, "public") + + +def validate_java_package(package_name: str) -> str: + """Validate a Java package name and return it unchanged. + + The package name is split into directories, so an unvalidated value would be + a second way out of the output directory. + + Raises: + ValueError: If the package name is not a dot-separated list of lowercase + Java identifiers, or if one of its segments is a Java keyword. + """ + if not isinstance(package_name, str) or not JAVA_PACKAGE_PATTERN.match(package_name): + raise ValueError( + f"Invalid Java package name: {package_name!r}. A package name must be a " + "dot-separated list of lowercase identifiers, e.g. 'com.example.app'." + ) + for segment in package_name.split("."): + if segment in JAVA_KEYWORDS: + raise ValueError( + f"Invalid Java package name: {package_name!r}. " + f"'{segment}' is a reserved Java keyword." + ) + return package_name + + +def java_type_for(buml_type: Type, model_type_names: set[str] | None = None) -> str: + """Map a B-UML type to its Java counterpart. + + User-defined types (classes and enumerations) keep their own, sanitized + name. Primitive types are looked up in :data:`JAVA_TYPES`, and anything the + mapping does not know about degrades to ``Object`` rather than raising. + + Args: + buml_type: The B-UML type to map. + model_type_names: Names of the classes and enumerations of the model. + Used so that a type referenced by name only is still recognised as + user-defined. + """ + if buml_type is None: + return "void" + name = buml_type.name + if isinstance(buml_type, (Class, Enumeration)) or (model_type_names and name in model_type_names): + return to_java_class_name(name) + return JAVA_TYPES.get(name, DEFAULT_JAVA_TYPE) + + +def java_type_import(java_type_name: str) -> str | None: + """str | None: The import required by a Java type name, if any.""" + return JAVA_TYPE_IMPORTS.get(java_type_name) + + +def is_many(multiplicity: Multiplicity | None) -> bool: + """bool: Whether a multiplicity denotes "many" (an upper bound above one). + + ``*`` is stored as :data:`UNLIMITED_MAX_MULTIPLICITY` by the metamodel, so + both the unbounded and the explicitly bounded cases are covered here. + """ + if multiplicity is None: + return False + return multiplicity.max == UNLIMITED_MAX_MULTIPLICITY or multiplicity.max > 1 + + +def get_id_attribute(cls: Class): + """Property: The identifier attribute of a class, inherited ones included. + + Raises: + ValueError: If the class has no attribute flagged with ``is_id``. + """ + id_attributes = [attr for attr in cls.all_attributes() if attr.is_id] + if not id_attributes: + raise ValueError( + f"Class '{cls.name}' has no identifier attribute. The Spring generator maps " + "every concrete class to a JPA entity, which requires exactly one attribute " + "marked with 'is_id=True' (directly or inherited)." + ) + return sorted(id_attributes, key=lambda attr: attr.name)[0] diff --git a/besser/generators/spring/resources/maven-wrapper.properties b/besser/generators/spring/resources/maven-wrapper.properties new file mode 100644 index 000000000..8dea6c227 --- /dev/null +++ b/besser/generators/spring/resources/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.12/apache-maven-3.9.12-bin.zip diff --git a/besser/generators/spring/resources/mvnw b/besser/generators/spring/resources/mvnw new file mode 100644 index 000000000..8a8fb2282 --- /dev/null +++ b/besser/generators/spring/resources/mvnw @@ -0,0 +1,316 @@ +#!/bin/sh +# ---------------------------------------------------------------------------- +# Licensed to the Apache Software Foundation (ASF) under one +# or more contributor license agreements. See the NOTICE file +# distributed with this work for additional information +# regarding copyright ownership. The ASF licenses this file +# to you under the Apache License, Version 2.0 (the +# "License"); you may not use this file except in compliance +# with the License. You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, +# software distributed under the License is distributed on an +# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +# KIND, either express or implied. See the License for the +# specific language governing permissions and limitations +# under the License. +# ---------------------------------------------------------------------------- + +# ---------------------------------------------------------------------------- +# Maven Start Up Batch script +# +# Required ENV vars: +# ------------------ +# JAVA_HOME - location of a JDK home dir +# +# Optional ENV vars +# ----------------- +# M2_HOME - location of maven2's installed home dir +# MAVEN_OPTS - parameters passed to the Java VM when running Maven +# e.g. to debug Maven itself, use +# set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +# MAVEN_SKIP_RC - flag to disable loading of mavenrc files +# ---------------------------------------------------------------------------- + +if [ -z "$MAVEN_SKIP_RC" ] ; then + + if [ -f /usr/local/etc/mavenrc ] ; then + . /usr/local/etc/mavenrc + fi + + if [ -f /etc/mavenrc ] ; then + . /etc/mavenrc + fi + + if [ -f "$HOME/.mavenrc" ] ; then + . "$HOME/.mavenrc" + fi + +fi + +# OS specific support. $var _must_ be set to either true or false. +cygwin=false; +darwin=false; +mingw=false +case "`uname`" in + CYGWIN*) cygwin=true ;; + MINGW*) mingw=true;; + Darwin*) darwin=true + # Use /usr/libexec/java_home if available, otherwise fall back to /Library/Java/Home + # See https://developer.apple.com/library/mac/qa/qa1170/_index.html + if [ -z "$JAVA_HOME" ]; then + if [ -x "/usr/libexec/java_home" ]; then + export JAVA_HOME="`/usr/libexec/java_home`" + else + export JAVA_HOME="/Library/Java/Home" + fi + fi + ;; +esac + +if [ -z "$JAVA_HOME" ] ; then + if [ -r /etc/gentoo-release ] ; then + JAVA_HOME=`java-config --jre-home` + fi +fi + +if [ -z "$M2_HOME" ] ; then + ## resolve links - $0 may be a link to maven's home + PRG="$0" + + # need this for relative symlinks + while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG="`dirname "$PRG"`/$link" + fi + done + + saveddir=`pwd` + + M2_HOME=`dirname "$PRG"`/.. + + # make it fully qualified + M2_HOME=`cd "$M2_HOME" && pwd` + + cd "$saveddir" + # echo Using m2 at $M2_HOME +fi + +# For Cygwin, ensure paths are in UNIX format before anything is touched +if $cygwin ; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --unix "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --unix "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --unix "$CLASSPATH"` +fi + +# For Mingw, ensure paths are in UNIX format before anything is touched +if $mingw ; then + [ -n "$M2_HOME" ] && + M2_HOME="`(cd "$M2_HOME"; pwd)`" + [ -n "$JAVA_HOME" ] && + JAVA_HOME="`(cd "$JAVA_HOME"; pwd)`" +fi + +if [ -z "$JAVA_HOME" ]; then + javaExecutable="`which javac`" + if [ -n "$javaExecutable" ] && ! [ "`expr \"$javaExecutable\" : '\([^ ]*\)'`" = "no" ]; then + # readlink(1) is not available as standard on Solaris 10. + readLink=`which readlink` + if [ ! `expr "$readLink" : '\([^ ]*\)'` = "no" ]; then + if $darwin ; then + javaHome="`dirname \"$javaExecutable\"`" + javaExecutable="`cd \"$javaHome\" && pwd -P`/javac" + else + javaExecutable="`readlink -f \"$javaExecutable\"`" + fi + javaHome="`dirname \"$javaExecutable\"`" + javaHome=`expr "$javaHome" : '\(.*\)/bin'` + JAVA_HOME="$javaHome" + export JAVA_HOME + fi + fi +fi + +if [ -z "$JAVACMD" ] ; then + if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + else + JAVACMD="`\\unset -f command; \\command -v java`" + fi +fi + +if [ ! -x "$JAVACMD" ] ; then + echo "Error: JAVA_HOME is not defined correctly." >&2 + echo " We cannot execute $JAVACMD" >&2 + exit 1 +fi + +if [ -z "$JAVA_HOME" ] ; then + echo "Warning: JAVA_HOME environment variable is not set." +fi + +CLASSWORLDS_LAUNCHER=org.codehaus.plexus.classworlds.launcher.Launcher + +# traverses directory structure from process work directory to filesystem root +# first directory with .mvn subdirectory is considered project base directory +find_maven_basedir() { + + if [ -z "$1" ] + then + echo "Path not specified to find_maven_basedir" + return 1 + fi + + basedir="$1" + wdir="$1" + while [ "$wdir" != '/' ] ; do + if [ -d "$wdir"/.mvn ] ; then + basedir=$wdir + break + fi + # workaround for JBEAP-8937 (on Solaris 10/Sparc) + if [ -d "${wdir}" ]; then + wdir=`cd "$wdir/.."; pwd` + fi + # end of workaround + done + echo "${basedir}" +} + +# concatenates all lines of a file +concat_lines() { + if [ -f "$1" ]; then + echo "$(tr -s '\n' ' ' < "$1")" + fi +} + +BASE_DIR=`find_maven_basedir "$(pwd)"` +if [ -z "$BASE_DIR" ]; then + exit 1; +fi + +########################################################################################## +# Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +# This allows using the maven wrapper in projects that prohibit checking in binary data. +########################################################################################## +if [ -r "$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found .mvn/wrapper/maven-wrapper.jar" + fi +else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Couldn't find .mvn/wrapper/maven-wrapper.jar, downloading it ..." + fi + if [ -n "$MVNW_REPOURL" ]; then + jarUrl="$MVNW_REPOURL/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + else + jarUrl="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + fi + while IFS="=" read key value; do + case "$key" in (wrapperUrl) jarUrl="$value"; break ;; + esac + done < "$BASE_DIR/.mvn/wrapper/maven-wrapper.properties" + if [ "$MVNW_VERBOSE" = true ]; then + echo "Downloading from: $jarUrl" + fi + wrapperJarPath="$BASE_DIR/.mvn/wrapper/maven-wrapper.jar" + if $cygwin; then + wrapperJarPath=`cygpath --path --windows "$wrapperJarPath"` + fi + + if command -v wget > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found wget ... using wget" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + wget "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + else + wget --http-user=$MVNW_USERNAME --http-password=$MVNW_PASSWORD "$jarUrl" -O "$wrapperJarPath" || rm -f "$wrapperJarPath" + fi + elif command -v curl > /dev/null; then + if [ "$MVNW_VERBOSE" = true ]; then + echo "Found curl ... using curl" + fi + if [ -z "$MVNW_USERNAME" ] || [ -z "$MVNW_PASSWORD" ]; then + curl -o "$wrapperJarPath" "$jarUrl" -f + else + curl --user $MVNW_USERNAME:$MVNW_PASSWORD -o "$wrapperJarPath" "$jarUrl" -f + fi + + else + if [ "$MVNW_VERBOSE" = true ]; then + echo "Falling back to using Java to download" + fi + javaClass="$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.java" + # For Cygwin, switch paths to Windows format before running javac + if $cygwin; then + javaClass=`cygpath --path --windows "$javaClass"` + fi + if [ -e "$javaClass" ]; then + if [ ! -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Compiling MavenWrapperDownloader.java ..." + fi + # Compiling the Java class + ("$JAVA_HOME/bin/javac" "$javaClass") + fi + if [ -e "$BASE_DIR/.mvn/wrapper/MavenWrapperDownloader.class" ]; then + # Running the downloader + if [ "$MVNW_VERBOSE" = true ]; then + echo " - Running MavenWrapperDownloader.java ..." + fi + ("$JAVA_HOME/bin/java" -cp .mvn/wrapper MavenWrapperDownloader "$MAVEN_PROJECTBASEDIR") + fi + fi + fi +fi +########################################################################################## +# End of extension +########################################################################################## + +export MAVEN_PROJECTBASEDIR=${MAVEN_BASEDIR:-"$BASE_DIR"} +if [ "$MVNW_VERBOSE" = true ]; then + echo $MAVEN_PROJECTBASEDIR +fi +MAVEN_OPTS="$(concat_lines "$MAVEN_PROJECTBASEDIR/.mvn/jvm.config") $MAVEN_OPTS" + +# For Cygwin, switch paths to Windows format before running java +if $cygwin; then + [ -n "$M2_HOME" ] && + M2_HOME=`cygpath --path --windows "$M2_HOME"` + [ -n "$JAVA_HOME" ] && + JAVA_HOME=`cygpath --path --windows "$JAVA_HOME"` + [ -n "$CLASSPATH" ] && + CLASSPATH=`cygpath --path --windows "$CLASSPATH"` + [ -n "$MAVEN_PROJECTBASEDIR" ] && + MAVEN_PROJECTBASEDIR=`cygpath --path --windows "$MAVEN_PROJECTBASEDIR"` +fi + +# Provide a "standardized" way to retrieve the CLI args that will +# work with both Windows and non-Windows executions. +MAVEN_CMD_LINE_ARGS="$MAVEN_CONFIG $@" +export MAVEN_CMD_LINE_ARGS + +WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +exec "$JAVACMD" \ + $MAVEN_OPTS \ + $MAVEN_DEBUG_OPTS \ + -classpath "$MAVEN_PROJECTBASEDIR/.mvn/wrapper/maven-wrapper.jar" \ + "-Dmaven.home=${M2_HOME}" \ + "-Dmaven.multiModuleProjectDirectory=${MAVEN_PROJECTBASEDIR}" \ + ${WRAPPER_LAUNCHER} $MAVEN_CONFIG "$@" diff --git a/besser/generators/spring/resources/mvnw.cmd b/besser/generators/spring/resources/mvnw.cmd new file mode 100644 index 000000000..1d8ab018e --- /dev/null +++ b/besser/generators/spring/resources/mvnw.cmd @@ -0,0 +1,188 @@ +@REM ---------------------------------------------------------------------------- +@REM Licensed to the Apache Software Foundation (ASF) under one +@REM or more contributor license agreements. See the NOTICE file +@REM distributed with this work for additional information +@REM regarding copyright ownership. The ASF licenses this file +@REM to you under the Apache License, Version 2.0 (the +@REM "License"); you may not use this file except in compliance +@REM with the License. You may obtain a copy of the License at +@REM +@REM https://www.apache.org/licenses/LICENSE-2.0 +@REM +@REM Unless required by applicable law or agreed to in writing, +@REM software distributed under the License is distributed on an +@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +@REM KIND, either express or implied. See the License for the +@REM specific language governing permissions and limitations +@REM under the License. +@REM ---------------------------------------------------------------------------- + +@REM ---------------------------------------------------------------------------- +@REM Maven Start Up Batch script +@REM +@REM Required ENV vars: +@REM JAVA_HOME - location of a JDK home dir +@REM +@REM Optional ENV vars +@REM M2_HOME - location of maven2's installed home dir +@REM MAVEN_BATCH_ECHO - set to 'on' to enable the echoing of the batch commands +@REM MAVEN_BATCH_PAUSE - set to 'on' to wait for a keystroke before ending +@REM MAVEN_OPTS - parameters passed to the Java VM when running Maven +@REM e.g. to debug Maven itself, use +@REM set MAVEN_OPTS=-Xdebug -Xrunjdwp:transport=dt_socket,server=y,suspend=y,address=8000 +@REM MAVEN_SKIP_RC - flag to disable loading of mavenrc files +@REM ---------------------------------------------------------------------------- + +@REM Begin all REM lines with '@' in case MAVEN_BATCH_ECHO is 'on' +@echo off +@REM set title of command window +title %0 +@REM enable echoing by setting MAVEN_BATCH_ECHO to 'on' +@if "%MAVEN_BATCH_ECHO%" == "on" echo %MAVEN_BATCH_ECHO% + +@REM set %HOME% to equivalent of $HOME +if "%HOME%" == "" (set "HOME=%HOMEDRIVE%%HOMEPATH%") + +@REM Execute a user defined script before this one +if not "%MAVEN_SKIP_RC%" == "" goto skipRcPre +@REM check for pre script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_pre.bat" call "%USERPROFILE%\mavenrc_pre.bat" %* +if exist "%USERPROFILE%\mavenrc_pre.cmd" call "%USERPROFILE%\mavenrc_pre.cmd" %* +:skipRcPre + +@setlocal + +set ERROR_CODE=0 + +@REM To isolate internal variables from possible post scripts, we use another setlocal +@setlocal + +@REM ==== START VALIDATION ==== +if not "%JAVA_HOME%" == "" goto OkJHome + +echo. +echo Error: JAVA_HOME not found in your environment. >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +:OkJHome +if exist "%JAVA_HOME%\bin\java.exe" goto init + +echo. +echo Error: JAVA_HOME is set to an invalid directory. >&2 +echo JAVA_HOME = "%JAVA_HOME%" >&2 +echo Please set the JAVA_HOME variable in your environment to match the >&2 +echo location of your Java installation. >&2 +echo. +goto error + +@REM ==== END VALIDATION ==== + +:init + +@REM Find the project base dir, i.e. the directory that contains the folder ".mvn". +@REM Fallback to current working directory if not found. + +set MAVEN_PROJECTBASEDIR=%MAVEN_BASEDIR% +IF NOT "%MAVEN_PROJECTBASEDIR%"=="" goto endDetectBaseDir + +set EXEC_DIR=%CD% +set WDIR=%EXEC_DIR% +:findBaseDir +IF EXIST "%WDIR%"\.mvn goto baseDirFound +cd .. +IF "%WDIR%"=="%CD%" goto baseDirNotFound +set WDIR=%CD% +goto findBaseDir + +:baseDirFound +set MAVEN_PROJECTBASEDIR=%WDIR% +cd "%EXEC_DIR%" +goto endDetectBaseDir + +:baseDirNotFound +set MAVEN_PROJECTBASEDIR=%EXEC_DIR% +cd "%EXEC_DIR%" + +:endDetectBaseDir + +IF NOT EXIST "%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config" goto endReadAdditionalConfig + +@setlocal EnableExtensions EnableDelayedExpansion +for /F "usebackq delims=" %%a in ("%MAVEN_PROJECTBASEDIR%\.mvn\jvm.config") do set JVM_CONFIG_MAVEN_PROPS=!JVM_CONFIG_MAVEN_PROPS! %%a +@endlocal & set JVM_CONFIG_MAVEN_PROPS=%JVM_CONFIG_MAVEN_PROPS% + +:endReadAdditionalConfig + +SET MAVEN_JAVA_EXE="%JAVA_HOME%\bin\java.exe" +set WRAPPER_JAR="%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.jar" +set WRAPPER_LAUNCHER=org.apache.maven.wrapper.MavenWrapperMain + +set DOWNLOAD_URL="https://repo.maven.apache.org/maven2/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + +FOR /F "usebackq tokens=1,2 delims==" %%A IN ("%MAVEN_PROJECTBASEDIR%\.mvn\wrapper\maven-wrapper.properties") DO ( + IF "%%A"=="wrapperUrl" SET DOWNLOAD_URL=%%B +) + +@REM Extension to allow automatically downloading the maven-wrapper.jar from Maven-central +@REM This allows using the maven wrapper in projects that prohibit checking in binary data. +if exist %WRAPPER_JAR% ( + if "%MVNW_VERBOSE%" == "true" ( + echo Found %WRAPPER_JAR% + ) +) else ( + if not "%MVNW_REPOURL%" == "" ( + SET DOWNLOAD_URL="%MVNW_REPOURL%/org/apache/maven/wrapper/maven-wrapper/3.1.0/maven-wrapper-3.1.0.jar" + ) + if "%MVNW_VERBOSE%" == "true" ( + echo Couldn't find %WRAPPER_JAR%, downloading it ... + echo Downloading from: %DOWNLOAD_URL% + ) + + powershell -Command "&{"^ + "$webclient = new-object System.Net.WebClient;"^ + "if (-not ([string]::IsNullOrEmpty('%MVNW_USERNAME%') -and [string]::IsNullOrEmpty('%MVNW_PASSWORD%'))) {"^ + "$webclient.Credentials = new-object System.Net.NetworkCredential('%MVNW_USERNAME%', '%MVNW_PASSWORD%');"^ + "}"^ + "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; $webclient.DownloadFile('%DOWNLOAD_URL%', '%WRAPPER_JAR%')"^ + "}" + if "%MVNW_VERBOSE%" == "true" ( + echo Finished downloading %WRAPPER_JAR% + ) +) +@REM End of extension + +@REM Provide a "standardized" way to retrieve the CLI args that will +@REM work with both Windows and non-Windows executions. +set MAVEN_CMD_LINE_ARGS=%* + +%MAVEN_JAVA_EXE% ^ + %JVM_CONFIG_MAVEN_PROPS% ^ + %MAVEN_OPTS% ^ + %MAVEN_DEBUG_OPTS% ^ + -classpath %WRAPPER_JAR% ^ + "-Dmaven.multiModuleProjectDirectory=%MAVEN_PROJECTBASEDIR%" ^ + %WRAPPER_LAUNCHER% %MAVEN_CONFIG% %* +if ERRORLEVEL 1 goto error +goto end + +:error +set ERROR_CODE=1 + +:end +@endlocal & set ERROR_CODE=%ERROR_CODE% + +if not "%MAVEN_SKIP_RC%"=="" goto skipRcPost +@REM check for post script, once with legacy .bat ending and once with .cmd ending +if exist "%USERPROFILE%\mavenrc_post.bat" call "%USERPROFILE%\mavenrc_post.bat" +if exist "%USERPROFILE%\mavenrc_post.cmd" call "%USERPROFILE%\mavenrc_post.cmd" +:skipRcPost + +@REM pause the script if MAVEN_BATCH_PAUSE is set to 'on' +if "%MAVEN_BATCH_PAUSE%"=="on" pause + +if "%MAVEN_TERMINATE_CMD%"=="on" exit %ERROR_CODE% + +cmd /C exit /B %ERROR_CODE% diff --git a/besser/generators/spring/spring_backend_generator.py b/besser/generators/spring/spring_backend_generator.py new file mode 100644 index 000000000..2911470e2 --- /dev/null +++ b/besser/generators/spring/spring_backend_generator.py @@ -0,0 +1,213 @@ +"""Generates a complete, buildable Spring Boot backend from a B-UML domain model.""" + +import os +import shutil +import stat +from pathlib import Path + +from besser.BUML.metamodel.structural import DomainModel +from besser.generators.generator_interface import GeneratorInterface +from besser.generators.spring._sub_generator import build_environment +from besser.generators.spring.java_types import to_java_class_name, validate_java_package +from besser.generators.spring.spring_controller_generator import SpringControllerGenerator +from besser.generators.spring.spring_entity_generator import SpringEntityGenerator +from besser.generators.spring.spring_http_generator import SpringHttpGenerator +from besser.generators.spring.spring_repository_generator import SpringRepositoryGenerator +from besser.generators.spring.spring_service_generator import SpringServiceGenerator + +#: Defaults for the generated project. They live here rather than in the web +#: editor's constants module so that the generator never has to import the web +#: backend; ``backend/constants/constants.py`` re-exports these instead. +DEFAULT_SPRING_BOOT_VERSION: str = "3.4.4" +DEFAULT_JAVA_VERSION: str = "21" +DEFAULT_SPRING_APP_NAME: str = "Application" +DEFAULT_SPRING_PACKAGE_NAME: str = "com.example" +DEFAULT_SPRING_GROUP_ID: str = "com.example" + +#: Static (non-templated) files shipped with the generator. +RESOURCES_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), "resources") + + +class SpringBackendGenerator(GeneratorInterface): + """Generates a Spring Boot project (Maven, JPA entities, repositories, + services and REST controllers) from a B-UML domain model. + + Args: + model (DomainModel): The domain model to generate the backend from. + output_dir (str): The directory the project is written to. + spring_boot_version (str): The ``spring-boot-starter-parent`` version. + java_version (str): The ``java.version`` property of the generated POM. + app_name (str): The name of the application (and of its main class). + package_name (str): The root Java package of the generated sources. + group_id (str): The Maven ``groupId`` of the generated project. + description (str): The Maven ``description`` of the generated project. + """ + + def __init__(self, + model: DomainModel, + output_dir: str = None, + *, + spring_boot_version: str = DEFAULT_SPRING_BOOT_VERSION, + java_version: str = DEFAULT_JAVA_VERSION, + app_name: str = DEFAULT_SPRING_APP_NAME, + package_name: str = DEFAULT_SPRING_PACKAGE_NAME, + group_id: str = DEFAULT_SPRING_GROUP_ID, + description: str = ""): + super().__init__(model, output_dir) + + self.package_name: str = validate_java_package(package_name) + self.spring_boot_version: str = spring_boot_version + self.java_version: str = java_version + self.app_name: str = app_name + self.group_id: str = group_id + self.description: str = description + + @property + def project_dir(self) -> Path: + """Path: The root of the generated project.""" + return Path(self.build_generation_dir()) + + @property + def main_class_name(self) -> str: + """str: The sanitized name of the generated ``@SpringBootApplication``.""" + return to_java_class_name(self.app_name) + + @property + def package_dir(self) -> Path: + """Path: The relative path of the root package inside a source folder.""" + return Path(*self.package_name.split(".")) + + def generate(self): + self._generate_pom_file() + self._generate_mvn_files() + self._generate_main_and_test_files() + self._generate_properties_file() + self._generate_entities() + self._generate_repositories() + self._generate_services() + self._generate_controllers() + self._generate_http() + + # ------------------------------------------------------------------ + # Project scaffolding + # ------------------------------------------------------------------ + + def _render(self, template_name: str, relative_path: Path | str, **context): + env = build_environment() + content = env.get_template(template_name).render(**context) + file_path = self.project_dir / relative_path + file_path.parent.mkdir(parents=True, exist_ok=True) + with open(file_path, mode="w", encoding="utf-8", newline="\n") as file: + file.write(content) + + def _generate_pom_file(self): + self._render( + "pom.xml.j2", "pom.xml", + name=self.app_name, + spring_boot_version=self.spring_boot_version, + group_id=self.group_id, + description=self.description, + java_version=self.java_version, + ) + + def _generate_mvn_files(self): + """Copy the Maven wrapper to the layout Maven expects. + + ``mvnw`` and ``mvnw.cmd`` have to sit at the project root; only + ``maven-wrapper.properties`` belongs under ``.mvn/wrapper/``. The two + scripts carry no model-dependent content, so they are plain static + resources rather than templates. + """ + project_dir = self.project_dir + project_dir.mkdir(parents=True, exist_ok=True) + + wrapper_dir = project_dir / ".mvn" / "wrapper" + wrapper_dir.mkdir(parents=True, exist_ok=True) + shutil.copy( + os.path.join(RESOURCES_PATH, "maven-wrapper.properties"), + wrapper_dir / "maven-wrapper.properties", + ) + + # The line endings are forced on copy: a CRLF ``mvnw`` is unusable on + # POSIX ("bad interpreter"), whatever the checkout settings were. + self._copy_script("mvnw", project_dir / "mvnw", newline=b"\n", executable=True) + self._copy_script("mvnw.cmd", project_dir / "mvnw.cmd", newline=b"\r\n") + + @staticmethod + def _copy_script(resource_name: str, destination: Path, newline: bytes, executable: bool = False): + content = Path(RESOURCES_PATH, resource_name).read_bytes().replace(b"\r\n", b"\n") + if newline != b"\n": + content = content.replace(b"\n", newline) + destination.write_bytes(content) + if executable: + mode = os.stat(destination).st_mode + os.chmod(destination, mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH) + + def _generate_main_and_test_files(self): + self._render( + "main.java.j2", + Path("src", "main", "java") / self.package_dir / f"{self.main_class_name}.java", + name=self.main_class_name, + package=self.package_name, + ) + self._render( + "test.java.j2", + Path("src", "test", "java") / self.package_dir / f"{self.main_class_name}Tests.java", + name=self.main_class_name, + package=self.package_name, + ) + + def _generate_properties_file(self): + self._render( + "application.properties.j2", + Path("src", "main", "resources", "application.properties"), + name=self.app_name, + ) + + # ------------------------------------------------------------------ + # Sources + # ------------------------------------------------------------------ + + def _source_dir(self, sub_package: str) -> Path: + return self.project_dir / Path("src", "main", "java") / self.package_dir / sub_package + + def _generate_entities(self): + SpringEntityGenerator( + self.model, + output_dir=self._source_dir("entity"), + package_name=f"{self.package_name}.entity", + ).generate() + + def _generate_repositories(self): + SpringRepositoryGenerator( + self.model, + f"{self.package_name}.entity", + output_dir=self._source_dir("repository"), + package_name=f"{self.package_name}.repository", + ).generate() + + def _generate_services(self): + SpringServiceGenerator( + self.model, + f"{self.package_name}.entity", + f"{self.package_name}.repository", + output_dir=self._source_dir("service"), + package_name=f"{self.package_name}.service", + ).generate() + + def _generate_controllers(self): + SpringControllerGenerator( + self.model, + f"{self.package_name}.entity", + f"{self.package_name}.service", + output_dir=self._source_dir("controller"), + package_name=f"{self.package_name}.controller", + ).generate() + + def _generate_http(self): + # Scratch request files are test resources, not compilation units: they + # must stay out of ``src/main/java``. + SpringHttpGenerator( + self.model, + output_dir=self.project_dir / Path("src", "test", "resources", "http"), + ).generate() diff --git a/besser/generators/spring/spring_controller_generator.py b/besser/generators/spring/spring_controller_generator.py new file mode 100644 index 000000000..5119f9151 --- /dev/null +++ b/besser/generators/spring/spring_controller_generator.py @@ -0,0 +1,74 @@ +"""Writes the REST controllers of a generated Spring Boot project.""" + +from besser.BUML.metamodel.structural import Class, DomainModel +from besser.generators.spring._sub_generator import SpringSubGenerator, build_environment +from besser.generators.spring.java_types import ( + get_id_attribute, + java_type_for, + java_type_import, + to_java_accessor_suffix, + to_java_class_name, + to_java_field_name, + validate_java_package, +) + + +class SpringControllerGenerator(SpringSubGenerator): + """Generates the ``controller`` package of a Spring Boot project (internal helper).""" + + def __init__(self, model: DomainModel, + entity_package_name: str, + service_package_name: str, + output_dir: str = "./generated/controller", + package_name: str = "com.example.controller"): + super().__init__(model, output_dir) + + self.package_name: str = validate_java_package(package_name) + self.entity_package_name: str = validate_java_package(entity_package_name) + self.service_package_name: str = validate_java_package(service_package_name) + + def generate(self): + for cls in self.concrete_classes(): + self._generate_controller_file(cls) + + def _generate_controller_file(self, cls: Class): + class_name: str = to_java_class_name(cls.name) + env = build_environment(trim_blocks=True, lstrip_blocks=True) + controller_template = env.get_template("controller.java.j2") + + imports: set[str] = { + "org.springframework.beans.factory.annotation.Autowired", + "org.springframework.web.bind.annotation.RestController", + "org.springframework.web.bind.annotation.RequestMapping", + "org.springframework.web.bind.annotation.PathVariable", + "org.springframework.web.bind.annotation.RequestBody", + "org.springframework.web.bind.annotation.GetMapping", + "org.springframework.web.bind.annotation.PostMapping", + "org.springframework.web.bind.annotation.PutMapping", + "org.springframework.web.bind.annotation.DeleteMapping", + "org.springframework.http.ResponseEntity", + "java.util.Optional", + "java.util.List", + f"{self.service_package_name}.interfaces.I{class_name}Service", + f"{self.entity_package_name}.{class_name}", + } + + id_attr = get_id_attribute(cls) + id_type: str = java_type_for(id_attr.type, self.model_type_names) + type_import = java_type_import(id_type) + if type_import: + imports.add(type_import) + + context = { + "package": self.package_name, + "imports": sorted(imports), + "cls": class_name, + "instance": to_java_field_name(class_name[0].lower() + class_name[1:]), + "route": class_name[0].lower() + class_name[1:], + "id_type": id_type, + # The identifier is not necessarily called "id": the setter has to + # follow whatever the model named it. + "id_accessor": to_java_accessor_suffix(id_attr.name), + } + + self.write(f"{class_name}Controller.java", controller_template.render(**context)) diff --git a/besser/generators/spring/spring_entity_generator.py b/besser/generators/spring/spring_entity_generator.py new file mode 100644 index 000000000..a6880b1c4 --- /dev/null +++ b/besser/generators/spring/spring_entity_generator.py @@ -0,0 +1,391 @@ +"""Writes the JPA entity and enumeration classes of a generated Spring Boot project.""" + +from collections import defaultdict +from typing import Any, List, Tuple + +from besser.BUML.metamodel.structural import ( + BinaryAssociation, + Class, + DateTimeType, + DateType, + DomainModel, + Enumeration, + Property, + StringType, + TimeDeltaType, + TimeType, +) +from besser.generators.spring._sub_generator import SpringSubGenerator, build_environment +from besser.generators.spring.java_types import ( + java_type_for, + java_type_import, + java_visibility, + is_many, + pluralize, + to_java_accessor_suffix, + to_java_class_name, + to_java_field_name, + to_snake_case, + validate_java_package, +) + +#: An association seen from one of its two sides: (association, source end, target end). +AssociationView = Tuple[BinaryAssociation, Property, Property] + +#: Java types Hibernate can auto-generate an identity value for. +GENERATED_ID_TYPES = ("Integer", "Long") + + +class SpringEntityGenerator(SpringSubGenerator): + """Generates the ``entity`` package of a Spring Boot project (internal helper).""" + + def __init__(self, model: DomainModel, + output_dir: str = "./generated/entity", + package_name: str = "com.example.entity"): + super().__init__(model, output_dir) + + self.package_name: str = validate_java_package(package_name) + self.association_views: dict[str, List[AssociationView]] = self._build_association_views(model) + self.relation_owners: dict[BinaryAssociation, str] = self._get_relation_owner_map(model) + + def generate(self): + for enum in sorted(self.enumerations, key=lambda e: e.name): + self._generate_enum_file(enum) + + for cls in self.classes: + self._generate_class_file(cls, self.association_views.get(cls.name, [])) + + # ------------------------------------------------------------------ + # Associations + # ------------------------------------------------------------------ + + @staticmethod + def _build_association_views(model: DomainModel) -> dict[str, List[AssociationView]]: + """Index every navigable association side by the class that owns the field. + + Each binary association yields up to two *directed* views. Handling the + two ends separately is what makes self-associations come out right: both + views land in the same class, but with the two distinct role names + instead of the same one twice. + """ + views: defaultdict[str, List[AssociationView]] = defaultdict(list) + + for assoc in sorted(model.associations, key=lambda a: a.name): + ends = sorted(assoc.ends, key=lambda end: (end.name, end.type.name)) + if len(ends) != 2: + continue + first, second = ends + # A field only exists on a side when the opposite end is navigable. + if second.is_navigable: + views[first.type.name].append((assoc, first, second)) + if first.is_navigable: + views[second.type.name].append((assoc, second, first)) + + for class_name in views: + views[class_name].sort(key=lambda view: (view[0].name, view[2].name, view[1].name)) + + return dict(views) + + @staticmethod + def _get_relation_owner_map(model: DomainModel) -> dict[BinaryAssociation, str]: + """Pick the owning side of the associations where JPA leaves a choice. + + One-to-one and many-to-many bidirectional associations have no natural + owner, so one has to be elected: without it both sides would emit a + ``@JoinTable``/``@JoinColumn`` and neither a ``mappedBy``. The end whose + role name sorts first wins, which keeps the choice stable across runs and + works for self-associations too (where both ends share a type name but + never a role name). + """ + owners: dict[BinaryAssociation, str] = {} + + for assoc in model.associations: + ends = sorted(assoc.ends, key=lambda end: (end.name, end.type.name)) + if len(ends) != 2: + continue + first, second = ends + first_many = is_many(first.multiplicity) + second_many = is_many(second.multiplicity) + ambiguous = (first_many and second_many) or (not first_many and not second_many) + if ambiguous and first.is_navigable and second.is_navigable: + owners[assoc] = first.name + + return owners + + def _prepare_relations(self, views: List[AssociationView]) -> List[dict]: + relations: List[dict] = [] + + for assoc, source, target in views: + bidirectional: bool = source.is_navigable and target.is_navigable + source_many: bool = is_many(source.multiplicity) + target_many: bool = is_many(target.multiplicity) + + relation: str | None = None + mapped_by: str | None = None + owning: bool = True + # The foreign key lives in the table of whichever side holds the + # single-valued end, so the column is named after the other one. + join_column: str = f"{to_snake_case(target.type.name)}_id" + + if not source_many and not target_many: + relation = "OneToOne" + if bidirectional and self.relation_owners.get(assoc) != source.name: + owning = False + mapped_by = to_java_field_name(source.name) + elif not source_many and target_many: + relation = "OneToMany" + # A unidirectional @OneToMany puts the foreign key in the target + # table, pointing back at the source. + join_column = f"{to_snake_case(source.type.name)}_id" + if bidirectional: + owning = False + mapped_by = to_java_field_name(source.name) + elif source_many and not target_many: + relation = "ManyToOne" + else: + relation = "ManyToMany" + if bidirectional and self.relation_owners.get(assoc) != source.name: + owning = False + mapped_by = to_java_field_name(source.name) + + target_type: str = to_java_class_name(target.type.name) + join_table: str = to_snake_case(assoc.name) + source_column: str = f"{to_snake_case(source.type.name)}_id" + target_column: str = f"{to_snake_case(target.type.name)}_id" + + annotations: List[str] = [ + f"@{relation}" + (f'(mappedBy = "{mapped_by}")' if mapped_by else "") + ] + if relation == "ManyToOne" or (owning and relation in ("OneToOne", "OneToMany")): + annotations.append(f'@JoinColumn(name = "{join_column}")') + elif relation == "ManyToMany" and owning: + annotations.append( + f'@JoinTable(name = "{join_table}",\n' + f' joinColumns = @JoinColumn(name = "{source_column}"),\n' + f' inverseJoinColumns = @JoinColumn(name = "{target_column}"))' + ) + + relations.append({ + "assoc": join_table, + "source_property": to_java_field_name(source.name), + "target_property": to_java_field_name(target.name), + "source_column": source_column, + "target_column": target_column, + "annotations": annotations, + "relation": relation, + "mapped_by": mapped_by, + "join_column": join_column, + "owning": owning, + "is_list": target_many, + "type": f"List<{target_type}>" if target_many else target_type, + "accessor": to_java_accessor_suffix(target.name), + }) + + return relations + + # ------------------------------------------------------------------ + # Classes + # ------------------------------------------------------------------ + + def _generate_class_file(self, cls: Class, views: List[AssociationView]): + class_name: str = to_java_class_name(cls.name) + env = build_environment(trim_blocks=True, lstrip_blocks=True) + entity_template = env.get_template("entity.java.j2") + + relations: List[dict] = self._prepare_relations(views) + attributes: List[dict] = sorted( + self._prepare_attributes(cls), + key=lambda a: (not a["is_id"], not a["is_enum"], not a["is_list"], a["name"]), + ) + imports: set[str] = self._get_mapping_imports(cls, attributes).union( + self._get_specific_imports_for_class(cls, relations) + ) + parents = sorted(cls.parents(), key=lambda parent: parent.name) + + context = { + "class_name": class_name, + "package_name": self.package_name, + "imports": sorted(imports), + "is_abstract": cls.is_abstract, + "table_name": pluralize(to_snake_case(cls.name)), + "parent": to_java_class_name(parents[0].name) if parents else None, + "attributes": attributes, + "methods": self._prepare_methods(cls), + "relations": relations, + } + + self.write(f"{class_name}.java", entity_template.render(**context)) + + def _prepare_attributes(self, cls: Class) -> List[dict]: + attributes: List[dict] = [] + + for attr in cls.attributes: + is_enum: bool = isinstance(attr.type, Enumeration) or any( + attr.type.name == enum.name for enum in self.enumerations + ) + is_list: bool = is_many(attr.multiplicity) + attr_type: str = java_type_for(attr.type, self.model_type_names) + if is_list: + attr_type = f"List<{attr_type}>" + + attributes.append({ + "is_id": attr.is_id, + # Hibernate can only auto-generate identifiers of an integral + # type; a natural key such as an ISBN is supplied by the caller. + "is_generated": attr.is_id and attr_type in GENERATED_ID_TYPES, + "column_name": to_snake_case(attr.name), + "is_optional": attr.is_optional, + "is_enum": is_enum, + "is_list": is_list, + # Rendered as a prefix so that package-private access (which has + # no Java keyword) does not leave a stray space in the declaration. + "visibility": f"{java_visibility(attr.visibility)} ".lstrip(), + "type": attr_type, + "name": to_java_field_name(attr.name), + "accessor": to_java_accessor_suffix(attr.name), + "default_value": self._prepare_default_value(attr, is_enum), + }) + + return attributes + + @staticmethod + def _prepare_default_value(attr: Property, is_enum: bool) -> Any: + if not attr.default_value: + return "" + + type_name = attr.type.name + if type_name == DateType.name: + return (f"LocalDate.of({attr.default_value['year']}, {attr.default_value['month']}, " + f"{attr.default_value['day']})") + if type_name == DateTimeType.name: + return (f"LocalDateTime.of({attr.default_value['year']}, {attr.default_value['month']}, " + f"{attr.default_value['day']}, {attr.default_value['hour']}, " + f"{attr.default_value['minute']}, {attr.default_value['second']})") + if type_name == TimeType.name: + return (f"LocalTime.of({attr.default_value['hour']}, {attr.default_value['minute']}, " + f"{attr.default_value['second']})") + if type_name == TimeDeltaType.name: + return f"Duration.ofSeconds({attr.default_value})" + if type_name == StringType.name: + escaped = str(attr.default_value).replace("\\", "\\\\").replace('"', '\\"') + return f'"{escaped}"' + if is_enum: + return f"{to_java_class_name(attr.type.name)}.{to_java_field_name(attr.default_value)}" + return attr.default_value + + def _prepare_methods(self, cls: Class) -> List[dict]: + methods: List[dict] = [] + + for method in sorted(cls.methods, key=lambda m: m.name): + parameters = [ + { + "name": to_java_field_name(parameter.name), + "type": java_type_for(parameter.type, self.model_type_names), + } + for parameter in method.parameters + ] + + methods.append({ + "name": to_java_field_name(method.name), + "visibility": java_visibility(method.visibility) or "public", + "return_type": java_type_for(method.type, self.model_type_names) if method.type else "void", + "code": method.code, + "parameters": parameters, + }) + + return methods + + # ------------------------------------------------------------------ + # Enumerations + # ------------------------------------------------------------------ + + def _generate_enum_file(self, enum: Enumeration): + enum_name: str = to_java_class_name(enum.name) + env = build_environment() + enum_template = env.get_template("enum.java.j2") + + context = { + "package_name": self.package_name, + "name": enum_name, + # ``literals`` is a set: sorting keeps consecutive runs identical. + "literals": sorted( + (to_java_field_name(literal.name) for literal in enum.literals) + ), + } + + self.write(f"{enum_name}.java", enum_template.render(**context)) + + # ------------------------------------------------------------------ + # Imports + # ------------------------------------------------------------------ + + @staticmethod + def _get_mapping_imports(cls: Class, attributes: List[dict]) -> set[str]: + """The ``jakarta.persistence`` imports the class mapping actually uses.""" + if cls.is_abstract: + imports = {"jakarta.persistence.MappedSuperclass"} + else: + imports = {"jakarta.persistence.Entity", "jakarta.persistence.Table"} + + if any(attr["is_id"] for attr in attributes): + imports.add("jakarta.persistence.Id") + if any(attr["is_generated"] for attr in attributes): + imports.add("jakarta.persistence.GeneratedValue") + imports.add("jakarta.persistence.GenerationType") + if any(not attr["is_enum"] for attr in attributes): + imports.add("jakarta.persistence.Column") + + return imports + + def _get_specific_imports_for_class(self, cls: Class, relations: List[dict]) -> set[str]: + imports: set[str] = set() + + if any(isinstance(attr.type, Enumeration) or + any(attr.type.name == enum.name for enum in self.enumerations) + for attr in cls.attributes): + imports.add("jakarta.persistence.Enumerated") + imports.add("jakarta.persistence.EnumType") + + for attr in cls.attributes: + type_import = java_type_import(java_type_for(attr.type, self.model_type_names)) + if type_import: + imports.add(type_import) + + if is_many(attr.multiplicity): + imports.add("java.util.List") + imports.add("java.util.ArrayList") + if attr.default_value: + imports.add("java.util.Arrays") + + for method in cls.methods: + if method.type: + type_import = java_type_import(java_type_for(method.type, self.model_type_names)) + if type_import: + imports.add(type_import) + for parameter in method.parameters: + type_import = java_type_import(java_type_for(parameter.type, self.model_type_names)) + if type_import: + imports.add(type_import) + + for relation in relations: + if relation["is_list"]: + imports.add("java.util.List") + imports.add("java.util.ArrayList") + if relation["relation"] == "OneToOne": + imports.add("jakarta.persistence.OneToOne") + if relation["owning"]: + imports.add("jakarta.persistence.JoinColumn") + if relation["relation"] == "OneToMany": + imports.add("jakarta.persistence.OneToMany") + if relation["owning"]: + imports.add("jakarta.persistence.JoinColumn") + if relation["relation"] == "ManyToOne": + imports.add("jakarta.persistence.ManyToOne") + imports.add("jakarta.persistence.JoinColumn") + if relation["relation"] == "ManyToMany": + imports.add("jakarta.persistence.ManyToMany") + if relation["owning"]: + imports.add("jakarta.persistence.JoinTable") + imports.add("jakarta.persistence.JoinColumn") + + return imports diff --git a/besser/generators/spring/spring_http_generator.py b/besser/generators/spring/spring_http_generator.py new file mode 100644 index 000000000..de41bb369 --- /dev/null +++ b/besser/generators/spring/spring_http_generator.py @@ -0,0 +1,24 @@ +"""Writes the ``.http`` request samples of a generated Spring Boot project.""" + +from besser.BUML.metamodel.structural import Class, DomainModel +from besser.generators.spring._sub_generator import SpringSubGenerator, build_environment +from besser.generators.spring.java_types import to_java_class_name + + +class SpringHttpGenerator(SpringSubGenerator): + """Generates the ``.http`` scratch files of a Spring Boot project (internal helper).""" + + def __init__(self, model: DomainModel, output_dir: str = "./generated/http"): + super().__init__(model, output_dir) + + def generate(self): + for cls in self.concrete_classes(): + self._generate_http_file(cls) + + def _generate_http_file(self, cls: Class): + class_name: str = to_java_class_name(cls.name) + route: str = class_name[0].lower() + class_name[1:] + env = build_environment(trim_blocks=True, lstrip_blocks=True) + http_template = env.get_template("http.http.j2") + + self.write(f"{route}.http", http_template.render(route=route)) diff --git a/besser/generators/spring/spring_repository_generator.py b/besser/generators/spring/spring_repository_generator.py new file mode 100644 index 000000000..e11c59921 --- /dev/null +++ b/besser/generators/spring/spring_repository_generator.py @@ -0,0 +1,50 @@ +"""Writes the Spring Data JPA repository interfaces of a generated project.""" + +from besser.BUML.metamodel.structural import Class, DomainModel +from besser.generators.spring._sub_generator import SpringSubGenerator, build_environment +from besser.generators.spring.java_types import ( + get_id_attribute, + java_type_for, + to_java_class_name, + validate_java_package, +) + + +class SpringRepositoryGenerator(SpringSubGenerator): + """Generates the ``repository`` package of a Spring Boot project (internal helper).""" + + def __init__(self, model: DomainModel, + entity_package_name: str, + output_dir: str = "./generated/repository", + package_name: str = "com.example.repository"): + super().__init__(model, output_dir) + + self.package_name: str = validate_java_package(package_name) + self.entity_package_name: str = validate_java_package(entity_package_name) + + def generate(self): + for cls in self.concrete_classes(): + self._generate_repository_file(cls) + + def _generate_repository_file(self, cls: Class): + class_name: str = to_java_class_name(cls.name) + env = build_environment(trim_blocks=True, lstrip_blocks=True) + repository_template = env.get_template("irepository.java.j2") + + imports: set[str] = { + "org.springframework.data.jpa.repository.JpaRepository", + "org.springframework.stereotype.Repository", + f"{self.entity_package_name}.{class_name}", + } + methods = self.derived_finder_methods(cls, self.entity_package_name, imports) + id_attr = get_id_attribute(cls) + + context = { + "package": self.package_name, + "imports": sorted(imports), + "cls": class_name, + "methods": methods, + "id_type": java_type_for(id_attr.type, self.model_type_names), + } + + self.write(f"I{class_name}Repository.java", repository_template.render(**context)) diff --git a/besser/generators/spring/spring_service_generator.py b/besser/generators/spring/spring_service_generator.py new file mode 100644 index 000000000..555e96092 --- /dev/null +++ b/besser/generators/spring/spring_service_generator.py @@ -0,0 +1,107 @@ +"""Writes the service interfaces and implementations of a generated project.""" + +from pathlib import Path +from typing import List + +from besser.BUML.metamodel.structural import Class, DomainModel +from besser.generators.spring._sub_generator import SpringSubGenerator, build_environment +from besser.generators.spring.java_types import ( + get_id_attribute, + java_type_for, + to_java_class_name, + to_java_field_name, + validate_java_package, +) + + +class SpringServiceGenerator(SpringSubGenerator): + """Generates the ``service`` package of a Spring Boot project (internal helper).""" + + def __init__(self, model: DomainModel, + entity_package_name: str, + repository_package_name: str, + output_dir: str = "./generated/service", + package_name: str = "com.example.service"): + super().__init__(model, output_dir) + + self.package_name: str = validate_java_package(package_name) + self.entity_package_name: str = validate_java_package(entity_package_name) + self.repository_package_name: str = validate_java_package(repository_package_name) + + def generate(self): + for cls in self.concrete_classes(): + self._generate_service_files(cls) + + def _generate_service_files(self, cls: Class): + class_name: str = to_java_class_name(cls.name) + env = build_environment(trim_blocks=True, lstrip_blocks=True) + + imports: set[str] = { + f"{self.entity_package_name}.{class_name}", + "java.util.List", + "java.util.Optional", + } + # The very same list the repository renders, so that the implementation + # below can delegate to it method for method. + methods: List[dict] = self.derived_finder_methods(cls, self.entity_package_name, imports) + methods.extend(self._get_crud_methods(cls, class_name)) + methods.sort(key=lambda method: method["name"]) + + context = { + "package": f"{self.package_name}.interfaces", + "imports": sorted(imports), + "cls": class_name, + "instance": to_java_field_name(class_name[0].lower() + class_name[1:]), + "methods": methods, + } + + self.write( + Path("interfaces") / f"I{class_name}Service.java", + env.get_template("iservice.java.j2").render(**context), + ) + + imports.add("org.springframework.beans.factory.annotation.Autowired") + imports.add("org.springframework.stereotype.Service") + imports.add(f"{self.package_name}.interfaces.I{class_name}Service") + imports.add(f"{self.repository_package_name}.I{class_name}Repository") + + for method in methods: + parameter: str = method["parameter"] + tokens: List[str] = parameter.split(", ") + method["parameter_names"] = ", ".join(token.split(" ")[1] for token in tokens) if parameter else "" + + context["package"] = f"{self.package_name}.impl" + context["imports"] = sorted(imports) + + self.write( + Path("impl") / f"{class_name}Service.java", + env.get_template("service.java.j2").render(**context), + ) + + def _get_crud_methods(self, cls: Class, class_name: str) -> List[dict]: + id_attr = get_id_attribute(cls) + instance_name: str = to_java_field_name(class_name[0].lower() + class_name[1:]) + + return [ + { + "return_value": f"List<{class_name}>", + "name": "findAll", + "parameter": "", + }, + { + "return_value": f"Optional<{class_name}>", + "name": "findById", + "parameter": (f"{java_type_for(id_attr.type, self.model_type_names)} " + f"{to_java_field_name(id_attr.name)}"), + }, + { + "return_value": class_name, + "name": "save", + "parameter": f"{class_name} {instance_name}", + }, + { + "return_value": "void", + "name": "delete", + "parameter": f"{class_name} {instance_name}", + }, + ] diff --git a/besser/generators/spring/templates/__init__.py b/besser/generators/spring/templates/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/besser/generators/spring/templates/application.properties.j2 b/besser/generators/spring/templates/application.properties.j2 new file mode 100644 index 000000000..4fad7097c --- /dev/null +++ b/besser/generators/spring/templates/application.properties.j2 @@ -0,0 +1,11 @@ +spring.application.name={{ name }} + +spring.datasource.url=jdbc:h2:mem:testdb +spring.datasource.driverClassName=org.h2.Driver +spring.datasource.username=sa +spring.datasource.password= + +spring.jpa.database-platform=org.hibernate.dialect.H2Dialect +spring.jpa.hibernate.ddl-auto=update + +spring.h2.console.enabled=true \ No newline at end of file diff --git a/besser/generators/spring/templates/controller.java.j2 b/besser/generators/spring/templates/controller.java.j2 new file mode 100644 index 000000000..49857d9dc --- /dev/null +++ b/besser/generators/spring/templates/controller.java.j2 @@ -0,0 +1,59 @@ +package {{ package }}; + +{% for import in imports %} +import {{ import }}; +{% endfor %} + +@RestController +@RequestMapping("/api/{{ route }}") +public class {{ cls }}Controller { + + @Autowired + private I{{ cls }}Service _{{ instance }}Service; + + @GetMapping + public ResponseEntity> getAll() { + return ResponseEntity.ok(_{{ instance }}Service.findAll()); + } + + @GetMapping("/{id}") + public ResponseEntity<{{ cls }}> getById(@PathVariable {{ id_type }} id) { + return _{{ instance }}Service.findById(id).map(ResponseEntity::ok).orElseGet(() -> ResponseEntity.notFound().build()); + } + + @PostMapping + public ResponseEntity<{{ cls }}> create(@RequestBody {{ cls }} entity) { + {{ cls }} saved = _{{ instance }}Service.save(entity); + return ResponseEntity.ok(saved); + } + + @PutMapping("/{id}") + public ResponseEntity<{{ cls }}> update(@PathVariable {{ id_type }} id, @RequestBody {{ cls }} entity) { + + Optional<{{ cls }}> existing = _{{ instance }}Service.findById(id); + + if (existing.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + entity.set{{ id_accessor }}(id); + {{ cls }} updated = _{{ instance }}Service.save(entity); + + return ResponseEntity.ok(updated); + } + + @DeleteMapping("/{id}") + public ResponseEntity deleteById(@PathVariable {{ id_type }} id) { + + Optional<{{ cls }}> existing = _{{ instance }}Service.findById(id); + + if (existing.isEmpty()) { + return ResponseEntity.notFound().build(); + } + + _{{ instance }}Service.delete(existing.get()); + + return ResponseEntity.noContent().build(); + } + +} diff --git a/besser/generators/spring/templates/entity.java.j2 b/besser/generators/spring/templates/entity.java.j2 new file mode 100644 index 000000000..07e70532a --- /dev/null +++ b/besser/generators/spring/templates/entity.java.j2 @@ -0,0 +1,83 @@ +package {{ package_name }}; + +{% for import in imports %} +import {{ import }}; +{% endfor %} + +{% if is_abstract %} +@MappedSuperclass +{% else %} +@Entity +@Table(name = "{{ table_name }}") +{% endif %} +public {% if is_abstract %}abstract {% endif %}class {{ class_name }}{% if parent %} extends {{ parent }}{% endif %} { + +{% for attribute in attributes %} +{% if attribute.is_id %} + @Id +{% endif %} +{% if attribute.is_generated %} + @GeneratedValue(strategy = GenerationType.IDENTITY) +{% endif %} +{% if attribute.is_enum %} + @Enumerated(EnumType.STRING) +{% else %} + @Column(name = "{{ attribute.column_name }}"{% if not attribute.is_optional %}, nullable = false{% endif %}) +{% endif %} +{% if attribute.is_list %} + {{ attribute.visibility }}{{ attribute.type }} {{ attribute.name }} = new ArrayList<>({% if attribute.default_value %}Arrays.asList({{ attribute.default_value }}){% endif %}); +{% else %} + {{ attribute.visibility }}{{ attribute.type }} {{ attribute.name }}{% if attribute.default_value %} = {{ attribute.default_value }}{% endif %}; +{% endif %} + +{% endfor %} +{% for rel in relations %} +{% for annotation in rel.annotations %} + {{ annotation }} +{% endfor %} + private {{ rel.type }} {{ rel.target_property }}{% if rel.is_list %} = new ArrayList<>(){% endif %}; + +{% endfor %} +{% if not is_abstract %} + public {{ class_name }}() { } + +{% if attributes %} + public {{ class_name }}({% for attribute in attributes %}{{ attribute.type }} {{ attribute.name }}{% if not loop.last %}, {% endif %}{% endfor %}) { +{% for attribute in attributes %} + this.{{ attribute.name }} = {{ attribute.name }}; +{% endfor %} + } + +{% endif %} +{% endif %} +{% for method in methods %} + {{ method.visibility }} {{ method.return_type }} {{ method.name }}({% for parameter in method.parameters %}{{ parameter.type }} {{ parameter.name }}{% if not loop.last %}, {% endif %}{% endfor %}) { +{% if method.code %} + {{ method.code }} +{% elif method.return_type != "void" %} + return null; +{% endif %} + } + +{% endfor %} +{% for attribute in attributes %} + public {{ attribute.type }} get{{ attribute.accessor }}() { + return this.{{ attribute.name }}; + } + + public void set{{ attribute.accessor }}({{ attribute.type }} {{ attribute.name }}) { + this.{{ attribute.name }} = {{ attribute.name }}; + } + +{% endfor %} +{% for rel in relations %} + public {{ rel.type }} get{{ rel.accessor }}() { + return this.{{ rel.target_property }}; + } + + public void set{{ rel.accessor }}({{ rel.type }} {{ rel.target_property }}) { + this.{{ rel.target_property }} = {{ rel.target_property }}; + } + +{% endfor %} +} diff --git a/besser/generators/spring/templates/enum.java.j2 b/besser/generators/spring/templates/enum.java.j2 new file mode 100644 index 000000000..0fdb6d071 --- /dev/null +++ b/besser/generators/spring/templates/enum.java.j2 @@ -0,0 +1,5 @@ +package {{ package_name }}; + +public enum {{ name }} { + {{ literals | join(", ") }} +} diff --git a/besser/generators/spring/templates/http.http.j2 b/besser/generators/spring/templates/http.http.j2 new file mode 100644 index 000000000..9c38f0a88 --- /dev/null +++ b/besser/generators/spring/templates/http.http.j2 @@ -0,0 +1,27 @@ +### GET ALL +GET http://localhost:8080/api/{{ route }} +Accept: application/json + +### GET BY ID +GET http://localhost:8080/api/{{ route }}/ +Accept: application/json + +### CREATE +POST http://localhost:8080/api/{{ route }} +Content-Type: application/json + +{ + +} + +### UPDATE +PUT http://localhost:8080/api/{{ route }}/ +Content-Type: application/json + +{ + +} + +### DELETE +DELETE http://localhost:8080/api/{{ route }}/ +Accept: application/json diff --git a/besser/generators/spring/templates/irepository.java.j2 b/besser/generators/spring/templates/irepository.java.j2 new file mode 100644 index 000000000..b37325249 --- /dev/null +++ b/besser/generators/spring/templates/irepository.java.j2 @@ -0,0 +1,14 @@ +package {{ package }}; + +{% for import in imports %} +import {{ import }}; +{% endfor %} + +@Repository +public interface I{{ cls }}Repository extends JpaRepository<{{ cls }}, {{ id_type }}> { + +{% for method in methods %} + {{ method.return_value }} {{ method.name }}({{ method.parameter }}); +{% endfor %} + +} diff --git a/besser/generators/spring/templates/iservice.java.j2 b/besser/generators/spring/templates/iservice.java.j2 new file mode 100644 index 000000000..6ea422bc1 --- /dev/null +++ b/besser/generators/spring/templates/iservice.java.j2 @@ -0,0 +1,13 @@ +package {{ package }}; + +{% for import in imports %} +import {{ import }}; +{% endfor %} + +public interface I{{ cls }}Service { + +{% for method in methods %} + {{ method.return_value }} {{ method.name }}({{ method.parameter }}); +{% endfor %} + +} diff --git a/besser/generators/spring/templates/main.java.j2 b/besser/generators/spring/templates/main.java.j2 new file mode 100644 index 000000000..afefe1313 --- /dev/null +++ b/besser/generators/spring/templates/main.java.j2 @@ -0,0 +1,13 @@ +package {{ package }}; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class {{ name }} { + + public static void main(String[] args) { + SpringApplication.run({{ name }}.class, args); + } + +} diff --git a/besser/generators/spring/templates/pom.xml.j2 b/besser/generators/spring/templates/pom.xml.j2 new file mode 100644 index 000000000..36aac81c1 --- /dev/null +++ b/besser/generators/spring/templates/pom.xml.j2 @@ -0,0 +1,54 @@ + + + 4.0.0 + + org.springframework.boot + spring-boot-starter-parent + {{ spring_boot_version }} + + + {{ group_id }} + {{ name }} + 0.0.1-SNAPSHOT + {{ name }} + {{ description }} + + + {{ java_version }} + + + + + org.springframework.boot + spring-boot-starter-data-jpa + + + + org.springframework.boot + spring-boot-starter-web + + + + org.springframework.boot + spring-boot-starter-test + test + + + + com.h2database + h2 + runtime + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + \ No newline at end of file diff --git a/besser/generators/spring/templates/service.java.j2 b/besser/generators/spring/templates/service.java.j2 new file mode 100644 index 000000000..5380af3d4 --- /dev/null +++ b/besser/generators/spring/templates/service.java.j2 @@ -0,0 +1,20 @@ +package {{ package }}; + +{% for import in imports %} +import {{ import }}; +{% endfor %} + +@Service +public class {{ cls }}Service implements I{{ cls }}Service { + + @Autowired + private I{{ cls }}Repository _{{ instance }}Repository; + +{% for method in methods %} + @Override + public {{ method.return_value }} {{ method.name }}({{ method.parameter }}) { + {{ "return " if method.return_value != "void" else "" }}_{{ instance }}Repository.{{ method.name }}({{ method.parameter_names }}); + } + +{% endfor %} +} diff --git a/besser/generators/spring/templates/test.java.j2 b/besser/generators/spring/templates/test.java.j2 new file mode 100644 index 000000000..316cf1fd9 --- /dev/null +++ b/besser/generators/spring/templates/test.java.j2 @@ -0,0 +1,13 @@ +package {{ package }}; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class {{ name }}Tests { + + @Test + void contextLoads() { + } + +} diff --git a/besser/utilities/web_modeling_editor/backend/config/generators.py b/besser/utilities/web_modeling_editor/backend/config/generators.py index 2e2da2267..2c2129c22 100644 --- a/besser/utilities/web_modeling_editor/backend/config/generators.py +++ b/besser/utilities/web_modeling_editor/backend/config/generators.py @@ -7,6 +7,7 @@ from besser.generators.python_classes import PythonGenerator from besser.generators.java_classes import JavaGenerator from besser.generators.pydantic_classes import PydanticGenerator +from besser.generators.spring.spring_backend_generator import SpringBackendGenerator from besser.generators.sql_alchemy import SQLAlchemyGenerator from besser.generators.sql import SQLGenerator from besser.generators.supabase import SupabaseGenerator @@ -90,6 +91,13 @@ class GeneratorInfo(NamedTuple): category="web_framework", requires_class_diagram=True ), + "spring": GeneratorInfo( + generator_class=SpringBackendGenerator, + output_type="zip", + file_extension=".zip", + category="web_framework", + requires_class_diagram=True + ), "backend": GeneratorInfo( generator_class=BackendGenerator, output_type="zip", diff --git a/besser/utilities/web_modeling_editor/backend/constants/constants.py b/besser/utilities/web_modeling_editor/backend/constants/constants.py index 6b95b4ff5..eded7cdf5 100644 --- a/besser/utilities/web_modeling_editor/backend/constants/constants.py +++ b/besser/utilities/web_modeling_editor/backend/constants/constants.py @@ -1,3 +1,10 @@ +from besser.generators.spring.spring_backend_generator import ( + DEFAULT_JAVA_VERSION as _SPRING_DEFAULT_JAVA_VERSION, + DEFAULT_SPRING_APP_NAME as _SPRING_DEFAULT_APP_NAME, + DEFAULT_SPRING_BOOT_VERSION as _SPRING_DEFAULT_BOOT_VERSION, + DEFAULT_SPRING_PACKAGE_NAME as _SPRING_DEFAULT_PACKAGE_NAME, +) + # API Configuration API_VERSION = "1.0.0" @@ -20,6 +27,14 @@ DEFAULT_DJANGO_PROJECT_NAME = "myproject" DEFAULT_DJANGO_APP_NAME = "myapp" DEFAULT_SUPABASE_USER_ROOT = "User" +# Owned by the generator (generators must not depend on the web backend), and +# re-exported here so the API layer has a single place to read them from. +DEFAULT_SPRING_BOOT_VERSION = _SPRING_DEFAULT_BOOT_VERSION +DEFAULT_JAVA_VERSION = _SPRING_DEFAULT_JAVA_VERSION +DEFAULT_SPRING_APP_NAME = _SPRING_DEFAULT_APP_NAME +DEFAULT_SPRING_PACKAGE_NAME = _SPRING_DEFAULT_PACKAGE_NAME +# Only the web editor wraps the generated sources in a named project folder. +DEFAULT_SPRING_PROJECT_NAME = "springproject" # CORS defaults DEFAULT_CORS_ORIGINS = [ diff --git a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py index 6033616f4..429c1ad5e 100644 --- a/besser/utilities/web_modeling_editor/backend/routers/generation_router.py +++ b/besser/utilities/web_modeling_editor/backend/routers/generation_router.py @@ -104,6 +104,11 @@ DEFAULT_DJANGO_PROJECT_NAME, DEFAULT_DJANGO_APP_NAME, DEFAULT_SUPABASE_USER_ROOT, + DEFAULT_SPRING_BOOT_VERSION, + DEFAULT_JAVA_VERSION, + DEFAULT_SPRING_APP_NAME, + DEFAULT_SPRING_PACKAGE_NAME, + DEFAULT_SPRING_PROJECT_NAME, ) # Centralized error handling @@ -770,6 +775,8 @@ async def _handle_class_diagram_generation( # Generate based on generator type if generator_type == "django": return await _generate_django(buml_model, generator_class, config, temp_dir) + if generator_type == "spring": + return await _generate_spring(buml_model, generator_class, config, temp_dir) if generator_type == "sql": return await _generate_sql(buml_model, generator_class, config, temp_dir) if generator_type == "supabase": @@ -937,6 +944,59 @@ def _run_generate_in_temp_dir(): ) +async def _generate_spring(buml_model, generator_class, config: dict, temp_dir: str): + """Generate a Spring Boot project and return it as a ZIP.""" + config = config or {} + project_name = config.get("project_name") or DEFAULT_SPRING_PROJECT_NAME + app_name = config.get("app_name") or DEFAULT_SPRING_APP_NAME + spring_boot_version = config.get("spring_boot_version") or DEFAULT_SPRING_BOOT_VERSION + java_version = config.get("java_version") or DEFAULT_JAVA_VERSION + package_name = config.get("package_name") or DEFAULT_SPRING_PACKAGE_NAME + + # Sanitize project_name to prevent path traversal + project_name = os.path.basename(project_name) + if not project_name: + project_name = DEFAULT_SPRING_PROJECT_NAME + + project_dir = _safe_path(temp_dir, project_name) + os.makedirs(project_dir, exist_ok=True) + + # An invalid package name is a user input error, not a server fault. + try: + generator_instance = generator_class( + buml_model, + output_dir=project_dir, + app_name=app_name, + spring_boot_version=spring_boot_version, + java_version=java_version, + package_name=package_name, + ) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + await asyncio.to_thread(generator_instance.generate) + + if not os.listdir(project_dir): + raise ValueError("Spring Boot project generation failed: Output directory is empty") + + zip_buffer = io.BytesIO() + with zipfile.ZipFile(zip_buffer, "w", zipfile.ZIP_DEFLATED) as zip_file: + for root, _, files in os.walk(project_dir): + for file in files: + file_path = os.path.join(root, file) + arc_name = os.path.relpath(file_path, project_dir) + zip_file.write(file_path, arc_name) + + zip_buffer.seek(0) + file_name = get_filename_for_generator("spring") + + return StreamingResponse( + zip_buffer, + media_type="application/zip", + headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + ) + + async def _generate_sql(buml_model, generator_class, config: dict, temp_dir: str): """Generate SQL files.""" dialect = DEFAULT_SQL_DIALECT diff --git a/docs/source/generators.rst b/docs/source/generators.rst index a23c111a6..dcc7f16d4 100644 --- a/docs/source/generators.rst +++ b/docs/source/generators.rst @@ -32,6 +32,10 @@ Choosing a Generator - Structural - ZIP (FastAPI + SQLAlchemy) - You need only a REST API backend without frontend + * - **Spring Boot** + - Structural + - ZIP (Maven project) + - You need a Java REST backend with JPA entities, repositories and controllers * - **REST API** - Structural - Python files @@ -133,6 +137,7 @@ Generate code for various frameworks and programming languages: generators/django generators/backend + generators/spring generators/rest_api generators/python generators/pydantic diff --git a/docs/source/generators/spring.rst b/docs/source/generators/spring.rst new file mode 100644 index 000000000..60b5c0ff7 --- /dev/null +++ b/docs/source/generators/spring.rst @@ -0,0 +1,159 @@ +Spring Boot Generator +===================== + +BESSER provides a code generator for `Spring Boot `_ +backends. From a structural model it produces a complete, runnable Maven project: +JPA entities, Spring Data repositories, a service layer, REST controllers, and a +Maven wrapper, so the generated project builds and starts without any further setup. + +**B-UML Model required** + +- :doc:`../buml_language/model_types/structural`: the classes, attributes, + associations and enumerations that become the persistence and API layers. + +**Getting started** + +You can either use the :doc:`../web_editor` to draw the structural model and generate +the code directly, or use the BESSER Python API as shown below. This example uses the +:doc:`../examples/library_example` as input. + +.. code-block:: python + + from besser.generators.spring import SpringBackendGenerator + + generator: SpringBackendGenerator = SpringBackendGenerator( + model=library_model, + output_dir="output/library", + app_name="LibraryApplication", + package_name="com.example.library", + ) + generator.generate() + +**Configuration Parameters** + +Only ``model`` is required. Every other parameter is keyword-only and has a default, +so the shortest useful call is ``SpringBackendGenerator(model, output_dir="...")``. + +- ``model``: the structural model the backend is generated from. +- ``output_dir``: the directory the project is written to. When omitted, the + generator writes to ``/output``. +- ``spring_boot_version``: the ``spring-boot-starter-parent`` version written to + the POM. Defaults to ``"3.4.4"``. +- ``java_version``: the ``java.version`` property of the generated POM. Defaults + to ``"21"``. +- ``app_name``: the application name, which is also the name of the generated main + class and of its test class. Defaults to ``"Application"``. +- ``package_name``: the root Java package of the generated sources. It is validated + as a legal Java package name and determines the directory nesting under + ``src/main/java``. Defaults to ``"com.example"``. +- ``group_id``: the Maven ``groupId`` of the generated project. Defaults to + ``"com.example"``. +- ``description``: the Maven ``description`` of the generated project. Defaults to + an empty string. + +What is Generated +----------------- + +- **Entities**: each class becomes a JPA ``@Entity`` with an ``@Table``, getters and + setters, and an all-args constructor. An attribute marked as the model's identifier + becomes ``@Id``; it additionally gets ``@GeneratedValue(strategy = GenerationType.IDENTITY)`` + only when its type maps to a Java ``Integer`` or ``Long``, since Hibernate cannot + auto-generate a ``String`` identifier. An abstract class becomes a ``@MappedSuperclass`` + instead, and generalizations are emitted as Java ``extends``. +- **Enumerations**: each enumeration becomes a Java ``enum``, and attributes typed by + it are annotated ``@Enumerated(EnumType.STRING)``. +- **Associations**: the multiplicity of each end decides the JPA annotation — + ``@OneToOne``, ``@OneToMany``, ``@ManyToOne`` or ``@ManyToMany`` — with + ``@JoinColumn`` on the owning side, ``@JoinTable`` for many-to-many, and + ``mappedBy`` on the inverse side. A non-navigable end produces no field. +- **Repositories**: one ``IRepository extends JpaRepository`` per concrete + class, with a derived finder for each simple attribute, plus a ``...Between`` + finder for date and time attributes. +- **Services**: an ``IService`` interface and a ``Service`` + implementation per concrete class, providing CRUD over the repository. +- **Controllers**: a ``@RestController`` per concrete class, mapped at + ``/api/``, exposing ``GET``, ``GET /{id}``, ``POST``, ``PUT /{id}`` and + ``DELETE /{id}``. +- **HTTP request files**: a ``.http`` file per concrete class under + ``src/test/resources/http``, ready to run against the started application from an + IDE or REST client. + +Abstract classes take part in the entity hierarchy but get no repository, service, +controller or ``.http`` file of their own. + +Output +------ + +For a model with an ``Author`` class, a ``Book`` class and a ``Genre`` enumeration, +generated with ``app_name="LibraryApplication"`` and +``package_name="com.example.library"``: + +.. code-block:: text + + output/library/ + ├── pom.xml + ├── mvnw + ├── mvnw.cmd + ├── .mvn/ + │ └── wrapper/ + │ └── maven-wrapper.properties + └── src/ + ├── main/ + │ ├── java/com/example/library/ + │ │ ├── LibraryApplication.java + │ │ ├── entity/ + │ │ │ ├── Author.java + │ │ │ ├── Book.java + │ │ │ └── Genre.java + │ │ ├── repository/ + │ │ │ ├── IAuthorRepository.java + │ │ │ └── IBookRepository.java + │ │ ├── service/ + │ │ │ ├── interfaces/ + │ │ │ │ ├── IAuthorService.java + │ │ │ │ └── IBookService.java + │ │ │ └── impl/ + │ │ │ ├── AuthorService.java + │ │ │ └── BookService.java + │ │ └── controller/ + │ │ ├── AuthorController.java + │ │ └── BookController.java + │ └── resources/ + │ └── application.properties + └── test/ + ├── java/com/example/library/ + │ └── LibraryApplicationTests.java + └── resources/http/ + ├── author.http + └── book.http + +How to Run the Application +-------------------------- + +**Requirement**: a JDK matching the ``java_version`` the project was generated with +(21 by default). Maven itself does not need to be installed — the generated project +carries a Maven wrapper. + +Go to the project folder and run: + +.. code-block:: bash + + # Start the application (use mvnw.cmd on Windows) + ./mvnw spring-boot:run + +The REST API is then served at `http://localhost:8080 `_, with +each class exposed under ``/api/`` — for example +`http://localhost:8080/api/book `_. + +The generated project is configured for an in-memory +`H2 database `_ with ``spring.jpa.hibernate.ddl-auto=update``, +so the schema is created at startup and no database has to be provisioned to try the +application out. The data is discarded when the application stops. The H2 console is +enabled at `http://localhost:8080/h2-console `_. +Point ``spring.datasource.*`` in ``src/main/resources/application.properties`` at a real +database to persist beyond a run. + +.. note:: + + The :doc:`../web_editor` offers this generator in the **Web** group of the + **Generate** menu, and returns the project as a ZIP archive. diff --git a/setup.cfg b/setup.cfg index 7b75c10ca..8112243f9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -20,6 +20,9 @@ packages=find: [options.package_data] * = *.j2 +; Static (non-templated) files shipped with a generator, e.g. the Maven wrapper +; the Spring generator copies into the projects it generates. +besser.generators.spring = resources/* [options.extras_require] nn = diff --git a/tests/generators/spring/test_spring_generator.py b/tests/generators/spring/test_spring_generator.py new file mode 100644 index 000000000..3ff4e12a9 --- /dev/null +++ b/tests/generators/spring/test_spring_generator.py @@ -0,0 +1,337 @@ +"""Tests for the Spring Boot backend generator. + +The models come from the centralized fixtures in ``tests/conftest.py``; the +Spring-specific fixtures below only add what a JPA mapping additionally needs +(identifiers, a ``time`` attribute and an inheritance pair). +""" + +import copy +import filecmp +import os +from pathlib import Path + +import pytest + +from besser.BUML.metamodel.structural import ( + Class, + Generalization, + IntegerType, + Property, + StringType, + TimeType, +) +from besser.generators.spring import SpringBackendGenerator +from besser.generators.spring.java_types import ( + JAVA_TYPES, + to_java_identifier, + validate_java_package, +) + +PACKAGE_DIR = Path("src", "main", "java", "com", "example") + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + +@pytest.fixture +def spring_library_model(library_book_author_model): + """``library_book_author_model`` completed with what a JPA mapping needs. + + * every concrete class gets an identifier (JPA entities require one), + * ``Library`` gets a ``time`` attribute (to pin the ``TimeType`` mapping), + * ``Author`` gets an abstract parent (to exercise ``@MappedSuperclass``). + """ + model = library_book_author_model + + library = model.get_class_by_name("Library") + library.add_attribute(Property(name="id", type=IntegerType, is_id=True)) + library.add_attribute(Property(name="openingTime", type=TimeType)) + + book = model.get_class_by_name("Book") + book.add_attribute(Property(name="isbn", type=StringType, is_id=True)) + + person = Class(name="Person", is_abstract=True) + person.add_attribute(Property(name="id", type=IntegerType, is_id=True)) + model.add_type(person) + model.add_generalization( + Generalization(general=person, specific=model.get_class_by_name("Author")) + ) + + return model + + +@pytest.fixture +def generated_library(spring_library_model, tmp_path): + """Path: A generated Spring Boot project for the library model.""" + output_dir = tmp_path / "project" + SpringBackendGenerator(spring_library_model, output_dir=str(output_dir), + app_name="Library").generate() + return output_dir + + +def read(project_dir: Path, *parts: str) -> str: + return (project_dir.joinpath(*parts)).read_text(encoding="utf-8") + + +# --------------------------------------------------------------------------- +# Construction +# --------------------------------------------------------------------------- + +def test_generator_is_constructible_like_every_other_generator(spring_library_model, tmp_path): + """The web editor instantiates generators as ``(model, output_dir=...)``.""" + generator = SpringBackendGenerator(spring_library_model, output_dir=str(tmp_path)) + generator.generate() + + assert (tmp_path / "pom.xml").exists() + + +def test_generator_is_registered_and_buildable_by_the_backend(spring_library_model, tmp_path): + """The registry entry has to be instantiable exactly as ``_generate_standard`` does.""" + from besser.utilities.web_modeling_editor.backend.config.generators import SUPPORTED_GENERATORS + + generator_info = SUPPORTED_GENERATORS["spring"] + generator = generator_info.generator_class(spring_library_model, output_dir=str(tmp_path)) + generator.generate() + + assert generator_info.output_type == "zip" + assert (tmp_path / "pom.xml").exists() + + +def test_defaults_are_shared_with_the_backend_constants(): + """The backend constants must mirror the generator-owned defaults.""" + from besser.generators.spring import ( + DEFAULT_JAVA_VERSION, + DEFAULT_SPRING_APP_NAME, + DEFAULT_SPRING_BOOT_VERSION, + ) + from besser.utilities.web_modeling_editor.backend.constants import constants + + assert constants.DEFAULT_SPRING_BOOT_VERSION == DEFAULT_SPRING_BOOT_VERSION + assert constants.DEFAULT_JAVA_VERSION == DEFAULT_JAVA_VERSION + assert constants.DEFAULT_SPRING_APP_NAME == DEFAULT_SPRING_APP_NAME + + +def test_relative_output_dir_is_not_joined_twice(spring_library_model, tmp_path, monkeypatch): + monkeypatch.chdir(tmp_path) + SpringBackendGenerator(spring_library_model, output_dir="generated/myapp").generate() + + assert (tmp_path / "generated" / "myapp" / "pom.xml").exists() + assert not (tmp_path / "generated" / "myapp" / "generated").exists() + + +# --------------------------------------------------------------------------- +# Structure +# --------------------------------------------------------------------------- + +def test_project_scaffolding_is_where_maven_expects_it(generated_library): + assert (generated_library / "pom.xml").is_file() + # mvnw/mvnw.cmd belong at the project root, only the properties file does not. + assert (generated_library / "mvnw").is_file() + assert (generated_library / "mvnw.cmd").is_file() + assert (generated_library / ".mvn" / "wrapper" / "maven-wrapper.properties").is_file() + assert not (generated_library / ".mvn" / "mvnw").exists() + + assert (generated_library / PACKAGE_DIR / "Library.java").is_file() + assert (generated_library / "src" / "main" / "resources" / "application.properties").is_file() + + +def test_maven_wrapper_is_a_usable_script(generated_library): + mvnw = generated_library / "mvnw" + content = mvnw.read_bytes() + + assert content.startswith(b"#!/bin/sh\n"), "the POSIX wrapper must not carry CRLF endings" + assert b"{{" not in content and b"{%" not in content + if os.name != "nt": + assert os.access(mvnw, os.X_OK), "the POSIX wrapper must be executable" + + +@pytest.mark.parametrize("class_name", ["Library", "Book", "Author"]) +def test_every_concrete_class_gets_a_full_layer_stack(generated_library, class_name): + assert (generated_library / PACKAGE_DIR / "entity" / f"{class_name}.java").is_file() + assert (generated_library / PACKAGE_DIR / "repository" / f"I{class_name}Repository.java").is_file() + assert (generated_library / PACKAGE_DIR / "service" / "interfaces" / f"I{class_name}Service.java").is_file() + assert (generated_library / PACKAGE_DIR / "service" / "impl" / f"{class_name}Service.java").is_file() + assert (generated_library / PACKAGE_DIR / "controller" / f"{class_name}Controller.java").is_file() + + +def test_abstract_class_has_no_repository_service_or_controller(generated_library): + assert (generated_library / PACKAGE_DIR / "entity" / "Person.java").is_file() + assert not (generated_library / PACKAGE_DIR / "repository" / "IPersonRepository.java").exists() + assert not (generated_library / PACKAGE_DIR / "controller" / "PersonController.java").exists() + + +def test_http_samples_stay_out_of_the_compiled_source_tree(generated_library): + assert (generated_library / "src" / "test" / "resources" / "http" / "book.http").is_file() + assert not (generated_library / PACKAGE_DIR / "http_test").exists() + + +# --------------------------------------------------------------------------- +# Content +# --------------------------------------------------------------------------- + +def test_concrete_class_is_mapped_as_an_entity(generated_library): + book = read(generated_library, *PACKAGE_DIR.parts, "entity", "Book.java") + + assert "@Entity" in book + assert '@Table(name = "books")' in book + assert "public class Book {" in book + + +def test_abstract_parent_is_a_mapped_superclass_the_child_extends(generated_library): + person = read(generated_library, *PACKAGE_DIR.parts, "entity", "Person.java") + author = read(generated_library, *PACKAGE_DIR.parts, "entity", "Author.java") + + assert "@MappedSuperclass" in person + assert "@Entity" not in person + assert "public abstract class Person {" in person + assert "public class Author extends Person {" in author + + +def test_many_to_many_has_exactly_one_owning_side(generated_library): + book = read(generated_library, *PACKAGE_DIR.parts, "entity", "Book.java") + author = read(generated_library, *PACKAGE_DIR.parts, "entity", "Author.java") + + assert book.count("@ManyToMany") == 1 + assert author.count("@ManyToMany") == 1 + # Exactly one side declares mappedBy, the other one owns the join table. + mapped_by_sides = [text for text in (book, author) if 'mappedBy = "' in text] + join_table_sides = [text for text in (book, author) if "@JoinTable" in text] + assert len(mapped_by_sides) == 1 + assert len(join_table_sides) == 1 + assert mapped_by_sides[0] is not join_table_sides[0] + assert '@JoinTable(name = "book_author"' in join_table_sides[0] + + +def test_many_to_one_side_owns_the_join_column(generated_library): + book = read(generated_library, *PACKAGE_DIR.parts, "entity", "Book.java") + library = read(generated_library, *PACKAGE_DIR.parts, "entity", "Library.java") + + # Book is the "many" side of Library 1 --- 0..* Book: it holds the FK. + assert "@ManyToOne" in book + assert '@JoinColumn(name = "library_id")' in book + assert "private Library locatedIn;" in book + + # The inverse side is a mapped-by one-to-many, never a second owner. + assert '@OneToMany(mappedBy = "locatedIn")' in library + assert "@JoinColumn" not in library + + +def test_self_association_produces_both_directed_ends(employee_self_assoc_model, tmp_path): + employee = employee_self_assoc_model.get_class_by_name("Employee") + employee.add_attribute(Property(name="id", type=IntegerType, is_id=True)) + SpringBackendGenerator(employee_self_assoc_model, output_dir=str(tmp_path)).generate() + + entity = read(tmp_path, *PACKAGE_DIR.parts, "entity", "Employee.java") + + assert '@OneToMany(mappedBy = "manager")' in entity + assert "private List subordinates" in entity + assert "@ManyToOne" in entity + assert "private Employee manager;" in entity + + +def test_time_type_is_the_same_java_type_in_every_layer(generated_library): + """The regression that made the generated project uncompilable: an entity + field typed ``LocalTime`` with a repository/service method taking + ``LocalDateTime``.""" + entity = read(generated_library, *PACKAGE_DIR.parts, "entity", "Library.java") + repository = read(generated_library, *PACKAGE_DIR.parts, "repository", "ILibraryRepository.java") + service = read(generated_library, *PACKAGE_DIR.parts, "service", "interfaces", "ILibraryService.java") + service_impl = read(generated_library, *PACKAGE_DIR.parts, "service", "impl", "LibraryService.java") + + assert "LocalTime openingTime;" in entity + for layer in (repository, service, service_impl): + assert "findAllByOpeningTime(LocalTime openingTime)" in layer + assert "LocalDateTime" not in layer + + +def test_controller_uses_the_real_identifier_setter(generated_library): + """``Book`` is identified by ``isbn``, so ``setId`` would not exist.""" + controller = read(generated_library, *PACKAGE_DIR.parts, "controller", "BookController.java") + + assert "entity.setIsbn(id);" in controller + assert "entity.setId(id);" not in controller + assert "public ResponseEntity getById(@PathVariable String id)" in controller + + +def test_java_types_are_defined_once(generated_library): + """The four sub-generators must share one mapping, not four copies.""" + from besser.generators.spring import ( + spring_controller_generator, + spring_entity_generator, + spring_repository_generator, + spring_service_generator, + ) + + for module in (spring_entity_generator, spring_repository_generator, + spring_service_generator, spring_controller_generator): + assert not hasattr(module, "JAVA_TYPES"), f"{module.__name__} redefines JAVA_TYPES" + assert JAVA_TYPES["time"] == "LocalTime" + + +# --------------------------------------------------------------------------- +# Determinism +# --------------------------------------------------------------------------- + +def _tree(root: Path) -> list[str]: + return sorted( + str(path.relative_to(root)).replace("\\", "/") + for path in root.rglob("*") if path.is_file() + ) + + +def test_output_is_byte_identical_across_runs(spring_library_model, tmp_path): + """Sets (``enum.literals``, ``model.associations``) iterate in an order that + depends on object identity, so a second, freshly built copy of the same model + is what actually catches unsorted iteration.""" + first, second = tmp_path / "first", tmp_path / "second" + SpringBackendGenerator(spring_library_model, output_dir=str(first)).generate() + SpringBackendGenerator(copy.deepcopy(spring_library_model), output_dir=str(second)).generate() + + assert _tree(first) == _tree(second) + for relative in _tree(first): + assert filecmp.cmp(first / relative, second / relative, shallow=False), relative + + +# --------------------------------------------------------------------------- +# Sanitization +# --------------------------------------------------------------------------- + +def test_traversal_in_a_class_name_stays_inside_the_output_dir(spring_library_model, tmp_path): + evil = Class(name="../../evil") + evil.add_attribute(Property(name="id", type=IntegerType, is_id=True)) + spring_library_model.add_type(evil) + + output_dir = tmp_path / "project" + SpringBackendGenerator(spring_library_model, output_dir=str(output_dir)).generate() + + written = list(tmp_path.rglob("*")) + assert all(output_dir in path.parents or path == output_dir for path in written) + assert not (tmp_path / "Evil.java").exists() + assert (output_dir / PACKAGE_DIR / "entity" / "Evil.java").is_file() + + +def test_java_keywords_and_odd_names_become_legal_identifiers(): + assert to_java_identifier("../../evil") == "evil" + assert to_java_identifier("a/b\\c") == "a_b_c" + assert to_java_identifier("class") == "class_" + assert to_java_identifier("new", capitalize=True) == "New" + assert to_java_identifier("2fast") == "_2fast" + assert to_java_identifier("..") == "Unnamed" + + +def test_invalid_package_names_are_rejected(): + assert validate_java_package("com.example.app") == "com.example.app" + for invalid in ("../../etc", "com..example", "com.Example", "com.new", ""): + with pytest.raises(ValueError): + validate_java_package(invalid) + + +# --------------------------------------------------------------------------- +# Error reporting +# --------------------------------------------------------------------------- + +def test_missing_identifier_reports_the_class(player_team_domain_model, tmp_path): + """Neither Player nor Team has an ``is_id`` attribute.""" + with pytest.raises(ValueError, match="Player|Team"): + SpringBackendGenerator(player_team_domain_model, output_dir=str(tmp_path)).generate() diff --git a/tests/utilities/web_modeling_editor/backend/test_api_integration.py b/tests/utilities/web_modeling_editor/backend/test_api_integration.py index 7e3298333..b39a13a8f 100644 --- a/tests/utilities/web_modeling_editor/backend/test_api_integration.py +++ b/tests/utilities/web_modeling_editor/backend/test_api_integration.py @@ -9,12 +9,13 @@ do not support the legacy TestClient(app=...) pattern. """ +import copy import io import json import os import asyncio -from functools import wraps -from typing import Any, Dict, Optional +import zipfile +from typing import Any, Dict import pytest import httpx @@ -109,6 +110,26 @@ def class_diagram_model(): } +@pytest.fixture +def spring_class_diagram_input(class_diagram_model): + """``class_diagram_input`` with identifiers, which JPA entities require.""" + model = copy.deepcopy(class_diagram_model) + for class_key, attribute_key in (("class-1", "id-1"), ("class-2", "id-2")): + model["elements"][attribute_key] = { + "type": "Attribute", + "name": "id", + "visibility": "public", + "attributeType": "int", + "isId": True, + } + model["elements"][class_key]["attributes"].append(attribute_key) + return { + "title": "LibraryModel", + "model": model, + "generator": "spring", + } + + @pytest.fixture def class_diagram_input(class_diagram_model): """DiagramInput payload for a class diagram.""" @@ -401,6 +422,66 @@ def test_generate_java_returns_zip(self, class_diagram_input): assert response.status_code == 200 assert "application/zip" in response.headers.get("content-type", "") + def test_generate_spring_applies_the_submitted_config(self, spring_class_diagram_input): + """The Spring dialog's fields must reach the generator, not be dropped.""" + payload = { + **spring_class_diagram_input, + "generator": "spring", + "config": { + "project_name": "mylibrary", + "app_name": "LibraryApplication", + "spring_boot_version": "3.4.4", + "java_version": "21", + "package_name": "com.acme.library", + }, + } + response = client.post("/besser_api/generate-output", json=payload) + assert response.status_code == 200 + assert "application/zip" in response.headers.get("content-type", "") + + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + names = archive.namelist() + assert "pom.xml" in names + assert "mvnw" in names + assert ".mvn/wrapper/maven-wrapper.properties" in names + # app_name and package_name are what the config asked for. + assert "src/main/java/com/acme/library/LibraryApplication.java" in names + assert "src/main/java/com/acme/library/entity/Book.java" in names + pom = archive.read("pom.xml").decode("utf-8") + assert "3.4.4" in pom + assert "21" in pom + + def test_generate_spring_without_config_uses_defaults(self, spring_class_diagram_input): + """No config at all must still produce a project (the registry default path).""" + payload = {**spring_class_diagram_input, "generator": "spring"} + response = client.post("/besser_api/generate-output", json=payload) + assert response.status_code == 200 + + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + names = archive.namelist() + assert "src/main/java/com/example/Application.java" in names + + def test_generate_spring_rejects_a_traversing_project_name(self, spring_class_diagram_input): + """``project_name`` is a directory name; it must not escape the temp dir.""" + payload = { + **spring_class_diagram_input, + "generator": "spring", + "config": {"project_name": "../../evil"}, + } + response = client.post("/besser_api/generate-output", json=payload) + assert response.status_code == 200 + with zipfile.ZipFile(io.BytesIO(response.content)) as archive: + assert "pom.xml" in archive.namelist() + + def test_generate_spring_rejects_an_invalid_package_name(self, spring_class_diagram_input): + payload = { + **spring_class_diagram_input, + "generator": "spring", + "config": {"package_name": "../../etc"}, + } + response = client.post("/besser_api/generate-output", json=payload) + assert response.status_code == 400 + # --------------------------------------------------------------------------- # Validation Endpoint -- POST /besser_api/validate-diagram