API Integration - Backend
Spring Boot backend API integration with OpenAPI ensures your implementation matches the contract, provides interactive documentation, and enables automated validation.
Overview
Integrate OpenAPI in Spring Boot to:
- Generate OpenAPI spec from code annotations
- Validate requests/responses against OpenAPI spec
- Provide interactive API documentation (Swagger UI)
- Enable contract-first or code-first development
- Ensure implementation matches contract
Code-First vs Contract-First
Contract-First (Recommended): Define OpenAPI spec first, then implement Code-First: Write Spring Boot controllers, generate OpenAPI spec from annotations
We recommend contract-first for better API design and frontend/backend collaboration.
Core Principles
- Contract Compliance: Implementation must match OpenAPI specification
- Request Validation: Validate all incoming requests against schema
- Response Validation: Ensure responses match defined schemas
- Documentation: Provide interactive API documentation
- Error Standards: Use RFC 7807 Problem Details for errors
Springdoc OpenAPI Setup
Dependencies
dependencies {
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0'
// Optional: For OAuth2 documentation
implementation 'org.springdoc:springdoc-openapi-starter-security-oauth2:2.2.0'
}
Configuration
// src/main/java/com/bank/payments/config/OpenApiConfig.java
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Contact;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.info.License;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import io.swagger.v3.oas.models.servers.Server;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Value("${app.version}")
private String appVersion;
@Bean
public OpenAPI paymentOpenAPI() {
return new OpenAPI()
.info(new Info()
.title("Payment API")
.version(appVersion)
.description("""
Payment processing API.
## Authentication
All endpoints require OAuth 2.0 Bearer token authentication.
## Rate Limiting
- 1000 requests per hour per user
- 10000 requests per hour per organization
""")
.contact(new Contact()
.name("API Support")
.email("[email protected]")
.url("https://developer.bank.com"))
.license(new License()
.name("Proprietary")
.url("https://bank.com/licenses")))
.addServersItem(new Server()
.url("https://api.bank.com/v1")
.description("Production server"))
.addServersItem(new Server()
.url("https://api-staging.bank.com/v1")
.description("Staging server"))
.addServersItem(new Server()
.url("http://localhost:8080/v1")
.description("Local development server"))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new io.swagger.v3.oas.models.Components()
.addSecuritySchemes("bearerAuth",
new SecurityScheme()
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")
.description("OAuth 2.0 Bearer token")));
}
}
Application Properties
# application.yml
springdoc:
api-docs:
path: /v3/api-docs
enabled: true
swagger-ui:
path: /swagger-ui.html
enabled: true
operations-sorter: method
tags-sorter: alpha
display-request-duration: true
doc-expansion: none
show-actuator: false
override-with-generic-response: false
app:
version: 1.0.0
Annotating Controllers
Payment Controller with OpenAPI Annotations
// src/main/java/com/bank/payments/controller/PaymentController.java
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.headers.Header;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.security.SecurityRequirement;
import io.swagger.v3.oas.annotations.tags.Tag;
@RestController
@RequestMapping("/api/v1/payments")
@SecurityRequirement(name = "bearerAuth")
@Tag(name = "payments", description = "Payment operations")
@Validated
public class PaymentController {
private final PaymentService paymentService;
public PaymentController(PaymentService paymentService) {
this.paymentService = paymentService;
}
@PostMapping
@Operation(
summary = "Create a new payment",
description = """
Creates a new payment transaction. Payments under $1000 are processed immediately.
Payments over $1000 require additional approval.
## Audit Logging
All payment creations are logged for compliance purposes.
"""
)
@ApiResponses({
@ApiResponse(
responseCode = "201",
description = "Payment created successfully",
headers = {
@Header(
name = "Location",
description = "URL of the created payment resource",
schema = @Schema(type = "string", format = "uri")
),
@Header(
name = "X-Request-ID",
description = "Request correlation ID for tracing",
schema = @Schema(type = "string", format = "uuid")
)
},
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = PaymentResponse.class)
)
),
@ApiResponse(
responseCode = "400",
description = "Bad request - invalid input",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "401",
description = "Unauthorized - missing or invalid authentication",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class)
)
),
@ApiResponse(
responseCode = "422",
description = "Unprocessable entity - validation errors",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ValidationErrorResponse.class)
)
)
})
public ResponseEntity<PaymentResponse> createPayment(
@Valid @RequestBody PaymentRequest request,
@RequestHeader(value = "X-Request-ID", required = false) String requestId
) {
PaymentResponse response = paymentService.processPayment(request);
URI location = ServletUriComponentsBuilder
.fromCurrentRequest()
.path("/{id}")
.buildAndExpand(response.getTransactionId())
.toUri();
return ResponseEntity
.created(location)
.header("X-Request-ID", requestId != null ? requestId : UUID.randomUUID().toString())
.body(response);
}
@GetMapping("/{paymentId}")
@Operation(
summary = "Get payment by ID",
description = "Retrieve detailed information about a specific payment"
)
@ApiResponses({
@ApiResponse(
responseCode = "200",
description = "Payment found",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = PaymentResponse.class)
)
),
@ApiResponse(
responseCode = "404",
description = "Payment not found",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = ErrorResponse.class)
)
)
})
public ResponseEntity<PaymentResponse> getPayment(
@Parameter(description = "Payment ID", required = true, example = "123e4567-e89b-12d3-a456-426614174000")
@PathVariable UUID paymentId
) {
return paymentService.getPayment(paymentId)
.map(ResponseEntity::ok)
.orElseThrow(() -> new PaymentNotFoundException(paymentId));
}
@GetMapping
@Operation(
summary = "List payments",
description = "Retrieve a paginated list of payments with optional filtering"
)
@ApiResponse(
responseCode = "200",
description = "List of payments",
content = @Content(
mediaType = "application/json",
schema = @Schema(implementation = PaymentListResponse.class)
)
)
public ResponseEntity<Page<PaymentResponse>> listPayments(
@Parameter(description = "Page number (0-indexed)", example = "0")
@RequestParam(defaultValue = "0") int page,
@Parameter(description = "Number of items per page", example = "20")
@RequestParam(defaultValue = "20") @Min(1) @Max(100) int size,
@Parameter(description = "Filter by payment status")
@RequestParam(required = false) Set<PaymentStatus> status,
@Parameter(description = "Filter payments from this date", example = "2025-01-01")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate fromDate,
@Parameter(description = "Filter payments until this date", example = "2025-01-31")
@RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate toDate,
@Parameter(description = "Sort fields (e.g., createdAt,desc)")
@RequestParam(defaultValue = "createdAt,desc") String[] sort
) {
Pageable pageable = PageRequest.of(page, size, Sort.by(parseSortOrders(sort)));
Page<PaymentResponse> payments = paymentService.listPayments(
status, fromDate, toDate, pageable
);
return ResponseEntity.ok(payments);
}
@PatchMapping("/{paymentId}")
@Operation(
summary = "Update payment",
description = "Update specific fields of a payment (only allowed for PENDING payments)"
)
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Payment updated successfully"),
@ApiResponse(responseCode = "404", description = "Payment not found"),
@ApiResponse(
responseCode = "409",
description = "Payment cannot be updated in current status",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))
)
})
public ResponseEntity<PaymentResponse> updatePayment(
@PathVariable UUID paymentId,
@Valid @RequestBody PaymentUpdateRequest request
) {
PaymentResponse response = paymentService.updatePayment(paymentId, request);
return ResponseEntity.ok(response);
}
@DeleteMapping("/{paymentId}")
@Operation(
summary = "Cancel payment",
description = "Cancel a pending payment (only PENDING payments can be cancelled)"
)
@ApiResponses({
@ApiResponse(responseCode = "204", description = "Payment cancelled successfully"),
@ApiResponse(responseCode = "404", description = "Payment not found"),
@ApiResponse(responseCode = "409", description = "Payment cannot be cancelled in current status")
})
public ResponseEntity<Void> cancelPayment(@PathVariable UUID paymentId) {
paymentService.cancelPayment(paymentId);
return ResponseEntity.noContent().build();
}
private Sort.Order[] parseSortOrders(String[] sort) {
return Arrays.stream(sort)
.map(s -> {
String[] parts = s.split(",");
String property = parts[0];
Sort.Direction direction = parts.length > 1 && "desc".equalsIgnoreCase(parts[1])
? Sort.Direction.DESC
: Sort.Direction.ASC;
return new Sort.Order(direction, property);
})
.toArray(Sort.Order[]::new);
}
}
Request/Response DTOs with Validation
PaymentRequest with Validation
// src/main/java/com/bank/payments/dto/PaymentRequest.java
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.*;
@Schema(description = "Payment creation request")
public record PaymentRequest(
@Schema(
description = "Payment amount (must be positive)",
example = "100.00",
minimum = "0.01",
maximum = "1000000.00"
)
@NotNull(message = "Amount is required")
@DecimalMin(value = "0.01", message = "Amount must be at least 0.01")
@DecimalMax(value = "1000000.00", message = "Amount cannot exceed 1,000,000.00")
@Digits(integer = 10, fraction = 2, message = "Amount must have at most 2 decimal places")
BigDecimal amount,
@Schema(
description = "Payment currency code (ISO 4217)",
example = "USD",
allowableValues = {"USD", "EUR", "GBP", "JPY", "CAD"}
)
@NotBlank(message = "Currency is required")
@Pattern(regexp = "USD|EUR|GBP|JPY|CAD", message = "Invalid currency code")
String currency,
@Schema(
description = "Payment recipient name",
example = "John Doe",
minLength = 1,
maxLength = 255
)
@NotBlank(message = "Recipient is required")
@Size(min = 1, max = 255, message = "Recipient must be between 1 and 255 characters")
String recipient,
@Schema(
description = "Optional payment reference or description",
example = "Invoice #12345",
maxLength = 500
)
@Size(max = 500, message = "Reference cannot exceed 500 characters")
String reference,
@Schema(
description = "Optional future date for scheduled payment",
example = "2025-02-15"
)
@Future(message = "Scheduled date must be in the future")
LocalDate scheduledDate
) {
// Custom validation logic
public PaymentRequest {
if (amount != null && amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
}
PaymentResponse
// src/main/java/com/bank/payments/dto/PaymentResponse.java
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "Payment response")
public record PaymentResponse(
@Schema(
description = "Unique transaction identifier",
example = "123e4567-e89b-12d3-a456-426614174000"
)
UUID transactionId,
@Schema(description = "Payment amount", example = "100.00")
BigDecimal amount,
@Schema(description = "Payment currency", example = "USD")
String currency,
@Schema(description = "Payment recipient", example = "John Doe")
String recipient,
@Schema(description = "Payment reference", example = "Invoice #12345")
String reference,
@Schema(
description = "Payment status",
implementation = PaymentStatus.class,
example = "COMPLETED"
)
PaymentStatus status,
@Schema(
description = "Payment creation timestamp",
example = "2025-01-15T10:30:00Z",
type = "string",
format = "date-time"
)
Instant createdAt,
@Schema(description = "Last update timestamp", type = "string", format = "date-time")
Instant updatedAt,
@Schema(
description = "Processing completion timestamp (null if not yet processed)",
type = "string",
format = "date-time"
)
Instant processedAt
) {}
PaymentStatus Enum
// src/main/java/com/bank/payments/model/PaymentStatus.java
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(
description = """
Payment status lifecycle:
- PENDING: Initial state, awaiting processing
- REQUIRES_APPROVAL: Payment requires manual approval (>$1000)
- APPROVED: Payment approved, awaiting processing
- PROCESSING: Payment currently being processed
- COMPLETED: Payment successfully completed
- FAILED: Payment processing failed
- CANCELLED: Payment cancelled by user
"""
)
public enum PaymentStatus {
PENDING,
REQUIRES_APPROVAL,
APPROVED,
PROCESSING,
COMPLETED,
FAILED,
CANCELLED
}
Error Handling (RFC 7807)
Error Response DTOs
// src/main/java/com/bank/payments/dto/ErrorResponse.java
import io.swagger.v3.oas.annotations.media.Schema;
@Schema(description = "RFC 7807 Problem Details error response")
public record ErrorResponse(
@Schema(
description = "URI reference identifying the error type",
example = "https://api.bank.com/errors/payment-not-found"
)
String type,
@Schema(description = "Short, human-readable summary", example = "Payment Not Found")
String title,
@Schema(description = "HTTP status code", example = "404")
int status,
@Schema(
description = "Human-readable explanation",
example = "No payment found with ID 123e4567-e89b-12d3-a456-426614174000"
)
String detail,
@Schema(
description = "URI reference to specific occurrence",
example = "/v1/payments/123e4567-e89b-12d3-a456-426614174000"
)
String instance,
@Schema(description = "Error timestamp", type = "string", format = "date-time")
Instant timestamp,
@Schema(description = "Request correlation ID", example = "987fcdeb-51a2-43f7-b123-456789abcdef")
String requestId
) {}
// src/main/java/com/bank/payments/dto/ValidationErrorResponse.java
@Schema(description = "Validation error response with field-level errors")
public record ValidationErrorResponse(
String type,
String title,
int status,
String detail,
String instance,
Instant timestamp,
String requestId,
@Schema(description = "List of field validation errors")
List<ValidationError> errors
) {}
@Schema(description = "Field validation error")
public record ValidationError(
@Schema(description = "Field that failed validation", example = "amount")
String field,
@Schema(description = "Validation error message", example = "Amount must be positive")
String message,
@Schema(description = "Value that was rejected", example = "-100.00")
Object rejectedValue
) {}
Global Exception Handler
// src/main/java/com/bank/payments/exception/GlobalExceptionHandler.java
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.context.request.WebRequest;
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(PaymentNotFoundException.class)
public ResponseEntity<ErrorResponse> handlePaymentNotFound(
PaymentNotFoundException ex,
WebRequest request
) {
ErrorResponse error = new ErrorResponse(
"https://api.bank.com/errors/payment-not-found",
"Payment Not Found",
HttpStatus.NOT_FOUND.value(),
ex.getMessage(),
request.getDescription(false).replace("uri=", ""),
Instant.now(),
getRequestId(request)
);
return ResponseEntity.status(HttpStatus.NOT_FOUND).body(error);
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public ResponseEntity<ValidationErrorResponse> handleValidationErrors(
MethodArgumentNotValidException ex,
WebRequest request
) {
List<ValidationError> errors = ex.getBindingResult()
.getFieldErrors()
.stream()
.map(error -> new ValidationError(
error.getField(),
error.getDefaultMessage(),
error.getRejectedValue()
))
.toList();
ValidationErrorResponse response = new ValidationErrorResponse(
"https://api.bank.com/errors/validation",
"Validation Error",
HttpStatus.UNPROCESSABLE_ENTITY.value(),
"Request validation failed",
request.getDescription(false).replace("uri=", ""),
Instant.now(),
getRequestId(request),
errors
);
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
}
@ExceptionHandler(PaymentStateException.class)
public ResponseEntity<ErrorResponse> handlePaymentStateException(
PaymentStateException ex,
WebRequest request
) {
ErrorResponse error = new ErrorResponse(
"https://api.bank.com/errors/invalid-payment-state",
"Invalid Payment State",
HttpStatus.CONFLICT.value(),
ex.getMessage(),
request.getDescription(false).replace("uri=", ""),
Instant.now(),
getRequestId(request)
);
return ResponseEntity.status(HttpStatus.CONFLICT).body(error);
}
private String getRequestId(WebRequest request) {
String requestId = request.getHeader("X-Request-ID");
return requestId != null ? requestId : UUID.randomUUID().toString();
}
}
Accessing Documentation
Swagger UI
Access interactive API documentation:
- Development:
http://localhost:8080/swagger-ui.html - Staging:
https://api-staging.bank.com/swagger-ui.html
OpenAPI JSON/YAML
Download OpenAPI specification:
- JSON:
http://localhost:8080/v3/api-docs - YAML:
http://localhost:8080/v3/api-docs.yaml
Best Practices
Use Records for DTOs
// Good: Immutable, concise
public record PaymentRequest(
BigDecimal amount,
String currency,
String recipient
) {}
// Avoid: Mutable, verbose
public class PaymentRequest {
private BigDecimal amount;
private String currency;
// Getters, setters, equals, hashCode, toString...
}
Comprehensive Validation
@NotNull(message = "Amount is required")
@DecimalMin(value = "0.01", message = "Amount must be positive")
@DecimalMax(value = "1000000.00", message = "Amount cannot exceed limit")
@Digits(integer = 10, fraction = 2)
BigDecimal amount;
Document All Responses
@ApiResponses({
@ApiResponse(responseCode = "200", description = "Success"),
@ApiResponse(responseCode = "400", description = "Bad Request"),
@ApiResponse(responseCode = "401", description = "Unauthorized"),
@ApiResponse(responseCode = "404", description = "Not Found"),
@ApiResponse(responseCode = "422", description = "Validation Error"),
@ApiResponse(responseCode = "500", description = "Internal Server Error")
})
Use Meaningful HTTP Status Codes
200 OK: Successful GET, PATCH201 Created: Successful POST with resource creation204 No Content: Successful DELETE400 Bad Request: Malformed request401 Unauthorized: Missing/invalid authentication403 Forbidden: Insufficient permissions404 Not Found: Resource not found409 Conflict: State conflict (e.g., can't update completed payment)422 Unprocessable Entity: Validation errors500 Internal Server Error: Unexpected server error
Further Reading
- OpenAPI Specifications - Creating OpenAPI specs
- API Contract Testing - Validating contracts
- Spring Boot API Design - REST API patterns
- Spring Boot Security - Authentication and authorization
External Resources:
Summary
Key Takeaways:
- Springdoc Integration: Use Springdoc to generate OpenAPI specs from Spring Boot code
- OpenAPI Annotations: Annotate controllers, DTOs, and methods comprehensively
- Request Validation: Use Jakarta Bean Validation annotations for input validation
- RFC 7807 Errors: Standardize error responses using Problem Details format
- Swagger UI: Provide interactive API documentation for developers
- Records for DTOs: Use Java records for immutable, concise DTOs
- Meaningful Status Codes: Use appropriate HTTP status codes for all responses
- Global Exception Handler: Centralize error handling with @RestControllerAdvice