OpenAPI to
Java Spring.
Turn an OpenAPI 3.0 or 3.1 spec into a Spring RestClient-based HTTP client. Java 17 records for models, sealed interfaces for oneOf, raw return types with no forced ResponseEntity, and null-safe optional query params via queryParamIfPresent. No signup, no install.
openapi: 3.0.3
info:
title: Payments API
version: 1.2.0
paths:
/payments/{id}:
get:
operationId: getPayment
parameters:
- name: id
in: path
required: true
schema: { type: string }
- name: expand
in: query
required: false
schema: { type: string }
responses:
'200':
content:
application/json:
schema:
$ref: '#/components/schemas/Payment'
components:
schemas:
Payment:
type: object
required: [id, amount, status, method]
properties:
id: { type: string }
amount: { type: number, format: double }
currency: { type: string, nullable: true }
status: { $ref: '#/components/schemas/PaymentStatus' }
method:
oneOf:
- $ref: '#/components/schemas/CardPayment'
- $ref: '#/components/schemas/BankPayment'
discriminator:
propertyName: kind// src/main/java/com/example/payments/client/DefaultClient.java
package com.example.payments.client;
import org.springframework.web.client.RestClient;
import org.springframework.http.MediaType;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.beans.factory.annotation.Value;
import jakarta.annotation.Nullable;
import com.example.payments.model.Payment;
import java.util.Optional;
@org.springframework.stereotype.Service
public class DefaultClient extends BaseRestClient {
public DefaultClient(
RestClient.Builder restClientBuilder,
@Value("${api.base-url}") String baseUrl
) {
super(restClientBuilder, baseUrl);
}
public Payment getPayment(String id, @Nullable String expand) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/payments/{id}")
.queryParamIfPresent("expand", Optional.ofNullable(expand))
.build(id))
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(new ParameterizedTypeReference<Payment>() {});
}
}Records, a sealed union, a RestClient.
Below: models emitted from a small payments spec with a oneOf discriminator. Each schema becomes a Java 17 record; the oneOf becomes a sealed interface with permits + Jackson @JsonTypeInfo/@JsonSubTypes wiring so the discriminator round-trips both ways.
// src/main/java/com/example/payments/model/Payment.java
package com.example.payments.model;
import jakarta.annotation.Nullable;
public record Payment(String id, Double amount, @Nullable String currency,
PaymentStatus status, MethodUnion method) {
}
// src/main/java/com/example/payments/model/MethodUnion.java
package com.example.payments.model;
import com.fasterxml.jackson.annotation.JsonTypeInfo;
import com.fasterxml.jackson.annotation.JsonSubTypes;
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "kind")
@JsonSubTypes({
@JsonSubTypes.Type(value = CardPayment.class, name = "card"),
@JsonSubTypes.Type(value = BankPayment.class, name = "bank")
})
public sealed interface MethodUnion permits CardPayment, BankPayment {
}
// src/main/java/com/example/payments/model/CardPayment.java
package com.example.payments.model;
public record CardPayment(CardPaymentKind kind, String last4)
implements MethodUnion {
}
// src/main/java/com/example/payments/model/BankPayment.java
package com.example.payments.model;
public record BankPayment(BankPaymentKind kind, String iban)
implements MethodUnion {
}Grounded in how the spec reads.
Each claim here is something you can verify by running the converter on your own spec. Click a card to see the spec fragment that triggered it and the Java that came out.
components:
schemas:
Payment:
type: object
required: [id, amount, status]
properties:
id: { type: string }
amount: { type: number, format: double }
status: { $ref: '#/components/schemas/PaymentStatus' }// model/Payment.java
package com.example.payments.model;
public record Payment(String id, Double amount, PaymentStatus status) {
}
// model/PaymentStatus.java (value enum, Jackson-bound)
public enum PaymentStatus {
PENDING("pending"),
SUCCEEDED("succeeded"),
FAILED("failed");
private final String value;
PaymentStatus(String value) { this.value = value; }
@JsonValue public String getValue() { return value; }
@JsonCreator public static PaymentStatus fromValue(String v) { /* ... */ }
}Fluent synchronous HTTP, no reactive overhead.
An abstract BaseRestClient holds the configured RestClient; DefaultClient extends it and exposes one method per operation. The generated client carries @org.springframework.stereotype.Service, so component scanning picks it up and any consumer can inject it by type — no @Bean declaration, no configuration class to maintain.
// src/main/java/com/example/payments/client/BaseRestClient.java
package com.example.payments.client;
import org.springframework.web.client.RestClient;
import org.springframework.beans.factory.annotation.Value;
public abstract class BaseRestClient {
protected final RestClient restClient;
public BaseRestClient(RestClient.Builder restClientBuilder,
@Value("${api.base-url}") String baseUrl) {
this.restClient = restClientBuilder.baseUrl(baseUrl).build();
}
}
// src/main/java/com/example/payments/client/DefaultClient.java
package com.example.payments.client;
import org.springframework.web.client.RestClient;
import org.springframework.http.MediaType;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.beans.factory.annotation.Value;
import jakarta.annotation.Nullable;
import com.example.payments.model.Payment;
import com.example.payments.model.CreatePaymentRequest;
import java.util.Optional;
@org.springframework.stereotype.Service
public class DefaultClient extends BaseRestClient {
public DefaultClient(
RestClient.Builder restClientBuilder,
@Value("${api.base-url}") String baseUrl
) {
super(restClientBuilder, baseUrl);
}
public Payment getPayment(String id, @Nullable String expand) {
return restClient.get()
.uri(uriBuilder -> uriBuilder
.path("/payments/{id}")
.queryParamIfPresent("expand", Optional.ofNullable(expand))
.build(id))
.accept(MediaType.APPLICATION_JSON)
.retrieve()
.body(new ParameterizedTypeReference<Payment>() {});
}
public Payment createPayment(CreatePaymentRequest requestBody) {
return restClient.post()
.uri("/payments")
.contentType(MediaType.APPLICATION_JSON)
.accept(MediaType.APPLICATION_JSON)
.body(requestBody)
.retrieve()
.body(new ParameterizedTypeReference<Payment>() {});
}
}
// application.yml
api:
base-url: https://api.example.com
// Injected anywhere — component scanning picks up @Service:
@org.springframework.stereotype.Service
public class CheckoutService {
private final DefaultClient payments;
public CheckoutService(DefaultClient payments) {
this.payments = payments;
}
}Or ask Claude to do it.
The same generator ships as a Model Context Protocol server. It calls the same web API this converter uses, so the output is identical. Point Claude Desktop, Cursor, or any MCP-aware agent at a spec and it produces the Java Spring client as a diff — free, no token, no leaving the chat.
// claude-desktop config · .mcp.json
{
"mcpServers": {
"metaengine": {
"command": "npx",
"args": ["-y", "@metaengine/mcp-server"]
}
}
}
// Then in Claude:
// "Load specs/payments.yaml and generate a Java Spring
// client in package com.example.payments."Same generator, as a package.
The converter above is the fastest way to try the output. For CI and repeatable builds, the identical generator ships as a package — pin a version, wire it into your pipeline, and review diffs like any other code.
Things people ask.
The questions that come up most when generating Spring clients from OpenAPI. If yours is not here, run the converter — the answer is usually in the output.
sealed interface per oneOf, and a RestClient-based client class per tag (DefaultClient when no tags are set) — all sharing a single BaseRestClient. Client classes are annotated @Service so component scanning wires them automatically. No controller stubs, no server scaffolding — client only.Same engine, other stacks.
Every converter runs the same spec-to-IR pipeline. Pick another source format or target stack.
Try it on your own spec.
The converter runs in the browser. Your spec never leaves the page.