Client api generator

This commit is contained in:
2026-04-06 10:22:11 +02:00
parent 4ba1bba5db
commit e36d75e38f
16 changed files with 776 additions and 396 deletions

47
.idea/compiler.xml generated
View File

@@ -7,6 +7,7 @@
<sourceOutputDir name="target/generated-sources/annotations" />
<sourceTestOutputDir name="target/generated-test-sources/test-annotations" />
<outputRelativeToContentRoot value="true" />
<module name="peakup-maven" />
<module name="peakup-tools" />
</profile>
<profile name="Annotation profile for peakup-parent" enabled="true">
@@ -60,52 +61,6 @@
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/projectlombok/lombok/1.18.42/lombok-1.18.42.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/mapstruct/mapstruct-processor/1.6.3/mapstruct-processor-1.6.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_core/2.37.0/error_prone_core-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotation/2.37.0/error_prone_annotation-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_check_api/2.37.0/error_prone_check_api-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/github/java-diff-utils/java-diff-utils/4.12/java-diff-utils-4.12.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/github/kevinstern/software-and-algorithms/1.0/software-and-algorithms-1.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/github/ben-manes/caffeine/caffeine/3.0.5/caffeine-3.0.5.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/googlejavaformat/google-java-format/1.24.0/google-java-format-1.24.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/pcollections/pcollections/4.0.1/pcollections-4.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/guava/32.1.1-jre/guava-32.1.1-jre.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/failureaccess/1.0.1/failureaccess-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/guava/listenablefuture/9999.0-empty-to-avoid-conflict-with-guava/listenablefuture-9999.0-empty-to-avoid-conflict-with-guava.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/code/findbugs/jsr305/3.0.2/jsr305-3.0.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/checker-qual/3.33.0/checker-qual-3.33.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/j2objc/j2objc-annotations/2.8/j2objc-annotations-2.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/auto-common/1.2.2/auto-common-1.2.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/github/eisop/dataflow-errorprone/3.41.0-eisop1/dataflow-errorprone-3.41.0-eisop1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/value/auto-value-annotations/1.9/auto-value-annotations-1.9.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/errorprone/error_prone_annotations/2.37.0/error_prone_annotations-2.37.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/protobuf/protobuf-java/3.25.5/protobuf-java-3.25.5.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/google/auto/service/auto-service-annotations/1.0.1/auto-service-annotations-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jspecify/jspecify/1.0.0/jspecify-1.0.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/javax/inject/javax.inject/1/javax.inject-1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/uber/nullaway/nullaway/0.13.1/nullaway-0.13.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/checkerframework/dataflow-nullaway/3.53.0/dataflow-nullaway-3.53.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-processor/7.2.4.Final/hibernate-processor-7.2.4.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/orm/hibernate-core/7.2.4.Final/hibernate-core-7.2.4.Final.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/persistence/jakarta.persistence-api/3.2.0/jakarta.persistence-api-3.2.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/transaction/jakarta.transaction-api/2.0.1/jakarta.transaction-api-2.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/fasterxml/classmate/1.7.1/classmate-1.7.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/inject/jakarta.inject-api/2.0.1/jakarta.inject-api-2.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/hibernate/models/hibernate-models/1.0.1/hibernate-models-1.0.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/io/smallrye/jandex/3.3.2/jandex-3.3.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/xml/bind/jakarta.xml.bind-api/4.0.4/jakarta.xml.bind-api-4.0.4.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/activation/jakarta.activation-api/2.1.4/jakarta.activation-api-2.1.4.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-runtime/4.0.6/jaxb-runtime-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/jaxb-core/4.0.6/jaxb-core-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/eclipse/angus/angus-activation/2.0.3/angus-activation-2.0.3.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/glassfish/jaxb/txw2/4.0.6/txw2-4.0.6.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/com/sun/istack/istack-commons-runtime/4.1.2/istack-commons-runtime-4.1.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/validation/jakarta.validation-api/3.1.1/jakarta.validation-api-3.1.1.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/jakarta/annotation/jakarta.annotation-api/3.0.0/jakarta.annotation-api-3.0.0.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/antlr/antlr4-runtime/4.13.2/antlr4-runtime-4.13.2.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/net/bytebuddy/byte-buddy/1.17.8/byte-buddy-1.17.8.jar" />
<entry name="$USER_HOME$/.m2/peak-repo/org/jboss/logging/jboss-logging/3.6.1.Final/jboss-logging-3.6.1.Final.jar" />
</processorPath>
<module name="peakup-backend" />
</profile>

2
.idea/encodings.xml generated
View File

@@ -5,6 +5,8 @@
<file url="file://$PROJECT_DIR$/peakup-backend/peakup-dev/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/peakup-backend/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/peakup-backend/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/peakup-maven/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/peakup-maven/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/peakup-tools/src/main/java" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/peakup-tools/src/main/resources" charset="UTF-8" />
<file url="file://$PROJECT_DIR$/src/main/java" charset="UTF-8" />

View File

@@ -65,7 +65,7 @@
- `$MVN` = `JAVA_HOME=/home/ingo/.jdks/temurin-21.0.7 mvn -s /home/ingo/.m2/settings_peak.xml`
- Backend: `$MVN compile`, `quarkus:dev`, `test -Dtest=ClassName`
- Frontend: `npm run dev`, `npm run build`
- Frontend: `npm run dev`, `npm run build`, `npm run lint`
---

View File

@@ -160,6 +160,25 @@
<build>
<plugins>
<plugin>
<groupId>com.peakup</groupId>
<artifactId>peakup-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<executions>
<execution>
<id>generate-package-info</id>
<goals>
<goal>generate-package-info</goal>
</goals>
</execution>
<execution>
<id>generate-typescript</id>
<goals>
<goal>generate-typescript</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>${quarkus.platform.group-id}</groupId>
<artifactId>quarkus-maven-plugin</artifactId>

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<module version="4">
<component name="CheckStyle-IDEA-Module" serialisationVersion="2">
<option name="activeLocationsIds" />
</component>
</module>

69
peakup-maven/pom.xml Normal file
View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.peakup</groupId>
<artifactId>peakup-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<packaging>maven-plugin</packaging>
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.version>3.9.9</maven.version>
</properties>
<dependencies>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-plugin-api</artifactId>
<version>${maven.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven.plugin-tools</groupId>
<artifactId>maven-plugin-annotations</artifactId>
<version>3.15.1</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.apache.maven</groupId>
<artifactId>maven-core</artifactId>
<version>${maven.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<version>2.18.3</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-plugin-plugin</artifactId>
<version>3.15.1</version>
<configuration>
<goalPrefix>peakup</goalPrefix>
</configuration>
<executions>
<execution>
<id>default-descriptor</id>
<phase>process-classes</phase>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,84 @@
package com.peakup.maven;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
/**
* Generates package-info.java files with the @NonNull annotation
* for all packages that contain Java source files.
*/
@Mojo(name = "generate-package-info", defaultPhase = LifecyclePhase.GENERATE_SOURCES, threadSafe = true)
public class PackageInfoGeneratorMojo extends AbstractMojo {
private static final String ANNOTATION = "@com.peakup.common.annotation.NonNull";
@Parameter(defaultValue = "${project.build.sourceDirectory}", required = true)
private File sourceDirectory;
@Override
public void execute() throws MojoExecutionException {
if (!sourceDirectory.exists()) {
getLog().info("Source directory does not exist, skipping: " + sourceDirectory);
return;
}
Path sourceRoot = sourceDirectory.toPath().toAbsolutePath().normalize();
getLog().info("Generating package-info.java files in: " + sourceRoot);
try (Stream<Path> stream = Files.walk(sourceRoot)) {
stream.filter(Files::isDirectory)
.forEach(directory -> processDirectory(sourceRoot, directory));
} catch (IOException exception) {
throw new MojoExecutionException("Failed to walk source directory: " + sourceRoot, exception);
}
}
private void processDirectory(Path sourceRoot, Path currentDir) {
try (Stream<Path> files = Files.list(currentDir)) {
boolean hasJavaFiles = files.anyMatch(path ->
path.toString().endsWith(".java") && !path.getFileName().toString().equals("package-info.java")
);
if (hasJavaFiles) {
String packageName = determinePackageName(sourceRoot, currentDir);
if (packageName != null) {
createPackageInfo(currentDir, packageName);
}
}
} catch (IOException exception) {
getLog().warn("Failed to process directory " + currentDir + ": " + exception.getMessage());
}
}
private String determinePackageName(Path sourceRoot, Path currentDir) {
Path relativePath = sourceRoot.relativize(currentDir);
String packageName = StreamSupport.stream(relativePath.spliterator(), false)
.map(Path::toString)
.collect(Collectors.joining("."));
return packageName.isEmpty() ? null : packageName;
}
private void createPackageInfo(Path dir, String packageName) throws IOException {
Path file = dir.resolve("package-info.java");
if (Files.exists(file)) {
return;
}
String content = String.format("%s%npackage %s;%n", ANNOTATION, packageName);
Files.writeString(file, content, StandardOpenOption.CREATE_NEW);
getLog().info("Created: " + file);
}
}

View File

@@ -1,4 +1,4 @@
package com.peakup.tools.generator;
package com.peakup.maven;
import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.ws.rs.DELETE;
@@ -17,13 +17,13 @@ import java.lang.reflect.Field;
import java.lang.reflect.GenericArrayType;
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
import java.lang.reflect.Parameter;
import java.lang.reflect.ParameterizedType;
import java.lang.reflect.Type;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Arrays;
@@ -37,94 +37,131 @@ import java.util.jar.JarFile;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import org.apache.maven.plugin.AbstractMojo;
import org.apache.maven.plugin.MojoExecutionException;
import org.apache.maven.plugins.annotations.LifecyclePhase;
import org.apache.maven.plugins.annotations.Mojo;
import org.apache.maven.plugins.annotations.Parameter;
import org.apache.maven.plugins.annotations.ResolutionScope;
import org.apache.maven.project.MavenProject;
/**
* Generates TypeScript REST client and entity types from peakup-backend REST resources.
*
* <p>Scans all classes extending {@code AbstractResource}, reads JAX-RS annotations and
* {@code RestResponse<T>} return types via reflection, then emits into
* {@code peakup-frontend/src/lib/api/}:
* <p>Scans all compiled classes extending {@code AbstractResource}, reads JAX-RS annotations
* and {@code RestResponse<T>} return types via reflection, then writes into the frontend:
* <ul>
* <li>{@code httpClient.ts} — shared fetch helpers</li>
* <li>{@code {Entity}Do.ts} — one interface per domain entity, mirroring the Java class hierarchy</li>
* <li>{@code {Entity}Do.ts} — one interface per domain entity</li>
* <li>{@code {Name}.ts} — union type per enum</li>
* <li>{@code {Resource}Client.ts} — one client file per JAX-RS resource</li>
* <li>{@code hooks.ts} — TanStack Query hooks</li>
* </ul>
*
* <p>Run from the project root (backend must be compiled first):
* <pre>
* $MVN -f peakup-backend/pom.xml install -DskipTests -q
* $MVN -f peakup-tools/pom.xml compile exec:java \
* -Dexec.mainClass=com.peakup.tools.generator.TypeScriptClientGenerator
* </pre>
*/
public class TypeScriptClientGenerator {
@Mojo(
name = "generate-typescript",
defaultPhase = LifecyclePhase.PROCESS_CLASSES,
requiresDependencyResolution = ResolutionScope.COMPILE,
threadSafe = true
)
public class TypeScriptClientGeneratorMojo extends AbstractMojo {
private static final String BACKEND_JAVA_PREFIX = "com.peakup";
private static final String OUTPUT_DIR_API = "peakup-frontend/src/api";
private static final String OUTPUT_DIR_TYPES = "peakup-frontend/src/types";
// ── Main ─────────────────────────────────────────────────────────────────
@Parameter(defaultValue = "${project}", readonly = true, required = true)
private MavenProject project;
public static void main(final String[] args) throws Exception {
final Path outputDirApi = Paths.get(System.getProperty("user.dir")).resolve(OUTPUT_DIR_API);
final Path outputDirTypes = Paths.get(System.getProperty("user.dir")).resolve(OUTPUT_DIR_TYPES);
Files.createDirectories(outputDirApi);
Files.createDirectories(outputDirTypes);
@Parameter(defaultValue = "${project.basedir}/../peakup-frontend/src/api", required = true)
private File outputDirApi;
System.out.println("Scanning for REST resources...");
final List<Class<?>> resourceClasses = findResourceClasses();
System.out.println("Found: " + resourceClasses.stream()
.map(Class::getSimpleName).collect(Collectors.joining(", ")));
@Parameter(defaultValue = "${project.basedir}/../peakup-frontend/src/types", required = true)
private File outputDirTypes;
final List<ResourceInfo> resources = resourceClasses.stream()
.map(TypeScriptClientGenerator::extractResource)
.filter(r -> !r.endpoints().isEmpty())
.sorted(Comparator.comparing(ResourceInfo::resourceKey))
.collect(Collectors.toList());
@Override
public void execute() throws MojoExecutionException {
final Path apiPath = outputDirApi.toPath();
final Path typesPath = outputDirTypes.toPath();
System.out.println("Collecting entity types...");
final Set<Class<?>> entityTypes = collectEntityTypes(resources);
System.out.println("Types: " + entityTypes.stream()
.map(Class::getSimpleName).collect(Collectors.joining(", ")));
writeFile(outputDirApi.resolve("httpClient.ts"), generateHttpClient());
for (final ResourceInfo resource : resources) {
final String clientFileName = resource.resourceClass().getSimpleName().replace("Resource", "") + "Client.ts";
writeFile(outputDirApi.resolve(clientFileName), generateResourceClient(resource, entityTypes));
try {
Files.createDirectories(apiPath);
Files.createDirectories(typesPath);
} catch (IOException exception) {
throw new MojoExecutionException("Failed to create output directories", exception);
}
for (final Class<?> entityType : topoSort(entityTypes)) {
final String doFileName = entityType.isEnum()
? entityType.getSimpleName() + ".ts"
: entityType.getSimpleName() + "Do.ts";
writeFile(outputDirTypes.resolve(doFileName), generateEntityDoFile(entityType, entityTypes));
final URLClassLoader classLoader = buildClassLoader();
try {
getLog().info("Scanning for REST resources...");
final List<Class<?>> resourceClasses = findResourceClasses(classLoader);
getLog().info("Found: " + resourceClasses.stream()
.map(Class::getSimpleName).collect(Collectors.joining(", ")));
final List<ResourceInfo> resources = resourceClasses.stream()
.map(this::extractResource)
.filter(resource -> !resource.endpoints().isEmpty())
.sorted(Comparator.comparing(ResourceInfo::resourceKey))
.collect(Collectors.toList());
getLog().info("Collecting entity types...");
final Set<Class<?>> entityTypes = collectEntityTypes(resources);
getLog().info("Types: " + entityTypes.stream()
.map(Class::getSimpleName).collect(Collectors.joining(", ")));
writeFile(apiPath.resolve("httpClient.ts"), generateHttpClient());
for (final ResourceInfo resource : resources) {
final String clientFileName = resource.resourceClass().getSimpleName().replace("Resource", "") + "Client.ts";
writeFile(apiPath.resolve(clientFileName), generateResourceClient(resource, entityTypes));
}
for (final Class<?> entityType : topoSort(entityTypes)) {
final String fileName = entityType.isEnum()
? entityType.getSimpleName() + ".ts"
: entityType.getSimpleName() + "Do.ts";
writeFile(typesPath.resolve(fileName), generateEntityDoFile(entityType, entityTypes));
}
writeFile(apiPath.resolve("hooks.ts"), generateHooks(resources, entityTypes));
getLog().info("Done → api: " + apiPath.toAbsolutePath() + ", types: " + typesPath.toAbsolutePath());
} catch (Exception exception) {
throw new MojoExecutionException("TypeScript generation failed", exception);
} finally {
try {
classLoader.close();
} catch (IOException exception) {
getLog().warn("Failed to close classloader: " + exception.getMessage());
}
}
writeFile(outputDirApi.resolve("hooks.ts"), generateHooks(resources, entityTypes));
System.out.println("Done → api: " + outputDirApi.toAbsolutePath() + ", types: " + outputDirTypes.toAbsolutePath());
}
// ── Classpath Scanning ────────────────────────────────────────────────────
// ── Classpath ─────────────────────────────────────────────────────────────
private static List<Class<?>> findResourceClasses() throws Exception {
final Class<?> abstractResource = Class.forName("com.peakup.common.AbstractResource");
@SuppressWarnings("unchecked")
private URLClassLoader buildClassLoader() throws MojoExecutionException {
try {
final List<String> elements = (List<String>) project.getCompileClasspathElements();
final URL[] urls = new URL[elements.size()];
for (int i = 0; i < elements.size(); i++) {
urls[i] = new File(elements.get(i)).toURI().toURL();
}
return new URLClassLoader(urls, getClass().getClassLoader());
} catch (Exception exception) {
throw new MojoExecutionException("Failed to build classpath URLClassLoader", exception);
}
}
private List<Class<?>> findResourceClasses(final URLClassLoader classLoader) throws Exception {
final Class<?> abstractResource = classLoader.loadClass("com.peakup.common.AbstractResource");
final List<Class<?>> result = new ArrayList<>();
// Collect classpath entries from the system property and the context classloader (exec:java
// uses an isolated URLClassLoader that is not reflected in java.class.path)
final Set<String> classpathEntries = new LinkedHashSet<>();
classpathEntries.addAll(Arrays.asList(System.getProperty("java.class.path").split(File.pathSeparator)));
collectUrlsFromClassLoader(Thread.currentThread().getContextClassLoader(), classpathEntries);
for (final String entry : classpathEntries) {
final Path entryPath = Paths.get(entry);
if (Files.isDirectory(entryPath)) {
scanDirectory(entryPath, abstractResource, result);
} else if (entry.endsWith(".jar") && entry.contains("peakup-backend")) {
scanJar(entryPath, abstractResource, result);
for (final URL url : classLoader.getURLs()) {
final File file = new File(url.toURI());
if (file.isDirectory()) {
scanDirectory(file.toPath(), abstractResource, classLoader, result);
} else if (file.getName().endsWith(".jar") && file.getName().contains("peakup-backend")) {
scanJar(file.toPath(), abstractResource, classLoader, result);
}
}
@@ -132,34 +169,19 @@ public class TypeScriptClientGenerator {
return result;
}
private static void collectUrlsFromClassLoader(final ClassLoader classLoader, final Set<String> entries) {
if (classLoader == null) return;
try {
final java.lang.reflect.Method getURLs = classLoader.getClass().getMethod("getURLs");
final java.net.URL[] urls = (java.net.URL[]) getURLs.invoke(classLoader);
for (final java.net.URL url : urls) {
if ("file".equals(url.getProtocol())) {
entries.add(new java.io.File(url.toURI()).getAbsolutePath());
}
}
} catch (final Exception ignored) {
}
collectUrlsFromClassLoader(classLoader.getParent(), entries);
}
private static void scanDirectory(final Path root, final Class<?> abstractResource, final List<Class<?>> result)
throws IOException {
private void scanDirectory(final Path root, final Class<?> abstractResource,
final URLClassLoader classLoader, final List<Class<?>> result) throws IOException {
try (final Stream<Path> walk = Files.walk(root)) {
walk.filter(p -> p.toString().endsWith(".class"))
.map(p -> toClassName(root.relativize(p).toString()))
walk.filter(path -> path.toString().endsWith(".class"))
.map(path -> toClassName(root.relativize(path).toString()))
.filter(name -> name.startsWith(BACKEND_JAVA_PREFIX)
&& !name.contains("$") && !name.endsWith("package-info"))
.forEach(name -> tryLoadResource(name, abstractResource, result));
.forEach(name -> tryLoadResource(name, abstractResource, classLoader, result));
}
}
private static void scanJar(final Path jarPath, final Class<?> abstractResource, final List<Class<?>> result)
throws IOException {
private void scanJar(final Path jarPath, final Class<?> abstractResource,
final URLClassLoader classLoader, final List<Class<?>> result) throws IOException {
try (final JarFile jar = new JarFile(jarPath.toFile())) {
jar.entries().asIterator().forEachRemaining(entry -> {
final String name = entry.getName();
@@ -167,15 +189,15 @@ public class TypeScriptClientGenerator {
|| name.contains("$") || name.contains("package-info")) {
return;
}
tryLoadResource(toClassName(name), abstractResource, result);
tryLoadResource(toClassName(name), abstractResource, classLoader, result);
});
}
}
private static void tryLoadResource(final String className, final Class<?> abstractResource,
final List<Class<?>> result) {
private void tryLoadResource(final String className, final Class<?> abstractResource,
final URLClassLoader classLoader, final List<Class<?>> result) {
try {
final Class<?> cls = Class.forName(className);
final Class<?> cls = classLoader.loadClass(className);
if (abstractResource.isAssignableFrom(cls)
&& !cls.equals(abstractResource)
&& !Modifier.isAbstract(cls.getModifiers())) {
@@ -191,15 +213,15 @@ public class TypeScriptClientGenerator {
// ── Resource Extraction ───────────────────────────────────────────────────
private static ResourceInfo extractResource(final Class<?> cls) {
final jakarta.ws.rs.Path pathAnn = cls.getAnnotation(jakarta.ws.rs.Path.class);
final String basePath = pathAnn != null ? pathAnn.value() : "";
private ResourceInfo extractResource(final Class<?> cls) {
final jakarta.ws.rs.Path pathAnnotation = cls.getAnnotation(jakarta.ws.rs.Path.class);
final String basePath = pathAnnotation != null ? pathAnnotation.value() : "";
final String resourceKey = deriveResourceKey(basePath, cls);
final List<EndpointInfo> endpoints = Arrays.stream(cls.getDeclaredMethods())
.filter(TypeScriptClientGenerator::hasHttpAnnotation)
.filter(TypeScriptClientGeneratorMojo::hasHttpAnnotation)
.sorted(Comparator.comparing(Method::getName))
.map(m -> extractEndpoint(m, basePath))
.map(method -> extractEndpoint(method, basePath))
.collect(Collectors.toList());
return new ResourceInfo(cls, basePath, resourceKey, endpoints);
@@ -207,8 +229,8 @@ public class TypeScriptClientGenerator {
private static EndpointInfo extractEndpoint(final Method method, final String basePath) {
final String httpMethod = getHttpMethod(method);
final jakarta.ws.rs.Path subPathAnn = method.getAnnotation(jakarta.ws.rs.Path.class);
final String subPath = subPathAnn != null ? subPathAnn.value() : "";
final jakarta.ws.rs.Path subPathAnnotation = method.getAnnotation(jakarta.ws.rs.Path.class);
final String subPath = subPathAnnotation != null ? subPathAnnotation.value() : "";
final Type javaReturnType = unwrapRestResponse(method.getGenericReturnType());
final String tsReturnType = toTsType(javaReturnType);
@@ -217,25 +239,25 @@ public class TypeScriptClientGenerator {
final List<ParamInfo> queryParams = new ArrayList<>();
ParamInfo bodyParam = null;
final Parameter[] params = method.getParameters();
final java.lang.reflect.Parameter[] params = method.getParameters();
final Annotation[][] allAnnotations = method.getParameterAnnotations();
final Type[] genericTypes = method.getGenericParameterTypes();
for (int i = 0; i < params.length; i++) {
final Annotation[] ann = allAnnotations[i];
final Annotation[] annotations = allAnnotations[i];
final Type javaType = genericTypes[i];
final boolean nullable = isNullable(ann);
final DefaultValue defVal = getAnnotation(ann, DefaultValue.class);
final PathParam pathParam = getAnnotation(ann, PathParam.class);
final QueryParam queryParam = getAnnotation(ann, QueryParam.class);
final boolean nullable = isNullable(annotations);
final DefaultValue defaultValue = getAnnotation(annotations, DefaultValue.class);
final PathParam pathParam = getAnnotation(annotations, PathParam.class);
final QueryParam queryParam = getAnnotation(annotations, QueryParam.class);
if (pathParam != null) {
pathParams.add(new ParamInfo(pathParam.value(), javaType, toTsType(javaType), false, null));
} else if (queryParam != null) {
final boolean optional = nullable || defVal != null;
final boolean optional = nullable || defaultValue != null;
queryParams.add(new ParamInfo(queryParam.value(), javaType, toTsType(javaType), optional,
defVal != null ? defVal.value() : null));
} else if (!isJaxRsSpecialParam(ann)) {
defaultValue != null ? defaultValue.value() : null));
} else if (!isJaxRsSpecialParam(annotations)) {
bodyParam = new ParamInfo("body", javaType, toTsType(javaType), false, null);
}
}
@@ -245,11 +267,11 @@ public class TypeScriptClientGenerator {
}
private static Type unwrapRestResponse(final Type type) {
if (type instanceof final ParameterizedType pt) {
final Type rawType = pt.getRawType();
if (type instanceof final ParameterizedType parameterizedType) {
final Type rawType = parameterizedType.getRawType();
if (rawType instanceof final Class<?> rawClass
&& rawClass.getSimpleName().equals("RestResponse")) {
final Type[] args = pt.getActualTypeArguments();
final Type[] args = parameterizedType.getActualTypeArguments();
if (args.length == 1) return args[0];
}
}
@@ -299,7 +321,6 @@ public class TypeScriptClientGenerator {
}
}
// Also include abstract com.peakup.* superclasses for DO hierarchy generation
for (final Class<?> cls : new ArrayList<>(visited)) {
if (!cls.isEnum()) {
addAbstractSuperclasses(cls, visited);
@@ -322,11 +343,11 @@ public class TypeScriptClientGenerator {
private static void seedFromType(final Type type, final Queue<Class<?>> queue, final Set<Class<?>> visited) {
if (type instanceof final Class<?> cls) {
if (isDomainType(cls) && !visited.contains(cls)) queue.add(cls);
} else if (type instanceof final ParameterizedType pt) {
seedFromType(pt.getRawType(), queue, visited);
for (final Type arg : pt.getActualTypeArguments()) seedFromType(arg, queue, visited);
} else if (type instanceof final GenericArrayType gat) {
seedFromType(gat.getGenericComponentType(), queue, visited);
} else if (type instanceof final ParameterizedType parameterizedType) {
seedFromType(parameterizedType.getRawType(), queue, visited);
for (final Type arg : parameterizedType.getActualTypeArguments()) seedFromType(arg, queue, visited);
} else if (type instanceof final GenericArrayType genericArrayType) {
seedFromType(genericArrayType.getGenericComponentType(), queue, visited);
}
}
@@ -361,21 +382,21 @@ public class TypeScriptClientGenerator {
return "unknown";
}
if (type instanceof final ParameterizedType pt) {
final Type rawType = pt.getRawType();
if (type instanceof final ParameterizedType parameterizedType) {
final Type rawType = parameterizedType.getRawType();
if (rawType instanceof final Class<?> rawClass) {
final String rawName = rawClass.getName();
if (rawName.equals("java.util.List") || rawName.equals("java.util.Collection")
|| rawName.equals("java.util.Set")) {
final Type[] args = pt.getActualTypeArguments();
final Type[] args = parameterizedType.getActualTypeArguments();
if (args.length == 1) return toTsType(args[0]) + "[]";
}
}
return "unknown";
}
if (type instanceof final GenericArrayType gat) {
return toTsType(gat.getGenericComponentType()) + "[]";
if (type instanceof final GenericArrayType genericArrayType) {
return toTsType(genericArrayType.getGenericComponentType()) + "[]";
}
return "unknown";
@@ -384,76 +405,67 @@ public class TypeScriptClientGenerator {
// ── httpClient.ts ─────────────────────────────────────────────────────────
private static String generateHttpClient() {
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
sb.append("const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8111';\n\n");
sb.append("export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {\n");
sb.append(" const p = new URLSearchParams();\n");
sb.append(" for (const [key, val] of Object.entries(params)) {\n");
sb.append(" if (val !== undefined) p.set(key, String(val));\n");
sb.append(" }\n");
sb.append(" const query = p.toString();\n");
sb.append(" return query ? `?${query}` : '';\n");
sb.append("}\n\n");
sb.append("export async function get<T>(path: string): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`);\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n\n");
sb.append("export async function post<T>(path: string, body?: unknown): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`, {\n");
sb.append(" method: 'POST',\n");
sb.append(" headers: { 'Content-Type': 'application/json' },\n");
sb.append(" body: body !== undefined ? JSON.stringify(body) : undefined,\n");
sb.append(" });\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" if (res.status === 204) return undefined as T;\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n\n");
sb.append("export async function put<T>(path: string, body?: unknown): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`, {\n");
sb.append(" method: 'PUT',\n");
sb.append(" headers: { 'Content-Type': 'application/json' },\n");
sb.append(" body: body !== undefined ? JSON.stringify(body) : undefined,\n");
sb.append(" });\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" if (res.status === 204) return undefined as T;\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n\n");
sb.append("export async function del<T>(path: string): Promise<T> {\n");
sb.append(" const res = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' });\n");
sb.append(" if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n");
sb.append(" if (res.status === 204) return undefined as T;\n");
sb.append(" return res.json() as Promise<T>;\n");
sb.append("}\n");
return sb.toString();
return "// GENERATED — do not edit manually.\n\n"
+ "const BASE_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:8111';\n\n"
+ "export function buildQuery(params: Record<string, string | number | boolean | undefined>): string {\n"
+ " const p = new URLSearchParams();\n"
+ " for (const [key, val] of Object.entries(params)) {\n"
+ " if (val !== undefined) p.set(key, String(val));\n"
+ " }\n"
+ " const query = p.toString();\n"
+ " return query ? `?${query}` : '';\n"
+ "}\n\n"
+ "export async function get<T>(path: string): Promise<T> {\n"
+ " const res = await fetch(`${BASE_URL}${path}`);\n"
+ " if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n"
+ " return res.json() as Promise<T>;\n"
+ "}\n\n"
+ "export async function post<T>(path: string, body?: unknown): Promise<T> {\n"
+ " const res = await fetch(`${BASE_URL}${path}`, {\n"
+ " method: 'POST',\n"
+ " headers: { 'Content-Type': 'application/json' },\n"
+ " body: body !== undefined ? JSON.stringify(body) : undefined,\n"
+ " });\n"
+ " if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n"
+ " if (res.status === 204) return undefined as T;\n"
+ " return res.json() as Promise<T>;\n"
+ "}\n\n"
+ "export async function put<T>(path: string, body?: unknown): Promise<T> {\n"
+ " const res = await fetch(`${BASE_URL}${path}`, {\n"
+ " method: 'PUT',\n"
+ " headers: { 'Content-Type': 'application/json' },\n"
+ " body: body !== undefined ? JSON.stringify(body) : undefined,\n"
+ " });\n"
+ " if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n"
+ " if (res.status === 204) return undefined as T;\n"
+ " return res.json() as Promise<T>;\n"
+ "}\n\n"
+ "export async function del<T>(path: string): Promise<T> {\n"
+ " const res = await fetch(`${BASE_URL}${path}`, { method: 'DELETE' });\n"
+ " if (!res.ok) throw new Error(`HTTP ${res.status}: ${path}`);\n"
+ " if (res.status === 204) return undefined as T;\n"
+ " return res.json() as Promise<T>;\n"
+ "}\n";
}
// ── {Entity}Do.ts / {Enum}.ts ─────────────────────────────────────────────
private static String generateEntityDoFile(final Class<?> cls, final Set<Class<?>> allTypes) {
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("// GENERATED — do not edit manually.\n\n");
if (cls.isEnum()) {
final Object[] constants = cls.getEnumConstants();
sb.append("export type ").append(cls.getSimpleName()).append(" =\n");
stringBuilder.append("export type ").append(cls.getSimpleName()).append(" =\n");
for (int i = 0; i < constants.length; i++) {
sb.append(" | '").append(((Enum<?>) constants[i]).name()).append("'");
sb.append(i < constants.length - 1 ? "\n" : ";\n");
stringBuilder.append(" | '").append(((Enum<?>) constants[i]).name()).append("'");
stringBuilder.append(i < constants.length - 1 ? "\n" : ";\n");
}
return sb.toString();
return stringBuilder.toString();
}
final Set<String> importStatements = new LinkedHashSet<>();
// Resolve parent for extends clause
final Class<?> superClass = cls.getSuperclass();
final String extendsClause;
if (superClass != null && superClass != Object.class && allTypes.contains(superClass)) {
@@ -464,10 +476,9 @@ public class TypeScriptClientGenerator {
extendsClause = "";
}
// Own declared fields only — each parent gets its own DO file
final List<Field> ownFields = Arrays.stream(cls.getDeclaredFields())
.filter(f -> !Modifier.isStatic(f.getModifiers()))
.filter(f -> !isJsonIgnored(f))
.filter(field -> !Modifier.isStatic(field.getModifiers()))
.filter(field -> !isJsonIgnored(field))
.collect(Collectors.toList());
for (final Field field : ownFields) {
@@ -476,22 +487,22 @@ public class TypeScriptClientGenerator {
if (!importStatements.isEmpty()) {
for (final String importStatement : importStatements) {
sb.append(importStatement).append("\n");
stringBuilder.append(importStatement).append("\n");
}
sb.append("\n");
stringBuilder.append("\n");
}
sb.append("export interface ").append(cls.getSimpleName()).append("Do").append(extendsClause).append(" {\n");
stringBuilder.append("export interface ").append(cls.getSimpleName()).append("Do").append(extendsClause).append(" {\n");
for (final Field field : ownFields) {
final boolean nullable = isNullableField(field);
final boolean nullable = isNullable(field.getAnnotations());
final String tsType = toTsType(field.getGenericType());
sb.append(" ").append(field.getName()).append(": ").append(tsType);
if (nullable) sb.append(" | null");
sb.append(";\n");
stringBuilder.append(" ").append(field.getName()).append(": ").append(tsType);
if (nullable) stringBuilder.append(" | null");
stringBuilder.append(";\n");
}
sb.append("}\n");
stringBuilder.append("}\n");
return sb.toString();
return stringBuilder.toString();
}
private static void collectImportsFromType(final Type type, final Set<Class<?>> allTypes,
@@ -501,13 +512,13 @@ public class TypeScriptClientGenerator {
final String tsName = cls.isEnum() ? cls.getSimpleName() : cls.getSimpleName() + "Do";
importStatements.add("import type { " + tsName + " } from '" + typeImportPrefix + tsName + "';");
}
} else if (type instanceof final ParameterizedType pt) {
collectImportsFromType(pt.getRawType(), allTypes, importStatements, typeImportPrefix);
for (final Type arg : pt.getActualTypeArguments()) {
} else if (type instanceof final ParameterizedType parameterizedType) {
collectImportsFromType(parameterizedType.getRawType(), allTypes, importStatements, typeImportPrefix);
for (final Type arg : parameterizedType.getActualTypeArguments()) {
collectImportsFromType(arg, allTypes, importStatements, typeImportPrefix);
}
} else if (type instanceof final GenericArrayType gat) {
collectImportsFromType(gat.getGenericComponentType(), allTypes, importStatements, typeImportPrefix);
} else if (type instanceof final GenericArrayType genericArrayType) {
collectImportsFromType(genericArrayType.getGenericComponentType(), allTypes, importStatements, typeImportPrefix);
}
}
@@ -522,23 +533,23 @@ public class TypeScriptClientGenerator {
}
}
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("// GENERATED — do not edit manually.\n\n");
final Set<String> httpHelpers = resolveHttpHelpers(resource.endpoints());
sb.append("import { ").append(String.join(", ", httpHelpers)).append(" } from './httpClient';\n");
stringBuilder.append("import { ").append(String.join(", ", httpHelpers)).append(" } from './httpClient';\n");
for (final String typeImport : typeImportStatements) {
sb.append(typeImport).append("\n");
stringBuilder.append(typeImport).append("\n");
}
sb.append("\n");
stringBuilder.append("\n");
sb.append("export const ").append(resource.resourceKey()).append(" = {\n");
stringBuilder.append("export const ").append(resource.resourceKey()).append(" = {\n");
for (final EndpointInfo endpoint : resource.endpoints()) {
sb.append(generateClientFunction(endpoint, resource.basePath()));
stringBuilder.append(generateClientFunction(endpoint, resource.basePath()));
}
sb.append("};\n");
stringBuilder.append("};\n");
return sb.toString();
return stringBuilder.toString();
}
private static Set<String> resolveHttpHelpers(final List<EndpointInfo> endpoints) {
@@ -580,26 +591,24 @@ public class TypeScriptClientGenerator {
private static String generateClientFunction(final EndpointInfo endpoint, final String basePath) {
final List<String> paramList = new ArrayList<>();
for (final ParamInfo p : endpoint.pathParams()) {
paramList.add(p.name() + ": " + p.tsType());
for (final ParamInfo paramInfo : endpoint.pathParams()) {
paramList.add(paramInfo.name() + ": " + paramInfo.tsType());
}
final List<ParamInfo> queryParams = endpoint.queryParams();
for (int i = 0; i < queryParams.size(); i++) {
final ParamInfo p = queryParams.get(i);
if (p.optional() && p.defaultValue() != null) {
paramList.add(p.name() + " = " + tsDefaultValue(p.defaultValue(), p.tsType()));
} else if (p.optional()) {
// Use `name: type | undefined` instead of `name?: type` when a required param follows,
// because TypeScript forbids required params after optional params (TS1016).
final ParamInfo paramInfo = queryParams.get(i);
if (paramInfo.optional() && paramInfo.defaultValue() != null) {
paramList.add(paramInfo.name() + " = " + tsDefaultValue(paramInfo.defaultValue(), paramInfo.tsType()));
} else if (paramInfo.optional()) {
final boolean requiredParamFollows = endpoint.bodyParam() != null
|| queryParams.subList(i + 1, queryParams.size()).stream().anyMatch(q -> !q.optional());
if (requiredParamFollows) {
paramList.add(p.name() + ": " + p.tsType() + " | undefined");
paramList.add(paramInfo.name() + ": " + paramInfo.tsType() + " | undefined");
} else {
paramList.add(p.name() + "?: " + p.tsType());
paramList.add(paramInfo.name() + "?: " + paramInfo.tsType());
}
} else {
paramList.add(p.name() + ": " + p.tsType());
paramList.add(paramInfo.name() + ": " + paramInfo.tsType());
}
}
if (endpoint.bodyParam() != null) {
@@ -616,9 +625,9 @@ public class TypeScriptClientGenerator {
final String urlExpr;
if (hasQuery) {
final String qpNames = endpoint.queryParams().stream()
final String queryParamNames = endpoint.queryParams().stream()
.map(ParamInfo::name).collect(Collectors.joining(", "));
urlExpr = "`" + tsPath + "${buildQuery({ " + qpNames + " })}`";
urlExpr = "`" + tsPath + "${buildQuery({ " + queryParamNames + " })}`";
} else if (hasTemplate) {
urlExpr = "`" + tsPath + "`";
} else {
@@ -642,9 +651,6 @@ public class TypeScriptClientGenerator {
// ── hooks.ts ──────────────────────────────────────────────────────────────
private static String generateHooks(final List<ResourceInfo> resources, final Set<Class<?>> allTypes) {
// Only collect type names from mutation endpoints: query hooks infer their return type
// from queryFn, so they never reference Do types by name in the generated code.
// Including GET return types causes unused-import warnings (e.g. AthleteDo).
final Set<String> neededTsTypeNames = new LinkedHashSet<>();
for (final ResourceInfo resource : resources) {
for (final EndpointInfo endpoint : resource.endpoints()) {
@@ -657,13 +663,13 @@ public class TypeScriptClientGenerator {
}
}
final StringBuilder sb = new StringBuilder();
sb.append("// GENERATED — do not edit manually.\n\n");
sb.append("import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\n");
final StringBuilder stringBuilder = new StringBuilder();
stringBuilder.append("// GENERATED — do not edit manually.\n\n");
stringBuilder.append("import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';\n");
for (final ResourceInfo resource : resources) {
final String clientFile = "./" + resource.resourceClass().getSimpleName().replace("Resource", "") + "Client";
sb.append("import { ").append(resource.resourceKey()).append(" } from '").append(clientFile).append("';\n");
stringBuilder.append("import { ").append(resource.resourceKey()).append(" } from '").append(clientFile).append("';\n");
}
final Set<String> typeImports = new LinkedHashSet<>();
@@ -671,45 +677,45 @@ public class TypeScriptClientGenerator {
collectImportsFromTsTypeName(tsTypeName, allTypes, typeImports, "../types/");
}
for (final String typeImport : typeImports) {
sb.append(typeImport).append("\n");
stringBuilder.append(typeImport).append("\n");
}
sb.append("\n");
stringBuilder.append("\n");
sb.append("export const queryKeys = {\n");
stringBuilder.append("export const queryKeys = {\n");
for (final ResourceInfo resource : resources) {
final List<EndpointInfo> gets = resource.endpoints().stream()
.filter(e -> e.httpMethod().equals("GET")).collect(Collectors.toList());
if (gets.isEmpty()) continue;
sb.append(" ").append(resource.resourceKey()).append(": {\n");
for (final EndpointInfo endpoint : gets) {
final List<EndpointInfo> getEndpoints = resource.endpoints().stream()
.filter(endpoint -> endpoint.httpMethod().equals("GET")).collect(Collectors.toList());
if (getEndpoints.isEmpty()) continue;
stringBuilder.append(" ").append(resource.resourceKey()).append(": {\n");
for (final EndpointInfo endpoint : getEndpoints) {
final List<ParamInfo> allParams = new ArrayList<>();
allParams.addAll(endpoint.pathParams());
allParams.addAll(endpoint.queryParams());
final String paramSig = allParams.stream()
.map(p -> p.name() + (p.optional() ? "?" : "") + ": " + p.tsType())
.map(paramInfo -> paramInfo.name() + (paramInfo.optional() ? "?" : "") + ": " + paramInfo.tsType())
.collect(Collectors.joining(", "));
final String keyItems = allParams.stream().map(ParamInfo::name).collect(Collectors.joining(", "));
final String keyArray = "'" + resource.resourceKey() + "', '" + endpoint.functionName() + "'"
+ (keyItems.isEmpty() ? "" : ", " + keyItems);
sb.append(" ").append(endpoint.functionName()).append(": (")
stringBuilder.append(" ").append(endpoint.functionName()).append(": (")
.append(paramSig).append(") => [").append(keyArray).append("] as const,\n");
}
sb.append(" },\n");
stringBuilder.append(" },\n");
}
sb.append("};\n\n");
stringBuilder.append("};\n\n");
for (final ResourceInfo resource : resources) {
sb.append("// ── ").append(resource.resourceKey()).append(" ────────────────────────────────────\n\n");
stringBuilder.append("// ── ").append(resource.resourceKey()).append(" ────────────────────────────────────\n\n");
for (final EndpointInfo endpoint : resource.endpoints()) {
if (endpoint.httpMethod().equals("GET")) {
sb.append(generateQueryHook(endpoint, resource));
stringBuilder.append(generateQueryHook(endpoint, resource));
} else {
sb.append(generateMutationHook(endpoint, resource));
stringBuilder.append(generateMutationHook(endpoint, resource));
}
}
}
return sb.toString();
return stringBuilder.toString();
}
private static String generateQueryHook(final EndpointInfo endpoint, final ResourceInfo resource) {
@@ -718,13 +724,13 @@ public class TypeScriptClientGenerator {
allParams.addAll(endpoint.pathParams());
allParams.addAll(endpoint.queryParams());
final String paramSig = allParams.stream().map(p -> {
if (p.optional() && p.defaultValue() != null) {
return p.name() + " = " + tsDefaultValue(p.defaultValue(), p.tsType());
} else if (p.optional()) {
return p.name() + "?: " + p.tsType();
final String paramSig = allParams.stream().map(paramInfo -> {
if (paramInfo.optional() && paramInfo.defaultValue() != null) {
return paramInfo.name() + " = " + tsDefaultValue(paramInfo.defaultValue(), paramInfo.tsType());
} else if (paramInfo.optional()) {
return paramInfo.name() + "?: " + paramInfo.tsType();
}
return p.name() + ": " + p.tsType();
return paramInfo.name() + ": " + paramInfo.tsType();
}).collect(Collectors.joining(", "));
final String queryKeyArgs = allParams.stream().map(ParamInfo::name).collect(Collectors.joining(", "));
@@ -764,7 +770,7 @@ public class TypeScriptClientGenerator {
destructure = single.name();
} else {
variablesType = "{ " + mutationParams.stream()
.map(p -> p.name() + (p.optional() ? "?" : "") + ": " + p.tsType())
.map(paramInfo -> paramInfo.name() + (paramInfo.optional() ? "?" : "") + ": " + paramInfo.tsType())
.collect(Collectors.joining("; ")) + " }";
destructure = "{ " + mutationParams.stream()
.map(ParamInfo::name).collect(Collectors.joining(", ")) + " }";
@@ -818,24 +824,20 @@ public class TypeScriptClientGenerator {
}
private static boolean isJsonIgnored(final Field field) {
final JsonIgnore ann = field.getAnnotation(JsonIgnore.class);
return ann != null && ann.value();
final JsonIgnore annotation = field.getAnnotation(JsonIgnore.class);
return annotation != null && annotation.value();
}
private static boolean isNullable(final Annotation[] annotations) {
for (final Annotation ann : annotations) {
if (ann.annotationType().getSimpleName().equals("Nullable")) return true;
for (final Annotation annotation : annotations) {
if (annotation.annotationType().getSimpleName().equals("Nullable")) return true;
}
return false;
}
private static boolean isNullableField(final Field field) {
return isNullable(field.getAnnotations());
}
private static boolean isJaxRsSpecialParam(final Annotation[] annotations) {
for (final Annotation ann : annotations) {
final String name = ann.annotationType().getSimpleName();
for (final Annotation annotation : annotations) {
final String name = annotation.annotationType().getSimpleName();
if (name.equals("Context") || name.equals("HeaderParam")
|| name.equals("CookieParam") || name.equals("FormParam")
|| name.equals("BeanParam") || name.equals("Suspended")) {
@@ -847,8 +849,8 @@ public class TypeScriptClientGenerator {
@SuppressWarnings("unchecked")
private static <A extends Annotation> A getAnnotation(final Annotation[] annotations, final Class<A> type) {
for (final Annotation ann : annotations) {
if (type.isInstance(ann)) return (A) ann;
for (final Annotation annotation : annotations) {
if (type.isInstance(annotation)) return (A) annotation;
}
return null;
}
@@ -863,8 +865,8 @@ public class TypeScriptClientGenerator {
private static String buildApiCallArgs(final EndpointInfo endpoint) {
final List<String> args = new ArrayList<>();
endpoint.pathParams().forEach(p -> args.add(p.name()));
endpoint.queryParams().forEach(p -> args.add(p.name()));
endpoint.pathParams().forEach(paramInfo -> args.add(paramInfo.name()));
endpoint.queryParams().forEach(paramInfo -> args.add(paramInfo.name()));
if (endpoint.bodyParam() != null) args.add(endpoint.bodyParam().name());
return String.join(", ", args);
}
@@ -881,18 +883,18 @@ public class TypeScriptClientGenerator {
private static String kebabToCamel(final String kebab) {
final String[] parts = kebab.split("-");
final StringBuilder sb = new StringBuilder(parts[0]);
final StringBuilder stringBuilder = new StringBuilder(parts[0]);
for (int i = 1; i < parts.length; i++) {
if (!parts[i].isEmpty()) {
sb.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
stringBuilder.append(Character.toUpperCase(parts[i].charAt(0))).append(parts[i].substring(1));
}
}
return sb.toString();
return stringBuilder.toString();
}
private static String capitalize(final String s) {
if (s == null || s.isEmpty()) return s;
return Character.toUpperCase(s.charAt(0)) + s.substring(1);
private static String capitalize(final String value) {
if (value == null || value.isEmpty()) return value;
return Character.toUpperCase(value.charAt(0)) + value.substring(1);
}
private static String pathToTsTemplate(final String path) {
@@ -919,7 +921,7 @@ public class TypeScriptClientGenerator {
try {
Double.parseDouble(javaDefault);
return javaDefault;
} catch (final NumberFormatException e) {
} catch (final NumberFormatException exception) {
return "0";
}
}
@@ -929,9 +931,9 @@ public class TypeScriptClientGenerator {
return "'" + javaDefault + "'";
}
private static void writeFile(final Path path, final String content) throws IOException {
private void writeFile(final Path path, final String content) throws IOException {
Files.writeString(path, content, StandardCharsets.UTF_8);
System.out.println("Written: " + path.getFileName());
getLog().info("Written: " + path.getFileName());
}
// ── Records ───────────────────────────────────────────────────────────────

View File

@@ -0,0 +1,86 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by maven-plugin-tools 3.15 (for help mojo with limited elements)-->
<plugin>
<name>peakup-maven</name>
<description></description>
<groupId>com.peakup</groupId>
<artifactId>peakup-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<goalPrefix>peakup</goalPrefix>
<mojos>
<mojo>
<goal>generate-package-info</goal>
<description>Generates package-info.java files with the @NonNull annotation for all packages that contain Java source files.</description>
<requiresDirectInvocation>false</requiresDirectInvocation>
<requiresProject>true</requiresProject>
<requiresReports>false</requiresReports>
<aggregator>false</aggregator>
<requiresOnline>false</requiresOnline>
<inheritedByDefault>true</inheritedByDefault>
<phase>generate-sources</phase>
<implementation>com.peakup.maven.PackageInfoGeneratorMojo</implementation>
<language>java</language>
<instantiationStrategy>per-lookup</instantiationStrategy>
<executionStrategy>once-per-session</executionStrategy>
<threadSafe>true</threadSafe>
<parameters>
<parameter>
<name>sourceDirectory</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
</parameters>
<configuration>
<sourceDirectory implementation="java.io.File" default-value="${project.build.sourceDirectory}"/>
</configuration>
</mojo>
<mojo>
<goal>generate-typescript</goal>
<description>Generates TypeScript REST client and entity types from peakup-backend REST resources.
Scans all compiled classes extending AbstractResource, reads JAX-RS annotations and RestResponse&lt;T&gt; return types via reflection, then writes into the frontend:
* httpClient.ts — shared fetch helpers
* {EntityDo.ts} — one interface per domain entity
* {Name.ts} — union type per enum
* {ResourceClient.ts} — one client file per JAX-RS resource
* hooks.ts — TanStack Query hooks </description>
<requiresDependencyResolution>compile</requiresDependencyResolution>
<requiresDirectInvocation>false</requiresDirectInvocation>
<requiresProject>true</requiresProject>
<requiresReports>false</requiresReports>
<aggregator>false</aggregator>
<requiresOnline>false</requiresOnline>
<inheritedByDefault>true</inheritedByDefault>
<phase>process-classes</phase>
<implementation>com.peakup.maven.TypeScriptClientGeneratorMojo</implementation>
<language>java</language>
<instantiationStrategy>per-lookup</instantiationStrategy>
<executionStrategy>once-per-session</executionStrategy>
<threadSafe>true</threadSafe>
<parameters>
<parameter>
<name>outputDirApi</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
<parameter>
<name>outputDirTypes</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
</parameters>
<configuration>
<outputDirApi implementation="java.io.File" default-value="${project.basedir}/../peakup-frontend/src/api"/>
<outputDirTypes implementation="java.io.File" default-value="${project.basedir}/../peakup-frontend/src/types"/>
</configuration>
</mojo>
</mojos>
</plugin>

View File

@@ -0,0 +1,112 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by maven-plugin-tools 3.15-->
<plugin>
<name>peakup-maven</name>
<description></description>
<groupId>com.peakup</groupId>
<artifactId>peakup-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<goalPrefix>peakup</goalPrefix>
<isolatedRealm>false</isolatedRealm>
<inheritedByDefault>true</inheritedByDefault>
<requiredJavaVersion>21</requiredJavaVersion>
<requiredMavenVersion>3.9.9</requiredMavenVersion>
<mojos>
<mojo>
<goal>generate-package-info</goal>
<description>Generates package-info.java files with the @NonNull annotation for all packages that contain Java source files.</description>
<requiresDirectInvocation>false</requiresDirectInvocation>
<requiresProject>true</requiresProject>
<requiresReports>false</requiresReports>
<aggregator>false</aggregator>
<requiresOnline>false</requiresOnline>
<inheritedByDefault>true</inheritedByDefault>
<phase>generate-sources</phase>
<implementation>com.peakup.maven.PackageInfoGeneratorMojo</implementation>
<language>java</language>
<instantiationStrategy>per-lookup</instantiationStrategy>
<executionStrategy>once-per-session</executionStrategy>
<threadSafe>true</threadSafe>
<parameters>
<parameter>
<name>sourceDirectory</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
</parameters>
<configuration>
<sourceDirectory implementation="java.io.File" default-value="${project.build.sourceDirectory}"/>
</configuration>
</mojo>
<mojo>
<goal>generate-typescript</goal>
<description>Generates TypeScript REST client and entity types from peakup-backend REST resources.
Scans all compiled classes extending AbstractResource, reads JAX-RS annotations and RestResponse&lt;T&gt; return types via reflection, then writes into the frontend:
* httpClient.ts — shared fetch helpers
* {EntityDo.ts} — one interface per domain entity
* {Name.ts} — union type per enum
* {ResourceClient.ts} — one client file per JAX-RS resource
* hooks.ts — TanStack Query hooks </description>
<requiresDependencyResolution>compile</requiresDependencyResolution>
<requiresDirectInvocation>false</requiresDirectInvocation>
<requiresProject>true</requiresProject>
<requiresReports>false</requiresReports>
<aggregator>false</aggregator>
<requiresOnline>false</requiresOnline>
<inheritedByDefault>true</inheritedByDefault>
<phase>process-classes</phase>
<implementation>com.peakup.maven.TypeScriptClientGeneratorMojo</implementation>
<language>java</language>
<instantiationStrategy>per-lookup</instantiationStrategy>
<executionStrategy>once-per-session</executionStrategy>
<threadSafe>true</threadSafe>
<parameters>
<parameter>
<name>outputDirApi</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
<parameter>
<name>outputDirTypes</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
<parameter>
<name>project</name>
<type>org.apache.maven.project.MavenProject</type>
<required>true</required>
<editable>false</editable>
<description></description>
</parameter>
</parameters>
<configuration>
<outputDirApi implementation="java.io.File" default-value="${project.basedir}/../peakup-frontend/src/api"/>
<outputDirTypes implementation="java.io.File" default-value="${project.basedir}/../peakup-frontend/src/types"/>
<project implementation="org.apache.maven.project.MavenProject" default-value="${project}"/>
</configuration>
</mojo>
</mojos>
<dependencies>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<type>jar</type>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<type>jar</type>
<version>2.18.3</version>
</dependency>
</dependencies>
</plugin>

View File

@@ -0,0 +1,3 @@
artifactId=peakup-maven
groupId=com.peakup
version=1.0-SNAPSHOT

View File

@@ -0,0 +1,5 @@
com/peakup/maven/TypeScriptClientGeneratorMojo.class
com/peakup/maven/TypeScriptClientGeneratorMojo$ResourceInfo.class
com/peakup/maven/PackageInfoGeneratorMojo.class
com/peakup/maven/TypeScriptClientGeneratorMojo$ParamInfo.class
com/peakup/maven/TypeScriptClientGeneratorMojo$EndpointInfo.class

View File

@@ -0,0 +1,2 @@
/home/ingo/deve/peakUp/peakup-maven/src/main/java/com/peakup/maven/PackageInfoGeneratorMojo.java
/home/ingo/deve/peakUp/peakup-maven/src/main/java/com/peakup/maven/TypeScriptClientGeneratorMojo.java

View File

@@ -0,0 +1,113 @@
<?xml version="1.0" encoding="UTF-8"?>
<!-- Generated by maven-plugin-tools 3.15 (enhanced XHTML version (used for plugin:report))-->
<plugin>
<name>peakup-maven</name>
<description></description>
<groupId>com.peakup</groupId>
<artifactId>peakup-maven</artifactId>
<version>1.0-SNAPSHOT</version>
<goalPrefix>peakup</goalPrefix>
<isolatedRealm>false</isolatedRealm>
<inheritedByDefault>true</inheritedByDefault>
<requiredJavaVersion>21</requiredJavaVersion>
<requiredMavenVersion>3.9.9</requiredMavenVersion>
<mojos>
<mojo>
<goal>generate-package-info</goal>
<description>Generates package-info.java files with the @NonNull annotation for all packages that contain Java source files.</description>
<requiresDirectInvocation>false</requiresDirectInvocation>
<requiresProject>true</requiresProject>
<requiresReports>false</requiresReports>
<aggregator>false</aggregator>
<requiresOnline>false</requiresOnline>
<inheritedByDefault>true</inheritedByDefault>
<phase>generate-sources</phase>
<implementation>com.peakup.maven.PackageInfoGeneratorMojo</implementation>
<language>java</language>
<instantiationStrategy>per-lookup</instantiationStrategy>
<executionStrategy>once-per-session</executionStrategy>
<threadSafe>true</threadSafe>
<parameters>
<parameter>
<name>sourceDirectory</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
</parameters>
<configuration>
<sourceDirectory implementation="java.io.File" default-value="${project.build.sourceDirectory}"/>
</configuration>
</mojo>
<mojo>
<goal>generate-typescript</goal>
<description>Generates TypeScript REST client and entity types from peakup-backend REST resources.
&lt;p&gt;Scans all compiled classes extending &lt;code&gt;AbstractResource&lt;/code&gt;, reads JAX-RS annotations and &lt;code&gt;RestResponse&amp;lt;T&amp;gt;&lt;/code&gt; return types via reflection, then writes into the frontend:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;httpClient.ts&lt;/code&gt; — shared fetch helpers&lt;/li&gt;
&lt;li&gt;&lt;code&gt;{Entity&lt;/code&gt;Do.ts} — one interface per domain entity&lt;/li&gt;
&lt;li&gt;&lt;code&gt;{Name&lt;/code&gt;.ts} — union type per enum&lt;/li&gt;
&lt;li&gt;&lt;code&gt;{Resource&lt;/code&gt;Client.ts} — one client file per JAX-RS resource&lt;/li&gt;
&lt;li&gt;&lt;code&gt;hooks.ts&lt;/code&gt; — TanStack Query hooks&lt;/li&gt;
&lt;/ul&gt;</description>
<requiresDependencyResolution>compile</requiresDependencyResolution>
<requiresDirectInvocation>false</requiresDirectInvocation>
<requiresProject>true</requiresProject>
<requiresReports>false</requiresReports>
<aggregator>false</aggregator>
<requiresOnline>false</requiresOnline>
<inheritedByDefault>true</inheritedByDefault>
<phase>process-classes</phase>
<implementation>com.peakup.maven.TypeScriptClientGeneratorMojo</implementation>
<language>java</language>
<instantiationStrategy>per-lookup</instantiationStrategy>
<executionStrategy>once-per-session</executionStrategy>
<threadSafe>true</threadSafe>
<parameters>
<parameter>
<name>outputDirApi</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
<parameter>
<name>outputDirTypes</name>
<type>java.io.File</type>
<required>true</required>
<editable>true</editable>
<description></description>
</parameter>
<parameter>
<name>project</name>
<type>org.apache.maven.project.MavenProject</type>
<required>true</required>
<editable>false</editable>
<description></description>
</parameter>
</parameters>
<configuration>
<outputDirApi implementation="java.io.File" default-value="${project.basedir}/../peakup-frontend/src/api"/>
<outputDirTypes implementation="java.io.File" default-value="${project.basedir}/../peakup-frontend/src/types"/>
<project implementation="org.apache.maven.project.MavenProject" default-value="${project}"/>
</configuration>
</mojo>
</mojos>
<dependencies>
<dependency>
<groupId>jakarta.ws.rs</groupId>
<artifactId>jakarta.ws.rs-api</artifactId>
<type>jar</type>
<version>3.1.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-annotations</artifactId>
<type>jar</type>
<version>2.18.3</version>
</dependency>
</dependencies>
</plugin>

View File

@@ -1,79 +0,0 @@
package com.peakup.tools.generator;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.nio.file.StandardOpenOption;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import java.util.stream.StreamSupport;
/**
* Utility zur automatischen Generierung von package-info.java Dateien
* in bestehenden Package-Strukturen.
*/
public class PackageInfoGenerator {
private static final String SRC_BASE = "peakup-backend/src/main/java"; // Pfad anpassen falls nötig
private static final String PACKAGE_PREFIX = "com.peakup";
private static final String ANNOTATION = "@com.peakup.common.annotation.NonNull";
public static void main(String[] args) {
Path projectRoot = Paths.get(System.getProperty("user.dir"));
Path sourceRoot = projectRoot.resolve(SRC_BASE).toAbsolutePath().normalize();
if (!Files.exists(sourceRoot)) {
System.err.println("Source Root nicht gefunden: " + sourceRoot);
return;
}
try (Stream<Path> stream = Files.walk(sourceRoot)) {
stream.filter(Files::isDirectory)
.forEach(dir -> processDirectory(sourceRoot, dir));
} catch (IOException e) {
e.printStackTrace();
}
}
private static void processDirectory(Path sourceRoot, Path currentDir) {
try (Stream<Path> files = Files.list(currentDir)) {
// Nur Verzeichnisse mit .java Dateien (außer package-info selbst)
boolean hasJavaFiles = files.anyMatch(p ->
p.toString().endsWith(".java") && !p.getFileName().toString().equals("package-info.java")
);
if (hasJavaFiles) {
String packageName = determinePackageName(sourceRoot, currentDir);
createPackageInfo(currentDir, packageName);
}
} catch (IOException e) {
System.err.println("Fehler in " + currentDir + ": " + e.getMessage());
}
}
private static String determinePackageName(Path sourceRoot, Path currentDir) {
// Erzeugt den relativen Pfad von src/main/java zum aktuellen Ordner
Path relativePath = sourceRoot.relativize(currentDir);
// Verbindet die Pfad-Elemente mit Punkten (plattformunabhängig)
String pkg = StreamSupport.stream(relativePath.spliterator(), false)
.map(Path::toString)
.collect(Collectors.joining("."));
return pkg.isEmpty() ? null : pkg;
}
private static void createPackageInfo(Path dir, String packageName) throws IOException {
Path file = dir.resolve("package-info.java");
if (Files.exists(file)) {
// System.out.println("Überspringe (existiert bereits): " + file);
return;
}
String content = String.format("%s\npackage %s;", ANNOTATION, packageName);
Files.writeString(file, content, StandardOpenOption.CREATE_NEW);
System.out.println("Erstellt: " + file.toAbsolutePath());
}
}

View File

@@ -10,6 +10,7 @@
<packaging>pom</packaging>
<modules>
<module>peakup-maven</module>
<module>peakup-backend</module>
<module>peakup-tools</module>
</modules>