commit 0c9ad846f4819bbd22c1b92737d546622fb6725c Author: Thilo Schwarz Date: Sun Jul 12 13:43:37 2026 +0200 Initial commit diff --git a/.gitea/workflows/deploy-image.yml b/.gitea/workflows/deploy-image.yml new file mode 100644 index 0000000..c3876ad --- /dev/null +++ b/.gitea/workflows/deploy-image.yml @@ -0,0 +1,73 @@ +name: Build and Push Docker Image + +on: + push: + tags: + - 'v*' + branches: + - develop + - 'feature/*' + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + permissions: + contents: read + packages: write + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Compute image name and tag + id: compute-tag + run: | + REGISTRY=$(echo "${{ github.server_url }}" | sed 's|https\?://||') + OWNER=$(echo "${{ github.repository_owner }}" | tr '[:upper:]' '[:lower:]') + ACTOR=$(echo "${{ github.actor }}" | tr '[:upper:]' '[:lower:]') + IMAGE_NAME="javadocviewerservice" + FULL_IMAGE="${REGISTRY}/${OWNER}/${IMAGE_NAME}" + + REF="${{ github.ref_name }}" + if [[ "${REF}" == v* ]]; then + TAG="${REF#v}" + TAGS="${FULL_IMAGE}:${TAG},${FULL_IMAGE}:latest" + else + TAG=$(echo "${REF}" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9._-]/-/g') + TAGS="${FULL_IMAGE}:${TAG}" + fi + + echo "Registry: ${REGISTRY}" + echo "Actor: ${ACTOR}" + echo "Tags: ${TAGS}" + + echo "registry=${REGISTRY}" >> "$GITHUB_OUTPUT" + echo "actor=${ACTOR}" >> "$GITHUB_OUTPUT" + echo "image=${FULL_IMAGE}" >> "$GITHUB_OUTPUT" + echo "tag=${TAG}" >> "$GITHUB_OUTPUT" + echo "tags=${TAGS}" >> "$GITHUB_OUTPUT" + + - name: Network Debug + run: | + curl -v https://git.mein-gateway.de/v2/ + + - name: Debug Secret + run: | + echo "Secret length: ${#TOKEN}" + env: + TOKEN: ${{ secrets.REGISTRY_TOKEN }} + + - name: Log in to Gitea Container Registry + uses: docker/login-action@v3 + with: + registry: git.mein-gateway.de + username: thischwa + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: ${{ steps.compute-tag.outputs.tags }} \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8c3bdd8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,43 @@ +target/ +!.mvn/wrapper/maven-wrapper.jar +!**/src/main/**/target/ +!**/src/test/**/target/ +.kotlin + +### IntelliJ IDEA ### +.idea/ +*.iws +*.iml +*.ipr + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ +build/ +!**/src/main/**/build/ +!**/src/test/**/build/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store + +## local ## +/database/ +/javadoc-storage/ +/jdvs.yml +/.claude/ +/info.txt diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..dd8ebd9 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,33 @@ +# JavadocViewerService + +## Tech Stack + +- **Java 21** +- **Spring Boot 4.x** (spring-boot-starter-parent) + - Spring MVC (spring-boot-starter-web) + - Spring Data JPA (spring-boot-starter-data-jpa) + - Thymeleaf (spring-boot-starter-thymeleaf) + - Bean Validation (spring-boot-starter-validation) + - Liquibase (spring-boot-starter-liquibase) +- **H2** — file-based embedded database (`./database/jdvsdb`) +- **Lombok** — boilerplate reduction (`@Data`, `@RequiredArgsConstructor`, `@Slf4j`) +- **JGit 7.x** — Git operations (clone, fetch, tag resolution) +- **Jackson YAML** — parsing of `jdvs.yml` config file +- **Maven** — build tool + +## Configuration + +- `application.yml` — base configuration (datasource, JPA, default jdvs properties) +- `jdvs.yml` — external runtime config (repositories, property overrides); imported via `spring.config.import` + +## Package Structure + +- `codes.thischwa.jdvs.config` — `@ConfigurationProperties` beans +- `codes.thischwa.jdvs.service` — business logic (Git, Javadoc generation, scheduling) +- `codes.thischwa.jdvs.jpa` — Spring Data repositories +- `codes.thischwa.jdvs.model` — JPA entities +- `codes.thischwa.jdvs.web` — MVC controllers and `WebMvcConfigurer` + +## Coding + +The generated code follows the principles of clean architecture and follows the SOLID principles. It is designed to be modular, testable, and maintainable. The code is written in a way that promotes readability and reduces complexity. It uses modern Java features and best practices to ensure efficient and reliable operation. To formate the code use che checkstyle definition in [google_custom_checks.xml](src/checkstyle/google_custom_checks.xml). Each changing of code must be verified with compiling. \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..7289a41 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,32 @@ +# Stage 1: Build the JAR using Maven +FROM maven:3.9-eclipse-temurin-21 AS builder + +LABEL org.opencontainers.image.description="JavadocViewerService serves and manages Javadoc documentation from Git repositories." + +WORKDIR /build + +# Copy pom.xml and download dependencies first (for Docker cache) +COPY pom.xml . +RUN mvn dependency:go-offline + +# Copy the full source tree and build the application +COPY src ./src +RUN mvn clean package -DskipTests + +# Stage 2: Minimal runtime image +FROM eclipse-temurin:21-jdk-jammy + +WORKDIR /app + +# Install tini and Maven +RUN apt-get update && apt-get install -y tini maven && rm -rf /var/lib/apt/lists/* + +# Copy the built jar from the builder stage +COPY --from=builder /build/target/*.jar /app/jvs.jar + +VOLUME ["/app/javadoc-storage", "/app/database"] + +EXPOSE 8080 + +ENTRYPOINT ["/usr/bin/tini", "--"] +CMD ["java", "-jar", "jvs.jar"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..a4e9dc9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,16 @@ +MIT No Attribution + +Copyright + +Permission is hereby granted, free of charge, to any person obtaining a copy of this +software and associated documentation files (the "Software"), to deal in the Software +without restriction, including without limitation the rights to use, copy, modify, +merge, publish, distribute, sublicense, and/or sell copies of the Software, and to +permit persons to whom the Software is furnished to do so. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, +INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A +PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT +HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION +OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE +SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a31c581 --- /dev/null +++ b/README.md @@ -0,0 +1,77 @@ +# JavadocViewerService + +## Preface + +This project Spring Boot web service that automatically fetches, manages, and serves Javadoc documentation for Maven artifacts. + +If you encounter any bugs or find missing features, feel free to report them on +the [GitHub Issues page](https://github.com/th-schwarz/JavadocViewerService/issues). + +## Features + +- Retrieves Javadoc JARs from Maven Central or custom Maven repositories +- Tracks multiple Maven artefacts with version detection (only the latest version is taken into account). +- Delivers generated Javadoc content via a simple bootstrap web interface +- Scheduled updates via a configurable cron expression + +## Requirements + +At least JRE-21 or docker + +## Start +### ... with Java + +```bash +java -jar jdvc-.jar --spring.config.import=file:./jdvs.yml +``` + +### .. with Docker + +```yaml +services: + jvs-dockerized: + image: git.mein-gateway.de/thischwa/javadocviewerservice:develop + volumes: + - /opt/javadocviewerservice-dockerized/jdvs.yml:/app/jdvs.yml:ro + - /opt/javadocviewerservice-dockerized/database:/app/database + - /opt/javadocviewerservice-dockerized/javadoc-storage:/app/javadoc-storage + restart: unless-stopped + ports: + - "127.0.0.1:8086:8080" +``` + +Volume mapping: + +| Mount | Purpose | +|---|---| +| `jdvs.yml:/app/jdvs.yml:ro` | Injects the repository configuration (which artifacts to track) as read-only — the only file you need to edit | +| `database:/app/database` | Persists the H2 database so tracked version metadata survives restarts and image updates | +| `javadoc-storage:/app/javadoc-storage` | Persists generated Javadoc HTML so it is not lost on container recreation | + + +## Configuration + +### `application.yml` + +Key defaults: + +| Property | Default | Description | +|---|----------------------------------|------------------------------------| +| `jdvs.base-dir` | `./javadoc-storage` | Storage root for generated Javadoc | +| `jdvs.cron` | `0 0/30 * * * ?` | Update schedule (every 30 min) | +| `jdvs.clean-on-start` | `false` | Wipe storage and DB on startup | +| `jdvs.run-on-start` | `false` | Run update immediately on startup | +| `jdvs.maven-central-url` | `https://repo1.maven.org/maven2` | Default Maven repository | + +### `jdvs.yml` + +Defines the repositories to track and individual settings (all key defaults can be overridden): + +```yaml +jdvs: + repositories: + - name: my-lib + group-id: com.example + artifact-id: my-lib + # maven-repo-url: https://custom.repo/maven # optional, defaults to Maven Central +``` diff --git a/pom.xml b/pom.xml new file mode 100644 index 0000000..f7873d0 --- /dev/null +++ b/pom.xml @@ -0,0 +1,144 @@ + + + 4.0.0 + + codes.thischwa + jdvc + 0.2.0-SNAPSHOT + + JavadocViewerService + + + org.springframework.boot + spring-boot-starter-parent + 4.0.5 + + + + + 21 + ${java.version} + ${java.version} + UTF-8 + + + + https://git.mein-gateway.de/thischwa/JavadocViewerService/issues + Gitea Issues + + + + scm:git:https://git.mein-gateway.de/thischwa/JavadocViewerService.git + scm:git:https://git.mein-gateway.de/thischwa/JavadocViewerService.git + https://git.mein-gateway.de/thischwa/JavadocViewerService + HEAD + + + + + mygitea + https://git.mein-gateway.de/api/packages/thischwa/maven + + true + + + + mygitea + https://git.mein-gateway.de/api/packages/thischwa/maven + + true + + + + + + + org.springframework.boot + spring-boot-starter-web + + + org.springframework.boot + spring-boot-starter-thymeleaf + + + org.springframework.boot + spring-boot-starter-data-jpa + + + org.springframework.boot + spring-boot-starter-validation + + + org.springframework.boot + spring-boot-starter-liquibase + + + com.h2database + h2 + runtime + + + org.projectlombok + lombok + true + + + org.eclipse.jgit + org.eclipse.jgit + 7.2.1.202505142326-r + + + com.fasterxml.jackson.dataformat + jackson-dataformat-yaml + + + org.springframework.boot + spring-boot-starter-test + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + + + + org.projectlombok + lombok + + + + + + org.apache.maven.plugins + maven-source-plugin + + + attach-sources + + jar-no-fork + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + org.projectlombok + lombok + + + + + + + + \ No newline at end of file diff --git a/src/checkstyle/google_custom_checks.xml b/src/checkstyle/google_custom_checks.xml new file mode 100644 index 0000000..1ef6e6e --- /dev/null +++ b/src/checkstyle/google_custom_checks.xml @@ -0,0 +1,484 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/main/java/codes/thischwa/jdvs/JdvsApplication.java b/src/main/java/codes/thischwa/jdvs/JdvsApplication.java new file mode 100644 index 0000000..ea933f4 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/JdvsApplication.java @@ -0,0 +1,21 @@ +package codes.thischwa.jdvs; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.scheduling.annotation.EnableScheduling; + +@SpringBootApplication +@EnableScheduling +@Slf4j +public class JdvsApplication { + public static void main(String[] args) { + + try { + SpringApplication.run(JdvsApplication.class, args); + } catch (Exception e) { + log.error("Unexpected exception, Spring Boot stops! Message: {}", e.getMessage()); + System.exit(10); + } + } +} diff --git a/src/main/java/codes/thischwa/jdvs/config/RepoConfigLoader.java b/src/main/java/codes/thischwa/jdvs/config/RepoConfigLoader.java new file mode 100644 index 0000000..e01304e --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/config/RepoConfigLoader.java @@ -0,0 +1,29 @@ +package codes.thischwa.jdvs.config; + +import codes.thischwa.jdvs.model.config.JdvsConfig; +import codes.thischwa.jdvs.jpa.GitRepositoryRepository; +import codes.thischwa.jdvs.model.GitRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class RepoConfigLoader { + private final JdvsConfig jdvsConfig; + private final GitRepositoryRepository repository; + + public void loadConfig() { + if (jdvsConfig.getRepositories() != null) { + for (JdvsConfig.RepoConfig repoCfg : jdvsConfig.getRepositories()) { + if (repository.findByName(repoCfg.getName()).isEmpty()) { + GitRepository repo = new GitRepository(); + repo.setName(repoCfg.getName()); + repository.save(repo); + log.info("Repository {} added from configuration.", repoCfg.getName()); + } + } + } + } +} diff --git a/src/main/java/codes/thischwa/jdvs/jpa/GitRepositoryRepository.java b/src/main/java/codes/thischwa/jdvs/jpa/GitRepositoryRepository.java new file mode 100644 index 0000000..51bb1a6 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/jpa/GitRepositoryRepository.java @@ -0,0 +1,9 @@ +package codes.thischwa.jdvs.jpa; + +import codes.thischwa.jdvs.model.GitRepository; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface GitRepositoryRepository extends JpaRepository { + Optional findByName(String name); +} diff --git a/src/main/java/codes/thischwa/jdvs/model/GitRepository.java b/src/main/java/codes/thischwa/jdvs/model/GitRepository.java new file mode 100644 index 0000000..9af0c26 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/model/GitRepository.java @@ -0,0 +1,24 @@ +package codes.thischwa.jdvs.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import java.time.LocalDateTime; +import lombok.Data; + +@Entity +@Data +public class GitRepository { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String name; + + private String lastTag; + + private LocalDateTime updated; +} diff --git a/src/main/java/codes/thischwa/jdvs/model/config/AppConfig.java b/src/main/java/codes/thischwa/jdvs/model/config/AppConfig.java new file mode 100644 index 0000000..2d561e7 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/model/config/AppConfig.java @@ -0,0 +1,14 @@ +package codes.thischwa.jdvs.model.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestClient; + +@Configuration +public class AppConfig { + + @Bean + public RestClient restClient() { + return RestClient.create(); + } +} diff --git a/src/main/java/codes/thischwa/jdvs/model/config/GitConfig.java b/src/main/java/codes/thischwa/jdvs/model/config/GitConfig.java new file mode 100644 index 0000000..37025c6 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/model/config/GitConfig.java @@ -0,0 +1,13 @@ +package codes.thischwa.jdvs.model.config; + +import java.util.List; +import lombok.Data; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "git") +@Data +public class GitConfig { + private List javadocPaths; +} diff --git a/src/main/java/codes/thischwa/jdvs/model/config/JdvsConfig.java b/src/main/java/codes/thischwa/jdvs/model/config/JdvsConfig.java new file mode 100644 index 0000000..cbbf023 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/model/config/JdvsConfig.java @@ -0,0 +1,31 @@ +package codes.thischwa.jdvs.model.config; + +import java.util.List; +import java.util.Objects; +import lombok.Data; +import org.jspecify.annotations.Nullable; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Configuration +@ConfigurationProperties(prefix = "jdvs") +@Data +public class JdvsConfig { + private String baseDir; + private boolean cleanOnStart; + private boolean runOnStart; + private String mavenCentralUrl; + private List repositories; + + public String getEffectiveMavenRepoUrl(RepoConfig cfg) { + return Objects.requireNonNullElse(cfg.getMavenRepoUrl(), mavenCentralUrl); + } + + @Data + public static class RepoConfig { + private String name; + private String groupId; + private String artifactId; + private String mavenRepoUrl; + } +} diff --git a/src/main/java/codes/thischwa/jdvs/service/ApplicationStartupListener.java b/src/main/java/codes/thischwa/jdvs/service/ApplicationStartupListener.java new file mode 100644 index 0000000..66d4708 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/service/ApplicationStartupListener.java @@ -0,0 +1,58 @@ +package codes.thischwa.jdvs.service; + +import codes.thischwa.jdvs.config.RepoConfigLoader; +import codes.thischwa.jdvs.jpa.GitRepositoryRepository; +import codes.thischwa.jdvs.model.config.JdvsConfig; +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Comparator; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class ApplicationStartupListener { + + private final GitRepositoryRepository repository; + private final JdvsConfig jdvsConfig; + private final RepoConfigLoader repoConfigLoader; + private final UpdateScheduler updateScheduler; + + @EventListener(ApplicationReadyEvent.class) + public void onApplicationReady() { + if (jdvsConfig.isCleanOnStart()) { + log.info("'jdvs.clean-on-start' is enabled – cleaning base directory and database."); + cleanBaseDir(); + repository.deleteAll(); + } + repoConfigLoader.loadConfig(); + if (jdvsConfig.isRunOnStart()) { + log.info("'jdvs.run-on-start' is enabled – running initial update."); + updateScheduler.updateAll(); + } + } + + private void cleanBaseDir() { + Path baseDir = Path.of(jdvsConfig.getBaseDir()); + if (!Files.exists(baseDir)) { + return; + } + try (var stream = Files.walk(baseDir)) { + stream.sorted(Comparator.reverseOrder()).forEach(path -> { + try { + Files.delete(path); + } catch (IOException e) { + log.warn("Failed to delete path: {}", path, e); + } + }); + log.info("Deleted base directory: {}", baseDir); + } catch (IOException e) { + log.error("Failed to walk base directory: {}", baseDir, e); + } + } +} diff --git a/src/main/java/codes/thischwa/jdvs/service/MavenJavadocService.java b/src/main/java/codes/thischwa/jdvs/service/MavenJavadocService.java new file mode 100644 index 0000000..fdce797 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/service/MavenJavadocService.java @@ -0,0 +1,153 @@ +package codes.thischwa.jdvs.service; + +import codes.thischwa.jdvs.model.config.JdvsConfig; +import codes.thischwa.jdvs.model.config.JdvsConfig.RepoConfig; +import java.io.ByteArrayInputStream; +import java.io.IOException; +import java.io.StringReader; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.StandardCopyOption; +import java.util.Optional; +import java.util.zip.ZipEntry; +import java.util.zip.ZipInputStream; +import javax.xml.parsers.DocumentBuilderFactory; +import javax.xml.parsers.ParserConfigurationException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClient; +import org.springframework.web.client.RestClientException; +import org.w3c.dom.Document; +import org.w3c.dom.NodeList; +import org.xml.sax.InputSource; +import org.xml.sax.SAXException; + +@Service +@RequiredArgsConstructor +@Slf4j +public class MavenJavadocService { + + private final JdvsConfig jdvsConfig; + private final RestClient restClient; + + public Optional fetchLatestVersion(String repoName) { + RepoConfig cfg = findConfig(repoName); + String metadataUrl = baseUrl(cfg) + "/maven-metadata.xml"; + log.info("Fetching Maven metadata from {}", metadataUrl); + try { + String xml = restClient.get().uri(metadataUrl).retrieve().body(String.class); + Document doc = parseXml(xml); + Optional version = resolveVersionTag(doc); + if (version.isPresent()) { + log.info("Latest version of {} is {}", repoName, version.get()); + return version; + } + log.warn("Could not determine latest version for {}", repoName); + } catch (ParserConfigurationException | SAXException | IOException e) { + log.error("Failed to parse Maven metadata for {}", repoName, e); + } catch (RestClientException e) { + log.error("Failed to fetch Maven metadata for {}", repoName, e); + } + return Optional.empty(); + } + + public boolean generateJavadoc(String repoName, String version) { + RepoConfig cfg = findConfig(repoName); + String resolvedVersion = version.endsWith("-SNAPSHOT") + ? resolveSnapshotVersion(cfg, version) + : version; + String jarUrl = baseUrl(cfg) + "/" + version + "/" + + cfg.getArtifactId() + "-" + resolvedVersion + "-javadoc.jar"; + log.info("Downloading Javadoc JAR from {}", jarUrl); + + Path outputDir = Path.of(jdvsConfig.getBaseDir(), "javadoc", repoName); + try { + Files.createDirectories(outputDir); + extractJavadocJar(jarUrl, outputDir); + log.info("Javadoc successfully extracted for {} to {}", repoName, outputDir); + return true; + } catch (IOException | RestClientException e) { + log.error("Failed to generate Javadoc for {} version {}", repoName, version, e); + return false; + } + } + + private void extractJavadocJar(String jarUrl, Path outputDir) throws IOException { + byte[] jarBytes = restClient.get().uri(jarUrl).retrieve().body(byte[].class); + if (jarBytes == null) { + throw new IOException("Empty response for " + jarUrl); + } + Path safeOutputDir = outputDir.toAbsolutePath().normalize(); + try (ZipInputStream zip = new ZipInputStream(new ByteArrayInputStream(jarBytes))) { + ZipEntry entry; + while ((entry = zip.getNextEntry()) != null) { + String name = entry.getName(); + if (!entry.isDirectory() && !name.startsWith("META-INF/")) { + Path dest = safeOutputDir.resolve(name).normalize(); + if (!dest.startsWith(safeOutputDir)) { + throw new IOException("Zip slip detected: " + name); + } + Files.createDirectories(dest.getParent()); + Files.copy(zip, dest, StandardCopyOption.REPLACE_EXISTING); + } + zip.closeEntry(); + } + } + } + + private String resolveSnapshotVersion(RepoConfig cfg, String version) { + String metadataUrl = baseUrl(cfg) + "/" + version + "/maven-metadata.xml"; + log.info("Resolving SNAPSHOT version from {}", metadataUrl); + try { + String xml = restClient.get().uri(metadataUrl).retrieve().body(String.class); + Document doc = parseXml(xml); + NodeList nodes = doc.getElementsByTagName("snapshotVersion"); + for (int i = 0; i < nodes.getLength(); i++) { + org.w3c.dom.Element el = (org.w3c.dom.Element) nodes.item(i); + String classifier = firstText(el.getElementsByTagName("classifier")); + String ext = firstText(el.getElementsByTagName("extension")); + String value = firstText(el.getElementsByTagName("value")); + if ("javadoc".equals(classifier) && "jar".equals(ext) && value != null) { + log.info("Resolved SNAPSHOT version to {}", value); + return value; + } + } + } catch (ParserConfigurationException | SAXException | IOException e) { + log.warn("Could not parse SNAPSHOT metadata for {}, falling back to {}", + cfg.getArtifactId(), version, e); + } catch (RestClientException e) { + log.warn("Could not fetch SNAPSHOT metadata for {}, falling back to {}", + cfg.getArtifactId(), version, e); + } + return version; + } + + private String baseUrl(RepoConfig cfg) { + String groupPath = cfg.getGroupId().replace('.', '/'); + return jdvsConfig.getEffectiveMavenRepoUrl(cfg) + "/" + groupPath + "/" + cfg.getArtifactId(); + } + + private RepoConfig findConfig(String repoName) { + return jdvsConfig.getRepositories().stream() + .filter(r -> repoName.equals(r.getName())) + .findFirst() + .orElseThrow(() -> new IllegalArgumentException("No config found for: " + repoName)); + } + + private static Optional resolveVersionTag(Document doc) { + return Optional.ofNullable(firstText(doc.getElementsByTagName("release"))) + .or(() -> Optional.ofNullable(firstText(doc.getElementsByTagName("latest")))); + } + + private static String firstText(NodeList nodes) { + return nodes.getLength() > 0 ? nodes.item(0).getTextContent() : null; + } + + private static Document parseXml(String xml) + throws ParserConfigurationException, SAXException, IOException { + return DocumentBuilderFactory.newInstance() + .newDocumentBuilder() + .parse(new InputSource(new StringReader(xml))); + } +} diff --git a/src/main/java/codes/thischwa/jdvs/service/UpdateScheduler.java b/src/main/java/codes/thischwa/jdvs/service/UpdateScheduler.java new file mode 100644 index 0000000..4057f02 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/service/UpdateScheduler.java @@ -0,0 +1,47 @@ +package codes.thischwa.jdvs.service; + +import codes.thischwa.jdvs.jpa.GitRepositoryRepository; +import codes.thischwa.jdvs.model.GitRepository; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UpdateScheduler { + + private final GitRepositoryRepository repository; + private final MavenJavadocService mavenJavadocService; + + @Scheduled(cron = "${jdvs.cron}") + public void updateAll() { + log.info("Starting scheduled update of repositories..."); + List repos = repository.findAll(); + for (GitRepository repo : repos) { + updateRepo(repo); + } + } + + private void updateRepo(GitRepository repo) { + log.info("Checking repository: {}", repo.getName()); + Optional latestVersion = mavenJavadocService.fetchLatestVersion(repo.getName()); + if (latestVersion.isPresent()) { + String version = latestVersion.get(); + if (!version.equals(repo.getLastTag())) { + log.info("New version {} found for {}. Generating Javadoc...", version, repo.getName()); + if (mavenJavadocService.generateJavadoc(repo.getName(), version)) { + repo.setLastTag(version); + repo.setUpdated(LocalDateTime.now()); + repository.save(repo); + } + } else { + log.info("Repository {} is already up to date ({}).", repo.getName(), version); + } + } + } +} diff --git a/src/main/java/codes/thischwa/jdvs/web/BadgeController.java b/src/main/java/codes/thischwa/jdvs/web/BadgeController.java new file mode 100644 index 0000000..9af5cac --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/web/BadgeController.java @@ -0,0 +1,34 @@ +package codes.thischwa.jdvs.web; + +import codes.thischwa.jdvs.jpa.GitRepositoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.stereotype.Controller; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.server.ResponseStatusException; + +@Controller +@RequiredArgsConstructor +public class BadgeController { + + private static final String BADGE_URL_PREFIX = + "https://img.shields.io/badge/javadoc%40JdVS-"; + + private static final String BADGE_URL_SUFFIX = "-green"; + + private final GitRepositoryRepository repository; + + @GetMapping("/badge/{repositoryName}") + public String badge(@PathVariable String repositoryName) { + String tag = + repository + .findByName(repositoryName) + .map(r -> r.getLastTag() != null ? r.getLastTag() : "unknown") + .orElseThrow( + () -> + new ResponseStatusException( + HttpStatus.NOT_FOUND, "Repository not found: " + repositoryName)); + return "redirect:" + BADGE_URL_PREFIX + tag + BADGE_URL_SUFFIX; + } +} diff --git a/src/main/java/codes/thischwa/jdvs/web/FaviconController.java b/src/main/java/codes/thischwa/jdvs/web/FaviconController.java new file mode 100644 index 0000000..19ad1b1 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/web/FaviconController.java @@ -0,0 +1,16 @@ +package codes.thischwa.jdvs.web; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * Controller for favicon.ico, just for reducing 404 errors in the logs. + */ +@RestController +public class FaviconController { + + @GetMapping("favicon.ico") + void returnNoFavicon() { + // see class comment + } +} diff --git a/src/main/java/codes/thischwa/jdvs/web/UiController.java b/src/main/java/codes/thischwa/jdvs/web/UiController.java new file mode 100644 index 0000000..c66b649 --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/web/UiController.java @@ -0,0 +1,19 @@ +package codes.thischwa.jdvs.web; + +import codes.thischwa.jdvs.jpa.GitRepositoryRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +@RequiredArgsConstructor +public class UiController { + private final GitRepositoryRepository repository; + + @GetMapping("/") + public String index(Model model) { + model.addAttribute("repos", repository.findAll()); + return "index"; + } +} diff --git a/src/main/java/codes/thischwa/jdvs/web/WebConfig.java b/src/main/java/codes/thischwa/jdvs/web/WebConfig.java new file mode 100644 index 0000000..80ee97c --- /dev/null +++ b/src/main/java/codes/thischwa/jdvs/web/WebConfig.java @@ -0,0 +1,24 @@ +package codes.thischwa.jdvs.web; + +import codes.thischwa.jdvs.model.config.JdvsConfig; +import java.nio.file.Path; +import lombok.RequiredArgsConstructor; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.ResourceHandlerRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@RequiredArgsConstructor +public class WebConfig implements WebMvcConfigurer { + private final JdvsConfig jdvsConfig; + + @Override + public void addResourceHandlers(ResourceHandlerRegistry registry) { + String javadocPath = Path.of(jdvsConfig.getBaseDir(), "javadoc").toAbsolutePath().toUri().toString(); + if (!javadocPath.endsWith("/")) { + javadocPath += "/"; + } + registry.addResourceHandler("/**") + .addResourceLocations(javadocPath); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..9b8b515 --- /dev/null +++ b/src/main/resources/application.yml @@ -0,0 +1,27 @@ +jdvs: + base-dir: ./javadoc-storage + cron: 0 0/30 * * * ? + clean-on-start: false + run-on-start: false + maven-central-url: https://repo1.maven.org/maven2 + +git: + javadoc-paths: + - target/reports/apidocs + - target/apidocs + - target/site/apidocs + +## import of the individual configuration settings +spring: + config: + import: optional:file:./jdvs.yml + + datasource: + url: jdbc:h2:file:./database/jdvsdb + driverClassName: org.h2.Driver + username: sa + password: "" + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: none diff --git a/src/main/resources/banner.txt b/src/main/resources/banner.txt new file mode 100644 index 0000000..e01ba46 --- /dev/null +++ b/src/main/resources/banner.txt @@ -0,0 +1,11 @@ + + ___ _ _ _ _____ + |_ | | | | | / ___| + | | __| | | | \ `--. + | |/ _` | | | |`--. \ +/\__/ / (_| \ \_/ /\__/ / +\____/ \__,_|\___/\____/ + +Version: ${application.version} +:: Spring Boot${spring-boot.formatted-version} :: +running on java ${java.version} diff --git a/src/main/resources/db/changelog/001-init-schema.yaml b/src/main/resources/db/changelog/001-init-schema.yaml new file mode 100644 index 0000000..e77c4c9 --- /dev/null +++ b/src/main/resources/db/changelog/001-init-schema.yaml @@ -0,0 +1,27 @@ +databaseChangeLog: + - changeSet: + id: 1 + author: th-schwarz + changes: + - createTable: + tableName: git_repository + columns: + - column: + name: id + type: BIGINT + autoIncrement: true + constraints: + primaryKey: true + nullable: false + - column: + name: name + type: VARCHAR(255) + constraints: + nullable: false + unique: true + - column: + name: last_tag + type: VARCHAR(255) + - column: + name: updated + type: TIMESTAMP diff --git a/src/main/resources/db/changelog/db.changelog-master.yaml b/src/main/resources/db/changelog/db.changelog-master.yaml new file mode 100644 index 0000000..4acda8f --- /dev/null +++ b/src/main/resources/db/changelog/db.changelog-master.yaml @@ -0,0 +1,3 @@ +databaseChangeLog: + - include: + file: db/changelog/001-init-schema.yaml diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..1c5fb72 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,21 @@ + + + + + + %d{HH:mm:ss.SSS} [%t] %-5level %logger{50} - %msg%n + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/index.html b/src/main/resources/templates/index.html new file mode 100644 index 0000000..b61f764 --- /dev/null +++ b/src/main/resources/templates/index.html @@ -0,0 +1,35 @@ + + + + Javadoc Viewer Service + + + +
+

Project Javadoc Overview

+ + + + + + + + + + + + + + + + + +
NameLatest VersionLast UpdateJavadoc link
Project-- + + javadoc badge + + Not yet generated +
+
+ +