Skip to main content

Stop Writing API Clients

·8 mins
Nicholas Meyers
Author
Nicholas Meyers
A driven Java developer with a strong focus on application security. I get a lot of energy from continuously learning and diving deep into the latest methods for making software safer. In my free time, I love working on hobby projects and exploring new technologies. Always on the lookout for the next challenge and an opportunity to keep learning and experimenting.

Why spend days building API clients manually when you can auto-generate them in minutes and keep them perfectly in sync?

Introduction
#

As backend developers, we frequently build APIs with the intention that others can easily consume them - whether in other applications, end-to-end tests, or stress tests. But why not make life easier for everyone and generate the clients ourselves? The traditional approach forces API consumers to manually write HTTP clients, parse responses, handle errors, and maintain code that mirrors your API structure. Every time your API changes, they need to update their client code. This creates unnecessary friction and maintenance overhead. What if we could eliminate this burden entirely? What if consuming your API was as simple as adding a dependency and calling type-safe methods?

The Idea
#

We invest significant time crafting our controllers, OpenAPI specifications, and API implementations. Once our API is working, we want to write end-to-end tests, create stress tests, or ensure other teams and organizations can easily integrate with our services. So why rebuild a client from scratch when we can generate it automatically? While our technology stack might be fixed - perhaps we’re using Spring Boot or Quarkus - the beauty of client generation is that it’s framework-agnostic. This means anyone can use our generated clients, regardless of their technology choices.

My goal was to create the lightest possible client that’s easy to use and maintain. The result? A solution that:

  • Eliminates manual client code
  • Ensures type safety
  • Automatically stays in sync with your API
  • Works with any Java application
  • Requires minimal dependencies

How It Works
#

To generate a client, we need an OpenAPI specification as our source of truth. There are two common approaches to obtaining this specification:

Contract-First Approach:

  1. Write your OpenAPI specification manually
  2. Generate controllers and models from the spec
  3. Implement the business logic

Code-First Approach:

  1. Write your controllers with annotations
  2. Use tools like SpringDoc or Swagger to generate the OpenAPI spec automatically
  3. Use the generated spec for client generation

Once you have your OpenAPI specification, the client generation process is straightforward:

  1. Configure the generator - Choose your target language, set package names, and configure code style options
  2. Run the build - Execute the generation as part of your Maven or Gradle build
  3. Publish the artifact - Deploy the generated client to your Maven repository (Nexus, Artifactory, or Maven Central)
  4. Consume - Other developers simply add your client as a dependency

The OpenAPI Generator Maven plugin handles all the heavy lifting. It reads your specification and generates:

  • Model classes (POJOs) for request and response objects
  • API interface definitions with all endpoints
  • HTTP client implementation using libraries like OkHttp
  • Serialization/deserialization logic
  • Exception handling
  • Documentation

Example Implementation
#

Let’s walk through a complete example of generating a client for a Person API.

The OpenAPI Specification
#

Here’s our API specification for managing persons. This could be hand-written or auto-generated from your controllers:

openapi: 3.0.3
info:
  title: Person API
  version: 1.0.0
  description: Demo API for managing persons

paths:
  /person:
    get:
      summary: Find all persons
      operationId: findAllPersons
      responses:
        '200':
          description: List of persons
          content:
            application/json:
              schema:
                type: array
                items:
                  $ref: '#/components/schemas/PersonResponseResource'
    post:
      summary: Create a new person
      operationId: createPerson
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequestResource'
      responses:
        '201':
          description: Person created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponseResource'

  /person/{id}:
    get:
      summary: Find person by ID
      operationId: findPersonById
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: integer
      responses:
        '200':
          description: Found person
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponseResource'
        '404':
          description: Not found
    put:
      summary: Update a person
      operationId: updatePerson
      parameters:
        - in: path
          name: id
          required: true
          schema:
            type: integer
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/PersonRequestResource'
      responses:
        '200':
          description: Person updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PersonResponseResource'
        '404':
          description: Not found

components:
  schemas:
    PersonResponseResource:
      type: object
      properties:
        id:
          type: string
          format: uuid
          example: b79cafbb-5a70-455e-93d2-62e744c68379
        name:
          type: string
          example: Nicholas Meyers
        age:
          type: integer
          example: 30
    PersonRequestResource:
      type: object
      properties:
        name:
          type: string
          example: Nicholas Meyers
        age:
          type: integer
          example: 30
      required:
        - name
        - age

Maven Configuration
#

This POM file configures the OpenAPI Generator plugin and includes all necessary dependencies. The key is the openapi-generator-maven-plugin configuration:

<?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 
         https://maven.apache.org/xsd/maven-4.0.0.xsd">
    <modelVersion>4.0.0</modelVersion>

    <groupId>be.nicholasmeyers</groupId>
    <artifactId>demo-client-generator</artifactId>
    <version>0.0.1-SNAPSHOT</version>

    <name>demo-client-generator</name>
    <description>Auto-generated client for Person API</description>

    <properties>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
    </properties>

    <dependencies>
        <!-- HTTP Client -->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>okhttp</artifactId>
            <version>4.12.0</version>
        </dependency>

        <!-- Logging for HTTP requests/responses -->
        <dependency>
            <groupId>com.squareup.okhttp3</groupId>
            <artifactId>logging-interceptor</artifactId>
            <version>4.12.0</version>
        </dependency>

        <!-- JSON serialization -->
        <dependency>
            <groupId>com.google.code.gson</groupId>
            <artifactId>gson</artifactId>
            <version>2.10.1</version>
        </dependency>

        <!-- Enhanced Gson features -->
        <dependency>
            <groupId>io.gsonfire</groupId>
            <artifactId>gson-fire</artifactId>
            <version>1.9.0</version>
        </dependency>

        <!-- Jakarta annotations -->
        <dependency>
            <groupId>jakarta.annotation</groupId>
            <artifactId>jakarta.annotation-api</artifactId>
            <version>2.1.1</version>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <!-- Compiler plugin -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-compiler-plugin</artifactId>
                <version>3.11.0</version>
            </plugin>
            
            <!-- Package sources -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-source-plugin</artifactId>
                <version>3.3.0</version>
                <executions>
                    <execution>
                        <id>attach-sources</id>
                        <phase>package</phase>
                        <goals>
                            <goal>jar</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            
            <!-- Package javadocs -->
            <plugin>
                <groupId>org.apache.maven.plugins</groupId>
                <artifactId>maven-javadoc-plugin</artifactId>
                <version>3.6.0</version>
                <configuration>
                    <failOnError>false</failOnError>
                    <doclint>none</doclint>
                </configuration>
                <executions>
                    <execution>
                        <id>attach-javadocs</id>
                        <phase>package</phase>
                        <goals>
                            <goal>jar</goal>
                        </goals>
                    </execution>
                </executions>
            </plugin>
            
            <!-- OpenAPI Generator -->
            <plugin>
                <groupId>org.openapitools</groupId>
                <artifactId>openapi-generator-maven-plugin</artifactId>
                <version>7.2.0</version>
                <executions>
                    <execution>
                        <goals>
                            <goal>generate</goal>
                        </goals>
                        <configuration>
                            <!-- Path to your OpenAPI spec -->
                            <inputSpec>${project.basedir}/src/main/resources/person-open-api-spec.yaml</inputSpec>
                            
                            <!-- Target language -->
                            <generatorName>java</generatorName>
                            
                            <!-- Generate model classes -->
                            <generateModels>true</generateModels>
                            <modelPackage>be.nicholasmeyers.democlientgenerator.client.model</modelPackage>
                            
                            <!-- Generate API interfaces -->
                            <generateApis>true</generateApis>
                            <apiPackage>be.nicholasmeyers.democlientgenerator.client.api</apiPackage>
                            
                            <!-- Configuration options -->
                            <configOptions>
                                <library>okhttp-gson</library>
                                <dateLibrary>java8</dateLibrary>
                                <useJakartaEe>true</useJakartaEe>
                                <interfaceOnly>false</interfaceOnly>
                                <skipDefaultInterface>false</skipDefaultInterface>
                            </configOptions>
                        </configuration>
                    </execution>
                </executions>
            </plugin>
        </plugins>
    </build>
</project>

Key Configuration Options Explained:

  • inputSpec: Location of your OpenAPI specification file
  • generatorName: Target language (java, typescript-axios, python, etc.)
  • library: HTTP client library to use (okhttp-gson, native, apache-httpclient)
  • modelPackage: Package name for generated model classes
  • apiPackage: Package name for generated API classes
  • useJakartaEe: Use Jakarta EE annotations instead of javax
  • dateLibrary: How to handle date/time types (java8 uses LocalDate/LocalDateTime)

Using the Generated Client
#

After running mvn clean install, the generated client is ready to use. Here’s how simple it becomes:

package be.nicholasmeyers.example;

import be.nicholasmeyers.democlientgenerator.client.api.PersonApi;
import be.nicholasmeyers.democlientgenerator.client.model.PersonRequestResource;
import be.nicholasmeyers.democlientgenerator.client.model.PersonResponseResource;
import be.nicholasmeyers.democlientgenerator.client.ApiClient;
import be.nicholasmeyers.democlientgenerator.client.ApiException;

import java.util.List;

public class PersonClientExample {
    
    private final PersonApi personApi;

    public PersonClientExample(String baseUrl) {
        // Configure the API client
        ApiClient apiClient = new ApiClient();
        apiClient.setBasePath(baseUrl);
        
        // Optionally add authentication
        // apiClient.setApiKey("your-api-key");
        // apiClient.setBearerToken("your-jwt-token");
        
        this.personApi = new PersonApi(apiClient);
    }

    public List<PersonResponseResource> getAllPersons() throws ApiException {
        // Simple method call - no manual HTTP handling needed
        return personApi.findAllPersons();
    }

    public PersonResponseResource getPersonById(Integer id) throws ApiException {
        return personApi.findPersonById(id);
    }

    public PersonResponseResource createPerson(String name, Integer age) throws ApiException {
        PersonRequestResource request = new PersonRequestResource();
        request.setName(name);
        request.setAge(age);
        
        return personApi.createPerson(request);
    }

    public PersonResponseResource updatePerson(Integer id, String name, Integer age) throws ApiException {
        PersonRequestResource request = new PersonRequestResource();
        request.setName(name);
        request.setAge(age);
        
        return personApi.updatePerson(id, request);
    }

    public static void main(String[] args) {
        try {
            PersonClientExample example = new PersonClientExample("http://localhost:8080");
            
            // Create a new person
            PersonResponseResource created = example.createPerson("John Doe", 25);
            System.out.println("Created person with ID: " + created.getId());
            
            // Fetch all persons
            List<PersonResponseResource> allPersons = example.getAllPersons();
            System.out.println("Total persons: " + allPersons.size());
            
            // Get specific person
            PersonResponseResource person = example.getPersonById(1);
            System.out.println("Found person: " + person.getName());
            
        } catch (ApiException e) {
            System.err.println("API error: " + e.getMessage());
            System.err.println("Response body: " + e.getResponseBody());
        }
    }
}

Notice how clean this is compared to manually writing HTTP clients with RestTemplate or HttpClient. No URL construction, no manual JSON parsing, no response mapping - it’s all handled automatically.

Advanced Use Cases
#

Adding Authentication Interceptors:

import okhttp3.Interceptor;
import okhttp3.Request;
import okhttp3.Response;

public class BearerTokenInterceptor implements Interceptor {
    private final String token;
    
    public BearerTokenInterceptor(String token) {
        this.token = token;
    }
    
    @Override
    public Response intercept(Chain chain) throws IOException {
        Request original = chain.request();
        Request request = original.newBuilder()
            .header("Authorization", "Bearer " + token)
            .build();
        return chain.proceed(request);
    }
}

// Usage
ApiClient apiClient = new ApiClient();
apiClient.getHttpClient().newBuilder()
    .addInterceptor(new BearerTokenInterceptor("your-jwt-token"))
    .build();

Creating Framework-Specific Starters:

You can wrap the generated client in framework-specific starters:

  • Spring Boot Starter: Auto-configure the client with Spring properties
  • Quarkus Extension: Create a Quarkus-native client extension
  • Micronaut Integration: Use Micronaut’s dependency injection

Multi-Client Generation:

Generate clients for multiple languages from the same spec:

<executions>
    <execution>
        <id>java-client</id>
        <goals><goal>generate</goal></goals>
        <configuration>
            <generatorName>java</generatorName>
            <outputDirectory>${project.basedir}/target/java-client</outputDirectory>
        </configuration>
    </execution>
    <execution>
        <id>typescript-client</id>
        <goals><goal>generate</goal></goals>
        <configuration>
            <generatorName>typescript-axios</generatorName>
            <outputDirectory>${project.basedir}/target/typescript-client</outputDirectory>
        </configuration>
    </execution>
</executions>

Conclusion
#

Generating API clients is no longer a luxury - it’s a best practice that saves time, reduces errors, and improves developer experience. With just a few lines of Maven configuration, you can provide type-safe, well-documented clients to all your API consumers.

The benefits compound over time. Every API change is automatically reflected in the client. Every new endpoint becomes immediately available. Every consumer gets the same high-quality experience.

Whether you’re building microservices, providing APIs to external partners, or writing integration tests, client generation should be part of your workflow. It’s fast, reliable, and eliminates entire classes of integration bugs.

Next Steps:

  1. Add OpenAPI documentation to your existing APIs
  2. Set up client generation in your build pipeline
  3. Publish your first generated client to your Maven repository
  4. Create organization-wide conventions for client generation
  5. Explore language-specific generators for frontend teams

Resources:

With minimal effort, you can transform how your organization builds and consumes APIs. Start generating clients today, and never write manual HTTP client code again.