CLOVER🍀

That was when it all began.

OpenAPI Generator × Spring Web MVCでファイルをダウンロードするREST APIを作成したい

これは、なにをしたくて書いたもの?

OpenAPI Generatorで生成したREST APIで、ファイルダウンロードのような機能を作るにはどうしたらいいのかな?ということで
ちょっと試してみました。

OpenAPIでバイナリを扱うメディアを定義する

ファイルダウンロードというと、画像やPDF、CSVといったメディアが多くなると思います。

OpenAPI定義でこのようなメディアタイプを扱うには?ということで、OpenAPI仕様を見てみます。こちらに記述がありました。

OpenAPI Specification v3.0.3 / Specification / Schema / Media Type Object / Considerations for File Uploads

ファイルアップロードの例でしたが、以下のようにContent-Typeはふつうに指定して、schematypestringで、formatbinary
するみたいです。

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プロジェクトを作成します。依存関係にはwebvalidationを指定。

$ 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>

Plugins | OpenAPI Generator

コンパイル

$ 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 FrameworkResourceになりました。こちらを使ってコンテンツを返すように実装すれば良さそうです。

    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ととても相性が悪いように思うのですが、他の方々はどのようにして
対応しているんでしょうね…?