diff --git a/.github/workflows/validate-openapi.yml b/.github/workflows/validate-openapi.yml new file mode 100644 index 0000000..32a5d28 --- /dev/null +++ b/.github/workflows/validate-openapi.yml @@ -0,0 +1,166 @@ +name: Validate OpenAPI Spec and Generated Client Code + +on: + pull_request: + branches: [main] + types: [opened, reopened, edited, synchronize] + +jobs: + validate-openapi: + name: Validate OpenAPI Spec + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + env: + # Check whether server changes, openapi.yaml changes, or client openapi folder changes + PATHS_TO_CHECK: | + server/application-server/src/main/java/ + server/application-server/openapi.yaml + client/src/app/core/modules/openapi/ + outputs: + CHANGE_DETECTED: ${{ steps.check_changes.outputs.CHANGE_DETECTED }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Fetch all branches + run: | + git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} + git fetch origin ${{ github.head_ref }}:refs/remotes/origin/${{ github.head_ref }} + + - name: Check for changes in specified paths + id: check_changes + run: | + CHANGED_PATHS=$(git diff --name-only origin/${{ github.base_ref }} origin/${{ github.head_ref }} | grep -E "^($(echo "$PATHS_TO_CHECK" | tr '\n' '|'))") + + if [[ -z "$CHANGED_PATHS" ]]; then + echo "CHANGE_DETECTED=true" >> "$GITHUB_OUTPUT" + echo "No OpenAPI changes detected." + else + echo "CHANGE_DETECTED=false" >> "$GITHUB_OUTPUT" + echo "OpenAPI changes detected in the following paths:" + echo "$CHANGED_PATHS" + fi + + - name: Set up Java + if: steps.check_changes.outputs.CHANGE_DETECTED == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Run Gradle to generate OpenAPI docs + if: steps.check_changes.outputs.CHANGE_DETECTED == 'true' + working-directory: ./server/application-server + run: ./gradlew generateOpenApiDocs + + - name: Check for OpenAPI spec differences + if: steps.check_changes.outputs.CHANGE_DETECTED == 'true' + id: check_openapi_spec + run: | + if git diff --exit-code ./server/application-server/openapi.yml; then + echo "OpenAPI spec is up-to-date." + else + echo "OpenAPI specs in openapi.yml differ from the generated version." + exit 1 + fi + + - name: Post comment about OpenAPI validation failure + if: failure() && steps.check_changes.outputs.CHANGE_DETECTED == 'true' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: openapi-validation-yml + message: | + 🚨 **OpenAPI Validation Failed** 🚨 + + The OpenAPI specs in `openapi.yml` differ from the generated version. + Please update the OpenAPI specs by running: + ```bash + cd ./server/application-server + ./gradlew generateOpenApiDocs + ``` + Commit and push the updated file. + + - name: Remove sticky comment on OpenAPI validation success + if: success() || steps.check_changes.outputs.CHANGE_DETECTED == 'false' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: openapi-validation-yml + delete: true + + validate-client-code: + name: Validate Client Code + runs-on: ubuntu-latest + needs: validate-openapi + permissions: + contents: read + pull-requests: write + env: + CHANGE_DETECTED: ${{needs.validate-openapi.outputs.CHANGE_DETECTED}} + steps: + - name: Checkout code + if: env.CHANGE_DETECTED == 'true' + uses: actions/checkout@v4 + + - name: Set up Java + if: env.CHANGE_DETECTED == 'true' + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '21' + + - name: Set up Node.js + if: env.CHANGE_DETECTED == 'true' + uses: actions/setup-node@v4 + with: + node-version: '22' + + - name: Install OpenAPI Generator CLI + if: env.CHANGE_DETECTED == 'true' + run: npm install -g @openapitools/openapi-generator-cli + + - name: Generate client code + if: env.CHANGE_DETECTED == 'true' + run: | + npx openapi-generator-cli generate \ + -i ./server/application-server/openapi.yaml \ + -g typescript-angular \ + -o ./client/src/app/core/modules/openapi \ + --additional-properties fileNaming=kebab-case,withInterfaces=true --generate-alias-as-model + + - name: Check for client code differences + if: env.CHANGE_DETECTED == 'true' + id: check_client_code + run: | + if git diff --exit-code ./client/src/app/core/modules/openapi; then + echo "Client code is up-to-date." + else + echo "Client code in /client/src/app/core/modules/openapi is not up-to-date." + exit 1 + + - name: Post comment about client code validation failure + if: failure() && env.CHANGE_DETECTED == 'true' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: client-code-validation + message: | + 🚨 **Client Code Validation Failed** 🚨 + + The client code in `/client/src/app/core/modules/openapi` is not up-to-date. + Please regenerate the client code by running: + ```bash + npm install -g @openapitools/openapi-generator-cli + # In root directory + npx openapi-generator-cli generate -i ./server/application-server/openapi.yaml -g typescript-angular -o ./client/src/app/core/modules/openapi --additional-properties fileNaming=kebab-case,withInterfaces=true --generate-alias-as-model + ``` + Commit and push the updated files. + + - name: Remove sticky comment on client code validation success + if: success() || env.CHANGE_DETECTED == 'false' + uses: marocchino/sticky-pull-request-comment@v2 + with: + header: client-code-validation + delete: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index f39ecdb..cb843a5 100644 --- a/.gitignore +++ b/.gitignore @@ -162,6 +162,13 @@ cython_debug/ # option (not recommended) you can uncomment the following to ignore the entire idea folder. .idea/ +# Node +/node_modules +npm-debug.log +yarn-error.log + +.sdkmanrc + CMakeLists.txt.user CMakeCache.txt CMakeFiles diff --git a/client/src/app/core/modules/openapi/.openapi-generator/FILES b/client/src/app/core/modules/openapi/.openapi-generator/FILES index 0e30f14..fa537d7 100644 --- a/client/src/app/core/modules/openapi/.openapi-generator/FILES +++ b/client/src/app/core/modules/openapi/.openapi-generator/FILES @@ -1,5 +1,4 @@ .gitignore -.openapi-generator-ignore README.md api.module.ts api/api.ts diff --git a/client/src/app/core/modules/openapi/api/status-controller.service.ts b/client/src/app/core/modules/openapi/api/status-controller.service.ts index 626b01f..7dcfcc7 100644 --- a/client/src/app/core/modules/openapi/api/status-controller.service.ts +++ b/client/src/app/core/modules/openapi/api/status-controller.service.ts @@ -95,10 +95,10 @@ export class StatusControllerService implements StatusControllerServiceInterface * @param observe set whether or not to return the data Observable as the body, response or events. defaults to returning the body. * @param reportProgress flag to report request and response progress. */ - public healthCheck(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: '*/*', context?: HttpContext, transferCache?: boolean}): Observable; - public healthCheck(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: '*/*', context?: HttpContext, transferCache?: boolean}): Observable>; - public healthCheck(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: '*/*', context?: HttpContext, transferCache?: boolean}): Observable>; - public healthCheck(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: '*/*', context?: HttpContext, transferCache?: boolean}): Observable { + public healthCheck(observe?: 'body', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable; + public healthCheck(observe?: 'response', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public healthCheck(observe?: 'events', reportProgress?: boolean, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable>; + public healthCheck(observe: any = 'body', reportProgress: boolean = false, options?: {httpHeaderAccept?: 'application/json', context?: HttpContext, transferCache?: boolean}): Observable { let localVarHeaders = this.defaultHeaders; @@ -106,7 +106,7 @@ export class StatusControllerService implements StatusControllerServiceInterface if (localVarHttpHeaderAcceptSelected === undefined) { // to determine the Accept header const httpHeaderAccepts: string[] = [ - '*/*' + 'application/json' ]; localVarHttpHeaderAcceptSelected = this.configuration.selectHeaderAccept(httpHeaderAccepts); } diff --git a/openapitools.json b/openapitools.json new file mode 100644 index 0000000..f80faaa --- /dev/null +++ b/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "7.9.0" + } +} diff --git a/server/application-server/README.md b/server/application-server/README.md index 69dcecd..aabfc92 100644 --- a/server/application-server/README.md +++ b/server/application-server/README.md @@ -4,7 +4,7 @@ Helios Application Server is a [Spring Boot application](https://spring.io/proje ## Prerequisites -- [Java 22](https://www.oracle.com/java/technologies/downloads/) (or higher) +- [Java 21](https://www.oracle.com/java/technologies/downloads/) (or higher) - [Postgres 16](https://www.postgresql.org/download/) (is automatically started with the provided Docker Compose setup) ## Getting Started @@ -34,10 +34,10 @@ Copy the file `.env.example` to `.env` and adjust the values to your needs. It i $ cp .env.example .env ``` -**4. Run the Application** +**4. Run the Application in Development Mode** ```bash -$ ./gradlew bootRun +$ ./gradlew bootRunDev ``` The application will be accessible at [http://localhost:8080/status/health](http://localhost:8080/status/health) diff --git a/server/application-server/build.gradle b/server/application-server/build.gradle index c0c84ec..73822a9 100644 --- a/server/application-server/build.gradle +++ b/server/application-server/build.gradle @@ -1,3 +1,5 @@ +import org.springframework.boot.gradle.tasks.run.BootRun + plugins { id 'java' id 'war' @@ -21,32 +23,17 @@ repositories { mavenCentral() } -def loadEnvFile() { - def env = [:] - def envFile = file('.env') - if (envFile.exists()) { - envFile.eachLine { line -> - line = line.trim() - if (line && !line.startsWith('#')) { - def keyValue = line.split('=', 2) - if (keyValue.length == 2) { - def key = keyValue[0].trim() - def value = keyValue[1].trim() - // Remove surrounding quotes if present - if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) { - value = value.substring(1, value.length() - 1) - } - env[key] = value - } - } +// Detect if the generateOpenApiDocs task is being executed +gradle.taskGraph.whenReady { taskGraph -> + if (taskGraph.hasTask(':generateOpenApiDocs')) { + println "Generating OpenAPI documentation..." + // Add the H2 dependency dynamically + dependencies { + runtimeOnly 'com.h2database:h2' } - } else { - println ".env file not found in project root." } - return env } - dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' @@ -62,17 +49,71 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' } -tasks.named('test') { - useJUnitPlatform() +springBoot { + mainClass = "de.tum.cit.aet.helios.HeliosApplication" +} + +// Custom bootRun task to force the user to specify an active profile +tasks.named("bootRun") { + doFirst { + def activeProfile = System.getProperty("spring.profiles.active") + ?: project.findProperty("spring.profiles.active") + ?: System.getenv("SPRING_PROFILES_ACTIVE") + if (!activeProfile) { + throw new GradleException(""" + Error: 'spring.profiles.active' is not set. + + Please specify an active profile using one of the following methods: + + - Use a predefined task like 'bootRunDev'. + - Set the 'SPRING_PROFILES_ACTIVE' environment variable. + - Use the '-Dspring.profiles.active=' JVM argument. + + Available profiles: dev, openapi (Don't use this profile directly). + + Examples: + + 1. Using predefined task: + ./gradlew bootRunDev + + 2. Setting environment variable: + export SPRING_PROFILES_ACTIVE=dev + ./gradlew bootRun + + 3. Passing JVM argument: + ./gradlew -Dspring.profiles.active=dev bootRun + + 4. Using project property: + ./gradlew bootRun -Pspring.profiles.active=dev + """ + ) + } else { + println "Running with profile: ${activeProfile}" + systemProperty "spring.profiles.active", activeProfile + } + } +} + +tasks.register("bootRunDev", BootRun) { + group = "application" + description = "Runs the Spring Boot application with the dev profile" + // Set the active profile to 'dev' + systemProperty "spring.profiles.active", "dev" + classpath = sourceSets.main.runtimeClasspath + mainClass.set("de.tum.cit.aet.helios.HeliosApplication") } + openApi { apiDocsUrl = 'http://localhost:8080/v3/api-docs.yaml' outputDir = file('.') outputFileName = 'openapi.yaml' - def envVars = loadEnvFile() + // Set the active profile to 'openapi' customBootRun { - args.set(["--spring.profiles.active=dev", "--DATASOURCE_URL=${envVars['DATASOURCE_URL'] ?: ''}", "--DATASOURCE_USERNAME=${envVars['DATASOURCE_USERNAME'] ?: ''}", "--DATASOURCE_PASSWORD=${envVars['DATASOURCE_PASSWORD'] ?: ''}"]) + args.set(["--spring.profiles.active=openapi"]) } +} +tasks.named('test') { + useJUnitPlatform() } \ No newline at end of file diff --git a/server/application-server/openapi.yaml b/server/application-server/openapi.yaml index 6ebda1e..29604f8 100644 --- a/server/application-server/openapi.yaml +++ b/server/application-server/openapi.yaml @@ -22,7 +22,7 @@ paths: "200": description: OK content: - '*/*': + application/json: schema: type: string components: {} diff --git a/server/application-server/openapitools.json b/server/application-server/openapitools.json new file mode 100644 index 0000000..f80faaa --- /dev/null +++ b/server/application-server/openapitools.json @@ -0,0 +1,7 @@ +{ + "$schema": "./node_modules/@openapitools/openapi-generator-cli/config.schema.json", + "spaces": 2, + "generator-cli": { + "version": "7.9.0" + } +} diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/config/NatsConfig.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/config/NatsConfig.java index 6ba2a6e..29eb86a 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/config/NatsConfig.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/config/NatsConfig.java @@ -1,5 +1,7 @@ package de.tum.cit.aet.helios.config; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; @@ -13,14 +15,35 @@ @Configuration public class NatsConfig { + private static final Logger logger = LoggerFactory.getLogger(NatsConfig.class); + + @Value("${nats.enabled}") + private boolean isNatsEnabled; + @Value("${nats.server}") private String natsServer; @Value("${nats.auth.token}") private String natsAuthToken; + private final Environment environment; + + @Autowired + public NatsConfig(Environment env) { + this.environment = env; + } + @Bean public Connection natsConnection() throws Exception { + if (environment.matchesProfiles("openapi")) { + logger.info("NOpenAPI profile detected. Skipping NATS connection."); + return null; + } + + if (!isNatsEnabled) { + logger.info("NATS is disabled. Skipping NATS connection."); + return null; + } Options options = Options.builder().server(natsServer).token(natsAuthToken).build(); return Nats.connect(options); diff --git a/server/application-server/src/main/java/de/tum/cit/aet/helios/syncing/NatsConsumerService.java b/server/application-server/src/main/java/de/tum/cit/aet/helios/syncing/NatsConsumerService.java index 97e4dcd..8f0b3b3 100644 --- a/server/application-server/src/main/java/de/tum/cit/aet/helios/syncing/NatsConsumerService.java +++ b/server/application-server/src/main/java/de/tum/cit/aet/helios/syncing/NatsConsumerService.java @@ -14,9 +14,10 @@ import io.nats.client.Options; import io.nats.client.StreamContext; import io.nats.client.api.ConsumerConfiguration; -import io.nats.client.api.ConsumerInfo; import io.nats.client.api.DeliverPolicy; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.core.env.Environment; import org.springframework.stereotype.Service; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; @@ -55,19 +56,28 @@ public class NatsConsumerService { @Value("${nats.auth.token}") private String natsAuthToken; + private final Environment environment; + private Connection natsConnection; private ConsumerContext consumerContext; private final GitHubMessageHandlerRegistry handlerRegistry; - public NatsConsumerService(GitHubMessageHandlerRegistry handlerRegistry) { + @Autowired + public NatsConsumerService(Environment environment, GitHubMessageHandlerRegistry handlerRegistry) { + this.environment = environment; this.handlerRegistry = handlerRegistry; } @EventListener(ApplicationReadyEvent.class) public void init() { + if (environment.matchesProfiles("openapi")) { + logger.info("OpenAPI profile detected. Skipping NATS initialization."); + return; + } + if (!isNatsEnabled) { - logger.info("NATS is disabled. Skipping initialization."); + logger.info("NATS is disabled. Skipping NATS initialization."); return; } @@ -108,7 +118,7 @@ private Options buildNatsOptions() { private void setupConsumer(Connection connection) throws IOException, InterruptedException { try { StreamContext streamContext = connection.getStreamContext("github"); - + // Check if consumer already exists if (durableConsumerName != null && !durableConsumerName.isEmpty()) { try { @@ -121,13 +131,13 @@ private void setupConsumer(Connection connection) throws IOException, Interrupte if (consumerContext == null) { logger.info("Setting up consumer for subjects: {}", Arrays.toString(getSubjects())); ConsumerConfiguration.Builder consumerConfigBuilder = ConsumerConfiguration.builder() - .filterSubjects(getSubjects()) - .deliverPolicy(DeliverPolicy.ByStartTime) - .startTime(ZonedDateTime.now().minusDays(timeframe)); - + .filterSubjects(getSubjects()) + .deliverPolicy(DeliverPolicy.ByStartTime) + .startTime(ZonedDateTime.now().minusDays(timeframe)); + if (durableConsumerName != null && !durableConsumerName.isEmpty()) { consumerConfigBuilder.durable(durableConsumerName); - } + } ConsumerConfiguration consumerConfig = consumerConfigBuilder.build(); consumerContext = streamContext.createOrUpdateConsumer(consumerConfig); @@ -170,7 +180,7 @@ private void handleMessage(Message msg) { /** * Subjects to monitor. - * + * * @return The subjects to monitor. */ private String[] getSubjects() { @@ -187,7 +197,7 @@ private String[] getSubjects() { /** * Get subject prefix from ownerWithName for the given repository. - * + * * @param ownerWithName The owner and name of the repository. * @return The subject prefix, i.e. "github.owner.name" sanitized. * @throws IllegalArgumentException if the repository string is improperly diff --git a/server/application-server/src/main/resources/application-dev.yml b/server/application-server/src/main/resources/application-dev.yml new file mode 100644 index 0000000..baae8d7 --- /dev/null +++ b/server/application-server/src/main/resources/application-dev.yml @@ -0,0 +1,41 @@ +spring: + datasource: + driver-class-name: org.postgresql.Driver + url: ${DATASOURCE_URL} + username: ${DATASOURCE_USERNAME} + password: ${DATASOURCE_PASSWORD} + jpa: + database: POSTGRESQL + show-sql: false + hibernate: + ddl-auto: update + +nats: + enabled: true + timeframe: ${MONITORING_TIMEFRAME:7} + durableConsumerName: "" + server: ${NATS_SERVER} + auth: + token: ${NATS_AUTH_TOKEN} + + +github: + organizationName: ${ORGANIZATION_NAME} + # Can be any OAuth token, such as the PAT + authToken: ${GITHUB_AUTH_TOKEN:} + cache: + enabled: true + ttl: 500 + # in MB + size: 50 + +monitoring: + # List of repositories to monitor in the format owner/repository + # Example: ls1intum/Helios or ls1intum/Helios,ls1intum/Artemis + repositories: ${REPOSITORY_NAME} + runOnStartup: true + # Fetching timeframe in days + timeframe: 7 + # Cooldown in minutes before running the monitoring again + runOnStartupCooldownInMinutes: 15 + repository-sync-cron: "0 0 * * * *" diff --git a/server/application-server/src/main/resources/application-openapi.yml b/server/application-server/src/main/resources/application-openapi.yml new file mode 100644 index 0000000..abc3083 --- /dev/null +++ b/server/application-server/src/main/resources/application-openapi.yml @@ -0,0 +1,37 @@ +spring: + datasource: + url: jdbc:h2:mem:testdb + driver-class-name: org.h2.Driver + username: sa + password: password + jpa: + database-platform: org.hibernate.dialect.H2Dialect + hibernate: + ddl-auto: none + +nats: + enabled: false + timeframe: 0 + server: "" + durableConsumerName: "" + auth: + token: "" + +github: + organizationName: "" + authToken: "" + cache: + enabled: false + ttl: 0 + size: 0 + +monitoring: + repositories: "" + runOnStartup: false + timeframe: 0 + runOnStartupCooldownInMinutes: 0 + repository-sync-cron: "0 0 5 31 2 ?" + +logging: + level: + root: INFO diff --git a/server/application-server/src/main/resources/application.yml b/server/application-server/src/main/resources/application.yml index 0af410e..81d8189 100644 --- a/server/application-server/src/main/resources/application.yml +++ b/server/application-server/src/main/resources/application.yml @@ -3,49 +3,10 @@ spring: name: Helios config: import: optional:file:.env[.properties] - datasource: - driver-class-name: org.postgresql.Driver - url: ${DATASOURCE_URL} - username: ${DATASOURCE_USERNAME} - password: ${DATASOURCE_PASSWORD} - jpa: - database: POSTGRESQL - show-sql: false - hibernate: - ddl-auto: update springdoc: default-produces-media-type: application/json -nats: - enabled: true - timeframe: ${MONITORING_TIMEFRAME:7} - durableConsumerName: "" - server: ${NATS_SERVER} - auth: - token: ${NATS_AUTH_TOKEN} - -github: - organizationName: ${ORGANIZATION_NAME} - # Can be any OAuth token, such as the PAT - authToken: ${GITHUB_AUTH_TOKEN:} - cache: - enabled: true - ttl: 500 - # in MB - size: 50 - -monitoring: - # List of repositories to monitor in the format owner/repository - # Example: ls1intum/Helios or ls1intum/Helios,ls1intum/Artemis - repositories: ${REPOSITORY_NAME} - runOnStartup: true - # Fetching timeframe in days - timeframe: 7 - # Cooldown in minutes before running the monitoring again - runOnStartupCooldownInMinutes: 15 - repository-sync-cron: "0 0 * * * *" - logging: level: org.kohsuke.github.GitHubClient: DEBUG \ No newline at end of file