NotionSwiftを使ってアプリ作ってみた話

「[NotionAPI](https://developers.notion.com/)でアプリ作ってみたい」と思い立って、公式より簡単に作れないかなーと方法を探していたところ、良い感じのライブラリを見つけました。

https://github.com/chojnac/NotionSwift

使ってみよう

READMEを読んだ感じ、使いやすそうだ

# 作ったアプリ
Notionで作成した習慣トラッカーの入力を簡単にするアプリを作りました。（よかったら使っていただけると嬉しいです！）

https://apps.apple.com/us/app/nabit/id6466298791

ライブラリを使用した箇所は以下になります。
- NotionDB作成
- 既存のNotionDBとの連携
- NotionDBにページを追加
- 特定のページのプロパティを更新

# 実装
## Wrapperクラスの作成
`NotionClient`のインスタンスとAPI処理を持つclass。

`NotionClient`のインスタンスを生成するには`integrationToken`と`databaseId`が必要なのですが、漏れたらまずい情報のため`KeyChain`に保存しておりインスタンス生成時に`KeyChain`から取得するようにしています。
```Swift
class KeyChainHelper {
    
    static let shared = KeyChainHelper()
    private init() {}
    // App Bundle Id
    let service = "com.xxxxxxx.xxxxxx"
    
    enum AccountString: String {
        case integrationToken = "integrationToken"
        case databaseId = "databaseId"
    }
    
    func save(account: AccountString, data: Data) -> Bool {
        let query = [
            kSecValueData: data,
            kSecClass: kSecClassGenericPassword,
            kSecAttrService: self.service,
            kSecAttrAccount: account.rawValue,
        ] as [CFString : Any] as CFDictionary
        
        let matchingStatus = SecItemCopyMatching(query, nil)
        switch matchingStatus {
        case errSecItemNotFound:
            // データが存在しない場合は保存
            let status = SecItemAdd(query, nil)
            return status == noErr
        case errSecSuccess:
            // データが存在する場合は更新
            SecItemUpdate(query, [kSecValueData as String: data] as CFDictionary)
            return true
        default:
            Logger.error("Failed to save data to keychain")
            return false
        }
    }
    
    func read(account: AccountString) -> String? {
        let query = [
            kSecAttrService: self.service,
            kSecAttrAccount: account.rawValue,
            kSecClass: kSecClassGenericPassword,
            kSecReturnData: true
        ] as [CFString : Any] as CFDictionary
        
        var result: AnyObject?
        SecItemCopyMatching(query, &result)
        
        if let data = (result as? Data) {
            return String(data: data, encoding: .utf8)
        } else {
            return nil
        }
    }
}
```
ライブラリのAPIはcallbackで実装されていましたが、SwiftUIで扱う場合`async/await`で使えた方が便利だなーと感じ、Wrapperクラスを作ることにしました。なくても良いです！
```Swift
import Foundation
import NotionSwift

class NotionClientWrapper: ObservableObject {
    @Published var notionClinent: NotionClient?
    @Published var databaseId: Database.Identifier?
    
    init(){
        if let accessKeyString = KeyChainHelper.shared.read(account: .integrationToken),
           let databaseIdString = KeyChainHelper.shared.read(account: .databaseId) {
            self.notionClinent = NotionClient(accessKeyProvider: StringAccessKeyProvider(accessKey: accessKeyString))
            self.databaseId = Database.Identifier(databaseIdString)
        } else {
            Logger.debug("accessKeyStringかdatabaseIdStringが存在しない")
            self.notionClinent = nil
            self.databaseId = nil
        }
    }
    // DBの作成
    func createDatabaseQueryAsync(
        request: DatabaseCreateRequest
    ) async throws -> Database {
        guard let notionClinent = self.notionClinent else {
            let error = "notionClinentがnil"
            Logger.debug(error)
            throw NotionClientError.builderError(message: error)
        }
        return try await withCheckedThrowingContinuation { continuation in
            notionClinent.databaseCreate(
                request: request
            ) { result in
                switch result {
                case let .success(database):
                    Logger.debug("databaseId: \(database.id) の作成に成功")
                    continuation.resume(returning: (database))
                case let .failure(error):
                    Logger.debug("updatePageProperties_error:\(error)")
                    continuation.resume(throwing: error)
                }
            }
        }
    }
    // 以下その他API
}
```
`withCheckedThrowingContinuation`でラップすると`async/await`で扱えるようになります。

https://developer.apple.com/documentation/swift/withcheckedthrowingcontinuation(function:_:)


## API部分
画面から扱いやすいようにNotionAPIのメソッドをまとめたclassを作成しました。
```Swift
class NotionClientStore: ObservableObject {
    @Published var notionClinent = NotionClientWrapper()
    // 以下APIメソッド
}
```
APIを投げる時にNotionのページのURLからdatabaseIdを取得する必要があるのですが、URL全文から以下のコードでidを取得できます。
```Swift
func createIdByPageUrl(urlString: String) -> String {
    let pattern = #"(?<=https:\/\/www\.notion\.so\/)[\w\d]+"#
    
    if let regex = try? NSRegularExpression(pattern: pattern, options: .caseInsensitive) {
        let nsrange = NSRange(urlString.startIndex..<urlString.endIndex, in: urlString)
        if let match = regex.firstMatch(in: urlString, options: [], range: nsrange) {
            return String(urlString[Range(match.range, in: urlString)!])
        }
    }
    return ""
}
```

### API実装例_DBの作成
ライブラリではNotionを様々な方法で操作できるのですが、本記事では一例としてDB作成の実装を紹介します。

`Request`の作成
```Swift
func createDatabaseCreateRequest(
    pageId: UUIDv4,
    databaseTitle: String
) -> DatabaseCreateRequest {
    let parentPageId = Page.Identifier(pageId)
    let request = DatabaseCreateRequest(
        parent: .pageId(parentPageId),
        icon: nil,
        cover: nil,
        title: [.init(string: databaseTitle)],
        properties: ["名前": .title,
                     "運動": .checkbox,
                     "勉強": .checkbox,
                     "カレンダー": .createdTime]
    )
    return request
}
```
api
```Swift
func createDatabaseQueryAsync(
    request: DatabaseCreateRequest
) async throws -> Database {
    guard let notionClinent = self.notionClinent else {
        let error = "notionClinentがnil"
        Logger.debug(error)
        throw NotionClientError.builderError(message: error)
    }
    return try await withCheckedThrowingContinuation { continuation in
        notionClinent.databaseCreate(
            request: request
        ) { result in
            switch result {
            case let .success(database):
                Logger.debug("databaseId: \(database.id) の作成に成功")
                continuation.resume(returning: (database))
            case let .failure(error):
                Logger.debug("updatePageProperties_error:\(error)")
                continuation.resume(throwing: error)
            }
        }
    }
}
```
ボタンを押した時などに呼ぶ
```Swift
private func createDatabase() {
    let pageId = self.notionClientStore.createIdByPageUrl(urlString: pegeUrlText)
    let request = self.notionClientStore.createDatabaseCreateRequest(pageId: pageId,
                                                                     databaseTitle: databaseNameText)
    Task {
        do {
            let database = try await notionClientStore.notionClinent.createDatabaseQueryAsync(request: request)
            // 作成したDBの情報を使っての処理はここに書く
        } catch let error as NotionClientError {
           // NotionApiエラー
        } catch {
           //　その他エラー
        }
    }
}
```

# 注意点
timezone対応していないため、「作成日時」「日付」プロパティで特定の日付が入力されているページを取得・操作したい場合は日本時間に変換する必要があります。
```Swift
func dateFormatterJa() -> DateFormatter {
    let dateFormatter = DateFormatter()
    dateFormatter.locale = Locale(identifier: "ja_JP")
    dateFormatter.dateFormat = "yyyy-MM-dd"
    dateFormatter.timeZone = TimeZone(identifier: "Asia/Tokyo")
    return dateFormatter
}
```
それでも日付判定がうまくいかない場合があります。
ライブラリのissueは立っているので対応してくれるのを願うばかりです、、

# 最後に
ライブラリを使った感想としてはNotionと連携したiOSアプリを作る場合は、導入すると結構楽に実装ができたので選択肢としては全然ありかなと思います。もし活用される際は本記事をご参考にしていただけると幸いです。

私自身の未熟さもあり、記載のコードが「これは...」となられたかもしれませんが、どうかご容赦ください。ご指摘あればよろしくお願いいたします。

アプリ使ってもらえると喜びます！