diff --git a/pom.xml b/pom.xml
index 4d0b9c0bc8..dcc929bd14 100644
--- a/pom.xml
+++ b/pom.xml
@@ -841,6 +841,7 @@
spring-5
spring-activiti
spring-actuator
+ spring-ai
spring-aop-2
spring-batch-2
@@ -1093,6 +1094,7 @@
spring-5
spring-activiti
spring-actuator
+ spring-ai
spring-aop-2
spring-batch-2
diff --git a/spring-ai/.gitignore b/spring-ai/.gitignore
new file mode 100644
index 0000000000..f475e4f82b
--- /dev/null
+++ b/spring-ai/.gitignore
@@ -0,0 +1,12 @@
+#folders#
+.idea
+/target
+/neoDb*
+/data
+/src/main/webapp/WEB-INF/classes
+*/META-INF/*
+
+# Packaged files #
+*.jar
+*.war
+*.ear
diff --git a/spring-ai/pom.xml b/spring-ai/pom.xml
new file mode 100644
index 0000000000..d0e8ab2d1b
--- /dev/null
+++ b/spring-ai/pom.xml
@@ -0,0 +1,63 @@
+
+
+ 4.0.0
+ spring-ai
+ spring-ai
+ war
+
+
+ com.baeldung
+ parent-boot-3
+ 0.0.1-SNAPSHOT
+ ../parent-boot-3
+
+
+
+
+ spring-snapshots
+ Spring Snapshots
+ https://repo.spring.io/snapshot
+
+ false
+
+
+
+
+
+
+ org.springframework.experimental.ai
+ spring-ai-openai-spring-boot-starter
+ 0.7.1-SNAPSHOT
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+ com.baeldung.spring.ai.SpringAIProjectApplication
+ JAR
+
+
+
+ org.apache.maven.plugins
+ maven-compiler-plugin
+
+ 16
+ 16
+
+ **/*ManualTest.java
+
+
+
+
+
+
diff --git a/spring-ai/postman/Spring_AI_Poetry.postman_environment.json b/spring-ai/postman/Spring_AI_Poetry.postman_environment.json
new file mode 100644
index 0000000000..b3d4a00bee
--- /dev/null
+++ b/spring-ai/postman/Spring_AI_Poetry.postman_environment.json
@@ -0,0 +1,25 @@
+{
+ "id": "df61838b-eb6f-4243-87ee-ca02c77e8646",
+ "name": "Spring_AI_Poetry",
+ "values": [
+ {
+ "key": "baseUrl",
+ "value": "localhost:8080",
+ "type": "default",
+ "enabled": true
+ },
+ {
+ "key": "genre",
+ "value": "liric",
+ "enabled": true
+ },
+ {
+ "key": "theme",
+ "value": "flames",
+ "enabled": true
+ }
+ ],
+ "_postman_variable_scope": "environment",
+ "_postman_exported_at": "2023-11-26T19:49:21.755Z",
+ "_postman_exported_using": "Postman/10.20.3"
+}
\ No newline at end of file
diff --git a/spring-ai/postman/spring-ai.postman_collection.json b/spring-ai/postman/spring-ai.postman_collection.json
new file mode 100644
index 0000000000..b26652bb4e
--- /dev/null
+++ b/spring-ai/postman/spring-ai.postman_collection.json
@@ -0,0 +1,56 @@
+{
+ "info": {
+ "_postman_id": "f4282fac-bfe5-45b9-aae6-5ea7c43528ee",
+ "name": "spring-ai",
+ "schema": "https://schema.getpostman.com/json/collection/v2.1.0/collection.json",
+ "_exporter_id": "9576856"
+ },
+ "item": [
+ {
+ "name": "Generate poetry with genre and theme",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ai/poetry?genre={{genre}}&theme={{theme}}",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "ai",
+ "poetry"
+ ],
+ "query": [
+ {
+ "key": "genre",
+ "value": "{{genre}}"
+ },
+ {
+ "key": "theme",
+ "value": "{{theme}}"
+ }
+ ]
+ }
+ },
+ "response": []
+ },
+ {
+ "name": "Generate haiku about cats",
+ "request": {
+ "method": "GET",
+ "header": [],
+ "url": {
+ "raw": "{{baseUrl}}/ai/cathaiku",
+ "host": [
+ "{{baseUrl}}"
+ ],
+ "path": [
+ "ai",
+ "cathaiku"
+ ]
+ }
+ },
+ "response": []
+ }
+ ]
+}
\ No newline at end of file
diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/SpringAIProjectApplication.java b/spring-ai/src/main/java/com/baeldung/spring/ai/SpringAIProjectApplication.java
new file mode 100644
index 0000000000..3dfa800d26
--- /dev/null
+++ b/spring-ai/src/main/java/com/baeldung/spring/ai/SpringAIProjectApplication.java
@@ -0,0 +1,9 @@
+package com.baeldung.spring.ai;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+
+@SpringBootApplication
+public class SpringAIProjectApplication {
+ public static void main(String[] args) { SpringApplication.run(SpringAIProjectApplication.class, args);}
+}
diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/dto/PoetryDto.java b/spring-ai/src/main/java/com/baeldung/spring/ai/dto/PoetryDto.java
new file mode 100644
index 0000000000..229d2b13f0
--- /dev/null
+++ b/spring-ai/src/main/java/com/baeldung/spring/ai/dto/PoetryDto.java
@@ -0,0 +1,3 @@
+package com.baeldung.spring.ai.dto;
+
+public record PoetryDto (String title, String poetry, String genre, String theme) {}
diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/service/PoetryService.java b/spring-ai/src/main/java/com/baeldung/spring/ai/service/PoetryService.java
new file mode 100644
index 0000000000..e2760b8950
--- /dev/null
+++ b/spring-ai/src/main/java/com/baeldung/spring/ai/service/PoetryService.java
@@ -0,0 +1,10 @@
+package com.baeldung.spring.ai.service;
+
+import com.baeldung.spring.ai.dto.PoetryDto;
+
+public interface PoetryService {
+
+ String getCatHaiku();
+
+ PoetryDto getPoetryByGenreAndTheme(String genre, String theme);
+}
diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/service/impl/PoetryServiceImpl.java b/spring-ai/src/main/java/com/baeldung/spring/ai/service/impl/PoetryServiceImpl.java
new file mode 100644
index 0000000000..791dba372e
--- /dev/null
+++ b/spring-ai/src/main/java/com/baeldung/spring/ai/service/impl/PoetryServiceImpl.java
@@ -0,0 +1,50 @@
+package com.baeldung.spring.ai.service.impl;
+
+import com.baeldung.spring.ai.dto.PoetryDto;
+import com.baeldung.spring.ai.service.PoetryService;
+import org.springframework.ai.client.AiClient;
+import org.springframework.ai.client.AiResponse;
+import org.springframework.ai.parser.BeanOutputParser;
+import org.springframework.ai.prompt.PromptTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Service;
+import org.springframework.web.bind.annotation.GetMapping;
+
+@Service
+public class PoetryServiceImpl implements PoetryService {
+
+ public static final String WRITE_ME_HAIKU_ABOUT_CAT = """
+ Write me Haiku about cat,
+ haiku should start with the word cat obligatory""";
+ private final AiClient aiClient;
+
+ @Autowired
+ public PoetryServiceImpl(AiClient aiClient) {
+ this.aiClient = aiClient;
+ }
+ @Override
+ public String getCatHaiku() {
+ return aiClient.generate(WRITE_ME_HAIKU_ABOUT_CAT);
+ }
+
+ @Override
+ public PoetryDto getPoetryByGenreAndTheme(String genre, String theme) {
+ BeanOutputParser poetryDtoBeanOutputParser = new BeanOutputParser<>(PoetryDto.class);
+
+ String promptString = """
+ Write me {genre} poetry about {theme}
+ {format}
+ """;
+
+ PromptTemplate promptTemplate = new PromptTemplate(promptString);
+ promptTemplate.add("genre", genre);
+ promptTemplate.add("theme", theme);
+ promptTemplate.add("format", poetryDtoBeanOutputParser.getFormat());
+
+ promptTemplate.setOutputParser(poetryDtoBeanOutputParser);
+
+ AiResponse response = aiClient.generate(promptTemplate.create());
+ return poetryDtoBeanOutputParser.parse(response.getGeneration().getText());
+ }
+}
diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/web/ExceptionTranslator.java b/spring-ai/src/main/java/com/baeldung/spring/ai/web/ExceptionTranslator.java
new file mode 100644
index 0000000000..55ba383fa6
--- /dev/null
+++ b/spring-ai/src/main/java/com/baeldung/spring/ai/web/ExceptionTranslator.java
@@ -0,0 +1,27 @@
+package com.baeldung.spring.ai.web;
+
+import com.theokanning.openai.OpenAiHttpException;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ProblemDetail;
+import org.springframework.web.bind.annotation.ExceptionHandler;
+import org.springframework.web.bind.annotation.RestControllerAdvice;
+import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
+
+import java.util.Optional;
+
+@RestControllerAdvice
+public class ExceptionTranslator extends ResponseEntityExceptionHandler {
+
+ public static final String OPEN_AI_CLIENT_RAISED_EXCEPTION = "Open AI client raised exception";
+
+ @ExceptionHandler(OpenAiHttpException.class)
+ ProblemDetail handleOpenAiHttpException(OpenAiHttpException ex) {
+ HttpStatus status = Optional
+ .ofNullable(HttpStatus.resolve(ex.statusCode))
+ .orElse(HttpStatus.BAD_REQUEST);
+
+ ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, ex.getMessage());
+ problemDetail.setTitle(OPEN_AI_CLIENT_RAISED_EXCEPTION);
+ return problemDetail;
+ }
+}
diff --git a/spring-ai/src/main/java/com/baeldung/spring/ai/web/PoetryController.java b/spring-ai/src/main/java/com/baeldung/spring/ai/web/PoetryController.java
new file mode 100644
index 0000000000..27c1bad6e2
--- /dev/null
+++ b/spring-ai/src/main/java/com/baeldung/spring/ai/web/PoetryController.java
@@ -0,0 +1,39 @@
+package com.baeldung.spring.ai.web;
+
+import com.baeldung.spring.ai.dto.PoetryDto;
+import com.baeldung.spring.ai.service.PoetryService;
+import org.springframework.ai.client.AiClient;
+import org.springframework.ai.client.AiResponse;
+import org.springframework.ai.parser.BeanOutputParser;
+import org.springframework.ai.prompt.PromptTemplate;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RequestParam;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("ai")
+public class PoetryController {
+
+ private final PoetryService poetryService;
+
+ @Autowired
+ public PoetryController(PoetryService poetryService) {
+ this.poetryService = poetryService;
+ }
+
+ @GetMapping("/cathaiku")
+ public ResponseEntity generateHaiku(){
+ return ResponseEntity.ok(poetryService.getCatHaiku());
+ }
+
+ @GetMapping("/poetry")
+ public ResponseEntity generatePoetry(
+ @RequestParam("genre") String genre,
+ @RequestParam("theme") String theme
+ ){
+ return ResponseEntity.ok(poetryService.getPoetryByGenreAndTheme(genre, theme));
+ }
+}
diff --git a/spring-ai/src/main/resources/application.yml b/spring-ai/src/main/resources/application.yml
new file mode 100644
index 0000000000..ec3b936adc
--- /dev/null
+++ b/spring-ai/src/main/resources/application.yml
@@ -0,0 +1,3 @@
+spring:
+ ai:
+ openai.api-key: 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
diff --git a/spring-ai/src/test/java/com/baeldung/spring/ai/web/PoetryControllerManualTest.java b/spring-ai/src/test/java/com/baeldung/spring/ai/web/PoetryControllerManualTest.java
new file mode 100644
index 0000000000..23202e6bce
--- /dev/null
+++ b/spring-ai/src/test/java/com/baeldung/spring/ai/web/PoetryControllerManualTest.java
@@ -0,0 +1,50 @@
+package com.baeldung.spring.ai.web;
+
+import com.fasterxml.jackson.databind.ObjectMapper;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.springframework.ai.client.AiClient;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.test.context.junit4.SpringRunner;
+import org.springframework.test.web.servlet.MockMvc;
+
+import static org.hamcrest.Matchers.*;
+import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
+import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
+
+@AutoConfigureMockMvc
+@RunWith(SpringRunner.class)
+@SpringBootTest
+public class PoetryControllerManualTest {
+
+ @Autowired
+ private MockMvc mockMvc;
+
+ @Autowired
+ private ObjectMapper objectMapper;
+
+ @Autowired
+ private AiClient aiClient;
+
+ @Test
+ public void givenGetCatHaiku_whenCallingAiClient_thenCorrect() throws Exception {
+ mockMvc.perform(get("/ai/cathaiku"))
+ .andExpect(status().isOk())
+ .andExpect(content().string(containsStringIgnoringCase("cat")));
+ }
+
+ @Test
+ public void givenGetPoetryWithGenreAndTheme_whenCallingAiClient_thenCorrect() throws Exception {
+ String genre = "lyric";
+ String theme = "coffee";
+ mockMvc.perform(get("/ai/poetry?genre={genre}&theme={theme}", genre, theme))
+ .andExpect(status().isOk())
+ .andExpect(jsonPath("$.genre").value(containsStringIgnoringCase(genre)))
+ .andExpect(jsonPath("$.theme").value(containsStringIgnoringCase(theme)))
+ .andExpect(jsonPath("$.poetry").isNotEmpty())
+ .andExpect(jsonPath("$.title").exists());
+ }
+}
+
diff --git a/spring-ai/src/test/resources/application.yml b/spring-ai/src/test/resources/application.yml
new file mode 100644
index 0000000000..813348fec5
--- /dev/null
+++ b/spring-ai/src/test/resources/application.yml
@@ -0,0 +1,3 @@
+spring:
+ ai:
+ openai.api-key: "mock-key-token"