REST WS, OpenAPI, client with Maven (Spring Boot)

1. Reference


2. Introduction

This article will automatically build a full REST WS client from a OpenAPI (former Swagger) spec file.

Note: Although this technique is not specific to Spring Boot, the example assumes we're working on an Spring Boot project using SpringDoc (org.springdoc:springdoc-openapi-ui).


3. OpenAPI spec file

Place the OpenAPI spec file inside the Maven project folder:

src/main/resources/openapis/

Note: For the example, the spec file name "CouOauth.json" will be used; which content is:

{

  "openapi": "3.0.1",

  "info": {

    "title": "COU OAuth (handcrafted)",

    "description": "This is a handcrafted OpenAPI 3 spec for the COU OAuth userinfo operation.",

    "contact": {

      "email": "wsparcie.techniczne@cou.edu"

    },

    "version": "0.0.1"

  },

  "servers": [

    {

      "url": "https://auth.test.cou.edu:443"

    },

    {

      "url": "https://oauth.test.am.cou.es:443"

    }

  ],

  "paths": {

    "/userinfo": {

      "get": {

        "tags": [

          "oauth"

        ],

        "summary": "Get user info (employee number, affiliations, etc.)",

        "operationId": "getUserinfo",

        "responses": {

          "200": {

            "description": "OK",

            "content": {

              "application/json": {

                "schema": {

                  "$ref": "#/components/schemas/Oauth2Userinfo"

                }

              }

            }

          },

          "401": {

            "description": "Unauthenticated",

            "content": {

              "application/json": {

                "schema": {

                  "$ref": "#/components/schemas/Oauth2Error"

                }

              }

            }

          },

          "default": {

            "description": "Default error",

            "content": {

              "application/json": {

                "schema": {

                  "$ref": "#/components/schemas/Oauth2Error"

                }

              }

            }

          }

        },

        "security": [

          {

            "bearer-key": []

          }

        ]

      }

    }

  },

  "components": {

    "schemas": {

      "Oauth2Userinfo": {

        "description": "COU OAuth2 Userinfo structure",

        "type": "object",

        "required": [

          "sub"

        ],

        "properties": {

          "campusSession": {

            "type": "string"

          },

          "eduPersonAffiliation": {

            "uniqueItems": true,

            "type": "array",

            "items": {

              "type": "string"

            }

          },

          "email": {

            "type": "string"

          },

          "employeeNumber": {

            "type": "string"

          },

          "rat": {

            "type": "integer",

            "format": "int64"

          },

          "sub": {

            "type": "string"

          },

          "username": {

            "type": "string"

          }

        }

      },

      "Oauth2Error": {

        "required": [

          "error"

        ],

        "properties": {

          "error": {

            "$ref": "#/components/schemas/Oauth2ErrorObject"

          }

        }

      },

      "Oauth2ErrorObject": {

        "required": [

          "code"

        ],

        "properties": {

          "code": {

            "type": "integer",

            "format": "int32"

          },

          "status": {

            "type": "string"

          },

          "reason": {

            "type": "string"

          },

          "message": {

            "type": "string"

          }

        }

      }

    },

    "securitySchemes": {

      "bearer-key": {

        "type": "http",

        "scheme": "bearer"

      }

    }

  }

}

Remark: It should work in the same way if using a spec file in .yaml format instead of .json.


4. pom.xml

4.1 Let's configure the Maven project POM file for automatically generate Java client classes for the REST WS out of the OpenAPI spec file

Note: Spring Boot 3 uses jakarta instead of javax and therefore it's nedeed to activate useSpringBoot3 flag as shown in the example below.


At the 'properties' section, declare the openapi-generator version:

<!-- build-helper compatible w/ openapi-generator -->

<build-helper-maven-plugin.version>3.0.0</build-helper-maven-plugin.version>

<openapi-generator.version>5.0.0-beta3</openapi-generator.version>

At the 'build.pluginManagements.plugins' section, add Eclipse lifecycle mapping:

              <plugin>

                   <groupId>org.eclipse.m2e</groupId>

                   <artifactId>lifecycle-mapping</artifactId>

                   <version>1.0.0</version>

                   <configuration>

                       <lifecycleMappingMetadata>

                           <pluginExecutions>

<pluginExecution>

<pluginExecutionFilter>

<groupId>org.openapitools</groupId>

<artifactId>openapi-generator-maven-plugin</artifactId>

<versionRange>[${openapi-generator.version},)</versionRange>

<goals>

<goal>generate</goal>

</goals>

</pluginExecutionFilter>

<action>

<execute/>

</action>

</pluginExecution>

                           </pluginExecutions>

                       </lifecycleMappingMetadata>

                   </configuration>

               </plugin>


At the 'build.plugins' section, add the generation for Java sources out of the OpenAPI spec file:

<plugin>

<!-- Generate Java Client for OpenAPI REST WS, eg: CouOauth -->

<groupId>org.openapitools</groupId>

<artifactId>openapi-generator-maven-plugin</artifactId>

<version>${openapi-generator.version}</version>

<executions>

<execution>

<id>couoauth-openapi-contract</id>

<goals>

<goal>generate</goal>

</goals>

<configuration>

<inputSpec>${project.basedir}/src/main/resources/openapis/CouOauth.json</inputSpec>

                            <skipValidateSpec>false</skipValidateSpec>

<!-- generatorName: {java, spring} -->

<generatorName>java</generatorName>

<configOptions>

<apiPackage>${package.generated}.uocoauth.api</apiPackage>

<dateLibrary>java8</dateLibrary>

<invokerPackage>${package.generated}.couoauth.invoker</invokerPackage>

<!-- library: {resttemplate, spring-boot} -->

<library>resttemplate</library>

                     <modelPackage>${package.generated}.couoauth.model</modelPackage>

                     <!-- useJakartaEe: Client jakarta vs javax -->

                     <useJakartaEe>true</useJakartaEe>

                     <!-- useSpringBoot3: Server jakarta vs javax -->

                     <useSpringBoot3>true</useSpringBoot3>

</configOptions>

</configuration>

</execution>

</executions>

</plugin>

Try to build the project for checking if any additional dependency is missing (it might depend on the specific OpenAPI spec file being used).

4.2 Adding, if needed, additional dependencies

If project compilation fails, probably openapi-generator generated Java sources with "import" statements pointing to dependencies not available in the project.

Fortunately, openapi-generator also generated a pom.xml file with all the dependencies used by the generated sources:

target/generated-sources/openapi/pom.xml 


In our example, a dependency for swagger annotations is needed, so let's add it to the project pom.xml:

<!-- openapi-generator-maven-plugin: Swagger v3 annotations -->

<dependency>

<groupId>io.swagger.parser.v3</groupId>

<artifactId>swagger-parser</artifactId>

<version>2.0.24</version>

</dependency>

Note: io.swagger.parser.v3 is used because of compatibility with SpringDoc.

4.3 Add the generated sources folder to the Eclipse build_class_path

At section "build.plugins":

    <plugin>

    <!-- adding generated source (it adds a new build_class_path to the project) -->

    <groupId>org.codehaus.mojo</groupId>

    <artifactId>build-helper-maven-plugin</artifactId>

    <executions>

     <execution>

      <id>add-source</id>

      <phase>generate-sources</phase>

      <goals>

       <goal>add-source</goal>

      </goals>

      <configuration>

       <sources>

        <source>${project.build.directory}/generated-sources/openapi/src/main/java</source>

       </sources>

      </configuration>

     </execution>

    </executions>

   </plugin>




5. Configure Spring Boot to use the generated OpenAPI client

5.1. PortRestCouOauthConfig.java

Remark: The oauthPort bean is annotated with scope prototype because, in this specific need, we need a new instance of its ApiClient since it'll hold the unique oauth token for that request.

Create this file at the same level than the main Spring Boot application one (annotated with @SpringBoot application):

package edu.cou.myapp;


import java.security.SecureRandom;


import javax.inject.Inject;


import javax.net.ssl.HttpsURLConnection;

import javax.net.ssl.SSLContext;

import javax.net.ssl.TrustManager;


import org.springframework.beans.factory.annotation.Value;

import org.springframework.context.annotation.Bean;

import org.springframework.beans.factory.config.ConfigurableBeanFactory;

import org.springframework.context.annotation.Configuration;

import org.springframework.context.annotation.Scope;


import edu.cou.myapp.helper.SecurityHelper;

import edu.cou.myapp.service.EnvironmentService;

import myapp_back.generated.couoauth.api.OauthApi;

import myapp_back.generated.couoauth.invoker.ApiClient;

import lombok.val;

import lombok.extern.slf4j.Slf4j;


/**

 * 

 * CouOauth WS REST (Configure Java Client generated classes as beans).<br>

 * <br>

 * Remark: This class is part of a NonNullApi!

 */

@Configuration

@Slf4j

public class PortRestCouOauthConfig {


  /**

   * Path segment, after hostname and before the operation 'path' (REST couoauth.json).<br>

   */

  private String couoauthWsPathSegment;


  /** Environment service. */

  private EnvironmentService environmentService;


  /**

   * Environment helper service.

   * 

   * @param environmentHelper -

   */

  @Inject

  public PortRestCouOauthConfig(@Value("${couoauth.path.segment:''}") String couoauthWsPathSegment,

      EnvironmentService environmentService) {

    this.couoauthWsPathSegment = couoauthWsPathSegment;

    this.environmentService = environmentService;

    this.installAllTrustingTrustManager();

  }


  /**

   * Invoker.<br>

   * <br>

   * DOC. @Scope(value = 'prototype') means that Spring will not instantiate the bean right on

   * start, but will do it later on demand. E.g: beanFactory.getBean(DefaultApi.class,

   * environmentHelper.getEndpointUrlBase())<br>

   * <br>

   * 'endpointUrlBase' eg: "http://sa-test.cou.org:80"<br>

   * 

   * @return Invoker API client

   */

  @Bean

  @Scope(ConfigurableBeanFactory.SCOPE_PROTOTYPE)

  public OauthApi authPort() {

    log.info("oauthPort has been invoked");

    return new OauthApi(newApiClient(this.environmentService.getEnvOpt(

        EnvironmentService.ENV_OAUTH_ISSUER).orElseThrow()));

  }


  /**

   * The ApiClient class is used for configuring authentication, the base path of the API, common

   * headers, and it's responsible for executing all API requests.<br>

   * <br>

   * OAuth token authentication can be provided with method:<br>

   * setBearerToken(oauthToken)<br>

   * 

   * @param  endpointUrlBase E.g: "http://sa-test.cou.org:80"

   * @return                 Invoker API client

   */

  private ApiClient newApiClient(String endpointUrlBase) {

    log.info("apiClient has been invoked");

    // Endpoint basePath

    final String endPointBasePath = endpointUrlBase + this.couoauthWsPathSegment;

    if (log.isInfoEnabled()) {

      log.info("CouOauth endpoint base path = " + endPointBasePath);

    }

    

    val apiClient = new ApiClient();

    apiClient.setBasePath(endPointBasePath);



    //

    return apiClient;

  }


  

  /*-

   * 

   */

  private void installAllTrustingTrustManager() {

    // INI (needed to avoid SSL exception)

    // (a) Create a trust manager that does not validate certificate chains

    TrustManager[] trustAllCerts = SecurityHelper.getAllTrustingTrustManager();


    // (b) Install the all-trusting trust manager

    try {

      SSLContext sc = SSLContext.getInstance("TLSv1.2");

      sc.init(null, trustAllCerts, new SecureRandom());

      HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());

    } catch (Exception e) {

      log.info("Exception caught installing the all-trusting trust manager", e);

    }

    // END (needed to avoid SSL exception)

  }

  

}

5.2. Application configuration class (@SpringBootApplication)

The application configuration class annotated with @SpringBootApplication should, by default, already be able to find the newly defined bean.

If you're running into issues with this, you could explicitly add to the the annotation @Import(PortRestCouOauthConfig.class)


Sample App.java:

...

/**

 * App.

 */

@Slf4j

@EnableJpaRepositories(repositoryFactoryBeanClass = EnversRevisionRepositoryFactoryBean.class)

@ComponentScan(basePackages = { "edu.cou.myapp" })

@Configuration

@SpringBootApplication

public class App implements CommandLineRunner {

...

}

...


6 Use the just configured remote OpenAPI REST WS

Note the @Lookup annotation on the getOauthPort() method which garantees getting a new instance of the prototype bean every time it's invoked.


@Service

public class OauthClientHelper implements OauthClient {


  /**

   * Spring will override the method annotated with (at)Lookup. It then registers the

   * bean into the application context. Whenever we request the method, it returns a new

   * OauthApi instance.

   * 

   * @return A new instance of OauthApi with its own unique ApiClient

   */

  @Lookup

  public OauthApi getOauthPort() {

    return null; // NOSONAR

  }



  /**

   * Invokes oauth/userinfo (using uocOauthPort).<br>

   * 

   * @param  oauthToken   oauthToken (w/out 'Bearer ')

   * @return              Response from oauth/userinfo

   * @throws AppException

   */

  @Override

  public Oauth2Userinfo userinfo(final String oauthToken) {

    try {


      // Since authorization can be different, we need a new instance of ApiClient every time

      OauthApi oauthPort = this.getOauthPort();

      ApiClient apiclient = oauthPort.getApiClient();

      log.info("apiClient should be a new object everythime: " + Integer.toHexString(System

          .identityHashCode(apiclient)));

      apiclient.setBearerToken(oauthToken);


      /*- INI remote call */

      if (log.isInfoEnabled()) {

        log.info("INI couOauthPort.getUserinfo()");

      }

      long callStart = System.nanoTime();

      // Invocation start

       Oauth2Userinfo res = oauthPort.getUserinfo();

      // Invocation finish

      long callFinish = System.nanoTime();

      if (log.isInfoEnabled()) {

        log.info(String.format("END couOauthPort.getUserinfo. Elapsed (ms): %d. Result: %s",

            (callFinish - callStart) / 1_000_000L, "<scrapped>"));

      }

      /*- END remote call */


      return res;

    } catch (RestClientException e) {

      log.error("RestClientException." + e.getMessage(), e);

      throw transformExceptionRestToAppException(e);

    }

  }




  /**

   * Transform the exception rest to a App exception.<br>

   * 

   * @param  restClientException -

   * @return                     NotNull

   */

  private AppException transformExceptionRestToAppException(final RestClientException e) {

    AppException ret;

    String errMsg = null;

    if (e instanceof HttpClientErrorException) {

      // HTTP status 4xx

      errMsg = ((HttpClientErrorException) e).getResponseBodyAsString();

      if (HttpStatus.BAD_REQUEST.equals(((HttpClientErrorException) e).getStatusCode())) {

        ret = new BadRequestAppException(errMsg, e.getCause());

      } else if (HttpStatus.CONFLICT.equals(((HttpClientErrorException) e).getStatusCode())) {

        ret = new ConflictAppException(errMsg, e.getCause());

      } else if (HttpStatus.FORBIDDEN.equals(((HttpClientErrorException) e).getStatusCode())) {

        ret = new ForbiddenAppException(errMsg, e.getCause());

      } else if (HttpStatus.NOT_FOUND.equals(((HttpClientErrorException) e).getStatusCode())) {

        ret = new NotFoundAppException(errMsg, e.getCause());

      } else {

        ret = new ServiceUnavailableAppException(errMsg, e.getCause());

      }

    } else if (e instanceof HttpServerErrorException) {

      errMsg = ((HttpServerErrorException) e).getResponseBodyAsString();

      if (HttpStatus.GATEWAY_TIMEOUT.equals(((HttpServerErrorException) e).getStatusCode())) {

        ret = new GatewayAppException(errMsg, e.getCause());

      } else if (HttpStatus.INTERNAL_SERVER_ERROR.equals(((HttpServerErrorException) e)

          .getStatusCode())) {

        ret = new InternalServerErrorAppException(errMsg, e.getCause());

      } else if (HttpStatus.SERVICE_UNAVAILABLE.equals(((HttpServerErrorException) e)

          .getStatusCode())) {

        ret = new ServiceUnavailableAppException(errMsg, e.getCause());

      } else {

        ret = new ServiceUnavailableAppException(errMsg, e.getCause());

      }

    } else {

      ret = new ServiceUnavailableAppException(e.getMessage(), e.getCause());

    }


    return ret;

  }



}


7. Unit tests, mocking OAuthApi

Remark: It's necessary to also mock the getApiClient method, otherwise you'd get a null pointer exception when getting it out from the OAuthApi for setting the 'Authorization' header with the OAuth token bearer.


TestConfig00.java:

...

@TestConfiguration

public class TestConfig00 {

...


 /**

   * OAuth port.<br>

   * 

   * @return -

   */

  @Bean

  @Primary

  public OauthApi oauthPortTest() {

    log.info("#### Mocking oauthPortTest");

    final OauthApi oauthPortMock = Mockito.mock(OauthApi.class, new TestMockingIssueAnswer());


    // getApiClient

    doReturn(new ApiClient()).when(oauthPortMock).getApiClient();


    // userinfo

    Oauth2Userinfo userinfores = new Oauth2Userinfo();

    userinfores.setEmployeeNumber(MICHAL_CODE.toString());

    userinfores.addEduPersonAffiliationItem("1227");

    userinfores.addEduPersonAffiliationItem("1234");

    doReturn(userinfores).when(oauthPortMock).getUserinfo();

    //

    return oauthPortMock;

  }


...

}


8. Troubleshooting

8.1. SSL certificate validation exception when invoking a remote operation

Relevant imports:

import java.security.SecureRandom;

import java.security.cert.X509Certificate;

import javax.net.ssl.HttpsURLConnection;

import javax.net.ssl.SSLContext;

import javax.net.ssl.TrustManager;

import javax.net.ssl.X509TrustManager;

import lombok.experimental.UtilityClass;


SecurityHelper.java:

@UtilityClass

public class SecurityHelper {


  /** Singleton of a all-trusting trust manager. */

  private static TrustManager[] trustAllCerts;


  /**

   * Trust manager that does not validate certificate chains.

   * 

   * @return Singleton of a all-trusting trust manager

   */

  public static TrustManager[] getAllTrustingTrustManager() {

    if (null == SecurityHelper.trustAllCerts) {

      SecurityHelper.trustAllCerts = new TrustManager[] { new X509TrustManager() {

        @Override

        public X509Certificate[] getAcceptedIssuers() {

          return new X509Certificate[0];

        }


        @Override

        public void checkClientTrusted(X509Certificate[] certs, String authType)

            throws java.security.cert.CertificateException {

          // Let's do nothing

          if (OffsetDateTime.now().isBefore(OffsetDateTime.MIN)) {

            throw new java.security.cert.CertificateException("Never thrown, it makes happy Sonar");

          }

        }


        @Override

        public void checkServerTrusted(X509Certificate[] certs, String authType)

            throws java.security.cert.CertificateException {

          // Let's do nothing

          if (OffsetDateTime.now().isAfter(OffsetDateTime.MAX)) {

            throw new java.security.cert.CertificateException("Never thrown, making Sonar happy");

          }

        }

      } };

    }

    //

    return SecurityHelper.trustAllCerts.clone();

  }


}


Install the all-trusting trust manager elsewhere:

  /*-

   * 

   */

  private void installAllTrustingTrustManager() {

    // INI (needed to avoid SSL exception)

    // (a) Create a trust manager that does not validate certificate chains

    TrustManager[] trustAllCerts = SecurityHelper.getAllTrustingTrustManager();


    // (b) Install the all-trusting trust manager

    try {

      SSLContext sc = SSLContext.getInstance("TLSv1.2");

      sc.init(null, trustAllCerts, new SecureRandom());

      HttpsURLConnection.setDefaultSSLSocketFactory(sc.getSocketFactory());

    } catch (Exception e) {

      log.info("Exception caught installing the all-trusting trust manager", e);

    }

    // END (needed to avoid SSL exception)

  }