jimble

ルーティング#

定義するところ#

JimbleApp を継承したクラスの初期化ブロックが、ルート定義です。

public class App extends JimbleApp {

	{
		get("/hello", context -> context.response().send("hello"));
	}

	public static void main (String[] args) {
		JimbleServer.start(new App());
	}

}

使えるメソッドは get post put patch delete head options trace、 それと全部に登録する any です。すべて小文字です。

パスパラメータ#

get("/users/{id}", context ->
	context.response().send(context.route().variables().get("id")));
  • {id} は1区切りぶん。%2F を含んでいても1つの値として取れます
  • * はワイルドカード。context.route().variables().wildcard() で残り全部が取れます

同じパスに複数当たる場合は、より具体的なほうが勝ちます/users/me/users/{id} より先)。 順番は 固定パス > パスパラメータ > ワイルドカードで、 同じ階層にパスパラメータが複数あるときは登録した順に試します。

同じパス・同じメソッドを2度登録すると、その場で例外になります。 起動してから気づくより早いほうがよいからです。

一生呼ばれないルート#

重複ではないのに、どんなリクエストでも他が先に当たることがあります。

get("/users/{id}", ...);
get("/users/{userId}", ...);   // ← 一生呼ばれない

名前が違うだけなので登録は通りますが、/users/5 は必ず1本目に当たります。 起動時に見つけて警告します。

WARN  ルート: GET     /users/{userId} は一生呼ばれません(GET     /users/{id} が先に当たります)

警告は起動ログの何十行にも紛れるので、CI では例外にしてください

server {
	strict_routes = true
}

なお /users/me/users/{id} より先に当たるのは正しい動きなので、警告しません ({id}me 以外のすべてで呼ばれます)。見ているのは1本も来ないものだけです。

メソッドだけ違うとき#

パスは合っていてメソッドだけ違うときは、404 ではなく 405 が返り、Allow が付きます。

$ curl -i -X POST http://localhost:9000/hello
HTTP/1.1 405 Method Not Allowed
Allow: GET

まとめる#

path("/form", () -> {

	/*
	 * 状態を変えるものだけ検証する。
	 * GET / HEAD / OPTIONS / TRACE は素通しする(Csrf.SAFE_METHODS)。
	 */
	before(Csrf::verify);

	get("", FormController::show);
	post("", FormController::submit);

});

path() の中で登録したものには、同じ before が付きます。 path() は入れ子にできます。

フィルタ#

登録 いつ走るか
before(handler) ハンドラの前
after(handler) ハンドラの後(レスポンスを送る前)
error(handler) 例外が出たとき・どのルートにも当たらなかったとき

before の中で例外を投げると、そこで止まって error に行きます。 認証はここでやります。

static void requireAuth (WebContext context) {

	if (!"secret".equals(context.request().header().getString("x-token"))) {
		throw new HttpException(401, "認証が必要です");
	}

}

フィルタの効く範囲#

フィルタは「書いた場所」に付きます。パスには付きません。 効くのは、同じブロックで登録したルートと、そこから path() / install() で ネストしたものだけです。

path("/admin", () -> {
	before(AdminController::requireAuth);
	get("/users", ...);          // ← 効く
	install(GroupController::new);   // ← 効く(中のルートも全部)
});

path("/admin", () -> {
	get("/login", ...);          // ← 効かない(別のブロック)
});

パスが同じでも、別のブロックで登録したルートには効きません。 だから「/admin 配下は全部認証」を成り立たせたいときは、 /admin のルートを1か所にまとめてください。 逆に言うと、誰かが別の場所で /admin/... を足しても、 知らないうちにフィルタに捕まることがありません。

ブロックの中では、before を書いた位置とルートを書いた位置の前後は関係ありません。 ブロック全体に効きます。

フィルタはルートごとに起動時に1度だけ組み立てます。 そのため、確定した後にフィルタを足すと落ちます(「足したのに効かない」を作らないため)。 ルート定義はコントローラの初期化ブロックの中で完結させてください。

未マッチ(404)のときに呼ばれるのは、一番外側の error だけです。 どのルートにも当たっていないので、内側のブロックが決まりません。

ルートごとの印#

フィルタを1本だけ外したいときは、注釈ではなく属性を使います。

static final AttributeKey<Boolean> NO_AUTH = new AttributeKey<>("no_auth", false);

get("/health_check", context -> context.response().send()).attribute(NO_AUTH, true);
if (context.route().route().attribute(NO_AUTH)) {
	return;
}

AttributeKey は既定値を持ちます。付いていないルートでは既定値が返るので、 null の判定が要りません。

コントローラに分ける#

1ファイルが長くなったら Controller に切り出します。

public class PostController extends Controller {

	{
		get("/posts", context -> context.response().json("posts", listPosts()));
	}

}
install(PostController::new);

install() に渡すのはインスタンスではなくコンストラクタ参照です。 走査はしないので、install() を書かないと登録されません。 起動ログのルート一覧で確認してください。

エラーの扱い#

error((context, cause, statusCode) ->
	context.response().code(statusCode).send("エラー: %d %s%n".formatted(statusCode, cause.getMessage())));

HttpException(404, "...") を投げると、そのステータスで error に入ります。 error を複数登録すると、登録した順に呼ばれます。 どれかがレスポンスを送ったら、そこで止まります。

ステータスコードの決まり方、未マッチ(404)の扱い、検証失敗との違いは エラー処理 にまとめてあります。

実装済みのルートを内側から呼ぶ#

登録済みのルートは、HTTP を通さずに呼べます。

CallResponse response = context.dispatcher().call(context
	, CallRequest.of("GET", "/api/posts").query("page", "2"));

Data json = response.json();

通常のリクエストとまったく同じ道を通ります。 before / after / エラーハンドラ / レートリミット が効き、 違うのは送り先だけです(ネットワークに出す代わりに CallResponse で受け取ります)。

内側は外側のリクエストの続きとして走ります。 ヘッダ・Cookie・セッション・Flash は外側のものをそのまま使い、 実行 ID も引き継ぐのでログは 1 本に繋がります。 個別に変えたいヘッダは .header("X-Token", "...") で上書きしてください。

内側で発行した Cookie も、外側のレスポンスに載って相手に届きます。

落とし穴

内側は外側とは別のトランザクションです。 DB 接続もコンテキストごとに持つので、 外側で開けたトランザクションの中には入りません。 まとめてコミットしたい処理は、ドメイン層で共有してください。

いちばんの用途は、MCP から API をそのまま公開することです。 RouteTool を使うと、ルート 1 本がそのままツールになります。