Skip to content

Commit 2fcad7d

Browse files
authored
Merge pull request #52 from quarkiverse/kogito-6970
Add support to OAuth2
2 parents 51829d2 + 2c01a97 commit 2fcad7d

File tree

28 files changed

+693
-88
lines changed

28 files changed

+693
-88
lines changed

README.md

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,8 @@
66

77
Quarkus' extension for generation of [Rest Clients](https://quarkus.io/guides/rest-client) based on OpenAPI specification files.
88

9-
This extension is based on the [OpenAPI Generator Tool](https://openapi-generator.tech/).
9+
This extension is based on the [OpenAPI Generator Tool](https://openapi-generator.tech/). Please consider donation to help them maintain the
10+
project: https://opencollective.com/openapi_generator/donate
1011

1112
## Getting Started
1213

@@ -158,6 +159,58 @@ Similarly to bearer token, the API Key Authentication also has the token entry k
158159

159160
The API Key scheme has an additional property that requires where to add the API key in the request token: header, cookie or query. The inner provider takes care of that for you.
160161

162+
### OAuth2 Authentication
163+
164+
The extension will generate a `ClientRequestFilter` capable to add OAuth2 authentication capabilities to the OpenAPI operations that require it. This means that you can use
165+
the [Quarkus OIDC Extension](https://quarkus.io/guides/security-openid-connect-client) configuration to define your authentication flow.
166+
167+
The generated code creates a named `OidcClient` for each [Security Scheme](https://spec.openapis.org/oas/v3.1.0#security-scheme-object) listed in the OpenAPI specification files. For example, given
168+
the following excerpt:
169+
170+
```json
171+
{
172+
"securitySchemes": {
173+
"petstore_auth": {
174+
"type": "oauth2",
175+
"flows": {
176+
"implicit": {
177+
"authorizationUrl": "https://petstore3.swagger.io/oauth/authorize",
178+
"scopes": {
179+
"write:pets": "modify pets in your account",
180+
"read:pets": "read your pets"
181+
}
182+
}
183+
}
184+
}
185+
}
186+
}
187+
```
188+
189+
You can configure this `OidcClient` as:
190+
191+
```properties
192+
quarkus.oidc-client.petstore_auth.auth-server-url=https://petstore3.swagger.io/oauth/authorize
193+
quarkus.oidc-client.petstore_auth.discovery-enabled=false
194+
quarkus.oidc-client.petstore_auth.token-path=/tokens
195+
quarkus.oidc-client.petstore_auth.credentials.secret=secret
196+
quarkus.oidc-client.petstore_auth.grant.type=password
197+
quarkus.oidc-client.petstore_auth.grant-options.password.username=alice
198+
quarkus.oidc-client.petstore_auth.grant-options.password.password=alice
199+
quarkus.oidc-client.petstore_auth.client-id=petstore-app
200+
```
201+
202+
The configuration suffix `quarkus.oidc-client.petstore_auth` is exclusive for the schema defined in the specification file.
203+
204+
For this to work you **must** add [Quarkus OIDC Client Filter Extension](https://quarkus.io/guides/security-openid-connect-client#oidc-client-filter) to your project:
205+
206+
````xml
207+
208+
<dependency>
209+
<groupId>io.quarkus</groupId>
210+
<artifactId>quarkus-oidc-client-filter</artifactId>
211+
</dependency>
212+
````
213+
161214
## Circuit Breaker
162215

163216
You can define the [CircuitBreaker annotation from MicroProfile Fault Tolerance](https://microprofile.io/project/eclipse/microprofile-fault-tolerance/spec/src/main/asciidoc/circuitbreaker.asciidoc)
@@ -357,7 +410,8 @@ to `application/json`.
357410

358411
## Generating files via InputStream
359412

360-
Having the files in the `src/main/openapi` directory will generate the REST stubs by default. Alternatively, you can implement the `io.quarkiverse.openapi.generator.deployment.codegen.OpenApiSpecInputProvider`
413+
Having the files in the `src/main/openapi` directory will generate the REST stubs by default. Alternatively, you can implement
414+
the `io.quarkiverse.openapi.generator.deployment.codegen.OpenApiSpecInputProvider`
361415
interface to provide a list of `InputStream`s of OpenAPI specification files. This is useful in scenarios where you want to dynamically generate the client code without having the target spec file
362416
saved locally in your project.
363417

@@ -374,7 +428,6 @@ Use the property key `<base_package>.model.MyClass.generateDeprecated=false` to
374428

375429
These are the known limitations of this pre-release version:
376430

377-
- No OAuth2 support
378431
- No reactive support
379432
- Only Jackson support
380433

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/SpecConfig.java

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,36 @@
11
package io.quarkiverse.openapi.generator.deployment;
22

33
import java.nio.file.Path;
4+
import java.util.Collections;
5+
import java.util.Map;
46

7+
import io.quarkus.runtime.annotations.ConfigGroup;
8+
import io.quarkus.runtime.annotations.ConfigItem;
9+
import io.quarkus.runtime.annotations.ConfigRoot;
10+
11+
// This configuration is read in codegen phase (before build time), the annotation is for document purposes and avoiding quarkus warns
12+
@ConfigGroup
13+
@ConfigRoot(prefix = SpecConfig.BUILD_TIME_CONFIG_PREFIX)
514
public class SpecConfig {
615

7-
private static final String BUILD_TIME_CONFIG_PREFIX = "quarkus.openapi-generator.codegen";
16+
public static final String BUILD_TIME_CONFIG_PREFIX = "quarkus.openapi-generator.codegen";
817
public static final String API_PKG_SUFFIX = ".api";
918
public static final String MODEL_PKG_SUFFIX = ".model";
10-
public static final String BUILD_TIME_SPEC_PREFIX_FORMAT = BUILD_TIME_CONFIG_PREFIX + ".spec.\"%s\"";
19+
// package visibility for unit tests
20+
static final String BUILD_TIME_SPEC_PREFIX_FORMAT = BUILD_TIME_CONFIG_PREFIX + ".spec.\"%s\"";
1121
private static final String BASE_PACKAGE_PROP_FORMAT = "%s.base-package";
1222
private static final String SKIP_FORM_MODEL_PROP_FORMAT = "%s.skip-form-model";
1323

24+
/**
25+
* OpenAPI Spec details for codegen configuration.
26+
*/
27+
@ConfigItem(name = "spec")
28+
Map<String, SpecItemConfig> specItem;
29+
30+
public Map<String, SpecItemConfig> getSpecItem() {
31+
return Collections.unmodifiableMap(specItem);
32+
}
33+
1434
public static String resolveApiPackage(final String basePackage) {
1535
return String.format("%s%s", basePackage, API_PKG_SUFFIX);
1636
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
package io.quarkiverse.openapi.generator.deployment;
2+
3+
// Configuration class for documentation purposes
4+
import io.quarkus.runtime.annotations.ConfigItem;
5+
6+
public class SpecItemConfig {
7+
8+
/**
9+
* Base package for where the generated code for the given OpenAPI specification will be added.
10+
*/
11+
@ConfigItem
12+
String basePackage;
13+
14+
}

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/codegen/ModelCodegenConfigParser.java

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,7 @@ public static Map<String, Object> parse(final Config config, final String basePa
2020
final List<String> modelProperties = filterModelPropertyNames(config.getPropertyNames(),
2121
resolveModelPackage(basePackage));
2222
final Map<String, Object> modelConfig = new HashMap<>();
23-
modelProperties.forEach(m -> {
24-
modelConfig.put(m, config.getValue(m, String.class));
25-
});
23+
modelProperties.forEach(m -> modelConfig.put(m, config.getValue(m, String.class)));
2624
return modelConfig;
2725
}
2826

deployment/src/main/java/io/quarkiverse/openapi/generator/deployment/wrapper/QuarkusJavaClientCodegen.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,10 +37,10 @@ public void processOpts() {
3737
private void replaceWithQuarkusTemplateFiles() {
3838
supportingFiles.clear();
3939

40-
// TODO: add others as we go
4140
if (ProcessUtils.hasHttpBasicMethods(this.openAPI) ||
4241
ProcessUtils.hasApiKeyMethods(this.openAPI) ||
43-
ProcessUtils.hasHttpBearerMethods(this.openAPI)) {
42+
ProcessUtils.hasHttpBearerMethods(this.openAPI) ||
43+
ProcessUtils.hasOAuthMethods(this.openAPI)) {
4444
supportingFiles.add(
4545
new SupportingFile("auth/compositeAuthenticationProvider.qute",
4646
authFileFolder(),

deployment/src/main/resources/templates/auth/compositeAuthenticationProvider.qute

Lines changed: 106 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,11 @@ import io.quarkiverse.openapi.generator.providers.ApiKeyIn;
1515
{#if hasHttpBearerMethods}
1616
import io.quarkiverse.openapi.generator.providers.BearerAuthenticationProvider;
1717
{/if}
18+
{#if hasOAuthMethods}
19+
import io.quarkiverse.openapi.generator.providers.OAuth2AuthenticationProvider;
20+
{/if}
1821
import io.quarkiverse.openapi.generator.providers.AbstractCompositeAuthenticationProvider;
22+
import io.quarkiverse.openapi.generator.providers.OperationAuthInfo;
1923

2024
@Priority(Priorities.AUTHENTICATION)
2125
public class CompositeAuthenticationProvider extends AbstractCompositeAuthenticationProvider {
@@ -25,17 +29,114 @@ public class CompositeAuthenticationProvider extends AbstractCompositeAuthentica
2529

2630
@PostConstruct
2731
public void init() {
28-
{#for auth in httpBasicMethods.orEmpty}this.addAuthenticationProvider(new BasicAuthenticationProvider("{auth.name}", authConfig));{/for}
29-
{#for auth in httpBearerMethods.orEmpty}this.addAuthenticationProvider(new BearerAuthenticationProvider("{auth.name}", "{auth.scheme}", authConfig));{/for}
32+
{#for auth in httpBasicMethods.orEmpty}
33+
this.addAuthenticationProvider(new BasicAuthenticationProvider("{auth.name}", authConfig)
34+
{#for api in apiInfo.apis}
35+
{#for op in api.operations.operation}
36+
{#if op.hasAuthMethods}
37+
{#for authM in op.authMethods}
38+
{#if authM.name == auth.name}
39+
.addOperation(OperationAuthInfo.builder()
40+
.withPath("{api.contextPath}{api.commonPath{op.path.orEmpty}")
41+
.withId("{op.operationId}")
42+
.withMethod("{op.httpMethod}")
43+
.build())
44+
{/if}
45+
{/for}
46+
{/if}
47+
{/for}
48+
{/for});
49+
{/for}
50+
{#for auth in oauthMethods.orEmpty}
51+
this.addAuthenticationProvider(new OAuth2AuthenticationProvider("{auth.name}")
52+
{#for api in apiInfo.apis}
53+
{#for op in api.operations.operation}
54+
{#if op.hasAuthMethods}
55+
{#for authM in op.authMethods}
56+
{#if authM.name == auth.name}
57+
.addOperation(OperationAuthInfo.builder()
58+
.withPath("{api.contextPath}{api.commonPath}{op.path.orEmpty}")
59+
.withId("{op.operationId}")
60+
.withMethod("{op.httpMethod}")
61+
.build())
62+
{/if}
63+
{/for}
64+
{/if}
65+
{/for}
66+
{/for});
67+
{/for}
68+
{#for auth in httpBearerMethods.orEmpty}
69+
this.addAuthenticationProvider(new BearerAuthenticationProvider("{auth.name}", "{auth.scheme}", authConfig)
70+
{#for api in apiInfo.apis}
71+
{#for op in api.operations.operation}
72+
{#if op.hasAuthMethods}
73+
{#for authM in op.authMethods}
74+
{#if authM.name == auth.name}
75+
.addOperation(OperationAuthInfo.builder()
76+
.withPath("{api.contextPath}{api.commonPath}{op.path.orEmpty}")
77+
.withId("{op.operationId}")
78+
.withMethod("{op.httpMethod}")
79+
.build())
80+
{/if}
81+
{/for}
82+
{/if}
83+
{/for}
84+
{/for});
85+
{/for}
3086
{#for auth in apiKeyMethods.orEmpty}
3187
{#if auth.isKeyInQuery}
32-
this.addAuthenticationProvider(new ApiKeyAuthenticationProvider("{auth.name}", ApiKeyIn.query, "{auth.keyParamName}", authConfig));
88+
this.addAuthenticationProvider(new ApiKeyAuthenticationProvider("{auth.name}", ApiKeyIn.query, "{auth.keyParamName}", authConfig)
89+
{#for api in apiInfo.apis}
90+
{#for op in api.operations.operation}
91+
{#if op.hasAuthMethods}
92+
{#for authM in op.authMethods}
93+
{#if authM.name == auth.name}
94+
.addOperation(OperationAuthInfo.builder()
95+
.withPath("{api.contextPath}{api.commonPath}{op.path.orEmpty}")
96+
.withId("{op.operationId}")
97+
.withMethod("{op.httpMethod}")
98+
.build())
99+
{/if}
100+
{/for}
101+
{/if}
102+
{/for}
103+
{/for});
33104
{/if}
34105
{#if auth.isKeyInHeader}
35-
this.addAuthenticationProvider(new ApiKeyAuthenticationProvider("{auth.name}", ApiKeyIn.header, "{auth.keyParamName}", authConfig));
106+
this.addAuthenticationProvider(new ApiKeyAuthenticationProvider("{auth.name}", ApiKeyIn.header, "{auth.keyParamName}", authConfig)
107+
{#for api in apiInfo.apis}
108+
{#for op in api.operations.operation}
109+
{#if op.hasAuthMethods}
110+
{#for authM in op.authMethods}
111+
{#if authM.name == auth.name}
112+
.addOperation(OperationAuthInfo.builder()
113+
.withPath("{api.contextPath}{api.commonPath}{op.path.orEmpty}")
114+
.withId("{op.operationId}")
115+
.withMethod("{op.httpMethod}")
116+
.build())
117+
{/if}
118+
{/for}
119+
{/if}
120+
{/for}
121+
{/for});
36122
{/if}
37123
{#if auth.isKeyInCookie}
38-
this.addAuthenticationProvider(new ApiKeyAuthenticationProvider("{auth.name}", ApiKeyIn.cookie, "{auth.keyParamName}", authConfig));
124+
this.addAuthenticationProvider(new ApiKeyAuthenticationProvider("{auth.name}", ApiKeyIn.cookie, "{auth.keyParamName}", authConfig)
125+
{#for api in apiInfo.apis}
126+
{#for op in api.operations.operation}
127+
{#if op.hasAuthMethods}
128+
{#for authM in op.authMethods}
129+
{#if authM.name == auth.name}
130+
.addOperation(OperationAuthInfo.builder()
131+
.withPath("{api.contextPath}{api.commonPath}{op.path.orEmpty}")
132+
.withId("{op.operationId}")
133+
.withMethod("{op.httpMethod}")
134+
.build())
135+
{/if}
136+
{/for}
137+
{/if}
138+
{/for}
139+
{/for});
39140
{/if}
40141
{/for}
41142
}

deployment/src/test/java/io/quarkiverse/openapi/generator/deployment/wrapper/OpenApiClientGeneratorWrapperTest.java

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -154,14 +154,6 @@ void checkAnnotations() throws URISyntaxException, FileNotFoundException {
154154
.forEach(m -> assertThat(m).doesNotHaveCircuitBreakerAnnotation());
155155
}
156156

157-
private List<File> generateRestClientFiles() throws URISyntaxException {
158-
OpenApiClientGeneratorWrapper generatorWrapper = createGeneratorWrapper("simple-openapi.json")
159-
.withCircuitBreakerConfiguration(Map.of(
160-
"org.openapitools.client.api.DefaultApi", List.of("opThatDoesNotExist", "byeGet")));
161-
162-
return generatorWrapper.generate();
163-
}
164-
165157
@Test
166158
void verifyMultipartFormAnnotationIsGeneratedForParameter() throws URISyntaxException, FileNotFoundException {
167159
List<File> generatedFiles = createGeneratorWrapper("multipart-openapi.yml")
@@ -216,6 +208,14 @@ void verifyMultipartPojoGeneratedAndFieldsHaveAnnotations() throws URISyntaxExce
216208
});
217209
}
218210

211+
private List<File> generateRestClientFiles() throws URISyntaxException {
212+
OpenApiClientGeneratorWrapper generatorWrapper = createGeneratorWrapper("simple-openapi.json")
213+
.withCircuitBreakerConfiguration(Map.of(
214+
"org.openapitools.client.api.DefaultApi", List.of("opThatDoesNotExist", "byeGet")));
215+
216+
return generatorWrapper.generate();
217+
}
218+
219219
private OpenApiClientGeneratorWrapper createGeneratorWrapper(String specFileName) throws URISyntaxException {
220220
final Path openApiSpec = Path
221221
.of(requireNonNull(this.getClass().getResource(String.format("/openapi/%s", specFileName))).toURI());

integration-tests/example-project/src/test/java/io/quarkiverse/openapi/generator/it/MultipartTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ public class MultipartTest {
3939

4040
@RestClient
4141
@Inject
42-
private UserProfileDataApi userProfileDataApi;
42+
UserProfileDataApi userProfileDataApi;
4343

4444
@Test
4545
public void testUploadMultipartFormdata(@TempDir Path tempDir) throws IOException {

integration-tests/example-project/src/test/java/io/quarkiverse/openapi/generator/it/OpenWeatherTest.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package io.quarkiverse.openapi.generator.it;
22

33
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNotNull;
45

56
import javax.inject.Inject;
67

@@ -26,6 +27,9 @@ public class OpenWeatherTest {
2627
@ConfigProperty(name = "org.acme.openapi.weather.security.auth.app_id/api-key")
2728
String apiKey;
2829

30+
@ConfigProperty(name = "org.acme.openapi.weather.api.CurrentWeatherDataApi/mp-rest/url")
31+
String weatherUrl;
32+
2933
@RestClient
3034
@Inject
3135
CurrentWeatherDataApi weatherApi;
@@ -34,8 +38,9 @@ public class OpenWeatherTest {
3438
public void testGetWeatherByLatLon() {
3539
final Model200 model = weatherApi.currentWeatherData("", "", "10", "-10", "", "", "", "");
3640
assertEquals("Nowhere", model.getName());
41+
assertNotNull(weatherUrl);
3742
openWeatherServer.verify(WireMock.getRequestedFor(
38-
WireMock.urlEqualTo("/weather?q=&id=&lat=10&lon=-10&zip=&units=&lang=&mode=&appid=" + apiKey)));
43+
WireMock.urlEqualTo("/data/2.5/weather?q=&id=&lat=10&lon=-10&zip=&units=&lang=&mode=&appid=" + apiKey)));
3944
}
4045

4146
}

integration-tests/example-project/src/test/java/io/quarkiverse/openapi/generator/it/WiremockOpenWeather.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,14 @@ public Map<String, String> start() {
2020
wireMockServer = new WireMockServer(8888);
2121
wireMockServer.start();
2222

23-
wireMockServer.stubFor(get(urlPathEqualTo("/weather"))
23+
wireMockServer.stubFor(get(urlPathEqualTo("/data/2.5/weather"))
2424
.willReturn(aResponse()
2525
.withStatus(200)
2626
.withHeader("Content-Type", "application/json")
2727
.withBody(
2828
"{\"name\": \"Nowhere\"}")));
2929
return Collections.singletonMap("org.acme.openapi.weather.api.CurrentWeatherDataApi/mp-rest/url",
30-
wireMockServer.baseUrl());
30+
wireMockServer.baseUrl().concat("/data/2.5"));
3131
}
3232

3333
@Override

0 commit comments

Comments
 (0)