🧩

自作言語TYMLで行うJWT認証とスキーマ駆動開発

に公開

TYMLとは

TYMLとは、JsonSchemaよりも簡潔で厳密な仕様を持つ、任意の設定用言語(現在はinitomljson)に対応可能なスキーマ言語です。
JsonSchemaOpenAPIの置き換えを目標としています。

https://tyml-org.github.io/tyml-lang.org/

JsonSchemaの代替機能に関しては、前回書いた以下の記事を読んでいただければと思います。
https://zenn.dev/bea4dev/articles/49adc4be4638b0

今回はOpenAPItypespecの代替機能であるREST-APIの定義機能を開発したのでそちらを紹介しようと思います。

REST-API定義機能

まずは、言語の記述例を見ていただいたほうが早いと思います。
(この記事ではsyntax-highlightをtsのもので代替していますが、実際には既にVSCodeの拡張機能(LSP)を公開しているのでそちらを利用していただく形となります)

type Token {
    token: string
}

type Error {
    reason: string
}

type User {
    id: int
    name: string
}

type Claim {
    iss: string
    sub: string
    iat: int
    exp: int
}

/// REST-APIを定義!
interface API {
    /// ユーザーを登録する
    /// Token型もしくはErrorを返す
    function register(user_name: string) -> Token | Error

    /// ユーザー情報を取得する
    /// authedをつけるとJWT認証が前提となります
    authed function get_user(@claim: Claim) -> User
}

どうでしょうか。
OpenAPIよりも仕様を絞っているのもあってか、読みやすいと思います。

OpenAPIとの差別化

TYMLにはOpenAPItypespecと違っている点がいくつかあります

  • 機能が最小限
  • 故にシンプル
  • エディタ拡張機能(LSP)による強力なアシスト
  • typespecのようにトランスパイルの必要がない
  • Rustで開発しているので高速に動作する
  • エディタの拡張機能によりワンクリックで簡易テストを実行可能!

特にシンプルな仕様とLSPがあるのが強みです。
簡易テストに関しては、拡張機能がインストールされたVSCodeでTYMLファイルを開くと以下のようにテストを実行するためのボタンが出現するようになります。

この機能はデフォルト値を記述すると使用できるようになり、ワンクリックで簡易テストを実行可能になります。

コード生成

この記事執筆時点では、Rust(サーバー)TypeScript(クライアント)に対応しています。
次は実際にコードを生成する手順です。(以下はドキュメントの内容と同じです)

1. VSCode拡張機能をダウンロード

VSCodeマーケットプレイスからTYML for VSCodeをダウンロードします。

tyml_vscode

2. APIを定義する

適当な場所にファイルapi.tymlを作成してください。

interface API {
    function hello() -> string {
        return "Hello, world!"
    }
}

3. TYMLのCLIツールをインストール

以下のコマンドでインストールできます(Rustのcargoが必要です)。

cargo install tyml_core tyml_api_generator

4. コードの生成と実装

今回の例では、サーバー側がRust、クライアント側がTypeScriptを想定します。

サーバー側(Rust)

まずテスト用のクレートを作成します。

cargo new api-example-server

次に2.で定義したapi.tymlを使って型を生成します。

tyml-api-gen server rust-axum api.tyml ./api-example-server/api

Success!と表示されれば成功です。

Cargo.tomlに先程作成したapiasync-traittokioを追加します

[dependencies]
api = { path = "./api/" }
async-trait = "0.1"
tokio = { version = "1", features = ["full"] }

最後にmain.rsを編集してAPIを実装します。

use api::{serve, types::API};
use async_trait::async_trait;

struct Server {}

#[async_trait]
impl API for Server {
    async fn hello(&self) -> String {
        "Hello, world!".to_string()
    }
}

#[tokio::main]
async fn main() {
    let server = Server {};

    serve(server, "localhost:3000").await.unwrap();
}

クライアント側

TypeScriptの型を作成します。

tyml-api-gen client typescript api.tyml ./api-example-client/api

次にmain.ts./api-example-client内に作成してAPIを使用するコードを実装します。

import { API } from "./api/types.ts";

async function main() {
    const api = new API("http://localhost:3000")

    /// メソッドのように呼び出せる
    const result = await api.hello()

    console.log(result)
}

main()

5. 実行

サーバーとクライアントの それぞれのディレクトリ で以下のコマンドを実行します。

サーバー側

cargo run

クライアント側

npx tsx main.ts

実行結果

クライアント側を実行したときにHello, world!が表示されれば成功です

JWT認証

この言語ではfunctionの前にauthedキーワードをつけることでJWT認証が前提のメソッドとして定義できます。
実際に使用するイメージとしては以下のようなものになります。

TYML側

type Token {
    token: string
}

type Claim {
    iss: string
    sub: string
    iat: int
    exp: int
}

interface API {
    /// あらかじめ登録してトークンを返すメソッドも必要になります
    function register(user_name: string) -> Token

    /// ログインした状態で名前を取得する
    authed function get_user_name(@claim: Claim) -> string
}

サーバー側(Rust)

JWT認証を利用する場合はJwtValidatorが作成され、JWTのトークンの検証用実装を強制されます。

use api::{
    serve,
    types::{Claim, JwtValidator, API, Token},
};
use async_trait::async_trait;
use jsonwebtoken::{DecodingKey, EncodingKey, Header, Validation};

struct Server {}

#[async_trait]
impl API for Server {
    async fn register(&self, user_name: String) -> String {
        let token = jsonwebtoken::encode(
            &Header::default(),
            &Claim {
                iss: "localhost".to_string(),
                iat: 0,
                sub: user_name,
                exp: i64::MAX,
            },
            &EncodingKey::from_secret(SECRET.as_bytes()),
        )
        .unwrap();

        Token { token }
    }
    async fn get_user_name(&self, claim: Claim) -> String {
        println!("Login : {}", &claim.sub);

        claim.sub
    }
}

static SECRET: &'static str = "RUST IS GOOD!";

impl JwtValidator for Server {
    fn validate<T: serde::de::DeserializeOwned>(token: &str) -> Result<T, ()> {
        let claim = jsonwebtoken::decode(
            token,
            &DecodingKey::from_secret(SECRET.as_bytes()),
            &Validation::default(),
        )
        .map_err(|_| ())?
        .claims;

        Ok(claim)
    }
}

#[tokio::main]
async fn main() {
    let server = Server {};
    serve(server, "localhost:3000").await.unwrap();
}

クライアント側(TypeScript)

また、クライアント側のメソッドの第一引数にはトークンが要求されるようになります。

async function main() {
    const api = new API("http://localhost:3000");

    const token = await api.register("typescript user!");

    // registerで返却されたトークンを用いてリクエストを送信する
    const name = await api.get_user_name(token.token);
}

以上の要領でJWT認証を利用できます。

ドキュメント

ドキュメントもある程度整備しており、これからも拡充予定です。
https://tyml-org.github.io/tyml-docs-jp/chapter1/

最後に

いつかOpenAPItypespecを追い越せるようにこれからも開発を続けていきますので、よろしくお願いします。
気が向いたら試していただければ幸いです。
また、リポジトリの方はスターをいただけると大変励みになりますのでよろしくお願いします。
https://github.com/tyml-org/tyml

Discussion