Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 4 additions & 1 deletion 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 @@ -26,6 +26,9 @@ dependencies {

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-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,48 @@
package org.couchbase.quickstart.springdata.config;

import io.github.cdimascio.dotenv.Dotenv;
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> {

@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);
System.out.println("Loaded from .env: " + key);
}
});

if (!envMap.isEmpty()) {
environment.getPropertySources().addFirst(new MapPropertySource("dotenv", envMap));
System.out.println("Environment variables loaded from .env file: " + envMap.keySet());
}

} catch (Exception e) {
System.err.println("Could not load .env file: " + e.getMessage());
}
}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

For better logging practices within a Spring application, it's recommended to use a proper logging framework like SLF4J instead of System.out.println and System.err.println. This provides more control over log levels and output formats.

Additionally, when catching exceptions, logging the full stack trace (e) is more informative for debugging than just logging the message (e.getMessage()).

To apply this suggestion, you'll also need to add a logger field to the class and the necessary imports:

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

// ...

public class DotEnvConfiguration implements ApplicationContextInitializer<ConfigurableApplicationContext> {
    private static final Logger log = LoggerFactory.getLogger(DotEnvConfiguration.class);
    // ...
}
    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,27 @@
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.JsonProperty;
import com.fasterxml.jackson.databind.JsonNode;

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) {

super(content, PageRequest.of(number, size), hasNext);
}

}

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The constructor currently accepts several properties that are part of a Page but are not used by Slice (pageable, last, sort, first, numberOfElements). This can be simplified by using @JsonIgnoreProperties(ignoreUnknown = true) on the class. This annotation tells Jackson to ignore any properties in the JSON that are not defined in the class, making the constructor cleaner and more focused on the properties that are actually needed for a Slice.

You will need to add the following import:
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;

@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("hasNext") boolean hasNext) {

        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.error("Error deleting test data for airline {}: {}", 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.error("Error deleting test data for airport {}: {}", 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.error("Error deleting test data for route {}: {}", routeId, e.getMessage());
// Continue with cleanup even if one deletion fails
}
}

Expand Down
Loading