Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -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
9 changes: 7 additions & 2 deletions .github/workflows/tests.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
4 changes: 4 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
.env

.DS_Store
*.iml
logs/

.gradle
build/
Expand Down
6 changes: 4 additions & 2 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand All @@ -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'
Expand Down
2 changes: 1 addition & 1 deletion gradle/wrapper/gradle-wrapper.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ConfigurableApplicationContext> {

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<String, Object> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -162,13 +163,13 @@ public ResponseEntity<Page<Airport>> 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<Page<String>> listDirectConnections(
public ResponseEntity<Slice<String>> listDirectConnections(
@RequestParam(required = true) String airportCode,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
try {
Page<Route> airports = airportService.getDirectConnections(airportCode, PageRequest.of(page, size));
Page<String> directConnections = airports.map(Route::getDestinationAirport);
Slice<Route> airports = airportService.getDirectConnections(airportCode, PageRequest.of(page, size));
Slice<String> directConnections = airports.map(Route::getDestinationAirport);
return new ResponseEntity<>(directConnections, HttpStatus.OK);

} catch (Exception e) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<T> extends SliceImpl<T> {
@JsonCreator(mode = JsonCreator.Mode.PROPERTIES)
public RestResponseSlice(@JsonProperty("content") List<T> 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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -22,10 +23,10 @@ public interface AirportRepository extends CouchbaseRepository<Airport, String>
@Query("SELECT META(airport).id as __id,airport.* FROM airport")
Page<Airport> 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<Route> getDirectConnections(String targetAirportCode, Pageable pageable);
Slice<Route> getDirectConnections(String targetAirportCode, Pageable pageable);

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -43,7 +44,7 @@ public Airport updateAirport(String id, Airport airport) {
return airportRepository.save(airport);
}

public Page<Route> getDirectConnections(String id, Pageable pageable) {
public Slice<Route> getDirectConnections(String id, Pageable pageable) {
return airportRepository.getDirectConnections(id, pageable);
}

Expand Down
1 change: 1 addition & 0 deletions src/main/resources/META-INF/spring.factories
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
org.springframework.context.ApplicationContextInitializer=org.couchbase.quickstart.springdata.config.DotEnvConfiguration
21 changes: 14 additions & 7 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
@@ -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

# 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
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
}
}

Expand Down Expand Up @@ -168,14 +170,14 @@ void testListAirports() {
@Test
void testListDirectConnections() {
String airportCode = "LAX";
ResponseEntity<RestResponsePage<String>> response = restTemplate.exchange(
ResponseEntity<RestResponseSlice<String>> response = restTemplate.exchange(
"/api/v1/airport/direct-connections?airportCode=" + airportCode + "&page=0&size=10",
HttpMethod.GET, null, new ParameterizedTypeReference<RestResponsePage<String>>() {
HttpMethod.GET, null, new ParameterizedTypeReference<RestResponseSlice<String>>() {
});

assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

RestResponsePage<String> directConnections = response.getBody();
RestResponseSlice<String> directConnections = response.getBody();

assertThat(directConnections).isNotNull().hasSize(10);
assertThat(directConnections).contains("NRT", "CUN", "GDL", "HMO", "MEX", "MZT", "PVR", "SJD", "ZIH",
Expand All @@ -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<RestResponsePage<String>>() {
HttpMethod.GET, null, new ParameterizedTypeReference<RestResponseSlice<String>>() {
});
assertThat(response.getStatusCode()).isEqualTo(HttpStatus.OK);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
48 changes: 48 additions & 0 deletions src/test/resources/logback-test.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<!-- Console appender for test output -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>

<!-- Set root logger to INFO level (reduces noise) -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>

<!-- Suppress noisy Spring Framework warnings during tests -->
<logger name="org.springframework.context.support.AbstractApplicationContext" level="ERROR"/>
<logger name="org.springframework.beans.factory.support.DefaultListableBeanFactory" level="ERROR"/>
<logger name="org.springframework.boot.autoconfigure.AutoConfigurationPackages" level="ERROR"/>
<logger name="org.springframework.data.repository.config.RepositoryConfigurationDelegate" level="ERROR"/>

<!-- Suppress Couchbase client noise -->
<logger name="com.couchbase" level="WARN"/>
<logger name="com.couchbase.tracing" level="ERROR"/>
<logger name="com.couchbase.client.core.compression.snappy.SnappyHelper" level="ERROR"/>

<!-- Suppress Spring Data pagination warning -->
<logger name="org.springframework.data.web.config.PageableHandlerMethodArgumentResolverCustomizer" level="ERROR"/>
<logger name="org.springframework.data.web.config.HateoasAwareSpringDataWebConfiguration" level="ERROR"/>

<!-- Suppress Jakarta persistence warnings -->
<logger name="jakarta.persistence.spi" level="ERROR"/>

<!-- Suppress Spring Boot DevTools noise -->
<logger name="org.springframework.boot.devtools" level="ERROR"/>

<!-- Suppress Tomcat initialization noise in tests -->
<logger name="org.apache.catalina" level="WARN"/>
<logger name="org.springframework.boot.web.embedded.tomcat" level="WARN"/>

<!-- Keep our application logs at appropriate levels -->
<logger name="org.couchbase.quickstart.springdata" level="INFO"/>

<!-- Show test-related logs for debugging when needed -->
<logger name="org.couchbase.quickstart.springdata.controllers" level="WARN"/>

<!-- Suppress JVM warnings -->
<logger name="jdk.internal" level="ERROR"/>
</configuration>