luarouter
This commit is contained in:
parent
b18cbc9cbc
commit
440f0a8378
276
core/routers/luarouter.go
Normal file
276
core/routers/luarouter.go
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
luajit "git.sharkk.net/Sky/LuaJIT-to-Go"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Maximum number of URL parameters per route
|
||||||
|
const maxParams = 20
|
||||||
|
|
||||||
|
// LuaRouter is a filesystem-based HTTP router for Lua files
|
||||||
|
type LuaRouter struct {
|
||||||
|
routesDir string // Root directory containing route files
|
||||||
|
routes map[string]*node // Method -> route tree
|
||||||
|
mu sync.RWMutex // Lock for concurrent access to routes
|
||||||
|
}
|
||||||
|
|
||||||
|
// node represents a node in the routing trie
|
||||||
|
type node struct {
|
||||||
|
handler string // Path to Lua file (empty if not an endpoint)
|
||||||
|
bytecode []byte // Pre-compiled Lua bytecode
|
||||||
|
paramName string // Parameter name (if this is a parameter node)
|
||||||
|
staticChild map[string]*node // Static children by segment name
|
||||||
|
paramChild *node // Parameter/wildcard child
|
||||||
|
}
|
||||||
|
|
||||||
|
// Params holds URL parameters with fixed-size arrays to avoid allocations
|
||||||
|
type Params struct {
|
||||||
|
Keys [maxParams]string
|
||||||
|
Values [maxParams]string
|
||||||
|
Count int
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get returns a parameter value by name
|
||||||
|
func (p *Params) Get(name string) string {
|
||||||
|
for i := 0; i < p.Count; i++ {
|
||||||
|
if p.Keys[i] == name {
|
||||||
|
return p.Values[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewLuaRouter creates a new LuaRouter instance
|
||||||
|
func NewLuaRouter(routesDir string) (*LuaRouter, error) {
|
||||||
|
// Verify routes directory exists
|
||||||
|
info, err := os.Stat(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !info.IsDir() {
|
||||||
|
return nil, errors.New("routes path is not a directory")
|
||||||
|
}
|
||||||
|
|
||||||
|
r := &LuaRouter{
|
||||||
|
routesDir: routesDir,
|
||||||
|
routes: make(map[string]*node),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize method trees
|
||||||
|
methods := []string{"GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS", "HEAD"}
|
||||||
|
for _, method := range methods {
|
||||||
|
r.routes[method] = &node{
|
||||||
|
staticChild: make(map[string]*node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build routes
|
||||||
|
if err := r.buildRoutes(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildRoutes scans the routes directory and builds the routing tree
|
||||||
|
func (r *LuaRouter) buildRoutes() error {
|
||||||
|
return filepath.Walk(r.routesDir, func(path string, info os.FileInfo, err error) error {
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip directories
|
||||||
|
if info.IsDir() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only process .lua files
|
||||||
|
if !strings.HasSuffix(info.Name(), ".lua") {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract method from filename
|
||||||
|
method := strings.ToUpper(strings.TrimSuffix(info.Name(), ".lua"))
|
||||||
|
|
||||||
|
// Check if valid method
|
||||||
|
root, exists := r.routes[method]
|
||||||
|
if !exists {
|
||||||
|
return nil // Skip invalid methods
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get relative path for URL
|
||||||
|
relDir, err := filepath.Rel(r.routesDir, filepath.Dir(path))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build URL path
|
||||||
|
urlPath := "/"
|
||||||
|
if relDir != "." {
|
||||||
|
urlPath = "/" + strings.ReplaceAll(relDir, "\\", "/")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add route to tree
|
||||||
|
return r.addRoute(root, urlPath, path)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// addRoute adds a route to the routing tree and compiles the Lua file to bytecode
|
||||||
|
func (r *LuaRouter) addRoute(root *node, urlPath, handlerPath string) error {
|
||||||
|
segments := strings.Split(strings.Trim(urlPath, "/"), "/")
|
||||||
|
current := root
|
||||||
|
|
||||||
|
for _, segment := range segments {
|
||||||
|
if len(segment) >= 2 && segment[0] == '[' && segment[len(segment)-1] == ']' {
|
||||||
|
if current.paramChild == nil {
|
||||||
|
current.paramChild = &node{
|
||||||
|
paramName: segment[1 : len(segment)-1],
|
||||||
|
staticChild: make(map[string]*node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
current = current.paramChild
|
||||||
|
} else {
|
||||||
|
// Create or get static child
|
||||||
|
child, exists := current.staticChild[segment]
|
||||||
|
if !exists {
|
||||||
|
child = &node{
|
||||||
|
staticChild: make(map[string]*node),
|
||||||
|
}
|
||||||
|
current.staticChild[segment] = child
|
||||||
|
}
|
||||||
|
current = child
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set handler path
|
||||||
|
current.handler = handlerPath
|
||||||
|
|
||||||
|
// Compile Lua file to bytecode
|
||||||
|
if err := r.compileHandler(current); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Match finds a handler for the given method and path
|
||||||
|
// Uses the pre-allocated params struct to avoid allocations
|
||||||
|
func (r *LuaRouter) Match(method, path string, params *Params) (*node, bool) {
|
||||||
|
// Reset params
|
||||||
|
params.Count = 0
|
||||||
|
|
||||||
|
// Get route tree for method
|
||||||
|
r.mu.RLock()
|
||||||
|
root, exists := r.routes[method]
|
||||||
|
r.mu.RUnlock()
|
||||||
|
|
||||||
|
if !exists {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split path
|
||||||
|
segments := strings.Split(strings.Trim(path, "/"), "/")
|
||||||
|
|
||||||
|
// Match path
|
||||||
|
return r.matchPath(root, segments, params, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchPath recursively matches a path against the routing tree
|
||||||
|
func (r *LuaRouter) matchPath(current *node, segments []string, params *Params, depth int) (*node, bool) {
|
||||||
|
// Base case: no more segments
|
||||||
|
if len(segments) == 0 {
|
||||||
|
if current.handler != "" {
|
||||||
|
return current, true
|
||||||
|
}
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
segment := segments[0]
|
||||||
|
remaining := segments[1:]
|
||||||
|
|
||||||
|
// Try static child first (exact match takes precedence)
|
||||||
|
if child, exists := current.staticChild[segment]; exists {
|
||||||
|
if node, found := r.matchPath(child, remaining, params, depth+1); found {
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try parameter child
|
||||||
|
if current.paramChild != nil {
|
||||||
|
// Store parameter
|
||||||
|
if params.Count < maxParams {
|
||||||
|
params.Keys[params.Count] = current.paramChild.paramName
|
||||||
|
params.Values[params.Count] = segment
|
||||||
|
params.Count++
|
||||||
|
}
|
||||||
|
|
||||||
|
if node, found := r.matchPath(current.paramChild, remaining, params, depth+1); found {
|
||||||
|
return node, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backtrack: remove parameter if no match
|
||||||
|
params.Count--
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// compileHandler compiles a Lua file to bytecode
|
||||||
|
func (r *LuaRouter) compileHandler(n *node) error {
|
||||||
|
if n.handler == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read the Lua file
|
||||||
|
content, err := os.ReadFile(n.handler)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile to bytecode
|
||||||
|
state := luajit.New()
|
||||||
|
if state == nil {
|
||||||
|
return errors.New("failed to create Lua state")
|
||||||
|
}
|
||||||
|
defer state.Close()
|
||||||
|
|
||||||
|
bytecode, err := state.CompileBytecode(string(content), n.handler)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store bytecode in the node
|
||||||
|
n.bytecode = bytecode
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetBytecode returns the compiled bytecode for a matched route
|
||||||
|
func (r *LuaRouter) GetBytecode(method, path string, params *Params) ([]byte, bool) {
|
||||||
|
node, found := r.Match(method, path, params)
|
||||||
|
if !found {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
return node.bytecode, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh rebuilds the router by rescanning the routes directory
|
||||||
|
func (r *LuaRouter) Refresh() error {
|
||||||
|
r.mu.Lock()
|
||||||
|
defer r.mu.Unlock()
|
||||||
|
|
||||||
|
// Reset routes
|
||||||
|
for method := range r.routes {
|
||||||
|
r.routes[method] = &node{
|
||||||
|
staticChild: make(map[string]*node),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Rebuild routes
|
||||||
|
return r.buildRoutes()
|
||||||
|
}
|
256
core/routers/luarouter_test.go
Normal file
256
core/routers/luarouter_test.go
Normal file
@ -0,0 +1,256 @@
|
|||||||
|
package routers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func setupTestRoutes(t *testing.T) (string, func()) {
|
||||||
|
// Create a temporary directory for test routes
|
||||||
|
tempDir, err := os.MkdirTemp("", "fsrouter-test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create route structure with valid Lua code
|
||||||
|
routes := map[string]string{
|
||||||
|
"get.lua": "return { path = '/' }",
|
||||||
|
"post.lua": "return { path = '/' }",
|
||||||
|
"api/get.lua": "return { path = '/api' }",
|
||||||
|
"api/users/get.lua": "return { path = '/api/users' }",
|
||||||
|
"api/users/[id]/get.lua": "return { path = '/api/users/[id]' }",
|
||||||
|
"api/users/[id]/posts/get.lua": "return { path = '/api/users/[id]/posts' }",
|
||||||
|
"api/[version]/docs/get.lua": "return { path = '/api/[version]/docs' }",
|
||||||
|
}
|
||||||
|
|
||||||
|
for path, content := range routes {
|
||||||
|
routePath := filepath.Join(tempDir, path)
|
||||||
|
|
||||||
|
// Create directories
|
||||||
|
err := os.MkdirAll(filepath.Dir(routePath), 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create directory %s: %v", filepath.Dir(routePath), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create file
|
||||||
|
err = os.WriteFile(routePath, []byte(content), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create file %s: %v", routePath, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return cleanup function
|
||||||
|
cleanup := func() {
|
||||||
|
os.RemoveAll(tempDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tempDir, cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouterInitialization(t *testing.T) {
|
||||||
|
routesDir, cleanup := setupTestRoutes(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
router, err := NewLuaRouter(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if router == nil {
|
||||||
|
t.Fatal("Router is nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouteMatching(t *testing.T) {
|
||||||
|
routesDir, cleanup := setupTestRoutes(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
router, err := NewLuaRouter(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
method string
|
||||||
|
path string
|
||||||
|
wantFound bool
|
||||||
|
wantParams map[string]string
|
||||||
|
wantHandler string
|
||||||
|
}{
|
||||||
|
// Static routes
|
||||||
|
{"GET", "/", true, nil, filepath.Join(routesDir, "get.lua")},
|
||||||
|
{"POST", "/", true, nil, filepath.Join(routesDir, "post.lua")},
|
||||||
|
{"GET", "/api", true, nil, filepath.Join(routesDir, "api/get.lua")},
|
||||||
|
{"GET", "/api/users", true, nil, filepath.Join(routesDir, "api/users/get.lua")},
|
||||||
|
|
||||||
|
// Parameterized routes
|
||||||
|
{"GET", "/api/users/123", true, map[string]string{"id": "123"}, filepath.Join(routesDir, "api/users/[id]/get.lua")},
|
||||||
|
{"GET", "/api/users/456/posts", true, map[string]string{"id": "456"}, filepath.Join(routesDir, "api/users/[id]/posts/get.lua")},
|
||||||
|
{"GET", "/api/v1/docs", true, map[string]string{"version": "v1"}, filepath.Join(routesDir, "api/[version]/docs/get.lua")},
|
||||||
|
|
||||||
|
// Non-existent routes
|
||||||
|
{"PUT", "/", false, nil, ""},
|
||||||
|
{"GET", "/nonexistent", false, nil, ""},
|
||||||
|
{"GET", "/api/nonexistent", false, nil, ""},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.method+" "+tt.path, func(t *testing.T) {
|
||||||
|
var params Params
|
||||||
|
node, found := router.Match(tt.method, tt.path, ¶ms)
|
||||||
|
|
||||||
|
if found != tt.wantFound {
|
||||||
|
t.Errorf("Match() found = %v, want %v", found, tt.wantFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if node.handler != tt.wantHandler {
|
||||||
|
t.Errorf("Match() handler = %v, want %v", node.handler, tt.wantHandler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify bytecode was compiled
|
||||||
|
if len(node.bytecode) == 0 {
|
||||||
|
t.Errorf("No bytecode found for handler: %s", node.handler)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify parameters
|
||||||
|
if tt.wantParams != nil {
|
||||||
|
for key, wantValue := range tt.wantParams {
|
||||||
|
gotValue := params.Get(key)
|
||||||
|
if gotValue != wantValue {
|
||||||
|
t.Errorf("Parameter %s = %s, want %s", key, gotValue, wantValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParamExtraction(t *testing.T) {
|
||||||
|
routesDir, cleanup := setupTestRoutes(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
router, err := NewLuaRouter(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var params Params
|
||||||
|
_, found := router.Match("GET", "/api/v2/docs", ¶ms)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Route not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Count != 1 {
|
||||||
|
t.Errorf("Expected 1 parameter, got %d", params.Count)
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Keys[0] != "version" {
|
||||||
|
t.Errorf("Expected parameter key 'version', got '%s'", params.Keys[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Values[0] != "v2" {
|
||||||
|
t.Errorf("Expected parameter value 'v2', got '%s'", params.Values[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if params.Get("version") != "v2" {
|
||||||
|
t.Errorf("Get(\"version\") returned '%s', expected 'v2'", params.Get("version"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetBytecode(t *testing.T) {
|
||||||
|
routesDir, cleanup := setupTestRoutes(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
router, err := NewLuaRouter(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
var params Params
|
||||||
|
bytecode, found := router.GetBytecode("GET", "/api/users/123", ¶ms)
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Fatalf("Route not found")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bytecode) == 0 {
|
||||||
|
t.Errorf("Expected non-empty bytecode")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check parameters were extracted
|
||||||
|
if params.Get("id") != "123" {
|
||||||
|
t.Errorf("Expected id parameter '123', got '%s'", params.Get("id"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRefresh(t *testing.T) {
|
||||||
|
routesDir, cleanup := setupTestRoutes(t)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
router, err := NewLuaRouter(routesDir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add a new route file
|
||||||
|
newRoutePath := filepath.Join(routesDir, "new", "get.lua")
|
||||||
|
err = os.MkdirAll(filepath.Dir(newRoutePath), 0755)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create directory: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
err = os.WriteFile(newRoutePath, []byte("return { path = '/new' }"), 0644)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create file: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Before refresh, route should not be found
|
||||||
|
var params Params
|
||||||
|
_, found := router.GetBytecode("GET", "/new", ¶ms)
|
||||||
|
if found {
|
||||||
|
t.Errorf("New route should not be found before refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh router
|
||||||
|
err = router.Refresh()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to refresh router: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// After refresh, route should be found
|
||||||
|
bytecode, found := router.GetBytecode("GET", "/new", ¶ms)
|
||||||
|
if !found {
|
||||||
|
t.Errorf("New route should be found after refresh")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bytecode) == 0 {
|
||||||
|
t.Errorf("Expected non-empty bytecode for new route")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidRoutesDir(t *testing.T) {
|
||||||
|
// Non-existent directory
|
||||||
|
_, err := NewLuaRouter("/non/existent/directory")
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for non-existent directory, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a file instead of a directory
|
||||||
|
tmpFile, err := os.CreateTemp("", "fsrouter-test-file")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create temp file: %v", err)
|
||||||
|
}
|
||||||
|
defer os.Remove(tmpFile.Name())
|
||||||
|
defer tmpFile.Close()
|
||||||
|
|
||||||
|
_, err = NewLuaRouter(tmpFile.Name())
|
||||||
|
if err == nil {
|
||||||
|
t.Error("Expected error for file as routes dir, got nil")
|
||||||
|
}
|
||||||
|
}
|
7
go.mod
Normal file
7
go.mod
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
module git.sharkk.net/Sky/Moonshark
|
||||||
|
|
||||||
|
go 1.24.1
|
||||||
|
|
||||||
|
require git.sharkk.net/Sky/LuaJIT-to-Go v0.0.0
|
||||||
|
|
||||||
|
replace git.sharkk.net/Sky/LuaJIT-to-Go => ./luajit
|
7
moonshark.go
Normal file
7
moonshark.go
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import "fmt"
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
fmt.Println("Hello, world!")
|
||||||
|
}
|
Loading…
x
Reference in New Issue
Block a user