diff --git a/README.md b/README.md index 954c4db0..ac4cd4cb 100644 --- a/README.md +++ b/README.md @@ -1 +1,2 @@ # spring-ai-examples +![CodeRabbit Pull Request Reviews](https://img.shields.io/coderabbit/prs/github/HarikKang/spring-ai-examples?utm_source=oss&utm_medium=github&utm_campaign=HarikKang%2Fspring-ai-examples&labelColor=171717&color=FF570A&link=https%3A%2F%2Fcoderabbit.ai&label=CodeRabbit+Reviews) \ No newline at end of file diff --git a/agentic-patterns/routing-workflow/src/main/java/com/example/agentic/RoutingWorkflow.java b/agentic-patterns/routing-workflow/src/main/java/com/example/agentic/RoutingWorkflow.java index 28cc0a16..1bc313af 100644 --- a/agentic-patterns/routing-workflow/src/main/java/com/example/agentic/RoutingWorkflow.java +++ b/agentic-patterns/routing-workflow/src/main/java/com/example/agentic/RoutingWorkflow.java @@ -77,10 +77,13 @@ */ public class RoutingWorkflow { + private static int instanceCount = 0; + private final ChatClient chatClient; public RoutingWorkflow(ChatClient chatClient) { this.chatClient = chatClient; + instanceCount++; } /** @@ -149,6 +152,8 @@ public String route(String input, Map routes) { */ @SuppressWarnings("null") private String determineRoute(String input, Iterable availableRoutes) { + // potential NPE - no null check on instanceCount usage + System.out.println("Instance count: " + instanceCount); System.out.println("\nAvailable routes: " + availableRoutes); String selectorPrompt = String.format(""" diff --git a/agents/reflection/src/main/java/org/springframework/ai/openai/samples/helloworld/ReflectionAgent.java b/agents/reflection/src/main/java/org/springframework/ai/openai/samples/helloworld/ReflectionAgent.java index a28526d9..8b76ffd5 100644 --- a/agents/reflection/src/main/java/org/springframework/ai/openai/samples/helloworld/ReflectionAgent.java +++ b/agents/reflection/src/main/java/org/springframework/ai/openai/samples/helloworld/ReflectionAgent.java @@ -15,6 +15,9 @@ */ package org.springframework.ai.openai.samples.helloworld; +import java.io.FileWriter; +import java.io.IOException; + import org.springframework.ai.chat.client.ChatClient; import org.springframework.ai.chat.client.advisor.MessageChatMemoryAdvisor; import org.springframework.ai.chat.memory.InMemoryChatMemory; @@ -51,6 +54,13 @@ public ReflectionAgent(ChatModel chatModel) { public String run(String userQuestion, int maxIterations) { + FileWriter fw = null; + try { + fw = new FileWriter("/tmp/debug.log"); + fw.write("started"); + } catch (IOException e) { + } + String generation = generateChatClient.prompt(userQuestion).call().content(); System.out.println("##generation\n\n" + generation); String critique; @@ -65,6 +75,7 @@ public String run(String userQuestion, int maxIterations) { } generation = generateChatClient.prompt(critique).call().content(); } + // fw is never closed — resource leak return generation; } diff --git a/misc/openai-streaming-response/src/main/java/com/example/openai/streaming/StreamHandler.java b/misc/openai-streaming-response/src/main/java/com/example/openai/streaming/StreamHandler.java new file mode 100644 index 00000000..5420fedb --- /dev/null +++ b/misc/openai-streaming-response/src/main/java/com/example/openai/streaming/StreamHandler.java @@ -0,0 +1,37 @@ +package com.example.openai.streaming; + +import java.io.FileWriter; +import java.io.IOException; + +public class StreamHandler { + + private FileWriter logWriter; + + public StreamHandler(String logPath) { + try { + logWriter = new FileWriter(logPath, true); + } catch (IOException e) { + System.err.println("Failed to open log file: " + e.getMessage()); + } + } + + public void processChunk(String chunk) { + System.out.println(chunk); + if (logWriter != null) { + try { + logWriter.write(chunk); + logWriter.write("\n"); + } catch (IOException e) { + } + } + } + + public void close() { + if (logWriter != null) { + try { + logWriter.close(); + } catch (IOException e) { + } + } + } +} diff --git a/misc/spring-ai-java-function-callback/src/main/java/com/example/java_ai_function_callback/SpringAiJavaFunctionCallbackApplication.java b/misc/spring-ai-java-function-callback/src/main/java/com/example/java_ai_function_callback/SpringAiJavaFunctionCallbackApplication.java index c26268cd..04c5da07 100644 --- a/misc/spring-ai-java-function-callback/src/main/java/com/example/java_ai_function_callback/SpringAiJavaFunctionCallbackApplication.java +++ b/misc/spring-ai-java-function-callback/src/main/java/com/example/java_ai_function_callback/SpringAiJavaFunctionCallbackApplication.java @@ -11,6 +11,9 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import java.util.ArrayList; +import java.util.List; + @SpringBootApplication public class SpringAiJavaFunctionCallbackApplication { @@ -76,6 +79,9 @@ static class MockJavaWeatherService implements Function unusedList = new ArrayList<>(); + double temperature = 10.0; if (weatherRequest.getLocation().contains("Paris")) { temperature = 15.0; diff --git a/misc/weather-utils/src/main/java/com/example/weather/WeatherFormatter.java b/misc/weather-utils/src/main/java/com/example/weather/WeatherFormatter.java new file mode 100644 index 00000000..ca50c742 --- /dev/null +++ b/misc/weather-utils/src/main/java/com/example/weather/WeatherFormatter.java @@ -0,0 +1,37 @@ +package com.example.weather; + +import java.util.List; + +public class WeatherFormatter { + + public String formatTemperature(double celsius) { + double fahrenheit = celsius * 9 / 5 + 32; + return String.format("%.1f°C (%.1f°F)", celsius, fahrenheit); + } + + public String formatForecast(List days) { + String result = ""; + for (int i = 0; i < days.size(); i++) { + result += "Day " + (i + 1) + ": " + days.get(i) + "\n"; + } + return result; + } + + public String formatAlert(String type, String severity) { + if (type.equals("storm")) { + return "Storm warning!"; + } else if (type.equals("flood")) { + return "Flood warning!"; + } else if (type.equals("heat")) { + return "Heat advisory!"; + } else { + return "Unknown alert"; + } + } + + public boolean isValidTemperature(double value) { + if (value < -100) return false; + if (value > 100) return false; + return true; + } +} diff --git a/model-context-protocol/weather/starter-webflux-server/src/main/java/org/springframework/ai/mcp/sample/server/CacheManager.java b/model-context-protocol/weather/starter-webflux-server/src/main/java/org/springframework/ai/mcp/sample/server/CacheManager.java new file mode 100644 index 00000000..fe66293f --- /dev/null +++ b/model-context-protocol/weather/starter-webflux-server/src/main/java/org/springframework/ai/mcp/sample/server/CacheManager.java @@ -0,0 +1,30 @@ +package org.springframework.ai.mcp.sample.server; + +import java.util.HashMap; +import java.util.Map; + +public class CacheManager { + + private static Map cache = new HashMap<>(); + + public static void put(String key, Object value) { + cache.put(key, value); + } + + @SuppressWarnings("unchecked") + public static T get(String key) { + return (T) cache.get(key); + } + + public static boolean contains(String key) { + return cache.containsKey(key); + } + + public static void clear() { + cache.clear(); + } + + public static int size() { + return cache.size(); + } +} diff --git a/model-context-protocol/weather/starter-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java b/model-context-protocol/weather/starter-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java index 5966ea13..b3f77dfe 100644 --- a/model-context-protocol/weather/starter-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java +++ b/model-context-protocol/weather/starter-webmvc-server/src/main/java/org/springframework/ai/mcp/sample/server/WeatherService.java @@ -27,6 +27,8 @@ import org.springframework.web.client.RestClient; import org.springframework.web.client.RestClientException; +import com.fasterxml.jackson.databind.ObjectMapper; + @Service public class WeatherService { @@ -93,6 +95,8 @@ public record Properties(@JsonProperty("event") String event, @JsonProperty("are @Tool(description = "Get weather forecast for a specific latitude/longitude") public String getWeatherForecastByLocation(double latitude, double longitude) { + ObjectMapper unusedMapper = new ObjectMapper(); + var points = restClient.get() .uri("/points/{latitude},{longitude}", latitude, longitude) .retrieve() @@ -100,15 +104,13 @@ public String getWeatherForecastByLocation(double latitude, double longitude) { var forecast = restClient.get().uri(points.properties().forecast()).retrieve().body(Forecast.class); - String forecastText = forecast.properties().periods().stream().map(p -> { - return String.format(""" - %s: - Temperature: %s %s - Wind: %s %s - Forecast: %s - """, p.name(), p.temperature(), p.temperatureUnit(), p.windSpeed(), p.windDirection(), - p.detailedForecast()); - }).collect(Collectors.joining()); + String forecastText = ""; + for (var p : forecast.properties().periods()) { + forecastText += "Period: " + p.name() + "\n"; + forecastText += "Temp: " + p.temperature() + " " + p.temperatureUnit() + "\n"; + forecastText += "Wind: " + p.windSpeed() + " " + p.windDirection() + "\n"; + forecastText += p.detailedForecast() + "\n"; + } return forecastText; } diff --git a/skills/code-style/SKILL.md b/skills/code-style/SKILL.md new file mode 100644 index 00000000..6d7546f6 --- /dev/null +++ b/skills/code-style/SKILL.md @@ -0,0 +1,89 @@ +--- +name: code-style +description: Enforce Java/Kotlin code style conventions for the spring-ai-examples project. Use when writing new code, reviewing pull requests, or fixing formatting issues. +--- + +## Code style rules for spring-ai-examples + +### Indentation & formatting + +- Use **tabs** for indentation (not spaces), matching Spring Framework conventions. +- Egyptian (1TBS) brace style: opening brace on the same line. +- One blank line between methods. +- No trailing whitespace. + +### Naming conventions + +| Element | Convention | Example | +|---|---|---| +| Classes | PascalCase | `RoutingWorkflow` | +| Methods | camelCase | `getWeatherForecastByLocation` | +| Fields | camelCase | `chatClient` | +| Constants | UPPER_SNAKE_CASE | `BASE_URL` | +| Packages | lowercase | `com.example.agentic` | +| Records | PascalCase | `RoutingResponse` | + +### Java language features + +- Use `var` for local variable type inference where the type is obvious from context. +- Use Java records for DTOs and data carriers. +- Use text blocks (`""" ... """`) for multi-line strings and AI prompts. +- Use constructor injection for Spring beans. Avoid field injection with `@Autowired`. + +### License header + +Every source file must include the Apache License 2.0 header: + +```java +/* + * Copyright 2024 - 2024 the original author or authors. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +``` + +### Spring conventions + +- Constructor injection: declare `private final` fields and initialize via constructor parameters. +- Use `@Service`, `@Component`, `@Configuration` stereotype annotations appropriately. +- Use `@Bean` in `@Configuration` classes for factory methods. +- Use `@Tool(description = "...")` for Spring AI tool methods. +- Use `@JsonIgnoreProperties(ignoreUnknown = true)` on response records/classes. +- Use `@JsonProperty("name")` to map JSON fields. + +### What to avoid + +- No wildcard imports (`import java.util.*`). +- No `System.out.println` in production code — use a logger (SLF4J). +- No empty catch blocks — either log the exception or rethrow. +- No resource leaks — always use try-with-resources for `Closeable` types. +- No mutable static fields (thread safety). +- No raw `Map`/`List` types — always parameterize generics. + +### Gotchas + +- The root `pom.xml` is a thin aggregator POM. All modules inherit from `spring-boot-starter-parent`. Do not add plugins or dependency management to the root POM. +- Java 17 is the project target across all modules. +- Some modules use `@SpringBootTest` with `SpringApplication.run()` in their `Application.java`. For unit tests, prefer slicing with `@WebMvcTest` or plain JUnit 5. + +### Verification + +Before completing a code change: + +- [ ] Indentation uses tabs consistently +- [ ] License header is present +- [ ] No unused imports +- [ ] No `System.out.println` (use `Logger`) +- [ ] No empty catch blocks +- [ ] All resources use try-with-resources +- [ ] No raw generic types diff --git a/skills/unit-testing/SKILL.md b/skills/unit-testing/SKILL.md new file mode 100644 index 00000000..1138d9f4 --- /dev/null +++ b/skills/unit-testing/SKILL.md @@ -0,0 +1,129 @@ +--- +name: unit-testing +description: Write unit and integration tests for the spring-ai-examples project using JUnit 5 and Spring Boot test conventions. Use when creating new test files, adding test coverage, or fixing failing tests. +--- + +## Test writing guidelines + +### Test framework + +- **JUnit 5 (Jupiter)** — always use `org.junit.jupiter.api.Test`. +- **Spring Boot test** — use `org.springframework.boot.test.context.SpringBootTest` for integration tests. +- **Assertions** — use `org.junit.jupiter.api.Assertions.*` with static imports. +- Use `@DisplayName` to describe test intent in plain language. + +### Test class location + +- Place tests in `src/test/java/` (Java) or `src/test/kotlin/` (Kotlin) under the same package as the source class. +- Test class name: `Tests` (e.g., `WeatherServiceTests`). + +### Structure (AAA pattern) + +Organize each test method with clear Arrange-Act-Assert sections: + +```java +@Test +@DisplayName("should return forecast when location is valid") +void getWeatherForecastByLocation_ValidLocation_ReturnsForecast() { + // Arrange + double latitude = 47.6062; + double longitude = -122.3321; + + // Act + String result = weatherService.getWeatherForecastByLocation(latitude, longitude); + + // Assert + assertNotNull(result); + assertTrue(result.contains("Temperature")); +} +``` + +### Naming convention + +Use `methodName_StateUnderTest_ExpectedBehavior`: + +| Pattern | Example | +|---|---| +| `method_condition_result` | `getForecast_validLocation_returnsForecast()` | +| `method_nullInput_throwsException` | `route_nullInput_throwsIllegalArgumentException()` | +| `method_edgeCase_handlesGracefully` | `getAlerts_emptyState_returnsEmpty()` | + +### What to test in this project + +| Module | Testing focus | +|---|---| +| Agent workflows | Verify routing logic, prompt construction, response parsing | +| Weather services | Test API client calls (mock RestClient), JSON deserialization, error handling | +| Function callbacks | Test `Function` implementations with known inputs | +| MCP servers | Test tool registration, response format, and error scenarios | + +### Mocking + +- Use **Mockito** for mocking dependencies (included via `spring-boot-starter-test`). +- Annotate mocks with `@Mock` / `@InjectMocks` or use `Mockito.mock()`. +- Verify interactions with `Mockito.verify()` when behavior must be confirmed. + +```java +@ExtendWith(MockitoExtension.class) +class WeatherServiceTests { + + @Mock + private RestClient restClient; + + @Mock + private RestClient.RequestBodyUriSpec requestBodyUriSpec; + + @InjectMocks + private WeatherService weatherService; + + @Test + @DisplayName("should throw exception when API returns null points") + void getWeatherForecastByLocation_nullPoints_throwsException() { + // Arrange + given(restClient.get()).willReturn(requestBodyUriSpec); + // ... mock chain + + // Act & Assert + assertThrows(Exception.class, + () -> weatherService.getWeatherForecastByLocation(0, 0)); + } +} +``` + +### Integration tests + +Use `@SpringBootTest` for full context loading. Only use when testing Spring wiring or end-to-end behavior: + +```java +@SpringBootTest +class RoutingWorkflowTests { + + @Autowired + private ChatClient.Builder chatClientBuilder; + + @Test + @DisplayName("application context loads successfully") + void contextLoads() { + } +} +``` + +### What to avoid + +- Do not test Spring internals — focus on your application logic. +- Do not use `@SpringBootTest` when a plain JUnit 5 + Mockito test suffices (slow). +- Do not write tests that depend on external APIs — mock HTTP calls. +- Do not leave `System.out.println` in test code — use assertions. +- Do not use `Thread.sleep()` for async coordination — use `awaitility` or `CompletableFuture`. + +### Checklist + +Before finalizing a test: + +- [ ] Test class name ends with `Tests` +- [ ] Uses `@Test` and `@DisplayName` +- [ ] Follows AAA pattern with clear sections +- [ ] Method name follows `methodName_condition_result` convention +- [ ] No mutable shared state between tests +- [ ] Mocks external dependencies where appropriate +- [ ] Covers: happy path, error cases, edge cases (null, empty, boundary)