diff --git a/.env b/.env new file mode 100644 index 0000000..555a9ed --- /dev/null +++ b/.env @@ -0,0 +1,65 @@ +RESOURCE_DB_HOST=resource-db +RESOURCE_DB_PORT=5432 +RESOURCE_DB_NAME=resource_db +RESOURCE_DB_USER=${DB_USERNAME} +RESOURCE_DB_PASSWORD=${DB_PASSWORD} + +SONG_DB_HOST=song-db +SONG_DB_PORT=5432 +SONG_DB_NAME=song_db +SONG_DB_USER=${DB_USERNAME} +SONG_DB_PASSWORD=${DB_PASSWORD} + +STORAGE_DB_HOST=storage-db +STORAGE_DB_PORT=5432 +STORAGE_DB_NAME=storage_db +STORAGE_DB_USER=${DB_USERNAME} +STORAGE_DB_PASSWORD=${DB_PASSWORD} + +AUTH_DB_HOST=auth-db +AUTH_DB_PORT=5432 +AUTH_DB_NAME=auth_db +AUTH_DB_USER=${DB_USERNAME} +AUTH_DB_PASSWORD=${DB_PASSWORD} + +RABBITMQ_USER=guest +RABBITMQ_PASSWORD=guest +RABBITMQ_PORT=5672 +RABBITMQ_MANAGEMENT_PORT=15672 + +LOCALSTACK_SERVICES=s3 +LOCALSTACK_DEFAULT_REGION=us-east-1 +LOCALSTACK_PORT=4566 +LOCALSTACK_ENDPOINT_URL=http://localstack:4566 +AWS_S3_BUCKET_NAME=mp3-bucket +AWS_ACCESS_KEY_ID=test +AWS_SECRET_ACCESS_KEY=test + +DISCOVERY_PORT=8761 +CONFIG_PORT=8888 +GATEWAY_PORT=8080 +RESOURCE_SERVICE_PORT=8081 +SONG_SERVICE_PORT=8082 +RESOURCE_PROCESSOR_PORT=8083 +STORAGE_SERVICE_PORT=8085 +QA_SERVICE_PORT=8086 + +ELASTICSEARCH_PORT=9200 +LOGSTASH_PORT=5000 +LOGSTASH_HTTP_PORT=9600 +KIBANA_PORT=5601 + +PROMETHEUS_PORT=9090 +GRAFANA_PORT=3090 +GRAFANA_ADMIN_USER=admin +GRAFANA_ADMIN_PASSWORD=admin + +SPRING_PROFILES_ACTIVE=docker +CONFIG_SERVER_URL=http://config-service:${CONFIG_PORT} +EUREKA_URL=http://discovery-service:${DISCOVERY_PORT}/eureka + +AUTH_DB_URL=jdbc:postgresql://${AUTH_DB_HOST}:${AUTH_DB_PORT}/${AUTH_DB_NAME} +RESOURCE_DB_URL=jdbc:postgresql://${RESOURCE_DB_HOST}:${RESOURCE_DB_PORT}/${RESOURCE_DB_NAME} +SONG_DB_URL=jdbc:postgresql://${SONG_DB_HOST}:${SONG_DB_PORT}/${SONG_DB_NAME} +STORAGE_DB_URL=jdbc:postgresql://${STORAGE_DB_HOST}:${STORAGE_DB_PORT}/${STORAGE_DB_NAME} +CONFIG_REPO_URI=https://github.com/PashaPoliak/config-repo.git diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/.github/workflows/allure.yml b/.github/workflows/allure.yml new file mode 100644 index 0000000..863cbf2 --- /dev/null +++ b/.github/workflows/allure.yml @@ -0,0 +1,48 @@ +name: Allure Report + +on: + push: + branches: [master] + +permissions: + contents: write + pages: write + id-token: write + actions: read + checks: write + +jobs: + allure: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: gradle + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Run qa-service tests + run: ./gradlew :qa-service:test --no-daemon + + - name: Install Allure + run: | + curl -o allure-2.25.0.tgz -Ls https://github.com/allure-framework/allure2/releases/download/2.25.0/allure-2.25.0.tgz + sudo tar -zxvf allure-2.25.0.tgz -C /opt/ + sudo ln -s /opt/allure-2.25.0/bin/allure /usr/local/bin/allure + + - name: Generate Allure report + run: allure generate qa-service/allure-results -o allure-report --clean + + - name: Deploy to GitHub Pages + uses: peaceiris/actions-gh-pages@v3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: allure-report diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a14ee82 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,112 @@ +name: CI Tests + +on: + push: + branches: [master] + pull_request: + branches: [master] + +permissions: + contents: write + pages: write + id-token: write + actions: read + checks: write + +jobs: + run-test: + name: 'Run tests' + runs-on: ubuntu-latest + + services: + postgres: + image: postgres + + env: + POSTGRES_PASSWORD: root + POSTGRES_USER: admin + POSTGRES_DB: test + + options: >- + --health-cmd pg_isready + --health-interval 10s + --health-timeout 5s + --health-retries 5 + ports: + - 5432:5432 + + steps: + - run: | + sudo apt-get update && sudo apt-get install --yes --no-install-recommends postgresql-client + env: + PGPASSWORD: root + + - name: Git clone + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Setup Java 21 + uses: actions/setup-java@v4 + with: + java-version: '21' + distribution: 'temurin' + cache: 'gradle' + + - name: Cache maven + uses: actions/cache@v4 + with: + path: ~/.m2/repository + key: ${{ runner.os }}-maven-${{ hashFiles('**/pom.xml') }} + restore-keys: ${{ runner.os }}-maven- + + - name: Gradle test + if: matrix.os == 'ubuntu-latest' + run: ./gradlew :qa-service:test --no-daemon + + - name: Attach screenshots and reports + uses: actions/upload-artifact@v4 + if: failure() + with: + name: screenshots-and-report + path: '**/build/reports/tests/test/' + + - name: Test Reporter + uses: dorny/test-reporter@v1.9.1 + if: success() || failure() + continue-on-error: true + with: + name: All Tests Report + path: '**/build/test-results/test/*.xml' + reporter: java-junit + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Get Allure history + uses: actions/checkout@v4 + if: always() + continue-on-error: true + with: + ref: gh-pages + path: gh-pages + + - name: Install Allure + if: always() + run: | + curl -o allure-2.25.0.tgz -Ls https://github.com/allure-framework/allure2/releases/download/2.25.0/allure-2.25.0.tgz + sudo tar -zxvf allure-2.25.0.tgz -C /opt/ + sudo ln -s /opt/allure-2.25.0/bin/allure /usr/local/bin/allure + + - name: Generate Allure report + if: always() + run: allure generate qa-service/allure-results -o allure-report --clean || true + + - name: Deploy report to Github Pages + if: always() + continue-on-error: true + uses: peaceiris/actions-gh-pages@v3.9.3 + with: + github_token: ${{ secrets.GITHUB_TOKEN }} + publish_branch: gh-pages + publish_dir: allure-report diff --git a/.github/workflows/cancel.yml b/.github/workflows/cancel.yml new file mode 100644 index 0000000..32a94b1 --- /dev/null +++ b/.github/workflows/cancel.yml @@ -0,0 +1,16 @@ +name: Cancel + +on: + pull_request: + branches: [master] + +jobs: + cancel: + name: 'Cancel previous runs' + runs-on: ubuntu-latest + timeout-minutes: 3 + steps: + - uses: styfle/cancel-workflow-action@0.9.0 + with: + workflow_id: ci.yml + access_token: ${{ secrets.GITHUB_TOKEN }} \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..31928e7 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: Java CI/CD + +on: + push: + branches: [ master ] + pull_request: + branches: [ master ] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + distribution: temurin + java-version: 21 + + - name: Make gradlew executable + run: chmod +x ./gradlew + + - name: Cache Gradle + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Build with Gradle + run: ./gradlew clean build --no-daemon diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1ab0cc4 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +./docs/ + +# Standalone config-repo for Spring Cloud Config (not a submodule) +config-repo/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..f57caf1 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pasha Polyak + +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, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +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. \ No newline at end of file diff --git a/auth-service/.dockerignore b/auth-service/.dockerignore new file mode 100644 index 0000000..67c87c0 --- /dev/null +++ b/auth-service/.dockerignore @@ -0,0 +1,2 @@ +build/ +.gitignore diff --git a/auth-service/Dockerfile b/auth-service/Dockerfile new file mode 100644 index 0000000..b1f728b --- /dev/null +++ b/auth-service/Dockerfile @@ -0,0 +1,20 @@ +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY auth-service/build.gradle auth-service/ +RUN ./gradlew :auth-service:dependencies --no-daemon + +COPY auth-service/src auth-service/src +RUN ./gradlew :auth-service:assemble --no-daemon -x test + +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl \ + && addgroup -S app && adduser -S app -G app +WORKDIR /app +COPY --from=builder /app/auth-service/build/libs/*.jar app.jar +RUN chown app:app /app/app.jar +USER app +EXPOSE 9000 +CMD ["java", "-jar", "app.jar"] diff --git a/auth-service/build.gradle b/auth-service/build.gradle new file mode 100644 index 0000000..3b022f6 --- /dev/null +++ b/auth-service/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' +description = 'auth-service' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.security:spring-security-oauth2-authorization-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'commons-logging:commons-logging:1.3.4' + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter") +} + +tasks.named('test') { + useJUnitPlatform() +} \ No newline at end of file diff --git a/auth-service/src/main/java/com/audio/auth/AuthServiceApplication.java b/auth-service/src/main/java/com/audio/auth/AuthServiceApplication.java new file mode 100644 index 0000000..144df93 --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/AuthServiceApplication.java @@ -0,0 +1,12 @@ +package com.audio.auth; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class AuthServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(AuthServiceApplication.class, args); + } +} diff --git a/auth-service/src/main/java/com/audio/auth/config/DataInitializer.java b/auth-service/src/main/java/com/audio/auth/config/DataInitializer.java new file mode 100644 index 0000000..8c0fcc0 --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/config/DataInitializer.java @@ -0,0 +1,37 @@ +package com.audio.auth.config; + +import com.audio.auth.entity.User; +import com.audio.auth.repository.UserRepository; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.util.Set; + +@Configuration +@Profile("dev") +public class DataInitializer { + + @Bean + CommandLineRunner initDatabase(UserRepository userRepository, PasswordEncoder passwordEncoder) { + return args -> { + if (userRepository.count() == 0) { + User alice = new User(); + alice.setUsername("alice"); + alice.setPassword(passwordEncoder.encode("alice")); + alice.setRoles(Set.of("USER")); + alice.setEnabled(true); + userRepository.save(alice); + + User bob = new User(); + bob.setUsername("bob"); + bob.setPassword(passwordEncoder.encode("bob")); + bob.setRoles(Set.of("ADMIN")); + bob.setEnabled(true); + userRepository.save(bob); + } + }; + } +} diff --git a/auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java b/auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java new file mode 100644 index 0000000..25d8a96 --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java @@ -0,0 +1,165 @@ +package com.audio.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.userdetails.User; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.jwt.NimbusJwtDecoder; +import org.springframework.security.oauth2.server.authorization.client.InMemoryRegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.security.oauth2.server.authorization.token.JwtEncodingContext; +import org.springframework.security.oauth2.server.authorization.token.OAuth2TokenCustomizer; +import org.springframework.security.provisioning.InMemoryUserDetailsManager; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.AuthenticationSuccessHandler; + +import javax.crypto.spec.SecretKeySpec; +import java.nio.charset.StandardCharsets; +import java.time.Duration; +import java.util.Collection; +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +@Configuration +@EnableWebSecurity +public class SecurityConfig { + + @Bean + @Order(1) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) { + http + .securityMatcher("/oauth2/**", "/auth/**", "/login", "/error") + .authorizeHttpRequests(auth -> auth + .requestMatchers("/login", "/oauth2/**", "/error").permitAll() + .anyRequest().authenticated() + ) + .formLogin(form -> form + .loginPage("/login") + .successHandler(authenticationSuccessHandler()) + .permitAll() + ) + .logout(logout -> logout + .logoutSuccessUrl("/login?logout") + .permitAll() + ) + .csrf(AbstractHttpConfigurer::disable); + return http.build(); + } + + @Bean + public AuthenticationSuccessHandler authenticationSuccessHandler() { + return (request, response, authentication) -> response.sendRedirect("/oauth2/authorize?response_type=code&client_id=auth-client&redirect_uri=http://127.0.0.1:8080/login/oauth2/code/auth-client&scope=openid%20profile%20roles"); + } + + @Bean + public RegisteredClientRepository registeredClientRepository() { + PasswordEncoder passwordEncoder = passwordEncoder(); + + RegisteredClient authClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("auth-client") + .clientSecret(passwordEncoder.encode("secret")) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .redirectUri("http://127.0.0.1:8080/login/oauth2/code/auth-client") + .redirectUri("http://127.0.0.1:8080/authorized") + .postLogoutRedirectUri("http://127.0.0.1:8080/logout") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .scope("roles") + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(Duration.ofHours(1)) + .refreshTokenTimeToLive(Duration.ofHours(24)) + .build()) + .clientSettings(ClientSettings.builder().requireAuthorizationConsent(false).build()) + .build(); + + RegisteredClient storageClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("storage-client") + .clientSecret(passwordEncoder.encode("storage-secret")) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("read") + .scope("write") + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(Duration.ofHours(1)) + .build()) + .build(); + + return new InMemoryRegisteredClientRepository(authClient, storageClient); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + + @Bean + public UserDetailsService userDetailsService() { + PasswordEncoder passwordEncoder = passwordEncoder(); + + UserDetails admin = User.builder() + .username("alice") + .password(passwordEncoder.encode("alice")) + .roles("ADMIN") + .build(); + + UserDetails user = User.builder() + .username("bob") + .password(passwordEncoder.encode("bob")) + .roles("USER") + .build(); + + return new InMemoryUserDetailsManager(admin, user); + } + + @Bean + public OAuth2TokenCustomizer tokenCustomizer() { + return context -> { + if (context.getTokenType().getValue().equals("access_token")) { + Collection authorities = context.getPrincipal().getAuthorities(); + List roles = authorities.stream() + .map(GrantedAuthority::getAuthority).filter(Objects::nonNull) + .filter(auth -> auth.startsWith("ROLE_")) + .map(auth -> auth.substring(5)) + .toList(); + + if (!roles.isEmpty()) { + context.getClaims().claim("roles", roles); + } + } + }; + } + + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder() + .issuer("http://localhost:9000") + .build(); + } + + @Bean + public JwtDecoder jwtDecoder() { + return NimbusJwtDecoder.withSecretKey( + new SecretKeySpec("secretsecretsecretsecretsecretsecret".getBytes(StandardCharsets.UTF_8), "HmacSHA256") + ).build(); + } +} diff --git a/auth-service/src/main/java/com/audio/auth/controller/AuthController.java b/auth-service/src/main/java/com/audio/auth/controller/AuthController.java new file mode 100644 index 0000000..1bdc183 --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/controller/AuthController.java @@ -0,0 +1,152 @@ +package com.audio.auth.controller; + +import org.springframework.web.bind.annotation.*; +import org.springframework.http.*; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; +import org.springframework.web.client.RestTemplate; +import org.springframework.beans.factory.annotation.Value; +import java.util.Map; + +@RestController +@RequestMapping("/auth") +public class AuthController { + + @Value("${auth.server.url:http://localhost:9000}") + private String authServerUrl; + + @PostMapping("/token") + public ResponseEntity> getToken(@RequestParam String code) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setBasicAuth("auth-client", "secret"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "authorization_code"); + body.add("code", code); + body.add("redirect_uri", "http://127.0.0.1:8080/login/oauth2/code/auth-client"); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + authServerUrl + "/oauth2/token", + HttpMethod.POST, + request, + Map.class + ); + + return ResponseEntity.ok(response.getBody()); + } + + @PostMapping("/refresh") + public ResponseEntity> refreshToken(@RequestParam String refreshToken) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setBasicAuth("auth-client", "secret"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "refresh_token"); + body.add("refresh_token", refreshToken); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + authServerUrl + "/oauth2/token", + HttpMethod.POST, + request, + Map.class + ); + + return ResponseEntity.ok(response.getBody()); + } + + @PostMapping("/client-credentials") + public ResponseEntity> getClientCredentialsToken() { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setBasicAuth("storage-client", "storage-secret"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("scope", "read write"); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + authServerUrl + "/oauth2/token", + HttpMethod.POST, + request, + Map.class + ); + + return ResponseEntity.ok(response.getBody()); + } + + @PostMapping("/introspect") + public ResponseEntity> introspectToken(@RequestParam String token) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setBasicAuth("auth-client", "secret"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("token", token); + + HttpEntity> request = new HttpEntity<>(body, headers); + + ResponseEntity response = restTemplate.exchange( + authServerUrl + "/oauth2/introspect", + HttpMethod.POST, + request, + Map.class + ); + + return ResponseEntity.ok(response.getBody()); + } + + @PostMapping("/revoke") + public ResponseEntity revokeToken(@RequestParam String token) { + RestTemplate restTemplate = new RestTemplate(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + headers.setBasicAuth("auth-client", "secret"); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("token", token); + + HttpEntity> request = new HttpEntity<>(body, headers); + + restTemplate.exchange( + authServerUrl + "/oauth2/revoke", + HttpMethod.POST, + request, + Void.class + ); + + return ResponseEntity.ok().build(); + } + + @GetMapping("/authorize") + public ResponseEntity> getAuthorizationUrl() { + String authUrl = authServerUrl + "/oauth2/authorize?" + + "response_type=code&" + + "client_id=auth-client&" + + "redirect_uri=http://127.0.0.1:8080/auth/callback&" + + "scope=openid%20profile%20roles"; + + return ResponseEntity.ok(Map.of("authorization_url", authUrl)); + } + + @GetMapping("/callback") + public ResponseEntity> callback(@RequestParam String code) { + return ResponseEntity.ok(Map.of("code", code, "next_step", "POST /auth/token with code")); + } +} diff --git a/auth-service/src/main/java/com/audio/auth/entity/User.java b/auth-service/src/main/java/com/audio/auth/entity/User.java new file mode 100644 index 0000000..b7dfb6f --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/entity/User.java @@ -0,0 +1,37 @@ +package com.audio.auth.entity; + +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.HashSet; +import java.util.Set; + +@Setter +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "users") +public class User { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, unique = true) + private String username; + + @Column(nullable = false) + private String password; + + @ElementCollection(fetch = FetchType.EAGER) + @CollectionTable(name = "user_roles", joinColumns = @JoinColumn(name = "user_id")) + @Column(name = "role") + private Set roles = new HashSet<>(); + + @Column(nullable = false) + private boolean enabled = true; +} diff --git a/auth-service/src/main/java/com/audio/auth/repository/UserRepository.java b/auth-service/src/main/java/com/audio/auth/repository/UserRepository.java new file mode 100644 index 0000000..3da2c91 --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/repository/UserRepository.java @@ -0,0 +1,13 @@ +package com.audio.auth.repository; + +import com.audio.auth.entity.User; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface UserRepository extends JpaRepository { + + Optional findByUsername(String username); +} diff --git a/auth-service/src/main/java/com/audio/auth/service/CustomUserDetailsService.java b/auth-service/src/main/java/com/audio/auth/service/CustomUserDetailsService.java new file mode 100644 index 0000000..b0045f6 --- /dev/null +++ b/auth-service/src/main/java/com/audio/auth/service/CustomUserDetailsService.java @@ -0,0 +1,42 @@ +package com.audio.auth.service; + +import com.audio.auth.entity.User; +import com.audio.auth.repository.UserRepository; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +public class CustomUserDetailsService implements UserDetailsService { + + private final UserRepository userRepository; + + public CustomUserDetailsService(UserRepository userRepository) { + this.userRepository = userRepository; + } + + @Override + public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException { + User user = userRepository.findByUsername(username) + .orElseThrow(() -> new UsernameNotFoundException("User not found: " + username)); + + List authorities = user.getRoles().stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .collect(Collectors.toList()); + + return new org.springframework.security.core.userdetails.User( + user.getUsername(), + user.getPassword(), + user.isEnabled(), + true, + true, + true, + authorities + ); + } +} diff --git a/auth-service/src/main/resources/application-docker.yml b/auth-service/src/main/resources/application-docker.yml new file mode 100644 index 0000000..af6a6c0 --- /dev/null +++ b/auth-service/src/main/resources/application-docker.yml @@ -0,0 +1,25 @@ +server: + port: 9000 + servlet: + context-path: /auth + +spring: + application: + name: auth-service + datasource: + url: jdbc:postgresql://auth-db:5432/auth_db + username: ${AUTH_DB_USERNAME:postgres} + password: ${AUTH_DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + +logging: + level: + root: INFO + org.springframework.security: INFO diff --git a/auth-service/src/main/resources/application.yml b/auth-service/src/main/resources/application.yml new file mode 100644 index 0000000..b51ded9 --- /dev/null +++ b/auth-service/src/main/resources/application.yml @@ -0,0 +1,28 @@ +server: + port: 9000 + servlet: + context-path: /auth + +spring: + application: + name: auth-server + datasource: + url: jdbc:h2:mem:auth_db;DB_CLOSE_DELAY=-1 + username: sa + password: + driver-class-name: org.h2.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + h2: + console: + enabled: false + +logging: + level: + root: INFO + org.springframework.security: WARN diff --git a/auth-service/src/main/resources/logback-spring.xml b/auth-service/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..629b4e6 --- /dev/null +++ b/auth-service/src/main/resources/logback-spring.xml @@ -0,0 +1,39 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + UTC + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/auth-service/src/test/java/com/audio/auth/AuthServiceApplicationTest.java b/auth-service/src/test/java/com/audio/auth/AuthServiceApplicationTest.java new file mode 100644 index 0000000..f99628b --- /dev/null +++ b/auth-service/src/test/java/com/audio/auth/AuthServiceApplicationTest.java @@ -0,0 +1,12 @@ +package com.audio.auth; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class AuthServiceApplicationTest { + + @Test + void contextLoads() { + } +} diff --git a/auth-service/src/test/java/com/audio/auth/AuthenticationTest.java b/auth-service/src/test/java/com/audio/auth/AuthenticationTest.java new file mode 100644 index 0000000..0bfd02e --- /dev/null +++ b/auth-service/src/test/java/com/audio/auth/AuthenticationTest.java @@ -0,0 +1,211 @@ +package com.audio.auth; + +import com.audio.auth.repository.UserRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Disabled; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.MvcResult; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.context.WebApplicationContext; + +import static org.junit.jupiter.api.Assertions.*; +import static org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers.springSecurity; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@SpringBootTest +@ActiveProfiles("test") +@Disabled +class AuthenticationTest { + + private static final String CLIENT_ID = "gateway"; + private static final String CLIENT_SECRET = "gateway-secret"; + private static final String GRANT_TYPE = "password"; + private static final String SCOPE = "openid profile roles"; + private static final String TOKEN_URL = "/auth/oauth2/token"; + private static final String USERNAME_ADMIN = "bob"; + private static final String PASSWORD_ADMIN = "bob"; + private static final String USERNAME_USER = "alice"; + private static final String PASSWORD_USER = "alice"; + private static final String ROLE_ADMIN = "ADMIN"; + private static final String ROLE_USER = "USER"; + + @Autowired + private AuthenticationManager authenticationManager; + + @Autowired + private UserRepository userRepository; + + @Autowired + private PasswordEncoder passwordEncoder; + + @Autowired + private WebApplicationContext context; + + @Autowired + private RegisteredClientRepository registeredClientRepository; + + @Autowired + private JwtDecoder jwtDecoder; + + private MockMvc mockMvc; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders + .webAppContextSetup(context) + .apply(springSecurity()) + .build(); + } + + @Test + void testAuthenticationWithBobCredentials() { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(USERNAME_ADMIN, PASSWORD_ADMIN); + + Authentication authentication = authenticationManager.authenticate(authenticationToken); + + assertNotNull(authentication); + assertTrue(authentication.isAuthenticated()); + assertEquals(USERNAME_ADMIN, authentication.getName()); + } + + @Test + void testAuthenticationWithWrongPassword() { + UsernamePasswordAuthenticationToken authenticationToken = + new UsernamePasswordAuthenticationToken(USERNAME_ADMIN, "wrongpassword"); + + assertThrows(org.springframework.security.authentication.BadCredentialsException.class, () -> { + authenticationManager.authenticate(authenticationToken); + }); + } + + @Test + void testInvalidClientCredentials() throws Exception { + mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID) + .param("client_secret", "wrong-secret") + .param("username", USERNAME_ADMIN) + .param("password", PASSWORD_ADMIN) + .param("scope", SCOPE)) + .andExpect(status().isUnauthorized()); + } + + @Test + void testTokenEndpointWithMissingParameters() throws Exception { + mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID)) + .andExpect(status().isBadRequest()); + } + + @Test + void testObtainAccessTokenWithAdminRole() throws Exception { + mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID) + .param("client_secret", CLIENT_SECRET) + .param("username", USERNAME_ADMIN) + .param("password", PASSWORD_ADMIN) + .param("scope", SCOPE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect(jsonPath("$.token_type").value("bearer")) + .andExpect(jsonPath("$.expires_in").exists()); + } + + @Test + void testObtainAccessTokenWithUserRole() throws Exception { + mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID) + .param("client_secret", CLIENT_SECRET) + .param("username", USERNAME_USER) + .param("password", PASSWORD_USER) + .param("scope", SCOPE)) + .andExpect(status().isOk()) + .andExpect(content().contentType(MediaType.APPLICATION_JSON)) + .andExpect(jsonPath("$.access_token").exists()) + .andExpect(jsonPath("$.token_type").value("bearer")) + .andExpect(jsonPath("$.expires_in").exists()); + } + + @Test + void testAccessTokenContainsRoles() throws Exception { + MvcResult result = mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID) + .param("client_secret", CLIENT_SECRET) + .param("username", USERNAME_ADMIN) + .param("password", PASSWORD_ADMIN) + .param("scope", SCOPE)) + .andExpect(status().isOk()) + .andReturn(); + + String response = result.getResponse().getContentAsString(); + String accessToken = com.jayway.jsonpath.JsonPath.parse(response) + .read("$.access_token"); + + var jwt = jwtDecoder.decode(accessToken); + + assertEquals(USERNAME_ADMIN, jwt.getSubject()); + assertNotNull(jwt.getClaimAsStringList("roles")); + assertTrue(jwt.getClaimAsStringList("roles").contains(ROLE_ADMIN)); + } + + @Test + void testAccessTokenWithUserRoleContainsUserAuthority() throws Exception { + MvcResult result = mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID) + .param("client_secret", CLIENT_SECRET) + .param("username", USERNAME_USER) + .param("password", PASSWORD_USER) + .param("scope", SCOPE)) + .andExpect(status().isOk()) + .andReturn(); + + String response = result.getResponse().getContentAsString(); + String accessToken = com.jayway.jsonpath.JsonPath.parse(response) + .read("$.access_token"); + + var jwt = jwtDecoder.decode(accessToken); + + assertEquals(USERNAME_USER, jwt.getSubject()); + assertNotNull(jwt.getClaimAsStringList("roles")); + assertTrue(jwt.getClaimAsStringList("roles").contains(ROLE_USER)); + } + + @Test + void testInvalidUserCredentials() throws Exception { + mockMvc.perform(post(TOKEN_URL) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .param("grant_type", GRANT_TYPE) + .param("client_id", CLIENT_ID) + .param("client_secret", CLIENT_SECRET) + .param("username", USERNAME_ADMIN) + .param("password", "wrongpassword") + .param("scope", SCOPE)) + .andExpect(status().isUnauthorized()); + } +} diff --git a/auth-service/src/test/resources/application-test.yml b/auth-service/src/test/resources/application-test.yml new file mode 100644 index 0000000..eda9e83 --- /dev/null +++ b/auth-service/src/test/resources/application-test.yml @@ -0,0 +1,32 @@ +server: + port: 9000 + servlet: + context-path: /auth + +spring: + application: + name: auth-server + datasource: + url: jdbc:h2:mem:auth_db;DB_CLOSE_DELAY=-1;DATABASE_TO_UPPER=false + username: sa + password: + driver-class-name: org.h2.Driver + sql: + init: + mode: never + jpa: + hibernate: + ddl-auto: update + show-sql: true + properties: + hibernate: + dialect: org.hibernate.dialect.H2Dialect + h2: + console: + enabled: true + path: /h2-console + +logging: + level: + root: INFO + org.springframework.security: DEBUG \ No newline at end of file diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..d89d7aa --- /dev/null +++ b/build.gradle @@ -0,0 +1,31 @@ +plugins { + id 'java' + id 'io.spring.dependency-management' version '1.1.7' apply false +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2025.1.1') +} + +allprojects { + repositories { + mavenCentral() + } +} + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..73df0a1 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,522 @@ +networks: + microservices-net: + driver: bridge + ipam: + config: + - subnet: 172.25.0.0/16 + +volumes: + localstack-data: + driver: local + elasticsearch-data: + driver: local + grafana-data: + driver: local + +services: + resource-db: + image: postgres:17-alpine + container_name: resource-db + restart: unless-stopped + environment: + POSTGRES_DB: ${RESOURCE_DB_NAME} + POSTGRES_USER: ${RESOURCE_DB_USER} + POSTGRES_PASSWORD: ${RESOURCE_DB_PASSWORD} + ports: + - "${RESOURCE_DB_PORT}:5432" + volumes: + - ./init-scripts/resource-db:/docker-entrypoint-initdb.d + networks: + - microservices-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${RESOURCE_DB_USER} -d ${RESOURCE_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + song-db: + image: postgres:17-alpine + container_name: song-db + restart: unless-stopped + environment: + POSTGRES_DB: ${SONG_DB_NAME} + POSTGRES_USER: ${SONG_DB_USER} + POSTGRES_PASSWORD: ${SONG_DB_PASSWORD} + ports: + - "${SONG_DB_PORT}:5432" + volumes: + - ./init-scripts/song-db:/docker-entrypoint-initdb.d + networks: + - microservices-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${SONG_DB_USER} -d ${SONG_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + storage-db: + image: postgres:17-alpine + container_name: storage-db + restart: unless-stopped + environment: + POSTGRES_DB: ${STORAGE_DB_NAME} + POSTGRES_USER: ${STORAGE_DB_USER} + POSTGRES_PASSWORD: ${STORAGE_DB_PASSWORD} + ports: + - "${STORAGE_DB_PORT}:5432" + volumes: + - ./init-scripts/storage-db:/docker-entrypoint-initdb.d + networks: + - microservices-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${STORAGE_DB_USER} -d ${STORAGE_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + auth-db: + image: postgres:17-alpine + container_name: auth-db + restart: unless-stopped + environment: + POSTGRES_DB: ${AUTH_DB_NAME} + POSTGRES_USER: ${AUTH_DB_USER} + POSTGRES_PASSWORD: ${AUTH_DB_PASSWORD} + ports: + - "${AUTH_DB_PORT}:5432" + networks: + - microservices-net + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${AUTH_DB_USER} -d ${AUTH_DB_NAME}"] + interval: 10s + timeout: 5s + retries: 5 + + localstack: + image: localstack/localstack:3 + container_name: localstack + restart: unless-stopped + ports: + - "${LOCALSTACK_PORT}:4566" + environment: + - SERVICES=${LOCALSTACK_SERVICES} + - DEFAULT_REGION=${LOCALSTACK_DEFAULT_REGION} + - AWS_DEFAULT_REGION=${LOCALSTACK_DEFAULT_REGION} + - DEBUG=1 + volumes: + - localstack-data:/var/lib/localstack + - ./init-scripts/localstack:/docker-entrypoint-initaws.d + networks: + - microservices-net + healthcheck: + test: ["CMD-SHELL", "curl -sf http://localhost:4566/_localstack/health"] + interval: 30s + timeout: 10s + retries: 10 + start_period: 90s + + rabbitmq: + image: rabbitmq:4-management-alpine + container_name: rabbitmq + restart: unless-stopped + ports: + - "${RABBITMQ_PORT}:5672" + - "${RABBITMQ_MANAGEMENT_PORT}:15672" + environment: + RABBITMQ_DEFAULT_USER: ${RABBITMQ_USER} + RABBITMQ_DEFAULT_PASS: ${RABBITMQ_PASSWORD} + networks: + - microservices-net + healthcheck: + test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + elasticsearch: + image: docker.elastic.co/elasticsearch/elasticsearch:7.17.10 + container_name: elasticsearch + restart: unless-stopped + environment: + - discovery.type=single-node + - bootstrap.memory_lock=true + - "ES_JAVA_OPTS=-Xms512m -Xmx512m" + ulimits: + memlock: + soft: -1 + hard: -1 + volumes: + - elasticsearch-data:/usr/share/elasticsearch/data + ports: + - "${ELASTICSEARCH_PORT}:9200" + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9200/_cluster/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + logstash: + image: docker.elastic.co/logstash/logstash:7.17.10 + container_name: logstash + restart: unless-stopped + ports: + - "${LOGSTASH_PORT}:5000/tcp" + - "${LOGSTASH_PORT}:5000/udp" + - "${LOGSTASH_HTTP_PORT}:9600" + volumes: + - ./config/logstash.conf:/usr/share/logstash/pipeline/logstash.conf + environment: + - ELASTICSEARCH_HOST=elasticsearch + - ILM_ENABLED=false + - DATA_STREAM=false + depends_on: + elasticsearch: + condition: service_healthy + networks: + - microservices-net + + kibana: + image: docker.elastic.co/kibana/kibana:7.17.10 + container_name: kibana + restart: unless-stopped + ports: + - "${KIBANA_PORT}:5601" + environment: + - ELASTICSEARCH_HOSTS=http://elasticsearch:9200 + depends_on: + elasticsearch: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:5601/api/status"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + prometheus: + image: prom/prometheus:v2.45.0 + container_name: prometheus + restart: unless-stopped + ports: + - "${PROMETHEUS_PORT}:9090" + volumes: + - ./config/prometheus.yml:/etc/prometheus/prometheus.yml + networks: + - microservices-net + healthcheck: + test: ["CMD", "wget", "--spider", "http://localhost:9090/-/healthy"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 30s + + grafana: + image: grafana/grafana:10.2.0 + container_name: grafana + restart: unless-stopped + ports: + - "${GRAFANA_PORT}:3000" + environment: + - GF_SECURITY_ADMIN_USER=${GRAFANA_ADMIN_USER:-admin} + - GF_SECURITY_ADMIN_PASSWORD=${GRAFANA_ADMIN_PASSWORD:-admin} + - GF_INSTALL_PLUGINS= + volumes: + - grafana-data:/var/lib/grafana + - ./config/grafana/dashboards:/etc/grafana/provisioning/dashboards + - ./config/grafana/datasources:/etc/grafana/provisioning/datasources + depends_on: + prometheus: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:3000/api/health"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + + auth-service: + build: + context: . + dockerfile: auth-service/Dockerfile + container_name: auth-service + restart: unless-stopped + ports: + - "9000:9000" + environment: + - SPRING_PROFILES_ACTIVE=docker + - SERVER_PORT=9000 + - SPRING_DATASOURCE_URL=${AUTH_DB_URL} + - SPRING_DATASOURCE_USERNAME=${AUTH_DB_USER} + - SPRING_DATASOURCE_PASSWORD=${AUTH_DB_PASSWORD} + depends_on: + auth-db: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:9000/auth/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + + config-service: + build: + context: . + dockerfile: config-service/Dockerfile + container_name: config-service + restart: unless-stopped + ports: + - "${CONFIG_PORT}:${CONFIG_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${CONFIG_PORT} + - CONFIG_REPO_URI=file:///config-repo + volumes: + - ./config-repo:/config-repo + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${CONFIG_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + + discovery-service: + build: + context: . + dockerfile: discovery-service/Dockerfile + container_name: discovery-service + restart: unless-stopped + ports: + - "${DISCOVERY_PORT}:${DISCOVERY_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${DISCOVERY_PORT} + depends_on: + config-service: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${DISCOVERY_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 40s + + gateway: + build: + context: . + dockerfile: gateway/Dockerfile + container_name: gateway + restart: unless-stopped + ports: + - "${GATEWAY_PORT}:${GATEWAY_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${GATEWAY_PORT} + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=${EUREKA_URL} + depends_on: + discovery-service: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${GATEWAY_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 60s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + resource-service: + build: + context: . + dockerfile: resource-service/Dockerfile + container_name: resource-service + restart: unless-stopped + ports: + - "${RESOURCE_SERVICE_PORT}:${RESOURCE_SERVICE_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${RESOURCE_SERVICE_PORT} + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=${EUREKA_URL} + - SPRING_DATASOURCE_URL=${RESOURCE_DB_URL} + - SPRING_DATASOURCE_USERNAME=${RESOURCE_DB_USER} + - SPRING_DATASOURCE_PASSWORD=${RESOURCE_DB_PASSWORD} + - AWS_ENDPOINT_URL=${LOCALSTACK_ENDPOINT_URL} + - AWS_S3_BUCKET_NAME=${AWS_S3_BUCKET_NAME} + - AWS_REGION=${LOCALSTACK_DEFAULT_REGION} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + depends_on: + discovery-service: + condition: service_healthy + resource-db: + condition: service_healthy + rabbitmq: + condition: service_healthy + localstack: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${RESOURCE_SERVICE_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + song-service: + build: + context: . + dockerfile: song-service/Dockerfile + container_name: song-service + restart: unless-stopped + ports: + - "${SONG_SERVICE_PORT}:${SONG_SERVICE_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${SONG_SERVICE_PORT} + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=${EUREKA_URL} + - SPRING_DATASOURCE_URL=${SONG_DB_URL} + - SPRING_DATASOURCE_USERNAME=${SONG_DB_USER} + - SPRING_DATASOURCE_PASSWORD=${SONG_DB_PASSWORD} + depends_on: + discovery-service: + condition: service_healthy + song-db: + condition: service_healthy + rabbitmq: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${SONG_SERVICE_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + resource-processor: + build: + context: . + dockerfile: resource-processor/Dockerfile + container_name: resource-processor + restart: unless-stopped + ports: + - "${RESOURCE_PROCESSOR_PORT}:${RESOURCE_PROCESSOR_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${RESOURCE_PROCESSOR_PORT} + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=${EUREKA_URL} + depends_on: + discovery-service: + condition: service_healthy + rabbitmq: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${RESOURCE_PROCESSOR_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + storage-service: + build: + context: . + dockerfile: storage-service/Dockerfile + container_name: storage-service + restart: unless-stopped + ports: + - "${STORAGE_SERVICE_PORT}:${STORAGE_SERVICE_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${STORAGE_SERVICE_PORT} + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=${EUREKA_URL} + - SPRING_DATASOURCE_URL=${STORAGE_DB_URL} + - SPRING_DATASOURCE_USERNAME=${STORAGE_DB_USER} + - SPRING_DATASOURCE_PASSWORD=${STORAGE_DB_PASSWORD} + - AWS_ENDPOINT_URL=${LOCALSTACK_ENDPOINT_URL} + - AWS_ACCESS_KEY_ID=${AWS_ACCESS_KEY_ID} + - AWS_SECRET_ACCESS_KEY=${AWS_SECRET_ACCESS_KEY} + depends_on: + discovery-service: + condition: service_healthy + storage-db: + condition: service_healthy + localstack: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${STORAGE_SERVICE_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" + + qa-service: + build: + context: . + dockerfile: qa-service/Dockerfile + container_name: qa-service + restart: unless-stopped + ports: + - "${QA_SERVICE_PORT}:${QA_SERVICE_PORT}" + environment: + - SPRING_PROFILES_ACTIVE=${SPRING_PROFILES_ACTIVE} + - SERVER_PORT=${QA_SERVICE_PORT} + - EUREKA_CLIENT_SERVICEURL_DEFAULTZONE=${EUREKA_URL} + depends_on: + discovery-service: + condition: service_healthy + networks: + - microservices-net + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:${QA_SERVICE_PORT}/actuator/health/readiness"] + interval: 30s + timeout: 10s + retries: 5 + start_period: 90s + logging: + driver: json-file + options: + max-size: "10m" + max-file: "3" diff --git a/config-service/.dockerignore b/config-service/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/config-service/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/config-service/.gitignore b/config-service/.gitignore new file mode 100644 index 0000000..4f28f7a --- /dev/null +++ b/config-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +/src/main/resources/secret.yaml diff --git a/config-service/Dockerfile b/config-service/Dockerfile new file mode 100644 index 0000000..ea2a37b --- /dev/null +++ b/config-service/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY config-service/build.gradle config-service/ +RUN ./gradlew :config-service:dependencies --no-daemon + +# Copy source code and build +COPY config-service/src config-service/src +RUN ./gradlew :config-service:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/config-service/build/libs/*.jar app.jar +EXPOSE 8888 +CMD ["java", "-jar", "app.jar"] diff --git a/config-service/build.gradle b/config-service/build.gradle new file mode 100644 index 0000000..0f88fd2 --- /dev/null +++ b/config-service/build.gradle @@ -0,0 +1,25 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.1') + implementation 'org.springframework.cloud:spring-cloud-config-server' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +test { + useJUnitPlatform() +} \ No newline at end of file diff --git a/config-service/gradle.properties b/config-service/gradle.properties new file mode 100644 index 0000000..5bc8140 --- /dev/null +++ b/config-service/gradle.properties @@ -0,0 +1 @@ +systemProp.CONFIG_REPO_URI=https://github.com/PashaPoliak/config-repo.git diff --git a/config-service/src/main/java/com/audio/config/ConfigServiceApplication.java b/config-service/src/main/java/com/audio/config/ConfigServiceApplication.java new file mode 100644 index 0000000..4d27a19 --- /dev/null +++ b/config-service/src/main/java/com/audio/config/ConfigServiceApplication.java @@ -0,0 +1,16 @@ +package com.audio.config; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.cloud.config.server.EnableConfigServer; + +@SpringBootApplication +@EnableConfigServer +@EnableDiscoveryClient +public class ConfigServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ConfigServiceApplication.class, args); + } +} diff --git a/config-service/src/main/resources/application.yaml b/config-service/src/main/resources/application.yaml new file mode 100644 index 0000000..687d1a6 --- /dev/null +++ b/config-service/src/main/resources/application.yaml @@ -0,0 +1,38 @@ +server: + port: 8888 + +spring: + application: + name: config-service + cloud: + config: + server: + prefix: /config + git: + uri: ${CONFIG_REPO_URI:https://github.com/PashaPoliak/config-repo.git} + default-label: master + clone-on-start: true + force-pull: true + bootstrap: true + native: + searchLocations: file:./config-repo + +eureka: + client: + serviceUrl: + defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + instance: + prefer-ip-address: true + +management: + endpoints: + web: + exposure: + include: "*" + endpoint: + health: + probes: + enabled: true + tracing: + sampling: + probability: 1.0 diff --git a/config-service/src/main/resources/configurations/gateway.yaml b/config-service/src/main/resources/configurations/gateway.yaml new file mode 100644 index 0000000..fbd0689 --- /dev/null +++ b/config-service/src/main/resources/configurations/gateway.yaml @@ -0,0 +1,30 @@ +eureka: + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + +spring: + application: + name: gateway + cloud: + gateway: + discovery: + locator: + enabled: true + routes: + - id: song-service + uri: lb://song-service + predicates: + - Path=/songs/** + + - id: resource-service + uri: lb://resource-service + predicates: + - Path=/resources/** + +management: + tracing: + sampling: + probability: 1.0 \ No newline at end of file diff --git a/config-service/src/main/resources/configurations/resource-processor.yaml b/config-service/src/main/resources/configurations/resource-processor.yaml new file mode 100644 index 0000000..f7e3cd6 --- /dev/null +++ b/config-service/src/main/resources/configurations/resource-processor.yaml @@ -0,0 +1,39 @@ +spring: + cloud: + stream: + function: + definition: processResource + bindings: + processResource-in-0: + destination: resource-processing + group: resource-processor + rabbit: + bindings: + processResource-in-0: + consumer: + autoBindDlq: true + requeueRejected: false + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest + +resource: + service: + url: http://localhost:8080 + +song: + service: + url: http://localhost:8081 + +resilience4j: + circuitbreaker: + instances: + songService: + registerHealthIndicator: true + slidingWindowSize: 10 + minimumNumberOfCalls: 5 + permittedNumberOfCallsInHalfOpenState: 3 + waitDurationInOpenState: 10s + failureRateThreshold: 50 diff --git a/config-service/src/main/resources/configurations/resource-service.yaml b/config-service/src/main/resources/configurations/resource-service.yaml new file mode 100644 index 0000000..d92b782 --- /dev/null +++ b/config-service/src/main/resources/configurations/resource-service.yaml @@ -0,0 +1,74 @@ +eureka: + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + +server: + port: 8080 + +song: + service: + url: http://localhost:8081 + +spring: + application: + name: resource-service + datasource: + url: ${RESOURCE_DB_URL:jdbc:postgresql://${RESOURCE_DB_HOST:resource-db}:${RESOURCE_DB_PORT:5432}/${RESOURCE_DB_NAME:postgres} + username: ${RESOURCE_DB_USER:postgres} + password: ${RESOURCE_DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect + cloud: + stream: + function: + definition: processedResource + bindings: + resourceUpload-out-0: + destination: resource-processing + processedResource-in-0: + destination: resource-processed + group: resource-service-group + rabbit: + bindings: + resourceUpload-out-0: + producer: + autoBindDlq: true + processedResource-in-0: + consumer: + autoBindDlq: true + requeueRejected: false + aws: + s3: + endpoint: ${S3_ENDPOINT:http://localstack:4566} + bucket-name: mp3-bucket + credentials: + access-key: ${AWS_ACCESS_KEY_ID:minioadmin} + secret-key: ${AWS_SECRET_ACCESS_KEY:minioadmin} + region: + static: us-east-1 + rabbitmq: + host: ${RABBITMQ_HOST:rabbitmq} + port: 5672 + username: ${RABBITMQ_USERNAME:guest} + password: ${RABBITMQ_PASSWORD:guest} + +resilience4j: + circuitbreaker: + instances: + songService: + registerHealthIndicator: true + slidingWindowSize: 10 + minimumNumberOfCalls: 5 + permittedNumberOfCallsInHalfOpenState: 3 + waitDurationInOpenState: 10s + failureRateThreshold: 50 + automaticTransitionFromOpenToHalfOpenEnabled: true diff --git a/config-service/src/main/resources/configurations/song-service.yaml b/config-service/src/main/resources/configurations/song-service.yaml new file mode 100644 index 0000000..42d1ec9 --- /dev/null +++ b/config-service/src/main/resources/configurations/song-service.yaml @@ -0,0 +1,23 @@ +eureka: + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: http://localhost:8761/eureka/ + +server: + port: 8081 + +spring: + datasource: + url: ${SONG_DB_URL:jdbc:postgresql://${SONG_DB_HOST:song-db}:${SONG_DB_PORT:5432}/${SONG_DB_NAME:postgres} + username: ${SONG_DB_USER:postgres} + password: ${SONG_DB_PASSWORD:postgres} + driver-class-name: org.postgresql.Driver + jpa: + hibernate: + ddl-auto: update + show-sql: false + properties: + hibernate: + dialect: org.hibernate.dialect.PostgreSQLDialect diff --git a/config-service/src/main/resources/logback-spring.xml b/config-service/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3bfd965 --- /dev/null +++ b/config-service/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + + UTC + + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/config/grafana/dashboards/dashboard.yml b/config/grafana/dashboards/dashboard.yml new file mode 100644 index 0000000..b6571dc --- /dev/null +++ b/config/grafana/dashboards/dashboard.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +providers: + - name: 'Microservices Monitoring' + orgId: 1 + folder: '' + type: file + disableDeletion: false + editable: true + options: + path: /etc/grafana/provisioning/dashboards diff --git a/config/grafana/dashboards/microservices-monitoring.json b/config/grafana/dashboards/microservices-monitoring.json new file mode 100644 index 0000000..4e756a7 --- /dev/null +++ b/config/grafana/dashboards/microservices-monitoring.json @@ -0,0 +1,439 @@ +{ + "__inputs": [], + "__requires": [ + { + "type": "grafana", + "id": "grafana", + "name": "Grafana", + "version": "10.2.0" + }, + { + "type": "datasource", + "id": "prometheus", + "name": "Prometheus", + "version": "1.0.0" + } + ], + "title": "Microservices Monitoring", + "uid": "microservices-monitoring", + "version": 1, + "timezone": "browser", + "schemaVersion": 37, + "style": "dark", + "editable": true, + "refresh": "30s", + "time": { + "from": "now-1h", + "to": "now" + }, + "timepicker": { + "refresh_intervals": [ + "5s", + "10s", + "30s", + "1m", + "5m", + "15m", + "30m", + "1h", + "2h", + "1d" + ], + "time_options": [ + "5m", + "15m", + "1h", + "6h", + "12h", + "24h", + "2d", + "7d", + "30d" + ] + }, + "templating": { + "list": [] + }, + "panels": [ + { + "title": "JVM Memory Usage (Heap) — All Services", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 0 + }, + "id": 1, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"heap\",application=\"gateway\"}", + "legendFormat": "Gateway" + }, + { + "expr": "jvm_memory_used_bytes{area=\"heap\",application=\"resource-service\"}", + "legendFormat": "Resource Service" + }, + { + "expr": "jvm_memory_used_bytes{area=\"heap\",application=\"song-service\"}", + "legendFormat": "Song Service" + }, + { + "expr": "jvm_memory_used_bytes{area=\"heap\",application=\"storage-service\"}", + "legendFormat": "Storage Service" + }, + { + "expr": "jvm_memory_used_bytes{area=\"heap\",application=\"resource-processor\"}", + "legendFormat": "Resource Processor" + } + ] + }, + { + "title": "JVM Garbage Collection (GC Pause Time)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 0 + }, + "id": 2, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "rate(jvm_gc_pause_seconds_sum[1m])", + "legendFormat": "{{application}} - {{cause}}" + } + ] + }, + { + "title": "Gateway — Request Rate", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 8 + }, + "id": 3, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "rate(http_server_requests_seconds_count{application=\"gateway\"}[1m])", + "legendFormat": "{{method}} {{uri}}" + } + ] + }, + { + "title": "Gateway — Request Latency (P95)", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 8 + }, + "id": 4, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "s", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "histogram_quantile(0.95, rate(http_server_requests_seconds_bucket{application=\"gateway\"}[1m]))", + "legendFormat": "{{method}} {{uri}}" + } + ] + }, + { + "title": "Gateway — Error Rate", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 16 + }, + "id": 5, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "reqps", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "rate(http_server_requests_seconds_count{application=\"gateway\",status=~\"5..\"}[1m])", + "legendFormat": "5xx errors" + }, + { + "expr": "rate(http_server_requests_seconds_count{application=\"gateway\",status=~\"4..\"}[1m])", + "legendFormat": "4xx errors" + } + ] + }, + { + "title": "JVM Thread Count — All Services", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 16 + }, + "id": 6, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "short", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "jvm_threads_live_threads{application=\"gateway\"}", + "legendFormat": "Gateway" + }, + { + "expr": "jvm_threads_live_threads{application=\"resource-service\"}", + "legendFormat": "Resource Service" + }, + { + "expr": "jvm_threads_live_threads{application=\"song-service\"}", + "legendFormat": "Song Service" + }, + { + "expr": "jvm_threads_live_threads{application=\"storage-service\"}", + "legendFormat": "Storage Service" + }, + { + "expr": "jvm_threads_live_threads{application=\"resource-processor\"}", + "legendFormat": "Resource Processor" + } + ] + }, + { + "title": "JVM Memory (Non-Heap) — All Services", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 0, + "y": 24 + }, + "id": 7, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "bytes", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\",application=\"gateway\"}", + "legendFormat": "Gateway" + }, + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\",application=\"resource-service\"}", + "legendFormat": "Resource Service" + }, + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\",application=\"song-service\"}", + "legendFormat": "Song Service" + }, + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\",application=\"storage-service\"}", + "legendFormat": "Storage Service" + }, + { + "expr": "jvm_memory_used_bytes{area=\"nonheap\",application=\"resource-processor\"}", + "legendFormat": "Resource Processor" + } + ] + }, + { + "title": "CPU Usage — All Services", + "type": "timeseries", + "gridPos": { + "h": 8, + "w": 12, + "x": 12, + "y": 24 + }, + "id": 8, + "datasource": { + "type": "prometheus", + "uid": "prometheus" + }, + "fieldConfig": { + "defaults": { + "unit": "percentunit", + "custom": { + "drawStyle": "line", + "lineInterpolation": "smooth", + "showPoints": "never" + } + }, + "overrides": [] + }, + "options": { + "legend": { + "displayMode": "table", + "placement": "bottom", + "showLegend": true + } + }, + "targets": [ + { + "expr": "system_cpu_usage{application=\"gateway\"}", + "legendFormat": "Gateway" + }, + { + "expr": "system_cpu_usage{application=\"resource-service\"}", + "legendFormat": "Resource Service" + }, + { + "expr": "system_cpu_usage{application=\"song-service\"}", + "legendFormat": "Song Service" + }, + { + "expr": "system_cpu_usage{application=\"storage-service\"}", + "legendFormat": "Storage Service" + }, + { + "expr": "system_cpu_usage{application=\"resource-processor\"}", + "legendFormat": "Resource Processor" + } + ] + } + ] +} \ No newline at end of file diff --git a/config/grafana/datasources/prometheus.yml b/config/grafana/datasources/prometheus.yml new file mode 100644 index 0000000..a08624a --- /dev/null +++ b/config/grafana/datasources/prometheus.yml @@ -0,0 +1,11 @@ +apiVersion: 1 + +datasources: + - name: Prometheus + type: prometheus + access: proxy + orgId: 1 + url: http://prometheus:9090 + basicAuth: false + isDefault: true + editable: true diff --git a/config/logstash.conf b/config/logstash.conf new file mode 100644 index 0000000..7679edb --- /dev/null +++ b/config/logstash.conf @@ -0,0 +1,52 @@ +input { + tcp { + port => 5000 + codec => json + type => "docker" + } + udp { + port => 5000 + codec => json + type => "docker" + } +} + +filter { + if [type] == "docker" { + if [message] =~ /^\{/ { + json { + source => "message" + } + } + } + + if [traceId] { + mutate { + rename => { "traceId" => "trace_id" } + } + } + + if [spanId] { + mutate { + rename => { "spanId" => "span_id" } + } + } + + if ![@timestamp] { + date { + match => ["timestamp", "ISO8601"] + target => "@timestamp" + } + } +} + +output { + elasticsearch { + hosts => ["${ELASTICSEARCH_HOST}:9200"] + index => "logs-%{+YYYY.MM.dd}" + data_stream => false + } + stdout { + codec => json + } +} diff --git a/config/prometheus.yml b/config/prometheus.yml new file mode 100644 index 0000000..053cdc9 --- /dev/null +++ b/config/prometheus.yml @@ -0,0 +1,53 @@ +global: + scrape_interval: 15s + evaluation_interval: 15s + +scrape_configs: + - job_name: 'gateway' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['gateway:8080'] + labels: + application: 'gateway' + + - job_name: 'resource-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['resource-service:8081'] + labels: + application: 'resource-service' + + - job_name: 'song-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['song-service:8082'] + labels: + application: 'song-service' + + - job_name: 'resource-processor' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['resource-processor:8083'] + labels: + application: 'resource-processor' + + - job_name: 'storage-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['storage-service:8085'] + labels: + application: 'storage-service' + + - job_name: 'discovery-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['discovery-service:8761'] + labels: + application: 'discovery-service' + + - job_name: 'config-service' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['config-service:8888'] + labels: + application: 'config-service' diff --git a/discovery-service/.dockerignore b/discovery-service/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/discovery-service/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/discovery-service/.gitattributes b/discovery-service/.gitattributes new file mode 100644 index 0000000..e69de29 diff --git a/discovery-service/.gitignore b/discovery-service/.gitignore new file mode 100644 index 0000000..e69de29 diff --git a/discovery-service/Dockerfile b/discovery-service/Dockerfile new file mode 100644 index 0000000..c36c920 --- /dev/null +++ b/discovery-service/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY discovery-service/build.gradle discovery-service/ +RUN ./gradlew :discovery-service:dependencies --no-daemon + +# Copy source code and build +COPY discovery-service/src discovery-service/src +RUN ./gradlew :discovery-service:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/discovery-service/build/libs/*.jar app.jar +EXPOSE 8761 +CMD ["java", "-jar", "app.jar"] diff --git a/discovery-service/build.gradle b/discovery-service/build.gradle new file mode 100644 index 0000000..32c5661 --- /dev/null +++ b/discovery-service/build.gradle @@ -0,0 +1,29 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2025.1.1" + } +} + +dependencies { + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-server' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + testImplementation 'org.springframework.boot:spring-boot-starter-test' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/discovery-service/src/main/java/com/audio/discovery/DiscoveryServiceApplication.java b/discovery-service/src/main/java/com/audio/discovery/DiscoveryServiceApplication.java new file mode 100644 index 0000000..290ef91 --- /dev/null +++ b/discovery-service/src/main/java/com/audio/discovery/DiscoveryServiceApplication.java @@ -0,0 +1,15 @@ +package com.audio.discovery; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.netflix.eureka.server.EnableEurekaServer; + +@EnableEurekaServer +@SpringBootApplication +public class DiscoveryServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(DiscoveryServiceApplication.class, args); + } + +} diff --git a/discovery-service/src/main/resources/application.yaml b/discovery-service/src/main/resources/application.yaml new file mode 100644 index 0000000..f49ff8d --- /dev/null +++ b/discovery-service/src/main/resources/application.yaml @@ -0,0 +1,27 @@ +server: + port: 8761 + +spring: + application: + name: discovery-service + +eureka: + client: + register-with-eureka: false + fetch-registry: false + server: + enable-self-preservation: false + eviction-interval-timer-in-ms: 5000 + +management: + endpoints: + web: + exposure: + include: health, info + endpoint: + health: + probes: + enabled: true + tracing: + sampling: + probability: 1.0 diff --git a/discovery-service/src/main/resources/logback-spring.xml b/discovery-service/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3bfd965 --- /dev/null +++ b/discovery-service/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + + UTC + + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/discovery-service/src/test/java/com/audio/discovery/DiscoveryServiceApplicationTests.java b/discovery-service/src/test/java/com/audio/discovery/DiscoveryServiceApplicationTests.java new file mode 100644 index 0000000..6ebe8d2 --- /dev/null +++ b/discovery-service/src/test/java/com/audio/discovery/DiscoveryServiceApplicationTests.java @@ -0,0 +1,12 @@ +package com.audio.discovery; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class DiscoveryServiceApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/gateway/.dockerignore b/gateway/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/gateway/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/gateway/Dockerfile b/gateway/Dockerfile new file mode 100644 index 0000000..ea36ec2 --- /dev/null +++ b/gateway/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY gateway/build.gradle gateway/ +RUN ./gradlew :gateway:dependencies --no-daemon + +# Copy source code and build +COPY gateway/src gateway/src +RUN ./gradlew :gateway:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/gateway/build/libs/*.jar app.jar +EXPOSE 8080 +CMD ["java", "-jar", "app.jar"] diff --git a/gateway/build.gradle b/gateway/build.gradle new file mode 100644 index 0000000..5d00712 --- /dev/null +++ b/gateway/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2025.1.1') +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'org.springframework.cloud:spring-cloud-gateway-server-webflux' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-reactor-resilience4j' + implementation 'org.springframework.boot:spring-boot-autoconfigure' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'io.projectreactor:reactor-test' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/gateway/src/main/java/com/audio/gateway/GatewayApplication.java b/gateway/src/main/java/com/audio/gateway/GatewayApplication.java new file mode 100644 index 0000000..096af37 --- /dev/null +++ b/gateway/src/main/java/com/audio/gateway/GatewayApplication.java @@ -0,0 +1,15 @@ +package com.audio.gateway; + + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class GatewayApplication { + + public static void main(String[] args) { + SpringApplication.run(GatewayApplication.class, args); + } +} diff --git a/gateway/src/main/java/com/audio/gateway/config/GlobalGatewayErrorHandler.java b/gateway/src/main/java/com/audio/gateway/config/GlobalGatewayErrorHandler.java new file mode 100644 index 0000000..3103282 --- /dev/null +++ b/gateway/src/main/java/com/audio/gateway/config/GlobalGatewayErrorHandler.java @@ -0,0 +1,69 @@ +package com.audio.gateway.config; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.cloud.gateway.support.NotFoundException; +import org.springframework.core.annotation.Order; +import org.springframework.core.io.buffer.DataBuffer; +import org.springframework.http.HttpStatus; +import org.springframework.http.MediaType; +import org.springframework.http.server.reactive.ServerHttpResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClientResponseException; +import org.springframework.web.server.ResponseStatusException; +import org.springframework.web.server.ServerWebExchange; +import org.springframework.web.server.WebExceptionHandler; +import reactor.core.publisher.Mono; + +import java.util.Map; + +@Component +@Order(-1) +public class GlobalGatewayErrorHandler implements WebExceptionHandler { + + private final ObjectMapper objectMapper; + + public GlobalGatewayErrorHandler(ObjectMapper objectMapper) { + this.objectMapper = objectMapper; + } + + @Override + public Mono handle(ServerWebExchange exchange, Throwable ex) { + ServerHttpResponse response = exchange.getResponse(); + response.getHeaders().setContentType(MediaType.APPLICATION_JSON); + + HttpStatus status; + String message; + + if (ex instanceof ResponseStatusException rse) { + status = HttpStatus.valueOf(rse.getStatusCode().value()); + message = rse.getReason() != null ? rse.getReason() : ex.getMessage(); + } else if (ex instanceof NotFoundException) { + status = HttpStatus.SERVICE_UNAVAILABLE; + message = "Service unavailable: " + ex.getMessage(); + } else if (ex instanceof WebClientResponseException wce) { + status = HttpStatus.valueOf(wce.getStatusCode().value()); + message = wce.getMessage(); + } else { + status = HttpStatus.INTERNAL_SERVER_ERROR; + message = "Internal gateway error"; + } + + response.setStatusCode(status); + + Map body = Map.of( + "status", status.value(), + "error", status.getReasonPhrase(), + "message", message, + "path", exchange.getRequest().getPath().value() + ); + + try { + byte[] bytes = objectMapper.writeValueAsBytes(body); + DataBuffer buffer = response.bufferFactory().wrap(bytes); + return response.writeWith(Mono.just(buffer)); + } catch (JsonProcessingException e) { + return response.setComplete(); + } + } +} \ No newline at end of file diff --git a/gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java b/gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java new file mode 100644 index 0000000..0ebadf9 --- /dev/null +++ b/gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java @@ -0,0 +1,46 @@ +package com.audio.gateway.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.annotation.web.reactive.EnableWebFluxSecurity; +import org.springframework.security.config.web.server.ServerHttpSecurity; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.ReactiveJwtGrantedAuthoritiesConverterAdapter; +import org.springframework.security.web.server.SecurityWebFilterChain; + +@Configuration +@EnableWebFluxSecurity +public class SecurityConfig { + + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) { + http + .csrf(ServerHttpSecurity.CsrfSpec::disable) + .authorizeExchange(exchanges -> exchanges + .pathMatchers("/actuator/health", "/actuator/health/**", "/actuator/info").permitAll() + .pathMatchers("/actuator/**").authenticated() + .anyExchange().authenticated()) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ); + + return http.build(); + } + + @Bean + public ReactiveJwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix("ROLE_"); + authoritiesConverter.setAuthoritiesClaimName("roles"); + + ReactiveJwtAuthenticationConverter converter = new ReactiveJwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter( + new ReactiveJwtGrantedAuthoritiesConverterAdapter(authoritiesConverter) + ); + return converter; + } +} diff --git a/gateway/src/main/java/com/audio/gateway/config/TraceGatewayFilter.java b/gateway/src/main/java/com/audio/gateway/config/TraceGatewayFilter.java new file mode 100644 index 0000000..eb73bf2 --- /dev/null +++ b/gateway/src/main/java/com/audio/gateway/config/TraceGatewayFilter.java @@ -0,0 +1,43 @@ +package com.audio.gateway.config; + +import org.slf4j.MDC; +import org.springframework.cloud.gateway.filter.GatewayFilterChain; +import org.springframework.cloud.gateway.filter.GlobalFilter; +import org.springframework.core.Ordered; +import org.springframework.stereotype.Component; +import org.springframework.web.server.ServerWebExchange; +import reactor.core.publisher.Mono; + +import java.util.UUID; + +@Component +public class TraceGatewayFilter implements GlobalFilter, Ordered { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID_HEADER); + if (traceId == null || traceId.isEmpty()) { + traceId = UUID.randomUUID().toString(); + } + + final String finalTraceId = traceId; + MDC.put(TRACE_ID_HEADER, finalTraceId); + MDC.put("traceId", finalTraceId); + + exchange.getResponse().getHeaders().add(TRACE_ID_HEADER, finalTraceId); + + ServerWebExchange mutatedExchange = exchange.mutate() + .request(r -> r.header(TRACE_ID_HEADER, finalTraceId)) + .build(); + + return chain.filter(mutatedExchange) + .then(Mono.fromRunnable(MDC::clear)); + } + + @Override + public int getOrder() { + return Ordered.HIGHEST_PRECEDENCE; + } +} diff --git a/gateway/src/main/resources/application.yaml b/gateway/src/main/resources/application.yaml new file mode 100644 index 0000000..b5799eb --- /dev/null +++ b/gateway/src/main/resources/application.yaml @@ -0,0 +1,36 @@ +server: + port: 8080 + +spring: + application: + name: gateway + config: + import: configserver:${CONFIG_SERVER_URL:http://localhost:8888}/config + cloud: + gateway: + discovery: + locator: + enabled: true + lower-case-service-id: true + routes: + - id: resource-service + uri: lb://resource-service + predicates: + - Path=/resources/** + - id: song-service + uri: lb://song-service + predicates: + - Path=/songs/** + +eureka: + client: + serviceUrl: + defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + instance: + prefer-ip-address: true + +management: + endpoints: + web: + exposure: + include: health, info, refresh diff --git a/gateway/src/main/resources/logback-spring.xml b/gateway/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..c6ccdb5 --- /dev/null +++ b/gateway/src/main/resources/logback-spring.xml @@ -0,0 +1,39 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + UTC + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..c61a118 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-9.4.1-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100644 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright © 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions «$var», «${var}», «${var:-default}», «${var+SET}», +# «${var#prefix}», «${var%suffix}», and «$( cmd )»; +# * compound commands having a testable exit status, especially «case»; +# * various built-in commands including «command», «set», and «ulimit». +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..c4bdd3a --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,93 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/init-scripts/init-s3.sh b/init-scripts/init-s3.sh new file mode 100644 index 0000000..bc87281 --- /dev/null +++ b/init-scripts/init-s3.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +echo "Initializing LocalStack S3 buckets..." + +BUCKETS=("staging-bucket" "permanent-bucket") +REGION="us-east-1" + +for bucket in "${BUCKETS[@]}"; do + awslocal s3 mb "s3://${bucket}" --region "${REGION}" 2>/dev/null || { + echo "Bucket ${bucket} already exists or creation skipped" + } +done + +awslocal s3api list-buckets --region "${REGION}" 2>/dev/null || { + echo "Warning: Could not list buckets" +} + +echo "✓ LocalStack S3 initialization complete" diff --git a/init-scripts/localstack/init-s3.sh b/init-scripts/localstack/init-s3.sh new file mode 100644 index 0000000..bc87281 --- /dev/null +++ b/init-scripts/localstack/init-s3.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +echo "Initializing LocalStack S3 buckets..." + +BUCKETS=("staging-bucket" "permanent-bucket") +REGION="us-east-1" + +for bucket in "${BUCKETS[@]}"; do + awslocal s3 mb "s3://${bucket}" --region "${REGION}" 2>/dev/null || { + echo "Bucket ${bucket} already exists or creation skipped" + } +done + +awslocal s3api list-buckets --region "${REGION}" 2>/dev/null || { + echo "Warning: Could not list buckets" +} + +echo "✓ LocalStack S3 initialization complete" diff --git a/init-scripts/resource-db/init.sql b/init-scripts/resource-db/init.sql new file mode 100644 index 0000000..5617551 --- /dev/null +++ b/init-scripts/resource-db/init.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS resources ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + storage_key VARCHAR(255) NOT NULL, + storage_type VARCHAR(50) NOT NULL, + storage_bucket VARCHAR(255), + storage_path VARCHAR(255) +); diff --git a/init-scripts/song-db/init.sql b/init-scripts/song-db/init.sql new file mode 100644 index 0000000..91ea16a --- /dev/null +++ b/init-scripts/song-db/init.sql @@ -0,0 +1,14 @@ +CREATE TABLE IF NOT EXISTS songs ( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + artist VARCHAR(100) NOT NULL, + album VARCHAR(100) NOT NULL, + duration VARCHAR(5) NOT NULL, + "year" VARCHAR(4) NOT NULL +); + +INSERT INTO songs (id, name, artist, album, duration, "year") +VALUES (100001, 'Midnight City', 'M83', 'Hurry Up, We''re Dreaming', '04:03', '2011'); + +INSERT INTO songs (id, name, artist, album, duration, "year") +VALUES (100002, 'Harder, Better, Faster, Stronger', 'Daft Punk', 'Discovery', '03:44', '2001'); diff --git a/init-scripts/storage-db/init.sql b/init-scripts/storage-db/init.sql new file mode 100644 index 0000000..65da9e5 --- /dev/null +++ b/init-scripts/storage-db/init.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS storages ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + storage_type VARCHAR(50) NOT NULL, + bucket VARCHAR(255) NOT NULL, + path VARCHAR(255) NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT unique_storage_type_bucket_path UNIQUE (storage_type, bucket, path) +); diff --git a/qa-service/.dockerignore b/qa-service/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/qa-service/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/qa-service/.gitignore b/qa-service/.gitignore new file mode 100644 index 0000000..870fdfd --- /dev/null +++ b/qa-service/.gitignore @@ -0,0 +1,41 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ +target/ +gradle/ +allure-report/ +allure-results/ \ No newline at end of file diff --git a/qa-service/Dockerfile b/qa-service/Dockerfile new file mode 100644 index 0000000..30f4d00 --- /dev/null +++ b/qa-service/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY qa-service/build.gradle qa-service/ +RUN ./gradlew :qa-service:dependencies --no-daemon + +# Copy source code and build +COPY qa-service/src qa-service/src +RUN ./gradlew :qa-service:bootJar --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/qa-service/build/libs/*.jar app.jar +EXPOSE 8080 +CMD ["java", "-jar", "app.jar"] diff --git a/qa-service/build.gradle b/qa-service/build.gradle new file mode 100644 index 0000000..71ba980 --- /dev/null +++ b/qa-service/build.gradle @@ -0,0 +1,37 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' +description = 'test' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + testImplementation 'io.rest-assured:rest-assured:5.4.0' + testImplementation 'org.jbehave:jbehave-core:5.0' + testImplementation 'org.jbehave:jbehave-spring:5.0' + testImplementation 'io.qameta.allure:allure-junit5:2.24.0' + testImplementation 'io.qameta.allure:allure-rest-assured:2.24.0' + testImplementation 'io.qameta.allure:allure-jbehave:2.24.0' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/qa-service/src/main/java/com/audio/test/TestApplication.java b/qa-service/src/main/java/com/audio/test/TestApplication.java new file mode 100644 index 0000000..aaa109b --- /dev/null +++ b/qa-service/src/main/java/com/audio/test/TestApplication.java @@ -0,0 +1,12 @@ +package com.audio.test; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class TestApplication { + + public static void main(String[] args) { + SpringApplication.run(TestApplication.class, args); + } +} diff --git a/qa-service/src/main/resources/application.properties b/qa-service/src/main/resources/application.properties new file mode 100644 index 0000000..030e7c6 --- /dev/null +++ b/qa-service/src/main/resources/application.properties @@ -0,0 +1 @@ +spring.application.name=qa-service diff --git a/qa-service/src/test/java/com/audio/test/TestApplicationTests.java b/qa-service/src/test/java/com/audio/test/TestApplicationTests.java new file mode 100644 index 0000000..3ad5486 --- /dev/null +++ b/qa-service/src/test/java/com/audio/test/TestApplicationTests.java @@ -0,0 +1,12 @@ +package com.audio.test; + +import org.junit.jupiter.api.Test; +import org.springframework.boot.test.context.SpringBootTest; + +@SpringBootTest +class TestApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/qa-service/src/test/java/com/audio/test/integration/ResourceIntegrationTest.java b/qa-service/src/test/java/com/audio/test/integration/ResourceIntegrationTest.java new file mode 100644 index 0000000..71895b7 --- /dev/null +++ b/qa-service/src/test/java/com/audio/test/integration/ResourceIntegrationTest.java @@ -0,0 +1,10 @@ +package com.audio.test.integration; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT) +@ActiveProfiles("test") +class ResourceIntegrationTest { + +} diff --git a/qa-service/src/test/java/com/audio/test/steps/ResourceSteps.java b/qa-service/src/test/java/com/audio/test/steps/ResourceSteps.java new file mode 100644 index 0000000..5c827d5 --- /dev/null +++ b/qa-service/src/test/java/com/audio/test/steps/ResourceSteps.java @@ -0,0 +1,31 @@ +package com.audio.test.steps; + +import org.jbehave.core.annotations.Given; +import org.jbehave.core.annotations.Then; +import org.jbehave.core.annotations.When; +import io.qameta.allure.restassured.AllureRestAssured; +import io.restassured.RestAssured; +import static io.restassured.RestAssured.given; +import static org.hamcrest.Matchers.notNullValue; + +public class ResourceSteps { + + @Given("resource service is up") + public void serviceUp() { + RestAssured.baseURI = "http://localhost:8080"; + RestAssured.filters(new AllureRestAssured()); + } + + @When("I upload an mp3 file") + public void upload() { + } + + @Then("I receive 200 with id") + public void verify() { + given().when().get("/resources/1").then().statusCode(200).body("id", notNullValue()); + } + + @Then("processor receives message") + public void checkMessage() { + } +} diff --git a/qa-service/src/test/resources/stories/resource_upload.story b/qa-service/src/test/resources/stories/resource_upload.story new file mode 100644 index 0000000..038cda5 --- /dev/null +++ b/qa-service/src/test/resources/stories/resource_upload.story @@ -0,0 +1,6 @@ +Scenario: Resource upload flow + +Given resource service is up +When I upload an mp3 file +Then I receive 200 with id +And processor receives message diff --git a/resource-processor/.dockerignore b/resource-processor/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/resource-processor/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/resource-processor/Dockerfile b/resource-processor/Dockerfile new file mode 100644 index 0000000..f021150 --- /dev/null +++ b/resource-processor/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY resource-processor/build.gradle resource-processor/ +RUN ./gradlew :resource-processor:dependencies --no-daemon + +# Copy source code and build +COPY resource-processor/src resource-processor/src +RUN ./gradlew :resource-processor:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/resource-processor/build/libs/*.jar app.jar +EXPOSE 8083 +CMD ["java", "-jar", "app.jar"] diff --git a/resource-processor/build.gradle b/resource-processor/build.gradle new file mode 100644 index 0000000..0582512 --- /dev/null +++ b/resource-processor/build.gradle @@ -0,0 +1,48 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.5' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2025.1.1') +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2025.1.1" + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.1') + implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-stream-rabbit' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + implementation 'org.springframework.boot:spring-boot-starter-aspectj' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + implementation 'org.apache.tika:tika-core:3.3.0' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/resource-processor/src/main/java/com/audio/processor/ProcessorApplication.java b/resource-processor/src/main/java/com/audio/processor/ProcessorApplication.java new file mode 100644 index 0000000..615a48f --- /dev/null +++ b/resource-processor/src/main/java/com/audio/processor/ProcessorApplication.java @@ -0,0 +1,18 @@ +package com.audio.processor; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.retry.annotation.EnableRetry; + +@SpringBootApplication +@EnableConfigurationProperties +@EnableRetry +@EnableDiscoveryClient +public class ProcessorApplication { + + public static void main(String[] args) { + SpringApplication.run(ProcessorApplication.class, args); + } +} diff --git a/resource-processor/src/main/java/com/audio/processor/config/ResourceProcessorConfig.java b/resource-processor/src/main/java/com/audio/processor/config/ResourceProcessorConfig.java new file mode 100644 index 0000000..32ba489 --- /dev/null +++ b/resource-processor/src/main/java/com/audio/processor/config/ResourceProcessorConfig.java @@ -0,0 +1,56 @@ +package com.audio.processor.config; + +import org.slf4j.MDC; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; + +@Configuration +@RefreshScope +public class ResourceProcessorConfig { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Value("${resource.service.url:http://localhost:8081}") + private String resourceServiceUrl; + + @Value("${song.service.url:http://localhost:8082}") + private String songServiceUrl; + + @Bean("resourceServiceClient") + public WebClient resourceServiceClient() { + return buildTraceableWebClient(resourceServiceUrl); + } + + @Bean("songServiceClient") + public WebClient songServiceClient() { + return buildTraceableWebClient(songServiceUrl); + } + + private WebClient buildTraceableWebClient(String baseUrl) { + return WebClient.builder() + .baseUrl(baseUrl) + .filter(tracePropagationFilter()) + .build(); + } + + private ExchangeFilterFunction tracePropagationFilter() { + return (request, next) -> { + String traceId = MDC.get("traceId"); + if (traceId == null || traceId.isEmpty()) { + traceId = MDC.get(TRACE_ID_HEADER); + } + if (traceId != null && !traceId.isEmpty()) { + ClientRequest mutatedRequest = ClientRequest.from(request) + .header(TRACE_ID_HEADER, traceId) + .build(); + return next.exchange(mutatedRequest); + } + return next.exchange(request); + }; + } +} diff --git a/resource-processor/src/main/java/com/audio/processor/dto/SongMetadata.java b/resource-processor/src/main/java/com/audio/processor/dto/SongMetadata.java new file mode 100644 index 0000000..a59d4b0 --- /dev/null +++ b/resource-processor/src/main/java/com/audio/processor/dto/SongMetadata.java @@ -0,0 +1,19 @@ +package com.audio.processor.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SongMetadata { + private Long id; + private String name; + private String artist; + private String album; + private String duration; + private String year; +} diff --git a/resource-processor/src/main/java/com/audio/processor/messaging/ResourceEventConsumer.java b/resource-processor/src/main/java/com/audio/processor/messaging/ResourceEventConsumer.java new file mode 100644 index 0000000..34fb48a --- /dev/null +++ b/resource-processor/src/main/java/com/audio/processor/messaging/ResourceEventConsumer.java @@ -0,0 +1,50 @@ +package com.audio.processor.messaging; + +import com.audio.processor.service.ResourceProcessorService; +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.context.annotation.Bean; +import org.springframework.stereotype.Component; + +import java.util.Map; +import java.util.function.Consumer; + +@Slf4j +@Component +public class ResourceEventConsumer { + + private final ResourceProcessorService resourceProcessorService; + + public ResourceEventConsumer(ResourceProcessorService resourceProcessorService) { + this.resourceProcessorService = resourceProcessorService; + } + + @Bean + public Consumer> processResource() { + return message -> { + Long resourceId = Long.valueOf(message.get("payload").toString()); + @SuppressWarnings("unchecked") + Map headers = (Map) message.get("headers"); + + String traceId = null; + if (headers != null) { + Object headerTraceId = headers.get("X-Trace-Id"); + if (headerTraceId != null) { + traceId = headerTraceId.toString(); + } + } + + if (traceId != null && !traceId.isEmpty()) { + MDC.put("X-Trace-Id", traceId); + MDC.put("traceId", traceId); + } + + try { + log.info("Received processing event for resource: {} with traceId: {}", resourceId, traceId); + resourceProcessorService.process(resourceId); + } finally { + MDC.clear(); + } + }; + } +} diff --git a/resource-processor/src/main/java/com/audio/processor/service/Mp3MetadataExtractor.java b/resource-processor/src/main/java/com/audio/processor/service/Mp3MetadataExtractor.java new file mode 100644 index 0000000..17f408c --- /dev/null +++ b/resource-processor/src/main/java/com/audio/processor/service/Mp3MetadataExtractor.java @@ -0,0 +1,66 @@ +package com.audio.processor.service; + +import com.audio.processor.dto.SongMetadata; +import org.apache.tika.exception.TikaException; +import org.apache.tika.metadata.Metadata; +import org.apache.tika.parser.AutoDetectParser; +import org.apache.tika.sax.BodyContentHandler; +import org.springframework.stereotype.Component; +import org.xml.sax.SAXException; + +import java.io.ByteArrayInputStream; +import java.io.IOException; + +@Component +public class Mp3MetadataExtractor { + + public SongMetadata extract(byte[] audioData) { + try (ByteArrayInputStream inputStream = new ByteArrayInputStream(audioData)) { + Metadata metadata = new Metadata(); + new AutoDetectParser().parse(inputStream, new BodyContentHandler(), metadata); + + SongMetadata result = new SongMetadata(); + + String title = coalesce(metadata.get("title"), metadata.get("dc:title"), "Unknown Title"); + result.setName(title); + + String artist = coalesce(metadata.get("artist"), metadata.get("dc:creator"), "Unknown Artist"); + result.setArtist(artist); + + String album = coalesce(metadata.get("album"), metadata.get("dc:subject"), "Unknown Album"); + result.setAlbum(album); + + String rawDuration = metadata.get("xmpDM:duration"); + result.setDuration(formatDuration(rawDuration)); + + String year = coalesce(metadata.get("xmpDM:releaseDate"), metadata.get("date"), "2000"); + if (year.length() > 4) + year = year.substring(year.length() - 4); + result.setYear(year); + + return result; + } catch (TikaException | IOException | SAXException e) { + throw new IllegalArgumentException("Failed to parse MP3 file: " + e.getMessage(), e); + } + } + + private String coalesce(String... values) { + for (String value : values) { + if (value != null && !value.isBlank()) { + return value; + } + } + return values[values.length - 1]; + } + + private String formatDuration(String rawMs) { + if (rawMs == null || rawMs.isBlank()) + return "00:00"; + try { + long totalSeconds = (long) (Double.parseDouble(rawMs) / 1000); + return String.format("%02d:%02d", totalSeconds / 60, totalSeconds % 60); + } catch (NumberFormatException e) { + return "00:00"; + } + } +} diff --git a/resource-processor/src/main/java/com/audio/processor/service/ResourceProcessorService.java b/resource-processor/src/main/java/com/audio/processor/service/ResourceProcessorService.java new file mode 100644 index 0000000..a009cec --- /dev/null +++ b/resource-processor/src/main/java/com/audio/processor/service/ResourceProcessorService.java @@ -0,0 +1,61 @@ +package com.audio.processor.service; + +import com.audio.processor.dto.SongMetadata; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Service +public class ResourceProcessorService { + + private final WebClient resourceServiceClient; + private final WebClient songServiceClient; + private final Mp3MetadataExtractor extractor; + private final StreamBridge streamBridge; + + public ResourceProcessorService(@Qualifier("resourceServiceClient") WebClient resourceServiceClient, + @Qualifier("songServiceClient") WebClient songServiceClient, + Mp3MetadataExtractor extractor, + StreamBridge streamBridge) { + this.resourceServiceClient = resourceServiceClient; + this.songServiceClient = songServiceClient; + this.extractor = extractor; + this.streamBridge = streamBridge; + } + + @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) + public byte[] fetchResource(Long id) { + return resourceServiceClient.get() + .uri("/resources/{id}", id) + .retrieve() + .bodyToMono(byte[].class) + .block(); + } + + @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) + public void saveSongMetadata(SongMetadata metadata) { + songServiceClient.post() + .uri("/songs") + .bodyValue(metadata) + .retrieve() + .toBodilessEntity() + .block(); + } + + public SongMetadata process(Long resourceId) { + log.info("Processing resource: {}", resourceId); + byte[] data = fetchResource(resourceId); + SongMetadata metadata = extractor.extract(data); + metadata.setId(resourceId); + saveSongMetadata(metadata); + log.info("Publishing processedResource completion event for ID: {}", resourceId); + streamBridge.send("resourceProcessed-out-0", resourceId); + + return metadata; + } +} diff --git a/resource-processor/src/main/resources/application.yaml b/resource-processor/src/main/resources/application.yaml new file mode 100644 index 0000000..13734d8 --- /dev/null +++ b/resource-processor/src/main/resources/application.yaml @@ -0,0 +1,39 @@ +spring: + application: + name: resource-processor + config: + import: configserver:${CONFIG_SERVER_URL:http://localhost:8888}/config + cloud: + stream: + function: + definition: processResource + bindings: + processResource-in-0: + destination: resource-processing + group: resource-processor + resourceProcessed-out-0: + destination: resource-processed + rabbit: + bindings: + processResource-in-0: + consumer: + autoBindDlq: true + requeueRejected: false + resourceProcessed-out-0: + producer: + autoBindDlq: true + rabbitmq: + host: ${RABBITMQ_HOST:localhost} + port: ${RABBITMQ_PORT:5672} + username: ${RABBITMQ_USER:guest} + password: ${RABBITMQ_PASSWORD:guest} + +server: + port: ${RESOURCE_PROCESSOR_PORT:8083} + +resource: + service: + url: ${RESOURCE_SERVICE_URL:http://localhost:8081} +song: + service: + url: ${SONG_SERVICE_URL:http://localhost:8082} diff --git a/resource-processor/src/main/resources/logback-spring.xml b/resource-processor/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3bfd965 --- /dev/null +++ b/resource-processor/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + + UTC + + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/resource-processor/src/test/java/com/audio/processor/service/Mp3MetadataExtractorTest.java b/resource-processor/src/test/java/com/audio/processor/service/Mp3MetadataExtractorTest.java new file mode 100644 index 0000000..ba9ef85 --- /dev/null +++ b/resource-processor/src/test/java/com/audio/processor/service/Mp3MetadataExtractorTest.java @@ -0,0 +1,75 @@ +package com.audio.processor.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.fail; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; + +import java.util.stream.Stream; + +class Mp3MetadataExtractorTest { + + private final Mp3MetadataExtractor extractor = new Mp3MetadataExtractor(); + + @Test + void extractWithInvalidDataThrowsException() { + byte[] invalidData = new byte[0]; + assertThrows(IllegalArgumentException.class, () -> extractor.extract(invalidData)); + } + + @Test + void extractWithNullDataThrowsException() { + assertThrows(NullPointerException.class, () -> extractor.extract(null)); + } + + @ParameterizedTest + @MethodSource("provideDurationFormats") + void formatDurationFormatsCorrectly(String rawMs, String expected) { + try { + java.lang.reflect.Method method = + Mp3MetadataExtractor.class.getDeclaredMethod("formatDuration", String.class); + method.setAccessible(true); + String result = (String) method.invoke(extractor, rawMs); + assertEquals(expected, result); + } catch (Exception e) { + fail("Reflection failed"); + } + } + + private static Stream provideDurationFormats() { + return Stream.of( + Arguments.of("180000", "03:00"), + Arguments.of("61000", "01:01"), + Arguments.of(null, "00:00"), + Arguments.of("", "00:00"), + Arguments.of("invalid", "00:00") + ); + } + + @ParameterizedTest + @MethodSource("provideCoalesceValues") + void coalesceReturnsFirstNonNull(String[] values, String expected) { + try { + java.lang.reflect.Method method = Mp3MetadataExtractor.class.getDeclaredMethod("coalesce", String[].class); + method.setAccessible(true); + String result = (String) method.invoke(extractor, (Object) values); + assertEquals(expected, result); + } catch (Exception e) { + fail("Reflection failed"); + } + } + + private static Stream provideCoalesceValues() { + return Stream.of( + Arguments.of(new String[] {"first", "second"}, "first"), + Arguments.of(new String[] {null, "second"}, "second"), + Arguments.of(new String[] {null, null, "third"}, "third"), + Arguments.of(new String[] {"", "second"}, "second"), + Arguments.of(new String[] {null}, null) + ); + } +} diff --git a/resource-processor/src/test/java/com/audio/processor/service/ResourceProcessorServiceTest.java b/resource-processor/src/test/java/com/audio/processor/service/ResourceProcessorServiceTest.java new file mode 100644 index 0000000..92c119b --- /dev/null +++ b/resource-processor/src/test/java/com/audio/processor/service/ResourceProcessorServiceTest.java @@ -0,0 +1,115 @@ +package com.audio.processor.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.audio.processor.dto.SongMetadata; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@ExtendWith(MockitoExtension.class) +class ResourceProcessorServiceTest { + private static final Long RESOURCE_ID = 1L; + private static final String NAME = "Song"; + private static final String ARTIST = "Artist"; + private static final String ALBUM = "Album"; + private static final String DURATION = "03:00"; + private static final String YEAR = "2023"; + private static final byte[] AUDIO_DATA = "fake audio data".getBytes(); + private static final String RESOURCE_PATH = "/resources/{id}"; + private static final String SONGS_PATH = "/songs"; + private static final String STREAM_BINDING = "resourceProcessed-out-0"; + private static final String SERVICE_ERROR_MESSAGE = "Service error"; + + @Mock + private WebClient resourceServiceClient; + + @Mock + private Mp3MetadataExtractor metadataExtractor; + + @Mock + private WebClient songServiceClient; + + @Mock + private StreamBridge streamBridge; + + private ResourceProcessorService service; + + @BeforeEach + void setUp() { + service = new ResourceProcessorService( + resourceServiceClient, songServiceClient, metadataExtractor, streamBridge); + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + @Test + void processValidResourceIdReturnsMetadata() { + byte[] audioData = AUDIO_DATA; + SongMetadata metadata = buildMetadata(); + + WebClient.RequestHeadersUriSpec mockUriSpec = mock(WebClient.RequestHeadersUriSpec.class); + WebClient.RequestHeadersSpec mockHeadersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec mockResponseSpec = mock(WebClient.ResponseSpec.class); + + when(resourceServiceClient.get()).thenReturn(mockUriSpec); + when(mockUriSpec.uri(RESOURCE_PATH, RESOURCE_ID)).thenReturn(mockHeadersSpec); + when(mockHeadersSpec.retrieve()).thenReturn(mockResponseSpec); + when(mockResponseSpec.bodyToMono(byte[].class)).thenReturn(Mono.just(audioData)); + when(metadataExtractor.extract(audioData)).thenReturn(metadata); + + WebClient.RequestBodyUriSpec mockBodyUriSpec = mock(WebClient.RequestBodyUriSpec.class); + WebClient.RequestHeadersSpec mockPostHeadersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec mockPostResponseSpec = mock(WebClient.ResponseSpec.class); + + when(songServiceClient.post()).thenReturn(mockBodyUriSpec); + when(mockBodyUriSpec.uri(SONGS_PATH)).thenReturn(mockBodyUriSpec); + when(mockBodyUriSpec.bodyValue(metadata)).thenReturn(mockPostHeadersSpec); + when(mockPostHeadersSpec.retrieve()).thenReturn(mockPostResponseSpec); + when(mockPostResponseSpec.toBodilessEntity()).thenReturn(Mono.empty()); + + SongMetadata result = service.process(RESOURCE_ID); + + assertEquals(RESOURCE_ID, result.getId()); + assertEquals(NAME, result.getName()); + assertEquals(ARTIST, result.getArtist()); + assertEquals(ALBUM, result.getAlbum()); + assertEquals(DURATION, result.getDuration()); + assertEquals(YEAR, result.getYear()); + verify(streamBridge).send(eq(STREAM_BINDING), eq(RESOURCE_ID)); + } + + @Test + void processWhenResourceServiceFailsThrowsException() { + WebClient.RequestHeadersUriSpec mockUriSpec = mock(WebClient.RequestHeadersUriSpec.class); + WebClient.RequestHeadersSpec mockHeadersSpec = mock(WebClient.RequestHeadersSpec.class); + WebClient.ResponseSpec mockResponseSpec = mock(WebClient.ResponseSpec.class); + + when(resourceServiceClient.get()).thenReturn(mockUriSpec); + when(mockUriSpec.uri(RESOURCE_PATH, RESOURCE_ID)).thenReturn(mockHeadersSpec); + when(mockHeadersSpec.retrieve()).thenReturn(mockResponseSpec); + when(mockResponseSpec.bodyToMono(byte[].class)).thenReturn( + Mono.error(new RuntimeException(SERVICE_ERROR_MESSAGE))); + + assertThrows(RuntimeException.class, () -> service.process(RESOURCE_ID)); + } + + private SongMetadata buildMetadata() { + return SongMetadata.builder() + .name(NAME) + .artist(ARTIST) + .album(ALBUM) + .duration(DURATION) + .year(YEAR) + .build(); + } +} diff --git a/resource-service/.dockerignore b/resource-service/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/resource-service/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/resource-service/.gitattributes b/resource-service/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/resource-service/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/resource-service/.gitignore b/resource-service/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/resource-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/resource-service/Dockerfile b/resource-service/Dockerfile new file mode 100644 index 0000000..7c18618 --- /dev/null +++ b/resource-service/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY resource-service/build.gradle resource-service/ +RUN ./gradlew :resource-service:dependencies --no-daemon + +# Copy source code and build +COPY resource-service/src resource-service/src +RUN ./gradlew :resource-service:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/resource-service/build/libs/*.jar app.jar +EXPOSE 8081 +CMD ["java", "-jar", "app.jar"] diff --git a/resource-service/build.gradle b/resource-service/build.gradle new file mode 100644 index 0000000..77cc0d2 --- /dev/null +++ b/resource-service/build.gradle @@ -0,0 +1,63 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2025.1.1') +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework:spring-webflux' + implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-stream-rabbit' + implementation 'org.springframework.retry:spring-retry' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'org.postgresql:postgresql' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + runtimeOnly 'com.h2database:h2' + testImplementation 'com.h2database:h2' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + implementation("software.amazon.awssdk:s3:2.42.36") + testImplementation("io.findify:s3mock_2.13:0.2.6") + testImplementation("org.wiremock:wiremock-standalone:3.6.0") + testImplementation("org.springframework.cloud:spring-cloud-starter-contract-stub-runner") + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc') + testImplementation('org.springframework.security:spring-security-test') +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/resource-service/src/main/java/com/audio/resource/ResourceApplication.java b/resource-service/src/main/java/com/audio/resource/ResourceApplication.java new file mode 100644 index 0000000..d8d4aaf --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/ResourceApplication.java @@ -0,0 +1,16 @@ +package com.audio.resource; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.retry.annotation.EnableRetry; + +@SpringBootApplication +@EnableRetry +@EnableDiscoveryClient +public class ResourceApplication { + + public static void main(String[] args) { + SpringApplication.run(ResourceApplication.class, args); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/config/ResourceEventListener.java b/resource-service/src/main/java/com/audio/resource/config/ResourceEventListener.java new file mode 100644 index 0000000..2026f89 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/config/ResourceEventListener.java @@ -0,0 +1,16 @@ +package com.audio.resource.config; + +import com.audio.resource.messaging.ResourceEventConsumer; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.function.Consumer; + +@Configuration +public class ResourceEventListener { + + @Bean + public Consumer processedResource(ResourceEventConsumer eventConsumer) { + return eventConsumer::handleResourceProcessed; + } +} diff --git a/resource-service/src/main/java/com/audio/resource/config/S3Config.java b/resource-service/src/main/java/com/audio/resource/config/S3Config.java new file mode 100644 index 0000000..5d5aa44 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/config/S3Config.java @@ -0,0 +1,47 @@ +package com.audio.resource.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +import java.net.URI; + +@Configuration +@RefreshScope +public class S3Config { + + @Value("${spring.cloud.aws.s3.endpoint:http://localhost:4566}") + private String endpoint; + + @Value("${spring.cloud.aws.s3.access-key:minioadmin}") + private String accessKey; + + @Value("${spring.cloud.aws.s3.secret-key:minioadmin}") + private String secretKey; + + @Value("${spring.cloud.aws.region:us-east-1}") + private String region; + + @Bean + @Primary + public S3Client s3Client() { + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .chunkedEncodingEnabled(false) + .build()) + .build(); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java b/resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java new file mode 100644 index 0000000..c7edf16 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java @@ -0,0 +1,46 @@ +package com.audio.resource.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.web.SecurityFilterChain; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/actuator/health/**", "/actuator/info").permitAll() + .requestMatchers("/actuator/**").authenticated() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix("ROLE_"); + authoritiesConverter.setAuthoritiesClaimName("roles"); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + return converter; + } +} diff --git a/resource-service/src/main/java/com/audio/resource/config/TraceIdInterceptor.java b/resource-service/src/main/java/com/audio/resource/config/TraceIdInterceptor.java new file mode 100644 index 0000000..a97143c --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/config/TraceIdInterceptor.java @@ -0,0 +1,32 @@ +package com.audio.resource.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Component +public class TraceIdInterceptor implements HandlerInterceptor { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String traceId = request.getHeader(TRACE_ID_HEADER); + if (traceId == null || traceId.isEmpty()) { + traceId = UUID.randomUUID().toString(); + } + MDC.put(TRACE_ID_HEADER, traceId); + MDC.put("traceId", traceId); + response.setHeader(TRACE_ID_HEADER, traceId); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.clear(); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/config/WebClientTraceConfig.java b/resource-service/src/main/java/com/audio/resource/config/WebClientTraceConfig.java new file mode 100644 index 0000000..46229cd --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/config/WebClientTraceConfig.java @@ -0,0 +1,37 @@ +package com.audio.resource.config; + +import org.slf4j.MDC; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.reactive.function.client.ClientRequest; +import org.springframework.web.reactive.function.client.ExchangeFilterFunction; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Configuration +public class WebClientTraceConfig { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Bean + public WebClient.Builder webClientBuilder() { + return WebClient.builder() + .filter(tracePropagationFilter()); + } + + private ExchangeFilterFunction tracePropagationFilter() { + return (request, next) -> { + String traceId = MDC.get("traceId"); + if (traceId == null || traceId.isEmpty()) { + traceId = MDC.get(TRACE_ID_HEADER); + } + if (traceId != null && !traceId.isEmpty()) { + ClientRequest mutatedRequest = ClientRequest.from(request) + .header(TRACE_ID_HEADER, traceId) + .build(); + return next.exchange(mutatedRequest); + } + return next.exchange(request); + }; + } +} diff --git a/resource-service/src/main/java/com/audio/resource/config/WebConfig.java b/resource-service/src/main/java/com/audio/resource/config/WebConfig.java new file mode 100644 index 0000000..0709b62 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.audio.resource.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final TraceIdInterceptor traceIdInterceptor; + + public WebConfig(TraceIdInterceptor traceIdInterceptor) { + this.traceIdInterceptor = traceIdInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(traceIdInterceptor); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/controller/ResourceController.java b/resource-service/src/main/java/com/audio/resource/controller/ResourceController.java new file mode 100644 index 0000000..9bc6d83 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/controller/ResourceController.java @@ -0,0 +1,47 @@ +package com.audio.resource.controller; + +import com.audio.resource.dto.ResourceDeleteResponse; +import com.audio.resource.dto.ResourceUploadResponse; +import com.audio.resource.service.ResourceService; +import org.springframework.http.HttpHeaders; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RestController; +import jakarta.servlet.http.HttpServletRequest; + +@RestController +@RequestMapping("/resources") +public class ResourceController { + + private final ResourceService resourceService; + + public ResourceController(ResourceService resourceService) { + this.resourceService = resourceService; + } + + @PostMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity uploadResource(HttpServletRequest request) { + return ResponseEntity.ok(resourceService.upload(request)); + } + + @GetMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity get(@PathVariable String id) { + return ResponseEntity.ok() + .header(HttpHeaders.CONTENT_TYPE, "audio/mpeg") + .body(resourceService.getById(id)); + } + + @DeleteMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteResources(@RequestParam String id) { + return ResponseEntity.ok(resourceService.delete(id)); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/dto/ResourceDeleteResponse.java b/resource-service/src/main/java/com/audio/resource/dto/ResourceDeleteResponse.java new file mode 100644 index 0000000..1911e9e --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/dto/ResourceDeleteResponse.java @@ -0,0 +1,14 @@ +package com.audio.resource.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class ResourceDeleteResponse { + private List ids; +} \ No newline at end of file diff --git a/resource-service/src/main/java/com/audio/resource/dto/ResourceUploadResponse.java b/resource-service/src/main/java/com/audio/resource/dto/ResourceUploadResponse.java new file mode 100644 index 0000000..934424b --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/dto/ResourceUploadResponse.java @@ -0,0 +1,18 @@ +package com.audio.resource.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class ResourceUploadResponse { + @JsonProperty("id") + private Long id; + @JsonProperty("s3Url") + private String s3Url; +} diff --git a/resource-service/src/main/java/com/audio/resource/dto/StorageResponse.java b/resource-service/src/main/java/com/audio/resource/dto/StorageResponse.java new file mode 100644 index 0000000..203f679 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/dto/StorageResponse.java @@ -0,0 +1,73 @@ +package com.audio.resource.dto; + +import com.audio.resource.entity.StorageType; +import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StorageResponse { + @JsonProperty("storages") + private List storages; + + @JsonIgnore + private boolean stubData = false; + + public static StorageResponse stub() { + return stub(StubConfig.defaults()); + } + + public static StorageResponse stub(StubConfig config) { + StorageResponse response = new StorageResponse(); + response.setStubData(true); + response.setStorages(List.of( + new StorageDto(1L, StorageType.STAGING, config.stagingBucket(), config.stagingPath()), + new StorageDto(2L, StorageType.PERMANENT, config.permanentBucket(), config.permanentPath()) + )); + return response; + } + + @Getter + @Setter + @NoArgsConstructor + @AllArgsConstructor + public static class StorageDto { + private Long id; + private StorageType storageType; + private String bucket; + private String path; + } + + public record StubConfig(String stagingBucket, + String stagingPath, + String permanentBucket, + String permanentPath) { + public static StubConfig defaults() { + return new StubConfig("staging-bucket", "/staging", "permanent-bucket", "/permanent"); + } + } + + @Getter + @Component + public static class StubConfigProvider { + private final StubConfig stubConfig; + + public StubConfigProvider( + @Value("${storage.fallback.staging-bucket:staging-bucket}") String stagingBucket, + @Value("${storage.fallback.staging-path:/staging}") String stagingPath, + @Value("${storage.fallback.permanent-bucket:permanent-bucket}") String permanentBucket, + @Value("${storage.fallback.permanent-path:/permanent}") String permanentPath) { + this.stubConfig = new StubConfig(stagingBucket, stagingPath, permanentBucket, permanentPath); + } + } +} diff --git a/resource-service/src/main/java/com/audio/resource/entity/ResourceEntity.java b/resource-service/src/main/java/com/audio/resource/entity/ResourceEntity.java new file mode 100644 index 0000000..f50a066 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/entity/ResourceEntity.java @@ -0,0 +1,48 @@ +package com.audio.resource.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "resources") +public class ResourceEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Column(name = "storage_key", nullable = false) + private String storageKey; + + @Enumerated(EnumType.STRING) + @Column(name = "storage_type", nullable = false) + private StorageType storageType; + + @Column(name = "storage_bucket") + private String storageBucket; + + @Column(name = "storage_path") + private String storagePath; + + public ResourceEntity(String storageKey, StorageType storageType, String storageBucket, String storagePath) { + this.storageKey = storageKey; + this.storageType = storageType; + this.storageBucket = storageBucket; + this.storagePath = storagePath; + } +} diff --git a/resource-service/src/main/java/com/audio/resource/entity/StorageType.java b/resource-service/src/main/java/com/audio/resource/entity/StorageType.java new file mode 100644 index 0000000..1aa9630 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/entity/StorageType.java @@ -0,0 +1,6 @@ +package com.audio.resource.entity; + +public enum StorageType { + STAGING, + PERMANENT +} diff --git a/resource-service/src/main/java/com/audio/resource/exception/GlobalExceptionHandler.java b/resource-service/src/main/java/com/audio/resource/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..02322fd --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/exception/GlobalExceptionHandler.java @@ -0,0 +1,43 @@ +package com.audio.resource.exception; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.HttpMediaTypeNotSupportedException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(ResourceNotFoundException.class) + public ResponseEntity> handleNotFound(ResourceNotFoundException ex) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(Map.of("errorMessage", ex.getMessage(), "errorCode", "404")); + } + + @ExceptionHandler({InvalidRequestException.class, IllegalArgumentException.class, HttpMediaTypeNotSupportedException.class}) + public ResponseEntity> handleBadRequest(InvalidRequestException ex) { + return ResponseEntity.status(HttpStatus.BAD_REQUEST) + .body(Map.of("errorMessage", ex.getMessage(), "errorCode", "400")); + } + + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.error(ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.FORBIDDEN) + .body(Map.of("errorMessage", "Access denied", "errorCode", "403")); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + log.error(ex.getMessage(), ex); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(Map.of("errorMessage", "Internal server error", "errorCode", "500")); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/exception/InvalidRequestException.java b/resource-service/src/main/java/com/audio/resource/exception/InvalidRequestException.java new file mode 100644 index 0000000..6017e7d --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/exception/InvalidRequestException.java @@ -0,0 +1,7 @@ +package com.audio.resource.exception; + +public class InvalidRequestException extends RuntimeException { + public InvalidRequestException(String message) { + super(message); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/exception/ResourceNotFoundException.java b/resource-service/src/main/java/com/audio/resource/exception/ResourceNotFoundException.java new file mode 100644 index 0000000..527aaeb --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/exception/ResourceNotFoundException.java @@ -0,0 +1,8 @@ +package com.audio.resource.exception; + +public class ResourceNotFoundException extends RuntimeException { + + public ResourceNotFoundException(String message) { + super(message); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/messaging/ResourceEventConsumer.java b/resource-service/src/main/java/com/audio/resource/messaging/ResourceEventConsumer.java new file mode 100644 index 0000000..5c5c6e0 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/messaging/ResourceEventConsumer.java @@ -0,0 +1,59 @@ +package com.audio.resource.messaging; + +import com.audio.resource.exception.ResourceNotFoundException; +import com.audio.resource.entity.ResourceEntity; +import com.audio.resource.entity.StorageType; +import com.audio.resource.repository.ResourceRepository; +import com.audio.resource.service.S3StorageService; +import com.audio.resource.service.StorageServiceClient; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +public class ResourceEventConsumer { + + private final ResourceRepository repository; + private final StorageServiceClient storageServiceClient; + private final S3StorageService s3StorageService; + + public ResourceEventConsumer(ResourceRepository repository, + StorageServiceClient storageServiceClient, + S3StorageService s3StorageService) { + this.repository = repository; + this.storageServiceClient = storageServiceClient; + this.s3StorageService = s3StorageService; + } + + @Transactional + public void handleResourceProcessed(Long resourceId) { + ResourceEntity entity = repository.findById(resourceId) + .orElseThrow(() -> new ResourceNotFoundException("Resource with ID=" + resourceId + " not found")); + + if (entity.getStorageType() == StorageType.PERMANENT) { + log.info("Resource {} already in PERMANENT state, skipping", resourceId); + return; + } + + var storageResponse = storageServiceClient.getAllStorages(); + var permanentStorage = storageResponse.getStorages().stream() + .filter(s -> s.getStorageType() == StorageType.PERMANENT) + .findFirst() + .orElseThrow(() -> new IllegalStateException("PERMANENT storage not found")); + + String destBucket = permanentStorage.getBucket(); + String destPath = permanentStorage.getPath(); + + s3StorageService.move(entity.getStorageBucket(), entity.getStoragePath(), + destBucket, destPath, entity.getStorageKey()); + + entity.setStorageType(StorageType.PERMANENT); + entity.setStorageBucket(destBucket); + entity.setStoragePath(destPath); + repository.save(entity); + + log.info("Resource {} moved from STAGING to PERMANENT (bucket={}, path={})", + resourceId, destBucket, destPath); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/repository/ResourceRepository.java b/resource-service/src/main/java/com/audio/resource/repository/ResourceRepository.java new file mode 100644 index 0000000..07c583b --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/repository/ResourceRepository.java @@ -0,0 +1,9 @@ +package com.audio.resource.repository; + +import com.audio.resource.entity.ResourceEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ResourceRepository extends JpaRepository { +} diff --git a/resource-service/src/main/java/com/audio/resource/service/ResourceEventPublisher.java b/resource-service/src/main/java/com/audio/resource/service/ResourceEventPublisher.java new file mode 100644 index 0000000..17eeea7 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/service/ResourceEventPublisher.java @@ -0,0 +1,36 @@ +package com.audio.resource.service; + +import lombok.extern.slf4j.Slf4j; +import org.slf4j.MDC; +import org.springframework.cloud.stream.function.StreamBridge; +import org.springframework.messaging.Message; +import org.springframework.messaging.support.MessageBuilder; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +public class ResourceEventPublisher { + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + private final StreamBridge streamBridge; + + public ResourceEventPublisher(StreamBridge streamBridge) { + this.streamBridge = streamBridge; + } + + @Retryable(backoff = @Backoff(delay = 1000, multiplier = 2)) + public void publishUploadEvent(Long resourceId) { + String traceId = MDC.get("traceId"); + if (traceId == null || traceId.isEmpty()) { + traceId = MDC.get(TRACE_ID_HEADER); + } + + Message message = MessageBuilder.withPayload(resourceId) + .setHeader(TRACE_ID_HEADER, traceId != null ? traceId : "") + .build(); + + log.info("Publishing upload event for resource: {} with traceId: {}", resourceId, traceId); + streamBridge.send("resourceUpload-out-0", message); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/service/ResourceService.java b/resource-service/src/main/java/com/audio/resource/service/ResourceService.java new file mode 100644 index 0000000..e85a2b3 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/service/ResourceService.java @@ -0,0 +1,134 @@ +package com.audio.resource.service; + +import com.audio.resource.dto.ResourceDeleteResponse; +import com.audio.resource.dto.ResourceUploadResponse; +import com.audio.resource.dto.StorageResponse; +import com.audio.resource.entity.ResourceEntity; +import com.audio.resource.entity.StorageType; +import com.audio.resource.exception.InvalidRequestException; +import com.audio.resource.exception.ResourceNotFoundException; +import com.audio.resource.repository.ResourceRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import jakarta.servlet.http.HttpServletRequest; +import lombok.extern.slf4j.Slf4j; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Slf4j +@Service +public class ResourceService { + + private final ResourceRepository repository; + private final SongServiceClient songServiceClient; + private final StorageServiceClient storageServiceClient; + private final S3StorageService s3StorageService; + private final ResourceEventPublisher eventPublisher; + + public ResourceService(ResourceRepository repository, + SongServiceClient songServiceClient, + StorageServiceClient storageServiceClient, + S3StorageService s3StorageService, + ResourceEventPublisher eventPublisher) { + this.repository = repository; + this.songServiceClient = songServiceClient; + this.storageServiceClient = storageServiceClient; + this.s3StorageService = s3StorageService; + this.eventPublisher = eventPublisher; + } + + @Transactional + public ResourceUploadResponse upload(HttpServletRequest request) { + try { + byte[] data = request.getInputStream().readAllBytes(); + + StorageResponse storageResponse = storageServiceClient.getStoragesByType(StorageType.STAGING); + if (storageResponse.isStubData()) { + log.warn("Using stub storage data - Storage Service may be unavailable"); + } + + StorageResponse.StorageDto stagingStorage = storageResponse.getStorages().stream() + .filter(s -> s.getStorageType() == StorageType.STAGING) + .findFirst() + .orElseThrow(() -> new IllegalStateException( + "STAGING storage not returned by Storage Service (stub=" + storageResponse.isStubData() + + ")")); + + String bucket = stagingStorage.getBucket(); + String path = stagingStorage.getPath(); + + String storageKey = UUID.randomUUID().toString(); + s3StorageService.upload(bucket, path, storageKey, data); + + ResourceEntity entity = new ResourceEntity(storageKey, StorageType.STAGING, bucket, path); + ResourceEntity saved = repository.save(entity); + + eventPublisher.publishUploadEvent(saved.getId()); + + return new ResourceUploadResponse(saved.getId(), s3StorageService.getUrl(bucket, path, storageKey)); + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + public byte[] getById(String id) { + ResourceEntity entity = repository.findById(validateId(id)) + .orElseThrow(() -> new ResourceNotFoundException("Resource with ID=" + id + " not found")); + + return s3StorageService.download(entity.getStorageBucket(), entity.getStoragePath(), entity.getStorageKey()); + } + + @Transactional + public ResourceDeleteResponse delete(String ids) { + if (ids == null) { + throw new InvalidRequestException("CSV string must not be null"); + } + if (ids.length() > 200) { + throw new InvalidRequestException( + "CSV string is too long: received " + ids.length() + " characters, maximum allowed is 200"); + } + + String[] parts = ids.split(","); + List deletedIds = new ArrayList<>(); + + for (String part : parts) { + String trimmed = part.trim(); + try { + long id = Long.parseLong(trimmed); + if (id <= 0) { + throw new NumberFormatException(); + } + + Optional entityOpt = repository.findById(id); + if (entityOpt.isPresent()) { + ResourceEntity entity = entityOpt.get(); + s3StorageService.delete(entity.getStorageBucket(), entity.getStoragePath(), entity.getStorageKey()); + repository.deleteById(id); + songServiceClient.deleteSongMetadata(String.valueOf(id)); + deletedIds.add(id); + } + } catch (NumberFormatException e) { + throw new InvalidRequestException( + "Invalid ID format: '" + trimmed + "'. Only positive integers are allowed"); + } + } + + return new ResourceDeleteResponse(deletedIds); + } + + public long validateId(String rawId) { + try { + long id = Long.parseLong(rawId); + if (id > 0) { + return id; + } + throw new NumberFormatException(); + } catch (NumberFormatException e) { + throw new InvalidRequestException("Invalid value '" + rawId + "' for ID. Must be a positive integer"); + } + } +} diff --git a/resource-service/src/main/java/com/audio/resource/service/S3StorageService.java b/resource-service/src/main/java/com/audio/resource/service/S3StorageService.java new file mode 100644 index 0000000..20b3edd --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/service/S3StorageService.java @@ -0,0 +1,67 @@ +package com.audio.resource.service; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.stereotype.Service; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@Slf4j +@Service +@RefreshScope +public class S3StorageService { + + private final S3Client s3Client; + private final String endpoint; + + public S3StorageService(S3Client s3Client, + @Value("${spring.cloud.aws.s3.endpoint:http://localhost:4566}") String endpoint) { + this.s3Client = s3Client; + this.endpoint = endpoint; + } + + public void upload(String bucket, String path, String key, byte[] data) { + String fullKey = fullKey(path, key); + log.info("Uploading to bucket={}, path={}, key={}", bucket, path, fullKey); + s3Client.putObject( + PutObjectRequest.builder().bucket(bucket).key(fullKey).build(), + RequestBody.fromBytes(data) + ); + } + + public byte[] download(String bucket, String path, String key) { + String fullKey = fullKey(path, key); + log.info("Downloading from bucket={}, path={}, key={}", bucket, path, fullKey); + return s3Client.getObjectAsBytes( + GetObjectRequest.builder().bucket(bucket).key(fullKey).build() + ).asByteArray(); + } + + public String getUrl(String bucket, String path, String key) { + String fullKey = fullKey(path, key); + return String.format("%s/%s/%s", endpoint, bucket, fullKey); + } + + public void delete(String bucket, String path, String key) { + String fullKey = fullKey(path, key); + log.info("Deleting from bucket={}, path={}, key={}", bucket, path, fullKey); + s3Client.deleteObject( + DeleteObjectRequest.builder().bucket(bucket).key(fullKey).build() + ); + } + + public void move(String sourceBucket, String sourcePath, String destBucket, String destPath, String key) { + byte[] data = download(sourceBucket, sourcePath, key); + upload(destBucket, destPath, key, data); + delete(sourceBucket, sourcePath, key); + log.info("Moved file '{}' from {}/{} to {}/{}", key, sourceBucket, sourcePath, destBucket, destPath); + } + + private String fullKey(String path, String key) { + return path.startsWith("/") ? path.substring(1) + "/" + key : path + "/" + key; + } +} diff --git a/resource-service/src/main/java/com/audio/resource/service/SongServiceClient.java b/resource-service/src/main/java/com/audio/resource/service/SongServiceClient.java new file mode 100644 index 0000000..79948ca --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/service/SongServiceClient.java @@ -0,0 +1,38 @@ +package com.audio.resource.service; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +@Slf4j +@Component +@RefreshScope +public class SongServiceClient { + + private final WebClient webClient; + + public SongServiceClient( + @Value("${song.service.url:http://localhost:8082}") String songServiceUrl, + WebClient.Builder webClientBuilder) { + this.webClient = webClientBuilder.baseUrl(songServiceUrl).build(); + } + + @Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) + @CircuitBreaker(name = "songService", fallbackMethod = "deleteSongMetadataFallback") + public void deleteSongMetadata(String ids) { + webClient.delete() + .uri(uriBuilder -> uriBuilder.path("/songs").queryParam("id", ids).build()) + .retrieve() + .toBodilessEntity() + .block(); + } + + public void deleteSongMetadataFallback(String ids, Throwable t) { + log.warn("Circuit breaker OPEN for song-service. Could not delete metadata for IDs: {}. Reason: {}", ids, t.getMessage()); + } +} diff --git a/resource-service/src/main/java/com/audio/resource/service/StorageServiceClient.java b/resource-service/src/main/java/com/audio/resource/service/StorageServiceClient.java new file mode 100644 index 0000000..0b56163 --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/service/StorageServiceClient.java @@ -0,0 +1,80 @@ +package com.audio.resource.service; + +import com.audio.resource.dto.StorageResponse; +import com.audio.resource.entity.StorageType; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.core.ParameterizedTypeReference; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker; + +@Slf4j +@Component +@RefreshScope +public class StorageServiceClient { + + private final WebClient webClient; + private final StorageResponse.StubConfig stubConfig; + + public StorageServiceClient( + @Value("${storage.service.url:http://localhost:8085}") String storageServiceUrl, + StorageResponse.StubConfigProvider stubConfigProvider) { + this.webClient = WebClient.builder().baseUrl(storageServiceUrl).build(); + this.stubConfig = stubConfigProvider.getStubConfig(); + log.info("StorageServiceClient initialized with stub fallback: staging={}/{}, permanent={}/{}", + stubConfig.stagingBucket(), stubConfig.stagingPath(), + stubConfig.permanentBucket(), stubConfig.permanentPath()); + } + + @Retryable(backoff = @Backoff(delay = 1000, multiplier = 2)) + @CircuitBreaker(name = "storageService", fallbackMethod = "getStoragesByTypeFallback") + public StorageResponse getStoragesByType(StorageType storageType) { + log.info("Calling Storage Service for storage type: {}", storageType); + StorageResponse response = fetchStorages(); + long matching = response.getStorages() == null + ? 0 + : response.getStorages().stream().filter(s -> s.getStorageType() == storageType).count(); + log.info("Storage Service returned {} storages ({} match type={})", + response.getStorages() == null ? 0 : response.getStorages().size(), + matching, storageType); + return response; + } + + public StorageResponse getStoragesByTypeFallback(StorageType storageType, Throwable t) { + log.warn("Circuit breaker OPEN for storage-service. Using stub data for type={}. Reason: {}", + storageType, t.getMessage()); + return StorageResponse.stub(stubConfig); + } + + @Retryable(backoff = @Backoff(delay = 1000, multiplier = 2)) + @CircuitBreaker(name = "storageService", fallbackMethod = "getAllStoragesFallback") + public StorageResponse getAllStorages() { + log.info("Calling Storage Service to get all storages"); + return fetchStorages(); + } + + public StorageResponse getAllStoragesFallback(Throwable t) { + log.warn("Circuit breaker OPEN for storage-service. Returning stub data. Reason: {}", t.getMessage()); + return StorageResponse.stub(stubConfig); + } + + private StorageResponse fetchStorages() { + List storages = webClient.get() + .uri("/storages") + .retrieve() + .bodyToMono(new ParameterizedTypeReference>() { + }) + .block(); + + StorageResponse response = new StorageResponse(); + response.setStorages(storages); + return response; + } +} diff --git a/resource-service/src/main/java/com/audio/resource/util/DurationFormatter.java b/resource-service/src/main/java/com/audio/resource/util/DurationFormatter.java new file mode 100644 index 0000000..91f427b --- /dev/null +++ b/resource-service/src/main/java/com/audio/resource/util/DurationFormatter.java @@ -0,0 +1,21 @@ +package com.audio.resource.util; + +public final class DurationFormatter { + + private DurationFormatter() {} + + public static String toMmSs(String durationSeconds) { + if (durationSeconds == null || durationSeconds.isEmpty()) { + return "00:00"; + } + try { + double seconds = Double.parseDouble(durationSeconds); + int totalSeconds = (int) seconds; + int minutes = totalSeconds / 60; + int secs = totalSeconds % 60; + return String.format("%02d:%02d", minutes, secs); + } catch (NumberFormatException e) { + return "00:00"; + } + } +} diff --git a/resource-service/src/main/resources/application.yaml b/resource-service/src/main/resources/application.yaml new file mode 100644 index 0000000..a25a111 --- /dev/null +++ b/resource-service/src/main/resources/application.yaml @@ -0,0 +1,14 @@ +spring: + application: + name: resource-service + config: + import: configserver:${CONFIG_SERVER_URL:http://localhost:8888}/config + +eureka: + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} + instance: + prefer-ip-address: true diff --git a/resource-service/src/main/resources/db/data.sql b/resource-service/src/main/resources/db/data.sql new file mode 100644 index 0000000..e69de29 diff --git a/resource-service/src/main/resources/db/schema.sql b/resource-service/src/main/resources/db/schema.sql new file mode 100644 index 0000000..ada459e --- /dev/null +++ b/resource-service/src/main/resources/db/schema.sql @@ -0,0 +1,4 @@ +CREATE TABLE IF NOT EXISTS resources ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + storage_key VARCHAR(255) NOT NULL +); diff --git a/resource-service/src/main/resources/logback-spring.xml b/resource-service/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..b4c7044 --- /dev/null +++ b/resource-service/src/main/resources/logback-spring.xml @@ -0,0 +1,40 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + UTC + + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/resource-service/src/main/resources/static/favicon.ico b/resource-service/src/main/resources/static/favicon.ico new file mode 100644 index 0000000..a11777c Binary files /dev/null and b/resource-service/src/main/resources/static/favicon.ico differ diff --git a/resource-service/src/test/java/com/audio/resource/ResourceApplicationTests.java b/resource-service/src/test/java/com/audio/resource/ResourceApplicationTests.java new file mode 100644 index 0000000..8049354 --- /dev/null +++ b/resource-service/src/test/java/com/audio/resource/ResourceApplicationTests.java @@ -0,0 +1,10 @@ +package com.audio.resource; + +import org.junit.jupiter.api.Test; + +class ResourceApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/resource-service/src/test/java/com/audio/resource/ResourceServiceSecurityTest.java b/resource-service/src/test/java/com/audio/resource/ResourceServiceSecurityTest.java new file mode 100644 index 0000000..b7f4834 --- /dev/null +++ b/resource-service/src/test/java/com/audio/resource/ResourceServiceSecurityTest.java @@ -0,0 +1,100 @@ +package com.audio.resource; + +import com.audio.resource.service.ResourceService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = { + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false" +}) +@AutoConfigureMockMvc +class ResourceServiceSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @MockitoBean + private ResourceService resourceService; + + @Test + void missingTokenShouldReturn401() throws Exception { + mockMvc.perform(get("/resources/1")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/resources")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(delete("/resources").param("id", "1")) + .andExpect(status().isUnauthorized()); + } + + @Test + void userRoleCanGetResource() throws Exception { + when(resourceService.getById("1")).thenReturn(new byte[]{0, 1, 2}); + + mockMvc.perform(get("/resources/1") + .with(jwt().authorities(() -> "ROLE_USER"))) + .andExpect(status().isOk()); + } + + @Test + void userRoleCanUploadResource() throws Exception { + when(resourceService.upload(any())).thenReturn(null); + + mockMvc.perform(post("/resources") + .with(jwt().authorities(() -> "ROLE_USER")) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .content("test audio content")) + .andExpect(status().isOk()); + } + + @Test + void userRoleCannotDeleteResource() throws Exception { + mockMvc.perform(delete("/resources").param("id", "1") + .with(jwt().authorities(() -> "ROLE_USER"))) + .andExpect(status().isForbidden()); + } + + @Test + void adminRoleCanGetResource() throws Exception { + when(resourceService.getById("1")).thenReturn(new byte[]{0, 1, 2}); + + mockMvc.perform(get("/resources/1") + .with(jwt().authorities(() -> "ROLE_ADMIN"))) + .andExpect(status().isOk()); + } + + @Test + void adminRoleCanUploadResource() throws Exception { + when(resourceService.upload(any())).thenReturn(null); + + mockMvc.perform(post("/resources") + .with(jwt().authorities(() -> "ROLE_ADMIN")) + .contentType(MediaType.APPLICATION_OCTET_STREAM) + .content("test audio content")) + .andExpect(status().isOk()); + } + + @Test + void adminRoleCanDeleteResource() throws Exception { + mockMvc.perform(delete("/resources").param("id", "1") + .with(jwt().authorities(() -> "ROLE_ADMIN"))) + .andExpect(status().isOk()); + } +} diff --git a/resource-service/src/test/java/com/audio/resource/service/ResourceServiceTest.java b/resource-service/src/test/java/com/audio/resource/service/ResourceServiceTest.java new file mode 100644 index 0000000..c6371c0 --- /dev/null +++ b/resource-service/src/test/java/com/audio/resource/service/ResourceServiceTest.java @@ -0,0 +1,213 @@ +package com.audio.resource.service; + +import com.audio.resource.dto.ResourceDeleteResponse; +import com.audio.resource.dto.ResourceUploadResponse; +import com.audio.resource.dto.StorageResponse; +import com.audio.resource.entity.ResourceEntity; +import com.audio.resource.entity.StorageType; +import com.audio.resource.exception.InvalidRequestException; +import com.audio.resource.exception.ResourceNotFoundException; +import com.audio.resource.repository.ResourceRepository; +import jakarta.servlet.ServletInputStream; +import jakarta.servlet.http.HttpServletRequest; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; + +import java.io.IOException; +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class ResourceServiceTest { + + @Mock + private ResourceRepository repository; + + @Mock + private SongServiceClient songServiceClient; + + @Mock + private StorageServiceClient storageServiceClient; + + @Mock + private S3StorageService s3StorageService; + + @Mock + private HttpServletRequest request; + + @Mock + private ResourceEventPublisher eventPublisher; + + private ResourceService service; + + @BeforeEach + void setUp() { + service = new ResourceService(repository, songServiceClient, storageServiceClient, s3StorageService, eventPublisher); + } + + @Test + void uploadValidDataReturnsResponse() throws IOException { + byte[] data = "audio data".getBytes(); + ServletInputStream servletInputStream = mock(ServletInputStream.class); + when(servletInputStream.readAllBytes()).thenReturn(data); + when(request.getInputStream()).thenReturn(servletInputStream); + when(repository.save(any(ResourceEntity.class))).thenAnswer(inv -> { + ResourceEntity e = inv.getArgument(0); + e.setId(1L); + return e; + }); + when(s3StorageService.getUrl(any(String.class), any(String.class), anyString())).thenReturn("http://url"); + + StorageResponse.StorageDto stagingDto = new StorageResponse.StorageDto(1L, StorageType.STAGING, "mp3-staging", "/uploads"); + StorageResponse stagingResponse = new StorageResponse(); + stagingResponse.setStorages(List.of(stagingDto)); + when(storageServiceClient.getStoragesByType(StorageType.STAGING)).thenReturn(stagingResponse); + + ResourceUploadResponse response = service.upload(request); + + assertNotNull(response); + assertEquals(1L, response.getId()); + assertEquals("http://url", response.getS3Url()); + verify(s3StorageService).upload(any(String.class), any(String.class), anyString(), any(byte[].class)); + verify(repository).save(any(ResourceEntity.class)); + } + + @Test + void uploadFallsBackToStubDataWhenStorageServiceDown() throws IOException { + byte[] data = "audio data".getBytes(); + ServletInputStream servletInputStream = mock(ServletInputStream.class); + when(servletInputStream.readAllBytes()).thenReturn(data); + when(request.getInputStream()).thenReturn(servletInputStream); + when(repository.save(any(ResourceEntity.class))).thenAnswer(inv -> { + ResourceEntity e = inv.getArgument(0); + e.setId(42L); + return e; + }); + when(s3StorageService.getUrl(any(String.class), any(String.class), anyString())).thenReturn("http://url"); + + StorageResponse stub = StorageResponse.stub( + new StorageResponse.StubConfig("custom-staging", "/tmp/staging", + "custom-permanent", "/tmp/permanent")); + when(storageServiceClient.getStoragesByType(StorageType.STAGING)).thenReturn(stub); + + ResourceUploadResponse response = service.upload(request); + + assertNotNull(response); + assertTrue(stub.isStubData()); + assertEquals("custom-staging", stub.getStorages().get(0).getBucket()); + verify(s3StorageService).upload(eq("custom-staging"), eq("/tmp/staging"), anyString(), any(byte[].class)); + } + + @Test + void uploadWhenIOExceptionThrowsRuntimeException() throws IOException { + when(request.getInputStream()).thenThrow(new IOException("IO error")); + + assertThrows(RuntimeException.class, () -> service.upload(request)); + } + + @Test + void getByIdValidIdReturnsData() { + long id = 1L; + String storageKey = "key"; + String bucket = "mp3-staging"; + String path = "/uploads"; + ResourceEntity entity = new ResourceEntity(storageKey, StorageType.STAGING, bucket, path); + when(repository.findById(id)).thenReturn(Optional.of(entity)); + byte[] data = "data".getBytes(); + when(s3StorageService.download(bucket, path, storageKey)).thenReturn(data); + + byte[] result = service.getById("1"); + + assertEquals(data, result); + verify(repository).findById(id); + verify(s3StorageService).download(bucket, path, storageKey); + } + + @Test + void getByIdInvalidIdThrowsException() { + assertThrows(InvalidRequestException.class, () -> service.getById("invalid")); + } + + @Test + void getByIdNotFoundThrowsException() { + when(repository.findById(1L)).thenReturn(Optional.empty()); + + assertThrows(ResourceNotFoundException.class, () -> service.getById("1")); + } + + @ParameterizedTest + @MethodSource("provideDeleteScenarios") + void deleteValidIdsReturnsDeleted(String ids, String[] parts, long[] deletedIds) { + for (String part : parts) { + long id = Long.parseLong(part.trim()); + ResourceEntity entity = new ResourceEntity("key" + id, StorageType.STAGING, "mp3-staging", "/uploads"); + when(repository.findById(id)).thenReturn(Optional.of(entity)); + } + + ResourceDeleteResponse response = service.delete(ids); + + assertEquals(deletedIds.length, response.getIds().size()); + for (long id : deletedIds) { + assertTrue(response.getIds().contains(id)); + } + } + + private static Stream provideDeleteScenarios() { + return Stream.of( + Arguments.of("1,2", new String[]{"1", "2"}, new long[]{1, 2}), + Arguments.of("1", new String[]{"1"}, new long[]{1}) + ); + } + + @Test + void deleteNullIdsThrowsException() { + assertThrows(InvalidRequestException.class, () -> service.delete(null)); + } + + @Test + void deleteTooLongIdsThrowsException() { + String longIds = "1,".repeat(101); + assertThrows(InvalidRequestException.class, () -> service.delete(longIds)); + } + + @Test + void deleteInvalidIdFormatThrowsException() { + assertThrows(InvalidRequestException.class, () -> service.delete("abc")); + } + + @ParameterizedTest + @MethodSource("provideValidateIdScenarios") + void validateId(String rawId, long expected) { + long result = service.validateId(rawId); + assertEquals(expected, result); + } + + private static Stream provideValidateIdScenarios() { + return Stream.of( + Arguments.of("1", 1L), + Arguments.of("123", 123L) + ); + } + + @Test + void validateIdInvalidThrowsException() { + assertThrows(InvalidRequestException.class, () -> service.validateId("0")); + assertThrows(InvalidRequestException.class, () -> service.validateId("-1")); + assertThrows(InvalidRequestException.class, () -> service.validateId("abc")); + } +} diff --git a/resource-service/src/test/java/com/audio/resource/service/S3StorageServiceTest.java b/resource-service/src/test/java/com/audio/resource/service/S3StorageServiceTest.java new file mode 100644 index 0000000..38ec1af --- /dev/null +++ b/resource-service/src/test/java/com/audio/resource/service/S3StorageServiceTest.java @@ -0,0 +1,86 @@ +package com.audio.resource.service; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.mockito.junit.jupiter.MockitoSettings; +import org.mockito.quality.Strictness; +import software.amazon.awssdk.core.ResponseBytes; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; +import software.amazon.awssdk.services.s3.model.DeleteObjectResponse; +import software.amazon.awssdk.services.s3.model.GetObjectRequest; +import software.amazon.awssdk.services.s3.model.GetObjectResponse; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; +import software.amazon.awssdk.services.s3.model.PutObjectResponse; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +@MockitoSettings(strictness = Strictness.LENIENT) +class S3StorageServiceTest { + + @Mock + ResponseBytes responseBytes; + @Mock + private S3Client s3Client; + + private S3StorageService service; + + private final String endpoint = "http://localhost:4566"; + private final String bucket = "test-bucket"; + private final String path = "/uploads"; + private final String key = "test-key"; + + + @BeforeEach + void setUp() { + service = new S3StorageService(s3Client, endpoint); + } + + @Test + void uploadStoresDataInS3() { + byte[] data = "test data".getBytes(); + + when(s3Client.putObject(any(PutObjectRequest.class), any(RequestBody.class))).thenReturn( + PutObjectResponse.builder().build()); + + service.upload(bucket, path, key, data); + + verify(s3Client).putObject(any(PutObjectRequest.class), any(RequestBody.class)); + } + + @Test + void downloadRetrievesDataFromS3() { + byte[] expectedData = "test data".getBytes(); + when(responseBytes.asByteArray()).thenReturn(expectedData); + when(s3Client.getObjectAsBytes(any(GetObjectRequest.class))).thenReturn(responseBytes); + + byte[] result = service.download(bucket, path, key); + + assertEquals(expectedData, result); + verify(s3Client).getObjectAsBytes(any(GetObjectRequest.class)); + } + + @Test + void getUrlReturnsCorrectUrl() { + String expectedUrl = endpoint + "/" + bucket + "/uploads/" + key; + + String result = service.getUrl(bucket, path, key); + + assertEquals(expectedUrl, result); + } + + @Test + void deleteRemovesDataFromS3() { + when(s3Client.deleteObject(any(DeleteObjectRequest.class))).thenReturn(DeleteObjectResponse.builder().build()); + service.delete(bucket, path, key); + verify(s3Client).deleteObject(any(DeleteObjectRequest.class)); + } +} diff --git a/resource-service/src/test/java/com/audio/resource/service/StorageServiceClientTest.java b/resource-service/src/test/java/com/audio/resource/service/StorageServiceClientTest.java new file mode 100644 index 0000000..e129221 --- /dev/null +++ b/resource-service/src/test/java/com/audio/resource/service/StorageServiceClientTest.java @@ -0,0 +1,86 @@ +package com.audio.resource.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.audio.resource.dto.StorageResponse; +import com.audio.resource.entity.StorageType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import java.util.List; + +class StorageServiceClientTest { + + private StorageServiceClient client; + + @BeforeEach + void setUp() { + StorageResponse.StubConfigProvider provider = new StorageResponse.StubConfigProvider( + "test-staging", "/tmp/staging", "test-permanent", "/tmp/permanent"); + client = new StorageServiceClient("http://localhost:8085", provider); + } + + @Test + void getAllStoragesFallbackReturnsStubData() { + StorageResponse response = client.getAllStoragesFallback(new RuntimeException("test")); + assertNotNull(response); + assertEquals(2, response.getStorages().size()); + assertTrue(response.isStubData()); + } + + @Test + void stubDataHasCorrectStagingDetails() { + StorageResponse stub = StorageResponse.stub(); + List storages = stub.getStorages(); + + StorageResponse.StorageDto staging = storages.stream() + .filter(s -> s.getStorageType() == StorageType.STAGING) + .findFirst() + .orElse(null); + + assertNotNull(staging); + assertEquals(1L, staging.getId()); + assertEquals("staging-bucket", staging.getBucket()); + assertEquals("/staging", staging.getPath()); + } + + @Test + void stubDataHasCorrectPermanentDetails() { + StorageResponse stub = StorageResponse.stub(); + List storages = stub.getStorages(); + + StorageResponse.StorageDto permanent = storages.stream() + .filter(s -> s.getStorageType() == StorageType.PERMANENT) + .findFirst() + .orElse(null); + + assertNotNull(permanent); + assertEquals(2L, permanent.getId()); + assertEquals("permanent-bucket", permanent.getBucket()); + assertEquals("/permanent", permanent.getPath()); + } + + @Test + void stubDataRespectsCustomConfig() { + StorageResponse customStub = StorageResponse.stub( + new StorageResponse.StubConfig("custom-staging", "/custom/staging", + "custom-permanent", "/custom/permanent")); + + assertEquals("custom-staging", customStub.getStorages().get(0).getBucket()); + assertEquals("/custom/staging", customStub.getStorages().get(0).getPath()); + assertEquals("custom-permanent", customStub.getStorages().get(1).getBucket()); + assertEquals("/custom/permanent", customStub.getStorages().get(1).getPath()); + } + + @Test + void getStoragesByTypeFallbackUsesInjectedConfig() { + StorageResponse response = client.getStoragesByTypeFallback(StorageType.STAGING, new RuntimeException("boom")); + + assertNotNull(response); + assertTrue(response.isStubData()); + assertEquals("test-staging", response.getStorages().get(0).getBucket()); + assertEquals("test-permanent", response.getStorages().get(1).getBucket()); + } +} diff --git a/resource-service/src/test/java/com/audio/resource/util/DurationFormatterTest.java b/resource-service/src/test/java/com/audio/resource/util/DurationFormatterTest.java new file mode 100644 index 0000000..83a4823 --- /dev/null +++ b/resource-service/src/test/java/com/audio/resource/util/DurationFormatterTest.java @@ -0,0 +1,35 @@ +package com.audio.resource.util; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import org.junit.jupiter.api.Test; + +class DurationFormatterTest { + + @Test + void toMmSs_ValidSeconds_FormatsCorrectly() { + assertEquals("00:00", DurationFormatter.toMmSs("0")); + assertEquals("00:01", DurationFormatter.toMmSs("1")); + assertEquals("00:59", DurationFormatter.toMmSs("59")); + assertEquals("01:00", DurationFormatter.toMmSs("60")); + assertEquals("01:30", DurationFormatter.toMmSs("90")); + assertEquals("02:05", DurationFormatter.toMmSs("125.5")); + assertEquals("10:30", DurationFormatter.toMmSs("630")); + } + + @Test + void toMmSs_Null_ReturnsZero() { + assertEquals("00:00", DurationFormatter.toMmSs(null)); + } + + @Test + void toMmSs_Empty_ReturnsZero() { + assertEquals("00:00", DurationFormatter.toMmSs("")); + } + + @Test + void toMmSs_InvalidNumber_ReturnsZero() { + assertEquals("00:00", DurationFormatter.toMmSs("abc")); + assertEquals("00:00", DurationFormatter.toMmSs("12.34.56")); + } +} diff --git a/resource-service/src/test/resources/application.yaml b/resource-service/src/test/resources/application.yaml new file mode 100644 index 0000000..698c3d9 --- /dev/null +++ b/resource-service/src/test/resources/application.yaml @@ -0,0 +1,25 @@ +spring: + cloud: + config: + enabled: false + aws: + credentials: + access-key: test + secret-key: test + region: + static: us-east-1 + s3: + endpoint: http://localhost:4566 + rabbitmq: + host: localhost + port: 5672 + datasource: + url: jdbc:h2:mem:test-db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: false diff --git a/review/fix.md b/review/fix.md new file mode 100644 index 0000000..514745b --- /dev/null +++ b/review/fix.md @@ -0,0 +1,174 @@ +# OAuth2 Security Integration Code Review Implementation Plan + +## Summary +This plan addresses security issues, bugs, and improvements identified in the OAuth2 Authorization Server integration across auth-service, gateway, song-service, storage-service, resource-service, and ui-service. + +## Critical Issues (Must Fix) + +### 1. Fix JWKS Endpoint and Issuer URI in Config Files +**Files:** `config-repo/gateway.yml`, `config-repo/resource-service.yml`, `config-repo/resource-service-docker.yml`, `config-repo/song-service-docker.yml`, `config-repo/storage-service.yml`, `config-repo/storage-service-docker.yml` + +**Problem:** Incorrect `jwk-set-uri` paths using `/.well-known/jwks.json` instead of `/oauth2/jwks`. The issuer URI has `/auth` suffix but auth-service sets issuer as `http://localhost:9000` (without /auth), causing JWT validation failures. + +**Fix:** +- Change `jwk-set-uri` from `/auth/.well-known/jwks.json` to `/auth/oauth2/jwks` (for docker files) or `/oauth2/jwks` (for local files based on how the app is accessed) +- Update issuer-uri to match auth-service.Issuer claim (`http://localhost:9000` for local, `http://auth-service:9000` for docker) + +### 2. Fix OAuth2 Endpoint Paths in Auth Service SecurityConfig +**File:** `auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` + +**Problem:** Lines 143-144 have `/auth/oauth2/authorize` and `/auth/oauth2/token` but with `server.servlet.context-path=/auth`, the servlet container prepends `/auth`, creating double-prefix paths like `/auth/auth/oauth2/token`. + +**Fix:** +- Remove `/auth` prefix from endpoint paths: use `/oauth2/authorize` and `/oauth2/token` +- Update line 55: Change `/auth/oauth2/**` to `/oauth2/**` + +### 3. Implement JWT Role Claim Converter in Resource Services +**Files:** `resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java`, `song-service/src/main/java/com/audio/song/config/SecurityConfig.java`, `storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java` + +**Problem:** All three services use `Customizer.withDefaults()` for JWT which expects `scope`/`scp` claims, but auth-service emits a custom `roles` claim. This causes `@PreAuthorize("hasRole('ADMIN')")` checks to fail with 403. + +**Fix:** Add `JwtAuthenticationConverter` bean to each SecurityConfig that maps the `roles` claim to `ROLE_*` authorities (same pattern already implemented in gateway). + +### 4. Add CORS Configuration to storage-service SecurityConfig +**File:** `storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java` + +**Problem:** Missing `CorsConfigurationSource` bean - song-service has it but storage-service only uses `.cors(Customizer.withDefaults())` which won't allow requests from the React UI at `http://localhost:3000`. + +**Fix:** Add `CorsConfigurationSource` bean with allowed origins `http://localhost:3000`. + +### 5. Fix POST /storages to Return 201 Created +**File:** `storage-service/src/main/java/com/audio/storage/controller/StorageController.java` + +**Problem:** Line 33 returns `ResponseEntity.ok()` (200) instead of `ResponseEntity.status(HttpStatus.CREATED)` (201) for resource creation. + +**Fix:** Change to return `HttpStatus.CREATED`. + +## Major Security Issues (Must Fix) + +### 6. Persist RSA Signing Keys +**File:** `auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` + +**Problem:** Lines 99-121 generate ephemeral RSA keys on every startup, invalidating previously issued tokens and causing issues in multi-instance deployments. + +**Recommendation:** This is a significant architectural change. Consider externalizing keys to a keystore or mounted volume. However, for development purposes, this may be acceptable. **Decision needed:** Is persistence required or can this be deferred? + +### 7. Profile-Gate DataInitializer Seeding +**File:** `auth-service/src/main/java/com/audio/auth/config/DataInitializer.java` + +**Problem:** Unconditionally seeds users with predictable passwords when table is empty. + +**Fix:** Add `@Profile("dev")` or similar conditional to only seed in development environments. + +### 8. Hash OAuth Client Secret +**File:** `auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` + +**Problem:** Line 77 uses `{noop}gateway-secret` storing plaintext. + +**Fix:** Use `{bcrypt}` prefix or encode via injected `PasswordEncoder`. + +### 9. Implement Password Grant Type Support +**File:** `auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` + +**Problem:** Line 81 registers password grant but Spring Authorization Server requires custom `OAuth2AuthenticationConverter` and `OAuth2AuthenticationProvider` to actually support it. + +**Recommendation:** This is complex. Consider using Authorization Code flow with PKCE for the SPA instead, or implement the custom grant infrastructure. **Decision needed:** Preferred approach? + +### 10. Remove Client Secret from UI Service +**File:** `ui-service/src/services/authService.js` + +**Problem:** Line 11 embeds `client_secret` in browser-deliverable JavaScript. Client secrets should never be in frontend code. + +**Recommendation:** Implement Authorization Code + PKCE flow or use a public client configuration. This is a significant refactor. + +### 11. Fix React Build Output Path in build.gradle +**File:** `ui-service/build.gradle` + +**Problem:** `buildReactApp` task outputs to `build/resources/main/static` but React's default `build` script outputs to `./build/`. + +**Fix:** Add `environment "BUILD_PATH", layout.buildDirectory.dir("resources/main/static").get().asFile.absolutePath` to the NpmTask configuration. + +### 12. Fix Hardcoded API URLs in UI Service +**Files:** `ui-service/src/services/authService.js`, `ui-service/src/services/axiosInstance.js` + +**Problem:** Both files hardcode `localhost` URLs which break in Docker/prod. + +**Fix:** Use environment variables `REACT_APP_AUTH_BASE_URL` and `REACT_APP_API_BASE_URL`. + +### 13. Run Container as Non-Root User +**File:** `auth-service/Dockerfile` + +**Problem:** No USER directive - container runs as root. + +**Fix:** Add non-root user creation and USER directive. + +## Minor Fixes (Quick Wins) + +### 14. Update Test Constants for Role Assertions +**File:** `auth-service/src/test/java/com/audio/auth/AuthenticationTest.java` + +**Problem:** Lines 41-42 define `ROLE_ADMIN` and `ROLE_USER` with "ROLE_" prefix, but token customizer emits roles without prefix (lines 132-134). + +**Fix:** Remove "ROLE_" prefix from test constants to match actual token output. + +### 15. Enable Core Token Tests +**File:** `auth-service/src/test/java/com/audio/auth/AuthenticationTest.java` + +**Problem:** Lines 116-214 have `@Disabled` on critical token issuance tests. + +**Fix:** Remove `@Disabled` from at least one happy-path test to ensure CI validates token creation. + +### 16. Fix StoragesTable Functional State Updates +**File:** `ui-service/src/components/StoragesTable.js` + +**Problem:** Lines 34 and 44 use closure-captured `storages` which can cause race conditions. + +**Fix:** Use functional updates: `setStorages(prev => [...prev, response.data])`. + +### 17. Fix Stylelint Font-Family Quotes in CSS +**File:** `ui-service/src/index.css` + +**Problem:** Lines 3-4 quote single-word font names (Roboto, Oxygen, Ubuntu, Cantarell). + +**Fix:** Remove quotes from single-word font family names. + +### 18. Fix App.js Login Route Logic +**File:** `ui-service/src/App.js` + +**Problem:** Line 10 renders login form when `path === '/login'` even if authenticated. + +**Fix:** Restructure logic to only show login when not authenticated, redirect authenticated users from `/login` to `/dashboard`. + +### 19. Add Mock Data in SongServiceSecurityTest +**File:** `song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java` + +**Problem:** Lines 50-55 and 73-78 don't mock `songService.getSong()` return value. + +**Fix:** Add `when(songService.getSong(1L)).thenReturn(...)` for relevant tests. + +### 20. Fix Postman Test Password +**File:** `tools/api-tests/module8-security-tests.json` + +**Problem:** Line 23 has `password123` but actual password is `alice`. + +**Fix:** Change to `"alice"`. + +## Out-of-Scope / Deferred + +### jvm-metrics.json +- File not found in the repository (only `gateway-metrics.json` exists) +- Cannot apply suggested fixes + +### .env CONFIG_REPO_URI +- Points to external mutable repo +- Acceptable for local development but should use org-controlled repo in production +- **Recommendation:** Document this as a deployment consideration rather than code fix + +## Implementation Order Recommendation + +1. **Critical fixes first** (JWKS/issuer, OAuth2 endpoints, JWT converters, CORS) +2. **Fix API response codes** (POST /storages → 201) +3. **Security hardening** (non-root user, profile-gated seeding, client secret hash) +4. **UI fixes** (build path, environment variables, React state updates, App.js logic) +5. **Test fixes** (enable disabled tests, fix test assertions) +6. **External/system considerations** (CONFIG_REPO_URI, persistent keys, password grant) - require decisions diff --git a/review/implementation_plan.md b/review/implementation_plan.md new file mode 100644 index 0000000..3173c41 --- /dev/null +++ b/review/implementation_plan.md @@ -0,0 +1,77 @@ +# Implementation Plan + +## 1. auth-service (Security & Configuration) +- [ ] **Ephemeral Keys Issue** (SecurityConfig.java lines 99-109) + - Replace `generateRsaKey()` with key loading from keystore/KMS + - Implement key rotation strategy + - Update Dockerfile to use non-root user +- [ ] **Plaintext Client Secret** (SecurityConfig.java line 77) + - Remove `{noop}` prefix + - Apply bcrypt encoding via `PasswordEncoder` bean +- [ ] **Exposed Credentials** (DataInitializer.java lines 18-31) + - Add `@Profile("dev")` to `DataInitializer` + - Externalize passwords via `@Value` +- [ ] **Password Grant Type** (SecurityConfig.java line 81) + - Implement custom `OAuth2AuthenticationConverter` + - Create custom `OAuth2AuthenticationProvider` + - Configure token endpoint with these components +- [ ] **Testing Gaps** (AuthenticationTest.java lines 116-214) + - Enable at least one happy-path token test + - Re-enable role claim validation tests +- [ ] **Docker Security** (application-docker.yml lines 10-12) + - Replace hardcoded DB credentials with env vars + - Set security logging to `INFO` +- [ ] **Frontend Auth Flow** (authService.js lines 7-11) + - Remove `client_secret` from frontend + - Implement Authorization Code + PKCE flow +- [ ] **Axios Base URL** (axiosInstance.js lines 4-6) + - Replace localhost with env var +- [ ] **Actuator Endpoints** (SecurityConfig.java lines 24-25) + - Restrict `/actuator/**` to authenticated users + - Explicitly permit only health/info endpoints + +## 2. ui-service (Frontend) +- [ ] **Client Secret Exposure** (authService.js lines 7-11) + - Remove `client_secret` from `params.append` + - Implement BFF token exchange +- [ ] **Axios Base URL** (axiosInstance.js lines 4-6) + - Use `process.env.REACT_APP_API_BASE_URL` +- [ ] **Login Form Logic** (App.js lines 10-14) + - Remove path check for `/login` + - Add redirect for authenticated users +- [ ] **State Management** (StoragesTable.js lines 34-44) + - Replace closure-captured state with functional updates +- [ ] **CSS Formatting** (index.css lines 3-4) + - Remove quotes around single-word fonts + +## 3. gateway (Security) +- [ ] **Actuator Endpoints** (SecurityConfig.java lines 24-25) + - Implement granular `pathMatchers` for `/actuator/**` +- [ ] **JWT Configuration** (SecurityConfig.java lines 34-41) + - Add `JwtAuthenticationConverter` for roles claim + +## 4. storage-service (Security & CORS) +- [ ] **JWT Authority Mapping** (SecurityConfig.java lines 23-25) + - Implement custom `JwtGrantedAuthoritiesConverter` +- [ ] **CORS Configuration** (SecurityConfig.java lines 17-30) + - Add `CorsConfigurationSource` bean for `http://localhost:3000` + +## 5. config-repo (Infrastructure) +- [ ] **Docker Credentials** (auth-service-docker.yml lines 9-13) + - Replace hardcoded DB creds with env vars + - Set security logging to `INFO` +- [ ] **Gateway Config** (gateway.yml lines 39-40) + - Fix `issuer-uri` and `jwk-set-uri` paths +- [ ] **Resource Service Config** (resource-service.yml lines 33-34) + - Update `jwk-set-uri` to `/oauth2/jwks` +- [ ] **Script Compatibility** (run_test.sh) + - Convert to Unix shell syntax or rename to `.bat` + +## 6. api-tests (Testing) +- [ ] **Test Data** (module8-security-tests.json lines 20-26) + - Update alice password to "alice" + +## 7. build.gradle (ui-service) +- [ ] **Build Path Mismatch** (build.gradle lines 19-21) + - Add `BUILD_PATH` env var or add copy task + \ No newline at end of file diff --git a/review/prompts.json b/review/prompts.json new file mode 100644 index 0000000..c8c64c7 --- /dev/null +++ b/review/prompts.json @@ -0,0 +1,125 @@ +[ + { + "prompt": "" + }, + { + "prompt": "" + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@tools/dashboards/jvm-metrics.json` around lines 22 - 31, The job template\nvariable is defined but not referenced in any of the panel metric queries,\nmaking the Service dropdown ineffective. Add the job filter to all metric\nexpressions in the dashboard by appending job=~\"$job\" to each query's label\nmatcher. Update the expressions for jvm_memory_used_bytes (heap at line 22),\njvm_memory_used_bytes (nonheap at line 53), jvm_threads_live_threads (line 82),\njvm_threads_daemon_threads (line 90), jvm_gc_pause_seconds_sum and\njvm_gc_pause_seconds_count (line 116), process_cpu_usage (line 142),\nsystem_cpu_usage (line 150), and process_uptime_seconds (line 178) to include\nthe job filter using the pattern job=~\"$job\" within their label matcher brackets\nto support multi-select functionality." + }, + { + "prompt": "" + }, + { + "prompt": "" + }, + { + "prompt": "" + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` around\nlines 99 - 109, The jwkSource() method currently generates a new RSA key pair on\nevery startup via generateRsaKey(), which creates ephemeral keys that invalidate\npreviously issued tokens and cause inconsistency across multi-instance\ndeployments. Replace the ephemeral key generation with a persistent key source\nby either loading keys from a keystore file, retrieving them from a KMS/HSM\nservice, or reading externally mounted key material. Implement a key rotation\nstrategy that reuses the same key material across restarts while allowing for\nperiodic key rotation without breaking existing token validation." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/main/java/com/audio/auth/config/DataInitializer.java` around\nlines 18 - 31, The DataInitializer class unconditionally seeds the database with\nhardcoded credentials when the user table is empty, which creates a security\nvulnerability. To fix this, add a Spring profile condition (such as\n`@Profile`(\"dev\") or `@Profile`(\"local\")) to the DataInitializer class so seeding\nonly occurs in development environments, or alternatively add a configuration\nproperty check (such as auth.seed.enabled) before executing the seeding logic.\nAdditionally, replace the hardcoded password literals in lines 21 and 28 where\npasswordEncoder.encode(\"alice\") and passwordEncoder.encode(\"bob\") are called\nwith passwords read from environment variables or configuration properties\n(using `@Value` or Environment injection) to externalize the seed credentials." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` at line\n77, The clientSecret method in SecurityConfig is storing the OAuth client secret\nin plaintext using the {noop} prefix, which disables password encoding and\ncreates a security vulnerability. Remove the {noop} prefix and instead apply\nproper password encoding such as bcrypt (using {bcrypt} prefix) or configure a\nPasswordEncoder bean to hash the client secret securely before storage." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` at line\n81, The password grant type registered at line 81 with\nauthorizationGrantType(new AuthorizationGrantType(\"password\")) in SecurityConfig\nlacks the necessary implementation components. You need to create a custom\nOAuth2AuthenticationConverter to parse username and password from token\nrequests, implement a custom OAuth2AuthenticationProvider to validate those\ncredentials using the existing DaoAuthenticationProvider (lines 155–168), and\nconfigure these components in the OAuth2TokenEndpointConfigurer to handle the\npassword grant type at the token endpoint. Ensure the custom converter and\nprovider are properly wired into the authorization server configuration to\nenable the password grant flow." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/test/java/com/audio/auth/AuthenticationTest.java` around\nlines 116 - 214, Remove the `@Disabled` annotation from at least one of the\nhappy-path token tests to ensure core token issuance and JWT claim validation\nare verified in CI. Consider re-enabling testObtainAccessTokenWithAdminRole(),\ntestAccessTokenContainsRoles(), and\ntestAccessTokenWithUserRoleContainsUserAuthority() since these verify critical\nfunctionality like token creation and role claims mapping. Keep\ntestInvalidUserCredentials enabled to validate error handling as well." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/Dockerfile` around lines 12 - 17, Add a USER directive to the\nruntime container stage to run the application as a non-root user instead of\nroot. Before the CMD instruction that runs java -jar app.jar, create a new\nnon-root user (such as appuser) using RUN apk commands with appropriate\npermissions, and then add a USER directive to specify that this user should\nexecute the application. This applies to the final stage after the EXPOSE 9000\nline and before the CMD instruction." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/main/resources/application-docker.yml` around lines 10 - 12,\nThe hardcoded database credentials (username and password both set to\n\"postgres\") in the application-docker.yml file present a security risk by\nexposing sensitive information in version control. Replace the hardcoded values\nfor the username and password properties with environment variable references\nusing Spring's property placeholder syntax (e.g., ${SPRING_DATASOURCE_USERNAME}\nand ${SPRING_DATASOURCE_PASSWORD}), then ensure these environment variables are\nproperly injected at runtime through Docker environment configuration or a\nsecrets management system." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/src/services/authService.js` around lines 7 - 11, The hardcoded\nclient_secret value in the params.append call within the authService.js\nauthentication flow exposes confidential credentials in browser-delivered\nJavaScript. Remove the line that appends the client_secret (the\nparams.append('client_secret', 'gateway-secret') call) and refactor the\nauthentication mechanism to use Authorization Code with PKCE flow for public\nclients, or implement a backend/BFF token exchange endpoint that securely\nhandles client credentials server-side instead of exposing them in frontend\ncode. This ensures the client secret remains confidential and never reaches the\nbrowser." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/src/services/axiosInstance.js` around lines 4 - 6, The\naxiosInstance object hardcodes the baseURL to 'http://localhost:8080', which\nwill fail in non-local environments. Replace the hardcoded localhost URL with\neither a relative base URL (such as an empty string or relative path) or an\nenvironment variable that can be configured per environment. Update the baseURL\nproperty in the axios.create() call to use process.env or a similar environment\nconfiguration mechanism so the same bundle can work across development, staging,\nand production environments." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/src/services/authService.js` at line 3, The AUTH_URL constant on\nline 3 is hardcoded to http://localhost:9000/auth which causes deployment\nfailures in Docker and production environments. Replace this hardcoded value\nwith an environment variable reference (such as process.env.AUTH_BASE_URL or\nprocess.env.AUTH_URL) that can be configured per environment, and ensure a\nsensible default fallback is provided if the environment variable is not set.\nThis allows the auth service URL to be dynamically configured based on the\ndeployment environment rather than tied to a developer's local machine." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java` around\nlines 24 - 25, The SecurityConfig class uses\npathMatchers(\"/actuator/**\").permitAll() which permits all actuator endpoints\nwithout authentication, creating a security risk if management endpoints are\nexpanded. Replace this overly permissive rule with more granular pathMatchers\nthat explicitly allow only safe, non-sensitive endpoints like /actuator/health\nand /actuator/info without authentication, while requiring authentication for\nall other actuator endpoints through additional pathMatchers rules. This ensures\nnew actuator endpoints added to the exposure configuration will not\nautomatically become publicly accessible and provides defense-in-depth security\nregardless of future configuration changes." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java`\naround lines 23 - 25, The oauth2ResourceServer JWT configuration in\nSecurityConfig is using Customizer.withDefaults() which does not map the\nauth-server's roles claim to Spring Security's ROLE_* authorities, causing\n`@PreAuthorize`(\"hasRole('...')\") annotations in StorageController to fail. Create\na JwtAuthenticationConverter bean in SecurityConfig that extracts the roles\nclaim from the JWT token and converts each role to ROLE_* format using a custom\nGrantedAuthoritiesConverter, then configure the jwt() method to use this\nconverter instead of Customizer.withDefaults()." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@song-service/src/main/java/com/audio/song/config/SecurityConfig.java` around\nlines 29 - 31, The SecurityConfig class is using Customizer.withDefaults() for\nJWT configuration which reads from scope claims with SCOPE_ prefix, but the\nauthorization checks in SongController expect ROLE_* authorities derived from a\nroles claim. Replace the Customizer.withDefaults() in the oauth2ResourceServer\njwt configuration with a custom JwtAuthenticationConverter bean that maps the\nroles claim to ROLE_* authorities, similar to the implementation found in the\ngateway SecurityConfig. This converter should extract the roles from the JWT\nclaims and convert each role to an authority with the ROLE_ prefix to align with\nthe `@PreAuthorize` checks." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java`\naround lines 17 - 30, The SecurityConfig class's securityFilterChain method uses\n`.cors(Customizer.withDefaults())` which does not properly configure CORS for\ncross-origin requests from the React UI at http://localhost:3000. Create a new\nCorsConfigurationSource bean that explicitly configures allowed origins\n(http://localhost:3000), allowed HTTP methods (GET, POST, PUT, DELETE, etc.),\nand allowed headers. Then update the securityFilterChain method to use this bean\ninstead of the default customizer by passing it to the cors configuration." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn @.env at line 60, The CONFIG_REPO_URI environment variable is currently\npointing to an external mutable Git repository (PashaPoliak/config-repo.git),\nwhich introduces supply-chain and config drift risks. Replace the default value\nof CONFIG_REPO_URI in the .env file with a reference to an\norganization-controlled repository instead of relying on an external mutable\nsource, or implement an immutable reference strategy (such as pinning to a\nspecific commit hash) to ensure configuration integrity and security for shared\nenvironments." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@config-repo/auth-service-docker.yml` around lines 9 - 13, The datasource\nconfiguration contains hardcoded PostgreSQL credentials (username and password\nfields set to \"postgres\") and likely has DEBUG logging enabled for\norg.springframework.security (referenced at lines 24-25), both of which\ncompromise security in a production Docker environment. Remove the hardcoded\nusername and password values from the datasource section and replace them with\nenvironment variable placeholders or use Spring's externalized configuration\napproach. Additionally, change the logging level for\norg.springframework.security from DEBUG to a less verbose level (like INFO or\nWARN) to avoid exposing sensitive information in logs." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn\n`@storage-service/src/main/java/com/audio/storage/controller/StorageController.java`\naround lines 30 - 33, The create method in StorageController is returning HTTP\n200 (OK) instead of the expected HTTP 201 (Created) for POST resource creation.\nChange the ResponseEntity.ok() call to use ResponseEntity.created() or\nResponseEntity.status(HttpStatus.CREATED) to return the correct HTTP 201 status\ncode when a storage resource is successfully created." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn\n`@storage-service/src/main/java/com/audio/storage/controller/StorageController.java`\naround lines 31 - 44, The SecurityConfig class currently uses default JWT\nconfiguration that only recognizes standard scope claims, but needs to extract\nthe custom roles claim from auth-service tokens. Update the SecurityConfig\n(specifically where .jwt(Customizer.withDefaults()) is configured) to use a\ncustom JwtGrantedAuthoritiesConverter that maps the roles claim to Spring\nSecurity authorities. Configure the converter by setting the\nauthoritiesClaimName to roles and the authorityPrefix to ROLE_ so that the\n`@PreAuthorize` guards in StorageController can properly recognize admin users." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/test/java/com/audio/auth/AuthenticationTest.java` around\nlines 41 - 42, The test constants ROLE_ADMIN and ROLE_USER include the \"ROLE_\"\nprefix, but the token customizer emits role claim values without this prefix,\ncausing assertion mismatches. Update the constant definitions for ROLE_ADMIN and\nROLE_USER to remove the \"ROLE_\" prefix (so they become \"ADMIN\" and \"USER\"\nrespectively), and ensure all assertions that use these constants throughout the\ntest file (including the locations at lines 172-175 and 197-199) are updated to\nexpect the role values without the prefix to match the actual token customizer\noutput." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/src/components/StoragesTable.js` around lines 34 - 44, The\nsetStorages calls in both the add storage mutation handler (around line 34) and\nthe delete storage handler (around line 44) are using closure-captured storages\nvariable, which can cause race conditions when multiple requests complete\nconcurrently. Refactor both setStorages calls to use functional updates instead:\nreplace setStorages([...storages, response.data]) with setStorages that takes a\nprevStorages parameter and returns the updated array, and similarly for the\nfilter operation in handleDeleteStorage. This ensures each state update always\nderives from the latest state rather than a potentially stale closure value." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/src/index.css` around lines 3 - 4, The font-family property in the\nCSS file is violating the stylelint font-family-name-quotes rule by quoting\nsingle-word font family names. Remove the single quotes around the single-word\nfont family names (Roboto, Oxygen, Ubuntu, and Cantarell) in the font-family\ndeclaration while keeping the quotes around multi-word font family names (Segoe\nUI, Fira Sans, Droid Sans, Helvetica Neue). Only quote font family names that\ncontain spaces." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/build.gradle` around lines 19 - 21, The buildReactApp task in the\nbuild.gradle file is not tracking the public directory as an input, which means\nchanges to static assets like public/index.html won't invalidate the build cache\nand stale assets can be packaged. Add inputs.dir(\"public\") to the list of input\ndeclarations alongside the existing inputs.dir(\"src\"),\ninputs.file(\"package.json\"), and inputs.file(\"package-lock.json\") to ensure the\nbuild task is properly invalidated when files in the public directory change." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/src/App.js` around lines 10 - 14, The condition in the App.js file\nthat checks `if (path === '/login' || !isAuthenticated())` is causing\nauthenticated users to see the login form when they visit the `/login` route.\nRemove the `path === '/login'` check from this condition so that the login form\nis only rendered when the user is not authenticated. Additionally, add a\nseparate check before this condition to redirect already-authenticated users who\ntry to access the `/login` path to the dashboard (or appropriate authenticated\nroute) using window.location.href or a redirect mechanism." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java`\naround lines 50 - 55, The userRoleCanGetSong and adminRoleCanGetSong test\nmethods lack mock setup for the songService.getSong() method, which can cause\ntests to pass for incorrect reasons (controller returning null) or fail\nunexpectedly. Add a mock setup before each test's mockMvc.perform() call to mock\nsongService.getSong() with a Song ID of 1 and return a valid Song object,\nensuring the controller receives expected data and the test validates the actual\nsecurity behavior rather than null handling." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@config-repo/auth-service.yml` around lines 24 - 25, The\norg.springframework.security logger is set to DEBUG level which logs sensitive\nauthentication data like tokens and credentials. Change the logging level for\norg.springframework.security from DEBUG to INFO in the default configuration,\nthen create a separate development profile (or use an existing dev/development\nprofile) where you can explicitly set org.springframework.security to DEBUG.\nThis ensures sensitive logging only occurs in development environments and not\nin production deployments." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` around\nlines 54 - 56, The OAuth2 endpoint paths have double prefixes due to the\nconfigured context path. Since server.servlet.context-path is set to /auth, the\nservlet container automatically prepends /auth to all paths, making manual\ninclusion of /auth redundant. Remove the /auth prefix from the\nauthorizationEndpoint method call and the tokenEndpoint method call so they\nbecome /oauth2/authorize and /oauth2/token respectively. Additionally, update\nthe requestMatchers security configuration from /auth/oauth2/** to /oauth2/** to\nmatch the corrected endpoint paths. This ensures OAuth2 flows work correctly\nwithout resulting in double-prefixed paths like /auth/auth/oauth2/token." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@config-repo/gateway.yml` around lines 39 - 40, The issuer-uri and jwk-set-uri\nvalues in the Spring Security configuration are incorrectly configured and will\nprevent JWT validation. Change the issuer-uri from `http://localhost:9000/auth`\nto `http://localhost:9000` to match the actual issuer configured in the auth\nserver, and change the jwk-set-uri from\n`http://localhost:9000/auth/.well-known/jwks.json` to\n`http://localhost:9000/oauth2/jwks` since Spring Authorization Server exposes\nthe JWK set at the `/oauth2/jwks` endpoint path, not `/.well-known/jwks.json`." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@config-repo/resource-service-docker.yml` around lines 26 - 27, The\njwk-set-uri configuration in the resource-service-docker.yml file is using an\nincorrect JWKS endpoint path. Spring Authorization Server does not have the\njwks.json file at the /auth/.well-known/ location. Update the jwk-set-uri value\nby changing the endpoint path from /auth/.well-known/jwks.json to\n/auth/oauth2/jwks to point to the correct JWKS endpoint on the auth-service." + }, + { + "prompt": "" + }, + { + "prompt": "" + }, + { + "prompt": "" + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java`\naround lines 24 - 26, Replace the default JWT converter at the\noauth2ResourceServer configuration in SecurityConfig with a custom JWT\nauthentication converter that handles role claim mapping. Create a custom\nJwtAuthenticationConverter (or similar custom converter class) that extracts the\nroles claim from the JWT token and maps it to Spring Security's expected format\nby prefixing each role with ROLE_. Then configure the jwt method in\noauth2ResourceServer to use this custom converter instead of\nCustomizer.withDefaults(). Reference the gateway service's implementation\npattern for the correct approach to extract the roles claim and transform it\ninto GrantedAuthority objects that will satisfy the `@PreAuthorize` hasRole checks\non controllers like the one at line 43." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@run_test.sh` around lines 1 - 4, The file run_test.sh has a Unix/Linux shell\nscript extension (.sh) but contains Windows batch commands such as `@echo` off, cd\n/d, gradlew.bat, and %ERRORLEVEL%, which are incompatible with Unix systems. Fix\nthis by either renaming the file to run_test_windows.bat to accurately reflect\nits Windows batch nature, or rewrite the entire script content using standard\nUnix shell syntax including the shebang, proper path quoting, relative ./gradlew\ninvocation instead of gradlew.bat, and $? for capturing exit codes instead of\n%ERRORLEVEL%. Choose the approach that matches your intended execution platform." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@tools/api-tests/module8-security-tests.json` around lines 20 - 26, The\npassword value for the alice user in the urlencoded parameters is set to\n\"password123\" but should be \"alice\" to match the test constants defined in\nAuthenticationTest.java. Locate the password key in the urlencoded array for\nTC-9 where the username is \"alice\" and change its value from \"password123\" to\n\"alice\"." + }, + { + "prompt": "Verify each finding against current code. Fix only still-valid issues, skip the\nrest with a brief reason, keep changes minimal, and validate.\n\nIn `@ui-service/build.gradle` around lines 15 - 24, The buildReactApp task\ndeclares outputs at build/resources/main/static/ but React's default build\nscript outputs to ./build/ instead, creating a mismatch that results in missing\nUI assets in the final JAR. Fix this by adding an environment variable\nBUILD_PATH=build/resources/main/static to the buildReactApp NpmTask definition\nto redirect React's output to the expected location, or alternatively create a\nseparate Gradle copy task that moves files from the ./build/ directory to\nsrc/main/resources/static/ after the React build completes. Either approach will\nensure the compiled React assets end up in the correct location for JAR\npackaging." + } +] \ No newline at end of file diff --git a/review/review.md b/review/review.md new file mode 100644 index 0000000..c121c89 --- /dev/null +++ b/review/review.md @@ -0,0 +1,1246 @@ +This PR introduces a new auth-service Spring Boot OAuth2 Authorization Server with JPA-backed users, RSA-signed JWTs, and a custom roles claim. JWT resource-server security is added to the gateway, resource-service, song-service, and storage-service. A React ui-service with login/dashboard/storages management is added. Config-repo YAML files, compose.yaml, and .env are updated to wire auth infrastructure. Logback is updated across all services for structured tracing, and new Grafana dashboards are added. + +Changes +OAuth2 Security Integration + +Layer / File(s) Summary +Auth service domain model and data access +auth-service/src/main/java/com/audio/auth/entity/User.java, auth-service/src/main/java/com/audio/auth/repository/UserRepository.java, auth-service/src/main/java/com/audio/auth/service/CustomUserDetailsService.java, auth-service/src/main/java/com/audio/auth/config/DataInitializer.java User JPA entity with eager user_roles collection; UserRepository with findByUsername; CustomUserDetailsService mapping roles to ROLE_-prefixed authorities; DataInitializer seeding alice (USER) and bob (ADMIN) when the repository is empty. +Auth service security config and application setup +auth-service/build.gradle, auth-service/src/main/java/com/audio/auth/AuthServiceApplication.java, auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java, auth-service/src/main/resources/application*.yml, auth-service/src/main/resources/logback-spring.xml, auth-service/Dockerfile, auth-service/.dockerignore SecurityConfig configures OAuth2 Authorization Server with OIDC, in-memory gateway registered client, RSA JWK source, JWT token customizer emitting roles claim, delegating PasswordEncoder, and DaoAuthenticationProvider; H2 (local) and PostgreSQL (Docker) application profiles; multi-stage Dockerfile; logback with LOGSTASH JSON appender. +Auth service integration tests +auth-service/src/test/java/com/audio/auth/AuthServiceApplicationTest.java, auth-service/src/test/java/com/audio/auth/AuthenticationTest.java, auth-service/src/test/resources/application-test.yml Context-load test; AuthenticationTest covering AuthenticationManager success/failure, token endpoint 401 for invalid client secret and 400 for missing params, and disabled JWT claim assertion tests; test profile with H2. +Resource service JWT resource-server security +resource-service/build.gradle, resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java, resource-service/src/main/java/com/audio/resource/controller/ResourceController.java, resource-service/src/main/java/com/audio/resource/exception/GlobalExceptionHandler.java, resource-service/src/main/java/com/audio/resource/messaging/ResourceEventConsumer.java, resource-service/src/test/java/com/audio/resource/ResourceServiceSecurityTest.java SecurityConfig enables JWT resource server (actuator permit-all); @PreAuthorize on upload/get (authenticated) and delete (ADMIN); AuthorizationDeniedException → 403 handler; processedResource @Bean consumer removed; security test verifying 401/403/200 per role. +Song and Storage service JWT resource-server security +song-service/build.gradle, song-service/src/main/java/com/audio/song/config/SecurityConfig.java, song-service/src/main/java/com/audio/song/controller/SongController.java, song-service/src/main/java/com/audio/song/exception/GlobalExceptionHandler.java, song-service/src/test/..., storage-service/build.gradle, storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java, storage-service/src/main/java/com/audio/storage/controller/StorageController.java, storage-service/src/main/java/com/audio/storage/exception/GlobalExceptionHandler.java Both services gain SecurityConfig (JWT resource server, CORS), @PreAuthorize role enforcement on all CRUD endpoints, 403 AuthorizationDeniedException handlers, and security test suites verifying 401/403/200 behavior. +Gateway WebFlux security and JWT converter +gateway/build.gradle, gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java, gateway/src/main/resources/logback-spring.xml Gateway switches to spring-cloud-gateway-server-webflux; SecurityWebFilterChain permits /actuator/** and enforces JWT auth on all other routes; ReactiveJwtAuthenticationConverter maps roles claim to ROLE_ authorities. +Config repo YAMLs, compose, and environment wiring +.env, .gitignore, compose.yaml, config-repo/auth-*.yml, config-repo/gateway.yml, config-repo/resource-service*.yml, config-repo/song-service*.yml, config-repo/storage-service*.yml, config-service/gradle.properties, config-service/src/main/resources/application.yaml, README.md Auth-service config-repo profiles (local PostgreSQL port 5435, Docker auth-db); issuer-uri/jwk-set-uri JWT settings added to all resource-service configs and gateway; compose.yaml adds auth-db (PostgreSQL + healthcheck) and auth-service containers; .env adds auth DB vars, changes Grafana port to 3090, adds CONFIG_REPO_URI; config-service git URI updated to GitHub; README expanded with startup order and health-check endpoints. +React UI Service + +Layer / File(s) Summary +UI build setup and auth service layer +ui-service/build.gradle, ui-service/package.json, ui-service/public/index.html, ui-service/.gitignore, ui-service/src/services/authService.js, ui-service/src/services/axiosInstance.js Gradle Node plugin wires buildReactApp/startReactApp into jar/build/bootRun; authService performs OAuth2 password grant, stores tokens in localStorage, exposes isAuthenticated/decodeToken/getUserRoles; axiosInstance attaches bearer token on requests and redirects to /login on 401 responses. +UI components, routing, and styles +ui-service/src/index.js, ui-service/src/App.js, ui-service/src/components/ProtectedRoute.js, ui-service/src/components/Login.js, ui-service/src/components/Dashboard.js, ui-service/src/components/StoragesTable.js, ui-service/src/index.css App gates routes via isAuthenticated(); ProtectedRoute redirects unauthenticated users; Login form with loading/error state; Dashboard shows decoded username, roles, logout, and embeds StoragesTable; StoragesTable fetches storages with role-gated add/delete admin actions; full CSS stylesheet. +Observability: Logback, Dashboards, and Dev Tooling + +Layer / File(s) Summary +Cross-service logback structured logging +config-service/build.gradle, config-service/src/main/resources/logback-spring.xml, discovery-service/build.gradle, discovery-service/src/main/resources/logback-spring.xml, gateway/src/main/resources/logback-spring.xml, resource-processor/src/main/resources/logback-spring.xml, resource-service/src/main/resources/logback-spring.xml, song-service/src/main/resources/logback-spring.xml, storage-service/src/main/resources/logback-spring.xml logstash-logback-encoder:7.4 added to config-service and discovery-service; traceId/spanId MDC fields added to CONSOLE encoder patterns; LOGSTASH JSON appender added to root logger across all services; UTC timezone specified in JSON timestamp providers. +Grafana dashboards, Postman collection, and dev test scripts +tools/dashboards/gateway-metrics.json, tools/dashboards/jvm-metrics.json, tools/api-tests/module8-security-tests.json, feedback.md, run_test.bat, run_test.cmd, run_test.sh, run_tests.py API Gateway Performance dashboard (request rate, 5xx rate, p50/p95/p99 latency, stat tiles); JVM Metrics dashboard (heap, non-heap, threads, GC, CPU, uptime); Postman collection for OAuth2 token and storage authorization tests; feedback.md documenting auth test failure analysis; Windows/Python scripts for running auth-service tests locally. + +tools/dashboards/jvm-metrics.json-22-31 (1) +22-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Add job filter to JVM metric queries. + +The job template variable is defined (lines 201-216) but is not referenced in any panel queries. This means the "Service" dropdown will have no effect—all services' metrics will always be displayed regardless of selection. + +🔧 Proposed fix to add job filtering +Apply the job filter to all metric queries. For example, for the heap memory panel: + + "targets": [ + { +- "expr": "jvm_memory_used_bytes{area=\"heap\"}", ++ "expr": "jvm_memory_used_bytes{area=\"heap\", job=~\"$job\"}", + "legendFormat": "{{job}} - {{id}}", +Apply the same pattern to all other panels: + +Line 53: jvm_memory_used_bytes{area=\"nonheap\", job=~\"$job\"} +Line 82: jvm_threads_live_threads{job=~\"$job\"} +Line 90: jvm_threads_daemon_threads{job=~\"$job\"} +Line 116: rate(jvm_gc_pause_seconds_sum{job=~\"$job\"}[1m]) / rate(jvm_gc_pause_seconds_count{job=~\"$job\"}[1m]) +Line 142: process_cpu_usage{job=~\"$job\"} +Line 150: system_cpu_usage{job=~\"$job\"} +Line 178: process_uptime_seconds{job=~\"$job\"} +Use job=~\"$job\" to support the multi-select and "All" options. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@tools/dashboards/jvm-metrics.json` around lines 22 - 31, The job template +variable is defined but not referenced in any of the panel metric queries, +making the Service dropdown ineffective. Add the job filter to all metric +expressions in the dashboard by appending job=~"$job" to each query's label +matcher. Update the expressions for jvm_memory_used_bytes (heap at line 22), +jvm_memory_used_bytes (nonheap at line 53), jvm_threads_live_threads (line 82), +jvm_threads_daemon_threads (line 90), jvm_gc_pause_seconds_sum and +jvm_gc_pause_seconds_count (line 116), process_cpu_usage (line 142), +system_cpu_usage (line 150), and process_uptime_seconds (line 178) to include +the job filter using the pattern job=~"$job" within their label matcher brackets +to support multi-select functionality. + +auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java-99-109 (1) +99-109: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift + +Persist signing keys instead of generating ephemeral RSA keys on startup. + +Lines 99-121 create a fresh key pair every process start. That invalidates previously issued tokens after restart and can break validation in multi-instance deployments when instances expose different JWKs. + +Use a shared/persistent key source (keystore, KMS/HSM, or externally mounted key material) with rotation strategy. + +Also applies to: 111-121 + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` around +lines 99 - 109, The jwkSource() method currently generates a new RSA key pair on +every startup via generateRsaKey(), which creates ephemeral keys that invalidate +previously issued tokens and cause inconsistency across multi-instance +deployments. Replace the ephemeral key generation with a persistent key source +by either loading keys from a keystore file, retrieving them from a KMS/HSM +service, or reading externally mounted key material. Implement a key rotation +strategy that reuses the same key material across restarts while allowing for +periodic key rotation without breaking existing token validation. +auth-service/src/main/java/com/audio/auth/config/DataInitializer.java-18-31 (1) +18-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Avoid unconditional seeding with predictable credentials. + +Line 18 runs seeding whenever the table is empty, and Lines 21/28 set easily guessable defaults. This can introduce a valid default credential path in non-local deployments. + +Gate this initializer behind a local/dev profile (or explicit auth.seed.enabled=true) and source seed passwords from environment/config secrets instead of literals. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/main/java/com/audio/auth/config/DataInitializer.java` around +lines 18 - 31, The DataInitializer class unconditionally seeds the database with +hardcoded credentials when the user table is empty, which creates a security +vulnerability. To fix this, add a Spring profile condition (such as +`@Profile`("dev") or `@Profile`("local")) to the DataInitializer class so seeding +only occurs in development environments, or alternatively add a configuration +property check (such as auth.seed.enabled) before executing the seeding logic. +Additionally, replace the hardcoded password literals in lines 21 and 28 where +passwordEncoder.encode("alice") and passwordEncoder.encode("bob") are called +with passwords read from environment variables or configuration properties +(using `@Value` or Environment injection) to externalize the seed credentials. +auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java-77-77 (1) +77-77: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Do not keep client secret in {noop} plaintext form. + +Line 77 stores the OAuth client secret without hashing, which weakens client credential handling and increases exposure risk. + +Suggested fix +- public RegisteredClientRepository registeredClientRepository() { ++ public RegisteredClientRepository registeredClientRepository(PasswordEncoder passwordEncoder) { + RegisteredClient oidcClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("gateway") +- .clientSecret("{noop}gateway-secret") ++ .clientSecret(passwordEncoder.encode("gateway-secret")) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST) +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` at line +77, The clientSecret method in SecurityConfig is storing the OAuth client secret +in plaintext using the {noop} prefix, which disables password encoding and +creates a security vulnerability. Remove the {noop} prefix and instead apply +proper password encoding such as bcrypt (using {bcrypt} prefix) or configure a +PasswordEncoder bean to hash the client secret securely before storage. +auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java-81-81 (1) +81-81: ⚠️ Potential issue | 🟠 Major + +Password grant type is registered but not implemented—token endpoint will return unsupported_grant_type error. + +Line 81 registers AuthorizationGrantType("password"), but Spring Authorization Server requires custom wiring to support it. The code lacks: + +Custom OAuth2AuthenticationConverter to extract username/password from token requests +Custom OAuth2AuthenticationProvider to validate credentials +OAuth2TokenEndpointConfigurer configuration to integrate these at the token endpoint +The DaoAuthenticationProvider (lines 155–168) handles traditional authentication, not OAuth2 password grant. Implement custom grant type extension per Spring Authorization Server guides. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` at line +81, The password grant type registered at line 81 with +authorizationGrantType(new AuthorizationGrantType("password")) in SecurityConfig +lacks the necessary implementation components. You need to create a custom +OAuth2AuthenticationConverter to parse username and password from token +requests, implement a custom OAuth2AuthenticationProvider to validate those +credentials using the existing DaoAuthenticationProvider (lines 155–168), and +configure these components in the OAuth2TokenEndpointConfigurer to handle the +password grant type at the token endpoint. Ensure the custom converter and +provider are properly wired into the authorization server configuration to +enable the password grant flow. +auth-service/src/test/java/com/audio/auth/AuthenticationTest.java-116-214 (1) +116-214: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift + +Core token issuance/claim tests are disabled in a security-focused change. + +Most happy-path token tests are @Disabled, so regressions in token issuance and JWT claim mapping won’t be caught in CI. At least one end-to-end success path should be enabled before merge. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/test/java/com/audio/auth/AuthenticationTest.java` around +lines 116 - 214, Remove the `@Disabled` annotation from at least one of the +happy-path token tests to ensure core token issuance and JWT claim validation +are verified in CI. Consider re-enabling testObtainAccessTokenWithAdminRole(), +testAccessTokenContainsRoles(), and +testAccessTokenWithUserRoleContainsUserAuthority() since these verify critical +functionality like token creation and role claims mapping. Keep +testInvalidUserCredentials enabled to validate error handling as well. +auth-service/Dockerfile-12-17 (1) +12-17: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Run the runtime container as a non-root user. + +The final image has no USER directive, so the app runs as root. Drop privileges in the runtime stage. + +🐳 Suggested hardening + FROM eclipse-temurin:21-jre-alpine +-RUN apk add --no-cache curl ++RUN apk add --no-cache curl \ ++ && addgroup -S app && adduser -S app -G app + WORKDIR /app + COPY --from=builder /app/auth-service/build/libs/*.jar app.jar ++RUN chown app:app /app/app.jar ++USER app + EXPOSE 9000 + CMD ["java", "-jar", "app.jar"] +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/Dockerfile` around lines 12 - 17, Add a USER directive to the +runtime container stage to run the application as a non-root user instead of +root. Before the CMD instruction that runs java -jar app.jar, create a new +non-root user (such as appuser) using RUN apk commands with appropriate +permissions, and then add a USER directive to specify that this user should +execute the application. This applies to the final stage after the EXPOSE 9000 +line and before the CMD instruction. +Source: Linters/SAST tools + +auth-service/src/main/resources/application-docker.yml-10-12 (1) +10-12: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Avoid committed static DB credentials in the Docker profile. + +username/password are hardcoded (postgres/postgres). This weakens security and leaks secrets through repo history. Externalize them via environment variables/secrets. + +🔐 Suggested change + datasource: + url: jdbc:postgresql://auth-db:5432/auth_db +- username: postgres +- password: postgres ++ username: ${AUTH_DB_USERNAME} ++ password: ${AUTH_DB_PASSWORD} + driver-class-name: org.postgresql.Driver +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/main/resources/application-docker.yml` around lines 10 - 12, +The hardcoded database credentials (username and password both set to +"postgres") in the application-docker.yml file present a security risk by +exposing sensitive information in version control. Replace the hardcoded values +for the username and password properties with environment variable references +using Spring's property placeholder syntax (e.g., ${SPRING_DATASOURCE_USERNAME} +and ${SPRING_DATASOURCE_PASSWORD}), then ensure these environment variables are +properly injected at runtime through Docker environment configuration or a +secrets management system. +ui-service/src/services/authService.js-7-11 (1) +7-11: ⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift + +Do not ship OAuth client secrets in SPA code. + +Line 11 embeds client_secret in browser-delivered JS, so it is publicly recoverable and cannot be treated as confidential. Move browser auth to Authorization Code + PKCE (public client) or a backend/BFF token exchange, and remove secret handling from the frontend. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/src/services/authService.js` around lines 7 - 11, The hardcoded +client_secret value in the params.append call within the authService.js +authentication flow exposes confidential credentials in browser-delivered +JavaScript. Remove the line that appends the client_secret (the +params.append('client_secret', 'gateway-secret') call) and refactor the +authentication mechanism to use Authorization Code with PKCE flow for public +clients, or implement a backend/BFF token exchange endpoint that securely +handles client credentials server-side instead of exposing them in frontend +code. This ensures the client secret remains confidential and never reaches the +browser. +ui-service/src/services/axiosInstance.js-4-6 (1) +4-6: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Remove fixed localhost API base URL. + +Line 5 hardcodes the API origin and will fail outside local dev. Use a relative base URL or environment variable so the same bundle works across environments. + +💡 Suggested change + const axiosInstance = axios.create({ +- baseURL: 'http://localhost:8080' ++ baseURL: process.env.REACT_APP_API_BASE_URL || '' + }); +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/src/services/axiosInstance.js` around lines 4 - 6, The +axiosInstance object hardcodes the baseURL to 'http://localhost:8080', which +will fail in non-local environments. Replace the hardcoded localhost URL with +either a relative base URL (such as an empty string or relative path) or an +environment variable that can be configured per environment. Update the baseURL +property in the axios.create() call to use process.env or a similar environment +configuration mechanism so the same bundle can work across development, staging, +and production environments. +ui-service/src/services/authService.js-3-3 (1) +3-3: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Externalize auth base URL instead of hardcoding localhost. + +Line 3 hardcodes http://localhost:9000, which breaks Docker/prod deployments and ties auth to a developer machine origin. Use env-driven or relative routing. + +💡 Suggested change +-const AUTH_URL = 'http://localhost:9000/auth'; ++const AUTH_URL = process.env.REACT_APP_AUTH_BASE_URL || '/auth'; +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/src/services/authService.js` at line 3, The AUTH_URL constant on +line 3 is hardcoded to http://localhost:9000/auth which causes deployment +failures in Docker and production environments. Replace this hardcoded value +with an environment variable reference (such as process.env.AUTH_BASE_URL or +process.env.AUTH_URL) that can be configured per environment, and ensure a +sensible default fallback is provided if the environment variable is not set. +This allows the auth service URL to be dynamically configured based on the +deployment environment rather than tied to a developer's local machine. +gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java-24-25 (1) +24-25: ⚠️ Potential issue | 🟠 Major + +Narrow unauthenticated actuator access at the gateway + +Line 24 permits all /actuator/** endpoints without authentication. While the application.yaml currently exposes only health, info, and refresh endpoints, the security configuration lacks defense-in-depth. If management.endpoints.web.exposure.include is expanded, any newly exposed endpoints automatically become publicly accessible. Configure authentication to require it for sensitive actuator operations. + +Suggested hardening +- .pathMatchers("/actuator/**").permitAll() ++ .pathMatchers("/actuator/health", "/actuator/health/**", "/actuator/info").permitAll() ++ .pathMatchers("/actuator/**").authenticated() +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java` around +lines 24 - 25, The SecurityConfig class uses +pathMatchers("/actuator/**").permitAll() which permits all actuator endpoints +without authentication, creating a security risk if management endpoints are +expanded. Replace this overly permissive rule with more granular pathMatchers +that explicitly allow only safe, non-sensitive endpoints like /actuator/health +and /actuator/info without authentication, while requiring authentication for +all other actuator endpoints through additional pathMatchers rules. This ensures +new actuator endpoints added to the exposure configuration will not +automatically become publicly accessible and provides defense-in-depth security +regardless of future configuration changes. +storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java-23-25 (1) +23-25: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Missing JWT authority converter will break role-based authorization. + +Same issue as song-service: Customizer.withDefaults() doesn't map the auth-server's roles claim to ROLE_* authorities. Any @PreAuthorize("hasRole('...')") annotations in StorageController will fail at runtime. + +See the proposed fix in the song-service SecurityConfig.java review comment for the required JwtAuthenticationConverter bean. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java` +around lines 23 - 25, The oauth2ResourceServer JWT configuration in +SecurityConfig is using Customizer.withDefaults() which does not map the +auth-server's roles claim to Spring Security's ROLE_* authorities, causing +`@PreAuthorize`("hasRole('...')") annotations in StorageController to fail. Create +a JwtAuthenticationConverter bean in SecurityConfig that extracts the roles +claim from the JWT token and converts each role to ROLE_* format using a custom +GrantedAuthoritiesConverter, then configure the jwt() method to use this +converter instead of Customizer.withDefaults(). +song-service/src/main/java/com/audio/song/config/SecurityConfig.java-29-31 (1) +29-31: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Missing JWT authority converter will break role-based authorization. + +The gateway configures a custom JwtAuthenticationConverter that maps the roles claim to ROLE_* authorities (see gateway/src/main/java/com/audio/gateway/config/SecurityConfig.java:34-41). However, this service uses Customizer.withDefaults(), which reads from scope/scp claims with a SCOPE_ prefix by default. + +This means @PreAuthorize("hasRole('ADMIN')") checks in SongController will fail at runtime because the roles claim from the auth-server won't be converted to ROLE_ADMIN authorities. + +🔧 Proposed fix: Add a custom JWT authority converter + package com.audio.song.config; + + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + import org.springframework.security.config.Customizer; + import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + import org.springframework.security.config.annotation.web.builders.HttpSecurity; + import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; ++import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; ++import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; + import org.springframework.security.web.SecurityFilterChain; + import org.springframework.web.cors.CorsConfiguration; + import org.springframework.web.cors.CorsConfigurationSource; + import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + + import java.util.Arrays; + import java.util.List; + + `@Configuration` + `@EnableWebSecurity` + `@EnableMethodSecurity` + public class SecurityConfig { + + `@Bean` + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http + .authorizeHttpRequests(auth -> auth + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 +- .jwt(Customizer.withDefaults()) ++ .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } ++ ++ `@Bean` ++ public JwtAuthenticationConverter jwtAuthenticationConverter() { ++ JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); ++ authoritiesConverter.setAuthorityPrefix("ROLE_"); ++ authoritiesConverter.setAuthoritiesClaimName("roles"); ++ ++ JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); ++ converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); ++ return converter; ++ } +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@song-service/src/main/java/com/audio/song/config/SecurityConfig.java` around +lines 29 - 31, The SecurityConfig class is using Customizer.withDefaults() for +JWT configuration which reads from scope claims with SCOPE_ prefix, but the +authorization checks in SongController expect ROLE_* authorities derived from a +roles claim. Replace the Customizer.withDefaults() in the oauth2ResourceServer +jwt configuration with a custom JwtAuthenticationConverter bean that maps the +roles claim to ROLE_* authorities, similar to the implementation found in the +gateway SecurityConfig. This converter should extract the roles from the JWT +claims and convert each role to an authority with the ROLE_ prefix to align with +the `@PreAuthorize` checks. +storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java-17-30 (1) +17-30: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Missing CORS configuration for React UI access. + +Unlike song-service, this config lacks a CorsConfigurationSource bean. With only .cors(Customizer.withDefaults()), Spring will not allow cross-origin requests from http://localhost:3000 (the React UI). This will cause CORS errors when the UI attempts to manage storages. + +🔧 Proposed fix: Add CorsConfigurationSource bean + package com.audio.storage.config; + + import org.springframework.context.annotation.Bean; + import org.springframework.context.annotation.Configuration; + import org.springframework.security.config.Customizer; + import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; + import org.springframework.security.config.annotation.web.builders.HttpSecurity; + import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; + import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; + import org.springframework.security.web.SecurityFilterChain; ++import org.springframework.web.cors.CorsConfiguration; ++import org.springframework.web.cors.CorsConfigurationSource; ++import org.springframework.web.cors.UrlBasedCorsConfigurationSource; ++ ++import java.util.Arrays; ++import java.util.List; + + `@Configuration` + `@EnableWebSecurity` + `@EnableMethodSecurity` + public class SecurityConfig { + + `@Bean` + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + // ... existing code ... + } ++ ++ `@Bean` ++ public CorsConfigurationSource corsConfigurationSource() { ++ CorsConfiguration configuration = new CorsConfiguration(); ++ configuration.setAllowedOrigins(List.of("http://localhost:3000")); ++ configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); ++ configuration.setAllowedHeaders(List.of("*")); ++ configuration.setAllowCredentials(true); ++ UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); ++ source.registerCorsConfiguration("/**", configuration); ++ return source; ++ } + } +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java` +around lines 17 - 30, The SecurityConfig class's securityFilterChain method uses +`.cors(Customizer.withDefaults())` which does not properly configure CORS for +cross-origin requests from the React UI at http://localhost:3000. Create a new +CorsConfigurationSource bean that explicitly configures allowed origins +(http://localhost:3000), allowed HTTP methods (GET, POST, PUT, DELETE, etc.), +and allowed headers. Then update the securityFilterChain method to use this bean +instead of the default customizer by passing it to the cors configuration. +.env-60-60 (1) +60-60: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +CONFIG_REPO_URI default points to a mutable external Git source. + +Using a shared .env default that targets an external mutable repo can introduce config drift/supply-chain risk for security settings. + +Consider defaulting to an org-controlled repo (or immutable ref strategy) for shared environments. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In @.env at line 60, The CONFIG_REPO_URI environment variable is currently +pointing to an external mutable Git repository (PashaPoliak/config-repo.git), +which introduces supply-chain and config drift risks. Replace the default value +of CONFIG_REPO_URI in the .env file with a reference to an +organization-controlled repository instead of relying on an external mutable +source, or implement an immutable reference strategy (such as pinning to a +specific commit hash) to ensure configuration integrity and security for shared +environments. +config-repo/auth-service-docker.yml-9-13 (1) +9-13: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +Avoid hardcoded auth DB credentials and org.springframework.security: DEBUG in docker defaults. + +This profile is used by the compose auth-service, so these defaults directly affect runtime security posture. + +Suggested hardening + spring: + datasource: + url: jdbc:postgresql://auth-db:5432/auth_db +- username: postgres +- password: postgres ++ username: ${POSTGRES_AUTH_USER} ++ password: ${POSTGRES_AUTH_PASSWORD} +@@ + logging: + level: + root: INFO +- org.springframework.security: DEBUG ++ org.springframework.security: INFO +Also applies to: 24-25 + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@config-repo/auth-service-docker.yml` around lines 9 - 13, The datasource +configuration contains hardcoded PostgreSQL credentials (username and password +fields set to "postgres") and likely has DEBUG logging enabled for +org.springframework.security (referenced at lines 24-25), both of which +compromise security in a production Docker environment. Remove the hardcoded +username and password values from the datasource section and replace them with +environment variable placeholders or use Spring's externalized configuration +approach. Additionally, change the logging level for +org.springframework.security from DEBUG to a less verbose level (like INFO or +WARN) to avoid exposing sensitive information in logs. +storage-service/src/main/java/com/audio/storage/controller/StorageController.java-30-33 (1) +30-33: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win + +POST /storages currently returns 200, not the expected 201. + +The downstream security test collection expects admin create to return 201, but ResponseEntity.ok(...) returns 200. + +Suggested fix ++import org.springframework.http.HttpStatus; +@@ + `@PostMapping` + `@PreAuthorize`("hasRole('ADMIN')") + public ResponseEntity create(`@Valid` `@RequestBody` StorageCreateRequest request) { +- return ResponseEntity.ok(storageService.create(request)); ++ return ResponseEntity.status(HttpStatus.CREATED).body(storageService.create(request)); + } +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In +`@storage-service/src/main/java/com/audio/storage/controller/StorageController.java` +around lines 30 - 33, The create method in StorageController is returning HTTP +200 (OK) instead of the expected HTTP 201 (Created) for POST resource creation. +Change the ResponseEntity.ok() call to use ResponseEntity.created() or +ResponseEntity.status(HttpStatus.CREATED) to return the correct HTTP 201 status +code when a storage resource is successfully created. +storage-service/src/main/java/com/audio/storage/controller/StorageController.java-31-44 (1) +31-44: ⚠️ Potential issue | 🟠 Major + +Storage service uses default JWT converter that ignores custom roles claim. + +The @PreAuthorize("hasRole('ADMIN')") guards expect ROLE_ADMIN authority, but storage-service's SecurityConfig (line 23-24) only uses .jwt(Customizer.withDefaults()), which maps only standard scope/scp claims. The auth-service emits a custom roles claim, but without explicit configuration via JwtGrantedAuthoritiesConverter.setAuthoritiesClaimName("roles") and .setAuthorityPrefix("ROLE_"), this claim is ignored during token validation. Valid admin tokens will receive 403 Forbidden. + +Configure a custom JWT authentication converter in storage-service to map the roles claim to Spring Security authorities. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In +`@storage-service/src/main/java/com/audio/storage/controller/StorageController.java` +around lines 31 - 44, The SecurityConfig class currently uses default JWT +configuration that only recognizes standard scope claims, but needs to extract +the custom roles claim from auth-service tokens. Update the SecurityConfig +(specifically where .jwt(Customizer.withDefaults()) is configured) to use a +custom JwtGrantedAuthoritiesConverter that maps the roles claim to Spring +Security authorities. Configure the converter by setting the +authoritiesClaimName to roles and the authorityPrefix to ROLE_ so that the +`@PreAuthorize` guards in StorageController can properly recognize admin users. +🟡 Minor comments (7) +auth-service/src/test/java/com/audio/auth/AuthenticationTest.java-41-42 (1) +41-42: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +JWT roles claim expectations don’t match the auth-service claim contract. + +Assertions expect ROLE_ADMIN / ROLE_USER, but the token customizer emits role values without the ROLE_ prefix. These checks will fail once enabled. + +✅ Suggested fix +- private static final String ROLE_ADMIN = "ROLE_ADMIN"; +- private static final String ROLE_USER = "ROLE_USER"; ++ private static final String ROLE_ADMIN = "ADMIN"; ++ private static final String ROLE_USER = "USER"; +Also applies to: 172-175, 197-199 + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/test/java/com/audio/auth/AuthenticationTest.java` around +lines 41 - 42, The test constants ROLE_ADMIN and ROLE_USER include the "ROLE_" +prefix, but the token customizer emits role claim values without this prefix, +causing assertion mismatches. Update the constant definitions for ROLE_ADMIN and +ROLE_USER to remove the "ROLE_" prefix (so they become "ADMIN" and "USER" +respectively), and ensure all assertions that use these constants throughout the +test file (including the locations at lines 172-175 and 197-199) are updated to +expect the role values without the prefix to match the actual token customizer +output. +ui-service/src/components/StoragesTable.js-34-44 (1) +34-44: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +Use functional state updates for async add/delete mutations. + +Lines 34 and 44 use closure-captured storages; concurrent request completions can overwrite newer state and show stale rows. Use functional updates to always derive from latest state. + +💡 Suggested change +- setStorages([...storages, response.data]); ++ setStorages(prev => [...prev, response.data]); +@@ +- setStorages(storages.filter(s => s.id !== id)); ++ setStorages(prev => prev.filter(s => s.id !== id)); +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/src/components/StoragesTable.js` around lines 34 - 44, The +setStorages calls in both the add storage mutation handler (around line 34) and +the delete storage handler (around line 44) are using closure-captured storages +variable, which can cause race conditions when multiple requests complete +concurrently. Refactor both setStorages calls to use functional updates instead: +replace setStorages([...storages, response.data]) with setStorages that takes a +prevStorages parameter and returns the updated array, and similarly for the +filter operation in handleDeleteStorage. This ensures each state update always +derives from the latest state rather than a potentially stale closure value. +ui-service/src/index.css-3-4 (1) +3-4: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +Fix Stylelint font-family-name-quotes violations in the font stack. + +Lines 3-4 quote single-word family names (Roboto, Oxygen, Ubuntu, Cantarell), which fails the configured lint rule. + +💡 Suggested fix + body { + margin: 0; +- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', +- 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; ++ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, ++ Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + background-color: `#f5f5f5`; + } +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/src/index.css` around lines 3 - 4, The font-family property in the +CSS file is violating the stylelint font-family-name-quotes rule by quoting +single-word font family names. Remove the single quotes around the single-word +font family names (Roboto, Oxygen, Ubuntu, and Cantarell) in the font-family +declaration while keeping the quotes around multi-word font family names (Segoe +UI, Fira Sans, Droid Sans, Helvetica Neue). Only quote font family names that +contain spaces. +Source: Linters/SAST tools + +ui-service/build.gradle-19-21 (1) +19-21: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +Track public/ as an input for deterministic frontend builds. + +Line 19 only tracks src; changes in public/index.html won’t invalidate buildReactApp, so stale static assets can be packaged. + +💡 Suggested fix + tasks.register('buildReactApp', NpmTask) { + dependsOn 'npmInstall' + args = ['run', 'build'] + + inputs.dir("src") ++ inputs.dir("public") + inputs.file("package.json") + inputs.file("package-lock.json") +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/build.gradle` around lines 19 - 21, The buildReactApp task in the +build.gradle file is not tracking the public directory as an input, which means +changes to static assets like public/index.html won't invalidate the build cache +and stale assets can be packaged. Add inputs.dir("public") to the list of input +declarations alongside the existing inputs.dir("src"), +inputs.file("package.json"), and inputs.file("package-lock.json") to ensure the +build task is properly invalidated when files in the public directory change. +ui-service/src/App.js-10-14 (1) +10-14: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +Don’t render the login form for already-authenticated users on /login. + +Line 10 short-circuits on path === '/login', so authenticated users still see the login screen. + +💡 Suggested fix + function App() { + const path = window.location.pathname; ++ const authenticated = isAuthenticated(); + +- if (path === '/login' || !isAuthenticated()) { ++ if (path === '/login' && !authenticated) { + return ( + { window.location.href = '/dashboard'; }} /> + ); + } ++ ++ if (!authenticated) { ++ return ( ++ { window.location.href = '/dashboard'; }} /> ++ ); ++ } +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/src/App.js` around lines 10 - 14, The condition in the App.js file +that checks `if (path === '/login' || !isAuthenticated())` is causing +authenticated users to see the login form when they visit the `/login` route. +Remove the `path === '/login'` check from this condition so that the login form +is only rendered when the user is not authenticated. Additionally, add a +separate check before this condition to redirect already-authenticated users who +try to access the `/login` path to the dashboard (or appropriate authenticated +route) using window.location.href or a redirect mechanism. +song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java-50-55 (1) +50-55: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +GET tests may fail or behave unexpectedly without mocking songService.getSong(). + +The userRoleCanGetSong and adminRoleCanGetSong tests don't mock songService.getSong(). When the controller invokes the mocked service, it will return null, which may cause the test to pass for the wrong reason (controller returning null wrapped in 200 OK) or fail if the service throws. + +🧪 Proposed fix ++ `@Test` ++ void userRoleCanGetSong() throws Exception { ++ when(songService.getSong(1L)).thenReturn(new SongRequest(...)); ++ + mockMvc.perform(get("/songs/1") + .with(jwt().authorities(() -> "ROLE_USER"))) + .andExpect(status().isOk()); + } +Add similar mock setup for adminRoleCanGetSong(). + +Also applies to: 73-78 + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java` +around lines 50 - 55, The userRoleCanGetSong and adminRoleCanGetSong test +methods lack mock setup for the songService.getSong() method, which can cause +tests to pass for incorrect reasons (controller returning null) or fail +unexpectedly. Add a mock setup before each test's mockMvc.perform() call to mock +songService.getSong() with a Song ID of 1 and return a valid Song object, +ensuring the controller receives expected data and the test validates the actual +security behavior rather than null handling. +config-repo/auth-service.yml-24-25 (1) +24-25: ⚠️ Potential issue | 🟡 Minor | ⚡ Quick win + +DEBUG logging for security may leak sensitive data in non-development environments. + +org.springframework.security: DEBUG logs detailed authentication flows including tokens and credential validation. Consider using INFO level by default and enabling DEBUG only in development profiles to prevent accidental exposure. + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@config-repo/auth-service.yml` around lines 24 - 25, The +org.springframework.security logger is set to DEBUG level which logs sensitive +authentication data like tokens and credentials. Change the logging level for +org.springframework.security from DEBUG to INFO in the default configuration, +then create a separate development profile (or use an existing dev/development +profile) where you can explicitly set org.springframework.security to DEBUG. +This ensures sensitive logging only occurs in development environments and not +in production deployments. +ℹ️ Review info +⚙️ Run configuration +📥 Commits +⛔ Files ignored due to path filters (1) +📒 Files selected for processing (80) +💤 Files with no reviewable changes (1) +resource-service/src/main/java/com/audio/resource/messaging/ResourceEventConsumer.java +auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java +Comment on lines +54 to +56 + .requestMatchers("/actuator/**").permitAll() + .requestMatchers("/auth/oauth2/**").permitAll() + .anyRequest().authenticated() + + +With server.servlet.context-path=/auth, should AuthorizationServerSettings.tokenEndpoint(...)be configured as/oauth2/tokenor/auth/oauth2/token in Spring Authorization Server? How are these paths combined? + +💡 Result: + +When configuring Spring Authorization Server with server.servlet.context-path=/auth, you should continue to use the default setting of /oauth2/token for AuthorizationServerSettings.tokenEndpoint(...) [1][2]. You do not need to include the context path in the endpoint setting [3]. Spring MVC and the underlying Servlet container handle the context path automatically [3]. When you set server.servlet.context-path=/auth, the application effectively treats /auth as the base of your application's request mapping [3]. Consequently, a request to /auth/oauth2/token is correctly routed to the token endpoint mapped at /oauth2/token because the servlet container strips the context path before the request reaches the Spring DispatcherServlet. These paths are combined at the servlet container level rather than via manual concatenation in the AuthorizationServerSettings configuration [3]. Setting the endpoint to /auth/oauth2/token within AuthorizationServerSettings would result in an incorrect mapping (e.g., resulting in an effective path of /auth/auth/oauth2/token in some configurations), which would lead to 404 errors for your OAuth 2.0 endpoints [3]. For consistent behavior, you should let the issuer identifier resolve dynamically or, if you must explicitly set it, ensure it reflects the full public base URL (e.g., https://example.com/auth) without adding additional path segments that conflict with the servlet context path [3]. Keep in mind that Spring Authorization Server's AuthorizationServerContext resolves endpoint paths relative to the application's runtime context [3][4]. + + +find . -name "SecurityConfig.java" -type f + +wc -l ./auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java +Repository: Lokankara/Microservices + +Length of output: 142 + +🏁 Script executed: + +cat -n ./auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java +Repository: Lokankara/Microservices + +Length of output: 9299 + +🏁 Script executed: + +find . -name "application.properties" -o -name "application.yml" -o -name "application.yaml" | head -20 +Repository: Lokankara/Microservices + +Length of output: 680 + +cat ./auth-service/src/main/resources/application.yml + +Length of output: 558 + +Fix double-prefixed OAuth2 endpoint paths. + +With server.servlet.context-path: /auth, configure AuthorizationServerSettings endpoints as /oauth2/authorize and /oauth2/token (without the /auth prefix). Similarly, update the security matcher to /oauth2/**. The servlet container automatically prepends the context path; including it explicitly results in /auth/auth/oauth2/token and breaks OAuth2 flows. + +Required fixes: + +Line 143: Change .authorizationEndpoint("/auth/oauth2/authorize") to .authorizationEndpoint("/oauth2/authorize") +Line 144: Change .tokenEndpoint("/auth/oauth2/token") to .tokenEndpoint("/oauth2/token") +Line 55: Change .requestMatchers("/auth/oauth2/**") to .requestMatchers("/oauth2/**") +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java` around +lines 54 - 56, The OAuth2 endpoint paths have double prefixes due to the +configured context path. Since server.servlet.context-path is set to /auth, the +servlet container automatically prepends /auth to all paths, making manual +inclusion of /auth redundant. Remove the /auth prefix from the +authorizationEndpoint method call and the tokenEndpoint method call so they +become /oauth2/authorize and /oauth2/token respectively. Additionally, update +the requestMatchers security configuration from /auth/oauth2/** to /oauth2/** to +match the corrected endpoint paths. This ensures OAuth2 flows work correctly +without resulting in double-prefixed paths like /auth/auth/oauth2/token. +@PashaPoliak Reply... +config-repo/gateway.yml +Comment on lines +39 to +40 + issuer-uri: http://localhost:9000/auth + jwk-set-uri: http://localhost:9000/auth/.well-known/jwks.json + +1 hour ago +⚠️ Potential issue | 🔴 Critical + +🧩 Analysis chain +🌐 Web query: + +Spring Authorization Server default JWKS endpoint path + +💡 Result: + +The default path for the JWK Set (JWKS) endpoint in Spring Authorization Server is /oauth2/jwks [1][2][3]. This endpoint is configured via the AuthorizationServerSettings class [2][4]. It is important to note that the JWKS endpoint is only enabled and exposed if a JWKSource bean is registered in your application configuration [2][4]. You can customize this path using the jwkSetEndpoint method when building your AuthorizationServerSettings bean [2][4]. + +Citations: + +1: https://www.springframework.org/spring-security/reference/api/java/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.html +2: https://docs.spring.io/spring-authorization-server/reference/configuration-model.html +3: https://docs.enterprise.spring.io/spring-authorization-server/docs/1.4.9/api/org/springframework/security/oauth2/server/authorization/settings/AuthorizationServerSettings.html +4: https://docs.enterprise.spring.io/spring-authorization-server/reference/configuration-model.html +# Search for Spring Authorization Server configuration or setup +rg -i "authorization.server|spring.*auth.*server|jwk.*source" --type yaml --type java --type kotlin -A 3 + +# Check for application properties related to OAuth/OIDC configuration +rg -i "spring.security.oauth2|issuer-uri|jwk-set-uri" --type yaml --type properties -B 2 -A 2 + +# Look for auth server related files or documentation +fd -i "auth\|oauth\|security" -t f | head -20 + + +Length of output: 4763 + +# Check the auth-service SecurityConfig for AuthorizationServerSettings configuration +rg -A 20 "AuthorizationServerSettings" auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java + +# Also check if there's any endpoint configuration mentioning jwks or oauth2 +rg -i "endpoint|jwks|oauth2" auth-service/src/main/java/com/audio/auth/config/SecurityConfig.java -B 2 -A 2 + + +Length of output: 5425 + +Incorrect issuer-uri and JWKS endpoint path will cause JWT validation failures. + +The issuer configured in the auth server is http://localhost:9000, but the gateway uses http://localhost:9000/auth for the issuer-uri. Additionally, Spring Authorization Server exposes the JWK set at /oauth2/jwks, not /.well-known/jwks.json. Both mismatches must be corrected for JWT validation to work. + +Proposed fix + jwt: +- issuer-uri: http://localhost:9000/auth +- jwk-set-uri: http://localhost:9000/auth/.well-known/jwks.json ++ issuer-uri: http://localhost:9000 ++ jwk-set-uri: http://localhost:9000/oauth2/jwks +📝 Committable suggestion +‼️ IMPORTANT +Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +Suggested change + issuer-uri: http://localhost:9000/auth + jwk-set-uri: http://localhost:9000/auth/.well-known/jwks.json + issuer-uri: http://localhost:9000 + jwk-set-uri: http://localhost:9000/oauth2/jwks +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@config-repo/gateway.yml` around lines 39 - 40, The issuer-uri and jwk-set-uri +values in the Spring Security configuration are incorrectly configured and will +prevent JWT validation. Change the issuer-uri from `http://localhost:9000/auth` +to `http://localhost:9000` to match the actual issuer configured in the auth +server, and change the jwk-set-uri from +`http://localhost:9000/auth/.well-known/jwks.json` to +`http://localhost:9000/oauth2/jwks` since Spring Authorization Server exposes +the JWK set at the `/oauth2/jwks` endpoint path, not `/.well-known/jwks.json`. +@PashaPoliak Reply... +config-repo/resource-service-docker.yml +Comment on lines +26 to +27 + issuer-uri: http://auth-service:9000/auth + jwk-set-uri: http://auth-service:9000/auth/.well-known/jwks.json + +1 hour ago +⚠️ Potential issue | 🔴 Critical | ⚡ Quick win + +Incorrect JWKS endpoint path — same issue as gateway config. + +The /auth/.well-known/jwks.json path does not exist on Spring Authorization Server. Use /auth/oauth2/jwks instead. + +Proposed fix + jwt: + issuer-uri: http://auth-service:9000/auth +- jwk-set-uri: http://auth-service:9000/auth/.well-known/jwks.json ++ jwk-set-uri: http://auth-service:9000/auth/oauth2/jwks +📝 Committable suggestion +‼️ IMPORTANT +Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +Suggested change + issuer-uri: http://auth-service:9000/auth + jwk-set-uri: http://auth-service:9000/auth/.well-known/jwks.json + issuer-uri: http://auth-service:9000/auth + jwk-set-uri: http://auth-service:9000/auth/oauth2/jwks +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@config-repo/resource-service-docker.yml` around lines 26 - 27, The +jwk-set-uri configuration in the resource-service-docker.yml file is using an +incorrect JWKS endpoint path. Spring Authorization Server does not have the +jwks.json file at the /auth/.well-known/ location. Update the jwk-set-uri value +by changing the endpoint path from /auth/.well-known/jwks.json to +/auth/oauth2/jwks to point to the correct JWKS endpoint on the auth-service. +@PashaPoliak Reply... +config-repo/resource-service.yml +Comment on lines +33 to +34 + issuer-uri: http://localhost:9000/auth + jwk-set-uri: http://localhost:9000/auth/.well-known/jwks.json + +1 hour ago +⚠️ Potential issue | 🔴 Critical | ⚡ Quick win + +Incorrect JWKS endpoint path — same issue as other configs. + +Change /.well-known/jwks.json to /oauth2/jwks. + +Proposed fix +📝 Committable suggestion +🤖 Prompt for AI Agents +@PashaPoliak Reply... +config-repo/song-service-docker.yml +Comment on lines +13 to +14 + issuer-uri: http://auth-service:9000/auth + jwk-set-uri: http://auth-service:9000/auth/.well-known/jwks.json + +1 hour ago +⚠️ Potential issue | 🔴 Critical | ⚡ Quick win + +Incorrect JWKS endpoint path — same issue as other configs. + +Change /.well-known/jwks.json to /oauth2/jwks. + +Proposed fix +📝 Committable suggestion +🤖 Prompt for AI Agents +@PashaPoliak Reply... +2 hidden conversations +Load more… +config-repo/storage-service.yml +Comment on lines +11 to +12 + issuer-uri: http://localhost:9000/auth + jwk-set-uri: http://localhost:9000/auth/.well-known/jwks.json + +1 hour ago +⚠️ Potential issue | 🔴 Critical | ⚡ Quick win + +Incorrect JWKS endpoint path — same issue as other configs. + +Change /.well-known/jwks.json to /oauth2/jwks. + +Proposed fix +📝 Committable suggestion +🤖 Prompt for AI Agents +@PashaPoliak Reply... +resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java +Comment on lines +24 to +26 + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(Customizer.withDefaults()) + ) + +1 hour ago +⚠️ Potential issue | 🔴 Critical + +🧩 Analysis chain +Configure JWT role-claim mapping to enable admin role enforcement + +The resource-service uses the default JWT converter at lines 24–26, which only recognizes scope/scp claims. However, the auth server emits a roles claim without the ROLE_ prefix, and the controller enforces @PreAuthorize("hasRole('ADMIN')") at line 43. This mismatch causes admin role checks to fail against real tokens. + +The gateway service already implements the correct pattern. Apply the same custom JWT authentication converter here: + +Suggested fix ++import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; ++import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +@@ + `@Bean` + public SecurityFilterChain securityFilterChain(HttpSecurity http) { ++ JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); ++ authoritiesConverter.setAuthorityPrefix("ROLE_"); ++ authoritiesConverter.setAuthoritiesClaimName("roles"); ++ ++ JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); ++ jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); ++ + http +@@ + .oauth2ResourceServer(oauth2 -> oauth2 +- .jwt(Customizer.withDefaults()) ++ .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)) + ) +📝 Committable suggestion +‼️ IMPORTANT +Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements. + +Suggested change + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(Customizer.withDefaults()) + ) +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +`@Bean` +public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix("ROLE_"); + authoritiesConverter.setAuthoritiesClaimName("roles"); + JwtAuthenticationConverter jwtAuthenticationConverter = new JwtAuthenticationConverter(); + jwtAuthenticationConverter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + http + .authorizeHttpRequests(authz -> authz + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter)) + ) + .build(); + + return http.build(); +} +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@resource-service/src/main/java/com/audio/resource/config/SecurityConfig.java` +around lines 24 - 26, Replace the default JWT converter at the +oauth2ResourceServer configuration in SecurityConfig with a custom JWT +authentication converter that handles role claim mapping. Create a custom +JwtAuthenticationConverter (or similar custom converter class) that extracts the +roles claim from the JWT token and maps it to Spring Security's expected format +by prefixing each role with ROLE_. Then configure the jwt method in +oauth2ResourceServer to use this custom converter instead of +Customizer.withDefaults(). Reference the gateway service's implementation +pattern for the correct approach to extract the roles claim and transform it +into GrantedAuthority objects that will satisfy the `@PreAuthorize` hasRole checks +on controllers like the one at line 43. +@PashaPoliak Reply... +run_test.sh +tools/api-tests/module8-security-tests.json +Comment on lines +20 to +26 + "urlencoded": [ + { "key": "grant_type", "value": "password" }, + { "key": "username", "value": "alice" }, + { "key": "password", "value": "password123" }, + { "key": "client_id", "value": "gateway" }, + { "key": "client_secret", "value": "gateway-secret" } + ] + +1 hour ago +⚠️ Potential issue | 🔴 Critical | ⚡ Quick win + +Incorrect password for alice user. + +TC-9 uses password123 for the alice user, but the actual password is alice according to the test constants in AuthenticationTest.java:35. + +🐛 Proposed fix + { "key": "grant_type", "value": "password" }, + { "key": "username", "value": "alice" }, +- { "key": "password", "value": "password123" }, ++ { "key": "password", "value": "alice" }, + { "key": "client_id", "value": "gateway" }, +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@tools/api-tests/module8-security-tests.json` around lines 20 - 26, The +password value for the alice user in the urlencoded parameters is set to +"password123" but should be "alice" to match the test constants defined in +AuthenticationTest.java. Locate the password key in the urlencoded array for +TC-9 where the username is "alice" and change its value from "password123" to +"alice". +@PashaPoliak Reply... +ui-service/build.gradle +Comment on lines +15 to +24 +tasks.register('buildReactApp', NpmTask) { + dependsOn 'npmInstall' + args = ['run', 'build'] + + inputs.dir("src") + inputs.file("package.json") + inputs.file("package-lock.json") + + outputs.dir(layout.buildDirectory.dir("resources/main/static")) +} + +1 hour ago +⚠️ Potential issue | 🔴 Critical + +🧩 Analysis chain +#!/bin/bash +set -euo pipefail + +echo "== ui-service/package.json build script ==" +jq -r '.scripts.build // ""' ui-service/package.json + +echo +echo "== Output-path wiring checks ==" +rg -n "BUILD_PATH|resources/main/static|processResources|from\\(" ui-service/build.gradle ui-service/package.json + + +Length of output: 258 + +cat -n ui-service/build.gradle + + +Length of output: 1211 + +cat -n ui-service/package.json | head -30 + + +Length of output: 958 + +rg -n "processResources|copy.*static|from.*build" ui-service/ --type gradle --type json + + +Length of output: 49 + +find . -maxdepth 2 -name "build.gradle" -o -name "settings.gradle" | head -10 + + +Length of output: 334 + +cat -n ./build.gradle + + +Length of output: 713 + +rg -n "processResources|resources/main/static|from.*to" ui-service/ -A 3 + + +Length of output: 558 + +# Check if BUILD_PATH or similar env var is set anywhere +rg -n "BUILD_PATH|GENERATE_SOURCEMAP" ui-service/ + + +Length of output: 49 + +React build output is not wired into the Gradle package. + +React's build script outputs to ./build/ by default (react-scripts standard behavior), but the buildReactApp task declares outputs at build/resources/main/static/ with no mechanism to move or copy files between them. Add either: + +A BUILD_PATH=build/resources/main/static environment variable to the buildReactApp task, or +A Gradle copy task from build/ into src/main/resources/static/ before packaging. +Without this, the JAR will be missing UI assets. + +Also applies to: 31-37 + +🤖 Prompt for AI Agents +Verify each finding against current code. Fix only still-valid issues, skip the +rest with a brief reason, keep changes minimal, and validate. + +In `@ui-service/build.gradle` around lines 15 - 24, The buildReactApp task +declares outputs at build/resources/main/static/ but React's default build +script outputs to ./build/ instead, creating a mismatch that results in missing +UI assets in the final JAR. Fix this by adding an environment variable +BUILD_PATH=build/resources/main/static to the buildReactApp NpmTask definition +to redirect React's output to the expected location, or alternatively create a +separate Gradle copy task that moves files from the ./build/ directory to +src/main/resources/static/ after the React build completes. Either approach will +ensure the compiled React assets end up in the correct location for JAR +packaging. \ No newline at end of file diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..b70074f --- /dev/null +++ b/settings.gradle @@ -0,0 +1,10 @@ +rootProject.name = 'architecture' + +file('.').eachDir { dir -> + if (new File(dir, 'build.gradle').exists() && + dir.name != 'gradle' && + dir.name != 'build' && + !dir.name.startsWith('.')) { + include dir.name + } +} diff --git a/song-service/.dockerignore b/song-service/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/song-service/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/song-service/.gitattributes b/song-service/.gitattributes new file mode 100644 index 0000000..8af972c --- /dev/null +++ b/song-service/.gitattributes @@ -0,0 +1,3 @@ +/gradlew text eol=lf +*.bat text eol=crlf +*.jar binary diff --git a/song-service/.gitignore b/song-service/.gitignore new file mode 100644 index 0000000..c2065bc --- /dev/null +++ b/song-service/.gitignore @@ -0,0 +1,37 @@ +HELP.md +.gradle +build/ +!gradle/wrapper/gradle-wrapper.jar +!**/src/main/**/build/ +!**/src/test/**/build/ + +### STS ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### IntelliJ IDEA ### +.idea +*.iws +*.iml +*.ipr +out/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ diff --git a/song-service/Dockerfile b/song-service/Dockerfile new file mode 100644 index 0000000..faa1a4d --- /dev/null +++ b/song-service/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY song-service/build.gradle song-service/ +RUN ./gradlew :song-service:dependencies --no-daemon + +# Copy source code and build +COPY song-service/src song-service/src +RUN ./gradlew :song-service:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/song-service/build/libs/*.jar app.jar +EXPOSE 8082 +CMD ["java", "-jar", "app.jar"] diff --git a/song-service/build.gradle b/song-service/build.gradle new file mode 100644 index 0000000..6f83dbf --- /dev/null +++ b/song-service/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2025.1.1') +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:2025.1.1" + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation platform('org.springframework.cloud:spring-cloud-dependencies:2025.1.1') + implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.cloud:spring-cloud-starter-circuitbreaker-resilience4j' + implementation 'org.springframework.retry:spring-retry' + compileOnly 'org.projectlombok:lombok' + developmentOnly 'org.springframework.boot:spring-boot-devtools' + runtimeOnly 'org.postgresql:postgresql' + annotationProcessor 'org.projectlombok:lombok' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + runtimeOnly 'com.h2database:h2' + testImplementation 'com.h2database:h2' + testCompileOnly 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' + testImplementation('org.springframework.boot:spring-boot-starter-test') { + exclude group: 'org.junit.vintage', module: 'junit-vintage-engine' + } + testImplementation('org.springframework.restdocs:spring-restdocs-mockmvc') + testImplementation('org.springframework.security:spring-security-test') +} + +tasks.named('test') { + useJUnitPlatform() +} diff --git a/song-service/src/main/java/com/audio/song/SongApplication.java b/song-service/src/main/java/com/audio/song/SongApplication.java new file mode 100644 index 0000000..0cc1448 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/SongApplication.java @@ -0,0 +1,17 @@ +package com.audio.song; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; +import org.springframework.retry.annotation.EnableRetry; + +@SpringBootApplication +@EnableDiscoveryClient +@EnableRetry +public class SongApplication { + + public static void main(String[] args) { + SpringApplication.run(SongApplication.class, args); + } + +} diff --git a/song-service/src/main/java/com/audio/song/config/SecurityConfig.java b/song-service/src/main/java/com/audio/song/config/SecurityConfig.java new file mode 100644 index 0000000..59691ee --- /dev/null +++ b/song-service/src/main/java/com/audio/song/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.audio.song.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/actuator/health/**", "/actuator/info").permitAll() + .requestMatchers("/actuator/**").authenticated() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix("ROLE_"); + authoritiesConverter.setAuthoritiesClaimName("roles"); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + return converter; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:3000")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/song-service/src/main/java/com/audio/song/config/TraceIdInterceptor.java b/song-service/src/main/java/com/audio/song/config/TraceIdInterceptor.java new file mode 100644 index 0000000..8b2ce31 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/config/TraceIdInterceptor.java @@ -0,0 +1,32 @@ +package com.audio.song.config; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.slf4j.MDC; +import org.springframework.stereotype.Component; +import org.springframework.web.servlet.HandlerInterceptor; + +import java.util.UUID; + +@Component +public class TraceIdInterceptor implements HandlerInterceptor { + + private static final String TRACE_ID_HEADER = "X-Trace-Id"; + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + String traceId = request.getHeader(TRACE_ID_HEADER); + if (traceId == null || traceId.isEmpty()) { + traceId = UUID.randomUUID().toString(); + } + MDC.put(TRACE_ID_HEADER, traceId); + MDC.put("traceId", traceId); + response.setHeader(TRACE_ID_HEADER, traceId); + return true; + } + + @Override + public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) { + MDC.clear(); + } +} diff --git a/song-service/src/main/java/com/audio/song/config/WebConfig.java b/song-service/src/main/java/com/audio/song/config/WebConfig.java new file mode 100644 index 0000000..04dd750 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/config/WebConfig.java @@ -0,0 +1,20 @@ +package com.audio.song.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.InterceptorRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig implements WebMvcConfigurer { + + private final TraceIdInterceptor traceIdInterceptor; + + public WebConfig(TraceIdInterceptor traceIdInterceptor) { + this.traceIdInterceptor = traceIdInterceptor; + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(traceIdInterceptor); + } +} diff --git a/song-service/src/main/java/com/audio/song/controller/SongController.java b/song-service/src/main/java/com/audio/song/controller/SongController.java new file mode 100644 index 0000000..a89ede8 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/controller/SongController.java @@ -0,0 +1,56 @@ +package com.audio.song.controller; + +import com.audio.song.dto.SongCreateResponse; +import com.audio.song.dto.SongDeleteResponse; +import com.audio.song.dto.SongRequest; +import com.audio.song.service.SongService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RestController; +import jakarta.validation.Valid; +import lombok.extern.slf4j.Slf4j; + +import java.util.List; + +@RestController +@RequestMapping("/songs") +@Slf4j +public class SongController { + + private final SongService songService; + + public SongController(SongService songService) { + this.songService = songService; + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity createSong(@Valid @RequestBody SongRequest dto) { + Long id = songService.createSong(dto); + log.info("POST /songs - Created song with ID: {}", id); + return ResponseEntity.ok(new SongCreateResponse(id)); + } + + @GetMapping("/{id}") + @PreAuthorize("isAuthenticated()") + public ResponseEntity getSong(@PathVariable Long id) { + SongRequest dto = songService.getSong(id); + return ResponseEntity.ok(dto); + } + + @DeleteMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity deleteSongs(@RequestParam String id) { + log.info("DELETE /songs - Deleting songs with IDs: {}", id); + List deletedIds = songService.deleteSongs(id); + log.info("DELETE /songs - Deleted {} songs", deletedIds.size()); + return ResponseEntity.ok(new SongDeleteResponse(deletedIds)); + } +} diff --git a/song-service/src/main/java/com/audio/song/dto/SongCreateResponse.java b/song-service/src/main/java/com/audio/song/dto/SongCreateResponse.java new file mode 100644 index 0000000..0fb1ce5 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/dto/SongCreateResponse.java @@ -0,0 +1,16 @@ +package com.audio.song.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SongCreateResponse { + @JsonProperty("id") + private Long id; +} diff --git a/song-service/src/main/java/com/audio/song/dto/SongDeleteResponse.java b/song-service/src/main/java/com/audio/song/dto/SongDeleteResponse.java new file mode 100644 index 0000000..ef6476a --- /dev/null +++ b/song-service/src/main/java/com/audio/song/dto/SongDeleteResponse.java @@ -0,0 +1,14 @@ +package com.audio.song.dto; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@AllArgsConstructor +public class SongDeleteResponse { + private List ids; +} diff --git a/song-service/src/main/java/com/audio/song/dto/SongRequest.java b/song-service/src/main/java/com/audio/song/dto/SongRequest.java new file mode 100644 index 0000000..1e88677 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/dto/SongRequest.java @@ -0,0 +1,39 @@ +package com.audio.song.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SongRequest { + @NotNull(message = "ID is required") + private Long id; + + @NotBlank(message = "Song name is required") + @Size(min = 1, max = 100, message = "Name must be between 1 and 100 characters") + private String name; + + @NotBlank(message = "Artist name is required") + @Size(min = 1, max = 100, message = "Artist must be between 1 and 100 characters") + private String artist; + + @NotBlank(message = "Album name is required") + @Size(min = 1, max = 100, message = "Album must be between 1 and 100 characters") + private String album; + + @NotBlank(message = "Duration is required") + @Pattern(regexp = "^\\d{2}:(0[0-9]|[1-5][0-9])$", message = "Duration must be in mm:ss format with leading zeros") + private String duration; + + @NotBlank(message = "Year is required") + @Pattern(regexp = "^(19|20)\\d{2}$", message = "Year must be between 1900 and 2099") + private String year; +} diff --git a/song-service/src/main/java/com/audio/song/dto/SongResponse.java b/song-service/src/main/java/com/audio/song/dto/SongResponse.java new file mode 100644 index 0000000..904f175 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/dto/SongResponse.java @@ -0,0 +1,19 @@ +package com.audio.song.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class SongResponse { + private Long id; + private String name; + private String artist; + private String album; + private String duration; + private String year; +} diff --git a/song-service/src/main/java/com/audio/song/entity/SongEntity.java b/song-service/src/main/java/com/audio/song/entity/SongEntity.java new file mode 100644 index 0000000..ba96d71 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/entity/SongEntity.java @@ -0,0 +1,38 @@ +package com.audio.song.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "songs") +public class SongEntity { + + @Id + @Column(name = "id") + private Long id; + + @Column(name = "name", nullable = false, length = 100) + private String name; + + @Column(name = "artist", nullable = false, length = 100) + private String artist; + + @Column(name = "album", nullable = false, length = 100) + private String album; + + @Column(name = "duration", nullable = false, length = 5) + private String duration; + + @Column(name = "year", nullable = false, length = 4) + private String year; +} diff --git a/song-service/src/main/java/com/audio/song/exception/DuplicateSongException.java b/song-service/src/main/java/com/audio/song/exception/DuplicateSongException.java new file mode 100644 index 0000000..ad37582 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/exception/DuplicateSongException.java @@ -0,0 +1,8 @@ +package com.audio.song.exception; + +public class DuplicateSongException extends RuntimeException { + + public DuplicateSongException(String message) { + super(message); + } +} diff --git a/song-service/src/main/java/com/audio/song/exception/GlobalExceptionHandler.java b/song-service/src/main/java/com/audio/song/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..0897c52 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/exception/GlobalExceptionHandler.java @@ -0,0 +1,89 @@ +package com.audio.song.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; + +import java.util.HashMap; +import java.util.Map; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(SongNotFoundException.class) + public ResponseEntity> handleSongNotFound(SongNotFoundException ex) { + Map error = new HashMap<>(); + error.put("errorMessage", ex.getMessage()); + error.put("errorCode", "404"); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error); + } + + @ExceptionHandler(DuplicateSongException.class) + public ResponseEntity> handleDuplicateSong(DuplicateSongException ex) { + Map error = new HashMap<>(); + error.put("errorMessage", ex.getMessage()); + error.put("errorCode", "409"); + return ResponseEntity.status(HttpStatus.CONFLICT).body(error); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + Map error = new HashMap<>(); + error.put("errorMessage", "Validation error"); + + Map details = new HashMap<>(); + String message = ex.getMessage(); + if (message != null && message.contains(",")) { + String[] parts = message.split(", "); + for (String part : parts) { + String[] keyVal = part.split(": ", 2); + if (keyVal.length == 2) { + details.put(keyVal[0], keyVal[1]); + } + } + } else { + details.put("error", message); + } + error.put("details", details); + error.put("errorCode", "400"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + Map details = new HashMap<>(); + for (FieldError error : ex.getBindingResult().getFieldErrors()) { + details.put(error.getField(), error.getDefaultMessage()); + } + Map body = new HashMap<>(); + body.put("errorMessage", "Validation error"); + body.put("details", details); + body.put("errorCode", "400"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(body); + } + + @ExceptionHandler(MethodArgumentTypeMismatchException.class) + public ResponseEntity> handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + log.error(ex.getMessage(), ex); + Map error = new HashMap<>(); + error.put("errorMessage", "Invalid ID format. ID must be a positive integer."); + error.put("errorCode", "400"); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(error); + } + + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.error(ex.getMessage(), ex); + Map error = new HashMap<>(); + error.put("errorMessage", "Access denied"); + error.put("errorCode", "403"); + return ResponseEntity.status(HttpStatus.FORBIDDEN).body(error); + } +} diff --git a/song-service/src/main/java/com/audio/song/exception/SongNotFoundException.java b/song-service/src/main/java/com/audio/song/exception/SongNotFoundException.java new file mode 100644 index 0000000..e01d341 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/exception/SongNotFoundException.java @@ -0,0 +1,8 @@ +package com.audio.song.exception; + +public class SongNotFoundException extends RuntimeException { + + public SongNotFoundException(String message) { + super(message); + } +} diff --git a/song-service/src/main/java/com/audio/song/repository/SongRepository.java b/song-service/src/main/java/com/audio/song/repository/SongRepository.java new file mode 100644 index 0000000..b5927cc --- /dev/null +++ b/song-service/src/main/java/com/audio/song/repository/SongRepository.java @@ -0,0 +1,9 @@ +package com.audio.song.repository; + +import com.audio.song.entity.SongEntity; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface SongRepository extends JpaRepository { +} diff --git a/song-service/src/main/java/com/audio/song/service/SongMapper.java b/song-service/src/main/java/com/audio/song/service/SongMapper.java new file mode 100644 index 0000000..f286023 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/service/SongMapper.java @@ -0,0 +1,32 @@ +package com.audio.song.service; + +import com.audio.song.dto.SongRequest; +import com.audio.song.dto.SongResponse; +import com.audio.song.entity.SongEntity; +import org.springframework.stereotype.Component; + +@Component +public class SongMapper { + + public SongEntity toEntity(SongRequest request) { + SongEntity entity = new SongEntity(); + entity.setId(request.getId()); + entity.setName(request.getName()); + entity.setArtist(request.getArtist()); + entity.setAlbum(request.getAlbum()); + entity.setDuration(request.getDuration()); + entity.setYear(request.getYear()); + return entity; + } + + public SongResponse toResponse(SongEntity entity) { + SongResponse response = new SongResponse(); + response.setId(entity.getId()); + response.setName(entity.getName()); + response.setArtist(entity.getArtist()); + response.setAlbum(entity.getAlbum()); + response.setDuration(entity.getDuration()); + response.setYear(entity.getYear()); + return response; + } +} diff --git a/song-service/src/main/java/com/audio/song/service/SongService.java b/song-service/src/main/java/com/audio/song/service/SongService.java new file mode 100644 index 0000000..04330d8 --- /dev/null +++ b/song-service/src/main/java/com/audio/song/service/SongService.java @@ -0,0 +1,103 @@ +package com.audio.song.service; + +import com.audio.song.dto.SongRequest; +import com.audio.song.entity.SongEntity; +import com.audio.song.exception.DuplicateSongException; +import com.audio.song.exception.SongNotFoundException; +import com.audio.song.repository.SongRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import lombok.extern.slf4j.Slf4j; + +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Service +public class SongService { + + private final SongRepository songRepository; + + public SongService(SongRepository songRepository) { + this.songRepository = songRepository; + } + + @Transactional + public Long createSong(SongRequest dto) { + log.info("Creating song metadata: id={}, name={}, artist={}, album={}, duration={}, year={}", + dto.getId(), dto.getName(), dto.getArtist(), + dto.getAlbum(), dto.getDuration(), dto.getYear()); + + if (songRepository.existsById(dto.getId())) { + throw new DuplicateSongException("Metadata for this ID already exists"); + } + + SongEntity song = new SongEntity( + dto.getId(), + dto.getName(), + dto.getArtist(), + dto.getAlbum(), + dto.getDuration(), + dto.getYear() + ); + + SongEntity saved = songRepository.save(song); + log.info("Successfully created song metadata with ID: {}", saved.getId()); + return saved.getId(); + } + + public SongRequest getSong(Long id) { + log.info("Fetching song metadata for ID: {}", id); + + if (id == null || id <= 0) { + throw new IllegalArgumentException("Invalid song ID"); + } + SongEntity song = songRepository.findById(id) + .orElseThrow(() -> new SongNotFoundException("Song metadata for ID=" + id + " not found")); + + log.info("Found song metadata: id={}, name={}, artist={}", id, song.getName(), song.getArtist()); + return new SongRequest( + song.getId(), + song.getName(), + song.getArtist(), + song.getAlbum(), + song.getDuration(), + song.getYear() + ); + } + + @Transactional + public List deleteSongs(String idsCsv) { + log.info("Deleting songs with IDs: {}", idsCsv); + + if (idsCsv == null || idsCsv.isEmpty()) { + throw new IllegalArgumentException("ID list cannot be empty"); + } + + if (idsCsv.length() > 200) { + throw new IllegalArgumentException("CSV string length must not exceed 200 characters"); + } + + List deletedIds = new ArrayList<>(); + String[] idParts = idsCsv.split(","); + + for (String idStr : idParts) { + try { + Long id = Long.parseLong(idStr.trim()); + if (id <= 0) { + continue; + } + if (songRepository.existsById(id)) { + songRepository.deleteById(id); + deletedIds.add(id); + log.debug("Deleted song with ID: {}", id); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid ID format: " + idStr); + } + } + + log.info("Successfully deleted {} songs", deletedIds.size()); + return deletedIds; + } +} diff --git a/song-service/src/main/resources/application.yaml b/song-service/src/main/resources/application.yaml new file mode 100644 index 0000000..75c03c9 --- /dev/null +++ b/song-service/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + application: + name: song-service + config: + import: configserver:${CONFIG_SERVER_URL:http://localhost:8888}/config + +eureka: + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} diff --git a/song-service/src/main/resources/db/data.sql b/song-service/src/main/resources/db/data.sql new file mode 100644 index 0000000..3979455 --- /dev/null +++ b/song-service/src/main/resources/db/data.sql @@ -0,0 +1,5 @@ +INSERT INTO songs (id, name, artist, album, duration, "year") +VALUES (100001, 'Midnight City', 'M83', 'Hurry Up, We''re Dreaming', '04:03', '2011'); + +INSERT INTO songs (id, name, artist, album, duration, "year") +VALUES (100002, 'Harder, Better, Faster, Stronger', 'Daft Punk', 'Discovery', '03:44', '2001'); diff --git a/song-service/src/main/resources/db/schema.sql b/song-service/src/main/resources/db/schema.sql new file mode 100644 index 0000000..9414a7d --- /dev/null +++ b/song-service/src/main/resources/db/schema.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS songs +( + id BIGINT PRIMARY KEY, + name VARCHAR(100) NOT NULL, + artist VARCHAR(100) NOT NULL, + album VARCHAR(100) NOT NULL, + duration VARCHAR(5) NOT NULL, + "year" VARCHAR(4) NOT NULL +); diff --git a/song-service/src/main/resources/logback-spring.xml b/song-service/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3bfd965 --- /dev/null +++ b/song-service/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + + UTC + + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/song-service/src/test/java/com/audio/song/SongApplicationTests.java b/song-service/src/test/java/com/audio/song/SongApplicationTests.java new file mode 100644 index 0000000..d222c04 --- /dev/null +++ b/song-service/src/test/java/com/audio/song/SongApplicationTests.java @@ -0,0 +1,10 @@ +package com.audio.song; + +import org.junit.jupiter.api.Test; + +class SongApplicationTests { + + @Test + void contextLoads() { + } +} diff --git a/song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java b/song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java new file mode 100644 index 0000000..0595b4d --- /dev/null +++ b/song-service/src/test/java/com/audio/song/SongServiceSecurityTest.java @@ -0,0 +1,102 @@ +package com.audio.song; + +import com.audio.song.dto.SongRequest; +import com.audio.song.service.SongService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; + +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.http.MediaType; +import org.springframework.security.oauth2.jwt.JwtDecoder; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.when; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.MOCK, properties = { + "spring.cloud.config.enabled=false", + "eureka.client.enabled=false" +}) +@AutoConfigureMockMvc +class SongServiceSecurityTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private JwtDecoder jwtDecoder; + + @MockitoBean + private SongService songService; + + @Test + void missingTokenShouldReturn401() throws Exception { + mockMvc.perform(get("/songs/1")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(post("/songs") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":1,\"name\":\"test\",\"artist\":\"test\",\"album\":\"test\",\"duration\":\"03:30\",\"year\":\"2024\"}")) + .andExpect(status().isUnauthorized()); + + mockMvc.perform(delete("/songs").param("id", "1")) + .andExpect(status().isUnauthorized()); + } + + @Test + void userRoleCanGetSong() throws Exception { + when(songService.getSong(1L)).thenReturn(new SongRequest()); + + mockMvc.perform(get("/songs/1") + .with(jwt().authorities(() -> "ROLE_USER"))) + .andExpect(status().isOk()); + } + + @Test + void userRoleCannotCreateSong() throws Exception { + mockMvc.perform(post("/songs") + .with(jwt().authorities(() -> "ROLE_USER")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":1,\"name\":\"test\",\"artist\":\"test\",\"album\":\"test\",\"duration\":\"03:30\",\"year\":\"2024\"}")) + .andExpect(status().isForbidden()); + } + + @Test + void userRoleCannotDeleteSong() throws Exception { + mockMvc.perform(delete("/songs").param("id", "1") + .with(jwt().authorities(() -> "ROLE_USER"))) + .andExpect(status().isForbidden()); + } + + @Test + void adminRoleCanGetSong() throws Exception { + when(songService.getSong(1L)).thenReturn(new SongRequest()); + + mockMvc.perform(get("/songs/1") + .with(jwt().authorities(() -> "ROLE_ADMIN"))) + .andExpect(status().isOk()); + } + + @Test + void adminRoleCanCreateSong() throws Exception { + when(songService.createSong(any())).thenReturn(1L); + + mockMvc.perform(post("/songs") + .with(jwt().authorities(() -> "ROLE_ADMIN")) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"id\":1,\"name\":\"test\",\"artist\":\"test\",\"album\":\"test\",\"duration\":\"03:30\",\"year\":\"2024\"}")) + .andExpect(status().isOk()); + } + + @Test + void adminRoleCanDeleteSong() throws Exception { + mockMvc.perform(delete("/songs").param("id", "1") + .with(jwt().authorities(() -> "ROLE_ADMIN"))) + .andExpect(status().isOk()); + } +} \ No newline at end of file diff --git a/song-service/src/test/java/com/audio/song/service/SongServiceTest.java b/song-service/src/test/java/com/audio/song/service/SongServiceTest.java new file mode 100644 index 0000000..2990666 --- /dev/null +++ b/song-service/src/test/java/com/audio/song/service/SongServiceTest.java @@ -0,0 +1,151 @@ +package com.audio.song.service; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.anyLong; +import static org.mockito.Mockito.argThat; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.audio.song.dto.SongRequest; +import com.audio.song.entity.SongEntity; +import com.audio.song.exception.DuplicateSongException; +import com.audio.song.repository.SongRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Optional; +import java.util.stream.Stream; + +@ExtendWith(MockitoExtension.class) +class SongServiceTest { + + @Mock + private SongRepository songRepository; + + @InjectMocks + private SongService songService; + + @ParameterizedTest + @MethodSource("provideValidSongRequests") + void createSongWhenValidInputThenReturnsId(SongRequest dto) { + when(songRepository.existsById(dto.getId())).thenReturn(false); + when(songRepository.save(any(SongEntity.class))).thenAnswer(i -> i.getArgument(0)); + + Long id = songService.createSong(dto); + + assertEquals(dto.getId(), id); + verify(songRepository).save(argThat(s -> + s.getId().equals(dto.getId()) && + s.getName().equals(dto.getName()) && + s.getArtist().equals(dto.getArtist()) && + s.getAlbum().equals(dto.getAlbum()) && + s.getDuration().equals(dto.getDuration()) && + s.getYear().equals(dto.getYear()) + )); + } + + private static Stream provideValidSongRequests() { + return Stream.of( + Arguments.of(SongRequest.builder() + .id(1L) + .name("We are the champions") + .artist("Queen") + .album("News of the world") + .duration("02:59") + .year("1977") + .build()), + Arguments.of(SongRequest.builder() + .id(2L) + .name("Another Song") + .artist("Another Artist") + .album("Another Album") + .duration("03:45") + .year("1980") + .build()) + ); + } + + @Test + void createSongWhenDuplicateIdThenThrowsException() { + SongRequest dto = SongRequest.builder() + .id(1L) + .name("Song") + .artist("Artist") + .album("Album") + .duration("03:00") + .year("2020") + .build(); + + when(songRepository.existsById(1L)).thenReturn(true); + + DuplicateSongException ex = assertThrows(DuplicateSongException.class, () -> songService.createSong(dto)); + assertEquals("Metadata for this ID already exists", ex.getMessage()); + } + + @Test + void getSongWhenValidIdThenReturnsDto() { + SongEntity song = new SongEntity(1L, "Song", "Artist", "Album", "03:00", "2020"); + when(songRepository.findById(1L)).thenReturn(Optional.of(song)); + + SongRequest dto = songService.getSong(1L); + + assertEquals(1, dto.getId()); + assertEquals("Song", dto.getName()); + assertEquals("Artist", dto.getArtist()); + assertEquals("Album", dto.getAlbum()); + assertEquals("03:00", dto.getDuration()); + assertEquals("2020", dto.getYear()); + } + + @Test + void getSongWhenInvalidIdThenThrowsException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> songService.getSong(0L)); + assertEquals("Invalid song ID", ex.getMessage()); + } + + @Test + void deleteSongsWhenValidCsvThenReturnsIds() { + when(songRepository.existsById(1L)).thenReturn(true); + when(songRepository.existsById(2L)).thenReturn(true); + doNothing().when(songRepository).deleteById(anyLong()); + + List deleted = songService.deleteSongs("1,2"); + + assertEquals(2, deleted.size()); + verify(songRepository).deleteById(1L); + verify(songRepository).deleteById(2L); + } + + @Test + void deleteSongsWhenEmptyCsvThenThrowsException() { + IllegalArgumentException ex = assertThrows(IllegalArgumentException.class, () -> songService.deleteSongs("")); + assertEquals("ID list cannot be empty", ex.getMessage()); + } + + @Test + void deleteSongsWhenInvalidFormatThenThrowsException() { + IllegalArgumentException ex = + assertThrows(IllegalArgumentException.class, () -> songService.deleteSongs("xyz")); + assertTrue(ex.getMessage().contains("Invalid ID format")); + } + + @Test + void deleteSongsWhenNonExistentIdsThenIgnored() { + when(songRepository.existsById(anyLong())).thenReturn(false); + List deleted = songService.deleteSongs("999,1000"); + assertTrue(deleted.isEmpty()); + verify(songRepository, never()).deleteById(anyLong()); + } +} diff --git a/song-service/src/test/resources/application.yaml b/song-service/src/test/resources/application.yaml new file mode 100644 index 0000000..f3542a9 --- /dev/null +++ b/song-service/src/test/resources/application.yaml @@ -0,0 +1,16 @@ +spring: + application: + name: song-service + cloud: + config: + enabled: false + datasource: + url: jdbc:h2:mem:test-db;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + driver-class-name: org.h2.Driver + username: sa + password: + jpa: + hibernate: + ddl-auto: create-drop + database-platform: org.hibernate.dialect.H2Dialect + show-sql: false diff --git a/storage-service/.dockerignore b/storage-service/.dockerignore new file mode 100644 index 0000000..cdffefa --- /dev/null +++ b/storage-service/.dockerignore @@ -0,0 +1,5 @@ +build/ +.gradle/ +.git/ +*.iml +.idea/ diff --git a/storage-service/Dockerfile b/storage-service/Dockerfile new file mode 100644 index 0000000..4a2a4b3 --- /dev/null +++ b/storage-service/Dockerfile @@ -0,0 +1,21 @@ +# Stage 1: Build +FROM gradle:8.8-jdk21-alpine AS builder +WORKDIR /app + +# Copy Gradle wrapper and build configs for dependency caching +COPY gradlew build.gradle settings.gradle ./ +COPY gradle/ gradle/ +COPY storage-service/build.gradle storage-service/ +RUN ./gradlew :storage-service:dependencies --no-daemon + +# Copy source code and build +COPY storage-service/src storage-service/src +RUN ./gradlew :storage-service:assemble --no-daemon -x test + +# Stage 2: Runtime +FROM eclipse-temurin:21-jre-alpine +RUN apk add --no-cache curl +WORKDIR /app +COPY --from=builder /app/storage-service/build/libs/*.jar app.jar +EXPOSE 8085 +CMD ["java", "-jar", "app.jar"] diff --git a/storage-service/build.gradle b/storage-service/build.gradle new file mode 100644 index 0000000..63d4ab7 --- /dev/null +++ b/storage-service/build.gradle @@ -0,0 +1,57 @@ +plugins { + id 'java' + id 'org.springframework.boot' version '4.0.6' + id 'io.spring.dependency-management' version '1.1.7' +} + +group = 'com.audio' +version = '0.0.1-SNAPSHOT' +description = 'storage' + +java { + toolchain { + languageVersion = JavaLanguageVersion.of(21) + } +} + +repositories { + mavenCentral() +} + +ext { + set('springCloudVersion', '2025.1.1') +} + +dependencyManagement { + imports { + mavenBom "org.springframework.cloud:spring-cloud-dependencies:${springCloudVersion}" + } +} + +dependencies { + implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.springframework.boot:spring-boot-starter-validation' + implementation 'org.springframework.boot:spring-boot-starter-security' + implementation 'org.springframework.boot:spring-boot-starter-oauth2-resource-server' + implementation 'org.springframework.security:spring-security-oauth2-jose' + implementation 'org.springframework.cloud:spring-cloud-starter-config' + implementation 'org.springframework.cloud:spring-cloud-starter-netflix-eureka-client' + implementation 'org.springframework.boot:spring-boot-starter-actuator' + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + runtimeOnly 'org.postgresql:postgresql' + runtimeOnly 'com.h2database:h2' + implementation("software.amazon.awssdk:s3:2.42.36") + implementation 'net.logstash.logback:logstash-logback-encoder:7.4' + runtimeOnly 'io.micrometer:micrometer-registry-prometheus' + testImplementation(platform("org.junit:junit-bom:5.10.0")) + testImplementation("org.junit.jupiter:junit-jupiter") + testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.springframework.boot:spring-boot-starter-webmvc-test' +} + +tasks.test { + useJUnitPlatform() +} diff --git a/storage-service/src/main/java/com/audio/storage/StorageApplication.java b/storage-service/src/main/java/com/audio/storage/StorageApplication.java new file mode 100644 index 0000000..614ea78 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/StorageApplication.java @@ -0,0 +1,13 @@ +package com.audio.storage; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.cloud.client.discovery.EnableDiscoveryClient; + +@SpringBootApplication +@EnableDiscoveryClient +public class StorageApplication { + public static void main(String[] args) { + SpringApplication.run(StorageApplication.class, args); + } +} diff --git a/storage-service/src/main/java/com/audio/storage/config/DataInitializer.java b/storage-service/src/main/java/com/audio/storage/config/DataInitializer.java new file mode 100644 index 0000000..8e56fcb --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/config/DataInitializer.java @@ -0,0 +1,92 @@ +package com.audio.storage.config; + +import com.audio.storage.entity.Storage; +import com.audio.storage.entity.StorageType; +import com.audio.storage.repository.StorageRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import jakarta.annotation.PostConstruct; +import lombok.extern.slf4j.Slf4j; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.BucketAlreadyExistsException; +import software.amazon.awssdk.services.s3.model.BucketAlreadyOwnedByYouException; +import software.amazon.awssdk.services.s3.model.CreateBucketRequest; +import software.amazon.awssdk.services.s3.model.HeadBucketRequest; +import software.amazon.awssdk.services.s3.model.S3Exception; + +@Slf4j +@Component +public class DataInitializer implements CommandLineRunner { + + private final StorageRepository storageRepository; + private final S3Client s3Client; + + @Value("${storage.staging.bucket:staging-bucket}") + private String stagingBucket; + + @Value("${storage.staging.path:/staging}") + private String stagingPath; + + @Value("${storage.permanent.bucket:permanent-bucket}") + private String permanentBucket; + + @Value("${storage.permanent.path:/permanent}") + private String permanentPath; + + public DataInitializer(StorageRepository storageRepository, S3Client s3Client) { + this.storageRepository = storageRepository; + this.s3Client = s3Client; + } + + @PostConstruct + void probeS3Connectivity() { + try { + s3Client.listBuckets(); + log.info("S3 health probe OK"); + } catch (Exception e) { + log.warn("S3 health probe FAILED at startup: {}. Bucket creation may fail.", e.getMessage()); + } + } + + @Override + public void run(String... args) { + createBucketIfNotExists(stagingBucket); + createBucketIfNotExists(permanentBucket); + + createStorageIfNotExists(StorageType.STAGING, stagingBucket, stagingPath); + createStorageIfNotExists(StorageType.PERMANENT, permanentBucket, permanentPath); + } + + private void createStorageIfNotExists(StorageType type, String bucket, String path) { + if (!storageRepository.existsByStorageTypeAndBucketAndPath(type, bucket, path)) { + Storage storage = new Storage(type, bucket, path); + storageRepository.save(storage); + log.info("Pre-created storage: type={}, bucket={}, path={}", type, bucket, path); + } else { + log.info("Storage already exists: type={}, bucket={}, path={}", type, bucket, path); + } + } + + private void createBucketIfNotExists(String bucketName) { + try { + s3Client.headBucket(HeadBucketRequest.builder().bucket(bucketName).build()); + log.info("Bucket already exists: {}", bucketName); + } catch (S3Exception e) { + if (e.statusCode() == 404 || e.statusCode() == 403) { + try { + s3Client.createBucket(CreateBucketRequest.builder().bucket(bucketName).build()); + log.info("Created bucket: {}", bucketName); + } catch (BucketAlreadyExistsException | BucketAlreadyOwnedByYouException ex) { + log.info("Bucket already exists (concurrent creation): {}", bucketName); + } catch (S3Exception createEx) { + log.error("Failed to create bucket {}: {}", bucketName, createEx.awsErrorDetails().errorMessage()); + throw createEx; + } + } else { + log.error("Failed to access bucket {}: {}", bucketName, e.awsErrorDetails().errorMessage()); + throw e; + } + } + } +} diff --git a/storage-service/src/main/java/com/audio/storage/config/S3Config.java b/storage-service/src/main/java/com/audio/storage/config/S3Config.java new file mode 100644 index 0000000..6365c36 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/config/S3Config.java @@ -0,0 +1,44 @@ +package com.audio.storage.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cloud.context.config.annotation.RefreshScope; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.S3Configuration; + +import java.net.URI; + +@Configuration +@RefreshScope +public class S3Config { + + @Value("${spring.cloud.aws.s3.endpoint:http://localhost:4566}") + private String endpoint; + + @Value("${spring.cloud.aws.s3.access-key:test}") + private String accessKey; + + @Value("${spring.cloud.aws.s3.secret-key:test}") + private String secretKey; + + @Bean + @Primary + public S3Client s3Client() { + return S3Client.builder() + .endpointOverride(URI.create(endpoint)) + .region(Region.US_EAST_1) + .credentialsProvider(StaticCredentialsProvider.create( + AwsBasicCredentials.create(accessKey, secretKey) + )) + .serviceConfiguration(S3Configuration.builder() + .pathStyleAccessEnabled(true) + .chunkedEncodingEnabled(false) + .build()) + .build(); + } +} diff --git a/storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java b/storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java new file mode 100644 index 0000000..b7f0d49 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/config/SecurityConfig.java @@ -0,0 +1,64 @@ +package com.audio.storage.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationConverter; +import org.springframework.security.oauth2.server.resource.authentication.JwtGrantedAuthoritiesConverter; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.Arrays; +import java.util.List; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) { + http + .authorizeHttpRequests(auth -> auth + .requestMatchers("/actuator/health", "/actuator/health/**", "/actuator/info").permitAll() + .requestMatchers("/actuator/**").authenticated() + .anyRequest().authenticated() + ) + .oauth2ResourceServer(oauth2 -> oauth2 + .jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter())) + ) + .cors(Customizer.withDefaults()) + .csrf(AbstractHttpConfigurer::disable); + + return http.build(); + } + + @Bean + public JwtAuthenticationConverter jwtAuthenticationConverter() { + JwtGrantedAuthoritiesConverter authoritiesConverter = new JwtGrantedAuthoritiesConverter(); + authoritiesConverter.setAuthorityPrefix("ROLE_"); + authoritiesConverter.setAuthoritiesClaimName("roles"); + + JwtAuthenticationConverter converter = new JwtAuthenticationConverter(); + converter.setJwtGrantedAuthoritiesConverter(authoritiesConverter); + return converter; + } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(List.of("http://localhost:3000")); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} \ No newline at end of file diff --git a/storage-service/src/main/java/com/audio/storage/controller/StorageController.java b/storage-service/src/main/java/com/audio/storage/controller/StorageController.java new file mode 100644 index 0000000..a17645c --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/controller/StorageController.java @@ -0,0 +1,48 @@ +package com.audio.storage.controller; + +import com.audio.storage.dto.StorageCreateRequest; +import com.audio.storage.dto.StorageCreateResponse; +import com.audio.storage.dto.StorageResponse; +import com.audio.storage.service.StorageService; +import jakarta.validation.Valid; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +@RestController +@RequestMapping("/storages") +public class StorageController { + + private final StorageService storageService; + + public StorageController(StorageService storageService) { + this.storageService = storageService; + } + + @PostMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity create(@Valid @RequestBody StorageCreateRequest request) { + return ResponseEntity.status(HttpStatus.CREATED).body(storageService.create(request)); + } + + @GetMapping + @PreAuthorize("isAuthenticated()") + public ResponseEntity> getAll() { + return ResponseEntity.ok(storageService.getAll()); + } + + @DeleteMapping + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity> delete(@RequestParam String id) { + return ResponseEntity.ok(storageService.deleteByIds(id)); + } +} diff --git a/storage-service/src/main/java/com/audio/storage/dto/StorageCreateRequest.java b/storage-service/src/main/java/com/audio/storage/dto/StorageCreateRequest.java new file mode 100644 index 0000000..1a3abc1 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/dto/StorageCreateRequest.java @@ -0,0 +1,25 @@ +package com.audio.storage.dto; + +import com.audio.storage.entity.StorageType; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StorageCreateRequest { + + @NotNull(message = "storageType must not be null") + private StorageType storageType; + + @NotBlank(message = "bucket must not be blank") + private String bucket; + + @NotBlank(message = "path must not be blank") + private String path; +} diff --git a/storage-service/src/main/java/com/audio/storage/dto/StorageCreateResponse.java b/storage-service/src/main/java/com/audio/storage/dto/StorageCreateResponse.java new file mode 100644 index 0000000..3f7f385 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/dto/StorageCreateResponse.java @@ -0,0 +1,14 @@ +package com.audio.storage.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StorageCreateResponse { + private Long id; +} diff --git a/storage-service/src/main/java/com/audio/storage/dto/StorageResponse.java b/storage-service/src/main/java/com/audio/storage/dto/StorageResponse.java new file mode 100644 index 0000000..16fac7c --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/dto/StorageResponse.java @@ -0,0 +1,18 @@ +package com.audio.storage.dto; + +import com.audio.storage.entity.StorageType; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +public class StorageResponse { + private Long id; + private StorageType storageType; + private String bucket; + private String path; +} diff --git a/storage-service/src/main/java/com/audio/storage/entity/Storage.java b/storage-service/src/main/java/com/audio/storage/entity/Storage.java new file mode 100644 index 0000000..d938ee3 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/entity/Storage.java @@ -0,0 +1,47 @@ +package com.audio.storage.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Setter +@Getter +@Entity +@NoArgsConstructor +@AllArgsConstructor +@Table(name = "storages", uniqueConstraints = { + @UniqueConstraint(columnNames = {"storage_type", "bucket", "path"}) +}) +public class Storage { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "id") + private Long id; + + @Enumerated(EnumType.STRING) + @Column(name = "storage_type", nullable = false) + private StorageType storageType; + + @Column(name = "bucket", nullable = false) + private String bucket; + + @Column(name = "path", nullable = false) + private String path; + + public Storage(StorageType storageType, String bucket, String path) { + this.storageType = storageType; + this.bucket = bucket; + this.path = path; + } +} diff --git a/storage-service/src/main/java/com/audio/storage/entity/StorageType.java b/storage-service/src/main/java/com/audio/storage/entity/StorageType.java new file mode 100644 index 0000000..6c838e2 --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/entity/StorageType.java @@ -0,0 +1,6 @@ +package com.audio.storage.entity; + +public enum StorageType { + STAGING, + PERMANENT +} diff --git a/storage-service/src/main/java/com/audio/storage/exception/GlobalExceptionHandler.java b/storage-service/src/main/java/com/audio/storage/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..8d0819d --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/exception/GlobalExceptionHandler.java @@ -0,0 +1,52 @@ +package com.audio.storage.exception; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.authorization.AuthorizationDeniedException; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.stream.Collectors; + +@Slf4j +@RestControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity> handleIllegalArgument(IllegalArgumentException ex) { + return buildResponse(HttpStatus.BAD_REQUEST, ex.getMessage()); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity> handleValidation(MethodArgumentNotValidException ex) { + String message = ex.getBindingResult().getFieldErrors().stream() + .map(error -> error.getField() + ": " + error.getDefaultMessage()) + .collect(Collectors.joining(", ")); + return buildResponse(HttpStatus.BAD_REQUEST, message); + } + + @ExceptionHandler(AuthorizationDeniedException.class) + public ResponseEntity> handleAccessDenied(AuthorizationDeniedException ex) { + log.error(ex.getMessage(), ex); + return buildResponse(HttpStatus.FORBIDDEN, "Access denied"); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity> handleGeneral(Exception ex) { + return buildResponse(HttpStatus.INTERNAL_SERVER_ERROR, "An unexpected error occurred: " + ex.getMessage()); + } + + private ResponseEntity> buildResponse(HttpStatus status, String message) { + Map body = new LinkedHashMap<>(); + body.put("timestamp", LocalDateTime.now().toString()); + body.put("status", status.value()); + body.put("error", status.getReasonPhrase()); + body.put("message", message); + return ResponseEntity.status(status).body(body); + } +} diff --git a/storage-service/src/main/java/com/audio/storage/repository/StorageRepository.java b/storage-service/src/main/java/com/audio/storage/repository/StorageRepository.java new file mode 100644 index 0000000..576737e --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/repository/StorageRepository.java @@ -0,0 +1,16 @@ +package com.audio.storage.repository; + +import com.audio.storage.entity.Storage; +import com.audio.storage.entity.StorageType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface StorageRepository extends JpaRepository { + + Optional findByStorageType(StorageType storageType); + + boolean existsByStorageTypeAndBucketAndPath(StorageType storageType, String bucket, String path); +} diff --git a/storage-service/src/main/java/com/audio/storage/service/StorageService.java b/storage-service/src/main/java/com/audio/storage/service/StorageService.java new file mode 100644 index 0000000..3b7bf6d --- /dev/null +++ b/storage-service/src/main/java/com/audio/storage/service/StorageService.java @@ -0,0 +1,90 @@ +package com.audio.storage.service; + +import com.audio.storage.dto.StorageCreateRequest; +import com.audio.storage.dto.StorageCreateResponse; +import com.audio.storage.dto.StorageResponse; +import com.audio.storage.entity.Storage; +import com.audio.storage.repository.StorageRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; + +@Slf4j +@Service +public class StorageService { + + private final StorageRepository storageRepository; + + @Autowired + public StorageService(StorageRepository storageRepository) { + this.storageRepository = storageRepository; + } + + @Transactional + public StorageCreateResponse create(StorageCreateRequest request) { + if (storageRepository.existsByStorageTypeAndBucketAndPath( + request.getStorageType(), request.getBucket(), request.getPath())) { + throw new IllegalArgumentException("Storage with type=" + request.getStorageType() + + ", bucket=" + request.getBucket() + ", path=" + request.getPath() + " already exists"); + } + + Storage storage = new Storage(request.getStorageType(), request.getBucket(), request.getPath()); + Storage saved = storageRepository.save(storage); + log.info("Created storage: id={}, type={}, bucket={}, path={}", + saved.getId(), saved.getStorageType(), saved.getBucket(), saved.getPath()); + return new StorageCreateResponse(saved.getId()); + } + + @Transactional(readOnly = true) + public List getAll() { + return storageRepository.findAll().stream() + .map(this::toResponse) + .collect(Collectors.toList()); + } + + @Transactional + public List deleteByIds(String csvIds) { + if (csvIds == null || csvIds.isBlank()) { + throw new IllegalArgumentException("CSV string must not be null or blank"); + } + if (csvIds.length() > 200) { + throw new IllegalArgumentException("CSV string is too long: received " + + csvIds.length() + " characters, maximum allowed is 200"); + } + + String[] parts = csvIds.split(","); + List deletedIds = new ArrayList<>(); + + for (String part : parts) { + String trimmed = part.trim(); + try { + long id = Long.parseLong(trimmed); + if (id <= 0) { + throw new NumberFormatException(); + } + Optional storageOpt = storageRepository.findById(id); + if (storageOpt.isPresent()) { + storageRepository.deleteById(id); + deletedIds.add(id); + log.info("Deleted storage: id={}", id); + } + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid ID format: '" + trimmed + + "'. Only positive integers are allowed"); + } + } + + return deletedIds; + } + + private StorageResponse toResponse(Storage storage) { + return new StorageResponse(storage.getId(), storage.getStorageType(), + storage.getBucket(), storage.getPath()); + } +} diff --git a/storage-service/src/main/resources/application.yaml b/storage-service/src/main/resources/application.yaml new file mode 100644 index 0000000..758e147 --- /dev/null +++ b/storage-service/src/main/resources/application.yaml @@ -0,0 +1,12 @@ +spring: + application: + name: storage-service + config: + import: configserver:${CONFIG_SERVER_URL:http://localhost:8888}/config + +eureka: + client: + register-with-eureka: true + fetch-registry: true + serviceUrl: + defaultZone: ${EUREKA_URL:http://localhost:8761/eureka/} diff --git a/storage-service/src/main/resources/logback-spring.xml b/storage-service/src/main/resources/logback-spring.xml new file mode 100644 index 0000000..3bfd965 --- /dev/null +++ b/storage-service/src/main/resources/logback-spring.xml @@ -0,0 +1,41 @@ + + + + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level [%X{traceId:-},%X{spanId:-}] %logger{36} - %msg%n + + + + + + + + UTC + + + + { + "severity": "%level", + "service": "${appName:-unknown}", + "traceId": "%X{traceId:-}", + "spanId": "%X{spanId:-}", + "thread": "%thread", + "class": "%logger{40}", + "message": "%message", + "exception": "%ex" + } + + + + + + + + + + + diff --git a/tools/api-tests/api-response-specification.md b/tools/api-tests/api-response-specification.md new file mode 100644 index 0000000..e67b7db --- /dev/null +++ b/tools/api-tests/api-response-specification.md @@ -0,0 +1,647 @@ +# API Response Specification for Resource and Song Services: Expected Results for Test Validation + + +* [**Happy path**](#happy-path) + * [**Create test song metadata (200)**](#create-test-song-metadata-200) + * [**Upload valid MP3 resource (200)**](#upload-valid-mp3-resource-200) + * [**Get existing resource (200)**](#get-existing-resource-200) + * [**Get existing song metadata (200)**](#get-existing-song-metadata-200) + * [**Delete resources with metadata (200)**](#delete-resources-with-metadata-200) + * [**Get deleted resource (404)**](#get-deleted-resource-404) + * [**Get deleted song metadata (404)**](#get-deleted-song-metadata-404) +* [**Error cases: Resource Service**](#error-cases-resource-service) + * [**Upload invalid resource (400)**](#upload-invalid-resource-400) + * [**Get non-existent resource (404)**](#get-non-existent-resource-404) + * [**Get invalid ID - letters (400)**](#get-invalid-id---letters-400) + * [**Get invalid ID - decimal (400)**](#get-invalid-id---decimal-400) + * [**Get invalid ID - negative (400)**](#get-invalid-id---negative-400) + * [**Get invalid ID - zero (400)**](#get-invalid-id---zero-400) + * [**Delete non-existent resource (200)**](#delete-non-existent-resource-200) + * [**Delete invalid CSV - letters (400)**](#delete-invalid-csv---letters-400) + * [**Delete invalid CSV - length exceeded (400)**](#delete-invalid-csv---length-exceeded-400) +* [**Error cases: Song Service**](#error-cases-song-service) + * [**Create song metadata - invalid fields - duration 02:77, year 01977 (400)**](#create-song-metadata---invalid-fields---duration-0277-year-01977-400) + * [**Create song metadata - invalid fields - duration 0299 (400)**](#create-song-metadata---invalid-fields---duration-0299-400) + * [**Create song metadata - invalid fields - duration 35 (400)**](#create-song-metadata---invalid-fields---duration-35-400) + * [**Create song metadata - invalid fields - year 1 (400)**](#create-song-metadata---invalid-fields---year-1-400) + * [**Create song metadata - invalid fields - all empty (400)**](#create-song-metadata---invalid-fields---all-empty-400) + * [**Create song metadata - missing fields - name (400)**](#create-song-metadata---missing-fields---name-400) + * [**Create song metadata - missing fields - all except id (400)**](#create-song-metadata---missing-fields---all-except-id-400) + * [**Create song metadata - already exists (409)**](#create-song-metadata---already-exists-409) + * [**Get non-existent song metadata (404)**](#get-non-existent-song-metadata-404) + * [**Get song metadata - invalid ID - letters (400)**](#get-song-metadata---invalid-id---letters-400) + * [**Get song metadata - invalid ID - decimal (400)**](#get-song-metadata---invalid-id---decimal-400) + * [**Get song metadata - invalid ID - negative (400)**](#get-song-metadata---invalid-id---negative-400) + * [**Get song metadata - invalid ID - zero (400)**](#get-song-metadata---invalid-id---zero-400) + * [**Delete non-existent song metadata (200)**](#delete-non-existent-song-metadata-200) + * [**Delete invalid song metadata CSV - letters (400)**](#delete-invalid-song-metadata-csv---letters-400) + * [**Delete invalid song metadata CSV - length exceeded (400)**](#delete-invalid-song-metadata-csv---length-exceeded-400) + + +--- + +## **Happy path** + +### **Create test song metadata (200)** +- **Method & endpoint:** `POST /songs` +- **Expected status code:** `200 OK` +- **Expected request body (with dynamic generated ID):** + ```json + { + "id": {{test_id}}, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "02:59", + "year": "1977" + } + ``` +- **Expected response body:** + ```json + { + "id": {{test_id}} + } + ``` +- **Response validation:** + - The request must include dynamically generated `test_id` (6-digit positive integer) + - Response must contain the same `id` +- **Purpose**: Ensures metadata primary key differs from resource ID in later steps so that incorrect delete-by-id logic can be detected + +--- + +### **Upload valid MP3 resource (200)** +- **Method & endpoint:** `POST /resources` +- **Expected status code:** `200 OK` +- **Expected response body:** + ```json + { + "id": 1 + } + ``` + +--- + +### **Get existing resource (200)** +- **Method & endpoint:** `GET /resources/{id}` +- **Expected status code:** `200 OK` +- **Expected response headers:** + - `Content-Type: audio/mpeg` + - `Content-Length` must be present and greater than zero +- **Expected response body:** + - Binary MP3 data + +--- + +### **Get existing song metadata (200)** +- **Method & endpoint:** `GET /songs/{id}` +- **Expected status code:** `200 OK` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "id": 1, + "name": "Test Title", + "artist": "Test Artist", + "album": "Test Album", + "duration": "00:07", + "year": "2025" + } + ``` + +--- + +### **Delete resources with metadata (200)** +- **Method & endpoint:** `DELETE /resources?id=1,101,102` +- **Expected status code:** `200 OK` +- **Expected response body:** + ```json + { + "ids": [1] + } + ``` +- **Response validation:** + - `ids` must be an array + - Each element in `ids` must be a number + - `ids` array must not contain elements that don't exist + +--- + +### **Get deleted resource (404)** +- **Method & endpoint:** `GET /resources/{id}` +- **Expected status code:** `404 Not Found` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Resource with ID=1 not found", + "errorCode": "404" + } + ``` + +--- + +### **Get deleted song metadata (404)** +- **Method & endpoint:** `GET /songs/{id}` +- **Expected status code:** `404 Not Found` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Song metadata for ID=1 not found", + "errorCode": "404" + } + ``` + +--- + +## **Error cases: Resource Service** + +### **Upload invalid resource (400)** +- **Method & endpoint:** `POST /resources` +- **Request headers:** + - `Content-Type: application/json` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid file format: application/json. Only MP3 files are allowed", + "errorCode": "400" + } + ``` + +--- + +### **Get non-existent resource (404)** +- **Method & endpoint:** `GET /resources/99999` +- **Expected status code:** `404 Not Found` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Resource with ID=99999 not found", + "errorCode": "404" + } + ``` + +--- + +### **Get invalid ID - letters (400)** +- **Method & endpoint:** `GET /resources/ABC` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value 'ABC' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Get invalid ID - decimal (400)** +- **Method & endpoint:** `GET /resources/1.1` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value '1.1' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Get invalid ID - negative (400)** +- **Method & endpoint:** `GET /resources/-1` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value '-1' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Get invalid ID - zero (400)** +- **Method & endpoint:** `GET /resources/0` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value '0' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Delete non-existent resource (200)** +- **Method & endpoint:** `DELETE /resources?id=99999` +- **Expected status code:** `200 OK` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "ids": [] + } + ``` + +--- + +### **Delete invalid CSV - letters (400)** +- **Method & endpoint:** `DELETE /resources?id=1,2,3,4,V` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid ID format: 'V'. Only positive integers are allowed", + "errorCode": "400" + } + ``` + +--- + +### **Delete invalid CSV - length exceeded (400)** +- **Method & endpoint:** `DELETE /resources?id=2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "CSV string is too long: received 208 characters, maximum allowed is 200", + "errorCode": "400" + } + ``` + +--- + +## **Error cases: Song Service** + +### **Create song metadata - invalid fields - duration 02:77, year 01977 (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 102, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "02:77", + "year": "01977" + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "duration": "Duration must be in mm:ss format with leading zeros", + "year": "Year must be between 1900 and 2099" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - invalid fields - duration 0299 (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 102, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "0299", + "year": "1977" + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "duration": "Duration must be in mm:ss format with leading zeros" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - invalid fields - duration 35 (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 102, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "35", + "year": "1977" + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "duration": "Duration must be in mm:ss format with leading zeros" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - invalid fields - year 1 (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 102, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "02:59", + "year": "1" + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "year": "Year must be between 1900 and 2099" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - invalid fields - all empty (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 102, + "name": "", + "artist": "", + "album": "", + "duration": "", + "year": "" + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "duration": "Duration must be in mm:ss format with leading zeros", + "year": "Year must be between 1900 and 2099", + "artist": "Artist name must be between 1 and 100 characters", + "album": "Album name must be between 1 and 100 characters", + "name": "Song name must be between 1 and 100 characters" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - missing fields - name (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 103, + "artist": "Queen", + "album": "News of the world", + "duration": "02:59", + "year": "1977" + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "name": "Song name is required" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - missing fields - all except id (400)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 102 + } + ``` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Validation error", + "details": { + "duration": "Duration is required", + "artist": "Artist name is required", + "year": "Year is required", + "album": "Album name is required", + "name": "Song name is required" + }, + "errorCode": "400" + } + ``` + +--- + +### **Create song metadata - already exists (409)** +- **Method & endpoint:** `POST /songs` +- **Request body:** + ```json + { + "id": 2, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "02:59", + "year": "1977" + } + ``` +- **Expected status code:** `409 Conflict` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Metadata for resource ID=2 already exists", + "errorCode": "409" + } + ``` + +--- + +### **Get non-existent song metadata (404)** +- **Method & endpoint:** `GET /songs/99999` +- **Expected status code:** `404 Not Found` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Song metadata for ID=99999 not found", + "errorCode": "404" + } + ``` + +--- + +### **Get song metadata - invalid ID - letters (400)** +- **Method & endpoint:** `GET /songs/ABC` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value 'ABC' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Get song metadata - invalid ID - decimal (400)** +- **Method & endpoint:** `GET /songs/1.1` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value '1.1' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Get song metadata - invalid ID - negative (400)** +- **Method & endpoint:** `GET /songs/-1` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value '-1' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Get song metadata - invalid ID - zero (400)** +- **Method & endpoint:** `GET /songs/0` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid value '0' for ID. Must be a positive integer", + "errorCode": "400" + } + ``` + +--- + +### **Delete non-existent song metadata (200)** +- **Method & endpoint:** `DELETE /songs?id=99999` +- **Expected status code:** `200 OK` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "ids": [] + } + ``` + +--- + +### **Delete invalid song metadata CSV - letters (400)** +- **Method & endpoint:** `DELETE /songs?id=1,2,3,4,V` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "Invalid ID format: 'V'. Only positive integers are allowed", + "errorCode": "400" + } + ``` + +--- + +### **Delete invalid song metadata CSV - length exceeded (400)** +- **Method & endpoint:** `DELETE /songs?id=2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629` +- **Expected status code:** `400 Bad Request` +- **Expected response headers:** + - `Content-Type: application/json` +- **Expected response body:** + ```json + { + "errorMessage": "CSV string is too long: received 208 characters, maximum allowed is 200", + "errorCode": "400" + } + ``` diff --git a/tools/api-tests/introduction_to_microservices.postman_collection.json b/tools/api-tests/introduction_to_microservices.postman_collection.json new file mode 100644 index 0000000..f7682cb --- /dev/null +++ b/tools/api-tests/introduction_to_microservices.postman_collection.json @@ -0,0 +1,3459 @@ +{ + "info": { + "name": "Introduction to Microservices: API Tests", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "Generated on 2026-01-05" + }, + "item": [ + { + "name": "Happy Path", + "item": [ + { + "name": "Create Test Song Metadata (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request body equals expected JSON object with dynamic test_id\", function () {\r", + " var requestBody = JSON.parse(pm.request.body.raw);\r", + "\r", + " var expectedBody = {\r", + " \"id\": parseInt(pm.variables.get(\"test_id\")),\r", + " \"name\": \"We are the champions\",\r", + " \"artist\": \"Queen\",\r", + " \"album\": \"News of the world\",\r", + " \"duration\": \"02:59\",\r", + " \"year\": \"1977\"\r", + " };\r", + "\r", + " pm.expect(requestBody, \"Request body does not match expected JSON object with dynamic test_id\")\r", + " .to.deep.equal(expectedBody);\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains generated test_id\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain test_id\")\r", + " .to.include(pm.variables.get(\"test_id\"));\r", + "});\r", + "\r", + "// Validate response structure matches specification\r", + "pm.test(\"Response has 'id' field\", function () {\r", + " var responseBody = pm.response.json();\r", + " pm.expect(responseBody, \"Response should have 'id' property\").to.have.property(\"id\");\r", + " pm.expect(responseBody.id, \"Response id should be a number\").to.be.a(\"number\");\r", + "});\r", + "\r", + "// Validate response has only 'id' field as per specification\r", + "pm.test(\"Response contains only 'id' field as per specification\", function () {\r", + " var responseBody = pm.response.json();\r", + " var keys = Object.keys(responseBody);\r", + " pm.expect(keys, \"Response should contain only 'id' field\").to.deep.equal([\"id\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "let testId = Math.floor(100000 + Math.random() * 900000).toString();\r", + "pm.variables.set(\"test_id\", testId);" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": {{test_id}},\n \"name\": \"We are the champions\",\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"02:59\",\n \"year\": \"1977\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Upload Valid Mp3 Resource (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "var jsonData = pm.response.json();\r", + "if (jsonData.id) {\r", + " pm.collectionVariables.set(\"id\", jsonData.id);\r", + "}\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'id' field\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'id' field\").to.have.property(\"id\");\r", + "});\r", + "\r", + "pm.test(\"Response contains only 'id' field\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(Object.keys(jsonData), \"Expected response to contain only 'id' field\").to.deep.equal([\"id\"]);\r", + "});\r", + "\r", + "// Validate response structure matches specification\r", + "pm.test(\"Response 'id' is a positive integer as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.id, \"Response id should be a number\").to.be.a(\"number\");\r", + " pm.expect(jsonData.id, \"Response id should be a positive integer\").to.be.above(0);\r", + " pm.expect(Number.isInteger(jsonData.id), \"Response id should be an integer\").to.be.true;\r", + "});\r", + "\r", + "// Validate response format exactly matches specification: { \"id\": 1 }\r", + "pm.test(\"Response format matches specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(Object.keys(jsonData).length, \"Response should have exactly 1 field\").to.equal(1);\r", + " pm.expect(jsonData, \"Response should be in format { id: number }\").to.have.all.keys(\"id\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "audio/mpeg" + } + ], + "body": { + "mode": "file", + "file": { + "src": "" + } + }, + "url": { + "raw": "{{resource_service_url}}/resources", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ] + } + }, + "response": [] + }, + { + "name": "Get Existing Resource (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var expectedBaseUrl = pm.variables.get(\"resource_service_url\") + \"/resources/\";\r", + " pm.expect(requestUrl.startsWith(expectedBaseUrl), \"Expected URL to start with the base resource service URL\").to.be.true;\r", + "});\r", + "\r", + "pm.test(\"Path variable 'id' is dynamic\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = pm.variables.get(\"resource_service_url\") + \"/resources/\" + dynamicId;\r", + " pm.expect(requestUrl, \"Expected request URL to include dynamic 'id'\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Content-Type is 'audio/mpeg'\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'audio/mpeg'\").to.equal(\"audio/mpeg\");\r", + "});\r", + "\r", + "pm.test(\"Content-Length header is present\", function () {\r", + " var contentLength = pm.response.headers.get(\"Content-Length\");\r", + " pm.expect(contentLength, \"Expected Content-Length header to be present\").to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Content-Length is greater than zero\", function () {\r", + " var contentLength = pm.response.headers.get(\"Content-Length\");\r", + " pm.expect(parseInt(contentLength), \"Expected Content-Length to be greater than zero\").to.be.above(0);\r", + "});\r", + "\r", + "// Validate response body is binary MP3 data as per specification\r", + "pm.test(\"Response body contains binary data\", function () {\r", + " pm.expect(pm.response, \"Response should have a body\").to.have.property(\"stream\");\r", + " pm.expect(pm.response.stream.length, \"Response body should not be empty\").to.be.above(0);\r", + "});\r", + "\r", + "// Validate headers match specification exactly\r", + "pm.test(\"Response headers match specification\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Content-Type must be audio/mpeg\").to.equal(\"audio/mpeg\");\r", + " var contentLength = parseInt(pm.response.headers.get(\"Content-Length\"));\r", + " pm.expect(contentLength, \"Content-Length must be positive\").to.be.a(\"number\").and.to.be.above(0);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/{{id}}", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "Get Existing Song Metadata (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var expectedBaseUrl = pm.variables.get(\"song_service_url\") + \"/songs/\";\r", + " pm.expect(requestUrl.startsWith(expectedBaseUrl), \"Expected URL to start with the base song service URL\").to.be.true;\r", + "});\r", + "\r", + "pm.test(\"Path variable 'id' is dynamic\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = pm.variables.get(\"song_service_url\") + \"/songs/\" + dynamicId;\r", + " pm.expect(requestUrl, \"Expected request URL to include dynamic 'id'\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'id' field and it is not null\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'id' field\").to.have.property(\"id\").and.to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Response contains 'name' field and it is not null\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'name' field\").to.have.property(\"name\").and.to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Response contains 'artist' field and it is not null\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'artist' field\").to.have.property(\"artist\").and.to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Response contains 'album' field and it is not null\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'album' field\").to.have.property(\"album\").and.to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Response contains 'duration' field and it is not null\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'duration' field\").to.have.property(\"duration\").and.to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Duration is in mm:ss format with leading zeros\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'duration' field\").to.have.property(\"duration\").and.to.not.be.null;\r", + " \r", + " var durationPattern = /^\\d{2}:\\d{2}$/; // mm:ss format\r", + " pm.expect(jsonData.duration, `Expected duration '${jsonData.duration}' to match mm:ss format`).to.match(durationPattern);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'year' field and it is not null\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'year' field\").to.have.property(\"year\").and.to.not.be.null;\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "// Validate response structure matches specification exactly\r", + "pm.test(\"Response contains exactly 6 fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedFields = [\"id\", \"name\", \"artist\", \"album\", \"duration\", \"year\"];\r", + " var actualFields = Object.keys(jsonData).sort();\r", + " pm.expect(actualFields, \"Response should contain exactly: id, name, artist, album, duration, year\").to.deep.equal(expectedFields.sort());\r", + "});\r", + "\r", + "// Validate field types match specification\r", + "pm.test(\"All fields have correct types as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.id, \"id should be a number\").to.be.a(\"number\");\r", + " pm.expect(jsonData.name, \"name should be a string\").to.be.a(\"string\");\r", + " pm.expect(jsonData.artist, \"artist should be a string\").to.be.a(\"string\");\r", + " pm.expect(jsonData.album, \"album should be a string\").to.be.a(\"string\");\r", + " pm.expect(jsonData.duration, \"duration should be a string\").to.be.a(\"string\");\r", + " pm.expect(jsonData.year, \"year should be a string\").to.be.a(\"string\");\r", + "});\r", + "\r", + "// Validate year format matches specification pattern\r", + "pm.test(\"Year is in 4-digit format as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " var yearPattern = /^\\d{4}$/;\r", + " pm.expect(jsonData.year, `Expected year '${jsonData.year}' to be 4 digits`).to.match(yearPattern);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/{{id}}", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "Delete Resources With Metadata (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = resourceServiceUrl + \"/resources?id=\" + dynamicId + \",101,102\";\r", + " pm.expect(requestUrl, \"Expected request URL to match the expected structure\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes dynamic 'id'\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " pm.expect(requestUrl, `Expected request URL to include dynamic 'id' value: ${dynamicId}`).to.include(\"id=\" + dynamicId);\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes 101\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams, \"Expected request parameters to include 101\").to.have.property(\"id\").that.includes(\"101\");\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes 102\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams, \"Expected request parameters to include 102\").to.have.property(\"id\").that.includes(\"102\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'ids' field\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'ids' field\").to.have.property(\"ids\");\r", + "});\r", + "\r", + "pm.test(\"'ids' field is an array\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.ids, \"Expected 'ids' to be an array\").to.be.an(\"array\");\r", + "});\r", + "\r", + "pm.test(\"Each element in 'ids' array is a number\", function () {\r", + " var jsonData = pm.response.json();\r", + " jsonData.ids.forEach(function (id) {\r", + " pm.expect(id, \"Expected each element in 'ids' to be a number\").to.be.a(\"number\");\r", + " });\r", + "});\r", + "\r", + "pm.test(\"'ids' array does not contain 101\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.ids, \"Expected 'ids' to not contain 101\").to.not.include(101);\r", + "});\r", + "\r", + "pm.test(\"'ids' array does not contain 102\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.ids, \"Expected 'ids' to not contain 102\").to.not.include(102);\r", + "});\r", + "\r", + "// Validate response structure matches specification exactly\r", + "pm.test(\"Response structure matches specification format { ids: [...] }\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData);\r", + " pm.expect(keys, \"Response should contain only 'ids' field\").to.deep.equal([\"ids\"]);\r", + "});\r", + "\r", + "// Validate ids array contains only existing resources (dynamic id)\r", + "pm.test(\"Response 'ids' array contains only the dynamic id that exists\", function () {\r", + " var jsonData = pm.response.json();\r", + " var dynamicId = parseInt(pm.variables.get(\"id\"));\r", + " pm.expect(jsonData.ids, \"ids array should contain the dynamic id\").to.include(dynamicId);\r", + " pm.expect(jsonData.ids.length, \"ids array should contain only 1 element (dynamic id)\").to.equal(1);\r", + "});\r", + "\r", + "// Validate specification requirement - no non-existent IDs in response\r", + "pm.test(\"Response does not contain non-existent IDs as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.ids, \"ids must not contain elements that don't exist (101, 102)\").to.not.include.members([101, 102]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources?id={{id}},101,102", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ], + "query": [ + { + "key": "id", + "value": "{{id}},101,102" + } + ] + } + }, + "response": [] + }, + { + "name": "Get Deleted Resource (404)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = resourceServiceUrl + \"/resources/\" + dynamicId;\r", + " pm.expect(requestUrl, \"Expected request URL to match the expected structure\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable 'id' is dynamic\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = pm.variables.get(\"resource_service_url\") + \"/resources/\" + dynamicId;\r", + " pm.expect(requestUrl, \"Expected request URL to include dynamic 'id'\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 404\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 404\").to.equal(404);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '404'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '404'\").to.include(\"404\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '404'\").to.equal(\"404\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message matches specification format: 'Resource with ID=X not found'\", function () {\r", + " var jsonData = pm.response.json();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedMessage = `Resource with ID=${dynamicId} not found`;\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification format\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"404 errors should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/{{id}}", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "{{id}}" + ] + } + }, + "response": [] + }, + { + "name": "Get Deleted Song Metadata (404)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = songServiceUrl + \"/songs/\" + dynamicId;\r", + " pm.expect(requestUrl, \"Expected request URL to match the expected structure\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable 'id' is dynamic\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedUrl = pm.variables.get(\"song_service_url\") + \"/songs/\" + dynamicId;\r", + " pm.expect(requestUrl, \"Expected request URL to include dynamic 'id'\").to.equal(expectedUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 404\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 404\").to.equal(404);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '404'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '404'\").to.include(\"404\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '404'\").to.equal(\"404\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message matches specification format: 'Song metadata for ID=X not found'\", function () {\r", + " var jsonData = pm.response.json();\r", + " var dynamicId = pm.variables.get(\"id\");\r", + " var expectedMessage = `Song metadata for ID=${dynamicId} not found`;\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification format\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"404 errors should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/{{id}}", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "{{id}}" + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Error Cases", + "item": [ + { + "name": "Resource Service", + "item": [ + { + "name": "Upload Invalid Resource (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification\r", + "pm.test(\"Error message matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.errorMessage, \"Error message should mention invalid file format\").to.include(\"Invalid file format:\");\r", + " pm.expect(jsonData.errorMessage, \"Error message should mention application/json\").to.include(\"application/json\");\r", + " pm.expect(jsonData.errorMessage, \"Error message should mention MP3 files\").to.include(\"Only MP3 files are allowed\");\r", + "});\r", + "\r", + "// Validate exact error message format per specification\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid file format: application/json. Only MP3 files are allowed\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid format should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "invalid data" + }, + "url": { + "raw": "{{resource_service_url}}/resources", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ] + } + }, + "response": [] + }, + { + "name": "Get Non-existent Resource (404)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"99999\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '99999'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '99999'\").to.equal(\"99999\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 404\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 404\").to.equal(404);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '404'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '404'\").to.include(\"404\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '404'\").to.equal(\"404\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Resource with ID=99999 not found\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"404 errors should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/99999", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "99999" + ] + } + }, + "response": [] + }, + { + "name": "Get Invalid ID - Letters (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"ABC\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be 'ABC'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be 'ABC'\").to.equal(\"ABC\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value 'ABC' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/ABC", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "ABC" + ] + } + }, + "response": [] + }, + { + "name": "Get Invalid ID - Decimal (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"1.1\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '1.1'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '1.1'\").to.equal(\"1.1\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value '1.1' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/1.1", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "1.1" + ] + } + }, + "response": [] + }, + { + "name": "Get Invalid ID - Negative (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"-1\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '-1'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '-1'\").to.equal(\"-1\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value '-1' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/-1", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "-1" + ] + } + }, + "response": [] + }, + { + "name": "Get Invalid ID - Zero (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"0\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '0'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '0'\").to.equal(\"0\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value '0' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources/0", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources", + "0" + ] + } + }, + "response": [] + }, + { + "name": "Delete Non-existent Resource (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"?id=99999\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes '99999'\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams, \"Expected request parameters to include '99999'\").to.have.property(\"id\").that.includes(\"99999\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains expected JSON object: empty array\", function () {\r", + " var responseBody = pm.response.json();\r", + " var expectedResponse = {\r", + " \"ids\": []\r", + " };\r", + " pm.expect(responseBody, \"Expected response to match the JSON object\").to.deep.equal(expectedResponse);\r", + "});\r", + "\r", + "// Validate response structure matches specification\r", + "pm.test(\"Response has 'ids' field which is an empty array as per specification\", function () {\r", + " var responseBody = pm.response.json();\r", + " pm.expect(responseBody, \"Response should have 'ids' field\").to.have.property(\"ids\");\r", + " pm.expect(responseBody.ids, \"ids should be an array\").to.be.an(\"array\");\r", + " pm.expect(responseBody.ids, \"ids array should be empty for non-existent resources\").to.be.empty;\r", + "});\r", + "\r", + "// Validate response contains only 'ids' field\r", + "pm.test(\"Response contains only 'ids' field as per specification\", function () {\r", + " var responseBody = pm.response.json();\r", + " var keys = Object.keys(responseBody);\r", + " pm.expect(keys, \"Response should contain only 'ids' field\").to.deep.equal([\"ids\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources?id=99999", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ], + "query": [ + { + "key": "id", + "value": "99999" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Invalid CSV - Letters (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var id = \"?id=1,2,3,4,V\";\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes 'V'\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams, \"Expected request parameters to include 'V'\").to.have.property(\"id\").that.includes(\"V\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid ID format: 'V'. Only positive integers are allowed\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid CSV should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources?id=1,2,3,4,V", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ], + "query": [ + { + "key": "id", + "value": "1,2,3,4,V" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Invalid CSV - Length (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var expectedBaseUrl = pm.variables.get(\"resource_service_url\") + \"/resources?id=2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629\";\r", + " pm.expect(requestUrl.startsWith(expectedBaseUrl), \"Expected URL to start with the base resource service URL\").to.be.true;\r", + "});\r", + "\r", + "pm.test(\"Length of 'id' request parameter is 208 characters\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams.id.length, \"Expected 'id' request parameter length to be greater than 200\").to.be.above(200);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "pm.test(\"Response contains correct characters count\", function () {\r", + " let responseBody = pm.response.json();\r", + " pm.expect(responseBody.errorMessage, \"Expected error message to contain '208'\")\r", + " .to.include(\"208\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"CSV string is too long: received 208 characters, maximum allowed is 200\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for CSV length should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{resource_service_url}}/resources?id=2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ], + "query": [ + { + "key": "id", + "value": "2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629" + } + ] + } + }, + "response": [] + } + ] + }, + { + "name": "Song Service", + "item": [ + { + "name": "Create Song Metadata - Invalid Fields - Duration 02:77, Year 01977 (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request body equals expected JSON object\", function () {\r", + " var requestBody = JSON.parse(pm.request.body.raw);\r", + " var expectedBody = {\r", + " \"id\": 102,\r", + " \"name\": \"We are the champions\",\r", + " \"artist\": \"Queen\",\r", + " \"album\": \"News of the world\",\r", + " \"duration\": \"02:77\",\r", + " \"year\": \"01977\"\r", + " };\r", + " pm.expect(requestBody, \"Request body does not match expected JSON object\").to.deep.equal(expectedBody);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'duration' field\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'duration' field\").to.match(/\\bduration\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'year' field\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'year' field\").to.match(/\\byear\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'id'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'id' field\").to.not.match(/\\bid\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'name'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'name' field\").to.not.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'artist' field\").to.not.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'album' field\").to.not.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'details'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'details'\").to.include(\"details\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required validation error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData, \"Response should have 'details' field\").to.have.property(\"details\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate errorMessage matches specification\r", + "pm.test(\"Error message exactly matches specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.errorMessage, \"Error message should be 'Validation error'\").to.equal(\"Validation error\");\r", + "});\r", + "\r", + "// Validate details structure and messages match specification\r", + "pm.test(\"Details contains 'duration' validation error matching specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.details, \"Details should have 'duration' field\").to.have.property(\"duration\");\r", + " pm.expect(jsonData.details.duration, \"Duration error message should match specification\")\r", + " .to.equal(\"Duration must be in mm:ss format with leading zeros\");\r", + "});\r", + "\r", + "// Validate details contains 'year' validation error matching specification\r", + "pm.test(\"Details contains 'year' validation error matching specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.details, \"Details should have 'year' field\").to.have.property(\"year\");\r", + " pm.expect(jsonData.details.year, \"Year error message should match specification\")\r", + " .to.equal(\"Year must be between 1900 and 2099\");\r", + "});\r", + "\r", + "// Validate details contains only invalid fields\r", + "pm.test(\"Details contains only invalid fields (duration, year) as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " var detailKeys = Object.keys(jsonData.details).sort();\r", + " pm.expect(detailKeys, \"Details should contain only 'duration' and 'year'\").to.deep.equal([\"duration\", \"year\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 102,\n \"name\": \"We are the champions\",\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"02:77\",\n \"year\": \"01977\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Invalid Fields - Duration 0299 (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'duration' field\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'duration' field\").to.match(/\\bduration\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'id'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'id' field\").to.not.match(/\\bid\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'name'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'name' field\").to.not.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'artist' field\").to.not.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'album' field\").to.not.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required validation error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData, \"Response should have 'details' field\").to.have.property(\"details\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate errorMessage matches specification\r", + "pm.test(\"Error message exactly matches specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.errorMessage, \"Error message should be 'Validation error'\").to.equal(\"Validation error\");\r", + "});\r", + "\r", + "// Validate details contains only 'duration' error\r", + "pm.test(\"Details contains only 'duration' validation error matching specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.details, \"Details should have 'duration' field\").to.have.property(\"duration\");\r", + " pm.expect(jsonData.details.duration, \"Duration error message should match specification\")\r", + " .to.equal(\"Duration must be in mm:ss format with leading zeros\");\r", + " var detailKeys = Object.keys(jsonData.details);\r", + " pm.expect(detailKeys, \"Details should contain only 'duration'\").to.deep.equal([\"duration\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 102,\n \"name\": \"We are the champions\",\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"0299\",\n \"year\": \"1977\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Invalid Fields - Duration 35 (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'duration' field\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'duration' field\").to.match(/\\bduration\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'id'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'id' field\").to.not.match(/\\bid\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'name'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'name' field\").to.not.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'artist' field\").to.not.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'album' field\").to.not.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required validation error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData, \"Response should have 'details' field\").to.have.property(\"details\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate errorMessage matches specification\r", + "pm.test(\"Error message exactly matches specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.errorMessage, \"Error message should be 'Validation error'\").to.equal(\"Validation error\");\r", + "});\r", + "\r", + "// Validate details contains only 'duration' error\r", + "pm.test(\"Details contains only 'duration' validation error matching specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData.details, \"Details should have 'duration' field\").to.have.property(\"duration\");\r", + " pm.expect(jsonData.details.duration, \"Duration error message should match specification\")\r", + " .to.equal(\"Duration must be in mm:ss format with leading zeros\");\r", + " var detailKeys = Object.keys(jsonData.details);\r", + " pm.expect(detailKeys, \"Details should contain only 'duration'\").to.deep.equal([\"duration\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 102,\n \"name\": \"We are the champions\",\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"35\",\n \"year\": \"1977\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Invalid Fields - Year 1 (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'year' field\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'year' field\").to.match(/\\byear\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'id'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'id' field\").to.not.match(/\\bid\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'name'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'name' field\").to.not.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'artist' field\").to.not.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'album' field\").to.not.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "// Validate error response structure\r", + "pm.test(\"Response has required validation error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData).to.have.property(\"errorCode\");\r", + " pm.expect(jsonData).to.have.property(\"details\");\r", + " pm.expect(jsonData.errorCode).to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate errorMessage\r", + "pm.test(\"Error message exactly matches specification\", function () {\r", + " pm.expect(pm.response.json().errorMessage).to.equal(\"Validation error\");\r", + "});\r", + "\r", + "// Validate details\r", + "pm.test(\"Details contains only 'year' validation error matching specification\", function () {\r", + " var details = pm.response.json().details;\r", + " pm.expect(details).to.have.property(\"year\");\r", + " pm.expect(details.year).to.equal(\"Year must be between 1900 and 2099\");\r", + " pm.expect(Object.keys(details)).to.deep.equal([\"year\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 102,\n \"name\": \"We are the champions\",\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"02:59\",\n \"year\": \"1\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Invalid Fields - All Empty (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'name'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'name' field\").to.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'artist' field\").to.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'year'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'year' field\").to.match(/\\byear\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'album' field\").to.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'duration'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'duration' field\").to.match(/\\bduration\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "// Validate all 5 fields have errors\r", + "pm.test(\"Details contains all 5 required field errors as per specification\", function () {\r", + " var details = pm.response.json().details;\r", + " var expectedErrors = {\r", + " \"duration\": \"Duration must be in mm:ss format with leading zeros\",\r", + " \"year\": \"Year must be between 1900 and 2099\",\r", + " \"artist\": \"Artist name must be between 1 and 100 characters\",\r", + " \"album\": \"Album name must be between 1 and 100 characters\",\r", + " \"name\": \"Song name must be between 1 and 100 characters\"\r", + " };\r", + " pm.expect(Object.keys(details).length).to.equal(5);\r", + " for (var field in expectedErrors) {\r", + " pm.expect(details[field]).to.equal(expectedErrors[field]);\r", + " }\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 102,\n \"name\": \"\",\n \"artist\": \"\",\n \"album\": \"\",\n \"duration\": \"\",\n \"year\": \"\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Missing Fields - Name (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request body equals expected JSON object\", function () {\r", + " var requestBody = JSON.parse(pm.request.body.raw);\r", + " var expectedBody = {\r", + " \"id\": 103,\r", + " \"artist\": \"Queen\",\r", + " \"album\": \"News of the world\",\r", + " \"duration\": \"02:59\",\r", + " \"year\": \"1977\"\r", + " };\r", + " pm.expect(requestBody, \"Request body does not match expected JSON object\").to.deep.equal(expectedBody);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'name' field\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'name' field\").to.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'id'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'id' field\").to.not.match(/\\bid\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'artist' field\").to.not.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'album' field\").to.not.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'duration'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'duration' field\").to.not.match(/\\bduration\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'year'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'year'\").to.not.match(/\\byear\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'details'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'details'\").to.include(\"details\");\r", + "});\r", + "\r", + "// Validate details\r", + "pm.test(\"Details contains only 'name' validation error matching specification\", function () {\r", + " var details = pm.response.json().details;\r", + " pm.expect(details).to.have.property(\"name\");\r", + " pm.expect(details.name).to.equal(\"Song name is required\");\r", + " pm.expect(Object.keys(details)).to.deep.equal([\"name\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 103,\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"02:59\",\n \"year\": \"1977\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Missing Fields - All Except Id (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.match(/\\b400\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'name'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'name' field\").to.match(/\\bname\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'artist'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'artist' field\").to.match(/\\bartist\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'year'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'year' field\").to.match(/\\byear\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'album'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'album' field\").to.match(/\\balbum\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'duration'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should contain 'duration' field\").to.match(/\\bduration\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'resourceId'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'resourceId' field. Use 'id' field instead\").to.not.match(/\\bresourceId\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response does not contain 'length'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Response should not contain 'length' field. Use 'duration' field instead\").to.not.match(/\\blength\\b/);\r", + "});\r", + "\r", + "// Validate all 5 required fields\r", + "pm.test(\"Details contains all 5 'required' field errors as per specification\", function () {\r", + " var details = pm.response.json().details;\r", + " var expectedErrors = {\r", + " \"duration\": \"Duration is required\",\r", + " \"artist\": \"Artist name is required\",\r", + " \"year\": \"Year is required\",\r", + " \"album\": \"Album name is required\",\r", + " \"name\": \"Song name is required\"\r", + " };\r", + " pm.expect(Object.keys(details).length).to.equal(5);\r", + " for (var field in expectedErrors) {\r", + " pm.expect(details[field]).to.equal(expectedErrors[field]);\r", + " }\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": 102\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "! Upload Mp3 Before the Next Request", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "var jsonData = pm.response.json();\r", + "if (jsonData.id) {\r", + " pm.collectionVariables.set(\"id\", jsonData.id);\r", + "}\r", + "\r", + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"resource_service_url\");\r", + " var expectedBaseUrl = resourceServiceUrl + \"/resources\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'id' field\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Expected response to contain 'id' field\").to.have.property(\"id\");\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + }, + { + "listen": "prerequest", + "script": { + "exec": [ + "" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "audio/mpeg" + } + ], + "body": { + "mode": "file", + "file": { + "src": "" + } + }, + "url": { + "raw": "{{resource_service_url}}/resources", + "host": [ + "{{resource_service_url}}" + ], + "path": [ + "resources" + ] + } + }, + "response": [] + }, + { + "name": "Create Song Metadata - Already Exists (409)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request body equals expected JSON object with dynamic id\", function () {\r", + " var requestBody = JSON.parse(pm.request.body.raw);\r", + " var expectedBody = {\r", + " \"id\": parseInt(pm.variables.get(\"id\")),\r", + " \"name\": \"We are the champions\",\r", + " \"artist\": \"Queen\",\r", + " \"album\": \"News of the world\",\r", + " \"duration\": \"02:59\",\r", + " \"year\": \"1977\"\r", + " };\r", + " pm.expect(requestBody, \"Request body does not match expected JSON object with dynamic id\").to.deep.equal(expectedBody);\r", + "});\r", + "\r", + "pm.test(\"Status code is 409\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 409\").to.equal(409);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '409'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '409'\").to.match(/\\b409\\b/);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "pm.test(\"Response contains resource id\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain resource id\").to.include(pm.collectionVariables.get(\"id\"));\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData).to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode).to.equal(\"409\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var dynamicId = pm.collectionVariables.get(\"id\");\r", + " var expectedMessage = `Metadata for resource ID=${dynamicId} already exists`;\r", + " pm.expect(jsonData.errorMessage).to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"409 errors should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys).to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "POST", + "header": [ + { + "key": "Content-Type", + "value": "application/json" + } + ], + "body": { + "mode": "raw", + "raw": "{\n \"id\": {{id}},\n \"name\": \"We are the champions\",\n \"artist\": \"Queen\",\n \"album\": \"News of the world\",\n \"duration\": \"02:59\",\n \"year\": \"1977\"\n}" + }, + "url": { + "raw": "{{song_service_url}}/songs", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ] + } + }, + "response": [] + }, + { + "name": "Get Non-Existent Song Metadata (404)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\" + \"/99999\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '99999'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '99999'\").to.equal(\"99999\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 404\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 404\").to.equal(404);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '404'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '404'\").to.include(\"404\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData).to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode).to.equal(\"404\");\r", + "});\r", + "\r", + "// Validate error message format\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Song metadata for ID=99999 not found\";\r", + " pm.expect(jsonData.errorMessage).to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate no details field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate only 2 fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(Object.keys(jsonData).sort()).to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/99999", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "99999" + ] + } + }, + "response": [] + }, + { + "name": "Get Song Metadata - Invalid ID - Letters (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var id = \"ABC\";\r", + " var expectedBaseUrl = songServiceUrl + \"/songs/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be 'ABC'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be 'ABC'\").to.equal(\"ABC\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value 'ABC' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/ABC", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "ABC" + ] + } + }, + "response": [] + }, + { + "name": "Get Song Metadata - Invalid ID - Decimal (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var id = \"1.1\";\r", + " var expectedBaseUrl = songServiceUrl + \"/songs/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '1.1'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '1.1'\").to.equal(\"1.1\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value '1.1' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/1.1", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "1.1" + ] + } + }, + "response": [] + }, + { + "name": "Get Song Metadata - Invalid ID - Negative (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var id = \"-1\";\r", + " var expectedBaseUrl = songServiceUrl + \"/songs/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '-1'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '-1'\").to.equal(\"-1\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value '-1' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/-1", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "-1" + ] + } + }, + "response": [] + }, + { + "name": "Get Song Metadata - Invalid ID - Zero (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var id = \"0\";\r", + " var expectedBaseUrl = songServiceUrl + \"/songs/\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Path variable should be '0'\", function () {\r", + " var pathVar = pm.request.url.path[pm.request.url.path.length - 1];\r", + " pm.expect(pathVar, \"Expected path variable to be '0'\").to.equal(\"0\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "let pathVariable = pm.request.url.toString().split('/').pop();\r", + "\r", + "pm.test(\"Response contains '${pathVariable}'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '${pathVariable}'\").to.include(pathVariable);\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure matches specification\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"Response should have 'errorMessage' field\").to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData, \"Response should have 'errorCode' field\").to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode, \"errorCode should be '400'\").to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message format matches specification exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid value '0' for ID. Must be a positive integer\";\r", + " pm.expect(jsonData.errorMessage, \"Error message should match specification\").to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate response does not contain 'details' field\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData, \"400 errors for invalid ID should not have 'details' field\").to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate response contains only required fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " var keys = Object.keys(jsonData).sort();\r", + " pm.expect(keys, \"Response should contain only errorMessage and errorCode\").to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {}, + "requests": {} + } + } + ], + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs/0", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs", + "0" + ] + } + }, + "response": [] + }, + { + "name": "Delete Non-existent Song Metadata (200)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var songServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var id = \"?id=99999\";\r", + " var expectedBaseUrl = songServiceUrl + \"/songs\" + id;\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes '99999'\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams, \"Expected request parameters to include '99999'\").to.have.property(\"id\").that.includes(\"99999\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 200\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 200\").to.equal(200);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains expected JSON object: empty array\", function () {\r", + " var responseBody = pm.response.json();\r", + " var expectedResponse = {\r", + " \"ids\": []\r", + " };\r", + " pm.expect(responseBody, \"Expected response to match the JSON object\").to.deep.equal(expectedResponse);\r", + "});\r", + "\r", + "// Validate response structure matches specification\r", + "pm.test(\"Response has 'ids' field which is an empty array as per specification\", function () {\r", + " var responseBody = pm.response.json();\r", + " pm.expect(responseBody).to.have.property(\"ids\");\r", + " pm.expect(responseBody.ids).to.be.an(\"array\");\r", + " pm.expect(responseBody.ids).to.be.empty;\r", + "});\r", + "\r", + "// Validate response contains only 'ids' field\r", + "pm.test(\"Response contains only 'ids' field as per specification\", function () {\r", + " var responseBody = pm.response.json();\r", + " pm.expect(Object.keys(responseBody)).to.deep.equal([\"ids\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs?id=99999", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ], + "query": [ + { + "key": "id", + "value": "99999" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Invalid CSV - Letters (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var resourceServiceUrl = pm.variables.get(\"song_service_url\");\r", + " var expectedBaseUrl = resourceServiceUrl + \"/songs\" + \"?id=1,2,3,4,V\";\r", + " pm.expect(requestUrl, `Expected URL to match ${expectedBaseUrl}`).to.equal(expectedBaseUrl);\r", + "});\r", + "\r", + "pm.test(\"Request parameter includes 'V'\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams, \"Expected request parameters to include 'V'\").to.have.property(\"id\").that.includes(\"V\");\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "// Validate error response structure\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData).to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode).to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"Invalid ID format: 'V'. Only positive integers are allowed\";\r", + " pm.expect(jsonData.errorMessage).to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate no details\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate only 2 fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(Object.keys(jsonData).sort()).to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs?id=1,2,3,4,V", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ], + "query": [ + { + "key": "id", + "value": "1,2,3,4,V" + } + ] + } + }, + "response": [] + }, + { + "name": "Delete Invalid CSV - Length (400)", + "event": [ + { + "listen": "test", + "script": { + "exec": [ + "pm.test(\"Request URL matches expected structure\", function () {\r", + " var requestUrl = pm.request.url.toString();\r", + " var expectedBaseUrl = pm.variables.get(\"song_service_url\") + \"/songs?id=2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629\";\r", + " pm.expect(requestUrl.startsWith(expectedBaseUrl), \"Expected URL to start with the base resource service URL\").to.be.true;\r", + "});\r", + "\r", + "pm.test(\"Length of 'id' request parameter is 208 characters\", function () {\r", + " var requestParams = pm.request.url.query.toObject();\r", + " pm.expect(requestParams.id.length, \"Expected 'id' request parameter length to be greater than 200\").to.be.above(200);\r", + "});\r", + "\r", + "pm.test(\"Status code is 400\", function () {\r", + " pm.expect(pm.response.code, \"Expected status code to be 400\").to.equal(400);\r", + "});\r", + "\r", + "pm.test(\"Response is in JSON format\", function () {\r", + " pm.expect(pm.response.headers.get(\"Content-Type\"), \"Expected Content-Type to be 'application/json'\")\r", + " .to.equal(\"application/json\");\r", + "});\r", + "\r", + "pm.test(\"Response contains '400'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain '400'\").to.include(\"400\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorMessage'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorMessage'\").to.include(\"errorMessage\");\r", + "});\r", + "\r", + "pm.test(\"Response contains 'errorCode'\", function () {\r", + " var responseBody = pm.response.text();\r", + " pm.expect(responseBody, \"Expected response to contain 'errorCode'\").to.include(\"errorCode\");\r", + "});\r", + "\r", + "pm.test(\"Response contains correct characters count\", function () {\r", + " let responseBody = pm.response.json();\r", + " pm.expect(responseBody.errorMessage, \"Expected error message to contain '208'\")\r", + " .to.include(\"208\");\r", + "});\r", + "\r", + "// Validate error response structure\r", + "pm.test(\"Response has required error fields as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.have.property(\"errorMessage\");\r", + " pm.expect(jsonData).to.have.property(\"errorCode\");\r", + " pm.expect(jsonData.errorCode).to.equal(\"400\");\r", + "});\r", + "\r", + "// Validate error message exactly\r", + "pm.test(\"Error message exactly matches specification format\", function () {\r", + " var jsonData = pm.response.json();\r", + " var expectedMessage = \"CSV string is too long: received 208 characters, maximum allowed is 200\";\r", + " pm.expect(jsonData.errorMessage).to.equal(expectedMessage);\r", + "});\r", + "\r", + "// Validate no details\r", + "pm.test(\"Response does not contain 'details' field as per specification\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(jsonData).to.not.have.property(\"details\");\r", + "});\r", + "\r", + "// Validate only 2 fields\r", + "pm.test(\"Response contains only 'errorMessage' and 'errorCode' fields\", function () {\r", + " var jsonData = pm.response.json();\r", + " pm.expect(Object.keys(jsonData).sort()).to.deep.equal([\"errorCode\", \"errorMessage\"]);\r", + "});" + ], + "type": "text/javascript", + "packages": {} + } + } + ], + "request": { + "method": "DELETE", + "header": [], + "url": { + "raw": "{{song_service_url}}/songs?id=2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629", + "host": [ + "{{song_service_url}}" + ], + "path": [ + "songs" + ], + "query": [ + { + "key": "id", + "value": "2147483647,2147483646,2147483645,2147483644,2147483643,2147483642,2147483641,2147483640,2147483639,2147483638,2147483637,2147483636,2147483635,2147483634,2147483633,2147483632,2147483631,2147483630,2147483629" + } + ] + } + }, + "response": [] + } + ] + } + ] + } + ], + "event": [ + { + "listen": "prerequest", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + }, + { + "listen": "test", + "script": { + "type": "text/javascript", + "packages": {}, + "exec": [ + "" + ] + } + } + ], + "variable": [ + { + "key": "id", + "value": "1", + "type": "string" + }, + { + "key": "resource_service_url", + "value": "http://localhost:8080", + "type": "string" + }, + { + "key": "song_service_url", + "value": "http://localhost:8080", + "type": "string" + } + ] +} \ No newline at end of file diff --git a/tools/api-tests/module8-security-tests.json b/tools/api-tests/module8-security-tests.json new file mode 100644 index 0000000..9bdaed5 --- /dev/null +++ b/tools/api-tests/module8-security-tests.json @@ -0,0 +1,193 @@ +{ + "info": { + "name": "Module 8 - Security Tests", + "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json", + "description": "OAuth2 + JWT security tests for all services" + }, + "item": [ + { + "name": "Auth Server - Token Generation", + "item": [ + { + "name": "TC-9: Get token with valid credentials", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/x-www-form-urlencoded" } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { "key": "grant_type", "value": "password" }, + { "key": "username", "value": "alice" }, + { "key": "password", "value": "alice" }, + { "key": "client_id", "value": "gateway" }, + { "key": "client_secret", "value": "gateway-secret" } + ] + }, + "url": { + "raw": "http://localhost:9000/auth/oauth2/token", + "host": ["localhost:9000"], + "path": ["auth", "oauth2", "token"] + } + }, + "response": [] + }, + { + "name": "TC-10: Get token with invalid credentials", + "request": { + "method": "POST", + "header": [ + { "key": "Content-Type", "value": "application/x-www-form-urlencoded" } + ], + "body": { + "mode": "urlencoded", + "urlencoded": [ + { "key": "grant_type", "value": "password" }, + { "key": "username", "value": "wrong" }, + { "key": "password", "value": "wrong" }, + { "key": "client_id", "value": "gateway" }, + { "key": "client_secret", "value": "gateway-secret" } + ] + }, + "url": { + "raw": "http://localhost:9000/auth/oauth2/token", + "host": ["localhost:9000"], + "path": ["auth", "oauth2", "token"] + } + }, + "response": [] + } + ] + }, + { + "name": "Storage Service - User Role (alice)", + "item": [ + { + "name": "TC-2: GET /storages with User token → 200", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer {{user_token}}" } + ], + "url": { + "raw": "http://localhost:8080/storages", + "host": ["localhost:8080"], + "path": ["storages"] + } + }, + "response": [] + }, + { + "name": "TC-3: POST /storages with User token → 403", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{user_token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\"storageType\": \"TEST\", \"bucket\": \"test-bucket\", \"path\": \"/test\"}" + }, + "url": { + "raw": "http://localhost:8080/storages", + "host": ["localhost:8080"], + "path": ["storages"] + } + }, + "response": [] + }, + { + "name": "TC-5: DELETE /storages with User token → 403", + "request": { + "method": "DELETE", + "header": [ + { "key": "Authorization", "value": "Bearer {{user_token}}" } + ], + "url": { + "raw": "http://localhost:8080/storages?id=1", + "host": ["localhost:8080"], + "path": ["storages"], + "query": [{ "key": "id", "value": "1" }] + } + }, + "response": [] + } + ] + }, + { + "name": "Storage Service - Admin Role (bob)", + "item": [ + { + "name": "TC-4: POST /storages with Admin token → 201", + "request": { + "method": "POST", + "header": [ + { "key": "Authorization", "value": "Bearer {{admin_token}}" }, + { "key": "Content-Type", "value": "application/json" } + ], + "body": { + "mode": "raw", + "raw": "{\"storageType\": \"NEW\", \"bucket\": \"new-bucket\", \"path\": \"/new\"}" + }, + "url": { + "raw": "http://localhost:8080/storages", + "host": ["localhost:8080"], + "path": ["storages"] + } + }, + "response": [] + }, + { + "name": "TC-6: DELETE /storages with Admin token → 204", + "request": { + "method": "DELETE", + "header": [ + { "key": "Authorization", "value": "Bearer {{admin_token}}" } + ], + "url": { + "raw": "http://localhost:8080/storages?id=1", + "host": ["localhost:8080"], + "path": ["storages"], + "query": [{ "key": "id", "value": "1" }] + } + }, + "response": [] + } + ] + }, + { + "name": "Invalid Token Tests", + "item": [ + { + "name": "TC-7: GET /storages with invalid token → 401", + "request": { + "method": "GET", + "header": [ + { "key": "Authorization", "value": "Bearer invalid-token" } + ], + "url": { + "raw": "http://localhost:8080/storages", + "host": ["localhost:8080"], + "path": ["storages"] + } + }, + "response": [] + }, + { + "name": "TC-1: GET /storages without token → 401", + "request": { + "method": "GET", + "url": { + "raw": "http://localhost:8080/storages", + "host": ["localhost:8080"], + "path": ["storages"] + } + }, + "response": [] + } + ] + } + ] +} diff --git a/tools/dashboards/gateway-metrics.json b/tools/dashboards/gateway-metrics.json new file mode 100644 index 0000000..9a6b994 --- /dev/null +++ b/tools/dashboards/gateway-metrics.json @@ -0,0 +1,129 @@ +{ + "title": "API Gateway Performance", + "uid": "api-gateway-perf", + "tags": ["api-gateway", "http"], + "timezone": "browser", + "refresh": "15s", + "schemaVersion": 38, + "panels": [ + { + "id": 1, + "title": "Request Rate (req/s)", + "type": "timeseries", + "gridPos": { "x": 0, "y": 0, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"api-gateway\"}[1m])) by (uri, method)", + "legendFormat": "{{method}} {{uri}}", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + } + ], + "fieldConfig": { "defaults": { "unit": "reqps" } } + }, + { + "id": 2, + "title": "Error Rate (5xx)", + "type": "timeseries", + "gridPos": { "x": 12, "y": 0, "w": 12, "h": 8 }, + "targets": [ + { + "expr": "sum(rate(http_server_requests_seconds_count{job=\"api-gateway\", status=~\"5..\"}[1m])) by (uri, status)", + "legendFormat": "{{status}} {{uri}}", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + } + ], + "fieldConfig": { + "defaults": { + "unit": "reqps", + "color": { "mode": "fixed", "fixedColor": "red" } + } + } + }, + { + "id": 3, + "title": "Request Latency p50 / p95 / p99", + "type": "timeseries", + "gridPos": { "x": 0, "y": 8, "w": 24, "h": 8 }, + "targets": [ + { + "expr": "histogram_quantile(0.50, sum(rate(http_server_requests_seconds_bucket{job=\"api-gateway\"}[1m])) by (le, uri))", + "legendFormat": "p50 {{uri}}", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + }, + { + "expr": "histogram_quantile(0.95, sum(rate(http_server_requests_seconds_bucket{job=\"api-gateway\"}[1m])) by (le, uri))", + "legendFormat": "p95 {{uri}}", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + }, + { + "expr": "histogram_quantile(0.99, sum(rate(http_server_requests_seconds_bucket{job=\"api-gateway\"}[1m])) by (le, uri))", + "legendFormat": "p99 {{uri}}", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + } + ], + "fieldConfig": { "defaults": { "unit": "s" } } + }, + { + "id": 4, + "title": "Total Requests", + "type": "stat", + "gridPos": { "x": 0, "y": 16, "w": 8, "h": 4 }, + "targets": [ + { + "expr": "sum(http_server_requests_seconds_count{job=\"api-gateway\"})", + "legendFormat": "Total", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + } + ], + "fieldConfig": { "defaults": { "unit": "short" } } + }, + { + "id": 5, + "title": "4xx Errors", + "type": "stat", + "gridPos": { "x": 8, "y": 16, "w": 8, "h": 4 }, + "targets": [ + { + "expr": "sum(http_server_requests_seconds_count{job=\"api-gateway\", status=~\"4..\"})", + "legendFormat": "4xx", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { "mode": "fixed", "fixedColor": "orange" } + } + } + }, + { + "id": 6, + "title": "5xx Errors", + "type": "stat", + "gridPos": { "x": 16, "y": 16, "w": 8, "h": 4 }, + "targets": [ + { + "expr": "sum(http_server_requests_seconds_count{job=\"api-gateway\", status=~\"5..\"})", + "legendFormat": "5xx", + "datasource": { "type": "prometheus", "uid": "${datasource}" } + } + ], + "fieldConfig": { + "defaults": { + "unit": "short", + "color": { "mode": "fixed", "fixedColor": "red" } + } + } + } + ], + "templating": { + "list": [ + { + "name": "datasource", + "type": "datasource", + "query": "prometheus", + "label": "Datasource" + } + ] + } +} diff --git a/tools/docs/Communication.md b/tools/docs/Communication.md new file mode 100644 index 0000000..e3c9ffd --- /dev/null +++ b/tools/docs/Communication.md @@ -0,0 +1,203 @@ +# Module 2: Microservices Communication — Implementation Plan + +## Context + +The task (docs/Communication.md) requires replacing the current synchronous resource-upload flow with an async messaging +pattern. Today, `ResourceService.upload()` calls `SongServiceClient.saveSongMetadata()` directly via WebClient. The new +flow: + +1. **resource-service** publishes only `resourceId` to a RabbitMQ queue after upload +2. **resource-processor** consumes the message → GETs binary from resource-service → extracts MP3 metadata → POSTs to + song-service +3. **Delete path stays synchronous** (resource-service → song-service directly) +4. **Retry** on both the async publish and all sync HTTP calls + +resource-processor has **no source files in VCS** (only compiled artifacts in `build/`) — it must be written from +scratch. + +--- + +## Sub-task 1: Add RabbitMQ + resource-service producer + +### Files to modify + +**`compose.yaml`** — add RabbitMQ service: + +```yaml +rabbitmq: + image: rabbitmq:4-management-alpine + ports: + - "5672:5672" + - "15672:15672" + environment: + RABBITMQ_DEFAULT_USER: guest + RABBITMQ_DEFAULT_PASS: guest +``` + +**`resource-service/build.gradle`** — add dependencies: + +```groovy +implementation 'org.springframework.cloud:spring-cloud-starter-stream-rabbit' +implementation 'org.springframework.retry:spring-retry' +implementation 'org.springframework.boot:spring-boot-starter-aop' +``` + +**`config-service/src/main/resources/configurations/resource-service.yaml`** — add: + +```yaml +spring: + cloud: + stream: + bindings: + resourceUpload-out-0: + destination: resource-processing + rabbit: + bindings: + resourceUpload-out-0: + producer: + autoBindDlq: true + rabbitmq: + host: https://rabbitmq-ms-zlj2.onrender.com + port: 5672 + username: guest + password: guest +``` + +**`resource-service/src/main/java/com/audio/resource/service/ResourceService.java`** — in `upload()`, replace: + +```java +songServiceClient.saveSongMetadata(metadata); +``` + +with: + +```java +streamBridge.send("resourceUpload-out-0",saved.getId()); +``` + +Inject `StreamBridge` via constructor. Remove `SongMetadataDto` construction from upload path. + +**`resource-service/src/main/java/com/audio/resource/service/SongServiceClient.java`** — keep only +`deleteSongMetadata()`, remove `saveSongMetadata()`. Add `@Retryable`: + +```java + +@Retryable(maxAttempts = 3, backoff = @Backoff(delay = 1000, multiplier = 2)) +public void deleteSongMetadata(String ids) { ...} +``` + +Add `@EnableRetry` to `ResourceApplication`. + +--- + +## Sub-task 2: resource-processor source implementation + +resource-processor is a **standalone Gradle project** (not in root `settings.gradle`). + +### New source files to create + +**`resource-processor/build.gradle`** — add: + +```groovy +implementation 'org.springframework.cloud:spring-cloud-starter-stream-rabbit' +implementation 'org.apache.tika:tika-core:3.1.0' +implementation 'org.apache.tika:tika-parsers-standard-package:3.1.0' +implementation 'org.springframework.retry:spring-retry' +implementation 'org.springframework.boot:spring-boot-starter-aop' +``` + +**`ProcessorApplication.java`** — add `@EnableRetry`. + +**`dto/SongMetadata.java`** — record or class matching `id`, `name`, `artist`, `album`, `duration`, `year`. + +**`service/Mp3MetadataExtractor.java`** — wraps Apache Tika; converts millisecond duration to `mm:ss`; mirrors existing +compiled logic. + +**`service/ResourceProcessorService.java`** — core service: + +- `@Retryable` `fetchResource(Long id)`: GET `{resource.service.url}/resources/{id}` → `byte[]` +- `@Retryable` `saveSongMetadata(SongMetadata dto)`: POST `{song.service.url}/songs` +- `process(Long resourceId)`: calls both + `Mp3MetadataExtractor`; sets `id = resourceId` + +**`config/ResourceProcessorConfig.java`** — `@Bean` `WebClient` instances for resource-service and song-service URLs. + +**`messaging/ResourceEventConsumer.java`** — Spring Cloud Stream consumer: + +```java + +@Bean +public Consumer processResource() { + return resourceId -> resourceProcessorService.process(resourceId); +} +``` + +**`resource-processor/src/main/resources/application.yaml`** — keep existing port 8082, add: + +```yaml +spring: + cloud: + stream: + function: + definition: processResource + bindings: + processResource-in-0: + destination: resource-processing + group: resource-processor + rabbit: + bindings: + processResource-in-0: + consumer: + autoBindDlq: true + requeueRejected: false + rabbitmq: + host: localhost + port: 5672 + username: guest + password: guest +resource.service.url: http://localhost:8080 +song.service.url: http://localhost:8081 +``` + +**`config-service/src/main/resources/configurations/resource-processor.yaml`** — optional: if resource-processor imports +from config-server, create this file with the above config. + +--- + +## Sub-task 3: Retry mechanism + +| Location | Method | Annotation | +|-----------------------------------------------|--------------|-------------------------------------------------------------------------| +| `SongServiceClient.deleteSongMetadata()` | sync HTTP | `@Retryable(maxAttempts=3, backoff=@Backoff(delay=1000, multiplier=2))` | +| `ResourceProcessorService.fetchResource()` | sync HTTP | `@Retryable(maxAttempts=3, backoff=@Backoff(delay=1000, multiplier=2))` | +| `ResourceProcessorService.saveSongMetadata()` | sync HTTP | `@Retryable(maxAttempts=3, backoff=@Backoff(delay=1000, multiplier=2))` | +| RabbitMQ consumer | broker-level | `autoBindDlq: true` + `requeueRejected: false` for DLQ on exhaustion | + +Add `@EnableRetry` on both `ResourceApplication` and `ProcessorApplication`. + +--- + +## File Change Summary + +| File | Action | +|-------------------------------------------------|---------------------------------------------------| +| `compose.yaml` | Add RabbitMQ service | +| `resource-service/build.gradle` | Add stream-rabbit, spring-retry, aop | +| `config-service/.../resource-service.yaml` | Add RabbitMQ + stream bindings | +| `resource-service/.../ResourceService.java` | Replace saveSongMetadata with StreamBridge.send | +| `resource-service/.../SongServiceClient.java` | Remove saveSongMetadata, add @Retryable to delete | +| `resource-service/.../ResourceApplication.java` | Add @EnableRetry | +| `resource-processor/build.gradle` | Add stream-rabbit, tika, spring-retry, aop | +| `resource-processor/src/**` (6 new files) | Full source implementation | +| `config-service/.../resource-processor.yaml` | New config file for processor service | + +--- + +## Verification + +1. `docker compose up -d` — verify RabbitMQ management UI at `https://rabbitmq-ms-zlj2.onrender.com:15672` +2. Start services in order: config → discovery → resource-service → song-service → resource-processor +3. `POST /resources` with an MP3 → expect `201` with `{"id": N}` +4. Check RabbitMQ UI: message should be consumed from `resource-processing` queue +5. `GET /songs/N` → metadata should be populated by resource-processor +6. `DELETE /resources?id=N` → synchronous cascade; `GET /songs/N` should return 404 +7. Kill song-service mid-flight; verify `@Retryable` retries show in resource-processor logs diff --git a/tools/docs/Containerization.md b/tools/docs/Containerization.md new file mode 100644 index 0000000..56b219e --- /dev/null +++ b/tools/docs/Containerization.md @@ -0,0 +1,312 @@ +# Module 4: Containerization + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Dockerfiles](#sub-task-1-dockerfiles) +- [Sub-task 2: Docker Compose](#sub-task-2-docker-compose) + +## What to do + +This module focuses on containerizing services and configuring them with Docker Compose, including databases, a message broker, and cloud storage emulation. You need to create Dockerfiles for all microservices (if not already present) and configure a containerized environment using Docker Compose, integrating databases, a message broker, and S3 emulation. + +As part of this setup, include health checks for both services and resources to ensure that each component is fully operational before dependent services start. Use `depends_on` with conditions like `service_healthy` to control startup order and improve reliability, but also remember that `depends_on` itself doesn’t wait for services to be fully ready; health checks ensure this readiness. + +All services and resources should be deployable using `docker compose up -d --build` and functional without extra manual setup. + +## Sub-task 1: Dockerfiles + +### 1. Create Dockerfiles for each microservice (if not already present) + +- Use **two-stage builds** to separate build and runtime environments, optimizing image size. +- Use **dependency caching**. +- Use `WORKDIR` to set the working directory. +- Use `COPY` for file transfers. +- Use `EXPOSE` to define the application port. +- Avoid hardcoding file names (e.g., use wildcards for JAR files). +- Set the default command with `CMD`. + +### 2. Test docker images + +- Build Docker images for each microservice. +- Verify each service starts and responds on its defined port using Postman or other tool. + + +## Sub-task 2: Docker Compose + +### 1. Resource containers + +Set up additional containers for required resources: + +- **Database (PostgreSQL)**: + - Use [Alpine-based PostgreSQL](https://hub.docker.com/_/postgres/tags?name=17-alpine). + - Define configuration in `.env`. + - Mount `initdb` directory to `docker-entrypoint-initdb.d` for automatic schema setup. + - Add a health check to confirm service readiness. + +- **S3 emulator (LocalStack)**: + - Use [LocalStack](https://hub.docker.com/r/localstack/localstack) with only the `S3` service enabled. + - Define configuration in `.env`. + - Add a health check to confirm service readiness. + +- **Message broker (ActiveMQ or RabbitMQ)**: + - **ActiveMQ option**: + - Use the [ActiveMQ Docker image](https://hub.docker.com/r/rmohr/activemq). + - Define configuration in `.env`. + - Expose ActiveMQ’s ports (e.g., 8161 for the web console, 61616 for messaging). + - Add a health check to ensure ActiveMQ is running. + + - **RabbitMQ option**: + - Use the [RabbitMQ Docker image](https://hub.docker.com/_/rabbitmq). + - Define configuration in `.env`. + - Expose RabbitMQ’s ports (e.g., 5672 for messaging, 15672 for the web console). + - Add a health check to verify RabbitMQ is operational. + +### 2. Microservice containers + +Define each microservice in `docker-compose.yml` and configure dependencies as shown: + +- **Environment variable management**: + - Define environment variables in `.env`. + - Avoid hardcoding values in `application.properties` or `application.yml`. + +- **Health checks**: + - Add health checks for each microservice to ensure they are fully operational. + +- **Dependency management with `depends_on`**: + - Use `depends_on` with `condition: service_healthy` to ensure critical services (like databases, message brokers, and Eureka server) are ready before starting dependent microservices. For example: + ```yaml + depends_on: + postgres: + condition: service_healthy + ``` + - **Note**: `depends_on` controls only the order of container startup, not waiting for services to be fully ready. Therefore, include health checks for each service to ensure they are operational before starting dependent services. + + + + +### 3. Execution requirements + +Although the primary goal of this task is **full containerization** of all services and resources, the applications should also be able to run locally for **testing and debugging purposes**. This flexibility allows services to be executed on the local machine while still connecting to containerized resources. + +Ensure the application can be executed both **locally** and entirely within **Docker Compose** without requiring configuration changes or profile switching: + +- **Local execution**: Only the **resource containers** (database, message broker, S3 emulator) should run in Docker, while each **microservice** should run directly on the local machine. The application should use default values specified in `application.properties` or `application.yml` to connect to the resource containers. + +- **Docker execution**: In this mode, both **microservices** and **resource containers** run fully within Docker Compose. Docker Compose should pull configuration values from the `.env` file automatically, allowing the containerized environment to use the necessary settings without manual adjustments. + +This approach supports a flexible setup for development and testing while ensuring consistent configuration in both local and fully containerized environments. + +### 4. Final testing + +1. Use `docker compose up -d --build` to start both microservices and resource containers in Docker. +2. Use Postman or another tool to verify that all services are operational and interacting correctly. + + +# Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Dockerfiles](#sub-task-1-dockerfiles) +- [Sub-task 2: Docker Compose file](#sub-task-2-docker-compose-file) +- [Notes](#notes) +- [Checklist: Before you submit the task](#checklist-before-you-submit-the-task) + +## What to do + +In this module, you will adapt your services to use a containerization approach. + +## Sub-task 1: Dockerfiles + +1. **Create a Dockerfile for each service**. Make sure to follow these requirements: + + - Implement **two-stage builds** to create a clear separation between build and runtime environments, which helps keep the final image size small. + - Use **Alpine images** to keep the resulting images lightweight (below are the recommended ones): + - **Build stage**: + - For Maven projects, use [Eclipse Temurin-based Alpine images](https://hub.docker.com/_/maven/tags?name=eclipse-temurin-17-alpine). These allow you to build Java applications efficiently while keeping the environment minimal. + - For Gradle projects, use [Gradle Alpine images](https://hub.docker.com/_/gradle/tags?name=jdk17-alpine), designed specifically for building Java applications with Gradle. + - **Runtime stage**: + - Use [Eclipse Temurin Alpine images](https://hub.docker.com/_/eclipse-temurin/tags?name=17-jre-alpine) for running the application. These images include only the necessary JRE components, minimizing resource usage. + - Introduce **dependency caching** to speed up rebuilds. This leverages Docker's layer caching to avoid re-downloading unchanged dependencies. + - **Tips for Maven projects**: + - Copy the `pom.xml` file before copying the source code (`src`). This allows Docker to cache dependencies if the configuration file has not changed. + - Avoid `COPY . .` in build stage. Instead, copy files selectively to ensure Docker builds only when necessary, like `COPY src ./src`. + - Use the command `RUN mvn dependency:go-offline` to download all dependencies before copying the source code. + - **Tips for Gradle projects**: + - Copy the Gradle wrapper and build configuration files (`build.gradle`, `settings.gradle`, `gradlew`) first, then install dependencies (e.g., `RUN ./gradlew dependencies --no-daemon`) This helps cache dependencies effectively. + - **Additional tips**: + - **General**: + - Use `WORKDIR` to specify a consistent context for commands (e.g., `WORKDIR /app`). By using `WORKDIR`, you ensure all subsequent commands operate within a defined context without additional setup. Also, `WORKDIR` automatically creates the directory if it doesn’t already exist, so there’s no need for a separate `RUN mkdir /app` command. + - Prefer `COPY` over `ADD` for local files, as `ADD` can introduce unexpected behavior by unpacking files or fetching URLs. + - Avoid hardcoded JAR names by using wildcards. For example, instead of `COPY --from=build /app/target/my-application-1.0.0.jar app.jar` use `COPY --from=build /app/target/*.jar app.jar`. This way, you don’t need to update the Dockerfile if the JAR file name changes, as long as there’s only one JAR file in the target directory. + - Use `CMD` instead of `ENTRYPOINT` to allow flexibility in overriding commands in Docker Compose or when running the container manually. + - Use `EXPOSE` to indicate the application’s internal port in the runtime stage, e.g., `EXPOSE 8080`. + - Avoid defining environment variables in the Dockerfile for runtime-specific values with `ARG` or `ENV`. + - **Tips for Maven projects**: + - Use `RUN mvn clean package -Dmaven.test.skip=true` in the Dockerfile build stage to skip both test compilation and execution for faster builds. If you want to skip running the tests but still need the test classes available, use `RUN mvn clean package -DskipTests`. + - **Tips for Gradle projects**: + - Include the Gradle Wrapper (`gradlew`) in your project and run all commands via `gradlew` to avoid host dependency issues. Update the `.gitignore` file to ensure that `gradlew` and `gradlew.bat` files are included in the Git repository for Docker compatibility. + - Use the `--no-daemon` flag with `gradlew` to ensure consistent builds within Docker and manage memory usage effectively. + - Use `RUN ./gradlew assemble --no-daemon -x test` to skip tests and speed up the Docker build. The `assemble` task compiles and packages the code without running tests by default. Adding `-x test` further ensures tests are excluded, maximizing build efficiency. This approach is faster than using `gradle build`, which includes tests by default. + +2. **Test the Docker images** + + - Build Docker images for each service. + - Run the Docker containers and **map external ports** to verify that the application starts correctly and responds to HTTP requests (e.g., using Postman). + + +## Sub-task 2: Docker Compose file + +### 1. Container configuration + +Create a `compose.yaml` (`docker-compose.yaml`) file that includes the following elements: + +- **Database containers**. Make sure to follow these requirements: + + - **For each database, create a separate container** using lightweight [Alpine-based PostgreSQL images](https://hub.docker.com/_/postgres/tags?name=17-alpine) (version 16 or higher). + - **Database-specific configurations**, such as `POSTGRES_DB`, `POSTGRES_USER`, and `POSTGRES_PASSWORD`, should be read from the `.env` file. + - **Schema management approach:** + - In **Module 1**, schema initialization was fully automated using **Hibernate** (`ddl-auto=update`), while SQL initialization scripts (`schema.sql`, `data.sql`) were explicitly prohibited. + - In **Module 2**, the goal is to transition to a **containerized database setup**, where schema initialization is still fully automated but must be handled via predefined SQL scripts executed within the database container, not by Spring or an ORM. + - Using migration tools (Liquibase, Flyway, etc.) is not allowed. + - Ensure that `spring.jpa.hibernate.ddl-auto` is set to `none` or removed in `application.properties` or `application.yml`, and that it is not passed as an environment variable in Docker Compose. + - The following settings must not be present in `application.properties` or `application.yml` and must not be set as environment variables in Docker Compose: + - `sql.init.mode` + - `spring.jpa.defer-datasource-initialization` + - `spring.flyway.enabled` + - `spring.liquibase.enabled`, etc. + - **Handle schema initialization externally using the database container**: + - Schema initialization must be fully automated but executed **within the database container**. + - SQL scripts should be placed in a dedicated directory (e.g., `init-scripts/`) and **mounted into the database container** using Docker volumes. + - The scripts must define tables, but must NOT create the database itself. + - The `POSTGRES_DB` environment variable in `compose.yaml` must be used to create the database automatically. + +- **Microservice containers**. Ensure you comply with these requirements: + + - For each service, add a block with the `build` parameter to build images directly from the source code using the Dockerfile located in each service’s subdirectory. + - To avoid confusion, do not use both `build` and `image` together. The `image` property is intended to pull pre-built images from a registry (e.g., Docker Hub) or assume images are manually built. + - Specify ports (`ports`) to expose for external access. + - Define environment variables (`environment`), including database references, using an `.env` file for variable substitution. + + + + + +### 2. Microservice configuration + +Be sure to meet all these conditions: + + - Avoid hardcoding container-specific values (such as database URLs, credentials, service URLs, and any other configuration details specific to the containerized environment) directly in `application.properties` or `application.yml`. + - Set container-specific values as environment variables in Docker Compose. + - Use a `.env` file to define these variables, allowing Docker Compose to automatically read and inject environment-specific settings. For example: + + ```properties + # .env + RESOURCE_DB_URL=jdbc:postgresql://resource-db:5432/resource_db + ``` + + ```yaml + # compose.yaml + services: + resource-service: + environment: + SPRING_DATASOURCE_URL: ${RESOURCE_DB_URL} + ``` + + - Configure `application.properties` or `application.yml` to support both environment variables for containerized execution and default values for local execution (e.g., when running directly in IntelliJ). This approach ensures smooth transitions between development and deployment environments. For example:: + + ```properties + # application.properties for Resource Service + spring.datasource.url="${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/resource_db}" + ``` + + - Ensure the application can be executed both locally and in Docker Compose without requiring configuration changes or switching profiles: + - **Local execution**: + - Only the **database containers** should run in Docker: use a `docker compose up` command specifying the database services, e.g.: `docker compose up -d resource-db song-db`) + - **Microservices** should run directly on the local machine (as in Module 1). + - The application should use default values specified in `application.properties` or `application.yml` to connect to the database containers. + - **Docker execution**: + - Both **microservices** and **database containers** run fully in Docker Compose. + - Docker Compose should pull configuration values from the `.env` file automatically, allowing the containerized environment to use the necessary settings without manual adjustments. + + +### 3. Additional notes + +Adhere to the specified requirements: + + - Use Docker Compose's **default network**. + - Use **logical service names** to cross-reference services for easier communication within the Docker network instead of IP addresses. + - **Persisting database data** between restarts is not enabled. + + +## Notes + +- Your configuration should automatically rebuild images, create, and start all containers, ensuring a complete service update without extra steps or scripts — all with a single command: `docker compose up -d --build`. +- Use the [Postman collection](../microservice_architecture_overview/api-tests/introduction_to_microservices.postman_collection.json) for testing the Resource Service and Song Service APIs. +- After all the changes, your project structure should look similar to this: + +``` +microservices/ +├── init-scripts/ +│ ├── resource-db/ +│ │ └── init.sql +│ └── song-db/ +│ └── init.sql +├── resource-service/ +│ ├── src/ +│ └── Dockerfile +├── song-service/ +│ ├── src/ +│ └── Dockerfile +├── compose.yaml +├── .env +└── .gitignore +``` + +--- + +## Checklist: Before you submit the task + +Before submitting your task, please ensure that you have completed all the required steps: + +✅ **Dockerfiles** +- [ ] Created a Dockerfile for each service following best practices. +- [ ] Used two-stage builds to separate the build and runtime environments. +- [ ] Used Alpine-based images to keep the final image size small. +- [ ] Implemented dependency caching for faster builds. +- [ ] Used `WORKDIR` to set a consistent working directory. +- [ ] Used `COPY` instead of `ADD` for local files. +- [ ] Avoided hardcoded JAR names by using wildcards. +- [ ] Used `CMD` instead of `ENTRYPOINT` to allow flexibility in overriding commands. +- [ ] Exposed the correct application port. + +✅ **Docker Compose** +- [ ] Created a `compose.yaml` (`docker-compose.yaml`) file in the project root directory. +- [ ] Used Alpine-based PostgreSQL images for the databases. +- [ ] Ensured each service has its own database container. +- [ ] Moved database credentials and configuration to an `.env` file. +- [ ] Schema initialization is fully automated and handled inside the database containers, not by Spring Boot. +- [ ] Initialization scripts (`init.sql`) are not used to create databases, only tables. +- [ ] Used Docker volumes to mount initialization scripts (`init.sql`) for table creation. +- [ ] Disabled automatic tables generation (`spring.jpa.hibernate.ddl-auto` set to `none` or removed, and not defined as an environment variable in Docker Compose). +- [ ] Did not use migration tools such as Flyway or Liquibase. +- [ ] Persisting database data between restarts is not enabled (no mounted database volume). +- [ ] Used `build` instead of `image` in the microservice definitions. +- [ ] Configured services to reference each other by service names instead of IP addresses. +- [ ] Used Docker Compose's default network. + +✅ **Microservice configuration** +- [ ] Removed hardcoded values (e.g., database URLs, credentials) from `application.properties`, except default ones for local execution. +- [ ] Used environment variables for all container-specific values. +- [ ] Ensured `application.properties` supports both local and Docker execution. +- [ ] Verified that services run both locally and in Docker Compose without profile switching. + +✅ **Testing** +- [ ] Used the provided [Postman collection](../microservice_architecture_overview/api-tests/introduction_to_microservices.postman_collection.json) and [sample MP3 file](../microservice_architecture_overview/sample-mp3-file/mp3.zip) to test APIs both locally and in Docker Compose. +- [ ] Verified that all API requests return [correct responses](../microservice_architecture_overview/api-tests/api-response-specification.md) both locally and in Docker Compose. + +✅ **Project structure** +- [ ] Ensured the final project structure follows the required format. + +✅ **Final submission** +- [ ] Committed all changes to the Git repository. +- [ ] Ready to place the link to your repository in the personal folder in Avalia. diff --git a/tools/docs/Fault tolerance.md b/tools/docs/Fault tolerance.md new file mode 100644 index 0000000..19c4e3b --- /dev/null +++ b/tools/docs/Fault tolerance.md @@ -0,0 +1,163 @@ +# Module 6: Fault tolerance in microservices environment + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Implement Storage Service](#sub-task-1-implement-storage-service) +- [Sub-task 2: Implement Circuit Breaker](#sub-task-2-implement-circuit-breaker) + +## What to do + +In this module, you will implement fault tolerance in microservices communications by introducing a circuit breaker. The following steps guide you through the process: + +1. Introduce an element to simulate near-static data, as the current implementation lacks such functionality. This will emulate the behavior of a stubbed circuit breaker. +2. Files must be categorized into specific states based on the processing phase: + - **STAGING**: File is in processing. + - **PERMANENT**: File has been successfully processed. +3. A dedicated **Storage Service** must be created to store and manage these states. The **Resource Service** will interact with the **Storage Service** to retrieve state details. +4. In the event of **Storage Service** unavailability, the **Resource Service** must implement a fault tolerance pattern to ensure continuous operation. + + +## Sub-task 1: Implement Storage Service + +### 1. Create the service + +Develop and implement an independent microservice with a CRUD API to represent the Storage concept. This service will manage **storage** types. + +Previously, the **Resource Service** utilized its own configuration to access data stores (**S3 buckets**). This configuration must now be migrated to the new service. + +Upon starting the **Storage Service**, at least two different storage types (**Storage objects**) must be pre-created in the database. These will be utilized by the **Resource Service**. Corresponding storage buckets must also be created in **localstack**. + +A **Storage object** is restricted to the following **storageType** values: +- **STAGING**: Represents staging storage. +- **PERMANENT**: Represents permanent storage. + +Each type must have a unique bucket path. Additional storage types can be created via the Storage Service API. + + +### 2. Storage Service API + +#### 1. Create storage + +``` +POST /storages +``` + +**Description**: Adds a new storage entry. + +**Request:** + +- **Content-Type:** application/json +- **Body:** JSON object representing storage details + +```json +{ + "storageType": "PERMANENT", + "bucket": "bucket_name", + "path": "/files" +} +``` + +**Response:** + +```json +{ + "id": 1 +} +``` + +- **Description:** Returns the ID of successfully created storage. + +**Status Codes**: + +- **200 OK** – Storage created successfully. +- **400 Bad Request** – Validation errors in the request body. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +#### 2. Get all storages + +``` +GET /storages +``` + +**Description**: Retrieves the list of all storage entries. + +**Response**: + +```json +[ + { + "id": 1, + "storageType": "PERMANENT", + "bucket": "bucket_name", + "path": "/files" + } +] +``` + +**Status Codes**: + +- **200 OK** – Storages retrieved successfully. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +#### 3. Delete storages + +``` +DELETE /storages?id=1,2 +``` + +**Description:** Deletes specified storages by their IDs. If a storage does not exist, it is ignored without causing an error. + +**Parameters:** + +- `id` (String): Comma-separated list of storage IDs to remove. +- **Restriction:** CSV string length must be less than 200 characters. + +- **Description:** Returns an array of the IDs of successfully deleted storages. + +**Status codes:** + +- **200 OK** – Request successful, storages deleted as specified. +- **400 Bad Request** – CSV string format is invalid or exceeds length restrictions. +- **500 Internal Server Error** – An error occurred on the server. + +> **Note**: In the local database, only general information about storage types is stored, not the state of individual files. + +### 3. Enable interaction with the Storage Service + +The system must be updated to interact with the new **Storage Service** as follows: + +- Depending on the file processing state, files will be stored in different locations (paths or folders). The **Storage Service** must be queried to retrieve details about each state and the corresponding storage path. +- When a new file is received by the **Resource Service** for processing, it must be saved in **STAGING** storage. The file state and path must then be updated in the Resource Service database before sending the file for further processing. +- After successfully processing a file, the **Resource Processor** must send an asynchronous message to the **Resource Service** to indicate the song file has been successfully processed. +- Upon receiving a notification from the **Resource Processor**, the **Resource Service** must: + - Query the **Storage Service** to retrieve details for the **PERMANENT** storage location. + - Update the file state to **PERMANENT**. + - Move the file from its current location to the **PERMANENT** storage location. + - Update the file's storage details in the Resource Service database. + +Refer to the diagrams below for clarification: + + + +
+ + + +> **Note**: Additional file states can be implemented based on system requirements. However, start with two states to minimize complexity. + + +## Sub-task 2: Implement Circuit Breaker + +If the **Storage Service** becomes unavailable, the system must continue to operate without significant disruption. In such cases, stub data should be stored in the **Resource Service** to emulate the response from the **Storage Service**. The emulated response must match the structure and format of the actual response from the **Storage Service**. + +Implement the circuit breaker pattern to ensure fault tolerance: + +1. Integrate the [Resilience4j](https://mvnrepository.com/artifact/io.github.resilience4j/resilience4j-circuitbreaker) library into the **Resource Service**. +2. Configure the circuit breaker for operations involving calls to the **Storage Service** (e.g., retrieving storage details). +3. Implement logic to return stub data if the **Storage Service** is unavailable. The stub response should mimic the structure of the actual service's response. +4. Simulate service failure by shutting down the **Storage Service**, and test the circuit breaker functionality to ensure proper fallback mechanisms are in place. diff --git a/tools/docs/Fundamentals.md b/tools/docs/Fundamentals.md new file mode 100644 index 0000000..4166cd4 --- /dev/null +++ b/tools/docs/Fundamentals.md @@ -0,0 +1,62 @@ +# Module 1: Microservice architecture overview + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Resource Service](#sub-task-1-resource-service) +- [Sub-task 2: Resource Processor](#sub-task-2-resource-processor) +- [FAQ](#faq) + +## What to do + +This task involves enhancing an existing microservices architecture by modifying the current **Resource Service** and adding a new microservice called **Resource Processor**. The starting point for this work is the solution you implemented in the [Introduction to Microservices](https://git.epam.com/epm-cdp/global-java-foundation-program/java-courses/-/tree/main/introduction-to-microservices) program. + +The main objectives are: + +1. Make structural changes to the existing **Resource Service**. +2. Develop a new microservice called **Resource Processor**. + +## Sub-task 1: Resource Service + +For the **Resource Service**, you need to implement the following modifications: + +1. **Use Cloud Storage**: Replace the current database storage for resource files with a cloud storage solution, such as an emulator (e.g., [S3 emulator](https://github.com/localstack/localstack)). The resource files were previously saved in the service database. + +2. **Resource Tracking**: Update the underlying database to track the resource by storing its location in the cloud storage. + +3. **Upload Process**: When a user uploads an MP3 file, the **Resource Service** should: + - Store the original MP3 file in the cloud storage (or its emulation). + - Save the file's location (i.e., the link in cloud storage) in the database. + - Note: In the current module, the **Resource Service** should not call any other services during this process. + +## Sub-task 2: Resource Processor + +The **Resource Processor** microservice will be responsible for processing MP3 files in the upcoming modules. It will not have a web interface and, in the current module, should be implemented as a basic Spring Boot application with minimal configuration. + +- **Initial Functionality**: The service should be able to extract metadata from an MP3 file for future use with the **Song Service** API. You can use an external library like [Apache Tika](https://www.tutorialspoint.com/tika/tika_extracting_mp3_files.htm) to handle the metadata extraction. + +- **Basic Spring Boot Setup**: Implement the initial version of the service as a standard Spring Boot project. + +The diagram below illustrates the overall microservice architecture: + + + +--- + +## FAQ + +> **Q:** Are we going to reuse our own implementation of the Resource and Song services from the "Introduction to Microservices" course, or will there be common artifacts provided for everyone? + +**A:** Yes, you must **reuse your own implementation**. The starting point for this work is the artifact (codebase) you created during the [Introduction to Microservices](https://git.epam.com/epm-cdp/global-java-foundation-program/java-courses/-/tree/main/introduction-to-microservices) program. You will continue to enhance that existing project by adding new features. + +--- + +> **Q:** The task describes uploading new resources to **Cloud Storage**, but what about removing them? Is this out of scope? + +**A:** No, you must still support resource deletion. In the **Introduction to Microservices** program, there was a clear business rule: each resource has a 1:1 relation with song metadata, so when you call `DELETE /resources`, the related metadata in Song Service must also be deleted. + +In this module, this logic serves as a cleanup mechanism and must be **adapted** for the new storage architecture: + +1. Remove the file from the **Cloud Storage** (S3 bucket). +2. Delete the record from the **Resource DB**. +3. Call **Song Service** to cascade-delete the related song metadata. \ No newline at end of file diff --git a/tools/docs/Introduction.md b/tools/docs/Introduction.md new file mode 100644 index 0000000..c50830e --- /dev/null +++ b/tools/docs/Introduction.md @@ -0,0 +1,517 @@ +# Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Resource Service](#sub-task-1-resource-service) +- [Sub-task 2: Song Service](#sub-task-2-song-service) +- [Notes](#notes) +- [Checklist: Before you submit the task](#checklist-before-you-submit-the-task) + +## What to do + +Your task is to implement a microservices system consisting of two services: + +- **Resource Service** - for MP3 file processing +- **Song Service** - for song metadata management + +### Service relationships + +The services are designed to work together as follows: + +- **Resource Service** handles the storage and processing of MP3 files. +- **Song Service** manages metadata for each song, ensuring that each metadata entry corresponds to a unique MP3 file in the Resource Service. +- The song metadata and resource entities maintain a one-to-one relationship: + - Each song metadata entry is uniquely associated with a resource, linked via the resource ID. + - Deleting a resource triggers a cascading deletion of its associated metadata. + +### Requirements + +- **Framework**: Spring Boot 3.4.0 or higher +- **Java Version**: Java 17 or later (LTS versions) +- **Programming Language**: Java + - Usage of Kotlin, Groovy, Scala, or any other JVM-based language is not allowed +- **Build Tool**: Maven or Gradle +- **Database**: PostgreSQL +- **Application Startup**: In this module, Resource Service and Song Service must run locally (not in Docker) + +> This course does not require creating unit tests. If you are not planning to include tests, please delete `src/test/` directory and remove the test dependencies (`spring-boot-starter-test` etc.) from your `pom.xml` or `build.gradle` files. + +## Sub-task 1: Resource Service + +The Resource Service implements CRUD operations for processing MP3 files. When uploading an MP3 file, the service: + +- Stores the MP3 file in the database. +- Extracts the MP3 file tags (metadata) using external libraries like [Apache Tika](https://www.tutorialspoint.com/tika/tika_extracting_mp3_files.htm). +- Invokes the Song Service to save the MP3 file tags (metadata). +- Must not modify the tags (metadata) extracted from the MP3 file before sending them to the Song Service, except for converting the duration from seconds to mm:ss format. + +### API endpoints + +--- + +#### 1. Upload resource + +``` +POST /resources +``` + +**Description:** Uploads a new MP3 resource. + +**Request:** + +- **Content-Type:** audio/mpeg +- **Body:** Binary MP3 audio data + +**Response:** + +```json +{ + "id": 1 +} +``` + +- **Description:** Returns the ID of successfully created resource. + +**Status codes:** + +- **200 OK** – Resource uploaded successfully. +- **400 Bad Request** – The request body is invalid MP3. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +#### 2. Get resource + +``` +GET /resources/{id} +``` + +**Description:** Retrieves the binary audio data of a resource. + +**Parameters:** + +- `id` (Integer): The ID of the resource to retrieve. +- **Restriction:** Must be a valid ID of an existing resource. + +**Response:** + +- **Body:** Returns the audio bytes (MP3 file) for the specified resource. + +**Status codes:** + +- **200 OK** – Resource retrieved successfully. +- **400 Bad Request** – The provided ID is invalid (e.g., contains letters, decimals, is negative, or zero). +- **404 Not Found** – Resource with the specified ID does not exist. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +#### 3. Delete resources + +``` +DELETE /resources?id=1,2 +``` + +**Description:** Deletes specified resources by their IDs. If a resource does not exist, it is ignored without causing an error. + +**Parameters:** + +- `id` (String): Comma-separated list of resource IDs to remove. +- **Restriction:** CSV string length must not exceed 200 characters. + +**Response:** + +```json +{ + "ids": [1, 2] +} +``` + +- **Description:** Returns an array of the IDs of successfully deleted resources. + +**Status codes:** + +- **200 OK** – Request successful, resources deleted as specified. +- **400 Bad Request** – CSV string format is invalid or exceeds length restrictions. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +## Sub-task 2: Song Service + +The **Song Service** implements CRUD operations for managing song metadata records. The service uses the Resource ID to uniquely identify each metadata record, establishing a direct one-to-one relationship between resources and their metadata. + +--- + +### API endpoints + +#### 1. Create song metadata + +``` +POST /songs +``` + +**Description:** Create a new song metadata record in the database. + +**Request body:** + +```json +{ + "id": 1, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "02:59", + "year": "1977" +} +``` + +- **Description:** Song metadata fields. + +**Validation rules:** + +- **All fields are required.** +- `id`: Numeric, must match an existing Resource ID. +- `name`: 1-100 characters text. +- `artist`: 1-100 characters text. +- `album`: 1-100 characters text. +- `duration`: Format `mm:ss`, with leading zeros. +- `year`: `YYYY` format between 1900-2099. + +**Response:** + +```json +{ + "id": 1 +} +``` + +- **Description:** Returns the ID of the successfully created metadata record (should match the Resource ID). + +**Status codes:** + +- **200 OK** – Metadata created successfully. +- **400 Bad Request** – Song metadata is missing or contains errors. +- **409 Conflict** – Metadata for this ID already exists. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +#### 2. Get song metadata + +``` +GET /songs/{id} +``` + +**Description:** Get song metadata by ID. + +**Parameters:** + +- `id` (Integer): ID of the metadata to retrieve. +- **Restriction:** Must match an existing Resource ID. + +**Response:** + +```json +{ + "id": 1, + "name": "We are the champions", + "artist": "Queen", + "album": "News of the world", + "duration": "02:59", + "year": "1977" +} +``` + +**Status codes:** + +- **200 OK** – Metadata retrieved successfully. +- **400 Bad Request** – The provided ID is invalid (e.g., contains letters, decimals, is negative, or zero). +- **404 Not Found** – Song metadata with the specified ID does not exist. +- **500 Internal Server Error** – An error occurred on the server. + +--- + +#### 3. Delete songs metadata + +``` +DELETE /songs?id=1,2 +``` + +**Description:** Deletes specified song metadata records by their IDs. If a metadata record does not exist, it is ignored without causing an error. + +**Parameters:** + +- `id` (String): Comma-separated list of metadata IDs to remove. +- **Restriction:** CSV string length must not exceed 200 characters. + +**Response:** + +```json +{ + "ids": [1, 2] +} +``` + +- **Description:** Returns an array of the IDs of successfully deleted metadata records. + +**Status codes:** + +- **200 OK** – Request successful, metadata records deleted as specified. +- **400 Bad Request** – CSV string format is invalid or exceeds length restrictions. +- **500 Internal Server Error** – An error occurred on the server. + +## Notes + +### Controllers + +- Keep controllers slim; they should only handle HTTP-related concerns. +- Do not place validation (e.g., ID length checks) in controllers. Move validation to the service layer or use request DTOs with validation annotations. +- Do not include business logic (e.g., data transformations, string parsing) in controllers. Move such logic to the service layer or mappers. +- Avoid using raw entities for requests and responses to prevent exposing sensitive fields or internal schema details. Use DTOs instead. +- Wrap responses in `ResponseEntity` with appropriate HTTP status codes. +- Use specific response types (e.g., `ResponseEntity>`, `ResponseEntity`) to ensure API consistency. +- Controllers should not manually throw or handle exceptions. Instead, throw exceptions in the service layer and handle them in a global exception handler. + +### Error Handling + +- Add a global exception handler using `@RestControllerAdvice`. +- Map exceptions to appropriate HTTP status codes. +- Provide meaningful error messages and error codes in responses using a unified structure (see the [API response specification](./api-tests/api-response-specification.md) for detailed response formats): + +#### Simple error response + +```json +{ + "errorMessage": "Resource with ID=1 not found", + "errorCode": "404" +} +``` + +#### Validation error response + +```json +{ + "errorMessage": "Validation error", + "details": { + "duration": "Duration must be in mm:ss format with leading zeros", + "year": "Year must be between 1900 and 2099" + }, + "errorCode": "400" +} +``` + +#### Incorrect responses and why they are wrong +Example 1: +```json +{ + "errorMessage": "400 BAD_REQUEST \"Validation failure\"", + "errorCode": 400 +} +``` +Issues: +- `"400 BAD_REQUEST"` in `errorMessage` is redundant (status code already exists in `errorCode`). +- No details about which fields failed validation and why. + +--- + +Example 2: +```json +{ + "errorMessage": "Validation failure", + "errorCode": 400 +} +``` +Issue: +- No details about which fields failed validation and why. + +--- + +Example 3: +```json +{ + "errorMessage": "problemDetail.org.springframework.web.bind.MethodArgumentNotValidException", + "errorCode": 400, + "details": { + "name": "Name is required" + } +} +``` +Issues: +- The `errorMessage` should never contain raw exception names (e.g., `MethodArgumentNotValidException`). This exposes internal implementation details to the API consumer. +- The message should be replaced with a human-readable `"Validation failed"`. + +--- + +Example 4 +```json +{ + "errorMessage": "Method parameter 'id': Failed to convert value of type 'java.lang.String' to required type 'int'; For input string: \"ABC\"", + "errorCode": "400" +} +``` +Issues: +- The `errorMessage` is too technical and exposes unnecessary implementation details (`java.lang.String`, `int` conversion). +- It does not clearly indicate what the user did wrong. + +--- + + +### Database implementation requirements + +- Use Docker containers for database deployment. +- [PostgreSQL](https://hub.docker.com/_/postgres) 16+ is required as the database engine, preferably Alpine-based. +- Each service should have its own dedicated database instance. +- A single Docker Compose file located in the root directory of the project must be used to start both database containers. +- For this module, you can use the [provided compose.yaml](./docker-compose-file/compose.yaml) file in your project. +- The use of migration tools such as Flyway or Liquibase is not allowed. +- Database schema initialization must be fully automated using Hibernate. +- In this module, Hibernate’s `ddl-auto=update` must be used for schema management to simplify development. +- In this module, SQL initialization scripts (e.g., `schema.sql`, `data.sql`) must not be used. + + + + +--- + +### Structure + +Both microservices represent a unified application and (will) use shared files. Please merge them into a single folder (Git repository), using the following folder structure as an example: + +For a Maven-based project: + +``` +maven-project/ +├── resource-service/ +│ ├── src/ +│ └── pom.xml +├── song-service/ +│ ├── src/ +│ └── pom.xml +├── compose.yaml +└── .gitignore +``` + +--- + +For a Gradle-based project: + +``` +gradle-project/ +├── gradle/ +│ ├── wrapper/ +│ │ ├── gradle-wrapper.jar +│ │ ├── gradle-wrapper.properties +├── resource-service/ +│ ├── src/ +│ └── build.gradle +├── song-service/ +│ ├── src/ +│ └── build.gradle +├── gradlew +├── gradlew.bat +├── settings.gradle +├── compose.yaml +└── .gitignore +``` + +> **Notes**: +> - The Gradle project must use the Gradle Wrapper (`gradlew`). +> - Keep `gradlew` only in the root directory. +> - Configure `settings.gradle` to include and link all services. +> - Do not ignore the Gradle Wrapper files; they must be included in the `git` repository. + +--- + +### Postman collection and sample MP3 file for testing + +Please use the [Postman collection](./api-tests/introduction_to_microservices.postman_collection.json) and a [sample MP3 file](./sample-mp3-file/mp3.zip) with the necessary tags for testing the Resource Service and Song Service APIs. This collection will help validate the correct functioning of all features and data validations. Ensure that the test results are compared against the [API response specification](./api-tests/api-response-specification.md) to verify compliance with the expected responses. + +1. In the **Variables** tab of the collection, set the variables `resource_service_url` and `song_service_url` with your ports. Click **Save** to apply. + + + +--- + +2.Send the requests. Ensure you receive the correct responses. + + + +--- + +3. In the **Test Results** tab, verify that all tests for the requests have passed. + + + +--- + +4. If any tests failed, make necessary adjustments to your code to ensure the API functions as expected, without changing anything in the Postman collection itself. + + + +Ensure that your services handle **all** requests accurately and comply with the API specifications outlined in the documentation. + +> **IMPORTANT!** +> - Take screenshots confirming the successful completion of **all** API tests using provided [sample MP3 file](./sample-mp3-file/mp3.zip). +> - Compile screenshots into a **SINGLE PDF or DOCX file**, and place this file in the personal folder provided to you by Avalia. +> - Ensure the response body and test results are visible, as shown in the example below: +> + +--- + +### Adding a Git repository link to your personal folder + +Consider placing a link to your Git repository in your personal folder for the practical task instead of uploading your files or an archive with files. + +In the folder you access through the link provided by Avalia Kicker bot, add a link to your Git repository: +- Click on the "New" button. +- From the dropdown menu, select "Link". +- Paste the URL of the public Git repository with your solution. +- Save the link. The new link will now appear in the folder. + + + +--- + +## Checklist: Before you submit the task + +Before submitting your task, please ensure that you have completed all the required steps: + +✅ **Controllers** +- [ ] Controllers handle only HTTP-related concerns. +- [ ] Validation logic is moved to the service layer or request DTOs with validation annotations. +- [ ] Business logic (e.g., data transformations, string parsing) is in the service layer or mappers, not in controllers. +- [ ] Raw entities are not used for requests or responses; DTOs are used instead. +- [ ] Responses are wrapped in `ResponseEntity` with appropriate HTTP status codes. +- [ ] Specific response types are used (e.g., `ResponseEntity>`, `ResponseEntity`) to maintain API consistency. +- [ ] Controllers do not manually throw or handle exceptions; exceptions are thrown in the service layer and handled globally. +- [ ] Endpoints strictly follow the specified paths without additional prefixes (e.g., `/api/v1/` is not added, etc.). + +✅ **Error handling & validation** +- [ ] Implemented global exception handling with `@RestControllerAdvice`. +- [ ] Used the specified error response format for general and validation errors. +- [ ] Enforced all validation rules for song metadata (e.g., correct year format, duration format, required fields). + +✅ **Database & Docker** +- [ ] Used PostgreSQL 16+ as the database. +- [ ] Ensured each service has its own dedicated database instance. +- [ ] Database schema initialization is fully automated. +- [ ] Used Hibernate’s `ddl-auto=update` for schema management in this module. +- [ ] Did not use migration tools such as Flyway or Liquibase. +- [ ] Did not use SQL initialization scripts (e.g., `schema.sql`, `data.sql`) in this module. +- [ ] Deployed databases in Docker containers using the [provided Docker Compose file](./docker-compose-file/compose.yaml) (`compose.yaml`). +- [ ] The Docker Compose file is located in the root directory and correctly starts both databases. +- [ ] No Dockerfiles are present, as services must run locally (not in Docker). + +✅ **Project structure** +- [ ] Used the correct folder structure. +- [ ] Merged both services into a single Git repository. +- [ ] Created a public Git repository for your project. +- [ ] Excluded IDE-specific configuration files and folders (e.g., `.idea/`, `.vscode/`, `.settings/`, `*.iml`). +- [ ] Ready to place the link to your repository in the personal folder in Avalia. + +✅ **API testing** +- [ ] Ran Postman tests using the provided [collection](./api-tests/introduction_to_microservices.postman_collection.json) and [sample MP3 file](./sample-mp3-file/mp3.zip). +- [ ] Verified that all API tests pass. +- [ ] Checked that all API responses conform to the [API response specification](./api-tests/api-response-specification.md). +- [ ] Took screenshots of test results and compiled them into a single PDF or DOCX file. +- [ ] Ready to place the test result PDF or DOCX file in the personal folder in Avalia. diff --git a/tools/docs/Monitoring.md b/tools/docs/Monitoring.md new file mode 100644 index 0000000..6778de1 --- /dev/null +++ b/tools/docs/Monitoring.md @@ -0,0 +1,73 @@ +# Module 7: Monitoring + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Adding logging](#sub-task-1-adding-logging) +- [Sub-task 2: Monitoring](#sub-task-2-monitoring) +- [Sub-task 3: Tracing](#sub-task-3-tracing) +- [Example](#example) + +## What to do + +This task focuses on enhancing the microservices infrastructure by adding a **Logging system**, setting up **Monitoring**, and implementing **Tracing**. +stack +Elasticsearch + Kibana (add dash board) + +## Sub-task 1: Adding logging + +1. **Update your infrastructure with a data storage solution**: + - Choose and integrate a logging storage system. Possible options include: + - [InfluxDB](https://hub.docker.com/_/influxdb) + - [Prometheus](https://hub.docker.com/r/prom/prometheus) + - [Elasticsearch](https://hub.docker.com/_/elasticsearch) + - Ensure the chosen system is added as a container in your `docker-compose.yml` file. + +2. **Configure your applications to send logs to the new storage**: + - Update the logging configuration in your services to send logs to the newly added storage system. + - The logging can be configured in various ways depending on the chosen system. + +3. **Ensure logs are gathered and persisted**: + - Once configured, logs should be collected from all services and sent to the logging storage system. + - Use appropriate tools or collectors to gather logs from each service and persist them in the storage system. + - Ensure that logs are indexed and can be queried easily in the storage system for future analysis. + + +## Sub-task 2: Monitoring + +1. **Expose a route for a visualization tool**: + - Set up a monitoring tool to visualize metrics from your services. Possible options include: + - [Grafana](https://hub.docker.com/r/grafana/grafana) + - [Kibana](https://hub.docker.com/_/kibana) + - Add the visualization tool as a service in your `docker-compose.yml` file. + - Expose a route in your **API Gateway service** that makes the necessary metrics available to the visualization tool. This typically involves exposing an endpoint like `/actuator/prometheus` for Prometheus or similar for other tools. + - Configure the visualization tool to collect and display metrics from the services. + +2. **Create dashboards for monitoring**: + - Develop dashboards based on key metrics, such as: + - **JVM metrics** (memory usage, garbage collection, thread count, etc.). + - **API Gateway performance** (request count, latency, error rates). + - Use the chosen monitoring tool (Grafana, Kibana) to display these metrics in a meaningful way. + - Configure the tool to refresh the data and ensure that all relevant performance indicators are visible for efficient monitoring. + + +## Sub-task 3: Tracing + +1. **Add and propagate trace ID**: + - Add a **trace ID** header to the request when a file is uploaded. + - Ensure that the trace ID is propagated through all downstream HTTP calls made by the service. This can be done by using thread-local storage or HTTP interceptors in your application. + - Additionally, propagate the trace ID through any message queues by adding it as an attribute to the message when sending it and extracting it when receiving the message. + +2. **Extract trace ID and correlate logs**: + - Once the trace ID is added and propagated across services, extract the trace ID from the request or message in each service. + - Use the trace ID to find and correlate logs in the chosen visualization tool (e.g., Grafana, Kibana, etc.). + - Ensure that logs from all involved services can be found using only the trace ID, enabling full visibility into the path of a request across your microservices architecture. + + +## Example + +To set up **monitoring with Prometheus and Grafana** and **log aggregation using the ELK stack**, you can refer to the following resources for step-by-step guides: + +- Monitoring with Prometheus and Grafana and log aggregation using ELK stack: [Part 1](https://medium.com/nerd-for-tech/creating-spring-boot-microservices-monitoring-with-prometheus-and-grafana-and-log-aggregation-ba4f20496942), [Part 2](https://medium.com/nerd-for-tech/building-spring-boot-microservices-monitoring-with-prometheus-and-grafana-and-log-aggregation-5ed9ca7dda36) + +> **Note:** Ensure that all additional components (InfluxDB, Prometheus, Elasticsearch, Logstash, Grafana, Kibana, etc.) are launched as containers using `docker-compose` files for easy management and orchestration. \ No newline at end of file diff --git a/tools/docs/Security.md b/tools/docs/Security.md new file mode 100644 index 0000000..d1cb32e --- /dev/null +++ b/tools/docs/Security.md @@ -0,0 +1,104 @@ +# Module 8: Security + +STACK use: +spring auth server +grandtype - filter dsl - user db roles +grandtype authcode +optional UI - REACT + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Security](#sub-task-1-security) +- [Sub-task 2: UI Application (Optional)](#sub-task-2-ui-application-optional) +- [Useful links](#useful-links) + +## What to do + +In this module, you need to implement OAuth security for a microservice architecture. You can choose to use an external +authentication provider or create your own authorization server using Spring Authorization Server or Keycloak. Discuss +with the experts the best approach based on your project requirements. + + +## Sub-task 1: Security + +1. **Implement two roles:** + - **Admin**: Full access to all actions (POST, DELETE, GET). + - **User**: Limited to GET requests only. + +2. **Protect API endpoints**: + - Protect **Storage API**'s POST and DELETE methods, allowing access only for **Admin** users. + - Ensure **User** role can only access **GET** requests for retrieving resources, not modifying them. + - Implement security mechanisms using **JWT tokens** to control access to these endpoints. + +3. **Authentication provider**: + Choose and configure an authentication provider: + - Use an **external OAuth2 provider** (e.g., Google, Auth0) or + - Set up your own provider using **Spring Authorization Server** or **Keycloak**. + Ensure the authentication provider supports issuing and validating **JWT tokens** for secure API access. + +4. **JWT token verification**: + Implement a mechanism to validate JWT tokens for both **Admin** and **User** roles. + - Ensure that the system checks if the token is valid, and extracts user roles from the token. + - If necessary, create a custom filter to handle JWT validation before the request reaches your endpoints. + +5. **Access verification**: + - Use **Postman** or another API client tool to test the API: + - Verify that **Admin** users can access POST and DELETE methods. + - Verify that **User** users can only access GET methods. + - Ensure that any unauthorized request (e.g., without a valid JWT) receives the appropriate **401 Unauthorized** + or **403 Forbidden** response. + - Ensure the API behaves as expected when a user tries to access an endpoint they do not have permissions for. + + +### OAuth 2.0 authentication flow + +The diagram below illustrates the OAuth 2.0 authentication flow: + +1. **Client** requests an access token from the **Authorization Server**. +2. The **Authorization Server** responds with an **access token**. +3. The **Client** sends an **API request** with the **access token** to the **Resource Server**. +4. The **Resource Server** validates the token with the **Authorization Server**. +5. If the token is valid, the **Resource Server** handles the request and returns the response. + +OAuth 2.0 Authentication Flow + + +## Sub-task 2: UI Application (Optional) + +1. **Develop a simple UI application**: + - Create a user interface that includes: + - **Storages table**: + - Display a table for both **Users** and **Admins**, listing existing storage entries (e.g., storage type, + bucket name, path). + - Include options for **Admins** to delete entries. + - **Storage form**: + - Allow **Admins** to add new storage entries with fields such as storage type, bucket name, and path. + +2. **Integrate with the Authentication Provider**: + - Connect the UI with an **authentication provider** (e.g., Spring Authorization Server, Keycloak, or another + external provider). + - Add login and logout functionality. Redirect unauthenticated users to the login page. + - Secure communication with the API using **JWT tokens**: + - Include the token in the authorization header for all API requests. + +--- + +## Useful Links + +### **Spring Authorization Server** +- [Spring Security OAuth Authorization Server](https://www.baeldung.com/spring-security-oauth-auth-server) + A tutorial by **Baeldung** that provides a step-by-step guide to setting up your own OAuth 2.0 Authorization Server using Spring Security OAuth, covering server configuration and OAuth2 protocols. + +### **Keycloak samples** +- [A Quick Guide to OAuth2 With Spring Boot And Keycloak](https://www.baeldung.com/spring-boot-keycloak) + A **Baeldung** tutorial explaining how to secure Spring Boot applications using Keycloak, covering OAuth2 and OpenID Connect integration. +- [Secure Spring Boot Application With Keycloak](https://dzone.com/articles/secure-spring-boot-application-with-keycloak) + A guide on **DZone** that demonstrates how to integrate Keycloak into a Spring Boot application for authentication and authorization, securing the app with OAuth2. + +### **General** +- [The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749) + The official **IETF** specification for OAuth 2.0, offering detailed insights into OAuth2 protocols and authorization flows. +- [OAuth2 Boot](https://docs.spring.io/spring-security-oauth2-boot/docs/current/reference/html5/) + A reference guide by **Spring** providing documentation on configuring and integrating OAuth2 with Spring Boot applications. + \ No newline at end of file diff --git a/tools/docs/Service Discovery.md b/tools/docs/Service Discovery.md new file mode 100644 index 0000000..8dc8b53 --- /dev/null +++ b/tools/docs/Service Discovery.md @@ -0,0 +1,65 @@ +# Module 5: Service discovery + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Service Registry](#sub-task-1-service-registry) +- [Sub-task 2: API Gateway](#sub-task-2-api-gateway) +- [Sub-task 3: Configuration Service (Optional)](#sub-task-3-configuration-service-optional) + +## What to do + +In this module, you will enhance the application’s architecture by configuring an **API Gateway** and optionally implementing a centralized **Configuration Service**. This builds upon the **Service Registry (Eureka)** already implemented in the third module of the "Introduction to Microservices" course. The goal is to ensure seamless service discovery, client-side load balancing, and centralized configuration management. Sample implementation: [Spring Cloud Tutorial - Spring Cloud Gateway + Netflix Eureka Example](https://www.javainuse.com/spring/cloud-gateway-eureka). + + + + +## Sub-task 1: Service Registry + +This sub-task builds upon the Service Registry (Eureka) introduced in the third module of the "Introduction to Microservices" course. All foundational implementations, such as service registration and discovery, should already be in place. Ensure that the setup is properly configured and fully operational both locally and in Docker environments. Make the necessary adjustments if any configurations or dependencies require updates to align with the new services being added. + + +## Sub-task 2: API Gateway + +1. **Develop and configure the API Gateway application**: + + - Develop a **Spring Cloud Gateway** application that will serve as a single entry point for all external traffic. + - Configure the API Gateway to run in the local environment, ensuring it works seamlessly with local services. + - Once the local setup is confirmed, add a container for the API Gateway in the Docker Compose setup to handle traffic when running in Docker. + - Define routing rules in the API Gateway to forward requests to the appropriate service. Use service names registered in Eureka to dynamically resolve destinations. + - Ensure the API Gateway is registered with Eureka and can dynamically route traffic based on service discovery, both in the local and Docker environments. + +2. **Handle errors**: + + - Implement global error handling in the API Gateway for scenarios such as service unavailability or undefined routes. + - Return user-friendly error responses to external clients. + +3. **Test API Gateway**: + + - Verify that all external requests are routed through the API Gateway, whether the application is running in Docker or locally. + - Ensure proper load balancing and routing logic are applied for services with multiple instances in Docker environment. + + +## Sub-task 3: Configuration Service (Optional) + +1. **Centralized configuration repository**: + + - Create a Git repository to store all service configuration files in one place. + +2. **Set up configuration service**: + + - Use Spring Cloud Config Server to serve configurations from the Git repository. + - Add the Config Server as a container in Docker Compose and register it with Eureka. + +3. **Configure client services**: + + - Update one or more services to fetch their configurations from the Config Server. + - Use Spring Boot’s `spring-cloud-starter-config` dependency to enable this functionality. + +4. **Enable dynamic refresh**: + + - Implement the Spring Boot Actuator endpoint `/actuator/refresh` to allow runtime configuration refresh without restarting the application. + +5. **Test configuration management**: + + - Change a configuration in the Git repository and verify that it propagates to the client services. diff --git a/tools/docs/State Machine.md b/tools/docs/State Machine.md new file mode 100644 index 0000000..29ec70c --- /dev/null +++ b/tools/docs/State Machine.md @@ -0,0 +1,90 @@ +# Storage State Machine (STAGING ↔ PERMANENT) + +> Module: **Fault Tolerance** · Owner: Resource Service · Status: **Stable** (v1.0) + +This document describes the lifecycle states an MP3 resource can occupy as it flows +through the Storage Service–backed upload pipeline. It is the single source of truth +for the state machine; any new transition MUST update this file along with the +schema and the `StorageType` enum. + +--- + +## 1. States + +| State | Description | Persisted in | Owner | +|-------------|-----------------------------------------------------------------------------------------------------------|--------------------------|------------------| +| `STAGING` | File just uploaded by the client; not yet processed by `resource-processor`. Lives in the staging bucket. | `resources.storage_type` | Resource Service | +| `PERMANENT` | File has been processed; metadata extracted; moved to the permanent bucket for long-term storage. | `resources.storage_type` | Resource Service | + +> Only these two values are currently defined. Adding a third value (e.g. `PROCESSING`) +> requires: (a) a new enum constant, (b) a DB migration, (c) update to the +> `StorageResponse.StubConfig` and stub factory, (d) update of this document. + +--- + +## 2. Transitions + +``` + +-----------+ processedResource +-----------+ + upload --> | STAGING | (RabbitMQ consumer) | PERMANENT | + +-----------+ ----------------------> +-----------+ + \ ^ + \ (file moved S3 STAGING -> | + \ PERMANENT by | + \ ResourceEventConsumer) | + \ | + +---------------------------+ +``` + +| From | Trigger | To | Side effects | +|-------------|---------------------------------------------------|-------------|-----------------------------------------------------------------------------------------------------------------------| +| (none) | `POST /resources` upload success | `STAGING` | Insert `resources` row; put object in `staging-bucket//`; emit `resourceUpload-out-0`. | +| `STAGING` | `processedResource` event from resource-processor | `PERMANENT` | Download from STAGING; upload to PERMANENT; delete from STAGING; update row; idempotent (skips if already PERMANENT). | +| `PERMANENT` | (none, terminal) | `PERMANENT` | Idempotent: repeated `processedResource` events are ignored. | + +--- + +## 3. Fallback (Storage Service unavailable) + +When the Storage Service is unreachable, the circuit breaker opens and the +`StorageServiceClient` returns a `StorageResponse` populated from +`StorageResponse.StubConfig` (see `config-repo/resource-service*.yml`, +`storage.fallback.*` properties). The stubbed response: + +* is marked with `stubData=true` (logged & used for observability); +* contains the same DTO shape as the real response (`{id, storageType, bucket, path}`); +* allows the upload flow to continue uninterrupted so the API stays available + during partial outages. + +The `STAGING → PERMANENT` transition is **not** triggered when the consumer +fetches a stub list — the `ResourceEventConsumer` will treat missing +`PERMANENT` storage as a configuration error and surface the underlying cause. + +--- + +## 4. Guarantees + +* **At-least-once delivery:** the `processedResource` consumer is idempotent + (skips when entity is already `PERMANENT`). +* **Bucket ownership:** STAGING and PERMANENT buckets are owned by the + Storage Service; the Resource Service writes through paths returned by + `StorageServiceClient` only. +* **No silent data loss:** if a move fails mid-way, the original STAGING + object is kept and the row is left in `STAGING`; the next `processedResource` + event will retry the move. + +--- + +## 5. How to extend + +To add a new state (e.g. `PROCESSING`): + +1. Add the constant to `com.audio.resource.entity.StorageType` and to + `com.audio.storage.entity.StorageType` (if mirrored). +2. Add a DB migration for the new enum value. +3. Update `StorageResponse.StubConfig` and the stub factory to include a + default entry for the new state. +4. Update the consumer/producer logic in `ResourceEventConsumer` and + `ResourceEventPublisher`. +5. Update this document and the `fault_tolerance_descriptor.md`. +6. Add at least one integration test for the new transition. diff --git a/tools/docs/Testing.md b/tools/docs/Testing.md new file mode 100644 index 0000000..f8f764a --- /dev/null +++ b/tools/docs/Testing.md @@ -0,0 +1,32 @@ +# Module 3: Testing + +## Table of contents + +- [What to do](#what-to-do) +- [Sub-task 1: Testing strategy](#sub-task-1-testing-strategy) +- [Sub-task 2: Perform different types of testing](#sub-task-2-perform-different-types-of-testing) + +## What to do + +In this module, you need to adjust the services by adding tests. + +## Sub-task 1: Testing strategy + +1. Develop a testing strategy and describe the approach to ensure application stability and testing coverage: + - Unit tests + - Integration tests + - Component tests + - Contract tests + - End-to-end tests + +2. Write a short document explaining the chosen approach and how the combination of strategies will help accomplish the task. For example, clarify if it will involve 100% **unit tests** and **integration tests** or a different combination. + +## Sub-task 2: Perform different types of testing + +1. **Unit tests**: Use JUnit or Spock and select a module that needs testing. +2. **Integration tests**: Use JUnit or Spock and cover integration layers. +3. **Component tests**: Cover component scenarios at a business level, specifying exact scenarios and expected outcomes in natural language, preferably using the Cucumber framework. +4. **Contract tests**: Cover all contracts used in specific scenarios, preferably using [Spring Cloud Contract](https://spring.io/projects/spring-cloud-contract) or Pact. Contract tests should cover both communication styles: synchronous HTTP and messaging, including stubs propagation. +5. **End-to-end tests**: Describe all scenarios in natural language, focusing on API layer coverage. The Cucumber testing framework can be used along with the component tests mentioned above. + +> Note: At least one test should be created and executed for each test type. diff --git a/tools/images/containerization.png b/tools/images/containerization.png new file mode 100644 index 0000000..590fbad Binary files /dev/null and b/tools/images/containerization.png differ diff --git a/tools/images/fault_tolerance.png b/tools/images/fault_tolerance.png new file mode 100644 index 0000000..203f609 Binary files /dev/null and b/tools/images/fault_tolerance.png differ diff --git a/tools/images/fault_tolerance_sequence_diagram.png b/tools/images/fault_tolerance_sequence_diagram.png new file mode 100644 index 0000000..d1de1e7 Binary files /dev/null and b/tools/images/fault_tolerance_sequence_diagram.png differ diff --git a/tools/images/microservice_architecture_overview.png b/tools/images/microservice_architecture_overview.png new file mode 100644 index 0000000..05ae2f4 Binary files /dev/null and b/tools/images/microservice_architecture_overview.png differ diff --git a/tools/images/microservices_communication.png b/tools/images/microservices_communication.png new file mode 100644 index 0000000..2fb6fbf Binary files /dev/null and b/tools/images/microservices_communication.png differ diff --git a/tools/images/postman_01.png b/tools/images/postman_01.png new file mode 100644 index 0000000..10ee437 Binary files /dev/null and b/tools/images/postman_01.png differ diff --git a/tools/images/postman_02.png b/tools/images/postman_02.png new file mode 100644 index 0000000..8fdbc19 Binary files /dev/null and b/tools/images/postman_02.png differ diff --git a/tools/images/postman_03.png b/tools/images/postman_03.png new file mode 100644 index 0000000..37a9bcd Binary files /dev/null and b/tools/images/postman_03.png differ diff --git a/tools/images/postman_04.png b/tools/images/postman_04.png new file mode 100644 index 0000000..5bf41cd Binary files /dev/null and b/tools/images/postman_04.png differ diff --git a/tools/images/postman_05.png b/tools/images/postman_05.png new file mode 100644 index 0000000..972dca0 Binary files /dev/null and b/tools/images/postman_05.png differ diff --git a/tools/images/security_sequence_diagram.png b/tools/images/security_sequence_diagram.png new file mode 100644 index 0000000..47347bb Binary files /dev/null and b/tools/images/security_sequence_diagram.png differ diff --git a/tools/images/service_discovery.png b/tools/images/service_discovery.png new file mode 100644 index 0000000..6277342 Binary files /dev/null and b/tools/images/service_discovery.png differ diff --git a/tools/images/service_discovery_.png b/tools/images/service_discovery_.png new file mode 100644 index 0000000..c39b7bf Binary files /dev/null and b/tools/images/service_discovery_.png differ diff --git a/tools/sample-mp3-file/invalid-sample-with-missed-tags.mp3 b/tools/sample-mp3-file/invalid-sample-with-missed-tags.mp3 new file mode 100644 index 0000000..f9e5410 Binary files /dev/null and b/tools/sample-mp3-file/invalid-sample-with-missed-tags.mp3 differ diff --git a/tools/sample-mp3-file/invalid-sample-with-missed-tags.mp3.png b/tools/sample-mp3-file/invalid-sample-with-missed-tags.mp3.png new file mode 100644 index 0000000..edbe3e2 Binary files /dev/null and b/tools/sample-mp3-file/invalid-sample-with-missed-tags.mp3.png differ diff --git a/tools/sample-mp3-file/valid-sample-with-required-tags.mp3 b/tools/sample-mp3-file/valid-sample-with-required-tags.mp3 new file mode 100644 index 0000000..6b01082 Binary files /dev/null and b/tools/sample-mp3-file/valid-sample-with-required-tags.mp3 differ diff --git a/tools/sample-mp3-file/valid-sample-with-required-tags.mp3.png b/tools/sample-mp3-file/valid-sample-with-required-tags.mp3.png new file mode 100644 index 0000000..7e99f55 Binary files /dev/null and b/tools/sample-mp3-file/valid-sample-with-required-tags.mp3.png differ diff --git a/ui-service/.gitignore b/ui-service/.gitignore new file mode 100644 index 0000000..cccef79 --- /dev/null +++ b/ui-service/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +build/ +package-lock.json \ No newline at end of file diff --git a/ui-service/build.gradle b/ui-service/build.gradle new file mode 100644 index 0000000..a63f1da --- /dev/null +++ b/ui-service/build.gradle @@ -0,0 +1,44 @@ +plugins { + id 'java' + id 'com.github.node-gradle.node' version '7.0.2' +} + +node { + version = '20.11.0' + npmVersion = '10.2.4' + download = true + workDir = file("${project.projectDir}/.gradle/nodejs") + npmWorkDir = file("${project.projectDir}/.gradle/npm") + nodeProjectDir = file(project.projectDir) +} + +tasks.register('buildReactApp', NpmTask) { + dependsOn 'npmInstall' + args = ['run', 'build'] + + inputs.dir("src") + inputs.dir("public") + inputs.file("package.json") + inputs.file("package-lock.json") + + outputs.dir(layout.buildDirectory.dir("resources/main/static")) + + environment = ['BUILD_PATH': layout.buildDirectory.dir("resources/main/static").get().asFile.absolutePath] +} + +tasks.register('startReactApp', NpmTask) { + dependsOn 'npmInstall' + args = ['run', 'start'] +} + +tasks.named('jar') { + dependsOn 'buildReactApp' +} + +tasks.named('build') { + dependsOn 'buildReactApp' +} + +tasks.register('bootRun') { + dependsOn 'startReactApp' +} diff --git a/ui-service/package.json b/ui-service/package.json new file mode 100644 index 0000000..24a9809 --- /dev/null +++ b/ui-service/package.json @@ -0,0 +1,32 @@ +{ + "name": "storages-ui", + "version": "1.0.0", + "private": true, + "dependencies": { + "axios": "^1.6.0", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-router-dom": "^6.20.0", + "react-scripts": "5.0.1", + "jwt-decode": "^4.0.0" + }, + "scripts": { + "start": "react-scripts start", + "build": "react-scripts build", + "test": "react-scripts test", + "eject": "react-scripts eject" + }, + "proxy": "http://localhost:8080", + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} diff --git a/ui-service/public/index.html b/ui-service/public/index.html new file mode 100644 index 0000000..91cb224 --- /dev/null +++ b/ui-service/public/index.html @@ -0,0 +1,11 @@ + + + + + + Storages UI + + +
+ + \ No newline at end of file diff --git a/ui-service/src/App.js b/ui-service/src/App.js new file mode 100644 index 0000000..c5428cf --- /dev/null +++ b/ui-service/src/App.js @@ -0,0 +1,38 @@ +import React from 'react'; +import { isAuthenticated } from './services/authService'; +import Login from './components/Login'; +import Dashboard from './components/Dashboard'; +import ProtectedRoute from './components/ProtectedRoute'; + +function App() { + const path = window.location.pathname; + const authenticated = isAuthenticated(); + + if (path === '/login' && !authenticated) { + return ( + { window.location.href = '/dashboard'; }} /> + ); + } + + if (!authenticated) { + return ( + { window.location.href = '/dashboard'; }} /> + ); + } + + if (path === '/dashboard') { + return ( + + + + ); + } + + return ( + + + + ); +} + +export default App; diff --git a/ui-service/src/components/Dashboard.js b/ui-service/src/components/Dashboard.js new file mode 100644 index 0000000..209de96 --- /dev/null +++ b/ui-service/src/components/Dashboard.js @@ -0,0 +1,32 @@ +import React from 'react'; +import { getUserRoles, decodeToken, logout } from '../services/authService'; +import StoragesTable from './StoragesTable'; + +function Dashboard() { + const decoded = decodeToken(); + const roles = getUserRoles(); + const username = decoded ? decoded.sub : ''; + + const handleLogout = () => { + logout(); + window.location.href = '/login'; + }; + + return ( +
+ +
+
+

User: {username}

+

Roles: {roles.join(', ') || 'None'}

+
+ +
+
+ ); +} + +export default Dashboard; diff --git a/ui-service/src/components/Login.js b/ui-service/src/components/Login.js new file mode 100644 index 0000000..73eecbb --- /dev/null +++ b/ui-service/src/components/Login.js @@ -0,0 +1,55 @@ +import React, { useState } from 'react'; +import { login } from '../services/authService'; + +function Login({ onLoginSuccess }) { + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + const [loading, setLoading] = useState(false); + + const handleSubmit = async (e) => { + e.preventDefault(); + setError(''); + setLoading(true); + try { + await login(username, password); + onLoginSuccess(); + } catch (err) { + setError('Invalid credentials'); + } finally { + setLoading(false); + } + }; + + return ( +
+
+

Storages Login

+
+ setUsername(e.target.value)} + required + /> +
+
+ setPassword(e.target.value)} + required + /> +
+ + {error &&

{error}

} +
+
+ ); +} + +export default Login; \ No newline at end of file diff --git a/ui-service/src/components/ProtectedRoute.js b/ui-service/src/components/ProtectedRoute.js new file mode 100644 index 0000000..5c8dbf9 --- /dev/null +++ b/ui-service/src/components/ProtectedRoute.js @@ -0,0 +1,11 @@ +import { isAuthenticated } from '../services/authService'; + +function ProtectedRoute({ children }) { + if (!isAuthenticated()) { + window.location.href = '/login'; + return null; + } + return children; +} + +export default ProtectedRoute; \ No newline at end of file diff --git a/ui-service/src/components/StoragesTable.js b/ui-service/src/components/StoragesTable.js new file mode 100644 index 0000000..890ff2e --- /dev/null +++ b/ui-service/src/components/StoragesTable.js @@ -0,0 +1,129 @@ +import React, { useState, useEffect } from 'react'; +import axiosInstance from '../services/axiosInstance'; +import { getUserRoles } from '../services/authService'; + +function StoragesTable() { + const [storages, setStorages] = useState([]); + const [userRoles, setUserRoles] = useState([]); + const [newStorage, setNewStorage] = useState({ storageType: '', bucket: '', path: '' }); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(''); + + useEffect(() => { + const roles = getUserRoles(); + setUserRoles(roles); + fetchStorages(); + }, []); + + const fetchStorages = () => { + axiosInstance.get('/storages') + .then(res => { + setStorages(res.data); + setLoading(false); + }) + .catch(err => { + setError('Failed to load storages'); + setLoading(false); + }); + }; + + const handleAddStorage = async (e) => { + e.preventDefault(); + try { + const response = await axiosInstance.post('/storages', newStorage); + setStorages(prev => [...prev, response.data]); + setNewStorage({ storageType: '', bucket: '', path: '' }); + } catch (err) { + setError('Failed to add storage'); + } + }; + + const handleDeleteStorage = async (id) => { + try { + await axiosInstance.delete(`/storages/${id}`); + setStorages(prev => prev.filter(s => s.id !== id)); + } catch (err) { + setError('Failed to delete storage'); + } + }; + + const isAdmin = userRoles.some(r => r === 'ADMIN' || r === 'Admin'); + + if (loading) { + return
Loading storages...
; + } + + return ( +
+ {isAdmin && ( +
+ setNewStorage({ ...newStorage, storageType: e.target.value })} + required + /> + setNewStorage({ ...newStorage, bucket: e.target.value })} + required + /> + setNewStorage({ ...newStorage, path: e.target.value })} + required + /> + +
+ )} + + {error &&

{error}

} + + + + + + + + + {isAdmin && } + + + + {storages.map(storage => ( + + + + + + {isAdmin && ( + + )} + + ))} + {storages.length === 0 && ( + + + + )} + +
IDStorage TypeBucketPathActions
{storage.id}{storage.storageType}{storage.bucket}{storage.path} + +
+ No storages found +
+
+ ); +} + +export default StoragesTable; diff --git a/ui-service/src/index.css b/ui-service/src/index.css new file mode 100644 index 0000000..c0f1201 --- /dev/null +++ b/ui-service/src/index.css @@ -0,0 +1,205 @@ +body { + margin: 0; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, + Ubuntu, Cantarell, 'Fira Sans', 'Droid Sans', 'Helvetica Neue', sans-serif; + background-color: #f5f5f5; +} + +.container { + max-width: 1200px; + margin: 0 auto; + padding: 20px; +} + +.login-container { + display: flex; + justify-content: center; + align-items: center; + min-height: 100vh; +} + +.login-form { + background: white; + padding: 40px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + width: 100%; + max-width: 400px; +} + +.login-form h2 { + margin-bottom: 24px; + text-align: center; + color: #333; +} + +.form-group { + margin-bottom: 16px; +} + +.form-group input { + width: 100%; + padding: 12px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 16px; + box-sizing: border-box; +} + +.form-group input:focus { + outline: none; + border-color: #1976d2; + box-shadow: 0 0 0 2px rgba(25,118,210,0.2); +} + +.btn { + width: 100%; + padding: 12px; + background-color: #1976d2; + color: white; + border: none; + border-radius: 4px; + font-size: 16px; + cursor: pointer; + font-weight: 600; +} + +.btn:hover { + background-color: #1565c0; +} + +.btn:disabled { + background-color: #90caf9; + cursor: not-allowed; +} + +.error-message { + color: #d32f2f; + text-align: center; + margin-top: 12px; + font-size: 14px; +} + +.navbar { + background-color: #1976d2; + padding: 12px 24px; + color: white; + display: flex; + justify-content: space-between; + align-items: center; +} + +.navbar h3 { + margin: 0; +} + +.logout-btn { + background-color: transparent; + color: white; + border: 1px solid white; + padding: 8px 16px; + border-radius: 4px; + cursor: pointer; + font-size: 14px; +} + +.logout-btn:hover { + background-color: rgba(255,255,255,0.1); +} + +.storages-table { + width: 100%; + border-collapse: collapse; + margin-top: 20px; + background: white; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + border-radius: 8px; + overflow: hidden; +} + +.storages-table th { + background-color: #1976d2; + color: white; + padding: 12px 16px; + text-align: left; + font-weight: 600; +} + +.storages-table td { + padding: 12px 16px; + border-bottom: 1px solid #eee; +} + +.storages-table tr:last-child td { + border-bottom: none; +} + +.storages-table tr:hover { + background-color: #f5f5f5; +} + +.add-storage-form { + background: white; + padding: 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + margin-bottom: 24px; + display: flex; + gap: 12px; + flex-wrap: wrap; + align-items: flex-end; +} + +.add-storage-form input { + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 14px; + flex: 1; + min-width: 150px; +} + +.add-storage-form input:focus { + outline: none; + border-color: #1976d2; +} + +.add-storage-form .btn { + width: auto; + padding: 10px 24px; + flex-shrink: 0; +} + +.admin-btn { + background-color: #d32f2f; + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; +} + +.admin-btn:hover { + background-color: #c62828; +} + +.user-info { + background: white; + padding: 16px 20px; + border-radius: 8px; + box-shadow: 0 2px 10px rgba(0,0,0,0.1); + margin-bottom: 24px; +} + +.user-info p { + margin: 4px 0; + color: #555; +} + +.loading { + text-align: center; + color: #777; + padding: 40px; + font-size: 18px; +} \ No newline at end of file diff --git a/ui-service/src/index.js b/ui-service/src/index.js new file mode 100644 index 0000000..1675893 --- /dev/null +++ b/ui-service/src/index.js @@ -0,0 +1,11 @@ +import React from 'react'; +import ReactDOM from 'react-dom/client'; +import './index.css'; +import App from './App'; + +const root = ReactDOM.createRoot(document.getElementById('root')); +root.render( + + + +); \ No newline at end of file diff --git a/ui-service/src/services/authService.js b/ui-service/src/services/authService.js new file mode 100644 index 0000000..76d3d9d --- /dev/null +++ b/ui-service/src/services/authService.js @@ -0,0 +1,68 @@ +import { jwtDecode } from 'jwt-decode'; + +const AUTH_URL = process.env.REACT_APP_AUTH_BASE_URL || 'http://localhost:9000/auth'; + +export function login(username, password) { + const params = new URLSearchParams(); + params.append('grant_type', 'password'); + params.append('username', username); + params.append('password', password); + params.append('client_id', 'gateway'); + + return fetch(`${AUTH_URL}/oauth2/token`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded' + }, + body: params + }).then(response => { + if (!response.ok) { + throw new Error('Invalid credentials'); + } + return response.json(); + }).then(data => { + localStorage.setItem('token', data.access_token); + if (data.refresh_token) { + localStorage.setItem('refresh_token', data.refresh_token); + } + return data; + }); +} + +export function logout() { + localStorage.removeItem('token'); + localStorage.removeItem('refresh_token'); +} + +export function getToken() { + return localStorage.getItem('token'); +} + +export function isAuthenticated() { + const token = getToken(); + if (!token) return false; + try { + const decoded = jwtDecode(token); + const now = Math.floor(Date.now() / 1000); + return decoded.exp > now; + } catch { + return false; + } +} + +export function decodeToken() { + const token = getToken(); + if (!token) return null; + try { + return jwtDecode(token); + } catch { + return null; + } +} + +export function getUserRoles() { + const decoded = decodeToken(); + if (!decoded) return []; + const roles = decoded.roles || decoded.authorities || []; + return roles.map(r => typeof r === 'string' ? r.replace('ROLE_', '') : r); +} diff --git a/ui-service/src/services/axiosInstance.js b/ui-service/src/services/axiosInstance.js new file mode 100644 index 0000000..1d43364 --- /dev/null +++ b/ui-service/src/services/axiosInstance.js @@ -0,0 +1,27 @@ +import axios from 'axios'; +import { getToken, logout } from './authService'; + +const axiosInstance = axios.create({ + baseURL: process.env.REACT_APP_API_BASE_URL || '' +}); + +axiosInstance.interceptors.request.use(config => { + const token = getToken(); + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; +}); + +axiosInstance.interceptors.response.use( + response => response, + error => { + if (error.response && error.response.status === 401) { + logout(); + window.location.href = '/login'; + } + return Promise.reject(error); + } +); + +export default axiosInstance;