これは、なにをしたくて書いたもの?
OpenAPI Generatorで生成したREST APIで、ファイルダウンロードのような機能を作るにはどうしたらいいのかな?ということで
ちょっと試してみました。
OpenAPIでバイナリを扱うメディアを定義する
ファイルダウンロードというと、画像やPDF、CSVといったメディアが多くなると思います。
OpenAPI定義でこのようなメディアタイプを扱うには?ということで、OpenAPI仕様を見てみます。こちらに記述がありました。
ファイルアップロードの例でしたが、以下のようにContent-Typeはふつうに指定して、schemaでtypeはstringで、formatはbinaryに
するみたいです。
requestBody: content: application/octet-stream: schema: # a binary file of any type type: string format: binary
ちょっと不思議な感じがしますが、そういう扱いなんでしょうね…。
で、こちらをOpenAPI Generator、Spring Boot(Spring Web MVC)を使ってどういう実装になるかを確認したいと思います。
Documentation for the spring Generator | OpenAPI Generator
環境
今回の環境はこちら。
$ java --version openjdk 21.0.1 2023-10-17 OpenJDK Runtime Environment (build 21.0.1+12-Ubuntu-222.04) OpenJDK 64-Bit Server VM (build 21.0.1+12-Ubuntu-222.04, mixed mode, sharing) $ mvn --version Apache Maven 3.9.6 (bc0240f3c744dd6b6ec2920b3cd08dcc295161ae) Maven home: $HOME/.sdkman/candidates/maven/current Java version: 21.0.1, vendor: Private Build, runtime: /usr/lib/jvm/java-21-openjdk-amd64 Default locale: ja_JP, platform encoding: UTF-8 OS name: "linux", version: "5.15.0-91-generic", arch: "amd64", family: "unix"
Spring Bootプロジェクトを作成する
まずはSpring Bootプロジェクトを作成します。依存関係にはwebとvalidationを指定。
$ curl -s https://start.spring.io/starter.tgz \ -d bootVersion=3.2.1 \ -d javaVersion=21 \ -d type=maven-project \ -d name=openapi-generator-file-download \ -d groupId=org.littlewings \ -d artifactId=openapi-generator-file-download \ -d version=0.0.1-SNAPSHOT \ -d packageName=org.littlewings.spring.download \ -d dependencies=web,validation \ -d baseDir=openapi-generator-file-download | tar zxvf -
今回特にBean Validationを使う予定はないのですが、OpenAPI Generatorで生成されるソースコードの依存関係に含まれているので
追加しています。
生成されたプロジェクト内へ移動。
$ cd openapi-generator-file-download
Maven依存関係等。
<parent> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-parent</artifactId> <version>3.2.1</version> <relativePath/> <!-- lookup parent from repository --> </parent> <groupId>org.littlewings</groupId> <artifactId>openapi-generator-file-download</artifactId> <version>0.0.1-SNAPSHOT</version> <name>openapi-generator-file-download</name> <description>Demo project for Spring Boot</description> <properties> <java.version>21</java.version> </properties> <dependencies> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-validation</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-test</artifactId> <scope>test</scope> </dependency> </dependencies> <build> <plugins> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>
自動生成されたソースコードは削除しておきます。
$ rm src/main/java/org/littlewings/spring/download/OpenapiGeneratorFileDownloadApplication.java src/test/java/org/littlewings/spring/download/OpenapiGeneratorFileDownloadApplicationTests.java
OpenAPI定義の作成と、OpenAPI Generatorの導入
OpenAPI Generatorでソースコードを生成するにも、OpenAPI定義がないと始まりません。
こんなOpenAPI定義を作成。
openapi-definition/openapi.yaml
openapi: 3.0.3 info: title: File Download API version: 0.0.1 servers: - url: http://localhost:8080 - url: http://0.0.0.0:8080 paths: /download/static-file: get: tags: - file operationId: getStaticFile responses: '200': description: get static file content: text/plain: schema: type: string /download/dynamic-file: get: tags: - file operationId: getDynamicFile responses: '200': description: get dynamic file content: text/plain: schema: type: string
静的なファイル、動的に作成するファイルをテーマに2つエンドポイントを定義してみました。
format: binaryはこの時点では追加していません。どのように変化するか見たいので。
pom.xmlに、OpenAPI GeneratorのMaven Pluginの定義を追加。
<plugin> <groupId>org.openapitools</groupId> <artifactId>openapi-generator-maven-plugin</artifactId> <version>7.2.0</version> <executions> <execution> <goals> <goal>generate</goal> </goals> <configuration> <inputSpec>${project.basedir}/openapi-definition/openapi.yaml</inputSpec> <generatorName>spring</generatorName> <configOptions> <sourceFolder>src/main/java</sourceFolder> <basePackage>org.littlewings.spring.openapi.generated</basePackage> <apiPackage>org.littlewings.spring.openapi.generated.api</apiPackage> <modelPackage>org.littlewings.spring.openapi.generated.model</modelPackage> <configPackage>org.littlewings.spring.openapi.generated.configuration</configPackage> <useSpringBoot3>true</useSpringBoot3> <interfaceOnly>true</interfaceOnly> </configOptions> </configuration> </execution> </executions> </plugin>
個人的な好みですが、生成方針としてはinterfaceOnlyにしました。
生成されたソースコードが依存するので、以下の依存関係も追加しておきます。
<dependency> <groupId>io.swagger.core.v3</groupId> <artifactId>swagger-annotations-jakarta</artifactId> <version>2.2.20</version> </dependency>
$ mvn compile
生成されるのは、こんな感じのディレクトリツリーになります。
$ tree target/generated-sources/openapi
target/generated-sources/openapi
├── README.md
├── pom.xml
└── src
└── main
└── java
└── org
└── littlewings
└── spring
└── openapi
└── generated
└── api
├── ApiUtil.java
└── DownloadApi.java
9 directories, 4 files
インターフェース定義を見てみましょう。
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/DownloadApi.java
/** * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.2.0). * https://openapi-generator.tech * Do not edit the class manually. */ package org.littlewings.spring.openapi.generated.api; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.ArraySchema; 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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.multipart.MultipartFile; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.util.List; import java.util.Map; import java.util.Optional; import jakarta.annotation.Generated; @Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-01-11T23:06:25.833048396+09:00[Asia/Tokyo]") @Validated @Tag(name = "file", description = "the file API") public interface DownloadApi { default Optional<NativeWebRequest> getRequest() { return Optional.empty(); } /** * GET /download/dynamic-file * * @return get dynamic file (status code 200) */ @Operation( operationId = "getDynamicFile", tags = { "file" }, responses = { @ApiResponse(responseCode = "200", description = "get dynamic file", content = { @Content(mediaType = "text/plain", schema = @Schema(implementation = String.class)) }) } ) @RequestMapping( method = RequestMethod.GET, value = "/download/dynamic-file", produces = { "text/plain" } ) default ResponseEntity<String> getDynamicFile( ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); } /** * GET /download/static-file * * @return get static file (status code 200) */ @Operation( operationId = "getStaticFile", tags = { "file" }, responses = { @ApiResponse(responseCode = "200", description = "get static file", content = { @Content(mediaType = "text/plain", schema = @Schema(implementation = String.class)) }) } ) @RequestMapping( method = RequestMethod.GET, value = "/download/static-file", produces = { "text/plain" } ) default ResponseEntity<String> getStaticFile( ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); } }
当たり前といえば当たり前ですが、レスポンスはStringになっています。
では、OpenAPI定義にformat: binaryを追加。
paths: /download/static-file: get: tags: - file operationId: getStaticFile responses: '200': description: get static file content: text/plain: schema: type: string format: binary /download/dynamic-file: get: tags: - file operationId: getDynamicFile responses: '200': description: get dynamic file content: text/plain: schema: type: string format: binary
再度コンパイル。
$ mvn compile
生成された結果。
target/generated-sources/openapi/src/main/java/org/littlewings/spring/openapi/generated/api/DownloadApi.java
/** * NOTE: This class is auto generated by OpenAPI Generator (https://openapi-generator.tech) (7.2.0). * https://openapi-generator.tech * Do not edit the class manually. */ package org.littlewings.spring.openapi.generated.api; import io.swagger.v3.oas.annotations.ExternalDocumentation; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.ArraySchema; 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.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.enums.ParameterIn; import org.springframework.http.HttpStatus; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.multipart.MultipartFile; import jakarta.validation.Valid; import jakarta.validation.constraints.*; import java.util.List; import java.util.Map; import java.util.Optional; import jakarta.annotation.Generated; @Generated(value = "org.openapitools.codegen.languages.SpringCodegen", date = "2024-01-11T23:07:09.127885224+09:00[Asia/Tokyo]") @Validated @Tag(name = "file", description = "the file API") public interface DownloadApi { default Optional<NativeWebRequest> getRequest() { return Optional.empty(); } /** * GET /download/dynamic-file * * @return get dynamic file (status code 200) */ @Operation( operationId = "getDynamicFile", tags = { "file" }, responses = { @ApiResponse(responseCode = "200", description = "get dynamic file", content = { @Content(mediaType = "text/plain", schema = @Schema(implementation = org.springframework.core.io.Resource.class)) }) } ) @RequestMapping( method = RequestMethod.GET, value = "/download/dynamic-file", produces = { "text/plain" } ) default ResponseEntity<org.springframework.core.io.Resource> getDynamicFile( ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); } /** * GET /download/static-file * * @return get static file (status code 200) */ @Operation( operationId = "getStaticFile", tags = { "file" }, responses = { @ApiResponse(responseCode = "200", description = "get static file", content = { @Content(mediaType = "text/plain", schema = @Schema(implementation = org.springframework.core.io.Resource.class)) }) } ) @RequestMapping( method = RequestMethod.GET, value = "/download/static-file", produces = { "text/plain" } ) default ResponseEntity<org.springframework.core.io.Resource> getStaticFile( ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); } }
先ほどはStringだった戻り値が
default ResponseEntity<String> getStaticFile( ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); }
Spring FrameworkのResourceになりました。こちらを使ってコンテンツを返すように実装すれば良さそうです。
default ResponseEntity<org.springframework.core.io.Resource> getStaticFile( ) { return new ResponseEntity<>(HttpStatus.NOT_IMPLEMENTED); }
Resource (Spring Framework 6.1.2 API)
ファイルダウンロードAPIを作成する
では、生成されたインターフェースに従って、REST APIを作成していきます。
その前に、mainメソッドを持ったクラスは作っておきましょう。
src/main/java/org/littlewings/spring/download/App.java package org.littlewings.spring.download; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class App { public static void main(String... args) { SpringApplication.run(App.class, args); } }
静的ファイルを返すようにしてみる
最初は静的ファイルを返すエンドポイントを実装してみます。
今回はクラスパス上にあるファイルを返すようにしてみましょう。
src/main/resources/hello.txt
Hello World!!
実装結果はこちら。
src/main/java/org/littlewings/spring/download/DownloadController.java
package org.littlewings.spring.download; import org.littlewings.spring.openapi.generated.api.DownloadApi; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; @RestController public class DownloadController implements DownloadApi { @Override public ResponseEntity<Resource> getDynamicFile() { return DownloadApi.super.getDynamicFile(); } @Override public ResponseEntity<Resource> getStaticFile() { return ResponseEntity.ok(new ClassPathResource("hello.txt")); } }
Resourceインターフェースの実装であるClassPathResourceを使って、ファイルを返すようにしました。
確認してみましょう。起動。
$ mvn spring-boot:run
アクセス。
$ curl -i localhost:8080/download/static-file HTTP/1.1 200 Accept-Ranges: bytes Content-Type: text/plain Content-Length: 14 Date: Thu, 11 Jan 2024 15:08:05 GMT Hello World!!
OKですね。
ローカルファイルパスのコンテンツを返すのであればこれらを使えばよさそうですし、
PathResource (Spring Framework 6.1.2 API)
FileSystemResource (Spring Framework 6.1.2 API)
byte配列ならこちら、
ByteArrayResource (Spring Framework 6.1.2 API)
InputStreamで汎用的に使いたい場合はこちらを使うなどすればいろいろ対応できそうです。
InputStreamResource (Spring Framework 6.1.2 API)
動的にファイルを作成して返す
ここまでで、format: binaryにするとResourceとしてレスポンスを返す必要があることがわかりました。
ですが、Resourceの実装は基本的に読み込みを対象にしています。
Resource (Spring Framework 6.1.2 API)
WritableResourceというインターフェースもあるのですが、これはこのインスタンスが管理するリソースに書き込むためのもの
みたいです。
WritableResource (Spring Framework 6.1.2 API)
汎用のInputStreamResourceみたいなものを使おうにも、書き込みに対応できません。
@Override public ResponseEntity<Resource> getDynamicFile() { return ResponseEntity.ok(new InputStreamResource(???)); }
通常、動的にコンテンツを生成してレスポンスを返す…たとえばCSVファイルを作るなどすると、OutputStreamを使いたくなると
思うのですがどうしたらいいんでしょうね?
StackOverflowでも似たような質問がありましたが、ResourceではOutputStreamは扱えない、OpenAPIでこのようなエンドポイントを
定義するのは諦めた方がいい、みたいな話になっていました。
java - Handle outputstream with openapi generated Resource in spring - Stack Overflow
どうしようかなと困ったのですが、「InputStream#readの結果を動的に生成する実装を作って、それをInputStreamResourceに渡せば
いいのでは?」と思って作成したのがこちら。
src/main/java/org/littlewings/spring/download/ByteArrayGenerateInputStream.java
package org.littlewings.spring.download; import java.io.IOException; import java.io.InputStream; public class ByteArrayGenerateInputStream extends InputStream { private ByteArrayGenerator generator; private Runnable closer; private byte[] currentBytes; private int currentByteIndex; public ByteArrayGenerateInputStream(ByteArrayGenerator generator, Runnable closer) { this.generator = generator; this.closer = closer; } @Override public int read() throws IOException { if (currentBytes == null || currentByteIndex == currentBytes.length) { currentBytes = generator.generate(); if (currentBytes == null) { return -1; } currentByteIndex = 0; } return currentBytes[currentByteIndex++]; } @Override public void close() throws IOException { closer.run(); } public interface ByteArrayGenerator { byte[] generate(); } }
byte配列を返すインターフェースを作成し、nullが返ってくるまで呼び出し続ける処理になっています。
@Override public int read() throws IOException { if (currentBytes == null || currentByteIndex == currentBytes.length) { currentBytes = generator.generate(); if (currentBytes == null) { return -1; } currentByteIndex = 0; } return currentBytes[currentByteIndex++]; }
あとはリソースのクローズ用。
@Override public void close() throws IOException { closer.run(); }
今回は簡単に、以下の内容をjava.io.Readerで読み込んで返すことで動的にコンテンツを生成するコードをエミュレーションしたいと
思います。
src/main/resources/lines.txt
Hello World こんにちは、世界 Hello Spring
こんな実装になりました。
src/main/java/org/littlewings/spring/download/DownloadController.java
package org.littlewings.spring.download; import org.littlewings.spring.openapi.generated.api.DownloadApi; import org.springframework.core.io.ClassPathResource; import org.springframework.core.io.InputStreamResource; import org.springframework.core.io.Resource; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RestController; import java.io.BufferedReader; import java.io.IOException; import java.io.InputStreamReader; import java.io.UncheckedIOException; import java.nio.charset.StandardCharsets; @RestController public class DownloadController implements DownloadApi { @Override public ResponseEntity<Resource> getDynamicFile() { try { BufferedReader reader = new BufferedReader(new InputStreamReader(new ClassPathResource("lines.txt").getInputStream(), StandardCharsets.UTF_8)); return ResponseEntity.ok(new InputStreamResource(new ByteArrayGenerateInputStream( // generator () -> { try { String line = reader.readLine(); if (line != null) { return (line + "\n").getBytes(StandardCharsets.UTF_8); } return null; } catch (IOException e) { throw new UncheckedIOException(e); } }, // closer () -> { try { reader.close(); } catch (IOException e) { throw new UncheckedIOException(e); } } ))); } catch (IOException e) { throw new UncheckedIOException(e); } } @Override public ResponseEntity<Resource> getStaticFile() { return ResponseEntity.ok(new ClassPathResource("hello.txt")); } }
ちょっとIOExceptionの扱いがノイズ気味になっていますが…。
ではアプリケーションを起動して
$ mvn spring-boot:run
確認。
$ curl -i localhost:8080/download/dynamic-file HTTP/1.1 200 Content-Type: text/plain Transfer-Encoding: chunked Date: Thu, 11 Jan 2024 15:22:02 GMT Hello World こんにちは、世界 Hello Spring
OKそうですね。
コンテンツの生成処理がOutputStreamを渡してあとはお任せ、という感じのインターフェースになっている処理が対象だとこうは
いかないと思うのですが、そうでなければこんな感じでなんとかならないでしょうか…。
おわりに
OpenAPI GeneratorとSpring Web MVCで、ファイルをダウンロードする処理を書いてみたいということで試行してみました。
動的にコンテンツを生成してダウンロードさせるようなREST APIととても相性が悪いように思うのですが、他の方々はどのようにして
対応しているんでしょうね…?