Open13

ちっちゃいgoのWAFを考えてみる

podhmopodhmo

ちっちゃいgoのWAFでも作ってみようかな。go1.22+由来のnet/httpのハンドラーをそのまま使う感じでgo-chiに雰囲気寄せる感じで(ただし登録順序とか気にしない感じで)

名前はrakudaとかにする

  • 駱駝に乗りたい(手軽に動きたい)
  • 楽だ
podhmopodhmo

たぶん雰囲気的にpythonのpyramidの影響を受ける。でもそこまで本格的にはしない。登録を順序を気にしたくない程度で拡張性を持たせたディレクティブ(pyramid用語)とかは作るつもりはない。

https://trypyramid.com/

podhmopodhmo

第一段階

とりあえずgo-chiを参考にするけれど登録順序を気にしたくない。なのでクロージャとして保持してソートしてから登録することにする。

このコミット的な処理の呼び忘れを防ぐためにビルダーとルーターは分ける。

https://aistudio.google.com/app/prompts?state={"ids":["1TlE_kFl3zbaYfhWkwC-QEVNtZrDUwHBC"],"action":"open","userId":"108405443477417806091","resourceKeys":{}}&usp=sharing

podhmopodhmo

Specification: rakuda HTTP Router (Revision 3)

1. Overview

rakuda is an HTTP router for Go that enforces a clear and safe configuration lifecycle. It achieves this by providing two distinct and separate components: a Builder for defining routes, and the standard http.Handler as the final, executable product. This design makes it impossible to accidentally use an unconfigured or partially configured router, as such an error would be caught by the Go compiler.

2. Core Concepts

2.1. Unambiguous Separation of Concerns: Builder vs. http.Handler

This is the foundational principle of the library.

  • rakuda.Builder: This is the type used exclusively for configuration. It provides all the methods for defining routes and middlewares. It does not implement the http.Handler interface. Its sole purpose is to accumulate routing rules in an internal tree structure.

  • http.Handler: This is the standard Go interface for handling HTTP requests. The Builder.Build() method returns a value of this type. It is the final, immutable, and executable handler that is passed to http.ListenAndServe.

This strict separation ensures that a developer cannot pass a Builder to an HTTP server, thus eliminating "forgot to build" runtime errors at compile time.

2.2. Internal Mechanism: The Configuration Tree

The Builder manages routing rules internally as a tree structure. This is the key to handling nested routes and scoped middlewares correctly.

  • Root Node: NewBuilder() creates the root of the tree.
  • Child Nodes: Calling Route(pattern, fn) or Group(fn) creates a new child node under the current builder's node.
    • Route creates a node associated with a path prefix.
    • Group creates a node for scoping middlewares without a path prefix.
  • Inheritance: When the final handler is constructed during the Build phase, middlewares are inherited down the tree. A handler registered on a child node will be wrapped by middlewares on that child, its parent, its grandparent, and so on, all the way to the root.

2.3. Internal Mechanism: How Order-Independence is Achieved

Order-independence is achieved by deferring all processing to the Build() phase.

  1. Declaration Phase: When you call methods like Use() or Get() on a Builder, the router does not immediately create middleware chains or register handlers. It simply adds a "middleware registration" or a "handler registration" entry to a list within the current node of the configuration tree.

  2. Build Phase: When Build() is called, it performs a traversal (e.g., Depth-First Search) of the entire configuration tree. For each node it visits, it performs the following steps:
    a. It calculates the full middleware chain applicable to that node by combining middlewares from all its ancestors (parent, grandparent, etc.) with the middlewares registered directly on the node itself.
    b. It then iterates through the handlers registered on that node. For each handler, it wraps it with the complete middleware chain calculated in the previous step.
    c. Finally, it registers the fully-wrapped handler with the final http.ServeMux under its complete, concatenated path.

Because the process of applying middlewares to handlers only happens at the end, the original order in which you called Use() and Get() within a given scope is irrelevant.

3. API Specification

3.1. Type Builder

The Builder is the entry point for all route definitions.

Factory Function

  • NewBuilder() *Builder
    • Creates and returns a new, empty Builder instance, representing the root of the configuration tree.

Configuration Methods

  • WithLogger(logger *slog.Logger) *Builder

    • Attaches a slog.Logger for debugging and internal logging. Returns the builder for chaining.
  • Use(middlewares ...func(http.Handler) http.Handler)

    • Appends one or more middlewares to the current node in the configuration tree.
  • Group(fn func(b *Builder))

    • Creates a new child node for scoping. The function fn receives a new Builder instance that operates on this new node.
  • Route(pattern string, fn func(b *Builder))

    • Creates a new child node with a URL path prefix. The function fn receives a new Builder instance for this sub-route.

Handler Registration Methods

  • Handle(pattern string, handler http.Handler)

    • Registers an http.Handler for a given pattern within the current node. The pattern must be in the format "{METHOD} {path}".
  • Get(pattern string, handler http.Handler)

    • A convenience wrapper for Handle("GET "+pattern, handler).
  • Post(pattern string, handler http.Handler)

  • Put(pattern string, handler http.Handler)

  • Delete(pattern string, handler http.Handler)

  • Patch(pattern string, handler http.Handler)

  • Options(pattern string, handler http.Handler)

  • Head(pattern string, handler http.Handler)

    • (Note: ...Func variants like GetFunc are intentionally omitted for a leaner API. Users should wrap functions with http.HandlerFunc.)

Build Method

  • Build() (http.Handler, error)
    • Executes the build process as described in section 2.3.
    • Returns a final, immutable http.Handler.
    • Returns an error if the configuration is invalid.
    • After Build() is called, the Builder instance becomes locked. Any subsequent calls to its configuration methods will return an error.

3.2. Route Inspection Utility

  • PrintRoutes(w io.Writer, b *Builder) error
    • A helper function to visualize the configuration tree of a Builder instance before it is built.

4. Example Usage Flow

func main() {
    // 1. Create a new Builder instance.
    b := rakuda.NewBuilder()

    // 2. Configure routes and middlewares.
    b.Use(RequestLoggerMiddleware)
    b.Get("/", http.HandlerFunc(handleHomepage))

    b.Route("/api/v1", func(api *rakuda.Builder) {
        api.Use(AuthMiddleware)
        api.Get("/users", http.HandlerFunc(handleListUsers))
    })

    // 3. (Optional) Print the routes for debugging.
    rakuda.PrintRoutes(os.Stdout, b)

    // 4. Build the final http.Handler from the Builder.
    handler, err := b.Build()
    if err != nil {
        log.Fatalf("Failed to build router: %v", err)
    }

    // 5. Use the resulting handler.
    // http.ListenAndServe(":8080", b) // <-- This will NOT compile.
    http.ListenAndServe(":8080", handler) // This is correct.
}

5. Error Handling

  • Configuration errors are returned exclusively by the Build() method.
  • Any attempt to modify a Builder after Build() has been called will result in an error on subsequent method calls.
  • There will be no panics for any configuration-related issues.
podhmopodhmo

登録がうまくいってるか確認するためのそして全体を辿って登録状況を確認できるproutes的なコマンドが欲しい

😔 あと仕様と呼ぶと外部向けのインターフェイスが出力され会話した内部的な意思決定が消える。design docs/とか言えば良いのかな。

podhmopodhmo

Design Document: rakuda HTTP Router

  • Author: Gemini
  • Status: Proposed
  • Date: 2025-09-07

1. Abstract

This document outlines the internal design and architecture of rakuda, a new HTTP router for Go. The primary goal of rakuda is to provide a type-safe and predictable routing experience by enforcing a strict separation between the configuration phase and the execution phase. This is achieved through a dedicated Builder type for route definition and the final output of a standard http.Handler. This design eliminates a class of common runtime errors, such as using a router before its configuration is complete.

2. Goals and Motivation

The Go ecosystem has many routers, but many either allow for runtime errors due to ambiguous lifecycles or have complex APIs. The motivation for rakuda is to address these issues with the following core goals:

  • Type Safety: The router's lifecycle must be enforced by the Go compiler. It should be impossible to compile code that uses a router before it has been explicitly built.
  • Predictable Lifecycle: The configuration of routes (the "what") should be completely separate from the serving of traffic (the "how"). The router's state should be immutable once built.
  • Declarative, Order-Independent API: Developers should be able to declare routes and middlewares in any order without affecting the final behavior.
  • Leverage Standard Library: Maximize the use of the standard net/http package, including the path parameter support introduced in Go 1.22, to ensure compatibility and minimize dependencies.
  • Flexible Middleware Scoping: Support applying middlewares globally, to specific route groups, or to nested groups.

3. Proposed Architecture

3.1. Core Components: Builder and http.Handler

The entire design hinges on the strict separation of two types:

  • rakuda.Builder: This is the sole object responsible for configuration. It exposes methods like Get, Post, Use, Route, etc. Critically, it does not implement the http.Handler interface. Its only responsibility is to build and maintain an internal configuration tree.

  • http.Handler: This is the standard library interface. The Builder.Build() method is the factory for this type. The returned handler is an immutable, ready-to-use component that encapsulates all the defined routing logic.

This separation forces the developer into a safe, two-step process: first, configure the Builder; second, Build() it into an http.Handler to be used by the server. An attempt to pass the Builder to http.ListenAndServe will result in a compile-time error.

3.2. Internal Data Structure: The Configuration Tree

To support flexible middleware scoping and nested routes (/api/v1/...), the Builder does not store routes in a flat list. Instead, it maintains a tree of configuration nodes.

  • Each Builder instance holds a reference to a node in this tree.
  • NewBuilder() creates the root node.
  • Route(pattern, fn) and Group(fn) create a new child node under the current node. The function fn receives a new Builder instance that points to this new child node.

A simplified representation of a node struct would be:

type node struct {
    pattern     string
    middlewares []func(http.Handler) http.Handler
    handlers    []handlerRegistration
    children    []*node
}

This tree structure naturally maps to the hierarchical nature of RESTful API routes.

3.3. The Build Process

All the "magic" happens within the Build() method. The method calls on the Builder (Get, Use, etc.) are lightweight operations that simply populate the configuration tree. Build() orchestrates the following process:

  1. Traversal: It performs a Depth-First Search (DFS) traversal of the entire configuration tree, starting from the root.
  2. Context Accumulation: As it traverses, it keeps track of the accumulated path prefix (e.g., /api + /v1) and the inherited middleware chain.
  3. Handler Assembly: For each node it visits, it:
    a. Creates the full middleware chain for that node by appending the node's own middlewares to the chain inherited from its parent.
    b. Iterates through all handlers registered on that node.
    c. For each handler, it applies the full middleware chain.
    d. It registers the final, wrapped handler with a new http.ServeMux instance, using the full, concatenated path pattern (e.g., GET /api/v1/users/{id}).
  4. Finalization: Once the traversal is complete, the populated http.ServeMux (which is itself an http.Handler) is returned. The Builder is then marked as "built" to prevent further modification.

This deferred assembly process is what enables the order-independent API. The order of Use() and Get() calls only matters relative to each other after the build process has categorized and applied them.

3.4. Path Parameters

rakuda will not implement its own path parameter parsing logic. It will rely entirely on the native capabilities of net/http introduced in Go 1.22. Handlers are expected to retrieve path parameters directly from the request object.

Example:

// Registration
b.Get("/users/{id}", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
    // Retrieval inside the handler
    userID := r.PathValue("id")
    // ...
}))

4. Rejected Alternatives

Several alternative designs were considered and rejected. Understanding these trade-offs is crucial to understanding the proposed architecture.

4.1. Alternative 1: Lazy Initialization

  • Description: The router would implement http.Handler, but the actual building would be deferred until the first time ServeHTTP is called, likely using a sync.Once.
  • Reason for Rejection: This is a poor developer experience. Configuration errors (e.g., a typo in a route, an invalid pattern) would only surface at runtime when the first request hits the server, not at application startup. This violates the principle of failing fast.

4.2. Alternative 2: Combined Router/Builder Type

  • Description: A single Router type would both implement http.Handler and expose configuration methods. A Build() method might exist, but its use wouldn't be enforced by the compiler.
  • Reason for Rejection: This is the most common pattern, but it's prone to error. A developer could easily forget to call Build() and pass the unconfigured or partially configured router to the server. This would lead to runtime panics (if ServeHTTP checks for a built state) or silent failures (404s for all routes). The proposed design solves this at compile time.

4.3. Alternative 3: Flat Slice and Sorting

  • Description: Instead of a tree, all registrations (middlewares and handlers) would be appended to a single flat slice. Each entry would have a "type" or "priority" (e.g., middleware=1, handler=2). During Build(), this slice would be sorted to ensure middlewares are processed before handlers.
  • Reason for Rejection: This approach is too simplistic. It cannot handle scoped middlewares. For example, you cannot apply a middleware only to routes under /api. All middlewares would effectively become global, severely limiting the router's utility for any non-trivial application. The tree structure is essential for this functionality.
podhmopodhmo

細々とした事項

初回に作るときのほそぼそとした事項。なるべく依存を増やさない方針にしたい。

scaffoldするときの命令的なものにいれるやつら

  • READMEを英語で書いてください
  • テストコードを追加してください
  • go1.24を使ってください
  • ログにはlog/slogだけを使ってください
  • デバッグプリントはslogのDebugContextを使ってください
  • http routerは net/http のものを使ってください
  • テストコードのassertにはgo-cmpを使ってください
  • テストコードのassertでは変数名としてwant,gotのペアを使ってください
  • github.com/stretchr/testify は使わないでください
  • github actionsのciを追加してください(pull_request で types: [ opened, synchronize, reopened ])
podhmopodhmo

TODO

  • 設定の読み込みどうする?
  • 実際のハンドラーの定義どうする?
  • リクエストのバインドどうする?(e.g.クエリーパラメーター)
podhmopodhmo

ユースケース的な

  • json response
  • redirect
  • error response (json)
podhmopodhmo

雑に聞く

承知いたしました。
現在のrakudaの設計は、安全で堅牢なHTTPルーターとしては優れていますが、Webアプリケーションを効率的に開発するための**Web Application Framework (WAF)**として見た場合、多くの便利な機能が不足しています。

以下に、WAFとして不足している機能をユースケースごとに列挙します。

ユースケース: リクエストデータの処理と検証

HTTPリクエストからデータ(JSON、フォーム、クエリパラメータ等)を安全かつ簡単に取り出すための機能です。

  • データバインディング: リクエストボディのJSONやフォームの内容を、自動的にGoのstructにマッピングする機能 (c.Bind(&userStruct))。これにより、手動でのJSONデコードやフォーム値のパースが不要になります。
  • 入力バリデーション: バインディングしたstructのフィールドに対して、「必須項目」「メールアドレス形式」「最小/最大値」などの検証ルールを宣言的に適用し、違反した場合にエラーを返す機能。
  • 型安全なパスパラメータ/クエリパラメータ取得: r.PathValue("id") は文字列を返しますが、これをintuuid.UUIDなどの型に変換し、失敗した場合にエラーをハンドリングするヘルパー機能。
  • ファイルアップロードの簡略化: マルチパートフォームからのファイルアップロード処理を、より簡単に扱うためのヘルパー機能。

ユースケース: レスポンスの生成

JSON、HTML、XMLなど、様々な形式のレスポンスを簡単に生成するための機能です。

  • レスポンスヘルパー: c.JSON(http.StatusOK, data)c.String(http.StatusOK, "message") のように、ステータスコード、コンテントタイプヘッダの設定、およびボディの書き込みを一行で行うヘルパー関数。
  • HTMLテンプレートレンダリング: html/template などのテンプレートエンジンと統合し、c.Render("index.html", data) のような形でHTMLをレンダリングする機能。テンプレートのキャッシュ管理なども含みます。

ユースケース: アプリケーションの状態管理と依存性注入

データベース接続プールや設定情報など、アプリケーション全体で共有するリソースを各ハンドラに安全に渡すための仕組みです。

  • カスタムコンテキスト: http.ResponseWriter*http.Request をラップし、リクエストスコープの値、パスパラメータ、レスポンスヘルパー、アプリケーションの依存関係(DB接続など)を保持する独自のContext型。ハンドラのシグネチャは func(c *rakuda.Context) のようになります。これは多くのWAFで中核的な機能です。
  • 依存性注入(DI)のパターン: カスタムコンテキストを通じて、アプリケーション起動時に初期化されたサービス(DB接続プールなど)を各ハンドラから透過的に利用できるようにするための明確なパターンや仕組み。

ユースケース: エラーハンドリングの一元化

アプリケーション全体でエラーレスポンスの形式を統一し、ハンドラ内のエラー処理の記述を簡潔にするための機能です。

  • 中央エラーハンドラ: ハンドラがerrorを返り値として返せるようにし、nilでない場合に特定のエラーレスポンス(例: 500 Internal Server ErrorのJSON)を自動で生成するミドルウェア。
  • パニックリカバリー: ハンドラ内で発生したパニックを捕捉し、サーバーをクラッシュさせる代わりに500エラーとして処理するミドルウェア。

ユースケース: セキュリティ対策

Webアプリケーションにおける一般的な脆弱性から保護するための標準的な機能です。

  • CORSミドルウェア: クロスオリジンリソース共有 (CORS) のヘッダを柔軟に設定するためのミドルウェア。
  • CSRFミドルウェア: クロスサイトリクエストフォージェリ (CSRF) からアプリケーションを保護するためのトークンベースのミドルウェア。
  • セキュリティヘッダミドルウェア: Content-Security-PolicyX-XSS-Protection といった、セキュリティ関連のHTTPヘッダを簡単に追加するためのミドルウェア。

ユースケース: 可観測性 (Observability)

アプリケーションの動作を監視し、デバッグを容易にするための機能です。

  • リクエストスコープのロガー: リクエストIDなど、リクエストに紐づく情報を含んだslog.Loggerをコンテキスト経由で利用できるようにする機能。
  • メトリクス/トレーシング連携: PrometheusやOpenTelemetryといった監視ツールと連携するための公式ミドルウェア。