🚂

Loco + utoipaでenumを整数として扱う

2024/10/20に公開

TL;DR

PartialSchemaToSchemaを自前で実装すればOK

はじめに

LocoはRuby on RailsのRust版のようなものです。本稿執筆時点の最新バージョンは0.11.0でありまだv1になっていませんが、活発な開発がされています。
https://loco.rs/

utoipaはOpenAPIのドキュメンテーションを自動生成するクレートです。今回はLocoのプロジェクトでswagger.jsonの生成に使用します。utopiaではなくutoipaなので注意してください。
https://github.com/juhaku/utoipa

Loco公式によるutoipa使用例が既にあるのですが、これだけでは物足りません。表題にもあるように、

  • DBではsmallintで扱われている
  • model層でenumの皮を被っている
  • レスポンスにシリアライズする時はもとのsmallintの値を使い、swagger定義にもこれを反映したい

というケースについて解説します。

レスポンスで整数にシリアライズする

model層でenumとして扱う方法はSeaORMの公式docsを参考に、最終的に整数にシリアライズする方法はserdeの公式docsを参考にすれば簡単です。

src/models/_entities/items.rs
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.0.1
use sea_orm::entity::prelude::*;
use serde::{Deserialize, Serialize};
use serde_repr::{Deserialize_repr, Serialize_repr};
use utoipa::ToSchema;

#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq, Serialize, Deserialize, ToSchema)]
#[sea_orm(table_name = "items")]
#[schema(as = Item)]
pub struct Model {
    #[sea_orm(primary_key)]
    pub id: i32,
    // 中略
    #[schema(inline)]
    pub condition: Condition,
}

#[derive(EnumIter, DeriveActiveEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize_repr, Serialize_repr, ToSchema)]
#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
#[repr(i16)]
pub enum Condition {
    New = 1,
    Good = 2,
    Damaged = 3,
}

swagger定義を整数にする

上記のままutoipaを用いてswagger.jsonを生成すると以下のようになります。実際に吐き出されるJSONではconditionの値は整数になるので、このswagger定義は嘘になってしまいます。

swagger.json
{
  // 中略
  "components": {
    "schemas": {
      "Item": {
        "type": "object",
        "required": [
          "id",
          // 中略
          "condition"
        ],
        "properties": {
          "id": {
            "type": "integer",
            "format": "int32"
          },
          // 中略
          "condition": {
            "type": "string",
            "enum": [
              "New",
              "Good",
              "Damaged"
            ]
          }
        }
      }
    }
  }
}

そこで、以下のようにToSchemaトレイトを自前で実装します。

src/models/_entities/items.rs
use utoipa::{openapi::{RefOr, Schema}, PartialSchema, ToSchema};
// 中略
#[derive(EnumIter, DeriveActiveEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize_repr, Serialize_repr)]
#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
#[repr(i16)]
pub enum Condition {
    New = 1,
    Good = 2,
    Damaged = 3,
}

impl PartialSchema for Condition {
  fn schema() -> RefOr<Schema> {
    i16::schema()
  }
}

impl ToSchema for Condition {}

これでswagger定義が正しくなります。

swagger.json
"condition": {
  "type": "integer",
  "format": "int32"
}

マクロを作成して記述を減らす

とはいえ、enumを作るたびにいちいちPartialSchemaToSchemaを実装するのは面倒です。そこで、ToSchema_reprトレイトを用意して以下のように書けるようにします。ToSchema_reprトレイトをderiveすると#[repr(i16)]の部分を読み、その型でswaggerを生成します。

src/models/_entities/items.rs
use utoipa::ToSchema;
use utoipa_repr::ToSchema_repr;

// 中略

#[derive(EnumIter, DeriveActiveEnum, Clone, Copy, Debug, PartialEq, Eq, Deserialize_repr, Serialize_repr, ToSchema_repr)]
#[sea_orm(rs_type = "i16", db_type = "SmallInteger")]
#[repr(i16)]
pub enum Condition {
    New = 1,
    Good = 2,
    Damaged = 3,
}

serde_reprのようにライブラリを作って公開するのが筋だとは思いますが、面倒なのでここではLocoのプロジェクト内で済ませる方法を紹介します。

まず、適当にワークスペースを作成してプロジェクトルートのCargo.tomlに追加します。

Cargo.toml
[dependencies]
+ utoipa_repr = { path = "utoipa_repr" }
utoipa_repr/Cargo.toml
[package]
name = "utoipa_repr"
version = "0.1.0"
edition = "2021"
publish = false

[lib]
name = "utoipa_repr"
path = "src/lib.rs"
proc-macro = true # ←ここ重要

[dependencies]
proc-macro2 = "1.0.88"
quote = "1.0.37"
syn = "2.0.79"

次に、serde_reprのparse.rsutoipa_repr/src/parse.rsにコピペします(欲しい構造体が公開されていないため、use serde_repr::*;では取り出せません)。

lib.rsは以下のようにします。

utoipa_repr/src/lib.rs
mod parse;
use parse::Input;
use proc_macro::TokenStream;
use quote::quote;
use syn::parse_macro_input;

#[proc_macro_derive(ToSchema_repr)]
pub fn derive(input: TokenStream) -> TokenStream {
    // identには`Condition`のようなenumの名前が、reprには`i16`のような型が入る
    let Input { ident, repr, .. } = parse_macro_input!(input as Input);

    // さっき書いたやつのenum名と型名をプレースホルダにする
    TokenStream::from(quote! {
        impl utoipa::PartialSchema for #ident {
            fn schema() -> utoipa::openapi::RefOr<utoipa::openapi::Schema> {
                #repr::schema()
            }
        }
        impl utoipa::ToSchema for #ident {}
    })
}

これにより、ToSchemaトレイトの代わりにToSchema_reprトレイトをderiveすることで正しいswagger定義を出力させられます。

最後に

Loco + utoipaでenumを整数にシリアライズする方法を紹介しました。さらに、(ライセンス問題をクリアする必要がありますが)マクロ化して記述量を減らす方法を紹介しました。
LocoはRustの静的型付けとRailsの恩恵を同時に受けられる面白いフレームワークなので、皆さんも使ってみてください。

Discussion