diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..6f9b3a5 --- /dev/null +++ b/.env.example @@ -0,0 +1,12 @@ +# Copy this file to .env and update with your actual values +# DO NOT commit .env file to git - it's already in .gitignore +# +# These environment variables are used for database connection +# and correspond to the Spring Boot 3.5+ Couchbase properties: +# - spring.couchbase.connection-string +# - spring.couchbase.username +# - spring.couchbase.password + +DB_CONN_STR=couchbases://your-cluster-url.cloud.couchbase.com +DB_USERNAME=your-username +DB_PASSWORD=your-password \ No newline at end of file diff --git a/.github/workflows/tests.yaml b/.github/workflows/tests.yaml index ad359cd..f58297c 100644 --- a/.github/workflows/tests.yaml +++ b/.github/workflows/tests.yaml @@ -31,14 +31,19 @@ jobs: uses: actions/setup-java@v4 with: java-version: ${{ matrix.java-version }} - distribution: "adopt" + distribution: "temurin" cache: "gradle" + - name: Setup Gradle + uses: gradle/actions/setup-gradle@v4 + with: + gradle-home-cache-cleanup: true + - name: Run Gradle Tests id: run run: | chmod +x gradlew - ./gradlew clean test --info --stacktrace + ./gradlew clean test --info --stacktrace --configuration-cache - name: Report Status if: always() diff --git a/.gitignore b/.gitignore index ff5af65..a39d3af 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ +.env + +.DS_Store *.iml +logs/ .gradle build/ diff --git a/build.gradle b/build.gradle index 62cd758..6619ee5 100644 --- a/build.gradle +++ b/build.gradle @@ -6,7 +6,7 @@ plugins { group = 'org.couchbase.quickstart.springdata' version = '0.0.1-SNAPSHOT' -archivesBaseName = 'java-springdata-quickstart' +base.archivesName = 'java-springdata-quickstart' repositories { mavenCentral() @@ -24,8 +24,10 @@ dependencies { implementation 'org.springframework.data:spring-data-couchbase' implementation 'org.springframework.boot:spring-boot-devtools' - implementation 'jakarta.persistence:jakarta.persistence-api' implementation 'jakarta.servlet:jakarta.servlet-api' + + // Environment variable loading from .env file + implementation 'io.github.cdimascio:dotenv-java:3.0.2' // lombok compileOnly 'org.projectlombok:lombok' diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index a80b22c..2a84e18 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.6-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java b/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java index 9048297..6ef1516 100644 --- a/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java +++ b/src/main/java/org/couchbase/quickstart/springdata/config/CouchbaseConfiguration.java @@ -18,17 +18,17 @@ @EnableCouchbaseRepositories public class CouchbaseConfiguration extends AbstractCouchbaseConfiguration { - @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.bootstrap-hosts:localhost}'}") + @Value("#{systemEnvironment['DB_CONN_STR'] ?: '${spring.couchbase.connection-string:localhost}'}") private String host; - @Value("#{systemEnvironment['DB_USERNAME'] ?: '${spring.couchbase.bucket.user:Administrator}'}") + @Value("#{systemEnvironment['DB_USERNAME'] ?: '${spring.couchbase.username:Administrator}'}") private String username; - @Value("#{systemEnvironment['DB_PASSWORD'] ?: '${spring.couchbase.bucket.password:password}'}") + @Value("#{systemEnvironment['DB_PASSWORD'] ?: '${spring.couchbase.password:password}'}") private String password; - @Value("${spring.couchbase.bucket.name:travel-sample}") - private String bucketName; + // Since bucket auto-configuration is removed, we'll hardcode the travel-sample bucket name + private String bucketName = "travel-sample"; @Override public String getConnectionString() { diff --git a/src/main/java/org/couchbase/quickstart/springdata/config/DotEnvConfiguration.java b/src/main/java/org/couchbase/quickstart/springdata/config/DotEnvConfiguration.java new file mode 100644 index 0000000..2ca650d --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/config/DotEnvConfiguration.java @@ -0,0 +1,52 @@ +package org.couchbase.quickstart.springdata.config; + +import io.github.cdimascio.dotenv.Dotenv; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.context.ApplicationContextInitializer; +import org.springframework.context.ConfigurableApplicationContext; +import org.springframework.core.env.ConfigurableEnvironment; +import org.springframework.core.env.MapPropertySource; + +import java.util.HashMap; +import java.util.Map; + +public class DotEnvConfiguration implements ApplicationContextInitializer { + + private static final Logger log = LoggerFactory.getLogger(DotEnvConfiguration.class); + + @Override + public void initialize(ConfigurableApplicationContext applicationContext) { + ConfigurableEnvironment environment = applicationContext.getEnvironment(); + + try { + // Load .env file if it exists + Dotenv dotenv = Dotenv.configure() + .directory(".") + .ignoreIfMalformed() + .ignoreIfMissing() + .load(); + + // Create a property source from .env entries + Map envMap = new HashMap<>(); + dotenv.entries().forEach(entry -> { + String key = entry.getKey(); + String value = entry.getValue(); + + // Only add if not already set by system environment + if (System.getenv(key) == null) { + envMap.put(key, value); + log.debug("Loaded from .env: {}", key); + } + }); + + if (!envMap.isEmpty()) { + environment.getPropertySources().addFirst(new MapPropertySource("dotenv", envMap)); + log.info("Environment variables loaded from .env file: {}", envMap.keySet()); + } + + } catch (Exception e) { + log.error("Could not load .env file", e); + } + } +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java b/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java index 7c2c9c7..be84f2a 100644 --- a/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java +++ b/src/main/java/org/couchbase/quickstart/springdata/controller/AirportController.java @@ -8,6 +8,7 @@ import org.springframework.dao.DataRetrievalFailureException; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Slice; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; @@ -162,13 +163,13 @@ public ResponseEntity> listAirports(@RequestParam(defaultValue = " @ApiResponse(responseCode = "500", description = "Internal server error") }) @Parameter(name = "airportCode", description = "The airport code to list direct connections", required = true, example = "SFO") - public ResponseEntity> listDirectConnections( + public ResponseEntity> listDirectConnections( @RequestParam(required = true) String airportCode, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "10") int size) { try { - Page airports = airportService.getDirectConnections(airportCode, PageRequest.of(page, size)); - Page directConnections = airports.map(Route::getDestinationAirport); + Slice airports = airportService.getDirectConnections(airportCode, PageRequest.of(page, size)); + Slice directConnections = airports.map(Route::getDestinationAirport); return new ResponseEntity<>(directConnections, HttpStatus.OK); } catch (Exception e) { diff --git a/src/main/java/org/couchbase/quickstart/springdata/models/RestResponseSlice.java b/src/main/java/org/couchbase/quickstart/springdata/models/RestResponseSlice.java new file mode 100644 index 0000000..6068798 --- /dev/null +++ b/src/main/java/org/couchbase/quickstart/springdata/models/RestResponseSlice.java @@ -0,0 +1,31 @@ +package org.couchbase.quickstart.springdata.models; + +import java.util.List; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.SliceImpl; + +import com.fasterxml.jackson.annotation.JsonCreator; +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; + +@JsonIgnoreProperties(ignoreUnknown = true) +public class RestResponseSlice extends SliceImpl { + @JsonCreator(mode = JsonCreator.Mode.PROPERTIES) + public RestResponseSlice(@JsonProperty("content") List content, + @JsonProperty("number") int number, + @JsonProperty("size") int size, + @JsonProperty("pageable") JsonNode pageable, + @JsonProperty("last") boolean last, + @JsonProperty("sort") JsonNode sort, + @JsonProperty("first") boolean first, + @JsonProperty("numberOfElements") int numberOfElements, + @JsonProperty("hasNext") boolean hasNext) { + + // Calculate hasNext from the available information + // For Slice, hasNext is typically determined by whether we have more content + super(content, PageRequest.of(number, size), hasNext); + } + +} \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java b/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java index b552463..d871e9e 100644 --- a/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java +++ b/src/main/java/org/couchbase/quickstart/springdata/repository/AirportRepository.java @@ -9,6 +9,7 @@ import org.springframework.data.couchbase.repository.Scope; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Repository; import com.couchbase.client.java.query.QueryScanConsistency; @@ -22,10 +23,10 @@ public interface AirportRepository extends CouchbaseRepository @Query("SELECT META(airport).id as __id,airport.* FROM airport") Page findAll(Pageable pageable); - @Query("SELECT DISTINCT META(route).id as __id,route.* " + + @Query("SELECT META(route).id as __id,route.* " + "FROM airport as airport " + "JOIN route as route ON airport.faa = route.sourceairport " + "WHERE airport.faa = $1 AND route.stops = 0") - Page getDirectConnections(String targetAirportCode, Pageable pageable); + Slice getDirectConnections(String targetAirportCode, Pageable pageable); } \ No newline at end of file diff --git a/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java b/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java index a00482c..aba4d52 100644 --- a/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java +++ b/src/main/java/org/couchbase/quickstart/springdata/services/AirportService.java @@ -7,6 +7,7 @@ import org.couchbase.quickstart.springdata.repository.AirportRepository; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @Service @@ -43,7 +44,7 @@ public Airport updateAirport(String id, Airport airport) { return airportRepository.save(airport); } - public Page getDirectConnections(String id, Pageable pageable) { + public Slice getDirectConnections(String id, Pageable pageable) { return airportRepository.getDirectConnections(id, pageable); } diff --git a/src/main/resources/META-INF/spring.factories b/src/main/resources/META-INF/spring.factories new file mode 100644 index 0000000..9820932 --- /dev/null +++ b/src/main/resources/META-INF/spring.factories @@ -0,0 +1 @@ +org.springframework.context.ApplicationContextInitializer=org.couchbase.quickstart.springdata.config.DotEnvConfiguration \ No newline at end of file diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index 62c63db..f54c49d 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -1,8 +1,15 @@ -server.use-forward-headers=true +# Server configuration server.forward-headers-strategy=framework -spring.couchbase.bootstrap-hosts=DB_CONN_STR -spring.couchbase.bucket.name=travel-sample -spring.couchbase.bucket.user=DB_USERNAME -spring.couchbase.bucket.password=DB_PASSWORD -spring.couchbase.scope.name=inventory -spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER \ No newline at end of file + +# Modern Couchbase configuration (Spring Boot 3.5+) +spring.couchbase.connection-string=${DB_CONN_STR} +spring.couchbase.username=${DB_USERNAME} +spring.couchbase.password=${DB_PASSWORD} + +# Couchbase connection and query optimizations +spring.couchbase.env.timeouts.query=30000ms +spring.couchbase.env.timeouts.key-value=5000ms +spring.couchbase.env.timeouts.connect=10000ms + +# Spring MVC configuration +spring.mvc.pathmatch.matching-strategy=ANT_PATH_MATCHER diff --git a/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java index 985ee67..0ad1e2e 100644 --- a/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java +++ b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirlineIntegrationTest.java @@ -47,7 +47,8 @@ private void deleteAirline(String baseUri, String airlineId) { } catch (DocumentNotFoundException | DataRetrievalFailureException | ResourceAccessException e) { log.warn("Document " + airlineId + " not present prior to test"); } catch (Exception e) { - log.error("Error deleting test data", e.getMessage()); + log.debug("Cleanup: Could not delete test airline {}: {} (this is expected during test cleanup)", airlineId, e.getMessage()); + // Continue with cleanup even if one deletion fails } } diff --git a/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java index 901c739..a9227ec 100644 --- a/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java +++ b/src/test/java/org/couchbase/quickstart/springdata/controllers/AirportIntegrationTest.java @@ -5,6 +5,7 @@ import org.couchbase.quickstart.springdata.models.Airport; import org.couchbase.quickstart.springdata.models.Airport.Geo; import org.couchbase.quickstart.springdata.models.RestResponsePage; +import org.couchbase.quickstart.springdata.models.RestResponseSlice; import org.couchbase.quickstart.springdata.services.AirportService; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; @@ -46,9 +47,10 @@ private void deleteAirport(String baseUri, String airportId) { restTemplate.delete(baseUri + "/api/v1/airport/" + airportId); } } catch (DocumentNotFoundException | DataRetrievalFailureException | ResourceAccessException e) { - log.warn("Document " + airportId + " not present prior to test"); + log.warn("Document {} not present prior to test", airportId); } catch (Exception e) { - log.error("Error deleting test data", e.getMessage()); + log.debug("Cleanup: Could not delete test airport {}: {} (this is expected during test cleanup)", airportId, e.getMessage()); + // Continue with cleanup even if one deletion fails } } @@ -168,14 +170,14 @@ void testListAirports() { @Test void testListDirectConnections() { String airportCode = "LAX"; - ResponseEntity> response = restTemplate.exchange( + ResponseEntity> response = restTemplate.exchange( "/api/v1/airport/direct-connections?airportCode=" + airportCode + "&page=0&size=10", - HttpMethod.GET, null, new ParameterizedTypeReference>() { + HttpMethod.GET, null, new ParameterizedTypeReference>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); - RestResponsePage directConnections = response.getBody(); + RestResponseSlice directConnections = response.getBody(); assertThat(directConnections).isNotNull().hasSize(10); assertThat(directConnections).contains("NRT", "CUN", "GDL", "HMO", "MEX", "MZT", "PVR", "SJD", "ZIH", @@ -184,7 +186,7 @@ void testListDirectConnections() { airportCode = "JFK"; response = restTemplate.exchange( "/api/v1/airport/direct-connections?airportCode=" + airportCode + "&page=0&size=10", - HttpMethod.GET, null, new ParameterizedTypeReference>() { + HttpMethod.GET, null, new ParameterizedTypeReference>() { }); assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK); diff --git a/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java b/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java index a5cf34c..cdd9fcb 100644 --- a/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java +++ b/src/test/java/org/couchbase/quickstart/springdata/controllers/RouteIntegrationTest.java @@ -49,7 +49,8 @@ private void deleteRoute(String baseUri, String routeId) { } catch (DocumentNotFoundException | DataRetrievalFailureException | ResourceAccessException e) { log.warn("Document " + routeId + " not present prior to test"); } catch (Exception e) { - log.error("Error deleting test data", e.getMessage()); + log.debug("Cleanup: Could not delete test route {}: {} (this is expected during test cleanup)", routeId, e.getMessage()); + // Continue with cleanup even if one deletion fails } } diff --git a/src/test/resources/logback-test.xml b/src/test/resources/logback-test.xml new file mode 100644 index 0000000..079dd21 --- /dev/null +++ b/src/test/resources/logback-test.xml @@ -0,0 +1,48 @@ + + + + + + %d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file