diff --git a/spring-boot-modules/pom.xml b/spring-boot-modules/pom.xml
index 221c3876d4..49f9a0a43a 100644
--- a/spring-boot-modules/pom.xml
+++ b/spring-boot-modules/pom.xml
@@ -112,6 +112,7 @@
spring-boot-3-url-matching
spring-boot-graalvm-docker
spring-boot-validations
+ spring-boot-openapi
diff --git a/spring-boot-modules/spring-boot-openapi/pom.xml b/spring-boot-modules/spring-boot-openapi/pom.xml
new file mode 100644
index 0000000000..f1cf98e4b5
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/pom.xml
@@ -0,0 +1,96 @@
+
+
+ 4.0.0
+ spring-boot-openapi
+ spring-boot-openapi
+ jar
+ OpenAPI Generator module
+
+
+ org.springframework.boot
+ spring-boot-starter-parent
+ 2.7.11
+
+
+
+
+
+
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-starter-web
+
+
+ org.springframework.boot
+ spring-boot-starter-test
+ test
+
+
+ org.springframework.boot
+ spring-boot-starter-validation
+
+
+ javax.validation
+ validation-api
+
+
+ io.swagger.core.v3
+ swagger-annotations
+ ${swagger-annotations.version}
+
+
+
+
+
+
+ org.openapitools
+ openapi-generator-maven-plugin
+ ${openapi-generator.version}
+
+
+
+ generate
+
+
+ true
+ ${project.basedir}/src/main/resources/api/quotes.yaml
+ spring
+ ApiUtil.java
+ ${project.basedir}/src/main/resources/templates/JavaSpring
+
+ true
+
+
+ java8
+ false
+ true
+ com.baeldung.tutorials.openapi.quotes.api
+ com.baeldung.tutorials.openapi.quotes.api.model
+ source
+
+
+
+
+
+
+ org.springframework.boot
+ spring-boot-maven-plugin
+
+
+
+
+
+ 17
+ 17
+ 7.3.0
+ 2.2.20
+
+
+
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/QuotesApplication.java b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/QuotesApplication.java
new file mode 100644
index 0000000000..37d8278133
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/QuotesApplication.java
@@ -0,0 +1,16 @@
+package com.baeldung.tutorials.openapi.quotes;
+
+import org.springframework.boot.SpringApplication;
+import org.springframework.boot.autoconfigure.SpringBootApplication;
+import org.springframework.cache.annotation.EnableCaching;
+
+import com.baeldung.tutorials.openapi.quotes.service.BrokerService;
+
+@SpringBootApplication
+@EnableCaching
+public class QuotesApplication {
+
+ public static void main(String[] args) {
+ SpringApplication.run(QuotesApplication.class, args);
+ }
+}
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/config/ClockConfiguration.java b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/config/ClockConfiguration.java
new file mode 100644
index 0000000000..60eb6fc967
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/config/ClockConfiguration.java
@@ -0,0 +1,17 @@
+package com.baeldung.tutorials.openapi.quotes.config;
+
+import java.time.Clock;
+
+import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+
+@Configuration
+public class ClockConfiguration {
+
+ @Bean
+ @ConditionalOnMissingBean
+ Clock defaultClock() {
+ return Clock.systemDefaultZone();
+ }
+}
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImpl.java b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImpl.java
new file mode 100644
index 0000000000..f0e4d5c33f
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImpl.java
@@ -0,0 +1,45 @@
+package com.baeldung.tutorials.openapi.quotes.controller;
+
+import java.time.Clock;
+import java.time.OffsetDateTime;
+
+import org.springframework.cache.annotation.Cacheable;
+import org.springframework.http.HttpStatus;
+import org.springframework.http.ResponseEntity;
+import org.springframework.stereotype.Component;
+
+import com.baeldung.tutorials.openapi.quotes.api.QuotesApi;
+import com.baeldung.tutorials.openapi.quotes.api.QuotesApiDelegate;
+import com.baeldung.tutorials.openapi.quotes.api.model.QuoteResponse;
+import com.baeldung.tutorials.openapi.quotes.service.BrokerService;
+
+@Component
+public class QuotesApiImpl implements QuotesApiDelegate {
+ private final BrokerService broker;
+ private final Clock clock;
+
+ public QuotesApiImpl(BrokerService broker, Clock clock) {
+ this.broker = broker;
+ this.clock = clock;
+ }
+
+
+ /**
+ * GET /quotes/{symbol} : Get current quote for a security
+ *
+ * @param symbol Security's symbol (required)
+ * @return OK (status code 200)
+ * @see QuotesApi#getQuote
+ */
+ @Override
+ public ResponseEntity getQuote(String symbol) {
+
+ var price = broker.getSecurityPrice(symbol);
+
+ var quote = new QuoteResponse();
+ quote.setSymbol(symbol);
+ quote.setPrice(price);
+ quote.setTimestamp(OffsetDateTime.now(clock));
+ return ResponseEntity.ok(quote);
+ }
+}
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/service/BrokerService.java b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/service/BrokerService.java
new file mode 100644
index 0000000000..f7520b098d
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/service/BrokerService.java
@@ -0,0 +1,32 @@
+package com.baeldung.tutorials.openapi.quotes.service;
+
+import java.math.BigDecimal;
+import java.math.RoundingMode;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Optional;
+import java.util.Random;
+
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import org.springframework.lang.NonNull;
+import org.springframework.stereotype.Service;
+
+@Service
+public class BrokerService {
+
+ private final Logger log = LoggerFactory.getLogger(BrokerService.class);
+
+ private final Random rnd = new Random();
+
+
+ public BrokerService() {
+ }
+
+
+ public BigDecimal getSecurityPrice(@NonNull String symbol) {
+ log.info("getSecurityPrice: {}", symbol);
+ // Just a mock value
+ return BigDecimal.valueOf(100.0 + rnd.nextDouble()*100.0);
+ }
+}
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/resources/api/quotes.yaml b/spring-boot-modules/spring-boot-openapi/src/main/resources/api/quotes.yaml
new file mode 100644
index 0000000000..590fe661ad
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/resources/api/quotes.yaml
@@ -0,0 +1,54 @@
+openapi: 3.0.0
+info:
+ title: Quotes API
+ version: 1.0.0
+servers:
+ - description: Test server
+ url: http://localhost:8080
+paths:
+ /quotes/{symbol}:
+ get:
+ tags:
+ - quotes
+ summary: Get current quote for a security
+ operationId: getQuote
+ x-spring-cacheable:
+ name: get-quotes
+ security:
+ - ApiKey:
+ - Quotes.Read
+ parameters:
+ - name: symbol
+ in: path
+ required: true
+ description: Security's symbol
+ schema:
+ type: string
+ pattern: '[A-Z0-9]+'
+ responses:
+ '200':
+ description: OK
+ content:
+ application/json:
+ schema:
+ $ref: '#/components/schemas/QuoteResponse'
+components:
+ securitySchemes:
+ ApiKey:
+ type: apiKey
+ in: header
+ name: X-API-KEY
+ schemas:
+ QuoteResponse:
+ description: Quote response
+ type: object
+ properties:
+ symbol:
+ type: string
+ description: security's symbol
+ price:
+ type: number
+ description: Quote value
+ timestamp:
+ type: string
+ format: date-time
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/resources/application.yaml b/spring-boot-modules/spring-boot-openapi/src/main/resources/application.yaml
new file mode 100644
index 0000000000..c177283306
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/resources/application.yaml
@@ -0,0 +1,5 @@
+
+logging:
+ level:
+ root: INFO
+ org.springframework: INFO
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-openapi/src/main/resources/templates/JavaSpring/apiDelegate.mustache b/spring-boot-modules/spring-boot-openapi/src/main/resources/templates/JavaSpring/apiDelegate.mustache
new file mode 100644
index 0000000000..a26fb3556d
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/main/resources/templates/JavaSpring/apiDelegate.mustache
@@ -0,0 +1,84 @@
+/*
+* Generated code: do not modify !
+* Custom template with support for x-spring-cacheable extension
+*/
+package {{package}};
+
+{{#imports}}import {{import}};
+{{/imports}}
+import org.springframework.http.HttpStatus;
+import org.springframework.http.MediaType;
+{{#useResponseEntity}}
+ import org.springframework.http.ResponseEntity;
+{{/useResponseEntity}}
+import org.springframework.web.context.request.NativeWebRequest;
+import org.springframework.web.multipart.MultipartFile;
+{{#reactive}}
+ import org.springframework.web.server.ServerWebExchange;
+ import reactor.core.publisher.Flux;
+ import reactor.core.publisher.Mono;
+ import org.springframework.http.codec.multipart.Part;
+{{/reactive}}
+
+{{#useBeanValidation}}
+ import {{javaxPackage}}.validation.constraints.*;
+ import {{javaxPackage}}.validation.Valid;
+{{/useBeanValidation}}
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+{{#async}}
+ import java.util.concurrent.CompletableFuture;
+{{/async}}
+import {{javaxPackage}}.annotation.Generated;
+
+{{#operations}}
+ /**
+ * A delegate to be called by the {@link {{classname}}Controller}}.
+ * Implement this interface with a {@link org.springframework.stereotype.Service} annotated class.
+ */
+ {{>generatedAnnotation}}
+ public interface {{classname}}Delegate {
+ {{#jdk8-default-interface}}
+
+ default Optional getRequest() {
+ return Optional.empty();
+ }
+ {{/jdk8-default-interface}}
+
+ {{#operation}}
+ /**
+ * {{httpMethod}} {{{path}}}{{#summary}} : {{.}}{{/summary}}
+ {{#notes}}
+ * {{.}}
+ {{/notes}}
+ *
+ {{#allParams}}
+ * @param {{paramName}} {{description}}{{#required}} (required){{/required}}{{^required}} (optional{{#defaultValue}}, default to {{.}}{{/defaultValue}}){{/required}}
+ {{/allParams}}
+ * @return {{#responses}}{{message}} (status code {{code}}){{^-last}}
+ * or {{/-last}}{{/responses}}
+ {{#isDeprecated}}
+ * @deprecated
+ {{/isDeprecated}}
+ {{#externalDocs}}
+ * {{description}}
+ * @see {{summary}} Documentation
+ {{/externalDocs}}
+ * @see {{classname}}#{{operationId}}
+ */
+ {{#isDeprecated}}
+ @Deprecated
+ {{/isDeprecated}}
+ {{#vendorExtensions.x-spring-cacheable}}
+ @org.springframework.cache.annotation.Cacheable({{#name}}"{{.}}"{{/name}}{{^name}}"default"{{/name}})
+ {{/vendorExtensions.x-spring-cacheable}}
+ {{#jdk8-default-interface}}default {{/jdk8-default-interface}}{{>responseType}} {{operationId}}({{#allParams}}{{^isFile}}{{^isBodyParam}}{{>optionalDataType}}{{/isBodyParam}}{{#isBodyParam}}{{^reactive}}{{{dataType}}}{{/reactive}}{{#reactive}}{{^isArray}}Mono<{{{dataType}}}>{{/isArray}}{{#isArray}}Flux<{{{baseType}}}>{{/isArray}}{{/reactive}}{{/isBodyParam}}{{/isFile}}{{#isFile}}{{#isArray}}List<{{/isArray}}{{#reactive}}Flux{{/reactive}}{{^reactive}}MultipartFile{{/reactive}}{{#isArray}}>{{/isArray}}{{/isFile}} {{paramName}}{{^-last}},
+ {{/-last}}{{/allParams}}{{#reactive}}{{#hasParams}},
+ {{/hasParams}}ServerWebExchange exchange{{/reactive}}{{#vendorExtensions.x-spring-paginated}}{{#hasParams}}, {{/hasParams}}{{^hasParams}}{{#reactive}}, {{/reactive}}{{/hasParams}}final Pageable pageable{{/vendorExtensions.x-spring-paginated}}){{#unhandledException}} throws Exception{{/unhandledException}}{{^jdk8-default-interface}};{{/jdk8-default-interface}}{{#jdk8-default-interface}} {
+ {{>methodBody}}
+ }{{/jdk8-default-interface}}
+
+ {{/operation}}
+ }
+{{/operations}}
diff --git a/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/QuotesApplicationIntegrationTest.java b/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/QuotesApplicationIntegrationTest.java
new file mode 100644
index 0000000000..b1defb99b1
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/QuotesApplicationIntegrationTest.java
@@ -0,0 +1,45 @@
+package com.baeldung.tutorials.openapi.quotes;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.util.stream.Collectors;
+import java.util.stream.IntStream;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.web.client.TestRestTemplate;
+import org.springframework.boot.test.web.server.LocalServerPort;
+import org.springframework.http.HttpEntity;
+import org.springframework.http.HttpStatus;
+
+import com.baeldung.tutorials.openapi.quotes.api.model.QuoteResponse;
+
+@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
+class QuotesApplicationIntegrationTest {
+
+ @LocalServerPort
+ private int port;
+
+ @Autowired
+ private TestRestTemplate restTemplate;
+
+ @Test
+ void whenGetQuote_thenSuccess() {
+ var response = restTemplate.getForEntity("http://localhost:" + port + "/quotes/BAEL", QuoteResponse.class);
+ assertThat(response.getStatusCode())
+ .isEqualTo(HttpStatus.OK);
+ }
+
+ @Test
+ void whenGetQuoteMultipleTimes_thenResponseCached() {
+
+ // Call server a few times and collect responses
+ var quotes = IntStream.range(1, 10).boxed()
+ .map((i) -> restTemplate.getForEntity("http://localhost:" + port + "/quotes/BAEL", QuoteResponse.class))
+ .map(HttpEntity::getBody)
+ .collect(Collectors.groupingBy((q -> q.hashCode()), Collectors.counting()));
+
+ assertThat(quotes.size()).isEqualTo(1);
+ }
+}
\ No newline at end of file
diff --git a/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImplUnitTest.java b/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImplUnitTest.java
new file mode 100644
index 0000000000..01e37ef104
--- /dev/null
+++ b/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImplUnitTest.java
@@ -0,0 +1,55 @@
+package com.baeldung.tutorials.openapi.quotes.controller;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+import java.time.Clock;
+import java.time.Instant;
+import java.time.OffsetDateTime;
+import java.time.ZoneId;
+
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.boot.test.context.TestConfiguration;
+import org.springframework.cache.annotation.EnableCaching;
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Primary;
+
+import com.baeldung.tutorials.openapi.quotes.api.QuotesApi;
+
+@SpringBootTest
+class QuotesApiImplUnitTest {
+
+ @Autowired
+ private QuotesApi api;
+
+
+ private static Instant NOW = Instant.now();
+
+ @Test
+ void whenGetQuote_then_success() {
+
+ var response = api.getQuote("GOOG");
+ assertThat(response)
+ .isNotNull();
+
+ assertThat(response.getStatusCode().is2xxSuccessful())
+ .isTrue();
+
+ assertThat(response.getBody().getTimestamp())
+ .isEqualTo(OffsetDateTime.ofInstant(NOW, ZoneId.systemDefault()));
+ }
+
+
+ @TestConfiguration
+ @EnableCaching
+ static class TestConfig {
+
+ @Bean
+ @Primary
+ Clock fixedClock() {
+ return Clock.fixed(NOW, ZoneId.systemDefault());
+ }
+
+ }
+}
\ No newline at end of file