Skip to content

Commit

Permalink
java: oauth improvements including token refresh (#3682)
Browse files Browse the repository at this point in the history
  • Loading branch information
dcb6 committed May 24, 2024
1 parent 9ca93bf commit c222939
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 44 deletions.
25 changes: 20 additions & 5 deletions generators/java/sdk/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,19 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.9.3] - 2024-05-23

- Feature: Generated SDK clients with an OAuth security scheme will now automatically refresh access tokens before they
expire.

## [0.9.2] - 2024-05-21

- Fix: Java 8 Compatibility.

## [0.9.1] - 2024-05-14

- Feature: Support OAuth without token refresh. Example of initializing a client with OAuth:

```java
ExampleApiClient client = ExampleApiClient
.builder()
Expand All @@ -20,11 +27,13 @@ ExampleApiClient client = ExampleApiClient
```

## [0.9.0-rc0] - 2024-05-13

- Chore: Bump intermediate representation to v42

## [0.8.11] - 2024-05-08

- Fix: Corrects the fix in 0.8.10 to check null value as opposed to a .isPresent check, given the header is not `Optional`, it's always `String`
- Fix: Corrects the fix in 0.8.10 to check null value as opposed to a .isPresent check, given the header is
not `Optional`, it's always `String`

## [0.8.10] - 2024-05-08

Expand All @@ -36,7 +45,8 @@ ExampleApiClient client = ExampleApiClient

## [0.8.8] - 2024-05-07

- Fix: The generated SDKs no longer require global headers that are not directly related to auth if auth is mandatory within the SDK. Previously, the generator would require all global headers if auth was mandatory.
- Fix: The generated SDKs no longer require global headers that are not directly related to auth if auth is mandatory
within the SDK. Previously, the generator would require all global headers if auth was mandatory.

## [0.8.7] - 2024-03-21

Expand All @@ -58,11 +68,15 @@ ExampleApiClient client = ExampleApiClient

## [0.8.6] - 2024-03-20

- Fix: the SDK now generates RequestOptions functions for timeouts with IdempotentRequestOptions correctly, previously timeout functions were only taking in regular RequestOptions. This also addresses a JavaPoet issue where fields were being initialized twice across RequestOptions and IdempotentRequestOptions classes, preventing the SDK from generating at all.
- Fix: the SDK now generates RequestOptions functions for timeouts with IdempotentRequestOptions correctly, previously
timeout functions were only taking in regular RequestOptions. This also addresses a JavaPoet issue where fields were
being initialized twice across RequestOptions and IdempotentRequestOptions classes, preventing the SDK from generating
at all.

## [0.8.5] - 2024-03-18

- Feat: add in publishing config that allows for signing published artifacts, this is required for publishing to Maven Central.
- Feat: add in publishing config that allows for signing published artifacts, this is required for publishing to Maven
Central.
To sign your artifacts, you must add the below to your publishing config:
```yaml
generators:
Expand Down Expand Up @@ -98,7 +112,8 @@ ExampleApiClient client = ExampleApiClient

## [0.8.4] - 2024-02-23

- Improvement: The timeout specified on the RequestOptions object now sets the timeout on the entire call, not just the read timeout of the request.
- Improvement: The timeout specified on the RequestOptions object now sets the timeout on the entire call, not just the
read timeout of the request.
As a refresher, a timeout can be added per request like so:
```java
RequestOptions ro = RequestOptions.builder().timeout(90).build(); // Creates a timeout of 90 seconds for the request
Expand Down
2 changes: 1 addition & 1 deletion generators/java/sdk/VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.9.2
0.9.3
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,10 @@ public GeneratedRootClient generateClient(ClientGeneratorContext context, Interm
Optional<GeneratedJavaFile> generatedOAuthTokenSupplier =
maybeOAuthScheme.map(it -> new OAuthTokenSupplierGenerator(
context,
it.getConfiguration().getClientCredentials().get())
it.getConfiguration()
.getClientCredentials()
.orElseThrow(() ->
new RuntimeException("Only client credentials oAuth scheme supported")))
.generateFile());
generatedOAuthTokenSupplier.ifPresent(this::addGeneratedFile);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,10 @@
import com.fern.irV42.model.commons.EndpointId;
import com.fern.irV42.model.commons.EndpointReference;
import com.fern.irV42.model.http.HttpEndpoint;
import com.fern.irV42.model.http.HttpResponse;
import com.fern.irV42.model.http.HttpService;
import com.fern.irV42.model.http.JsonResponseBody;
import com.fern.irV42.model.http.ResponseProperty;
import com.fern.irV42.model.http.SdkRequestBodyType;
import com.fern.irV42.model.http.SdkRequestShape.Visitor;
import com.fern.irV42.model.http.SdkRequestWrapper;
Expand All @@ -17,12 +19,17 @@
import com.fern.java.generators.AbstractFileGenerator;
import com.fern.java.output.GeneratedJavaFile;
import com.squareup.javapoet.ClassName;
import com.squareup.javapoet.CodeBlock;
import com.squareup.javapoet.FieldSpec;
import com.squareup.javapoet.JavaFile;
import com.squareup.javapoet.MethodSpec;
import com.squareup.javapoet.ParameterizedTypeName;
import com.squareup.javapoet.TypeName;
import com.squareup.javapoet.TypeSpec;
import com.squareup.javapoet.TypeSpec.Builder;
import java.time.Instant;
import java.time.temporal.ChronoUnit;
import java.util.Optional;
import java.util.function.Supplier;
import javax.lang.model.element.Modifier;

Expand All @@ -33,9 +40,13 @@ public class OAuthTokenSupplierGenerator extends AbstractFileGenerator {
private static final String ACCESS_TOKEN_FIELD_NAME = "accessToken";
private static final String AUTH_CLIENT_NAME = "authClient";
private static final String GET_TOKEN_REQUEST_NAME = "getTokenRequest";
private static final String EXPIRES_AT_FIELD_NAME = "expiresAt";
private static final String BUFFER_IN_MINUTES_CONSTANT_NAME = "BUFFER_IN_MINUTES";
private static final String EXPIRES_IN_SECONDS_PARAMETER_NAME = "expiresInSeconds";

private static final String FETCH_TOKEN_METHOD_NAME = "fetchToken";
private static final String GET_METHOD_NAME = "get";
private static final String GET_EXPIRES_AT_METHOD_NAME = "getExpiresAt";

private final OAuthClientCredentials clientCredentials;
private final ClientGeneratorContext clientGeneratorContext;
Expand All @@ -51,6 +62,7 @@ public OAuthTokenSupplierGenerator(

@Override
public GeneratedJavaFile generateFile() {
validateSupportedConfiguration(clientCredentials);
EndpointReference tokenEndpointReference =
clientCredentials.getTokenEndpoint().getEndpointReference();
HttpService httpService = generatorContext.getIr().getServices().get(tokenEndpointReference.getServiceId());
Expand Down Expand Up @@ -83,8 +95,12 @@ public GeneratedJavaFile generateFile() {
.getUnsafeName();
TypeName fetchTokenRequestType = getFetchTokenRequestType(httpEndpoint, httpService);
// todo: handle other response types
JsonResponseBody jsonResponseBody =
httpEndpoint.getResponse().get().getJson().get().getResponse().get();
HttpResponse tokenHttpResponse = httpEndpoint.getResponse().get();
JsonResponseBody jsonResponseBody = tokenHttpResponse
.getJson()
.orElseThrow(() -> new RuntimeException("Unexpected non json response type for token endpoint"))
.getResponse()
.get();
TypeName fetchTokenReturnType = clientGeneratorContext
.getPoetTypeNameMapper()
.convertToTypeName(true, jsonResponseBody.getResponseBodyType());
Expand All @@ -99,7 +115,60 @@ public GeneratedJavaFile generateFile() {
.getUnsafeName();
ParameterizedTypeName supplierOfString =
ParameterizedTypeName.get(ClassName.get(Supplier.class), ClassName.get(String.class));
TypeSpec oAuthTypeSpec = TypeSpec.classBuilder(className)
Optional<ResponseProperty> expiryResponseProperty =
clientCredentials.getTokenEndpoint().getResponseProperties().getExpiresIn();
boolean refreshRequired = expiryResponseProperty.isPresent();
MethodSpec.Builder getMethodSpecBuilder = MethodSpec.methodBuilder(GET_METHOD_NAME)
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.returns(String.class)
.beginControlFlow(
refreshRequired
? CodeBlock.builder()
.add(
"if ($L == null || $L.isBefore($T.now()))",
ACCESS_TOKEN_FIELD_NAME,
EXPIRES_AT_FIELD_NAME,
Instant.class)
.build()
: CodeBlock.builder()
.add("if ($L == null)", ACCESS_TOKEN_FIELD_NAME)
.build())
.addStatement("$T authResponse = $L()", fetchTokenReturnType, FETCH_TOKEN_METHOD_NAME)
.addStatement(
"this.$L = authResponse.get$L()", ACCESS_TOKEN_FIELD_NAME, accessTokenResponsePropertyName);
if (refreshRequired) {
String tokenPropertyName = expiryResponseProperty
.get()
.getProperty()
.getName()
.getName()
.getPascalCase()
.getUnsafeName();
getMethodSpecBuilder.addStatement(
"this.$L = $L(authResponse.get$L())",
EXPIRES_AT_FIELD_NAME,
GET_EXPIRES_AT_METHOD_NAME,
tokenPropertyName);
}
getMethodSpecBuilder
.endControlFlow()
.addStatement(
"return $S + $L",
clientCredentials.getTokenPrefix().orElse("Bearer") + " ",
ACCESS_TOKEN_FIELD_NAME);
MethodSpec.Builder constructorBuilder = MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(String.class, CLIENT_ID_FIELD_NAME)
.addParameter(String.class, CLIENT_SECRET_FIELD_NAME)
.addParameter(authClientClassName, AUTH_CLIENT_NAME)
.addStatement("this.$L = $L", CLIENT_ID_FIELD_NAME, CLIENT_ID_FIELD_NAME)
.addStatement("this.$L = $L", CLIENT_SECRET_FIELD_NAME, CLIENT_SECRET_FIELD_NAME)
.addStatement("this.$L = $L", AUTH_CLIENT_NAME, AUTH_CLIENT_NAME);
if (refreshRequired) {
constructorBuilder.addStatement("this.$L = $T.now()", EXPIRES_AT_FIELD_NAME, Instant.class);
}
Builder oauthTypeSpecBuilder = TypeSpec.classBuilder(className)
.addSuperinterface(supplierOfString)
.addModifiers(Modifier.PUBLIC, Modifier.FINAL)
.addField(FieldSpec.builder(String.class, CLIENT_ID_FIELD_NAME, Modifier.PRIVATE, Modifier.FINAL)
Expand All @@ -110,15 +179,7 @@ public GeneratedJavaFile generateFile() {
.build())
.addField(FieldSpec.builder(String.class, ACCESS_TOKEN_FIELD_NAME, Modifier.PRIVATE)
.build())
.addMethod(MethodSpec.constructorBuilder()
.addModifiers(Modifier.PUBLIC)
.addParameter(String.class, CLIENT_ID_FIELD_NAME)
.addParameter(String.class, CLIENT_SECRET_FIELD_NAME)
.addParameter(authClientClassName, AUTH_CLIENT_NAME)
.addStatement("this.$L = $L", CLIENT_ID_FIELD_NAME, CLIENT_ID_FIELD_NAME)
.addStatement("this.$L = $L", CLIENT_SECRET_FIELD_NAME, CLIENT_SECRET_FIELD_NAME)
.addStatement("this.$L = $L", AUTH_CLIENT_NAME, AUTH_CLIENT_NAME)
.build())
.addMethod(constructorBuilder.build())
.addMethod(MethodSpec.methodBuilder(FETCH_TOKEN_METHOD_NAME)
.addModifiers(Modifier.PUBLIC)
.returns(fetchTokenReturnType)
Expand All @@ -137,33 +198,47 @@ public GeneratedJavaFile generateFile() {
httpEndpoint.getName().get().getCamelCase().getUnsafeName(),
GET_TOKEN_REQUEST_NAME)
.build())
.addMethod(MethodSpec.methodBuilder(GET_METHOD_NAME)
.addModifiers(Modifier.PUBLIC)
.addAnnotation(Override.class)
.returns(String.class)
.beginControlFlow("if ($L == null)", ACCESS_TOKEN_FIELD_NAME)
.addStatement("$T authResponse = $L()", fetchTokenReturnType, FETCH_TOKEN_METHOD_NAME)
.addStatement(
"this.$L = authResponse.$L()",
ACCESS_TOKEN_FIELD_NAME,
"get" + accessTokenResponsePropertyName)
.endControlFlow()
.addStatement(
"return $S + ($L != null ? $L : $L())",
"Bearer ",
ACCESS_TOKEN_FIELD_NAME,
ACCESS_TOKEN_FIELD_NAME,
FETCH_TOKEN_METHOD_NAME)
.build())
.addMethod(getMethodSpecBuilder.build());
if (refreshRequired) {
oauthTypeSpecBuilder
.addField(FieldSpec.builder(Instant.class, EXPIRES_AT_FIELD_NAME, Modifier.PRIVATE)
.build())
.addField(FieldSpec.builder(
long.class,
BUFFER_IN_MINUTES_CONSTANT_NAME,
Modifier.PRIVATE,
Modifier.STATIC,
Modifier.FINAL)
.initializer("2")
.build())
.addMethod(MethodSpec.methodBuilder(GET_EXPIRES_AT_METHOD_NAME)
.addModifiers(Modifier.PRIVATE)
.returns(Instant.class)
.addParameter(long.class, EXPIRES_IN_SECONDS_PARAMETER_NAME)
.addStatement(
"return $T.now().plus($L, $T.SECONDS).minus($L, $T.MINUTES)",
Instant.class,
EXPIRES_IN_SECONDS_PARAMETER_NAME,
ChronoUnit.class,
BUFFER_IN_MINUTES_CONSTANT_NAME,
ChronoUnit.class)
.build());
}
JavaFile authHeaderFile = JavaFile.builder(className.packageName(), oauthTypeSpecBuilder.build())
.build();
JavaFile authHeaderFile =
JavaFile.builder(className.packageName(), oAuthTypeSpec).build();
return GeneratedJavaFile.builder()
.className(className)
.javaFile(authHeaderFile)
.build();
}

private static void validateSupportedConfiguration(OAuthClientCredentials clientCredentials) {
if (clientCredentials.getRefreshEndpoint().isPresent())
throw new RuntimeException("Refresh endpoints not supported");
if (clientCredentials.getScopes().isPresent()
&& !clientCredentials.getScopes().get().isEmpty()) throw new RuntimeException("Scopes not supported");
}

private TypeName getFetchTokenRequestType(HttpEndpoint httpEndpoint, HttpService httpService) {
return httpEndpoint.getSdkRequest().get().getShape().visit(new Visitor<>() {
@Override
Expand Down

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit c222939

Please sign in to comment.