自作言語TYMLで行うJWT認証とスキーマ駆動開発
TYMLとは
TYMLとは、JsonSchema
よりも簡潔で厳密な仕様を持つ、任意の設定用言語(現在はini
とtoml
とjson
)に対応可能なスキーマ言語です。
JsonSchema
とOpenAPI
の置き換えを目標としています。
JsonSchema
の代替機能に関しては、前回書いた以下の記事を読んでいただければと思います。
今回はOpenAPI
やtypespec
の代替機能である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にはOpenAPI
やtypespec
と違っている点がいくつかあります
- 機能が最小限
- 故にシンプル
- エディタ拡張機能(LSP)による強力なアシスト
- typespecのようにトランスパイルの必要がない
- Rustで開発しているので高速に動作する
- エディタの拡張機能によりワンクリックで簡易テストを実行可能!
特にシンプルな仕様とLSPがあるのが強みです。
簡易テストに関しては、拡張機能がインストールされたVSCodeでTYMLファイルを開くと以下のようにテストを実行するためのボタンが出現するようになります。
この機能はデフォルト値を記述すると使用できるようになり、ワンクリックで簡易テストを実行可能になります。
コード生成
この記事執筆時点では、Rust(サーバー)
とTypeScript(クライアント)
に対応しています。
次は実際にコードを生成する手順です。(以下はドキュメントの内容と同じです)
1. VSCode拡張機能をダウンロード
VSCodeマーケットプレイスからTYML for 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
に先程作成したapi
とasync-trait
とtokio
を追加します
[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認証を利用できます。
ドキュメント
ドキュメントもある程度整備しており、これからも拡充予定です。
最後に
いつかOpenAPI
やtypespec
を追い越せるようにこれからも開発を続けていきますので、よろしくお願いします。
気が向いたら試していただければ幸いです。
また、リポジトリの方はスターをいただけると大変励みになりますのでよろしくお願いします。
Discussion