async-graphqlで独自の名前とフィールドを持つconnection/edgeを定義する

GraphQLで、適切にグラフ上のノード間の関係を示すために、connectionやedgeに独自の名前をつけて、独自のフィールドも追加したいことがある。

Explaining GraphQL Connections | Apollo GraphQL Blog

Instead think of them as the relationship between two nodes, and add fields that are appropriate for that relationship

async-graphqlでこれを実現するには、Connectionの型パラメータに独自の名前とフィールドを表す型を渡せばよい。

実装したいスキーマ

今回は次のスキーマを実装する。ユーザーと書籍がリソースとして存在して、ユーザーが書籍を「読了」したという関係性をUserReadBooksConnectionで表現する。UserReadBooksEdgereadAt(読了日時)を持つ。

type Book {
  id: Int!                                                        
  title: String!                                                  
  author: String!                                                 
}

type Query {
  books: [Book!]!
  user: User!
}

type User {
  """
  ユーザーが読了した書籍
  """
  readBooks: UserReadBooksConnection!
}

type UserReadBooksConnection {
  pageInfo: PageInfo!
  edges: [UserReadBooksEdge!]!
  nodes: [Book!]!
}

type UserReadBooksEdge {
  node: Book!
  cursor: String!

  """
  読了日時
  """
  readAt: String!
}

async-graphqlのConnection

async-graphqlのConnectionの定義は次のようになっている[^1]。

pub struct Connection<
    Cursor,
    Node,
    ConnectionFields = EmptyFields,
    EdgeFields = EmptyFields,
    Name = DefaultConnectionName,
    EdgeName = DefaultEdgeName,
    NodesField = EnableNodesField
>
where
    Cursor: CursorType + Send + Sync,
    Node: OutputType,
    ConnectionFields: ObjectType,
    EdgeFields: ObjectType,
    Name: ConnectionNameType,
    EdgeName: EdgeNameType,
    NodesField: NodesFieldSwitcherSealed,
{
  // ...
}

型パラメータのうち、今回着目するものの意味は次のとおり。

ConnectionFields, EdgeFields

connectionとedgeの独自フィールドの型。普通のフィールドと同じく、メソッドとしてリゾルバを持つObjectなどを渡せばよい。指定しないときはconnection::EmptyFieldsを渡す。

Name, EdgeName

ConnectionNameTypeEdgeNameTypeを実装した構造体を渡せばよい。指定しないときはconnection::DefaultConnectionNameを渡す。

独自のconnectionを定義

async-graphqlのConnectionに適切な型パラメータを渡して、独自の名前やフィールドを持つconnectionを定義する。

use async_graphql::{
    connection::{Connection, Edge, EmptyFields},
    types::connection::{ConnectionNameType, EdgeNameType},
    Object, OutputType, Result, SimpleObject,
};

// ...

// 独自のconnectionを定義する
// 型の記述が長くなるので別名をつける
type UserReadBooksConnection = Connection<
    i32,
    Book,
    EmptyFields,
    UserReadBooksEdgeFields,
    UserReadBooksConnectionName,
    UserReadBooksEdgeName,
>;

// 普通のフィールドと同じく、メソッドとしてリゾルバを持つObjectを実装
struct UserReadBooksEdgeFields;

#[Object]
impl UserReadBooksEdgeFields {
    /// 読了日時
    async fn read_at(&self) -> &'static str {
        "2024-01-01T12:34:56Z"
    }
}

// ConnectionNameTypeを実装した構造体で独自のconnection名を定義
struct UserReadBooksConnectionName;

impl ConnectionNameType for UserReadBooksConnectionName {
    fn type_name<T: OutputType>() -> String {
        "UserReadBooksConnection".to_string()
    }
}

// EdgeNameTypeを実装した構造体で独自のedge名を定義
struct UserReadBooksEdgeName;

impl EdgeNameType for UserReadBooksEdgeName {
    fn type_name<T: OutputType>() -> String {
        "UserReadBooksEdge".to_string()
    }
}

このconnectionを使って、フィールドUser.readBooksを次のように定義できる。

use async_graphql::{
    connection::{Connection, Edge, EmptyFields},
    types::connection::{ConnectionNameType, EdgeNameType},
    Object, OutputType, Result, SimpleObject,
};

// ...

/// 書籍
#[derive(SimpleObject)]
pub struct Book {
    id: i32,

    /// 書名
    title: String,

    /// 著者
    author: String,
}

// 書籍のダミーデータを作るファクトリ関数
fn get_books() -> Vec<Book> {
    let book1 = Book {
        id: 1,
        title: "Refactoring".to_string(),
        author: "Martin Fowler".to_string(),
    };

    let book2 = Book {
        id: 2,
        title: "Extreme Programming Explained".to_string(),
        author: "Kent Beck".to_string(),
    };

    vec![book1, book2]
}

struct User;

#[Object]
impl User {
    /// ユーザーが読了した書籍
    async fn read_books(&self) -> Result<UserReadBooksConnection> {
        let mut connection = UserReadBooksConnection::new(false, false);
        connection.edges.extend(
            get_books()
                .into_iter()
                .map(|book| Edge::with_additional_fields(book.id, book, UserReadBooksEdgeFields)),
        );

        Ok(connection)
    }
}

read_booksの中で、Edge::with_additional_fieldsを使ってUserReadBooksConnectionのインスタンスのedgesに独自フィールドを持つedgeを追加している。

結果

スキーマは独自に定義した名前のconnectionとedgeを提供しており、UserReadBooksEdge.readAtが追加されている。

スキーマにUserReadBooksConnectionとUserReadBooksEdgeとUserReadBooksEdge.readAtが追加されている
スキーマにUserReadBooksConnectionとUserReadBooksEdgeとUserReadBooksEdge.readAtが追加されている

GraphiQLでクエリすると次のようになる。edgeに追加された独自フィールドを取得できている。

GraphiQLでUser.readBooksをクエリすると独自のconnectionとedgeを取得できている
GraphiQLでUser.readBooksをクエリすると独自のconnectionとedgeを取得できている