From 552acd65e7c9a65a24ffb92f5ad1c21ef1fbfbc2 Mon Sep 17 00:00:00 2001 From: Darren Ackers Date: Wed, 22 Jan 2025 17:22:08 +0000 Subject: [PATCH 1/8] docs(memorystore): added valkey caching demo application --- memorystore/valkey/caching/demo/README.md | 120 +++++++++ .../valkey/caching/demo/app/.gitignore | 92 +++++++ .../valkey/caching/demo/app/Dockerfile | 24 ++ .../caching/demo/app/docker-compose.yaml | 37 +++ memorystore/valkey/caching/demo/app/init.sql | 6 + memorystore/valkey/caching/demo/app/pom.xml | 142 ++++++++++ .../app/src/main/java/app/DataController.java | 97 +++++++ .../app/src/main/java/app/HomeController.java | 19 ++ .../demo/app/src/main/java/app/Item.java | 87 ++++++ .../app/src/main/java/app/ItemController.java | 74 ++++++ .../src/main/java/app/ItemsRepository.java | 110 ++++++++ .../app/src/main/java/app/JdbcConfig.java | 49 ++++ .../app/src/main/java/app/JedisConfig.java | 55 ++++ .../demo/app/src/main/java/app/Main.java | 16 ++ .../demo/app/src/main/java/app/WebConfig.java | 39 +++ .../app/src/main/resources/static/script.js | 242 +++++++++++++++++ .../src/main/resources/templates/index.html | 126 +++++++++ .../src/test/java/app/DataControllerTest.java | 247 ++++++++++++++++++ .../src/test/java/app/ItemControllerTest.java | 180 +++++++++++++ .../caching/demo/sample-data/.gitignore | 84 ++++++ .../caching/demo/sample-data/Dockerfile | 24 ++ .../demo/sample-data/docker-compose.yaml | 52 ++++ .../valkey/caching/demo/sample-data/pom.xml | 80 ++++++ .../sample-data/src/main/java/app/Main.java | 84 ++++++ 24 files changed, 2086 insertions(+) create mode 100644 memorystore/valkey/caching/demo/README.md create mode 100644 memorystore/valkey/caching/demo/app/.gitignore create mode 100644 memorystore/valkey/caching/demo/app/Dockerfile create mode 100644 memorystore/valkey/caching/demo/app/docker-compose.yaml create mode 100644 memorystore/valkey/caching/demo/app/init.sql create mode 100644 memorystore/valkey/caching/demo/app/pom.xml create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/Item.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/Main.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java create mode 100644 memorystore/valkey/caching/demo/app/src/main/resources/static/script.js create mode 100644 memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html create mode 100644 memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java create mode 100644 memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java create mode 100644 memorystore/valkey/caching/demo/sample-data/.gitignore create mode 100644 memorystore/valkey/caching/demo/sample-data/Dockerfile create mode 100644 memorystore/valkey/caching/demo/sample-data/docker-compose.yaml create mode 100644 memorystore/valkey/caching/demo/sample-data/pom.xml create mode 100644 memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java diff --git a/memorystore/valkey/caching/demo/README.md b/memorystore/valkey/caching/demo/README.md new file mode 100644 index 00000000000..5f9b752e4f8 --- /dev/null +++ b/memorystore/valkey/caching/demo/README.md @@ -0,0 +1,120 @@ +# Caching Demo Application + +This demo shows how to use Valkey as an in-memory cache to accelerate data retrieval in a Spring Boot application. By storing hot data in Valkey, you can reduce the number of queries to your PostgreSQL database, improving performance and scalability. + +## Running locally + +## Run locally using Docker + +You can use [docker compose](https://docs.docker.com/compose/install/) to run the application locally. Run the following: + +```bash +cd api +docker-compose up --build +``` + +You can also run with sample data. Run the following: + +```bash +cd sample-data +docker-compose up --build +``` + +You can also run with a web application. Run the following: + +```bash +cd web +docker-compose up --build +``` + +Once the application is running, you can access it at [http://localhost:8080](http://localhost:8080) for local development, or the URL provided by Cloud Run for production. + +## How to deploy the application to Google Cloud (Solo API) + +1. You can use [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli) to deploy the infrastructure to Google Cloud. Run the following: + + ```bash + cd api + terraform init + terraform apply + ``` + + It should fail to ceate the Google Cloud Run service, but don't worry, we'll fix that in the next series of steps. The message you get might look like this: + + ```bash + Error waiting to create Service: Error waiting for Creating Service: Error code 13, message: Revision 'caching-app-service-00001-9zj' is not ready and cannot serve traffic. Image 'gcr.io/cloudannot serve traffic. Image 'gcr.io/cloud-memorystore-demos/caching-app:latest' not found. + ``` + +2. Once the infrastructure is created, you'll need to run the `init.sql` script in the Cloud SQL instance to create the necessary tables. You can use the Cloud Shell to do this. Run the following command in the Cloud Shell: + + ```bash + gcloud sql connect --database=caching-app-db --user=admin # The admin and database were created in the Terraform script + ``` + + Note: Ensure that the instance name is the same as the one you used in the Terraform script. + + a. When prompted to enable the Cloud SQL Admin API, type `Y` and press `Enter`. + b. When prompted to enter the password, type the password you set in the Terraform script and press `Enter`. + c. Once you're connected to the Cloud SQL instance, run the following command to run the `init.sql` script: + + ```sql + \i init.sql + ``` + +3. Finally, redeploy the Cloud Run service using the local source code. Run the following command: + + ```bash + gcloud run deploy \ + --source=. \ + --region= + ``` + + Note: Ensure that the instance name and region are the same as the ones you used in the Terraform script. + +Now you should have the application running on Google Cloud. + +## How to deploy the application to Google Cloud (Web App + API) + +1. Push your Docker images for the Web App and API to Google Container Registry. Run the following: + + ```bash + cd web + docker build -t gcr.io//caching-app-web . + docker push gcr.io//caching-app-web + + cd ../api + docker build -t gcr.io//caching-app-api . + docker push gcr.io//caching-app-api + ``` + +2. You can use [Terraform](https://learn.hashicorp.com/tutorials/terraform/install-cli) to deploy the infrastructure to Google Cloud. Run the following: + + ```bash + cd web + terraform init + terraform apply + ``` + +3. Once the infrastructure is created, you'll need to run the `init.sql` script in the Cloud SQL instance to create the necessary tables. You can use the Cloud Shell to do this. Run the following command in the Cloud Shell: + + ```bash + gcloud sql connect --database=caching-app-db --user=admin # The admin and database were created in the Terraform script + ``` + + Note: Ensure that the instance name is the same as the one you used in the Terraform script. + + a. When prompted to enable the Cloud SQL Admin API, type `Y` and press `Enter`. + b. When prompted to enter the password, type the password you set in the Terraform script and press `Enter`. + c. Once you're connected to the Cloud SQL instance, run the following command to run the `init.sql` script: + + ```sql + \i init.sql + ``` + +Now you should have the application running on Google Cloud. + +### Endpoints + +- `GET /item/{id}`: Get an item by ID +- `POST /item/create`: Create a new item +- `DELETE /item/delete/{id}`: Delete an item by ID diff --git a/memorystore/valkey/caching/demo/app/.gitignore b/memorystore/valkey/caching/demo/app/.gitignore new file mode 100644 index 00000000000..91618798d77 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/.gitignore @@ -0,0 +1,92 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store + +############################## +## Terraform +############################## +.terraform/ +.terraform.lock.hcl +terraform.tfstate +terraform.tfstate.backup \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/Dockerfile b/memorystore/valkey/caching/demo/app/Dockerfile new file mode 100644 index 00000000000..131e16d0504 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/Dockerfile @@ -0,0 +1,24 @@ +# Use an OpenJDK base image +FROM openjdk:17-jdk-slim + +# Install Maven for building the project +RUN apt-get update && apt-get install -y maven + +# Set the working directory +WORKDIR /app + +# Copy Maven project files +COPY pom.xml ./ +COPY src ./src + +# Build the project +RUN mvn clean package -DskipTests + +# Copy the built JAR file to the container +RUN cp target/app-1.0-SNAPSHOT.jar app.jar + +# Expose the application port +EXPOSE 8080 + +# Run the application +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/docker-compose.yaml b/memorystore/valkey/caching/demo/app/docker-compose.yaml new file mode 100644 index 00000000000..bb9cf8b014a --- /dev/null +++ b/memorystore/valkey/caching/demo/app/docker-compose.yaml @@ -0,0 +1,37 @@ +name: caching-app + +services: + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + + postgres: + image: postgres:latest + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + volumes: + - ./init.sql:/docker-entrypoint-initdb.d/init.sql + + app: + build: + context: . + dockerfile: Dockerfile + container_name: app + ports: + - "8080:8080" + depends_on: + - postgres + - valkey + environment: + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password diff --git a/memorystore/valkey/caching/demo/app/init.sql b/memorystore/valkey/caching/demo/app/init.sql new file mode 100644 index 00000000000..e6bb11589a7 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/init.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS items ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL, + description TEXT NOT NULL, + price DOUBLE PRECISION NOT NULL +); \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/pom.xml b/memorystore/valkey/caching/demo/app/pom.xml new file mode 100644 index 00000000000..24c1ead04d1 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/pom.xml @@ -0,0 +1,142 @@ + + + 4.0.0 + + org.example + app + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-web + 3.3.6 + + + + + org.slf4j + slf4j-api + 2.0.16 + + + org.slf4j + slf4j-simple + 2.0.16 + + + + + redis.clients + jedis + 4.3.0 + + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.3.6 + + + + + org.springframework.boot + spring-boot-starter-thymeleaf + 3.3.6 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + + org.springframework.boot + spring-boot-starter-test + 3.3.6 + test + + + + + jakarta.validation + jakarta.validation-api + 3.0.2 + + + org.hibernate.validator + hibernate-validator + 8.0.1.Final + + + + + + + + + org.springframework.boot + spring-boot-maven-plugin + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + -parameters + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + true + + + app.Main + + + + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M8 + + + + \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java new file mode 100644 index 00000000000..b560d135690 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java @@ -0,0 +1,97 @@ +/** + * Responsible for handling the data operations. + * Handles checking cache first, then database, and updating the cache. + */ + +package app; + +import java.util.List; +import java.util.Optional; +import org.springframework.stereotype.Controller; +import redis.clients.jedis.Jedis; + +@Controller +public class DataController { + + // Default TTL for cached data is 60 seconds (1 minute) + public static final Long DEFAULT_TTL = 60L; + + private final ItemsRepository itemsRepository; + private final Jedis jedis; + + public DataController(ItemsRepository cacheRepository, Jedis jedis) { + this.itemsRepository = cacheRepository; + this.jedis = jedis; + } + + public Item get(long id) { + String idString = Long.toString(id); + + // Check if the data exists in the cache first + if (jedis.exists(idString)) { + // If the data exists in the cache extend the TTL + jedis.expire(idString, DEFAULT_TTL); + + // Return the cached data + Item cachedItem = Item.fromJSONString(jedis.get(idString)); + cachedItem.setFromCache(true); + return cachedItem; + } + + Optional item = itemsRepository.get(id); + + if (item.isEmpty()) { + // If the data doesn't exist in the database, return null + return null; + } + + // Cache result from the database with the default TTL + jedis.set(idString, item.get().toJSONObject().toString()); + jedis.expire(idString, DEFAULT_TTL); + + return item.get(); + } + + public List getMultiple(int amount) { + // Get multiple items from the database + return itemsRepository.getMultiple(amount); + } + + public long create(Item item) { + // Create the data in the database + long itemId = itemsRepository.create(item); + + // Clone the item with the generated ID + Item createdItem = new Item( + itemId, + item.getName(), + item.getDescription(), + item.getPrice() + ); + + // Cache the data with the default TTL + String idString = Long.toString(itemId); + jedis.set(idString, createdItem.toJSONObject().toString()); + jedis.expire(idString, DEFAULT_TTL); + + return itemId; + } + + public void delete(long id) { + // Delete the data from database + itemsRepository.delete(id); + + // Also, delete the data from the cache if it exists + String idString = Long.toString(id); + if (jedis.exists(idString)) { + jedis.del(idString); + } + } + + public boolean exists(long id) { + String idString = Long.toString(id); + + // Check if the data exists in the cache or the database (check the cache first) + return jedis.exists(idString) || itemsRepository.exists(id); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java new file mode 100644 index 00000000000..7acf5478cef --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java @@ -0,0 +1,19 @@ +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; + +@Controller +public class HomeController { + + @Value("${API_URL:localhost:8080}") + private String apiUrl; + + @GetMapping("/") + public String home(Model model) { + model.addAttribute("apiUrl", apiUrl); + return "index"; // Refers to templates/index.html + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java new file mode 100644 index 00000000000..b34c0b1c797 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java @@ -0,0 +1,87 @@ +/** + * Data class representing an item in the application. + */ + +package app; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import org.json.JSONObject; + +public class Item { + + private final Long id; + + @NotNull + private final String name; + + @NotNull + private final String description; + + @NotNull + @Positive + private final Double price; + + private boolean fromCache; + + public Item() { + this(null, "", "", null); + } + + public Item(Long id, String name, String description, Double price) { + this.id = id; + this.name = name; + this.description = description; + this.price = price; + this.fromCache = false; + } + + public Item(String name, String description, Double price) { + this(null, name, description, price); + } + + public Long getId() { + return this.id; + } + + public String getName() { + return this.name; + } + + public String getDescription() { + return this.description; + } + + public Double getPrice() { + return this.price; + } + + public boolean isFromCache() { + return this.fromCache; + } + + public void setFromCache(boolean fromCache) { + this.fromCache = fromCache; + } + + public JSONObject toJSONObject() { + JSONObject obj = new JSONObject(); + obj.put("id", this.id); + obj.put("name", this.name); + obj.put("description", this.description); + obj.put("price", this.price); + obj.put("fromCache", this.fromCache); + + return obj; + } + + public static Item fromJSONString(String obj) { + JSONObject jsonObject = new JSONObject(obj); + return new Item( + jsonObject.getLong("id"), + jsonObject.getString("name"), + jsonObject.getString("description"), + jsonObject.getDouble("price") + ); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java new file mode 100644 index 00000000000..c87d5dbbb01 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java @@ -0,0 +1,74 @@ +/** + * Provides a RESTful API for interacting with the application's data. + * + * The controller contains three routes: + * - GET /item/{id} - Get an item by ID + * - POST /item/create - Create a new item + * - DELETE /item/delete/{id} - Delete an item by ID + */ + +package app; + +import jakarta.validation.Valid; +import org.json.JSONObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/api/item") +public class ItemController { + + public static final int TOTAL_RANDOM_ITEMS = 10; + + private final DataController dataController; + + public ItemController(DataController dataController) { + this.dataController = dataController; + } + + @GetMapping("/{id}") + public ResponseEntity read(@PathVariable Long id) { + Item item = dataController.get(id); + + if (item == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(item.toJSONObject().toString()); + } + + @GetMapping("/random") + public ResponseEntity read() { + return ResponseEntity.ok( + new JSONObject() + .put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)) + .toString() + ); + } + + @PostMapping("/create") + public ResponseEntity create(@Valid @RequestBody Item item) { + /** Create a new item */ + Item createdItem = new Item( + item.getName(), + item.getDescription(), + item.getPrice() + ); + + /** Save the item */ + long itemId = dataController.create(createdItem); + + /** Return a successful response */ + return ResponseEntity.ok( + JSONObject.valueToString(new JSONObject().put("id", itemId)) + ); + } + + @DeleteMapping("/delete/{id}") + public ResponseEntity delete(@PathVariable long id) { + dataController.delete(id); + return ResponseEntity.ok( + JSONObject.valueToString(new JSONObject().put("id", id)) + ); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java new file mode 100644 index 00000000000..5707d49fab4 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java @@ -0,0 +1,110 @@ +/** + * Handles CRUD operations for the items table. + */ + +package app; + +import java.sql.PreparedStatement; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.springframework.dao.EmptyResultDataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.support.GeneratedKeyHolder; +import org.springframework.jdbc.support.KeyHolder; +import org.springframework.stereotype.Repository; + +@Repository +public class ItemsRepository { + + private final JdbcTemplate jdbcTemplate; + + public ItemsRepository(JdbcTemplate jdbcTemplate) { + this.jdbcTemplate = jdbcTemplate; + } + + public Optional get(long id) { + try { + return Optional.ofNullable( + jdbcTemplate.queryForObject( + "SELECT * FROM items WHERE id = ?", + (rs, rowNum) -> + new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price") + ), + id + ) + ); + } catch (EmptyResultDataAccessException e) { + return Optional.empty(); + } + } + + public List getMultiple(int amount) { + return jdbcTemplate.query( + "SELECT * FROM items ORDER BY random() LIMIT ?", + (rs, rowNum) -> + new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price") + ), + amount + ); + } + + public long create(Item item) { + String name = item.getName(); + String description = item.getDescription(); + double price = item.getPrice(); + + KeyHolder keyHolder = new GeneratedKeyHolder(); + + jdbcTemplate.update( + connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", + new String[] { "id" } // Explicitly specify the generated key column + ); + ps.setString(1, name); + ps.setString(2, description); + ps.setDouble(3, price); + return ps; + }, + keyHolder + ); + + // Ensure the keyHolder contains the generated ID only + Map keys = keyHolder.getKeys(); + if (keys != null && keys.size() > 1) { + throw new IllegalStateException( + "Expected a single key, but multiple keys were returned: " + keys + ); + } + + Number key = keyHolder.getKey(); + if (key == null) { + throw new IllegalStateException("No key generated during insert"); + } + + return key.longValue(); + } + + public void delete(long id) { + jdbcTemplate.update("DELETE FROM items WHERE id = ?", id); + } + + public boolean exists(long id) { + /** Set the query to execute */ + String query = "SELECT EXISTS(SELECT 1 FROM items WHERE id = ?)"; + + /** Return query result */ + return Boolean.TRUE.equals( + jdbcTemplate.queryForObject(query, Boolean.class, id) + ); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java new file mode 100644 index 00000000000..048c8e5fe0b --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java @@ -0,0 +1,49 @@ +/** + * Configuration for the JDBC DataSource to connect to the PostgreSQL server. + */ + +package app; + +import javax.sql.DataSource; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.jdbc.datasource.DriverManagerDataSource; + +@Configuration +public class JdbcConfig { + + // Database configuration properties with environment variable fallback + @Value("${DB_URL:jdbc:postgresql://localhost:5432/postgres}") + private String url; + + @Value("${DB_USERNAME:postgres}") + private String username; + + @Value("${DB_PASSWORD:}") + private String password; + + @Bean + public DataSource dataSource() { + // Validate mandatory properties + if (url == null || url.isEmpty()) { + throw new IllegalArgumentException( + "Database URL (DB_URL) is not configured" + ); + } + if (username == null || username.isEmpty()) { + throw new IllegalArgumentException( + "Database username (DB_USERNAME) is not configured" + ); + } + + // Set up the DataSource + DriverManagerDataSource dataSource = new DriverManagerDataSource(); + dataSource.setDriverClassName("org.postgresql.Driver"); + dataSource.setUrl(url); + dataSource.setUsername(username); + dataSource.setPassword(password); + + return dataSource; + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java new file mode 100644 index 00000000000..1d2c98b33b9 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java @@ -0,0 +1,55 @@ +/** + * Configuration for the Jedis client to connect to the Valkey server. + */ + +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import redis.clients.jedis.Jedis; + +@Configuration +public class JedisConfig { + + // Redis server configuration properties + @Value("${VALKEY_HOST:localhost}") // Default to localhost if not set + private String redisHost; + + @Value("${VALKEY_PORT:6379}") // Default to 6379 if not set + private int redisPort; + + @Value("${VALKEY_PASSWORD:}") // Empty by default if not set + private String redisPassword; + + @Bean + public Jedis jedis() { + // Validate mandatory properties + if (redisHost == null || redisHost.isEmpty()) { + throw new IllegalArgumentException( + "Redis host (VALKEY_HOST) is not configured" + ); + } + if (redisPort <= 0 || redisPort > 65535) { + throw new IllegalArgumentException("Redis port (VALKEY_PORT) is invalid"); + } + + Jedis jedis = new Jedis(redisHost, redisPort); + + // Authenticate if a password is set + if (!redisPassword.isEmpty()) { + jedis.auth(redisPassword); + } + + // Verify the connection to the Redis server + try { + jedis.ping(); + } catch (Exception e) { + String msg = + "Failed to connect to Redis server at " + redisHost + ":" + redisPort; + throw new RuntimeException(msg, e); + } + + return jedis; + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java new file mode 100644 index 00000000000..8b441a11cf1 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java @@ -0,0 +1,16 @@ +/** + * Main class to start the Spring Boot application. + */ + +package app; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; + +@SpringBootApplication +public class Main { + + public static void main(String[] args) { + SpringApplication.run(Main.class, args); + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java new file mode 100644 index 00000000000..d9392732c13 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java @@ -0,0 +1,39 @@ +/** + * Use this configuration to allow CORS requests from the frontend. + */ + +package app; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +public class WebConfig { + + @Value("${ALLOWED_ORIGINS:localhost:3000}") // Default to localhost:3000 if not set + private String allowedOrigins; + + @Value("${ALLOWED_METHODS:GET,POST,PUT,DELETE}") // Default to GET,POST,PUT,DELETE methods if not set + private String allowedMethods; + + @Value("${ALLOWED_HEADERS:*}") // Default to all headers if not set + private String allowedHeaders; + + @Bean + public WebMvcConfigurer corsConfigurer() { + return new WebMvcConfigurer() { + @Override + public void addCorsMappings(CorsRegistry registry) { + registry + .addMapping("/**") // Allow all endpoints + .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend + .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods + .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers + .allowCredentials(true); // Allow cookies and credentials + } + }; + } +} diff --git a/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js b/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js new file mode 100644 index 00000000000..679de29aeb4 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js @@ -0,0 +1,242 @@ +/** Set API URL */ +const API_URL = window.location.origin + "/api"; + +/** API Helper Function */ +async function apiRequest(url, options = {}) { + let msTaken = new Date().getTime(); + try { + const res = await fetch(API_URL + url, options); + + if (!res.ok && res.status === 500) { + const errorText = "An error occurred"; + throw new Error(errorText); + } + + if (!res.ok && res.status === 404) { + const errorText = "Item not found"; + throw new Error(errorText); + } + + const data = await res.json(); + + /** Calculate response time in ms */ + msTaken = new Date().getTime() - msTaken; + + /** Update the UI for responses */ + setTiming(data.fromCache, msTaken); + setResponse({ type: "", data: null }); + + return { data }; + } catch (error) { + setResponse({ type: "error", data: error.message }); + setLoading(false); + return null; + } +} + +/** Set Global State */ +let loading = false; + +/** Add Element References */ +const queryRef = document.getElementById("search-input"); +const nameRef = document.getElementById("name"); +const descriptionRef = document.getElementById("description"); +const priceRef = document.getElementById("price"); + +const searchBtn = document.getElementById("search-btn"); +const randomBtn = document.getElementById("retrieve-random-btn"); +const createBtn = document.getElementById("create-btn"); + +const errorMsg = document.getElementById("error-msg"); +const successMsg = document.getElementById("success-msg"); + +const fromCache = document.getElementById("from-cache"); +const timeToFetch = document.getElementById("time-to-fetch"); +const itemsContainer = document.getElementById("items"); + +/** Loading Utility Function */ +function setLoading(value) { + loading = value; + + /** Disable inputs on loading */ + searchBtn.disabled = loading; + createBtn.disabled = loading; + + nameRef.disabled = loading; + descriptionRef.disabled = loading; + priceRef.disabled = loading; +} + +/** Update UI Timing */ +function setTiming(inFromCache, inTimeToFetch) { + fromCache.textContent = inFromCache ? "yes" : "no"; + timeToFetch.textContent = isNaN(inTimeToFetch) + ? "N/A" + : `${inTimeToFetch} ms`; +} + +/** Clear Input Fields */ +function clearInputFields() { + nameRef.value = ""; + descriptionRef.value = ""; + priceRef.value = ""; +} + +/** Set Response */ +function setResponse({ type, data }) { + console.log("Response:", type, data); + /** Set success message */ + if (type === "success") { + successMsg.style.display = "block"; + errorMsg.style.display = "none"; + successMsg.textContent = data; + return; + } + + /** Set error message */ + if (type === "error") { + successMsg.style.display = "none"; + errorMsg.style.display = "block"; + errorMsg.textContent = data; + return; + } + + /** Clear response message */ + successMsg.style.display = "none"; + successMsg.textContent = ""; + errorMsg.style.display = "none"; + errorMsg.textContent = ""; +} + +/** Search for an Item */ +async function search() { + setLoading(true); + itemsContainer.innerHTML = ""; + + const resp = await apiRequest(`/item/${queryRef.value.trim()}`); + if (resp && resp.data) { + const itemElement = document.createElement("div"); + itemElement.className = + "item flex flex-row gap-2.5 p-4 bg-gray-200 rounded"; + itemElement.innerHTML = addItemStyling(resp.data); + + itemElement.dataset.id = resp.data.id; + + const deleteBtn = itemElement.querySelector(".delete-btn"); + deleteBtn.addEventListener("click", () => deleteItem(resp.data.id)); + + itemsContainer.appendChild(itemElement); + } + + setLoading(false); +} + +/** Retrieve Random Items */ +async function retrieveRandom() { + setLoading(true); + itemsContainer.innerHTML = ""; + + const resp = await apiRequest("/item/random"); + if (resp && resp.data && Array.isArray(resp.data.items)) { + resp.data.items.forEach((item) => { + const itemElement = document.createElement("div"); + itemElement.className = + "item flex flex-row gap-2.5 p-4 bg-gray-200 rounded"; + itemElement.innerHTML = addItemStyling(item); + + itemElement.dataset.id = item.id; + + const deleteBtn = itemElement.querySelector(".delete-btn"); + deleteBtn.addEventListener("click", () => deleteItem(item.id)); + + itemsContainer.appendChild(itemElement); + }); + } else { + setResponse({ type: "error", data: "No items found." }); + } + + setLoading(false); +} + +/** Create a New Item */ +async function createItem() { + const name = nameRef.value.trim(); + const description = descriptionRef.value.trim(); + const price = parseFloat(priceRef.value.trim()); + + if (!name || !description || isNaN(price)) { + setResponse({ + type: "error", + data: "Please fill in all fields with valid data", + }); + return; + } + + setLoading(true); + + const resp = await apiRequest("/item/create", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ name, description, price }), + }); + + if (resp && resp.data) { + setResponse({ + type: "success", + data: `Item created successfully with ID: ${resp.data.id}`, + }); + clearInputFields(); + } + + setLoading(false); +} + +/** Delete an Item */ +async function deleteItem(id) { + setLoading(true); + + const resp = await apiRequest(`/item/delete/${id}`, { method: "DELETE" }); + + if (!resp) { + setResponse({ type: "error", data: "Failed to delete item" }); + setLoading(false); + return; + } + setResponse({ type: "success", data: "Item deleted successfully" }); + + /** Remove the item from the UI */ + const itemElement = document.querySelector(`.item[data-id="${id}"]`); + if (itemElement) { + itemElement.remove(); + } + + /** Reset loading status */ + setLoading(false); +} + +/** Add Item Styling */ +function addItemStyling({ id, name, price, description }) { + return ` +

ID: ${id}

+
+
+

Name: ${name}

+

Price: $${price}

+
+

${description}

+
+ +
+
`; +} + +/** Initialize */ +function init() { + errorMsg.style.display = "none"; + successMsg.style.display = "none"; +} + +window.addEventListener("load", init); +searchBtn.addEventListener("click", search); +randomBtn.addEventListener("click", retrieveRandom); +createBtn.addEventListener("click", createItem); diff --git a/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html b/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html new file mode 100644 index 00000000000..e8ebf024314 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html @@ -0,0 +1,126 @@ + + + + + + Valkey For MemoryStore Caching Demo + + + + + + + +
+
+

+ Valkey For MemoryStore Caching Demo +

+
+
+ +
+ +
+ + +
+ + + + + +
+
+

Nothing to show yet.

+
+
+ + +
+

Add a New Item

+
+ +
+
+ +
+
+ +
+
+ +
+
+ + +
+

+ Item is from cache: null +

+

|

+

+ Time to fetch item: + null +

+
+
+ + diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java new file mode 100644 index 00000000000..469172e980a --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java @@ -0,0 +1,247 @@ +package app; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.ArgumentMatchers.*; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.never; + +import java.util.Optional; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.*; +import org.mockito.junit.jupiter.MockitoExtension; +import redis.clients.jedis.Jedis; + +@ExtendWith(MockitoExtension.class) +class DataControllerTest { + + @Mock + private ItemsRepository itemsRepository; // Mocked repository for database interactions + + @Mock + private Jedis jedis; // Mocked Redis client for cache interactions + + private DataController dataController; // System under test + + @BeforeEach + void setUp() { + // Initialize the DataController with mocked dependencies before each test + dataController = new DataController(itemsRepository, jedis); + } + + // ---------------------------------------------------- + // get() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing get() method") + class GetTests { + + @Test + @DisplayName("Should return item from cache if it exists in cache") + void testGet_ItemInCache() { + // Arrange: Item exists in cache + long itemId = 1; + String itemIdStr = Long.toString(itemId); + String cachedData = + "{\"id\":1,\"name\":\"Cached Item\",\"description\":\"Cached description\",\"price\":10.5}"; + Item cachedItem = Item.fromJSONString(cachedData); + + given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item + given(jedis.get(itemIdStr)).willReturn(cachedData); + + // Act: Call the get() method + Item result = dataController.get(itemId); + + // Assert: Verify cache is used, database is not queried, and correct item is returned + verify(jedis).expire(itemIdStr, DataController.DEFAULT_TTL); // Extend TTL in cache + verify(itemsRepository, never()).get(anyLong()); // Database should not be called + assertEquals(cachedItem.getId(), result.getId()); + assertEquals(cachedItem.getName(), result.getName()); + assertEquals(true, result.isFromCache()); + } + + @Test + @DisplayName( + "Should return item from database and cache it if not in cache" + ) + void testGet_ItemNotInCache() { + // Arrange: Item is not in cache but exists in the database + long itemId = 2; + String itemIdStr = Long.toString(itemId); + Item dbItem = new Item(2L, "Database Item", "From DB", 15.99); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(itemsRepository.get(itemId)).willReturn(Optional.of(dbItem)); // Database contains the item + + // Act: Call the get() method + Item result = dataController.get(itemId); + + // Assert: Verify database usage, cache update, and correct item return + verify(jedis).set(itemIdStr, dbItem.toJSONObject().toString()); // Add item to cache + verify(jedis).expire(itemIdStr, DataController.DEFAULT_TTL); // Set TTL for cache + assertEquals(dbItem.getId(), result.getId()); + assertEquals(dbItem.getName(), result.getName()); + assertEquals(false, result.isFromCache()); + } + + @Test + @DisplayName( + "Should return null if item does not exist in cache or database" + ) + void testGet_ItemNotFound() { + // Arrange: Item does not exist in cache or database + long itemId = 3; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(itemsRepository.get(itemId)).willReturn(Optional.empty()); // Database miss + + // Act: Call the get() method + Item result = dataController.get(itemId); + + // Assert: Verify no cache update and null return + verify(jedis, never()).set(anyString(), anyString()); // Cache should not be updated + assertNull(result); + } + } + + // ---------------------------------------------------- + // create() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing create() method") + class CreateTests { + + @Test + @DisplayName("Should create item in cache and database") + void testCreate() { + // Arrange: Item to be created + Item item = new Item("New Item", "New Description", 20.0); + + given(itemsRepository.create(item)).willReturn(0L); // Simulate database creation with ID 0 + + // Act: Call the create() method + long result = dataController.create(item); + + // Assert: Verify cache and database interactions + Item expectedItem = new Item( + 0L, + item.getName(), + item.getDescription(), + item.getPrice() + ); + verify(jedis).set( + Long.toString(result), + expectedItem.toJSONObject().toString() + ); // Add item to cache + verify(jedis).expire(Long.toString(result), DataController.DEFAULT_TTL); // Set TTL for cache + assertEquals(0L, result); // Validate returned ID + } + } + + // ---------------------------------------------------- + // delete() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing delete() method") + class DeleteTests { + + @Test + @DisplayName("Should delete item from both cache and database") + void testDelete_ItemExists() { + // Arrange: Item exists in cache + long itemId = 6; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item + + // Act: Call the delete() method + dataController.delete(itemId); + + // Assert: Verify deletion from both cache and database + verify(itemsRepository).delete(itemId); // Delete from database + verify(jedis).del(itemIdStr); // Delete from cache + } + + @Test + @DisplayName("Should only delete item from database if not in cache") + void testDelete_ItemNotInCache() { + // Arrange: Item does not exist in cache + long itemId = 7; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + + // Act: Call the delete() method + dataController.delete(itemId); + + // Assert: Verify database deletion and no cache interaction + verify(itemsRepository).delete(itemId); // Delete from database + verify(jedis, never()).del(anyString()); // Cache should not be updated + } + } + + // ---------------------------------------------------- + // exists() tests + // ---------------------------------------------------- + @Nested + @DisplayName("Testing exists() method") + class ExistsTests { + + @Test + @DisplayName("Should return true if item exists in cache") + void testExists_ItemInCache() { + // Arrange: Item exists in cache + long itemId = 8; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item + + // Act: Call the exists() method + boolean result = dataController.exists(itemId); + + // Assert: Verify true result and no database call + assertTrue(result); + verify(itemsRepository, never()).exists(anyLong()); // Database should not be queried + } + + @Test + @DisplayName("Should return true if item exists in database") + void testExists_ItemInDatabase() { + // Arrange: Item is not in cache but exists in the database + long itemId = 9; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(itemsRepository.exists(itemId)).willReturn(true); // Database contains the item + + // Act: Call the exists() method + boolean result = dataController.exists(itemId); + + // Assert: Verify true result + assertTrue(result); + } + + @Test + @DisplayName( + "Should return false if item does not exist in cache or database" + ) + void testExists_ItemNotFound() { + // Arrange: Item does not exist in cache or database + long itemId = 10; + String itemIdStr = Long.toString(itemId); + + given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(itemsRepository.exists(itemId)).willReturn(false); // Database miss + + // Act: Call the exists() method + boolean result = dataController.exists(itemId); + + // Assert: Verify false result + assertFalse(result); + } + } +} diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java new file mode 100644 index 00000000000..fb875de3c1a --- /dev/null +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java @@ -0,0 +1,180 @@ +package app; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.BDDMockito.*; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +import java.util.List; +import org.hamcrest.Matchers; +import org.json.JSONObject; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.boot.test.mock.mockito.MockBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest(ItemController.class) +class ItemControllerTest { + + @Autowired + private MockMvc mockMvc; // MockMvc is used to perform HTTP requests in tests + + @MockBean + private DataController dataController; // Mocked dependency of ItemController + + @Test + @DisplayName("Test reading an item by ID") + void testReadItem() throws Exception { + // Arrange: DataController returns an Item for the provided ID + long itemId = 1; + Item item = new Item(1L, "ItemName", "ItemDescription", 100.0); + + given(dataController.get(itemId)).willReturn(item); // Simulate DataController behavior + + // Act: Perform GET /item/1 + mockMvc + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string(Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 + .andExpect( + content().string(Matchers.containsString("\"name\":\"ItemName\"")) + ) // Assert JSON contains name + .andExpect( + content() + .string( + Matchers.containsString("\"description\":\"ItemDescription\"") + ) + ) // Assert description + .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price + + // Assert: Verify DataController's get method was called with the correct ID + verify(dataController).get(itemId); + } + + @Test + @DisplayName("Test reading an item that does not exist") + void testReadItem_NotFound() throws Exception { + // Arrange: DataController returns null for the provided ID + long itemId = 2; + given(dataController.get(itemId)).willReturn(null); // Simulate item not found + + // Act: Perform GET /item/2 + mockMvc + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found + + // Assert: Verify DataController's get method was called + verify(dataController).get(itemId); + } + + @Test + @DisplayName("Test reading multiple random items") + void testReadMultipleItems() throws Exception { + // Arrange: DataController returns a list of random items + Item item1 = new Item(1L, "Item1", "Description1", 100.0); + Item item2 = new Item(2L, "Item2", "Description2", 200.0); + Item item3 = new Item(3L, "Item3", "Description3", 300.0); + Item item4 = new Item(4L, "Item4", "Description4", 400.0); + Item item5 = new Item(5L, "Item5", "Description5", 500.0); + Item item6 = new Item(6L, "Item6", "Description6", 600.0); + Item item7 = new Item(7L, "Item7", "Description7", 700.0); + Item item8 = new Item(8L, "Item8", "Description8", 800.0); + Item item9 = new Item(9L, "Item9", "Description9", 900.0); + Item item10 = new Item(10L, "Item10", "Description10", 1000.0); + + List items = List.of( + item1, + item2, + item3, + item4, + item5, + item6, + item7, + item8, + item9, + item10 + ); + given(dataController.getMultiple(10)).willReturn(items); + + // Act: Perform GET /random + mockMvc + .perform(get("/api/item/random")) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(jsonPath("$.items.length()").value(10)) // Assert the `items` array has 10 elements + .andExpect(jsonPath("$.items[0].id").value(1)) // Check first item's ID + .andExpect(jsonPath("$.items[0].name").value("Item1")) // Check first item's name + .andExpect(jsonPath("$.items[9].id").value(10)) // Check last item's ID + .andExpect(jsonPath("$.items[9].name").value("Item10")); // Check last item's name + + // Assert: Verify DataController's getMultiple method was called with the correct parameter + verify(dataController).getMultiple(10); + } + + @Test + @DisplayName("Test creating a new item") + void testCreateItem() throws Exception { + // Arrange: DataController successfully creates the item + Item item = new Item("NewItem", "NewDescription", 200.0); + + given(dataController.create(any(Item.class))).willReturn(0L); // Simulate creation with ID 0 + + // Act: Perform POST /item/create + JSONObject itemJson = item.toJSONObject(); + itemJson.remove("id"); // Remove ID from JSON for creation + mockMvc + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content(itemJson.toString()) // Convert JSON to string + ) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":0}")); // Assert response message + + // Assert: Verify DataController's create method was called with an argument matching the expected properties + verify(dataController).create( + argThat( + argument -> + argument.getName().equals("NewItem") && + argument.getDescription().equals("NewDescription") && + argument.getPrice() == 200.0 + ) + ); + } + + @Test + @DisplayName("Test creating an item with invalid request body") + void testCreateItem_Validations() throws Exception { + // Act: Perform POST /item/create with an invalid request body (missing required fields) + mockMvc + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) + ) + .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request + + // Assert: Verify DataController's create method was not called + verify(dataController, never()).create(any()); + } + + @Test + @DisplayName("Test deleting an item") + void testDeleteItem() throws Exception { + // Act: Perform DELETE /item/delete/5 + long itemId = 5; + mockMvc + .perform(delete("/api/item/delete/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":5}")); // Assert response message + + // Assert: Verify DataController's delete method was called with the correct ID + verify(dataController).delete(itemId); + } +} diff --git a/memorystore/valkey/caching/demo/sample-data/.gitignore b/memorystore/valkey/caching/demo/sample-data/.gitignore new file mode 100644 index 00000000000..e9e224cae7f --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/.gitignore @@ -0,0 +1,84 @@ +############################## +## Java +############################## +.mtj.tmp/ +*.class +*.jar +*.war +*.ear +*.nar +hs_err_pid* + +############################## +## Maven +############################## +target/ +pom.xml.tag +pom.xml.releaseBackup +pom.xml.versionsBackup +pom.xml.next +pom.xml.bak +release.properties +dependency-reduced-pom.xml +buildNumber.properties +.mvn/timing.properties +.mvn/wrapper/maven-wrapper.jar + +############################## +## Gradle +############################## +bin/ +build/ +.gradle +.gradletasknamecache +gradle-app.setting +!gradle-wrapper.jar + +############################## +## IntelliJ +############################## +out/ +.idea/ +.idea_modules/ +*.iml +*.ipr +*.iws + +############################## +## Eclipse +############################## +.settings/ +bin/ +tmp/ +.metadata +.classpath +.project +*.tmp +*.bak +*.swp +*~.nib +local.properties +.loadpath +.factorypath + +############################## +## NetBeans +############################## +nbproject/private/ +build/ +nbbuild/ +dist/ +nbdist/ +nbactions.xml +nb-configuration.xml + +############################## +## Visual Studio Code +############################## +.vscode/ +.code-workspace + +############################## +## OS X +############################## +.DS_Store \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/sample-data/Dockerfile b/memorystore/valkey/caching/demo/sample-data/Dockerfile new file mode 100644 index 00000000000..131e16d0504 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/Dockerfile @@ -0,0 +1,24 @@ +# Use an OpenJDK base image +FROM openjdk:17-jdk-slim + +# Install Maven for building the project +RUN apt-get update && apt-get install -y maven + +# Set the working directory +WORKDIR /app + +# Copy Maven project files +COPY pom.xml ./ +COPY src ./src + +# Build the project +RUN mvn clean package -DskipTests + +# Copy the built JAR file to the container +RUN cp target/app-1.0-SNAPSHOT.jar app.jar + +# Expose the application port +EXPOSE 8080 + +# Run the application +CMD ["java", "-jar", "app.jar"] \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml new file mode 100644 index 00000000000..87175710c52 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml @@ -0,0 +1,52 @@ +name: caching-app + +services: + valkey: + image: valkey/valkey:latest + ports: + - "6379:6379" + command: ["valkey-server", "--save", "60", "1", "--loglevel", "warning"] + + postgres: + image: postgres:latest + container_name: postgres + ports: + - "5432:5432" + environment: + - POSTGRES_USER=admin + - POSTGRES_PASSWORD=password + - POSTGRES_DB=postgres + volumes: + - ../app/init.sql:/docker-entrypoint-initdb.d/init.sql + + data: + build: + context: . + dockerfile: Dockerfile + container_name: sample-data + ports: + - "8082:8082" + depends_on: + - postgres + environment: + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password + + app: + build: + context: ../app + dockerfile: Dockerfile + container_name: app + ports: + - "8080:8080" + depends_on: + - valkey + - postgres + - data + environment: + - VALKEY_HOST=valkey + - VALKEY_PORT=6379 + - DB_URL=jdbc:postgresql://postgres:5432/postgres + - DB_USERNAME=admin + - DB_PASSWORD=password diff --git a/memorystore/valkey/caching/demo/sample-data/pom.xml b/memorystore/valkey/caching/demo/sample-data/pom.xml new file mode 100644 index 00000000000..aab7d138ea7 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/pom.xml @@ -0,0 +1,80 @@ + + + 4.0.0 + + org.example + app + 1.0-SNAPSHOT + + + 17 + 17 + UTF-8 + + + + + + org.springframework.boot + spring-boot-starter-jdbc + 3.3.6 + + + + + org.postgresql + postgresql + 42.6.0 + + + + + com.github.javafaker + javafaker + 1.0.2 + + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.8.1 + + 17 + 17 + + -parameters + + + + + + + org.apache.maven.plugins + maven-shade-plugin + 3.2.4 + + + package + + shade + + + true + + + app.Main + + + + + + + + + \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java new file mode 100644 index 00000000000..3bf74b2b48d --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java @@ -0,0 +1,84 @@ +package app; + +import com.github.javafaker.Faker; +import java.util.ArrayList; +import java.util.List; +import java.util.Random; +import org.springframework.boot.jdbc.DataSourceBuilder; +import org.springframework.jdbc.CannotGetJdbcConnectionException; +import org.springframework.jdbc.core.JdbcTemplate; + +public class Main { + + private static final int MAX_GENERATED_ENTRIES = 15000; + + private static final Faker FAKER = new Faker(); + private static final Random RANDOM = new Random(); + + public static void main(String[] args) { + // Connect to PostgreSQL + System.out.println("Connecting to PostgreSQL..."); + JdbcTemplate jdbcTemplate = configureJdbcTemplate(); + + // Populate leaderboard with test data + try { + System.out.println("Populating items table with sample data..."); + populateItems(jdbcTemplate); + } catch (CannotGetJdbcConnectionException e) { + System.out.println( + "Failed to connect to the database. Retrying in 5 seconds..." + ); + // Sleep for 5 seconds and retry + try { + Thread.sleep(5000); + } catch (InterruptedException ex) { + Thread.currentThread().interrupt(); + } + main(args); + } + } + + private static void populateItems(JdbcTemplate jdbcTemplate) { + String sql = + "INSERT INTO items (name, description, price) VALUES (?, ?, ?)"; + + // Prepare batch arguments + List batchArgs = new ArrayList<>(); + for (int i = 0; i < MAX_GENERATED_ENTRIES; i++) { + String name = generateProductName(); + String description = generateDescription(); + double price = RANDOM.nextInt(10000) / 100.0; + + batchArgs.add(new Object[] { name, description, price }); + } + + // Execute batch update + jdbcTemplate.batchUpdate(sql, batchArgs); + } + + private static String generateProductName() { + return FAKER.commerce().productName(); + } + + private static String generateDescription() { + return FAKER.lorem().paragraph(); + } + + private static JdbcTemplate configureJdbcTemplate() { + String jdbcUrl = System.getenv() + .getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/items"); + String jdbcUsername = System.getenv().getOrDefault("DB_USERNAME", "root"); + String jdbcPassword = System.getenv() + .getOrDefault("DB_PASSWORD", "password"); + + JdbcTemplate jdbcTemplate = new JdbcTemplate(); + jdbcTemplate.setDataSource( + DataSourceBuilder.create() + .url(jdbcUrl) + .username(jdbcUsername) + .password(jdbcPassword) + .build() + ); + return jdbcTemplate; + } +} From 5a20ce7152f5d6d6265a3ce93cd39ba09e563659 Mon Sep 17 00:00:00 2001 From: Darren Ackers Date: Thu, 23 Jan 2025 15:09:37 +0000 Subject: [PATCH 2/8] feat(memorystore): added terraform configuration --- memorystore/valkey/caching/demo/app/main.tf | 139 ++++++++++++++++++++ 1 file changed, 139 insertions(+) create mode 100644 memorystore/valkey/caching/demo/app/main.tf diff --git a/memorystore/valkey/caching/demo/app/main.tf b/memorystore/valkey/caching/demo/app/main.tf new file mode 100644 index 00000000000..ada2ba4df58 --- /dev/null +++ b/memorystore/valkey/caching/demo/app/main.tf @@ -0,0 +1,139 @@ +provider "google" { + project = "cloud-memorystore-demos" + region = "us-central1" +} + +data "google_project" "project" { + project_id = "cloud-memorystore-demos" +} + +resource "google_compute_network" "app_network" { + name = "caching-app-network" +} + +resource "google_compute_firewall" "allow_http" { + name = "caching-app-allow-http-8080" + network = google_compute_network.app_network.name + + allow { + protocol = "tcp" + ports = ["8080"] + } + + source_ranges = ["0.0.0.0/0"] + + depends_on = [google_compute_network.app_network] +} + +resource "google_cloud_run_v2_service" "app" { + name = "caching-app-service" + location = "us-central1" + + template { + containers { + image = "replace" # Will be set at a later time + + env { + name = "VALKEY_HOST" + value = module.valkey.discovery_endpoints[0]["address"] + } + + env { + name = "VALKEY_PORT" + value = module.valkey.discovery_endpoints[0]["port"] + } + + env { + name = "DB_URL" + value = "jdbc:postgresql://${google_sql_database_instance.postgres.public_ip_address}/${google_sql_database.postgres_db.name}" + } + + env { + name = "DB_USERNAME" + value = google_sql_user.postgres_user.name + } + + env { + name = "DB_PASSWORD" + value = google_sql_user.postgres_user.password + } + + ports { + container_port = 8080 + } + } + + vpc_access { + network_interfaces { + network = google_compute_network.app_network.name + subnetwork = google_compute_network.app_network.name + tags = [] + } + } + } + + depends_on = [ + google_compute_network.app_network, + module.valkey, + google_sql_database_instance.postgres + ] +} + +module "valkey" { + source = "terraform-google-modules/memorystore/google//modules/valkey" + version = "12.0" + + instance_id = "caching-app-valkey-instance" + project_id = data.google_project.project.project_id + location = "us-central1" + node_type = "SHARED_CORE_NANO" + shard_count = 1 + engine_version = "VALKEY_7_2" + + network = google_compute_network.app_network.name + + service_connection_policies = { + caching_valkey_scp = { + subnet_names = [google_compute_network.app_network.name] + } + } + + depends_on = [google_compute_network.app_network] +} + +resource "google_sql_database_instance" "postgres" { + name = "caching-app-postgres-instance" + database_version = "POSTGRES_16" + region = "us-central1" + + settings { + edition = "ENTERPRISE" + tier = "db-custom-1-3840" + + ip_configuration { + ipv4_enabled = true + + authorized_networks { + name = "caching-app-access" + value = "0.0.0.0/0" + } + } + } + + depends_on = [google_compute_network.app_network] +} + +resource "google_sql_user" "postgres_user" { + name = "admin" + instance = google_sql_database_instance.postgres.name + password = "password123" # Set this to the password you want to use for the user + + depends_on = [google_sql_database_instance.postgres] +} + +resource "google_sql_database" "postgres_db" { + name = "caching-app-db" + instance = google_sql_database_instance.postgres.name + + depends_on = [google_sql_database_instance.postgres] +} \ No newline at end of file From 774bc4fa723d8cfae7155703f594ddc92c9b6d17 Mon Sep 17 00:00:00 2001 From: Darren Ackers Date: Thu, 23 Jan 2025 15:19:46 +0000 Subject: [PATCH 3/8] chore(memorstore): added file headers --- .../caching/demo/app/docker-compose.yaml | 13 +++++++++++ memorystore/valkey/caching/demo/app/main.tf | 16 +++++++++++++ .../app/src/main/java/app/DataController.java | 21 ++++++++++++++--- .../app/src/main/java/app/HomeController.java | 16 +++++++++++++ .../demo/app/src/main/java/app/Item.java | 17 ++++++++++++-- .../app/src/main/java/app/ItemController.java | 23 +++++++++++++++---- .../src/main/java/app/ItemsRepository.java | 17 ++++++++++++-- .../app/src/main/java/app/JdbcConfig.java | 17 ++++++++++++-- .../app/src/main/java/app/JedisConfig.java | 17 ++++++++++++-- .../demo/app/src/main/java/app/Main.java | 17 ++++++++++++-- .../demo/app/src/main/java/app/WebConfig.java | 20 +++++++++++++--- .../app/src/main/resources/static/script.js | 16 +++++++++++++ .../src/main/resources/templates/index.html | 18 ++++++++++++++- .../src/test/java/app/DataControllerTest.java | 16 +++++++++++++ .../src/test/java/app/ItemControllerTest.java | 16 +++++++++++++ .../demo/sample-data/docker-compose.yaml | 14 +++++++++++ .../sample-data/src/main/java/app/Main.java | 16 +++++++++++++ 17 files changed, 268 insertions(+), 22 deletions(-) diff --git a/memorystore/valkey/caching/demo/app/docker-compose.yaml b/memorystore/valkey/caching/demo/app/docker-compose.yaml index bb9cf8b014a..750227f8ef7 100644 --- a/memorystore/valkey/caching/demo/app/docker-compose.yaml +++ b/memorystore/valkey/caching/demo/app/docker-compose.yaml @@ -1,3 +1,16 @@ +# Copyright 2025 Google Inc. +# +# 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 +# +# http://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. name: caching-app services: diff --git a/memorystore/valkey/caching/demo/app/main.tf b/memorystore/valkey/caching/demo/app/main.tf index ada2ba4df58..1e951a0c5ee 100644 --- a/memorystore/valkey/caching/demo/app/main.tf +++ b/memorystore/valkey/caching/demo/app/main.tf @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + provider "google" { project = "cloud-memorystore-demos" region = "us-central1" diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java index b560d135690..a1aabea04e5 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java @@ -1,8 +1,23 @@ -/** - * Responsible for handling the data operations. - * Handles checking cache first, then database, and updating the cache. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** + * Responsible for handling the data operations. Handles checking cache first, then database, and + * updating the cache. + */ package app; import java.util.List; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java index 7acf5478cef..cf83ec9a0af 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; import org.springframework.beans.factory.annotation.Value; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java index b34c0b1c797..f23630cd527 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java @@ -1,7 +1,20 @@ -/** - * Data class representing an item in the application. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** Data class representing an item in the application. */ package app; import jakarta.validation.constraints.NotNull; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java index c87d5dbbb01..acd02cae2a0 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java @@ -1,12 +1,25 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Provides a RESTful API for interacting with the application's data. * - * The controller contains three routes: - * - GET /item/{id} - Get an item by ID - * - POST /item/create - Create a new item - * - DELETE /item/delete/{id} - Delete an item by ID + *

The controller contains three routes: - GET /item/{id} - Get an item by ID - POST /item/create + * - Create a new item - DELETE /item/delete/{id} - Delete an item by ID */ - package app; import jakarta.validation.Valid; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java index 5707d49fab4..81ead7def93 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java @@ -1,7 +1,20 @@ -/** - * Handles CRUD operations for the items table. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** Handles CRUD operations for the items table. */ package app; import java.sql.PreparedStatement; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java index 048c8e5fe0b..0d7afe54857 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java @@ -1,7 +1,20 @@ -/** - * Configuration for the JDBC DataSource to connect to the PostgreSQL server. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** Configuration for the JDBC DataSource to connect to the PostgreSQL server. */ package app; import javax.sql.DataSource; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java index 1d2c98b33b9..466217c2167 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java @@ -1,7 +1,20 @@ -/** - * Configuration for the Jedis client to connect to the Valkey server. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** Configuration for the Jedis client to connect to the Valkey server. */ package app; import org.springframework.beans.factory.annotation.Value; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java index 8b441a11cf1..4b974be8c90 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java @@ -1,7 +1,20 @@ -/** - * Main class to start the Spring Boot application. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** Main class to start the Spring Boot application. */ package app; import org.springframework.boot.SpringApplication; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java index d9392732c13..cf2e7bc9f39 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java @@ -1,7 +1,20 @@ -/** - * Use this configuration to allow CORS requests from the frontend. +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. */ +/** Use this configuration to allow CORS requests from the frontend. */ package app; import org.springframework.beans.factory.annotation.Value; @@ -16,7 +29,8 @@ public class WebConfig { @Value("${ALLOWED_ORIGINS:localhost:3000}") // Default to localhost:3000 if not set private String allowedOrigins; - @Value("${ALLOWED_METHODS:GET,POST,PUT,DELETE}") // Default to GET,POST,PUT,DELETE methods if not set + @Value( + "${ALLOWED_METHODS:GET,POST,PUT,DELETE}") // Default to GET,POST,PUT,DELETE methods if not set private String allowedMethods; @Value("${ALLOWED_HEADERS:*}") // Default to all headers if not set diff --git a/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js b/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js index 679de29aeb4..b713525685e 100644 --- a/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js +++ b/memorystore/valkey/caching/demo/app/src/main/resources/static/script.js @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** Set API URL */ const API_URL = window.location.origin + "/api"; diff --git a/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html b/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html index e8ebf024314..45c9060105e 100644 --- a/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html +++ b/memorystore/valkey/caching/demo/app/src/main/resources/templates/index.html @@ -1,4 +1,20 @@ - + + + diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java index 469172e980a..415808f5806 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; import static org.junit.jupiter.api.Assertions.*; diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java index fb875de3c1a..17dab27873a 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; import static org.mockito.ArgumentMatchers.any; diff --git a/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml index 87175710c52..6214593b602 100644 --- a/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml +++ b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml @@ -1,3 +1,17 @@ +# Copyright 2025 Google Inc. +# +# 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 +# +# http://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. + name: caching-app services: diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java index 3bf74b2b48d..67dcf1e3890 100644 --- a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; import com.github.javafaker.Faker; From 052e30a9a9937e23f2aa7bb3d9ea94c7c8efc9f9 Mon Sep 17 00:00:00 2001 From: Darren Ackers Date: Thu, 23 Jan 2025 15:20:22 +0000 Subject: [PATCH 4/8] chore(memorystore): ran lint formatting --- .../app/src/main/java/app/DataController.java | 7 +- .../demo/app/src/main/java/app/Item.java | 19 ++- .../app/src/main/java/app/ItemController.java | 19 +-- .../src/main/java/app/ItemsRepository.java | 70 +++++------ .../app/src/main/java/app/JdbcConfig.java | 8 +- .../app/src/main/java/app/JedisConfig.java | 7 +- .../demo/app/src/main/java/app/WebConfig.java | 10 +- .../src/test/java/app/DataControllerTest.java | 37 ++---- .../src/test/java/app/ItemControllerTest.java | 118 ++++++++---------- .../sample-data/src/main/java/app/Main.java | 27 ++-- 10 files changed, 129 insertions(+), 193 deletions(-) diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java index a1aabea04e5..3f8abeb2670 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java @@ -77,12 +77,7 @@ public long create(Item item) { long itemId = itemsRepository.create(item); // Clone the item with the generated ID - Item createdItem = new Item( - itemId, - item.getName(), - item.getDescription(), - item.getPrice() - ); + Item createdItem = new Item(itemId, item.getName(), item.getDescription(), item.getPrice()); // Cache the data with the default TTL String idString = Long.toString(itemId); diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java index f23630cd527..87397d145c4 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java @@ -25,15 +25,11 @@ public class Item { private final Long id; - @NotNull - private final String name; + @NotNull private final String name; - @NotNull - private final String description; + @NotNull private final String description; - @NotNull - @Positive - private final Double price; + @NotNull @Positive private final Double price; private boolean fromCache; @@ -91,10 +87,9 @@ public JSONObject toJSONObject() { public static Item fromJSONString(String obj) { JSONObject jsonObject = new JSONObject(obj); return new Item( - jsonObject.getLong("id"), - jsonObject.getString("name"), - jsonObject.getString("description"), - jsonObject.getDouble("price") - ); + jsonObject.getLong("id"), + jsonObject.getString("name"), + jsonObject.getString("description"), + jsonObject.getDouble("price")); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java index acd02cae2a0..a10e3a37f59 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java @@ -53,35 +53,24 @@ public ResponseEntity read(@PathVariable Long id) { @GetMapping("/random") public ResponseEntity read() { return ResponseEntity.ok( - new JSONObject() - .put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)) - .toString() - ); + new JSONObject().put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)).toString()); } @PostMapping("/create") public ResponseEntity create(@Valid @RequestBody Item item) { /** Create a new item */ - Item createdItem = new Item( - item.getName(), - item.getDescription(), - item.getPrice() - ); + Item createdItem = new Item(item.getName(), item.getDescription(), item.getPrice()); /** Save the item */ long itemId = dataController.create(createdItem); /** Return a successful response */ - return ResponseEntity.ok( - JSONObject.valueToString(new JSONObject().put("id", itemId)) - ); + return ResponseEntity.ok(JSONObject.valueToString(new JSONObject().put("id", itemId))); } @DeleteMapping("/delete/{id}") public ResponseEntity delete(@PathVariable long id) { dataController.delete(id); - return ResponseEntity.ok( - JSONObject.valueToString(new JSONObject().put("id", id)) - ); + return ResponseEntity.ok(JSONObject.valueToString(new JSONObject().put("id", id))); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java index 81ead7def93..639ab5d966e 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java @@ -39,18 +39,15 @@ public ItemsRepository(JdbcTemplate jdbcTemplate) { public Optional get(long id) { try { return Optional.ofNullable( - jdbcTemplate.queryForObject( - "SELECT * FROM items WHERE id = ?", - (rs, rowNum) -> - new Item( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getDouble("price") - ), - id - ) - ); + jdbcTemplate.queryForObject( + "SELECT * FROM items WHERE id = ?", + (rs, rowNum) -> + new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price")), + id)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } @@ -58,16 +55,14 @@ public Optional get(long id) { public List getMultiple(int amount) { return jdbcTemplate.query( - "SELECT * FROM items ORDER BY random() LIMIT ?", - (rs, rowNum) -> - new Item( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getDouble("price") - ), - amount - ); + "SELECT * FROM items ORDER BY random() LIMIT ?", + (rs, rowNum) -> + new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price")), + amount); } public long create(Item item) { @@ -78,25 +73,24 @@ public long create(Item item) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update( - connection -> { - PreparedStatement ps = connection.prepareStatement( - "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", - new String[] { "id" } // Explicitly specify the generated key column - ); - ps.setString(1, name); - ps.setString(2, description); - ps.setDouble(3, price); - return ps; - }, - keyHolder - ); + connection -> { + PreparedStatement ps = + connection.prepareStatement( + "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", + new String[] {"id"} // Explicitly specify the generated key column + ); + ps.setString(1, name); + ps.setString(2, description); + ps.setDouble(3, price); + return ps; + }, + keyHolder); // Ensure the keyHolder contains the generated ID only Map keys = keyHolder.getKeys(); if (keys != null && keys.size() > 1) { throw new IllegalStateException( - "Expected a single key, but multiple keys were returned: " + keys - ); + "Expected a single key, but multiple keys were returned: " + keys); } Number key = keyHolder.getKey(); @@ -116,8 +110,6 @@ public boolean exists(long id) { String query = "SELECT EXISTS(SELECT 1 FROM items WHERE id = ?)"; /** Return query result */ - return Boolean.TRUE.equals( - jdbcTemplate.queryForObject(query, Boolean.class, id) - ); + return Boolean.TRUE.equals(jdbcTemplate.queryForObject(query, Boolean.class, id)); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java index 0d7afe54857..5e95a2e2f24 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java @@ -40,14 +40,10 @@ public class JdbcConfig { public DataSource dataSource() { // Validate mandatory properties if (url == null || url.isEmpty()) { - throw new IllegalArgumentException( - "Database URL (DB_URL) is not configured" - ); + throw new IllegalArgumentException("Database URL (DB_URL) is not configured"); } if (username == null || username.isEmpty()) { - throw new IllegalArgumentException( - "Database username (DB_USERNAME) is not configured" - ); + throw new IllegalArgumentException("Database username (DB_USERNAME) is not configured"); } // Set up the DataSource diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java index 466217c2167..f4ed7e8129b 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java @@ -39,9 +39,7 @@ public class JedisConfig { public Jedis jedis() { // Validate mandatory properties if (redisHost == null || redisHost.isEmpty()) { - throw new IllegalArgumentException( - "Redis host (VALKEY_HOST) is not configured" - ); + throw new IllegalArgumentException("Redis host (VALKEY_HOST) is not configured"); } if (redisPort <= 0 || redisPort > 65535) { throw new IllegalArgumentException("Redis port (VALKEY_PORT) is invalid"); @@ -58,8 +56,7 @@ public Jedis jedis() { try { jedis.ping(); } catch (Exception e) { - String msg = - "Failed to connect to Redis server at " + redisHost + ":" + redisPort; + String msg = "Failed to connect to Redis server at " + redisHost + ":" + redisPort; throw new RuntimeException(msg, e); } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java index cf2e7bc9f39..8b4051cf61f 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java @@ -42,11 +42,11 @@ public WebMvcConfigurer corsConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry - .addMapping("/**") // Allow all endpoints - .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend - .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods - .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers - .allowCredentials(true); // Allow cookies and credentials + .addMapping("/**") // Allow all endpoints + .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend + .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods + .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers + .allowCredentials(true); // Allow cookies and credentials } }; } diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java index 415808f5806..2098053076d 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java @@ -34,11 +34,9 @@ @ExtendWith(MockitoExtension.class) class DataControllerTest { - @Mock - private ItemsRepository itemsRepository; // Mocked repository for database interactions + @Mock private ItemsRepository itemsRepository; // Mocked repository for database interactions - @Mock - private Jedis jedis; // Mocked Redis client for cache interactions + @Mock private Jedis jedis; // Mocked Redis client for cache interactions private DataController dataController; // System under test @@ -62,7 +60,8 @@ void testGet_ItemInCache() { long itemId = 1; String itemIdStr = Long.toString(itemId); String cachedData = - "{\"id\":1,\"name\":\"Cached Item\",\"description\":\"Cached description\",\"price\":10.5}"; + "{\"id\":1,\"name\":\"Cached Item\",\"description\":\"Cached" + + " description\",\"price\":10.5}"; Item cachedItem = Item.fromJSONString(cachedData); given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item @@ -80,9 +79,7 @@ void testGet_ItemInCache() { } @Test - @DisplayName( - "Should return item from database and cache it if not in cache" - ) + @DisplayName("Should return item from database and cache it if not in cache") void testGet_ItemNotInCache() { // Arrange: Item is not in cache but exists in the database long itemId = 2; @@ -90,7 +87,8 @@ void testGet_ItemNotInCache() { Item dbItem = new Item(2L, "Database Item", "From DB", 15.99); given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss - given(itemsRepository.get(itemId)).willReturn(Optional.of(dbItem)); // Database contains the item + given(itemsRepository.get(itemId)) + .willReturn(Optional.of(dbItem)); // Database contains the item // Act: Call the get() method Item result = dataController.get(itemId); @@ -104,9 +102,7 @@ void testGet_ItemNotInCache() { } @Test - @DisplayName( - "Should return null if item does not exist in cache or database" - ) + @DisplayName("Should return null if item does not exist in cache or database") void testGet_ItemNotFound() { // Arrange: Item does not exist in cache or database long itemId = 3; @@ -143,16 +139,9 @@ void testCreate() { long result = dataController.create(item); // Assert: Verify cache and database interactions - Item expectedItem = new Item( - 0L, - item.getName(), - item.getDescription(), - item.getPrice() - ); - verify(jedis).set( - Long.toString(result), - expectedItem.toJSONObject().toString() - ); // Add item to cache + Item expectedItem = new Item(0L, item.getName(), item.getDescription(), item.getPrice()); + verify(jedis) + .set(Long.toString(result), expectedItem.toJSONObject().toString()); // Add item to cache verify(jedis).expire(Long.toString(result), DataController.DEFAULT_TTL); // Set TTL for cache assertEquals(0L, result); // Validate returned ID } @@ -242,9 +231,7 @@ void testExists_ItemInDatabase() { } @Test - @DisplayName( - "Should return false if item does not exist in cache or database" - ) + @DisplayName("Should return false if item does not exist in cache or database") void testExists_ItemNotFound() { // Arrange: Item does not exist in cache or database long itemId = 10; diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java index 17dab27873a..dcf6d060fab 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java @@ -39,11 +39,9 @@ @WebMvcTest(ItemController.class) class ItemControllerTest { - @Autowired - private MockMvc mockMvc; // MockMvc is used to perform HTTP requests in tests + @Autowired private MockMvc mockMvc; // MockMvc is used to perform HTTP requests in tests - @MockBean - private DataController dataController; // Mocked dependency of ItemController + @MockBean private DataController dataController; // Mocked dependency of ItemController @Test @DisplayName("Test reading an item by ID") @@ -56,19 +54,20 @@ void testReadItem() throws Exception { // Act: Perform GET /item/1 mockMvc - .perform(get("/api/item/{id}", itemId)) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string(Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 - .andExpect( - content().string(Matchers.containsString("\"name\":\"ItemName\"")) - ) // Assert JSON contains name - .andExpect( - content() - .string( - Matchers.containsString("\"description\":\"ItemDescription\"") - ) - ) // Assert description - .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect( + content().string(Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 + .andExpect( + content() + .string( + Matchers.containsString("\"name\":\"ItemName\""))) // Assert JSON contains name + .andExpect( + content() + .string( + Matchers.containsString( + "\"description\":\"ItemDescription\""))) // Assert description + .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price // Assert: Verify DataController's get method was called with the correct ID verify(dataController).get(itemId); @@ -83,8 +82,8 @@ void testReadItem_NotFound() throws Exception { // Act: Perform GET /item/2 mockMvc - .perform(get("/api/item/{id}", itemId)) - .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found // Assert: Verify DataController's get method was called verify(dataController).get(itemId); @@ -105,29 +104,20 @@ void testReadMultipleItems() throws Exception { Item item9 = new Item(9L, "Item9", "Description9", 900.0); Item item10 = new Item(10L, "Item10", "Description10", 1000.0); - List items = List.of( - item1, - item2, - item3, - item4, - item5, - item6, - item7, - item8, - item9, - item10 - ); + List items = + List.of(item1, item2, item3, item4, item5, item6, item7, item8, item9, item10); given(dataController.getMultiple(10)).willReturn(items); // Act: Perform GET /random mockMvc - .perform(get("/api/item/random")) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(jsonPath("$.items.length()").value(10)) // Assert the `items` array has 10 elements - .andExpect(jsonPath("$.items[0].id").value(1)) // Check first item's ID - .andExpect(jsonPath("$.items[0].name").value("Item1")) // Check first item's name - .andExpect(jsonPath("$.items[9].id").value(10)) // Check last item's ID - .andExpect(jsonPath("$.items[9].name").value("Item10")); // Check last item's name + .perform(get("/api/item/random")) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect( + jsonPath("$.items.length()").value(10)) // Assert the `items` array has 10 elements + .andExpect(jsonPath("$.items[0].id").value(1)) // Check first item's ID + .andExpect(jsonPath("$.items[0].name").value("Item1")) // Check first item's name + .andExpect(jsonPath("$.items[9].id").value(10)) // Check last item's ID + .andExpect(jsonPath("$.items[9].name").value("Item10")); // Check last item's name // Assert: Verify DataController's getMultiple method was called with the correct parameter verify(dataController).getMultiple(10); @@ -145,23 +135,23 @@ void testCreateItem() throws Exception { JSONObject itemJson = item.toJSONObject(); itemJson.remove("id"); // Remove ID from JSON for creation mockMvc - .perform( - post("/api/item/create") - .contentType("application/json") // Specify JSON content type - .content(itemJson.toString()) // Convert JSON to string - ) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string("{\"id\":0}")); // Assert response message - - // Assert: Verify DataController's create method was called with an argument matching the expected properties - verify(dataController).create( - argThat( - argument -> - argument.getName().equals("NewItem") && - argument.getDescription().equals("NewDescription") && - argument.getPrice() == 200.0 - ) - ); + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content(itemJson.toString()) // Convert JSON to string + ) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":0}")); // Assert response message + + // Assert: Verify DataController's create method was called with an argument matching the + // expected properties + verify(dataController) + .create( + argThat( + argument -> + argument.getName().equals("NewItem") + && argument.getDescription().equals("NewDescription") + && argument.getPrice() == 200.0)); } @Test @@ -169,12 +159,12 @@ void testCreateItem() throws Exception { void testCreateItem_Validations() throws Exception { // Act: Perform POST /item/create with an invalid request body (missing required fields) mockMvc - .perform( - post("/api/item/create") - .contentType("application/json") // Specify JSON content type - .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) - ) - .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) + ) + .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request // Assert: Verify DataController's create method was not called verify(dataController, never()).create(any()); @@ -186,9 +176,9 @@ void testDeleteItem() throws Exception { // Act: Perform DELETE /item/delete/5 long itemId = 5; mockMvc - .perform(delete("/api/item/delete/{id}", itemId)) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string("{\"id\":5}")); // Assert response message + .perform(delete("/api/item/delete/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":5}")); // Assert response message // Assert: Verify DataController's delete method was called with the correct ID verify(dataController).delete(itemId); diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java index 67dcf1e3890..87e6b50592a 100644 --- a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java @@ -41,9 +41,7 @@ public static void main(String[] args) { System.out.println("Populating items table with sample data..."); populateItems(jdbcTemplate); } catch (CannotGetJdbcConnectionException e) { - System.out.println( - "Failed to connect to the database. Retrying in 5 seconds..." - ); + System.out.println("Failed to connect to the database. Retrying in 5 seconds..."); // Sleep for 5 seconds and retry try { Thread.sleep(5000); @@ -55,8 +53,7 @@ public static void main(String[] args) { } private static void populateItems(JdbcTemplate jdbcTemplate) { - String sql = - "INSERT INTO items (name, description, price) VALUES (?, ?, ?)"; + String sql = "INSERT INTO items (name, description, price) VALUES (?, ?, ?)"; // Prepare batch arguments List batchArgs = new ArrayList<>(); @@ -65,7 +62,7 @@ private static void populateItems(JdbcTemplate jdbcTemplate) { String description = generateDescription(); double price = RANDOM.nextInt(10000) / 100.0; - batchArgs.add(new Object[] { name, description, price }); + batchArgs.add(new Object[] {name, description, price}); } // Execute batch update @@ -81,20 +78,18 @@ private static String generateDescription() { } private static JdbcTemplate configureJdbcTemplate() { - String jdbcUrl = System.getenv() - .getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/items"); + String jdbcUrl = + System.getenv().getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/items"); String jdbcUsername = System.getenv().getOrDefault("DB_USERNAME", "root"); - String jdbcPassword = System.getenv() - .getOrDefault("DB_PASSWORD", "password"); + String jdbcPassword = System.getenv().getOrDefault("DB_PASSWORD", "password"); JdbcTemplate jdbcTemplate = new JdbcTemplate(); jdbcTemplate.setDataSource( - DataSourceBuilder.create() - .url(jdbcUrl) - .username(jdbcUsername) - .password(jdbcPassword) - .build() - ); + DataSourceBuilder.create() + .url(jdbcUrl) + .username(jdbcUsername) + .password(jdbcPassword) + .build()); return jdbcTemplate; } } From c00b55161427196e750a02aa9d9777f154659f53 Mon Sep 17 00:00:00 2001 From: Darren Ackers Date: Thu, 23 Jan 2025 15:22:46 +0000 Subject: [PATCH 5/8] chore(memorstore): updated yaml file headers --- memorystore/valkey/caching/demo/app/docker-compose.yaml | 5 +++-- .../valkey/caching/demo/sample-data/docker-compose.yaml | 4 ++-- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/memorystore/valkey/caching/demo/app/docker-compose.yaml b/memorystore/valkey/caching/demo/app/docker-compose.yaml index 750227f8ef7..14c09a7ad7e 100644 --- a/memorystore/valkey/caching/demo/app/docker-compose.yaml +++ b/memorystore/valkey/caching/demo/app/docker-compose.yaml @@ -1,16 +1,17 @@ -# Copyright 2025 Google Inc. +# Copyright 2025 Google LLC # # 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://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. + name: caching-app services: diff --git a/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml index 6214593b602..985ac91cff6 100644 --- a/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml +++ b/memorystore/valkey/caching/demo/sample-data/docker-compose.yaml @@ -1,10 +1,10 @@ -# Copyright 2025 Google Inc. +# Copyright 2025 Google LLC # # 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 # -# http://www.apache.org/licenses/LICENSE-2.0 +# http://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, From b4035758e269f7f3e8e8a38c45987bfeb92feb60 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Mon, 3 Feb 2025 10:19:15 +0000 Subject: [PATCH 6/8] fix: updated code --- .../app/src/main/java/app/DataController.java | 106 ++++++++------ .../app/src/main/java/app/HomeController.java | 16 --- .../demo/app/src/main/java/app/Item.java | 36 ++--- .../app/src/main/java/app/ItemController.java | 42 +++--- .../src/main/java/app/ItemsRepository.java | 87 ++++++------ .../app/src/main/java/app/JdbcConfig.java | 25 ++-- .../app/src/main/java/app/JedisConfig.java | 27 ++-- .../demo/app/src/main/java/app/Main.java | 17 +-- .../demo/app/src/main/java/app/WebConfig.java | 30 ++-- .../src/test/java/app/DataControllerTest.java | 74 +++++----- .../src/test/java/app/ItemControllerTest.java | 134 +++++++++--------- 11 files changed, 269 insertions(+), 325 deletions(-) diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java index 3f8abeb2670..5d14a9304fa 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java @@ -1,23 +1,8 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. - */ - /** - * Responsible for handling the data operations. Handles checking cache first, then database, and - * updating the cache. + * Responsible for handling the data operations. + * Handles checking cache first, then database, and updating the cache. */ + package app; import java.util.List; @@ -42,15 +27,20 @@ public DataController(ItemsRepository cacheRepository, Jedis jedis) { public Item get(long id) { String idString = Long.toString(id); - // Check if the data exists in the cache first - if (jedis.exists(idString)) { - // If the data exists in the cache extend the TTL - jedis.expire(idString, DEFAULT_TTL); - - // Return the cached data - Item cachedItem = Item.fromJSONString(jedis.get(idString)); - cachedItem.setFromCache(true); - return cachedItem; + // Use try-catch to avoid missing the database if there's an error with the + // cache + try { + // Check if the data exists in the cache first + String cachedValue = jedis.get(idString); + if (cachedValue != null) { + // Return the cached data + Item cachedItem = Item.fromJSONString(cachedValue); + cachedItem.setFromCache(true); + return cachedItem; + } + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); } Optional item = itemsRepository.get(id); @@ -60,9 +50,15 @@ public Item get(long id) { return null; } - // Cache result from the database with the default TTL - jedis.set(idString, item.get().toJSONObject().toString()); - jedis.expire(idString, DEFAULT_TTL); + // Use try-catch to avoid missing returning the data if there's an error with + // the cache + try { + // Cache result from the database with the default TTL + jedis.setex(idString, DEFAULT_TTL, item.get().toJSONObject().toString()); + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } return item.get(); } @@ -77,12 +73,21 @@ public long create(Item item) { long itemId = itemsRepository.create(item); // Clone the item with the generated ID - Item createdItem = new Item(itemId, item.getName(), item.getDescription(), item.getPrice()); - - // Cache the data with the default TTL - String idString = Long.toString(itemId); - jedis.set(idString, createdItem.toJSONObject().toString()); - jedis.expire(idString, DEFAULT_TTL); + Item createdItem = new Item( + itemId, + item.getName(), + item.getDescription(), + item.getPrice()); + + // Use try-catch to avoid returning the data if there's an error with the cache + try { + // Cache the data with the default TTL + String idString = Long.toString(itemId); + jedis.setex(idString, DEFAULT_TTL, createdItem.toJSONObject().toString()); + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } return itemId; } @@ -91,17 +96,36 @@ public void delete(long id) { // Delete the data from database itemsRepository.delete(id); - // Also, delete the data from the cache if it exists - String idString = Long.toString(id); - if (jedis.exists(idString)) { - jedis.del(idString); + // Use try-catch to avoid missing the cache if there's an error with the cache + try { + // Also, delete the data from the cache if it exists + String idString = Long.toString(id); + long totalDeleted = jedis.del(idString); + + if (totalDeleted == 0) { + throw new Exception("Item not found in cache"); + } + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); } } public boolean exists(long id) { String idString = Long.toString(id); + // Use try-catch to avoid missing the database if there's an error with the + // cache + boolean cacheExists = false; + try { + // Check if the data exists in the cache + cacheExists = jedis.exists(idString); + } catch (Exception e) { + // If there's an error with the cache, log the error and continue + System.err.println("Error with cache: " + e.getMessage()); + } + // Check if the data exists in the cache or the database (check the cache first) - return jedis.exists(idString) || itemsRepository.exists(id); + return cacheExists || itemsRepository.exists(id); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java index cf83ec9a0af..7acf5478cef 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java @@ -1,19 +1,3 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. - */ - package app; import org.springframework.beans.factory.annotation.Value; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java index 87397d145c4..b34c0b1c797 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java @@ -1,20 +1,7 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. +/** + * Data class representing an item in the application. */ -/** Data class representing an item in the application. */ package app; import jakarta.validation.constraints.NotNull; @@ -25,11 +12,15 @@ public class Item { private final Long id; - @NotNull private final String name; + @NotNull + private final String name; - @NotNull private final String description; + @NotNull + private final String description; - @NotNull @Positive private final Double price; + @NotNull + @Positive + private final Double price; private boolean fromCache; @@ -87,9 +78,10 @@ public JSONObject toJSONObject() { public static Item fromJSONString(String obj) { JSONObject jsonObject = new JSONObject(obj); return new Item( - jsonObject.getLong("id"), - jsonObject.getString("name"), - jsonObject.getString("description"), - jsonObject.getDouble("price")); + jsonObject.getLong("id"), + jsonObject.getString("name"), + jsonObject.getString("description"), + jsonObject.getDouble("price") + ); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java index a10e3a37f59..c87d5dbbb01 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java @@ -1,25 +1,12 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. - */ - /** * Provides a RESTful API for interacting with the application's data. * - *

The controller contains three routes: - GET /item/{id} - Get an item by ID - POST /item/create - * - Create a new item - DELETE /item/delete/{id} - Delete an item by ID + * The controller contains three routes: + * - GET /item/{id} - Get an item by ID + * - POST /item/create - Create a new item + * - DELETE /item/delete/{id} - Delete an item by ID */ + package app; import jakarta.validation.Valid; @@ -53,24 +40,35 @@ public ResponseEntity read(@PathVariable Long id) { @GetMapping("/random") public ResponseEntity read() { return ResponseEntity.ok( - new JSONObject().put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)).toString()); + new JSONObject() + .put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)) + .toString() + ); } @PostMapping("/create") public ResponseEntity create(@Valid @RequestBody Item item) { /** Create a new item */ - Item createdItem = new Item(item.getName(), item.getDescription(), item.getPrice()); + Item createdItem = new Item( + item.getName(), + item.getDescription(), + item.getPrice() + ); /** Save the item */ long itemId = dataController.create(createdItem); /** Return a successful response */ - return ResponseEntity.ok(JSONObject.valueToString(new JSONObject().put("id", itemId))); + return ResponseEntity.ok( + JSONObject.valueToString(new JSONObject().put("id", itemId)) + ); } @DeleteMapping("/delete/{id}") public ResponseEntity delete(@PathVariable long id) { dataController.delete(id); - return ResponseEntity.ok(JSONObject.valueToString(new JSONObject().put("id", id))); + return ResponseEntity.ok( + JSONObject.valueToString(new JSONObject().put("id", id)) + ); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java index 639ab5d966e..5707d49fab4 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java @@ -1,20 +1,7 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. +/** + * Handles CRUD operations for the items table. */ -/** Handles CRUD operations for the items table. */ package app; import java.sql.PreparedStatement; @@ -39,15 +26,18 @@ public ItemsRepository(JdbcTemplate jdbcTemplate) { public Optional get(long id) { try { return Optional.ofNullable( - jdbcTemplate.queryForObject( - "SELECT * FROM items WHERE id = ?", - (rs, rowNum) -> - new Item( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getDouble("price")), - id)); + jdbcTemplate.queryForObject( + "SELECT * FROM items WHERE id = ?", + (rs, rowNum) -> + new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price") + ), + id + ) + ); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } @@ -55,14 +45,16 @@ public Optional get(long id) { public List getMultiple(int amount) { return jdbcTemplate.query( - "SELECT * FROM items ORDER BY random() LIMIT ?", - (rs, rowNum) -> - new Item( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getDouble("price")), - amount); + "SELECT * FROM items ORDER BY random() LIMIT ?", + (rs, rowNum) -> + new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price") + ), + amount + ); } public long create(Item item) { @@ -73,24 +65,25 @@ public long create(Item item) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update( - connection -> { - PreparedStatement ps = - connection.prepareStatement( - "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", - new String[] {"id"} // Explicitly specify the generated key column - ); - ps.setString(1, name); - ps.setString(2, description); - ps.setDouble(3, price); - return ps; - }, - keyHolder); + connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", + new String[] { "id" } // Explicitly specify the generated key column + ); + ps.setString(1, name); + ps.setString(2, description); + ps.setDouble(3, price); + return ps; + }, + keyHolder + ); // Ensure the keyHolder contains the generated ID only Map keys = keyHolder.getKeys(); if (keys != null && keys.size() > 1) { throw new IllegalStateException( - "Expected a single key, but multiple keys were returned: " + keys); + "Expected a single key, but multiple keys were returned: " + keys + ); } Number key = keyHolder.getKey(); @@ -110,6 +103,8 @@ public boolean exists(long id) { String query = "SELECT EXISTS(SELECT 1 FROM items WHERE id = ?)"; /** Return query result */ - return Boolean.TRUE.equals(jdbcTemplate.queryForObject(query, Boolean.class, id)); + return Boolean.TRUE.equals( + jdbcTemplate.queryForObject(query, Boolean.class, id) + ); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java index 5e95a2e2f24..048c8e5fe0b 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java @@ -1,20 +1,7 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. +/** + * Configuration for the JDBC DataSource to connect to the PostgreSQL server. */ -/** Configuration for the JDBC DataSource to connect to the PostgreSQL server. */ package app; import javax.sql.DataSource; @@ -40,10 +27,14 @@ public class JdbcConfig { public DataSource dataSource() { // Validate mandatory properties if (url == null || url.isEmpty()) { - throw new IllegalArgumentException("Database URL (DB_URL) is not configured"); + throw new IllegalArgumentException( + "Database URL (DB_URL) is not configured" + ); } if (username == null || username.isEmpty()) { - throw new IllegalArgumentException("Database username (DB_USERNAME) is not configured"); + throw new IllegalArgumentException( + "Database username (DB_USERNAME) is not configured" + ); } // Set up the DataSource diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java index f4ed7e8129b..fbfb4e4acc5 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java @@ -1,20 +1,7 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. +/** + * Configuration for the Jedis client to connect to the Valkey server. */ -/** Configuration for the Jedis client to connect to the Valkey server. */ package app; import org.springframework.beans.factory.annotation.Value; @@ -39,7 +26,9 @@ public class JedisConfig { public Jedis jedis() { // Validate mandatory properties if (redisHost == null || redisHost.isEmpty()) { - throw new IllegalArgumentException("Redis host (VALKEY_HOST) is not configured"); + throw new IllegalArgumentException( + "Redis host (VALKEY_HOST) is not configured" + ); } if (redisPort <= 0 || redisPort > 65535) { throw new IllegalArgumentException("Redis port (VALKEY_PORT) is invalid"); @@ -56,8 +45,10 @@ public Jedis jedis() { try { jedis.ping(); } catch (Exception e) { - String msg = "Failed to connect to Redis server at " + redisHost + ":" + redisPort; - throw new RuntimeException(msg, e); + throw new RuntimeException( + "Failed to connect to Redis server at " + redisHost + ":" + redisPort, + e + ); } return jedis; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java index 4b974be8c90..8b441a11cf1 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java @@ -1,20 +1,7 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. +/** + * Main class to start the Spring Boot application. */ -/** Main class to start the Spring Boot application. */ package app; import org.springframework.boot.SpringApplication; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java index 8b4051cf61f..d9392732c13 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java @@ -1,20 +1,7 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. +/** + * Use this configuration to allow CORS requests from the frontend. */ -/** Use this configuration to allow CORS requests from the frontend. */ package app; import org.springframework.beans.factory.annotation.Value; @@ -29,8 +16,7 @@ public class WebConfig { @Value("${ALLOWED_ORIGINS:localhost:3000}") // Default to localhost:3000 if not set private String allowedOrigins; - @Value( - "${ALLOWED_METHODS:GET,POST,PUT,DELETE}") // Default to GET,POST,PUT,DELETE methods if not set + @Value("${ALLOWED_METHODS:GET,POST,PUT,DELETE}") // Default to GET,POST,PUT,DELETE methods if not set private String allowedMethods; @Value("${ALLOWED_HEADERS:*}") // Default to all headers if not set @@ -42,11 +28,11 @@ public WebMvcConfigurer corsConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry - .addMapping("/**") // Allow all endpoints - .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend - .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods - .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers - .allowCredentials(true); // Allow cookies and credentials + .addMapping("/**") // Allow all endpoints + .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend + .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods + .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers + .allowCredentials(true); // Allow cookies and credentials } }; } diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java index 2098053076d..067d774faaf 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java @@ -1,19 +1,3 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. - */ - package app; import static org.junit.jupiter.api.Assertions.*; @@ -34,9 +18,11 @@ @ExtendWith(MockitoExtension.class) class DataControllerTest { - @Mock private ItemsRepository itemsRepository; // Mocked repository for database interactions + @Mock + private ItemsRepository itemsRepository; // Mocked repository for database interactions - @Mock private Jedis jedis; // Mocked Redis client for cache interactions + @Mock + private Jedis jedis; // Mocked Redis client for cache interactions private DataController dataController; // System under test @@ -60,18 +46,15 @@ void testGet_ItemInCache() { long itemId = 1; String itemIdStr = Long.toString(itemId); String cachedData = - "{\"id\":1,\"name\":\"Cached Item\",\"description\":\"Cached" - + " description\",\"price\":10.5}"; + "{\"id\":1,\"name\":\"Cached Item\",\"description\":\"Cached description\",\"price\":10.5}"; Item cachedItem = Item.fromJSONString(cachedData); - given(jedis.exists(itemIdStr)).willReturn(true); // Cache contains the item given(jedis.get(itemIdStr)).willReturn(cachedData); // Act: Call the get() method Item result = dataController.get(itemId); // Assert: Verify cache is used, database is not queried, and correct item is returned - verify(jedis).expire(itemIdStr, DataController.DEFAULT_TTL); // Extend TTL in cache verify(itemsRepository, never()).get(anyLong()); // Database should not be called assertEquals(cachedItem.getId(), result.getId()); assertEquals(cachedItem.getName(), result.getName()); @@ -79,43 +62,53 @@ void testGet_ItemInCache() { } @Test - @DisplayName("Should return item from database and cache it if not in cache") + @DisplayName( + "Should return item from database and cache it if not in cache" + ) void testGet_ItemNotInCache() { // Arrange: Item is not in cache but exists in the database long itemId = 2; String itemIdStr = Long.toString(itemId); Item dbItem = new Item(2L, "Database Item", "From DB", 15.99); - given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss - given(itemsRepository.get(itemId)) - .willReturn(Optional.of(dbItem)); // Database contains the item + given(jedis.get(itemIdStr)).willReturn(null); + given(itemsRepository.get(itemId)).willReturn(Optional.of(dbItem)); // Database contains the item // Act: Call the get() method Item result = dataController.get(itemId); // Assert: Verify database usage, cache update, and correct item return - verify(jedis).set(itemIdStr, dbItem.toJSONObject().toString()); // Add item to cache - verify(jedis).expire(itemIdStr, DataController.DEFAULT_TTL); // Set TTL for cache + verify(jedis).setex( + itemIdStr, + DataController.DEFAULT_TTL, + dbItem.toJSONObject().toString() + ); // Add item to cache assertEquals(dbItem.getId(), result.getId()); assertEquals(dbItem.getName(), result.getName()); assertEquals(false, result.isFromCache()); } @Test - @DisplayName("Should return null if item does not exist in cache or database") + @DisplayName( + "Should return null if item does not exist in cache or database" + ) void testGet_ItemNotFound() { // Arrange: Item does not exist in cache or database long itemId = 3; String itemIdStr = Long.toString(itemId); - given(jedis.exists(itemIdStr)).willReturn(false); // Cache miss + given(jedis.get(itemIdStr)).willReturn(null); // Cache miss given(itemsRepository.get(itemId)).willReturn(Optional.empty()); // Database miss // Act: Call the get() method Item result = dataController.get(itemId); // Assert: Verify no cache update and null return - verify(jedis, never()).set(anyString(), anyString()); // Cache should not be updated + verify(jedis, never()).setex( + anyString(), + eq(DataController.DEFAULT_TTL), + anyString() + ); // Cache should not be updated assertNull(result); } } @@ -139,10 +132,17 @@ void testCreate() { long result = dataController.create(item); // Assert: Verify cache and database interactions - Item expectedItem = new Item(0L, item.getName(), item.getDescription(), item.getPrice()); - verify(jedis) - .set(Long.toString(result), expectedItem.toJSONObject().toString()); // Add item to cache - verify(jedis).expire(Long.toString(result), DataController.DEFAULT_TTL); // Set TTL for cache + Item expectedItem = new Item( + 0L, + item.getName(), + item.getDescription(), + item.getPrice() + ); + verify(jedis).setex( + Long.toString(result), + DataController.DEFAULT_TTL, + expectedItem.toJSONObject().toString() + ); // Add item to cache with TTL assertEquals(0L, result); // Validate returned ID } } @@ -231,7 +231,9 @@ void testExists_ItemInDatabase() { } @Test - @DisplayName("Should return false if item does not exist in cache or database") + @DisplayName( + "Should return false if item does not exist in cache or database" + ) void testExists_ItemNotFound() { // Arrange: Item does not exist in cache or database long itemId = 10; diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java index dcf6d060fab..fb875de3c1a 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java @@ -1,19 +1,3 @@ -/* - * Copyright 2025 Google LLC - * - * 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 - * - * http://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. - */ - package app; import static org.mockito.ArgumentMatchers.any; @@ -39,9 +23,11 @@ @WebMvcTest(ItemController.class) class ItemControllerTest { - @Autowired private MockMvc mockMvc; // MockMvc is used to perform HTTP requests in tests + @Autowired + private MockMvc mockMvc; // MockMvc is used to perform HTTP requests in tests - @MockBean private DataController dataController; // Mocked dependency of ItemController + @MockBean + private DataController dataController; // Mocked dependency of ItemController @Test @DisplayName("Test reading an item by ID") @@ -54,20 +40,19 @@ void testReadItem() throws Exception { // Act: Perform GET /item/1 mockMvc - .perform(get("/api/item/{id}", itemId)) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect( - content().string(Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 - .andExpect( - content() - .string( - Matchers.containsString("\"name\":\"ItemName\""))) // Assert JSON contains name - .andExpect( - content() - .string( - Matchers.containsString( - "\"description\":\"ItemDescription\""))) // Assert description - .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string(Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 + .andExpect( + content().string(Matchers.containsString("\"name\":\"ItemName\"")) + ) // Assert JSON contains name + .andExpect( + content() + .string( + Matchers.containsString("\"description\":\"ItemDescription\"") + ) + ) // Assert description + .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price // Assert: Verify DataController's get method was called with the correct ID verify(dataController).get(itemId); @@ -82,8 +67,8 @@ void testReadItem_NotFound() throws Exception { // Act: Perform GET /item/2 mockMvc - .perform(get("/api/item/{id}", itemId)) - .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found // Assert: Verify DataController's get method was called verify(dataController).get(itemId); @@ -104,20 +89,29 @@ void testReadMultipleItems() throws Exception { Item item9 = new Item(9L, "Item9", "Description9", 900.0); Item item10 = new Item(10L, "Item10", "Description10", 1000.0); - List items = - List.of(item1, item2, item3, item4, item5, item6, item7, item8, item9, item10); + List items = List.of( + item1, + item2, + item3, + item4, + item5, + item6, + item7, + item8, + item9, + item10 + ); given(dataController.getMultiple(10)).willReturn(items); // Act: Perform GET /random mockMvc - .perform(get("/api/item/random")) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect( - jsonPath("$.items.length()").value(10)) // Assert the `items` array has 10 elements - .andExpect(jsonPath("$.items[0].id").value(1)) // Check first item's ID - .andExpect(jsonPath("$.items[0].name").value("Item1")) // Check first item's name - .andExpect(jsonPath("$.items[9].id").value(10)) // Check last item's ID - .andExpect(jsonPath("$.items[9].name").value("Item10")); // Check last item's name + .perform(get("/api/item/random")) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(jsonPath("$.items.length()").value(10)) // Assert the `items` array has 10 elements + .andExpect(jsonPath("$.items[0].id").value(1)) // Check first item's ID + .andExpect(jsonPath("$.items[0].name").value("Item1")) // Check first item's name + .andExpect(jsonPath("$.items[9].id").value(10)) // Check last item's ID + .andExpect(jsonPath("$.items[9].name").value("Item10")); // Check last item's name // Assert: Verify DataController's getMultiple method was called with the correct parameter verify(dataController).getMultiple(10); @@ -135,23 +129,23 @@ void testCreateItem() throws Exception { JSONObject itemJson = item.toJSONObject(); itemJson.remove("id"); // Remove ID from JSON for creation mockMvc - .perform( - post("/api/item/create") - .contentType("application/json") // Specify JSON content type - .content(itemJson.toString()) // Convert JSON to string - ) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string("{\"id\":0}")); // Assert response message - - // Assert: Verify DataController's create method was called with an argument matching the - // expected properties - verify(dataController) - .create( - argThat( - argument -> - argument.getName().equals("NewItem") - && argument.getDescription().equals("NewDescription") - && argument.getPrice() == 200.0)); + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content(itemJson.toString()) // Convert JSON to string + ) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":0}")); // Assert response message + + // Assert: Verify DataController's create method was called with an argument matching the expected properties + verify(dataController).create( + argThat( + argument -> + argument.getName().equals("NewItem") && + argument.getDescription().equals("NewDescription") && + argument.getPrice() == 200.0 + ) + ); } @Test @@ -159,12 +153,12 @@ void testCreateItem() throws Exception { void testCreateItem_Validations() throws Exception { // Act: Perform POST /item/create with an invalid request body (missing required fields) mockMvc - .perform( - post("/api/item/create") - .contentType("application/json") // Specify JSON content type - .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) - ) - .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) + ) + .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request // Assert: Verify DataController's create method was not called verify(dataController, never()).create(any()); @@ -176,9 +170,9 @@ void testDeleteItem() throws Exception { // Act: Perform DELETE /item/delete/5 long itemId = 5; mockMvc - .perform(delete("/api/item/delete/{id}", itemId)) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string("{\"id\":5}")); // Assert response message + .perform(delete("/api/item/delete/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":5}")); // Assert response message // Assert: Verify DataController's delete method was called with the correct ID verify(dataController).delete(itemId); From 8e3aa6867cde73c9c77cb304986661adb0d866ae Mon Sep 17 00:00:00 2001 From: Jacob Cable Date: Wed, 5 Feb 2025 13:52:09 +0000 Subject: [PATCH 7/8] chore(memorystore-valkey-caching-demo): lint demo app --- memorystore/valkey/caching/demo/app/pom.xml | 10 ++ .../app/src/main/java/app/DataController.java | 22 ++- .../app/src/main/java/app/HomeController.java | 16 ++ .../demo/app/src/main/java/app/Item.java | 29 ++- .../app/src/main/java/app/ItemController.java | 46 +++-- .../src/main/java/app/ItemsRepository.java | 82 +++++---- .../app/src/main/java/app/JdbcConfig.java | 16 ++ .../app/src/main/java/app/JedisConfig.java | 24 ++- .../demo/app/src/main/java/app/Main.java | 17 ++ .../demo/app/src/main/java/app/WebConfig.java | 36 +++- .../src/test/java/app/DataControllerTest.java | 86 +++++---- .../src/test/java/app/ItemControllerTest.java | 168 +++++++++++------- .../sample-data/src/main/java/app/Main.java | 54 ++++-- .../src/main/java/app/package-info.java | 18 ++ 14 files changed, 436 insertions(+), 188 deletions(-) create mode 100644 memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java diff --git a/memorystore/valkey/caching/demo/app/pom.xml b/memorystore/valkey/caching/demo/app/pom.xml index 24c1ead04d1..08d1f745a37 100644 --- a/memorystore/valkey/caching/demo/app/pom.xml +++ b/memorystore/valkey/caching/demo/app/pom.xml @@ -8,6 +8,16 @@ app 1.0-SNAPSHOT + + + com.google.cloud.samples + shared-configuration + 1.2.0 + + 17 17 diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java index 5d14a9304fa..8e88f1f62ed 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/DataController.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Responsible for handling the data operations. * Handles checking cache first, then database, and updating the cache. @@ -34,7 +50,7 @@ public Item get(long id) { String cachedValue = jedis.get(idString); if (cachedValue != null) { // Return the cached data - Item cachedItem = Item.fromJSONString(cachedValue); + Item cachedItem = Item.fromJsonString(cachedValue); cachedItem.setFromCache(true); return cachedItem; } @@ -54,7 +70,7 @@ public Item get(long id) { // the cache try { // Cache result from the database with the default TTL - jedis.setex(idString, DEFAULT_TTL, item.get().toJSONObject().toString()); + jedis.setex(idString, DEFAULT_TTL, item.get().toJsonObject().toString()); } catch (Exception e) { // If there's an error with the cache, log the error and continue System.err.println("Error with cache: " + e.getMessage()); @@ -83,7 +99,7 @@ public long create(Item item) { try { // Cache the data with the default TTL String idString = Long.toString(itemId); - jedis.setex(idString, DEFAULT_TTL, createdItem.toJSONObject().toString()); + jedis.setex(idString, DEFAULT_TTL, createdItem.toJsonObject().toString()); } catch (Exception e) { // If there's an error with the cache, log the error and continue System.err.println("Error with cache: " + e.getMessage()); diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java index 7acf5478cef..cf83ec9a0af 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/HomeController.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; import org.springframework.beans.factory.annotation.Value; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java index b34c0b1c797..a97dde5f4e5 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Item.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Data class representing an item in the application. */ @@ -64,7 +80,7 @@ public void setFromCache(boolean fromCache) { this.fromCache = fromCache; } - public JSONObject toJSONObject() { + public JSONObject toJsonObject() { JSONObject obj = new JSONObject(); obj.put("id", this.id); obj.put("name", this.name); @@ -75,13 +91,12 @@ public JSONObject toJSONObject() { return obj; } - public static Item fromJSONString(String obj) { + public static Item fromJsonString(String obj) { JSONObject jsonObject = new JSONObject(obj); return new Item( - jsonObject.getLong("id"), - jsonObject.getString("name"), - jsonObject.getString("description"), - jsonObject.getDouble("price") - ); + jsonObject.getLong("id"), + jsonObject.getString("name"), + jsonObject.getString("description"), + jsonObject.getDouble("price")); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java index c87d5dbbb01..f206444d92d 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemController.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Provides a RESTful API for interacting with the application's data. * @@ -12,7 +28,13 @@ import jakarta.validation.Valid; import org.json.JSONObject; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.*; +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.RestController; @RestController @RequestMapping("/api/item") @@ -34,41 +56,37 @@ public ResponseEntity read(@PathVariable Long id) { return ResponseEntity.notFound().build(); } - return ResponseEntity.ok(item.toJSONObject().toString()); + return ResponseEntity.ok(item.toJsonObject().toString()); } @GetMapping("/random") public ResponseEntity read() { return ResponseEntity.ok( - new JSONObject() - .put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)) - .toString() - ); + new JSONObject() + .put("items", dataController.getMultiple(TOTAL_RANDOM_ITEMS)) + .toString()); } @PostMapping("/create") public ResponseEntity create(@Valid @RequestBody Item item) { /** Create a new item */ Item createdItem = new Item( - item.getName(), - item.getDescription(), - item.getPrice() - ); + item.getName(), + item.getDescription(), + item.getPrice()); /** Save the item */ long itemId = dataController.create(createdItem); /** Return a successful response */ return ResponseEntity.ok( - JSONObject.valueToString(new JSONObject().put("id", itemId)) - ); + JSONObject.valueToString(new JSONObject().put("id", itemId))); } @DeleteMapping("/delete/{id}") public ResponseEntity delete(@PathVariable long id) { dataController.delete(id); return ResponseEntity.ok( - JSONObject.valueToString(new JSONObject().put("id", id)) - ); + JSONObject.valueToString(new JSONObject().put("id", id))); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java index 5707d49fab4..2d6f2e6c6fd 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/ItemsRepository.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Handles CRUD operations for the items table. */ @@ -26,18 +42,14 @@ public ItemsRepository(JdbcTemplate jdbcTemplate) { public Optional get(long id) { try { return Optional.ofNullable( - jdbcTemplate.queryForObject( - "SELECT * FROM items WHERE id = ?", - (rs, rowNum) -> - new Item( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getDouble("price") - ), - id - ) - ); + jdbcTemplate.queryForObject( + "SELECT * FROM items WHERE id = ?", + (rs, rowNum) -> new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price")), + id)); } catch (EmptyResultDataAccessException e) { return Optional.empty(); } @@ -45,16 +57,13 @@ public Optional get(long id) { public List getMultiple(int amount) { return jdbcTemplate.query( - "SELECT * FROM items ORDER BY random() LIMIT ?", - (rs, rowNum) -> - new Item( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description"), - rs.getDouble("price") - ), - amount - ); + "SELECT * FROM items ORDER BY random() LIMIT ?", + (rs, rowNum) -> new Item( + rs.getLong("id"), + rs.getString("name"), + rs.getString("description"), + rs.getDouble("price")), + amount); } public long create(Item item) { @@ -65,25 +74,23 @@ public long create(Item item) { KeyHolder keyHolder = new GeneratedKeyHolder(); jdbcTemplate.update( - connection -> { - PreparedStatement ps = connection.prepareStatement( - "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", - new String[] { "id" } // Explicitly specify the generated key column - ); - ps.setString(1, name); - ps.setString(2, description); - ps.setDouble(3, price); - return ps; - }, - keyHolder - ); + connection -> { + PreparedStatement ps = connection.prepareStatement( + "INSERT INTO items (name, description, price) VALUES (?, ?, ?)", + new String[] { "id" } // Explicitly specify the generated key column + ); + ps.setString(1, name); + ps.setString(2, description); + ps.setDouble(3, price); + return ps; + }, + keyHolder); // Ensure the keyHolder contains the generated ID only Map keys = keyHolder.getKeys(); if (keys != null && keys.size() > 1) { throw new IllegalStateException( - "Expected a single key, but multiple keys were returned: " + keys - ); + "Expected a single key, but multiple keys were returned: " + keys); } Number key = keyHolder.getKey(); @@ -104,7 +111,6 @@ public boolean exists(long id) { /** Return query result */ return Boolean.TRUE.equals( - jdbcTemplate.queryForObject(query, Boolean.class, id) - ); + jdbcTemplate.queryForObject(query, Boolean.class, id)); } } diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java index 048c8e5fe0b..17891e6bd5d 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JdbcConfig.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Configuration for the JDBC DataSource to connect to the PostgreSQL server. */ diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java index fbfb4e4acc5..6b76fc93089 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/JedisConfig.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Configuration for the Jedis client to connect to the Valkey server. */ @@ -27,8 +43,7 @@ public Jedis jedis() { // Validate mandatory properties if (redisHost == null || redisHost.isEmpty()) { throw new IllegalArgumentException( - "Redis host (VALKEY_HOST) is not configured" - ); + "Redis host (VALKEY_HOST) is not configured"); } if (redisPort <= 0 || redisPort > 65535) { throw new IllegalArgumentException("Redis port (VALKEY_PORT) is invalid"); @@ -46,9 +61,8 @@ public Jedis jedis() { jedis.ping(); } catch (Exception e) { throw new RuntimeException( - "Failed to connect to Redis server at " + redisHost + ":" + redisPort, - e - ); + "Failed to connect to Redis server at " + redisHost + ":" + redisPort, + e); } return jedis; diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java index 8b441a11cf1..113c5532129 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/Main.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Main class to start the Spring Boot application. */ @@ -14,3 +30,4 @@ public static void main(String[] args) { SpringApplication.run(Main.class, args); } } + \ No newline at end of file diff --git a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java index d9392732c13..1c8c3014ec9 100644 --- a/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java +++ b/memorystore/valkey/caching/demo/app/src/main/java/app/WebConfig.java @@ -1,3 +1,19 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + /** * Use this configuration to allow CORS requests from the frontend. */ @@ -13,13 +29,15 @@ @Configuration public class WebConfig { - @Value("${ALLOWED_ORIGINS:localhost:3000}") // Default to localhost:3000 if not set + // Default to localhost:3000 if not set + @Value("${ALLOWED_ORIGINS:localhost:3000}") private String allowedOrigins; - - @Value("${ALLOWED_METHODS:GET,POST,PUT,DELETE}") // Default to GET,POST,PUT,DELETE methods if not set + // Default to GET,POST,PUT,DELETE methods if not set + @Value("${ALLOWED_METHODS:GET,POST,PUT,DELETE}") private String allowedMethods; - @Value("${ALLOWED_HEADERS:*}") // Default to all headers if not set + // Default to all headers if not set + @Value("${ALLOWED_HEADERS:*}") private String allowedHeaders; @Bean @@ -28,11 +46,11 @@ public WebMvcConfigurer corsConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry - .addMapping("/**") // Allow all endpoints - .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend - .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods - .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers - .allowCredentials(true); // Allow cookies and credentials + .addMapping("/**") // Allow all endpoints + .allowedOrigins(allowedOrigins.split(",")) // Allow requests from the frontend + .allowedMethods(allowedMethods.split(",")) // Restrict HTTP methods + .allowedHeaders(allowedHeaders.split(",")) // Specify allowed headers + .allowCredentials(true); // Allow cookies and credentials } }; } diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java index 067d774faaf..60a494ef003 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/DataControllerTest.java @@ -1,9 +1,31 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; -import static org.junit.jupiter.api.Assertions.*; -import static org.mockito.ArgumentMatchers.*; -import static org.mockito.BDDMockito.*; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import java.util.Optional; import org.junit.jupiter.api.BeforeEach; @@ -11,7 +33,7 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.*; +import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import redis.clients.jedis.Jedis; @@ -45,16 +67,17 @@ void testGet_ItemInCache() { // Arrange: Item exists in cache long itemId = 1; String itemIdStr = Long.toString(itemId); - String cachedData = - "{\"id\":1,\"name\":\"Cached Item\",\"description\":\"Cached description\",\"price\":10.5}"; - Item cachedItem = Item.fromJSONString(cachedData); + String cachedData = "{\"id\":1,\"name\":\"Cached Item\"" + + ",\"description\":\"Cached description\",\"price\":10.5}"; + Item cachedItem = Item.fromJsonString(cachedData); given(jedis.get(itemIdStr)).willReturn(cachedData); // Act: Call the get() method Item result = dataController.get(itemId); - // Assert: Verify cache is used, database is not queried, and correct item is returned + // Assert: Verify cache is used, database is not queried, and correct item is + // returned verify(itemsRepository, never()).get(anyLong()); // Database should not be called assertEquals(cachedItem.getId(), result.getId()); assertEquals(cachedItem.getName(), result.getName()); @@ -62,9 +85,7 @@ void testGet_ItemInCache() { } @Test - @DisplayName( - "Should return item from database and cache it if not in cache" - ) + @DisplayName("Should return item from database and cache it if not in cache") void testGet_ItemNotInCache() { // Arrange: Item is not in cache but exists in the database long itemId = 2; @@ -72,26 +93,26 @@ void testGet_ItemNotInCache() { Item dbItem = new Item(2L, "Database Item", "From DB", 15.99); given(jedis.get(itemIdStr)).willReturn(null); - given(itemsRepository.get(itemId)).willReturn(Optional.of(dbItem)); // Database contains the item + given( + itemsRepository + .get(itemId)) + .willReturn(Optional.of(dbItem)); // Database contains the item // Act: Call the get() method Item result = dataController.get(itemId); // Assert: Verify database usage, cache update, and correct item return verify(jedis).setex( - itemIdStr, - DataController.DEFAULT_TTL, - dbItem.toJSONObject().toString() - ); // Add item to cache + itemIdStr, + DataController.DEFAULT_TTL, + dbItem.toJsonObject().toString()); // Add item to cache assertEquals(dbItem.getId(), result.getId()); assertEquals(dbItem.getName(), result.getName()); assertEquals(false, result.isFromCache()); } @Test - @DisplayName( - "Should return null if item does not exist in cache or database" - ) + @DisplayName("Should return null if item does not exist in cache or database") void testGet_ItemNotFound() { // Arrange: Item does not exist in cache or database long itemId = 3; @@ -105,10 +126,9 @@ void testGet_ItemNotFound() { // Assert: Verify no cache update and null return verify(jedis, never()).setex( - anyString(), - eq(DataController.DEFAULT_TTL), - anyString() - ); // Cache should not be updated + anyString(), + eq(DataController.DEFAULT_TTL), + anyString()); // Cache should not be updated assertNull(result); } } @@ -133,16 +153,14 @@ void testCreate() { // Assert: Verify cache and database interactions Item expectedItem = new Item( - 0L, - item.getName(), - item.getDescription(), - item.getPrice() - ); + 0L, + item.getName(), + item.getDescription(), + item.getPrice()); verify(jedis).setex( - Long.toString(result), - DataController.DEFAULT_TTL, - expectedItem.toJSONObject().toString() - ); // Add item to cache with TTL + Long.toString(result), + DataController.DEFAULT_TTL, + expectedItem.toJsonObject().toString()); // Add item to cache with TTL assertEquals(0L, result); // Validate returned ID } } @@ -231,9 +249,7 @@ void testExists_ItemInDatabase() { } @Test - @DisplayName( - "Should return false if item does not exist in cache or database" - ) + @DisplayName("Should return false if item does not exist in cache or database") void testExists_ItemNotFound() { // Arrange: Item does not exist in cache or database long itemId = 10; diff --git a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java index fb875de3c1a..e84ba92d04e 100644 --- a/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java +++ b/memorystore/valkey/caching/demo/app/src/test/java/app/ItemControllerTest.java @@ -1,14 +1,32 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + package app; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.argThat; -import static org.mockito.BDDMockito.*; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; 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.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import java.util.List; import org.hamcrest.Matchers; @@ -40,19 +58,24 @@ void testReadItem() throws Exception { // Act: Perform GET /item/1 mockMvc - .perform(get("/api/item/{id}", itemId)) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string(Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 - .andExpect( - content().string(Matchers.containsString("\"name\":\"ItemName\"")) - ) // Assert JSON contains name - .andExpect( - content() - .string( - Matchers.containsString("\"description\":\"ItemDescription\"") - ) - ) // Assert description - .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect( + content() + .string( + Matchers.containsString("\"id\":1"))) // Assert JSON contains "id":1 + .andExpect( + content() + .string( + Matchers + .containsString("\"name\":\"ItemName\""))) // Assert JSON contains name + .andExpect( + content() + .string( + Matchers + .containsString( + "\"description\":\"ItemDescription\""))) // Assert description + .andExpect(content().string(Matchers.containsString("\"price\":100"))); // Assert price // Assert: Verify DataController's get method was called with the correct ID verify(dataController).get(itemId); @@ -67,8 +90,8 @@ void testReadItem_NotFound() throws Exception { // Act: Perform GET /item/2 mockMvc - .perform(get("/api/item/{id}", itemId)) - .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found + .perform(get("/api/item/{id}", itemId)) + .andExpect(status().isNotFound()); // Assert HTTP status is 404 Not Found // Assert: Verify DataController's get method was called verify(dataController).get(itemId); @@ -90,30 +113,42 @@ void testReadMultipleItems() throws Exception { Item item10 = new Item(10L, "Item10", "Description10", 1000.0); List items = List.of( - item1, - item2, - item3, - item4, - item5, - item6, - item7, - item8, - item9, - item10 - ); + item1, + item2, + item3, + item4, + item5, + item6, + item7, + item8, + item9, + item10); given(dataController.getMultiple(10)).willReturn(items); // Act: Perform GET /random mockMvc - .perform(get("/api/item/random")) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(jsonPath("$.items.length()").value(10)) // Assert the `items` array has 10 elements - .andExpect(jsonPath("$.items[0].id").value(1)) // Check first item's ID - .andExpect(jsonPath("$.items[0].name").value("Item1")) // Check first item's name - .andExpect(jsonPath("$.items[9].id").value(10)) // Check last item's ID - .andExpect(jsonPath("$.items[9].name").value("Item10")); // Check last item's name - - // Assert: Verify DataController's getMultiple method was called with the correct parameter + .perform(get("/api/item/random")) + .andExpect( + status() + .isOk()) // Assert HTTP status is 200 OK + .andExpect( + jsonPath("$.items.length()") + .value(10)) // Assert the `items` array has 10 elements + .andExpect( + jsonPath("$.items[0].id") + .value(1)) // Check first item's ID + .andExpect( + jsonPath("$.items[0].name") + .value("Item1")) // Check first item's name + .andExpect( + jsonPath("$.items[9].id") + .value(10)) // Check last item's ID + .andExpect( + jsonPath("$.items[9].name") + .value("Item10")); // Check last item's name + + // Assert: Verify DataController's getMultiple method was called with the + // correct parameter verify(dataController).getMultiple(10); } @@ -126,39 +161,44 @@ void testCreateItem() throws Exception { given(dataController.create(any(Item.class))).willReturn(0L); // Simulate creation with ID 0 // Act: Perform POST /item/create - JSONObject itemJson = item.toJSONObject(); + JSONObject itemJson = item.toJsonObject(); itemJson.remove("id"); // Remove ID from JSON for creation mockMvc - .perform( - post("/api/item/create") - .contentType("application/json") // Specify JSON content type - .content(itemJson.toString()) // Convert JSON to string - ) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string("{\"id\":0}")); // Assert response message - - // Assert: Verify DataController's create method was called with an argument matching the expected properties + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content(itemJson.toString()) // Convert JSON to string + ) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":0}")); // Assert response message + + // Assert: Verify DataController's create method was called with an argument + // matching the expected properties verify(dataController).create( - argThat( - argument -> - argument.getName().equals("NewItem") && - argument.getDescription().equals("NewDescription") && - argument.getPrice() == 200.0 - ) - ); + argThat( + argument -> argument + .getName() + .equals("NewItem") + && + argument + .getDescription() + .equals("NewDescription") + && + argument.getPrice() == 200.0)); } @Test @DisplayName("Test creating an item with invalid request body") void testCreateItem_Validations() throws Exception { - // Act: Perform POST /item/create with an invalid request body (missing required fields) + // Act: Perform POST /item/create with an invalid request body (missing required + // fields) mockMvc - .perform( - post("/api/item/create") - .contentType("application/json") // Specify JSON content type - .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) - ) - .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request + .perform( + post("/api/item/create") + .contentType("application/json") // Specify JSON content type + .content("{\"name\":\"NewItem\"}") // Incomplete JSON (missing fields) + ) + .andExpect(status().isBadRequest()); // Assert HTTP status is 400 Bad Request // Assert: Verify DataController's create method was not called verify(dataController, never()).create(any()); @@ -170,9 +210,9 @@ void testDeleteItem() throws Exception { // Act: Perform DELETE /item/delete/5 long itemId = 5; mockMvc - .perform(delete("/api/item/delete/{id}", itemId)) - .andExpect(status().isOk()) // Assert HTTP status is 200 OK - .andExpect(content().string("{\"id\":5}")); // Assert response message + .perform(delete("/api/item/delete/{id}", itemId)) + .andExpect(status().isOk()) // Assert HTTP status is 200 OK + .andExpect(content().string("{\"id\":5}")); // Assert response message // Assert: Verify DataController's delete method was called with the correct ID verify(dataController).delete(itemId); diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java index 87e6b50592a..b05a0382949 100644 --- a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/Main.java @@ -24,14 +24,32 @@ import org.springframework.jdbc.CannotGetJdbcConnectionException; import org.springframework.jdbc.core.JdbcTemplate; -public class Main { +public final class Main { - private static final int MAX_GENERATED_ENTRIES = 15000; + private Main() { + throw new UnsupportedOperationException( + "This is a utility class and cannot be instantiated"); + } + /** Maximum number of entries to generate. */ + private static final int MAX_GENERATED_ENTRIES = 15000; + /** Faker instance for generating random data. */ private static final Faker FAKER = new Faker(); + /** Random number generator for generating random data. */ private static final Random RANDOM = new Random(); + /** Sleep time in milliseconds before retrying to connect. */ + private static final int SLEEP_TIME = 5000; + /** Bound on price. */ + private static final int PRICE_BOUND = 10000; + /** Scaling factor for price. */ + private static final int PRICE_SCALE = 100; - public static void main(String[] args) { + /** + * Main method to start sample-data application. + * + * @param args Command-line arguments + */ + public static void main(final String[] args) { // Connect to PostgreSQL System.out.println("Connecting to PostgreSQL..."); JdbcTemplate jdbcTemplate = configureJdbcTemplate(); @@ -41,10 +59,12 @@ public static void main(String[] args) { System.out.println("Populating items table with sample data..."); populateItems(jdbcTemplate); } catch (CannotGetJdbcConnectionException e) { - System.out.println("Failed to connect to the database. Retrying in 5 seconds..."); + System.out + .println("Failed to connect to the" + + " database. Retrying in 5 seconds..."); // Sleep for 5 seconds and retry try { - Thread.sleep(5000); + Thread.sleep(SLEEP_TIME); } catch (InterruptedException ex) { Thread.currentThread().interrupt(); } @@ -52,17 +72,19 @@ public static void main(String[] args) { } } - private static void populateItems(JdbcTemplate jdbcTemplate) { - String sql = "INSERT INTO items (name, description, price) VALUES (?, ?, ?)"; + private static void populateItems(final JdbcTemplate jdbcTemplate) { + String sql = "INSERT INTO items" + + " (name, description, price) VALUES (?, ?, ?)"; // Prepare batch arguments List batchArgs = new ArrayList<>(); for (int i = 0; i < MAX_GENERATED_ENTRIES; i++) { String name = generateProductName(); String description = generateDescription(); - double price = RANDOM.nextInt(10000) / 100.0; + double price = RANDOM.nextInt(PRICE_BOUND) / PRICE_SCALE; - batchArgs.add(new Object[] {name, description, price}); + batchArgs.add(new Object[] { + name, description, price }); } // Execute batch update @@ -78,10 +100,15 @@ private static String generateDescription() { } private static JdbcTemplate configureJdbcTemplate() { - String jdbcUrl = - System.getenv().getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/items"); - String jdbcUsername = System.getenv().getOrDefault("DB_USERNAME", "root"); - String jdbcPassword = System.getenv().getOrDefault("DB_PASSWORD", "password"); + String jdbcUrl = System + .getenv() + .getOrDefault("DB_URL", "jdbc:postgresql://localhost:5432/items"); + String jdbcUsername = System + .getenv() + .getOrDefault("DB_USERNAME", "root"); + String jdbcPassword = System + .getenv() + .getOrDefault("DB_PASSWORD", "password"); JdbcTemplate jdbcTemplate = new JdbcTemplate(); jdbcTemplate.setDataSource( @@ -92,4 +119,5 @@ private static JdbcTemplate configureJdbcTemplate() { .build()); return jdbcTemplate; } + } diff --git a/memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java new file mode 100644 index 00000000000..30c58bf7980 --- /dev/null +++ b/memorystore/valkey/caching/demo/sample-data/src/main/java/app/package-info.java @@ -0,0 +1,18 @@ +/* + * Copyright 2025 Google LLC + * + * 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 + * + * http://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. + */ + +/** Contains classes for the caching application. */ +package app; From 2800cb599c2bcdbd7846e4f0cf6444e434a291a6 Mon Sep 17 00:00:00 2001 From: Corie Watson Date: Fri, 7 Feb 2025 11:37:26 +0000 Subject: [PATCH 8/8] chore: add license to pom.xml --- memorystore/valkey/caching/demo/app/pom.xml | 16 ++++++++++++++++ .../valkey/caching/demo/sample-data/pom.xml | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/memorystore/valkey/caching/demo/app/pom.xml b/memorystore/valkey/caching/demo/app/pom.xml index 08d1f745a37..67d8b6a6768 100644 --- a/memorystore/valkey/caching/demo/app/pom.xml +++ b/memorystore/valkey/caching/demo/app/pom.xml @@ -1,4 +1,20 @@ + + + diff --git a/memorystore/valkey/caching/demo/sample-data/pom.xml b/memorystore/valkey/caching/demo/sample-data/pom.xml index aab7d138ea7..df1991ea617 100644 --- a/memorystore/valkey/caching/demo/sample-data/pom.xml +++ b/memorystore/valkey/caching/demo/sample-data/pom.xml @@ -1,4 +1,20 @@ + + +