From 866af9970f59e3c1b7a0f7baf536aacd73509fad Mon Sep 17 00:00:00 2001 From: Philippe Sevestre Date: Sat, 24 Feb 2024 17:34:23 -0300 Subject: [PATCH] [BAEL-7525] Article code --- .../spring-boot-openapi/pom.xml | 11 ++- .../openapi/quotes/QuotesApplication.java | 2 + .../quotes/config/ClockConfiguration.java | 17 ++++ .../quotes/controller/OrdersApiImpl.java | 9 -- .../quotes/controller/QuotesApiImpl.java | 17 ++-- .../openapi/quotes/service/BrokerService.java | 17 ++-- .../src/main/resources/api/quotes.yaml | 84 +------------------ .../src/main/resources/application.yaml | 3 +- .../templates/JavaSpring/apiDelegate.mustache | 84 +++++++++++++++++++ .../QuotesApplicationIntegrationTest.java | 43 ++++++++++ .../controller/QuotesApiImplUnitTest.java | 55 ++++++++++++ 11 files changed, 237 insertions(+), 105 deletions(-) create mode 100644 spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/config/ClockConfiguration.java delete mode 100644 spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/controller/OrdersApiImpl.java create mode 100644 spring-boot-modules/spring-boot-openapi/src/main/resources/templates/JavaSpring/apiDelegate.mustache create mode 100644 spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/QuotesApplicationIntegrationTest.java create mode 100644 spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/controller/QuotesApiImplUnitTest.java diff --git a/spring-boot-modules/spring-boot-openapi/pom.xml b/spring-boot-modules/spring-boot-openapi/pom.xml index 6612a429f9..fba77253cf 100644 --- a/spring-boot-modules/spring-boot-openapi/pom.xml +++ b/spring-boot-modules/spring-boot-openapi/pom.xml @@ -19,6 +19,11 @@ org.springframework.boot spring-boot-starter-web + + org.springframework.boot + spring-boot-starter-test + test + org.springframework.boot spring-boot-starter-validation @@ -50,8 +55,12 @@ ${project.basedir}/src/main/resources/api/quotes.yaml spring ApiUtil.java + src/main/resources/templates/JavaSpring + + true + - custom + java8 false true com.baeldung.tutorials.openapi.quotes.api 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 index 6a7e5fce12..37d8278133 100644 --- 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 @@ -2,10 +2,12 @@ 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) { 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/OrdersApiImpl.java b/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/controller/OrdersApiImpl.java deleted file mode 100644 index 991d765b97..0000000000 --- a/spring-boot-modules/spring-boot-openapi/src/main/java/com/baeldung/tutorials/openapi/quotes/controller/OrdersApiImpl.java +++ /dev/null @@ -1,9 +0,0 @@ -package com.baeldung.tutorials.openapi.quotes.controller; - -import org.springframework.stereotype.Component; - -import com.baeldung.tutorials.openapi.quotes.api.OrdersApiDelegate; - -@Component -public class OrdersApiImpl implements OrdersApiDelegate { -} 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 index 40a583d960..f0e4d5c33f 100644 --- 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 @@ -1,9 +1,14 @@ 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; @@ -11,9 +16,11 @@ 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) { + public QuotesApiImpl(BrokerService broker, Clock clock) { this.broker = broker; + this.clock = clock; } @@ -29,14 +36,10 @@ public class QuotesApiImpl implements QuotesApiDelegate { var price = broker.getSecurityPrice(symbol); - if ( price.isPresent()) { var quote = new QuoteResponse(); quote.setSymbol(symbol); - quote.setPrice(price.get()); + quote.setPrice(price); + quote.setTimestamp(OffsetDateTime.now(clock)); return ResponseEntity.ok(quote); - } - else { - return ResponseEntity.status(HttpStatus.NOT_FOUND).build(); - } } } 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 index f55c0a8656..f7520b098d 100644 --- 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 @@ -7,23 +7,26 @@ 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 Map securities = new HashMap<>(); + private final Logger log = LoggerFactory.getLogger(BrokerService.class); + + private final Random rnd = new Random(); + public BrokerService() { - Random rnd = new Random(); - securities.put("GOOG", BigDecimal.valueOf(rnd.nextDouble() * 1000.00).setScale(4, RoundingMode.DOWN)); - securities.put("AA", BigDecimal.valueOf(rnd.nextDouble() * 1000.00).setScale(4, RoundingMode.DOWN)); - securities.put("BAEL", BigDecimal.valueOf(rnd.nextDouble() * 1000.00).setScale(4, RoundingMode.DOWN)); } - public Optional getSecurityPrice(@NonNull String symbol) { - return Optional.ofNullable(securities.get(symbol)); + 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 index 283395e27a..590fe661ad 100644 --- 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 @@ -12,6 +12,8 @@ paths: - quotes summary: Get current quote for a security operationId: getQuote + x-spring-cacheable: + name: get-quotes security: - ApiKey: - Quotes.Read @@ -30,57 +32,13 @@ paths: application/json: schema: $ref: '#/components/schemas/QuoteResponse' - /orders/{symbol}: - post: - tags: - - orders - operationId: createOrder - security: - - ApiKey: - - Orders.Write - summary: Places a new order to buy a security - parameters: - - name: symbol - in: path - required: true - description: Security's symbol - schema: - type: string - pattern: '[A-Z0-9]' - requestBody: - required: true - description: Order info - content: - 'application/json': - schema: - $ref: '#'#/components/schemas/OrderRequest' - responses: - '201': - description: Order accepted - content: - 'application/json': - schema: - $ref: '#/components/schemas/OrderResponse' - components: securitySchemes: ApiKey: type: apiKey in: header name: X-API-KEY - schemas: - OrderType: - type: string - enum: - - BUY - - SELL - OrderStatus: - type: string - enum: - - PENDING - - REJECTED - - FULFILLED QuoteResponse: description: Quote response type: object @@ -91,40 +49,6 @@ components: price: type: number description: Quote value - OrderRequest: - description: Buy/Sell order details - type: object - properties: - clientRef: + timestamp: type: string - orderType: - $ref: '#/components/schemas/OrderType' - symbol: - type: string - description: security's symbol - quantity: - type: number - price: - type: number - OrderResponse: - description: Buy/Sell order response - properties: - clientRef: - type: string - orderType: - $ref: '#/components/schemas/OrderType' - symbol: - type: string - description: security's symbol - quantity: - type: number - price: - type: number - orderStatus: - $ref: '#/components/schemas/OrderStatus' - - - - - - + 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 index 7f074c3a3b..c177283306 100644 --- a/spring-boot-modules/spring-boot-openapi/src/main/resources/application.yaml +++ b/spring-boot-modules/spring-boot-openapi/src/main/resources/application.yaml @@ -1,4 +1,5 @@ + logging: level: root: INFO - org.springframework: DEBUG \ No newline at end of file + 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..22a5a9cebe --- /dev/null +++ b/spring-boot-modules/spring-boot-openapi/src/test/java/com/baeldung/tutorials/openapi/quotes/QuotesApplicationIntegrationTest.java @@ -0,0 +1,43 @@ +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 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 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