Merge pull request #16003 from psevestre/master

[BAEL-7524] OpenAPI Generator Customization , take 2
This commit is contained in:
Andrea Giulio Cerasoni 2024-03-09 13:08:47 +00:00 committed by GitHub
commit ebedbc6466
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 450 additions and 0 deletions

View File

@ -112,6 +112,7 @@
<module>spring-boot-3-url-matching</module> <module>spring-boot-3-url-matching</module>
<module>spring-boot-graalvm-docker</module> <module>spring-boot-graalvm-docker</module>
<module>spring-boot-validations</module> <module>spring-boot-validations</module>
<module>spring-boot-openapi</module>
</modules> </modules>
<dependencyManagement> <dependencyManagement>

View File

@ -0,0 +1,96 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<artifactId>spring-boot-openapi</artifactId>
<name>spring-boot-openapi</name>
<packaging>jar</packaging>
<description>OpenAPI Generator module</description>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.7.11</version>
<relativePath/>
</parent>
<!-- <parent>-->
<!-- <groupId>com.baeldung.spring-boot-modules</groupId>-->
<!-- <artifactId>spring-boot-modules</artifactId>-->
<!-- <version>1.0.0-SNAPSHOT</version>-->
<!-- </parent>-->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>javax.validation</groupId>
<artifactId>validation-api</artifactId>
</dependency>
<dependency>
<groupId>io.swagger.core.v3</groupId>
<artifactId>swagger-annotations</artifactId>
<version>${swagger-annotations.version}</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>${openapi-generator.version}</version>
<executions>
<execution>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<skipValidateSpec>true</skipValidateSpec>
<inputSpec>${project.basedir}/src/main/resources/api/quotes.yaml</inputSpec>
<generatorName>spring</generatorName>
<supportingFilesToGenerate>ApiUtil.java</supportingFilesToGenerate>
<templateResourcePath>${project.basedir}/src/main/resources/templates/JavaSpring</templateResourcePath>
<globalProperties>
<debugOpenAPI>true</debugOpenAPI>
</globalProperties>
<configOptions>
<dateLibrary>java8</dateLibrary>
<openApiNullable>false</openApiNullable>
<delegatePattern>true</delegatePattern>
<apiPackage>com.baeldung.tutorials.openapi.quotes.api</apiPackage>
<modelPackage>com.baeldung.tutorials.openapi.quotes.api.model</modelPackage>
<documentationProvider>source</documentationProvider>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<openapi-generator.version>7.3.0</openapi-generator.version>
<swagger-annotations.version>2.2.20</swagger-annotations.version>
</properties>
</project>

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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&#39;s symbol (required)
* @return OK (status code 200)
* @see QuotesApi#getQuote
*/
@Override
public ResponseEntity<QuoteResponse> 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);
}
}

View File

@ -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);
}
}

View File

@ -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

View File

@ -0,0 +1,5 @@
logging:
level:
root: INFO
org.springframework: INFO

View File

@ -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<NativeWebRequest> 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 <a href="{{url}}">{{summary}} Documentation</a>
{{/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<Part>{{/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}}

View File

@ -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);
}
}

View File

@ -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());
}
}
}