In a customer project, documentation forms (data record for a dialysis treatment) from dialysis centers are read in and subjected to a quality analysis. The quarterly billing to the centers is based on the number of processed documentation forms per quarter and center.
An extra interface was implemented for billing. In the first draft, the data was queried internally, summarized and returned as a finished CSV file. Controlling wanted a CSV file so that it could be used internally via Excel for further evaluations.
For a second implementation, it was to be investigated whether an implementation on a streaming basis was feasible with the existing architecture and whether this would bring advantages for the user.
Used frameworks and architecture
The application runs with Spring Boot version 3 and an Elasticsearch installation as the backend. The account information is queried by the application via a REST interface. This interface was formulated in OpenApi 3.0.1. The server stubs are generated from the interface description using maven and the OpenApi generator for Spring (https://openapi-generator.tech/docs/generators/spring/).
Procedure
Initial situation
The original definition of the interface in openapi.yml was as follows:
/v2/dokumentationsboegen:
get:
tags:
- accounting-controller
summary: liefert alle verarbeiteten Dokumentationsbögen
description: liefert alle verfügbaren Quartale (für die übergebenen BSNRs und Quartale)
operationId: dokumentationsboegen
parameters:
- name: quarter
in: query
description: Welche Quartale
required: false
schema:
type: array
items:
type: string
- name: bsnr
in: query
description: Welche BSNRs
required: false
schema:
type: array
items:
type: string
responses:
'200':
description: Dokubögen im System
content:
text/csv:
schema:
type: string
'422':
description: keine gültigen Quartale oder BSNRs
content:
application/json:
schema:
type: string
The creation of the stubs was defined in pom.xml:
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.1.0</version>
<executions>
<execution>
<id>generate interface</id>
<phase>process-resources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.build.directory}/classes/openapi/openapi.yml</inputSpec>
<generatorName>spring</generatorName>
<configOptions>
<modelPackage>de.akquinet.tas.qmd.rest.model</modelPackage>
<apiPackage>de.akquinet.tas.qmd.rest.api</apiPackage>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
The implementation in the controller retrieved the necessary data from ElasticSearch, generated the result as a string using OpenCSV and wrote it into the response.
List accountingBeans = getAccountingBeansFromElasticSearch(...);
String content = convertToCSVString(accountingBeans);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=accounting.csv")
.body(content);
Final result
This should now be changed to an interface based on streaming. SpringBoot provides the StreamingResponseBody interface for this purpose. However, adapting the OpenApi description to this turned out to be somewhat difficult. After a few failures, controlling the generation of the stubs via type and import mapping proved to be the right way.
Only the format of the response was added to the interface description. The specification “streaming” contains no(!) semantics, but is merely a “marker”.
responses:
'200':
description: Dokubögen im System
content:
text/csv:
schema:
type: string
format: streaming
and in the generation of the stubs, this format was handled specifically:
<plugin>
<groupId>org.openapitools</groupId>
<artifactId>openapi-generator-maven-plugin</artifactId>
<version>7.1.0</version>
<executions>
<execution>
<id>generate interface</id>
<phase>process-resources</phase>
<goals>
<goal>generate</goal>
</goals>
<configuration>
<inputSpec>
${project.build.directory}/classes/openapi/openapi.yml</inputSpec>
<generatorName>spring</generatorName>
<typeMappings>
<typeMapping>
string+streaming=StreamingResponseBody
</typeMapping>
</typeMappings>
<importMappings>
<importMapping>
StreamingResponseBody=org.springframework.web.servlet.mvc.method.annotation.StreamingResponseBody
</importMapping>
</importMappings>
<configOptions>
<modelPackage>de.akquinet.tas.qmd.rest.model</modelPackage>
<apiPackage>de.akquinet.tas.qmd.rest.api</apiPackage>
<interfaceOnly>true</interfaceOnly>
<useSpringBoot3>true</useSpringBoot3>
</configOptions>
</configuration>
</execution>
</executions>
</plugin>
Then, of course, the implementation had to be adapted:
List accountingBeans = getAccountingBeansFromElasticSearch(...);
StreamingResponseBody stream = out -> writeBeansToOutStream(accountingCsvBeans, out);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment; filename=accounting.csv")
.body(stream);
Benchmarks
Now, of course, it was interesting to examine both variants in terms of their runtime. To do this, the application was started as a Docker container, test data was generated and imported for 5 operating sites with 5000 sheets each and then the account data was retrieved. First with the old interfaces and then with the new ones. As controlling is only carried out once a quarter and by a small number of people, the load test is carried out with a maximum of 4 users simultaneously.
The benchmark was implemented with Gatling https://gatling.io/). It comprises 4 scenarios with 1, 2 and 4 users who pulled the accounting data in parallel. The CSV data was retrieved for five operating sites and two quarters. The CSV file has a size of 7.1 MB. The benchmark ran completely on a laptop with 32GB main memory and an Intel i9 CPU with 2.60GHz.
In the first variant without streaming, Gatling delivered the following table. Note: If you are not yet familiar with Gatling, you can find a good description of the table in the “reports” section at https://gatling.io/2022/01/my-first-gatling-test-report/.
Scenario | Total | OK | KO | %KO | Cnt/s | Min | 50 pct | 75 pct | 95 pct | 99 pct | Max | Mean | StdDev |
1 User | 1 | 1 | 0 | 0% | 0.007 | 25670 | 25670 | 25670 | 25670 | 25670 | 25670 | 25670 | 0 |
2 Users | 2 | 2 | 0 | 0% | 0.015 | 28453 | 28580 | 28644 | 28694 | 28704 | 28707 | 28580 | 127 |
3 Users | 3 | 3 | 0 | 0% | 0.022 | 32581 | 33020 | 33172 | 33293 | 33317 | 33323 | 32975 | 305 |
4 Users | 4 | 4 | 0 | 0% | 0.029 | 39690 | 40244 | 40433 | 40487 | 40497 | 40500 | 40170 | 319 |
The variant with streaming resulted in the following table:
Scenario | Total | OK | KO | %KO | Cnt/s | Min | 50 pct | 75 pct | 95 pct | 99 pct | Max | Mean | StdDev |
1 User | 1 | 1 | 0 | 0% | 0.007 | 25094 | 25094 | 25094 | 25094 | 25094 | 25094 | 25094 | 0 |
2 Users | 2 | 2 | 0 | 0% | 0.014 | 28789 | 28791 | 28791 | 28792 | 28792 | 28792 | 28791 | 2 |
3 Users | 3 | 3 | 0 | 0% | 0.021 | 32847 | 33124 | 33144 | 33159 | 33162 | 33163 | 33045 | 141 |
4 Users | 4 | 4 | 0 | 0% | 0.028 | 42015 | 44560 | 44610 | 44661 | 44671 | 44674 | 43952 | 1119 |
If you now compare the mean values without streaming and the mean values with streaming, there are hardly any differences.
Scenario | Mean without Streaming | Mean with Streaming |
1 Users | 25670 | 25094 |
2 Users | 28580 | 28791 |
3 Users | 32975 | 33045 |
4 Users | 40170 | 43952 |

Conclusions
It is therefore possible to specify a StreamingResponse for the REST interface using openapi’s own means. In our case, this did not result in any performance advantages. In the interest of faster feedback to the user, it was then decided to retain streaming.
For an architecturally complete solution based entirely on streaming, the call to getAccountingBeansFromElasticSearch() would now have to be converted to streaming. However, this is not the focus of this article.