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