DataSonnet offers significant development speed benefits in Spring Boot applications with minimal performance trade-offs versus Java POJOs. However, implementation requires rethinking standard Spring architecture patterns. Initial approaches using separate maps for URI/query parameters alongside DataSonnet mappings become unwieldy at scale. Key challenges include:
- Input Validation — Standard Spring annotations like
@Required,@NotNull,@Sizedon't apply to string-based DataSonnet operations - Swagger Console — Most Spring consoles rely on POJOs; OASv3 specification-based alternatives differ significantly
- Database Queries — Mapping JPA responses to objects then converting to strings creates unnecessary overhead
- HTTP Requestor — String-based request/response handling differs from typical object-oriented approaches
Input Validation
String-based processing in DataSonnet is incompatible with entity annotations. Converting requests to objects for validation then to strings wastes resources. The openapi4j tool parses OASv3 files and validates request compliance, enabling custom error handling.
Setup openapi4j
Maven Dependencies:
<!-- Dependency for validation using HttpServlet -->
<dependency>
<groupId>org.openapi4j</groupId>
<artifactId>openapi-operation-servlet</artifactId>
<version>1.0.4</version>
</dependency>
<!-- Main input validation -->
<dependency>
<groupId>org.openapi4j</groupId>
<artifactId>openapi-operation-validator</artifactId>
<version>1.0.4</version>
</dependency>
Load API Specification at Application Startup:
@SpringBootApplication
public class DsInSbApplication {
static OpenApi3 apiSpec;
public static RequestValidator validator;
public static void main(String[] args) throws ResolutionException, ValidationException {
// Get URL path of file in resources folder
URL apiFile = Thread.currentThread().getContextClassLoader().getResource("oas.yaml");
// Parse the file to generate OpenApi3 object
apiSpec = new OpenApi3Parser().parse(apiFile, true);
// Validator object that handles the validation
validator = new RequestValidator(DsInSbApplication.apiSpec);
SpringApplication.run(DsInSbApplication.class, args);
}
public static OpenApi3 getApiSpec() { return apiSpec; }
}
Implement Interceptor for Request Validation:
Use Spring Interceptors to validate input before controller execution, rather than adding checks within controller methods.
@Component
public class ControllerInterceptor implements HandlerInterceptor {
private final Logger log = LoggerFactory.getLogger(this.getClass());
// This array is for disabling validation for certain endpoints
private final String[] skippable = new String[]{
"/console",
"/api-docs",
"/favicon.ico",
"/swagger-ui",
"/error"
};
// Executes before the controller class, returns false on error
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
// Skip console and Spring related paths
if (Arrays.stream(skippable).anyMatch(request.getServletPath()::startsWith)) {
log.info("[Data Validation] Skip");
return true;
}
Request apiSpecRequest = ServletRequest.of(request);
try {
// Validate the request follows spec
DsInSbApplication.validator.validate(apiSpecRequest);
log.info("[Data Validation] Pass");
return true;
} catch (ValidationException e) {
log.info("[Data Validation] Failure");
Map<String, ArrayList<String>> errorMap = new HashMap<>();
e.results().items().forEach(item -> {
String reducedMsg, replacedCrumb;
if (item.dataCrumbs().isEmpty()) {
replacedCrumb = "other";
reducedMsg = item.toString();
} else {
reducedMsg = item.toString().substring(
item.toString().indexOf(":") + 2,
item.toString().indexOf("("));
replacedCrumb = item.dataCrumbs().replace("body", "payload");
}
errorMap.putIfAbsent(replacedCrumb, new ArrayList<>());
errorMap.get(replacedCrumb).add(reducedMsg);
});
errorMap.forEach((name, errors) -> log.info(name + ": " + String.join(",", errors)));
Map<String, Object> payload = new HashMap<>();
payload.put("message", "Failed input validation.");
payload.put("errors", errorMap);
response.setStatus(400);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
response.getWriter().write((new ObjectMapper()).writeValueAsString(payload));
return false;
}
}
}
@Configuration
public class ControllerInterceptorConfig extends WebMvcConfigurerAdapter {
@Autowired
ControllerInterceptor controllerInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(controllerInterceptor);
}
}
Handle Request Body Caching:
Since openapi4j reads from the servlet input stream, the stream becomes unreadable in the controller. Cache the request body to allow multiple reads (based on baeldung.com):
public class CachedBodyHttpServletRequest extends HttpServletRequestWrapper {
private byte[] cachedBody;
public CachedBodyHttpServletRequest(HttpServletRequest request) throws IOException {
super(request);
InputStream requestInputStream = request.getInputStream();
this.cachedBody = StreamUtils.copyToByteArray(requestInputStream);
}
@Override
public ServletInputStream getInputStream() throws IOException {
return new CachedBodyServletInputStream(this.cachedBody);
}
@Override
public BufferedReader getReader() throws IOException {
ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.cachedBody);
return new BufferedReader(new InputStreamReader(byteArrayInputStream));
}
}
public class CachedBodyServletInputStream extends ServletInputStream {
private InputStream cachedBodyInputStream;
public CachedBodyServletInputStream(byte[] cachedBody) {
this.cachedBodyInputStream = new ByteArrayInputStream(cachedBody);
}
@Override
public boolean isFinished() {
try {
return cachedBodyInputStream.available() == 0;
} catch (IOException e) {
e.printStackTrace();
}
return false;
}
@Override
public boolean isReady() { return true; }
@Override
public void setReadListener(ReadListener readListener) {
throw new UnsupportedOperationException();
}
@Override
public int read() throws IOException {
return cachedBodyInputStream.read();
}
}
@Order(value = Ordered.HIGHEST_PRECEDENCE)
@Component
@WebFilter(filterName = "ContentCachingFilter", urlPatterns = "/*")
public class ContentCachingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(
HttpServletRequest httpServletRequest,
HttpServletResponse httpServletResponse,
FilterChain filterChain) throws ServletException, IOException {
CachedBodyHttpServletRequest cachedBodyHttpServletRequest =
new CachedBodyHttpServletRequest(httpServletRequest);
filterChain.doFilter(cachedBodyHttpServletRequest, httpServletResponse);
}
}
Swagger Console
Springdoc and Springfox provide console capabilities, but since DataSonnet applications don't use entities, auto-generation requires modification. Disable auto-generation and provide a custom specification endpoint.
Add Dependency:
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-ui</artifactId>
<version>1.4.4</version>
</dependency>
Configuration Properties:
# In my use case we use a context path of /api which springdoc will append
server.servlet.context-path=/api
# Disable the autogenerated api-docs
springdoc.api-docs.enabled=false
# Set our api-docs endpoint and UI endpoint
springdoc.api-docs.path=/api-docs
springdoc.swagger-ui.path=/swagger-ui
Serve Specification File:
@RestController
public class Console {
private final Logger log = LoggerFactory.getLogger(this.getClass());
// This endpoint is for springdoc to parse our specification for the console
@GetMapping(value = "/api-docs")
public ResponseEntity getSwaggerDoc() throws IOException {
String value = Utilities.getFileFromResources("specification.yaml");
return ResponseEntity.status(200).body(value);
}
}
public class Utilities {
public static String getFileFromResources(String fileName) throws IOException {
BufferedReader reader = new BufferedReader(
new InputStreamReader(
new ClassPathResource(fileName).getInputStream()));
return reader.lines().collect(Collectors.joining("\n"));
}
}
Configure Application Beans:
@SpringBootApplication
public class DsInSbApplication {
// ...other stuff
@Bean
SpringDocConfiguration springDocConfiguration() {
return new SpringDocConfiguration();
}
@Bean
public SpringDocConfigProperties springDocConfigProperties() {
return new SpringDocConfigProperties();
}
}
Access the console at host:port/api/swagger-ui.
Caveats
Springdoc redirects requests to /swagger-ui to locate specification data. Local environments handle this seamlessly; reverse proxy deployments require special headers (see springdoc documentation). Kong proxy limitations require internal redirection handling:
// Loop back function to negate the redirect
@GetMapping(value = "/console")
public ResponseEntity<?> getSwaggerConsole() throws Exception {
WebClient webClient = WebClient.create("http://localhost:8080");
String body;
try {
body = webClient.get()
.uri("/api/swagger-ui/index.html?configUrl=/api/api-docs/swagger-config")
.retrieve()
.bodyToMono(String.class)
.block();
// This fixes some location issues in the JavaScript
body = body.replaceAll("src=\".", "src=\"./swagger-ui")
.replaceAll("href=\".", "href=\"./swagger-ui")
.replace("url: \"https://petstore.swagger.io/v2/swagger.json\",", "url: \"/api/api-docs\",");
return ResponseEntity.ok(Objects.requireNonNull(body));
} catch (WebClientResponseException ex) {
log.error(ex.getLocalizedMessage());
throw new Exception();
}
}
Database Queries
Create custom repositories with entity managers or use standard JpaRepository with dummy entities. Leverage native SQL with database JSON functions to return string-formatted results.
Dummy Entity:
@Entity
public class DummyEntity {
@Id
private int id;
}
Repository Interface:
@Repository
public interface BillionairesRepository extends JpaRepository<DummyEntity, Integer> {
@Query(value = "...", nativeQuery = true)
String getAllBillionaires();
}
Database JSON Functions:
Many modern databases (including embedded H2) support JSON_OBJECT and JSON_ARRAY functions. Generate JSON at the database level and return as strings:
SELECT CAST(JSON_ARRAY(
(SELECT JSON_OBJECT(
'id'\:b.id,
KEY 'first_name' VALUE b.first_name,
'last_name'\:b.last_name,
'career'\:b.career)
FROM billionaires b)) as varchar)
This approach returns JSON strings directly usable in DataSonnet or returnable with JSON content-type headers. For insertions, database JSON parsing combined with stored procedures accepts JSON objects; otherwise DataSonnet extracts values for parameterized repository calls.
HTTP Requestor
WebClient requests use String class casting instead of object mapping:
String formattedString = String.format(path.replaceAll("{(.*?)}", "%s"), (Object[]) uriParams);
log.info("[API Call] [POST] " + getApiUrl() + formattedString);
try {
return webClient.method(HttpMethod.POST)
.uri(getApiUrl() + path, (Object[]) uriParams)
.headers(httpHeaders -> {
httpHeaders.setContentType(MediaType.APPLICATION_JSON);
httpHeaders.set(HttpHeaders.AUTHORIZATION, auth);
})
.body(Mono.just(payload), String.class).retrieve()
.toEntity(String.class).block();
} catch (WebClientResponseException ex) {
log.error("Error posting path {}. Status code is {} and the message is {}\nPayload attempted:\n{}\nAuthorization attempted: {}",
formattedString, ex.getRawStatusCode(), ex.getResponseBodyAsString(), payload, auth);
throw ex;
}
Bonus: Configurable HTTP Request Builder
Create reusable HTTP request configuration with a builder pattern:
@Configuration
public class CustomHttpPropertyConfigurer {
@Bean
@ConfigurationProperties("downstream.api.one")
public HttpRequestConfiguration httpRequestApiOne() {
return new HttpRequestConfiguration();
}
@Bean
@ConfigurationProperties("downstream.api.two")
public HttpRequestConfiguration httpRequestApiTwo() {
return new HttpRequestConfiguration();
}
}
Application Properties:
#-----------------------------------------------
# Application
#-----------------------------------------------
#---------- Api one -------
downstream.api.one.hostname=https://host1.com
downstream.api.one.port=443
downstream.api.one.baseurl=/api
#---------- Api two -------
downstream.api.two.hostname=http://host2.com
downstream.api.two.port=80
downstream.api.two.baseurl=/api
HTTP Request Configuration Class:
@Configuration
@ConfigurationProperties("downstream.properties")
public class HttpRequestConfiguration {
private String hostname;
private String port;
private String baseurl;
private String apiUrl;
public HttpRequestConfiguration() {}
public HttpRequestConfiguration(String hostname, String port, String baseurl) {
this.hostname = hostname;
this.port = port;
this.baseurl = baseurl;
this.apiUrl = hostname + ":" + port + baseurl;
}
public String getApiUrl() {
if (apiUrl == null) {
this.apiUrl = hostname + ":" + port + baseurl;
}
return apiUrl;
}
public RequestBuilder getRequestBuilder(WebClient webClient, String urlPath) {
return new RequestBuilder(webClient, getApiUrl(), urlPath);
}
// getters and setters...
public static class RequestBuilder {
private HttpMethod method;
private WebClient webClient;
private Class<?> clazz = Object.class;
private String urlPath;
private Object payload = "";
private List<String> urlParams = List.of();
private Map<String, String> headers = Map.of();
private String baseUrl;
public RequestBuilder(WebClient webClient, String baseUrl, String urlPath) {
this.webClient = webClient;
this.baseUrl = baseUrl;
this.urlPath = urlPath;
}
public RequestBuilder setMethod(HttpMethod method) { this.method = method; return this; }
public RequestBuilder setBody(Object payload) { this.payload = payload; return this; }
public RequestBuilder setHeaders(Map<String, String> headers) { this.headers = headers; return this; }
public RequestBuilder setUrlParameters(List<String> urlParams) { this.urlParams = urlParams; return this; }
public ResponseEntity<?> get() throws WebClientResponseException { this.method = HttpMethod.GET; return execute(); }
public ResponseEntity<?> post() throws WebClientResponseException { this.method = HttpMethod.POST; return execute(); }
public ResponseEntity<?> patch() throws WebClientResponseException { this.method = HttpMethod.PATCH; return execute(); }
public ResponseEntity<?> put() throws WebClientResponseException { this.method = HttpMethod.PUT; return execute(); }
public ResponseEntity<?> delete() throws WebClientResponseException { this.method = HttpMethod.DELETE; return execute(); }
private ResponseEntity<?> execute() throws WebClientResponseException {
return webClient.method(method)
.uri(baseUrl + urlPath, urlParams.toArray())
.headers(httpHeaders -> httpHeaders.setAll(Objects.requireNonNullElse(headers, Map.of())))
.body(Mono.just(payload), String.class).retrieve()
.toEntity(clazz).block();
}
}
}
A complete reference implementation for all patterns discussed in this post is available on GitHub.