From 1a9abde11c15de2662759631c43656d055fe987b Mon Sep 17 00:00:00 2001 From: dimiro1 Date: Sun, 14 Dec 2025 22:26:34 +0100 Subject: [PATCH 1/2] Reorganize internal packages into services/ and runtime/ - Move service interfaces (ai, email, env, http, kv, logger) to internal/services/ - Rename stdlib to runtime for language-agnostic utilities - Extract business logic from Lua wrappers into runtime packages - Add unit tests for all runtime packages --- cmd/main.go | 12 +- e2e/helpers_test.go | 8 +- frontend/js/components/command-palette.js | 7 +- internal/api/handlers.go | 12 +- internal/api/server.go | 12 +- internal/api/server_test.go | 8 +- internal/runner/lua_ai.go | 138 +++------ internal/runner/lua_ai_test.go | 10 +- internal/runner/lua_base64.go | 18 +- internal/runner/lua_crypto.go | 65 ++-- internal/runner/lua_email.go | 284 +++++++----------- internal/runner/lua_email_test.go | 10 +- internal/runner/lua_env.go | 2 +- internal/runner/lua_http.go | 2 +- internal/runner/lua_json.go | 26 +- internal/runner/lua_kv.go | 2 +- internal/runner/lua_logger.go | 2 +- internal/runner/lua_random.go | 80 +---- internal/runner/lua_router.go | 100 ++---- internal/runner/lua_router_test.go | 9 +- internal/runner/lua_strings.go | 60 ++-- internal/runner/lua_time.go | 36 +-- internal/runner/lua_url.go | 63 ++-- internal/runner/runner.go | 12 +- internal/runner/runner_test.go | 8 +- internal/runtime/ai/doc.go | 3 + internal/runtime/ai/tracked.go | 93 ++++++ internal/runtime/ai/tracked_test.go | 202 +++++++++++++ internal/runtime/base64/base64.go | 18 ++ internal/runtime/base64/base64_test.go | 84 ++++++ internal/runtime/base64/doc.go | 2 + internal/runtime/crypto/crypto.go | 66 ++++ internal/runtime/crypto/crypto_test.go | 131 ++++++++ internal/runtime/crypto/doc.go | 2 + internal/runtime/doc.go | 10 + internal/runtime/email/doc.go | 3 + internal/runtime/email/tracked.go | 98 ++++++ internal/runtime/email/tracked_test.go | 226 ++++++++++++++ internal/runtime/email/validation.go | 24 ++ internal/runtime/email/validation_test.go | 138 +++++++++ internal/runtime/json/doc.go | 2 + internal/runtime/json/json.go | 28 ++ internal/runtime/json/json_test.go | 115 +++++++ internal/runtime/random/doc.go | 3 + internal/runtime/random/random.go | 86 ++++++ internal/runtime/random/random_test.go | 177 +++++++++++ internal/runtime/router/doc.go | 3 + internal/runtime/router/router.go | 87 ++++++ internal/runtime/router/router_test.go | 153 ++++++++++ internal/runtime/strings/doc.go | 2 + internal/runtime/strings/strings.go | 64 ++++ internal/runtime/strings/strings_test.go | 256 ++++++++++++++++ internal/runtime/time/doc.go | 2 + internal/runtime/time/time.go | 42 +++ internal/runtime/time/time_test.go | 148 +++++++++ internal/runtime/url/doc.go | 2 + internal/runtime/url/url.go | 56 ++++ internal/runtime/url/url_test.go | 203 +++++++++++++ internal/{ => services}/ai/ai.go | 4 +- internal/{ => services}/ai/ai_test.go | 4 +- internal/{ => services}/ai/anthropic.go | 2 +- internal/{ => services}/ai/doc.go | 0 internal/{ => services}/ai/openai.go | 2 +- internal/{ => services}/ai/tracker.go | 0 internal/{ => services}/ai/tracker_test.go | 0 internal/{ => services}/email/doc.go | 0 internal/{ => services}/email/email.go | 2 +- internal/{ => services}/email/email_test.go | 0 internal/{ => services}/email/tracker.go | 0 internal/{ => services}/email/tracker_test.go | 0 internal/{ => services}/env/doc.go | 0 internal/{ => services}/env/env.go | 0 internal/{ => services}/env/env_test.go | 0 internal/{ => services}/http/doc.go | 0 internal/{ => services}/http/http_client.go | 0 .../{ => services}/http/http_client_test.go | 0 internal/{ => services}/kv/doc.go | 0 internal/{ => services}/kv/kv.go | 0 internal/{ => services}/kv/kv_test.go | 0 internal/{ => services}/logger/doc.go | 0 internal/{ => services}/logger/logger.go | 0 internal/{ => services}/logger/logger_test.go | 0 82 files changed, 2891 insertions(+), 638 deletions(-) create mode 100644 internal/runtime/ai/doc.go create mode 100644 internal/runtime/ai/tracked.go create mode 100644 internal/runtime/ai/tracked_test.go create mode 100644 internal/runtime/base64/base64.go create mode 100644 internal/runtime/base64/base64_test.go create mode 100644 internal/runtime/base64/doc.go create mode 100644 internal/runtime/crypto/crypto.go create mode 100644 internal/runtime/crypto/crypto_test.go create mode 100644 internal/runtime/crypto/doc.go create mode 100644 internal/runtime/doc.go create mode 100644 internal/runtime/email/doc.go create mode 100644 internal/runtime/email/tracked.go create mode 100644 internal/runtime/email/tracked_test.go create mode 100644 internal/runtime/email/validation.go create mode 100644 internal/runtime/email/validation_test.go create mode 100644 internal/runtime/json/doc.go create mode 100644 internal/runtime/json/json.go create mode 100644 internal/runtime/json/json_test.go create mode 100644 internal/runtime/random/doc.go create mode 100644 internal/runtime/random/random.go create mode 100644 internal/runtime/random/random_test.go create mode 100644 internal/runtime/router/doc.go create mode 100644 internal/runtime/router/router.go create mode 100644 internal/runtime/router/router_test.go create mode 100644 internal/runtime/strings/doc.go create mode 100644 internal/runtime/strings/strings.go create mode 100644 internal/runtime/strings/strings_test.go create mode 100644 internal/runtime/time/doc.go create mode 100644 internal/runtime/time/time.go create mode 100644 internal/runtime/time/time_test.go create mode 100644 internal/runtime/url/doc.go create mode 100644 internal/runtime/url/url.go create mode 100644 internal/runtime/url/url_test.go rename internal/{ => services}/ai/ai.go (97%) rename internal/{ => services}/ai/ai_test.go (98%) rename internal/{ => services}/ai/anthropic.go (97%) rename internal/{ => services}/ai/doc.go (100%) rename internal/{ => services}/ai/openai.go (96%) rename internal/{ => services}/ai/tracker.go (100%) rename internal/{ => services}/ai/tracker_test.go (100%) rename internal/{ => services}/email/doc.go (100%) rename internal/{ => services}/email/email.go (98%) rename internal/{ => services}/email/email_test.go (100%) rename internal/{ => services}/email/tracker.go (100%) rename internal/{ => services}/email/tracker_test.go (100%) rename internal/{ => services}/env/doc.go (100%) rename internal/{ => services}/env/env.go (100%) rename internal/{ => services}/env/env_test.go (100%) rename internal/{ => services}/http/doc.go (100%) rename internal/{ => services}/http/http_client.go (100%) rename internal/{ => services}/http/http_client_test.go (100%) rename internal/{ => services}/kv/doc.go (100%) rename internal/{ => services}/kv/kv.go (100%) rename internal/{ => services}/kv/kv_test.go (100%) rename internal/{ => services}/logger/doc.go (100%) rename internal/{ => services}/logger/logger.go (100%) rename internal/{ => services}/logger/logger_test.go (100%) diff --git a/cmd/main.go b/cmd/main.go index ef3b404..56847d0 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -11,15 +11,15 @@ import ( "time" "github.com/dimiro1/lunar/frontend" - "github.com/dimiro1/lunar/internal/ai" + "github.com/dimiro1/lunar/internal/services/ai" "github.com/dimiro1/lunar/internal/api" internalcron "github.com/dimiro1/lunar/internal/cron" - "github.com/dimiro1/lunar/internal/email" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" "github.com/dimiro1/lunar/internal/housekeeping" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" "github.com/dimiro1/lunar/internal/migrate" store "github.com/dimiro1/lunar/internal/store" _ "modernc.org/sqlite" diff --git a/e2e/helpers_test.go b/e2e/helpers_test.go index c485503..c70fb2d 100644 --- a/e2e/helpers_test.go +++ b/e2e/helpers_test.go @@ -12,10 +12,10 @@ import ( "github.com/chromedp/chromedp" "github.com/dimiro1/lunar/frontend" "github.com/dimiro1/lunar/internal/api" - "github.com/dimiro1/lunar/internal/env" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + "github.com/dimiro1/lunar/internal/services/env" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" "github.com/dimiro1/lunar/internal/migrate" "github.com/dimiro1/lunar/internal/store" _ "modernc.org/sqlite" diff --git a/frontend/js/components/command-palette.js b/frontend/js/components/command-palette.js index 93a20a5..fdb65fd 100644 --- a/frontend/js/components/command-palette.js +++ b/frontend/js/components/command-palette.js @@ -8,7 +8,7 @@ import { paths } from "../routes.js"; import { i18n, localeNames, t } from "../i18n/index.js"; /** - * @typedef {import('../types.js').LunarFunction} lunarFunction + * @typedef {import('../types.js').LunarFunction} LunarFunction */ /** @@ -419,7 +419,10 @@ export const CommandPalette = { ]), item.type === "function" && item.disabled && - m("span.command-palette__item-badge", t("common.disabled")), + m( + "span.command-palette__item-badge", + t("common.disabled"), + ), ], ) ), diff --git a/internal/api/handlers.go b/internal/api/handlers.go index e14a4ea..ca02136 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -9,15 +9,15 @@ import ( "strings" "time" - "github.com/dimiro1/lunar/internal/ai" + "github.com/dimiro1/lunar/internal/services/ai" internalcron "github.com/dimiro1/lunar/internal/cron" "github.com/dimiro1/lunar/internal/diff" - "github.com/dimiro1/lunar/internal/email" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" "github.com/dimiro1/lunar/internal/events" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" "github.com/dimiro1/lunar/internal/masking" "github.com/dimiro1/lunar/internal/runner" "github.com/dimiro1/lunar/internal/store" diff --git a/internal/api/server.go b/internal/api/server.go index 6e1e9ff..9c48828 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -5,13 +5,13 @@ import ( "net/http" "time" - "github.com/dimiro1/lunar/internal/ai" + "github.com/dimiro1/lunar/internal/services/ai" internalcron "github.com/dimiro1/lunar/internal/cron" - "github.com/dimiro1/lunar/internal/email" - "github.com/dimiro1/lunar/internal/env" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" "github.com/dimiro1/lunar/internal/store" ) diff --git a/internal/api/server_test.go b/internal/api/server_test.go index e83b411..b82f5c4 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -9,10 +9,10 @@ import ( "strings" "testing" - "github.com/dimiro1/lunar/internal/env" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + "github.com/dimiro1/lunar/internal/services/env" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" "github.com/dimiro1/lunar/internal/store" ) diff --git a/internal/runner/lua_ai.go b/internal/runner/lua_ai.go index 52ddab7..467bcfb 100644 --- a/internal/runner/lua_ai.go +++ b/internal/runner/lua_ai.go @@ -1,87 +1,40 @@ package runner import ( - "time" - - "github.com/dimiro1/lunar/internal/ai" - "github.com/dimiro1/lunar/internal/store" + "github.com/dimiro1/lunar/internal/services/ai" + stdlibai "github.com/dimiro1/lunar/internal/runtime/ai" lua "github.com/yuin/gopher-lua" ) -// registerAI creates the global 'ai' table with AI provider functions +// registerAI creates the global 'ai' table with AI provider functions. +// This is a thin wrapper using the stdlib/ai TrackedClient decorator. func registerAI(L *lua.LState, client ai.Client, functionID string, tracker ai.Tracker, executionID string) { + trackedClient := stdlibai.NewTrackedClient(client, tracker, executionID) + aiTable := L.NewTable() // ai.chat(options) L.SetField(aiTable, "chat", L.NewFunction(func(L *lua.LState) int { options := L.CheckTable(1) - // Extract required parameters - provider := lua.LVAsString(options.RawGetString("provider")) - model := lua.LVAsString(options.RawGetString("model")) - messagesLV := options.RawGetString("messages") - - // Validate required parameters - if provider == "" { - L.Push(lua.LNil) - L.Push(lua.LString("provider is required (openai or anthropic)")) - return 2 - } - if model == "" { - L.Push(lua.LNil) - L.Push(lua.LString("model is required")) - return 2 - } - if messagesLV.Type() != lua.LTTable { - L.Push(lua.LNil) - L.Push(lua.LString("messages is required and must be a table")) - return 2 - } - - // Convert messages from Lua to Go - messages := luaMessagesToGo(messagesLV.(*lua.LTable)) - if len(messages) == 0 { + // Extract and validate parameters + req, errMsg := parseAIChatRequest(options) + if errMsg != "" { L.Push(lua.LNil) - L.Push(lua.LString("messages cannot be empty")) + L.Push(lua.LString(errMsg)) return 2 } - // Extract optional parameters - maxTokens := int(lua.LVAsNumber(options.RawGetString("max_tokens"))) - temperature := lua.LVAsNumber(options.RawGetString("temperature")) - endpoint := lua.LVAsString(options.RawGetString("endpoint")) + // Execute with automatic tracking via decorator + result := trackedClient.ChatWithTracking(functionID, req) - // Set defaults for optional parameters - if maxTokens == 0 { - maxTokens = 1024 - } - - // Build chat request - req := ai.ChatRequest{ - Provider: provider, - Model: model, - Messages: messages, - MaxTokens: maxTokens, - Temperature: float64(temperature), - Endpoint: endpoint, - } - - // Execute the request with tracking - response, trackReq := executeWithTracking(client, functionID, req) - - // Track the request (success or error) - if tracker != nil { - tracker.Track(executionID, trackReq) - } - - if trackReq.Status == store.AIRequestStatusError { + if result.Error != nil { L.Push(lua.LNil) - L.Push(lua.LString(*trackReq.ErrorMessage)) + L.Push(lua.LString(result.Error.Error())) return 2 } - // Convert response to Lua table - L.Push(aiResponseToLuaTable(L, response)) + L.Push(aiResponseToLuaTable(L, result.Response)) L.Push(lua.LNil) return 2 })) @@ -89,38 +42,47 @@ func registerAI(L *lua.LState, client ai.Client, functionID string, tracker ai.T L.SetGlobal("ai", aiTable) } -// executeWithTracking executes an AI chat request and returns tracking info -func executeWithTracking(client ai.Client, functionID string, req ai.ChatRequest) (*ai.ChatResponse, ai.TrackRequest) { - trackReq := ai.TrackRequest{ - Provider: req.Provider, - Model: req.Model, - } +// parseAIChatRequest extracts ai.ChatRequest from Lua options table +func parseAIChatRequest(options *lua.LTable) (ai.ChatRequest, string) { + provider := lua.LVAsString(options.RawGetString("provider")) + model := lua.LVAsString(options.RawGetString("model")) + messagesLV := options.RawGetString("messages") - startTime := time.Now() - response, err := client.Chat(functionID, req) - trackReq.DurationMs = time.Since(startTime).Milliseconds() - - // Capture tracking info from response even on error (if available) - if response != nil { - trackReq.Endpoint = response.Endpoint - trackReq.RequestJSON = response.RequestJSON - if response.ResponseJSON != "" { - trackReq.ResponseJSON = &response.ResponseJSON - } + // Validate required parameters + if provider == "" { + return ai.ChatRequest{}, "provider is required (openai or anthropic)" + } + if model == "" { + return ai.ChatRequest{}, "model is required" + } + if messagesLV.Type() != lua.LTTable { + return ai.ChatRequest{}, "messages is required and must be a table" } - if err != nil { - errMsg := err.Error() - trackReq.Status = store.AIRequestStatusError - trackReq.ErrorMessage = &errMsg - return nil, trackReq + // Convert messages from Lua to Go + messages := luaMessagesToGo(messagesLV.(*lua.LTable)) + if len(messages) == 0 { + return ai.ChatRequest{}, "messages cannot be empty" } - trackReq.Status = store.AIRequestStatusSuccess - trackReq.InputTokens = &response.Usage.InputTokens - trackReq.OutputTokens = &response.Usage.OutputTokens + // Extract optional parameters + maxTokens := int(lua.LVAsNumber(options.RawGetString("max_tokens"))) + temperature := lua.LVAsNumber(options.RawGetString("temperature")) + endpoint := lua.LVAsString(options.RawGetString("endpoint")) + + // Set defaults for optional parameters + if maxTokens == 0 { + maxTokens = 1024 + } - return response, trackReq + return ai.ChatRequest{ + Provider: provider, + Model: model, + Messages: messages, + MaxTokens: maxTokens, + Temperature: float64(temperature), + Endpoint: endpoint, + }, "" } // luaMessagesToGo converts a Lua table of messages to Go diff --git a/internal/runner/lua_ai_test.go b/internal/runner/lua_ai_test.go index 496b4a3..01c61e9 100644 --- a/internal/runner/lua_ai_test.go +++ b/internal/runner/lua_ai_test.go @@ -10,12 +10,12 @@ import ( "testing" "time" - "github.com/dimiro1/lunar/internal/ai" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/ai" + "github.com/dimiro1/lunar/internal/services/env" "github.com/dimiro1/lunar/internal/events" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" ) func TestRun_AI_OpenAI_Success(t *testing.T) { diff --git a/internal/runner/lua_base64.go b/internal/runner/lua_base64.go index acfc98b..4d458a7 100644 --- a/internal/runner/lua_base64.go +++ b/internal/runner/lua_base64.go @@ -1,40 +1,32 @@ package runner import ( - "encoding/base64" - + stdlibbase64 "github.com/dimiro1/lunar/internal/runtime/base64" lua "github.com/yuin/gopher-lua" ) -// registerBase64 registers the base64 module with encode/decode functions +// registerBase64 registers the base64 module with encode/decode functions. +// This is a thin wrapper around the stdlib/base64 package. func registerBase64(L *lua.LState) { base64Module := L.NewTable() - // Register base64.encode function L.SetField(base64Module, "encode", L.NewFunction(base64Encode)) - - // Register base64.decode function L.SetField(base64Module, "decode", L.NewFunction(base64Decode)) - // Set the base64 module as a global L.SetGlobal("base64", base64Module) } // base64Encode encodes a string to base64 // Usage: local encoded = base64.encode(str) func base64Encode(L *lua.LState) int { - str := L.CheckString(1) - encoded := base64.StdEncoding.EncodeToString([]byte(str)) - L.Push(lua.LString(encoded)) + L.Push(lua.LString(stdlibbase64.Encode(L.CheckString(1)))) return 1 } // base64Decode decodes a base64 string // Usage: local decoded, err = base64.decode(str) func base64Decode(L *lua.LState) int { - str := L.CheckString(1) - - decoded, err := base64.StdEncoding.DecodeString(str) + decoded, err := stdlibbase64.Decode(L.CheckString(1)) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) diff --git a/internal/runner/lua_crypto.go b/internal/runner/lua_crypto.go index 9684513..783e7c2 100644 --- a/internal/runner/lua_crypto.go +++ b/internal/runner/lua_crypto.go @@ -1,19 +1,12 @@ package runner import ( - "crypto/hmac" - "crypto/md5" - "crypto/sha1" - "crypto/sha256" - "crypto/sha512" - "encoding/hex" - "hash" - - "github.com/google/uuid" + "github.com/dimiro1/lunar/internal/runtime/crypto" lua "github.com/yuin/gopher-lua" ) -// registerCrypto registers the crypto module with hashing and UUID functions +// registerCrypto registers the crypto module with hashing and UUID functions. +// This is a thin wrapper around the stdlib/crypto package. func registerCrypto(L *lua.LState) { cryptoModule := L.NewTable() @@ -31,83 +24,61 @@ func registerCrypto(L *lua.LState) { // UUID function L.SetField(cryptoModule, "uuid", L.NewFunction(cryptoUUID)) - // Set the crypto module as a global L.SetGlobal("crypto", cryptoModule) } // cryptoMD5 computes MD5 hash of a string // Usage: local hash = crypto.md5(str) func cryptoMD5(L *lua.LState) int { - str := L.CheckString(1) - return hashString(L, md5.New(), str) + L.Push(lua.LString(crypto.MD5(L.CheckString(1)))) + return 1 } // cryptoSHA1 computes SHA1 hash of a string // Usage: local hash = crypto.sha1(str) func cryptoSHA1(L *lua.LState) int { - str := L.CheckString(1) - return hashString(L, sha1.New(), str) + L.Push(lua.LString(crypto.SHA1(L.CheckString(1)))) + return 1 } // cryptoSHA256 computes SHA256 hash of a string // Usage: local hash = crypto.sha256(str) func cryptoSHA256(L *lua.LState) int { - str := L.CheckString(1) - return hashString(L, sha256.New(), str) + L.Push(lua.LString(crypto.SHA256(L.CheckString(1)))) + return 1 } // cryptoSHA512 computes SHA512 hash of a string // Usage: local hash = crypto.sha512(str) func cryptoSHA512(L *lua.LState) int { - str := L.CheckString(1) - return hashString(L, sha512.New(), str) + L.Push(lua.LString(crypto.SHA512(L.CheckString(1)))) + return 1 } // cryptoHMACSHA1 computes HMAC-SHA1 of a message with a secret key // Usage: local hash = crypto.hmac_sha1(message, key) func cryptoHMACSHA1(L *lua.LState) int { - message := L.CheckString(1) - key := L.CheckString(2) - return hmacString(L, sha1.New, message, key) + L.Push(lua.LString(crypto.HMACSHA1(L.CheckString(1), L.CheckString(2)))) + return 1 } // cryptoHMACSHA256 computes HMAC-SHA256 of a message with a secret key // Usage: local hash = crypto.hmac_sha256(message, key) func cryptoHMACSHA256(L *lua.LState) int { - message := L.CheckString(1) - key := L.CheckString(2) - return hmacString(L, sha256.New, message, key) + L.Push(lua.LString(crypto.HMACSHA256(L.CheckString(1), L.CheckString(2)))) + return 1 } // cryptoHMACSHA512 computes HMAC-SHA512 of a message with a secret key // Usage: local hash = crypto.hmac_sha512(message, key) func cryptoHMACSHA512(L *lua.LState) int { - message := L.CheckString(1) - key := L.CheckString(2) - return hmacString(L, sha512.New, message, key) + L.Push(lua.LString(crypto.HMACSHA512(L.CheckString(1), L.CheckString(2)))) + return 1 } // cryptoUUID generates a new UUID v4 // Usage: local id = crypto.uuid() func cryptoUUID(L *lua.LState) int { - id := uuid.New() - L.Push(lua.LString(id.String())) - return 1 -} - -// hashString is a helper function to compute hash of a string -func hashString(L *lua.LState, h hash.Hash, str string) int { - h.Write([]byte(str)) - hashBytes := h.Sum(nil) - L.Push(lua.LString(hex.EncodeToString(hashBytes))) - return 1 -} - -// hmacString is a helper function to compute HMAC of a string -func hmacString(L *lua.LState, hashFunc func() hash.Hash, message, key string) int { - h := hmac.New(hashFunc, []byte(key)) - h.Write([]byte(message)) - hashBytes := h.Sum(nil) - L.Push(lua.LString(hex.EncodeToString(hashBytes))) + L.Push(lua.LString(crypto.UUID())) return 1 } diff --git a/internal/runner/lua_email.go b/internal/runner/lua_email.go index df56d28..5febb0d 100644 --- a/internal/runner/lua_email.go +++ b/internal/runner/lua_email.go @@ -1,200 +1,53 @@ package runner import ( - "encoding/json" "time" - "github.com/dimiro1/lunar/internal/email" - "github.com/dimiro1/lunar/internal/store" + "github.com/dimiro1/lunar/internal/services/email" + stdlibemail "github.com/dimiro1/lunar/internal/runtime/email" lua "github.com/yuin/gopher-lua" ) -// registerEmail creates the global 'email' table with email sending functions +// registerEmail creates the global 'email' table with email sending functions. +// This is a thin wrapper using the stdlib/email TrackedClient decorator. func registerEmail(L *lua.LState, emailClient email.Client, functionID string, emailTracker email.Tracker, executionID string) { + // Create tracked client (decorator pattern) + trackedClient := stdlibemail.NewTrackedClient(emailClient, emailTracker, executionID) + emailTable := L.NewTable() // email.send(options) L.SetField(emailTable, "send", L.NewFunction(func(L *lua.LState) int { options := L.CheckTable(1) - // Extract required parameters - from := lua.LVAsString(options.RawGetString("from")) - toLV := options.RawGetString("to") - subject := lua.LVAsString(options.RawGetString("subject")) - - // Validate required parameters - if from == "" { - L.Push(lua.LNil) - L.Push(lua.LString("from is required")) - return 2 - } - if toLV.Type() == lua.LTNil { - L.Push(lua.LNil) - L.Push(lua.LString("to is required")) - return 2 - } - if subject == "" { - L.Push(lua.LNil) - L.Push(lua.LString("subject is required")) - return 2 - } - - // Convert 'to' to slice - can be string or table - var to []string - if toLV.Type() == lua.LTString { - to = []string{lua.LVAsString(toLV)} - } else if toLV.Type() == lua.LTTable { - to = luaTableToStringSlice(toLV.(*lua.LTable)) - } else { + // Parse request from Lua options + req, err := parseEmailSendRequest(options) + if err != "" { L.Push(lua.LNil) - L.Push(lua.LString("to must be a string or table of strings")) + L.Push(lua.LString(err)) return 2 } - if len(to) == 0 { + // Validate using reusable validation + if validationErr := stdlibemail.ValidateSendRequest(req); validationErr != nil { L.Push(lua.LNil) - L.Push(lua.LString("to cannot be empty")) + L.Push(lua.LString(validationErr.Error())) return 2 } - // Extract optional parameters - text := lua.LVAsString(options.RawGetString("text")) - html := lua.LVAsString(options.RawGetString("html")) - replyTo := lua.LVAsString(options.RawGetString("reply_to")) - - // Handle scheduled_at - accepts Unix timestamp (number) or ISO 8601 string - var scheduledAt string - scheduledAtLV := options.RawGetString("scheduled_at") - if scheduledAtLV.Type() == lua.LTNumber { - // Convert Unix timestamp to ISO 8601 - ts := int64(lua.LVAsNumber(scheduledAtLV)) - scheduledAt = time.Unix(ts, 0).UTC().Format(time.RFC3339) - } else if scheduledAtLV.Type() == lua.LTString { - scheduledAt = lua.LVAsString(scheduledAtLV) - } - - // At least text or html must be provided - if text == "" && html == "" { - L.Push(lua.LNil) - L.Push(lua.LString("either text or html content is required")) - return 2 - } - - // Convert optional cc and bcc - var cc, bcc []string - ccLV := options.RawGetString("cc") - if ccLV.Type() == lua.LTString { - cc = []string{lua.LVAsString(ccLV)} - } else if ccLV.Type() == lua.LTTable { - cc = luaTableToStringSlice(ccLV.(*lua.LTable)) - } - - bccLV := options.RawGetString("bcc") - if bccLV.Type() == lua.LTString { - bcc = []string{lua.LVAsString(bccLV)} - } else if bccLV.Type() == lua.LTTable { - bcc = luaTableToStringSlice(bccLV.(*lua.LTable)) - } - - // Convert optional headers - var headers map[string]string - headersLV := options.RawGetString("headers") - if headersLV.Type() == lua.LTTable { - headers = make(map[string]string) - headersLV.(*lua.LTable).ForEach(func(k, v lua.LValue) { - headers[lua.LVAsString(k)] = lua.LVAsString(v) - }) - } - - // Convert optional tags - var tags []email.Tag - tagsLV := options.RawGetString("tags") - if tagsLV.Type() == lua.LTTable { - tagsLV.(*lua.LTable).ForEach(func(_, v lua.LValue) { - if tagTbl, ok := v.(*lua.LTable); ok { - tag := email.Tag{ - Name: lua.LVAsString(tagTbl.RawGetString("name")), - Value: lua.LVAsString(tagTbl.RawGetString("value")), - } - if tag.Name != "" { - tags = append(tags, tag) - } - } - }) - } + // Send with automatic tracking via decorator + result := trackedClient.SendWithTracking(functionID, req) - // Build the send request - req := email.SendRequest{ - From: from, - To: to, - Subject: subject, - Text: text, - HTML: html, - ReplyTo: replyTo, - Cc: cc, - Bcc: bcc, - Headers: headers, - Tags: tags, - ScheduledAt: scheduledAt, - } - - // Send the email - startTime := time.Now() - resp, err := emailClient.Send(functionID, req) - durationMs := time.Since(startTime).Milliseconds() - - // Get request JSON for tracking (available even on error) - var requestJSON string - if resp != nil { - requestJSON = resp.RequestJSON - } - - if err != nil { - // Track failed request - errMsg := err.Error() - if emailTracker != nil { - emailTracker.Track(executionID, email.TrackRequest{ - From: from, - To: to, - Subject: subject, - HasText: text != "", - HasHTML: html != "", - RequestJSON: requestJSON, - Status: store.EmailRequestStatusError, - ErrorMessage: &errMsg, - DurationMs: durationMs, - }) - } + if result.Error != nil { L.Push(lua.LNil) - L.Push(lua.LString(err.Error())) + L.Push(lua.LString(result.Error.Error())) return 2 } - // Build response JSON - responseJSON, _ := json.Marshal(map[string]string{"id": resp.ID}) - responseJSONStr := string(responseJSON) - - // Track successful request - if emailTracker != nil { - emailTracker.Track(executionID, email.TrackRequest{ - From: from, - To: to, - Subject: subject, - HasText: text != "", - HasHTML: html != "", - RequestJSON: requestJSON, - ResponseJSON: &responseJSONStr, - Status: store.EmailRequestStatusSuccess, - EmailID: &resp.ID, - DurationMs: durationMs, - }) - } - // Convert response to Lua table - result := L.NewTable() - L.SetField(result, "id", lua.LString(resp.ID)) - - L.Push(result) + resultTbl := L.NewTable() + L.SetField(resultTbl, "id", lua.LString(result.Response.ID)) + L.Push(resultTbl) L.Push(lua.LNil) return 2 })) @@ -202,6 +55,101 @@ func registerEmail(L *lua.LState, emailClient email.Client, functionID string, e L.SetGlobal("email", emailTable) } +// parseEmailSendRequest extracts email.SendRequest from Lua options table +func parseEmailSendRequest(options *lua.LTable) (email.SendRequest, string) { + from := lua.LVAsString(options.RawGetString("from")) + toLV := options.RawGetString("to") + subject := lua.LVAsString(options.RawGetString("subject")) + text := lua.LVAsString(options.RawGetString("text")) + html := lua.LVAsString(options.RawGetString("html")) + replyTo := lua.LVAsString(options.RawGetString("reply_to")) + + // Convert 'to' to slice - can be string or table + var to []string + switch toLV.Type() { + case lua.LTString: + to = []string{lua.LVAsString(toLV)} + case lua.LTTable: + to = luaTableToStringSlice(toLV.(*lua.LTable)) + if len(to) == 0 { + return email.SendRequest{}, "to cannot be empty" + } + case lua.LTNil: + return email.SendRequest{}, "to is required" + default: + return email.SendRequest{}, "to must be a string or table of strings" + } + + // Handle scheduled_at - accepts Unix timestamp (number) or ISO 8601 string + var scheduledAt string + scheduledAtLV := options.RawGetString("scheduled_at") + switch scheduledAtLV.Type() { + case lua.LTNumber: + // Convert Unix timestamp to ISO 8601 + ts := int64(lua.LVAsNumber(scheduledAtLV)) + scheduledAt = time.Unix(ts, 0).UTC().Format(time.RFC3339) + case lua.LTString: + scheduledAt = lua.LVAsString(scheduledAtLV) + } + + // Convert optional cc and bcc + cc := extractStringSliceFromLua(options.RawGetString("cc")) + bcc := extractStringSliceFromLua(options.RawGetString("bcc")) + + // Convert optional headers + var headers map[string]string + headersLV := options.RawGetString("headers") + if headersLV.Type() == lua.LTTable { + headers = make(map[string]string) + headersLV.(*lua.LTable).ForEach(func(k, v lua.LValue) { + headers[lua.LVAsString(k)] = lua.LVAsString(v) + }) + } + + // Convert optional tags + var tags []email.Tag + tagsLV := options.RawGetString("tags") + if tagsLV.Type() == lua.LTTable { + tagsLV.(*lua.LTable).ForEach(func(_, v lua.LValue) { + if tagTbl, ok := v.(*lua.LTable); ok { + tag := email.Tag{ + Name: lua.LVAsString(tagTbl.RawGetString("name")), + Value: lua.LVAsString(tagTbl.RawGetString("value")), + } + if tag.Name != "" { + tags = append(tags, tag) + } + } + }) + } + + return email.SendRequest{ + From: from, + To: to, + Subject: subject, + Text: text, + HTML: html, + ReplyTo: replyTo, + Cc: cc, + Bcc: bcc, + Headers: headers, + Tags: tags, + ScheduledAt: scheduledAt, + }, "" +} + +// extractStringSliceFromLua extracts a string slice from a Lua value +func extractStringSliceFromLua(lv lua.LValue) []string { + switch lv.Type() { + case lua.LTString: + return []string{lua.LVAsString(lv)} + case lua.LTTable: + return luaTableToStringSlice(lv.(*lua.LTable)) + default: + return nil + } +} + // luaTableToStringSlice converts a Lua table to a slice of strings func luaTableToStringSlice(tbl *lua.LTable) []string { var result []string diff --git a/internal/runner/lua_email_test.go b/internal/runner/lua_email_test.go index 7d0a383..b4471e7 100644 --- a/internal/runner/lua_email_test.go +++ b/internal/runner/lua_email_test.go @@ -9,12 +9,12 @@ import ( "testing" "time" - "github.com/dimiro1/lunar/internal/email" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" "github.com/dimiro1/lunar/internal/events" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" ) func TestRun_Email_MissingFrom(t *testing.T) { diff --git a/internal/runner/lua_env.go b/internal/runner/lua_env.go index 353310a..789b6be 100644 --- a/internal/runner/lua_env.go +++ b/internal/runner/lua_env.go @@ -1,7 +1,7 @@ package runner import ( - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/env" lua "github.com/yuin/gopher-lua" ) diff --git a/internal/runner/lua_http.go b/internal/runner/lua_http.go index 04ab06c..83c9732 100644 --- a/internal/runner/lua_http.go +++ b/internal/runner/lua_http.go @@ -1,7 +1,7 @@ package runner import ( - internalhttp "github.com/dimiro1/lunar/internal/http" + internalhttp "github.com/dimiro1/lunar/internal/services/http" lua "github.com/yuin/gopher-lua" ) diff --git a/internal/runner/lua_json.go b/internal/runner/lua_json.go index e2faffe..ce07a15 100644 --- a/internal/runner/lua_json.go +++ b/internal/runner/lua_json.go @@ -1,22 +1,18 @@ package runner import ( - "encoding/json" - + stdlibjson "github.com/dimiro1/lunar/internal/runtime/json" lua "github.com/yuin/gopher-lua" ) -// registerJSON registers the json module with encode/decode functions +// registerJSON registers the json module with encode/decode functions. +// This is a thin wrapper around the stdlib/json package. func registerJSON(L *lua.LState) { jsonModule := L.NewTable() - // Register json.encode function L.SetField(jsonModule, "encode", L.NewFunction(jsonEncode)) - - // Register json.decode function L.SetField(jsonModule, "decode", L.NewFunction(jsonDecode)) - // Set the json module as a global L.SetGlobal("json", jsonModule) } @@ -28,15 +24,15 @@ func jsonEncode(L *lua.LState) int { // Convert Lua value to Go value goValue := luaValueToGo(L, value) - // Marshal to JSON - jsonBytes, err := json.Marshal(goValue) + // Encode using stdlib + jsonStr, err := stdlibjson.Encode(goValue) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) return 2 } - L.Push(lua.LString(jsonBytes)) + L.Push(lua.LString(jsonStr)) return 1 } @@ -45,9 +41,9 @@ func jsonEncode(L *lua.LState) int { func jsonDecode(L *lua.LState) int { jsonStr := L.CheckString(1) - // Unmarshal JSON to Go value - var goValue any - if err := json.Unmarshal([]byte(jsonStr), &goValue); err != nil { + // Decode using stdlib + goValue, err := stdlibjson.Decode(jsonStr) + if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) return 2 @@ -59,7 +55,7 @@ func jsonDecode(L *lua.LState) int { return 1 } -// luaValueToGo converts a Lua value to a Go value +// luaValueToGo converts a Lua value to a Go value (Lua-specific converter) func luaValueToGo(L *lua.LState, lv lua.LValue) any { switch v := lv.(type) { case *lua.LNilType: @@ -107,7 +103,7 @@ func luaValueToGo(L *lua.LState, lv lua.LValue) any { } } -// goValueToLua converts a Go value to a Lua value +// goValueToLua converts a Go value to a Lua value (Lua-specific converter) func goValueToLua(L *lua.LState, v any) lua.LValue { switch val := v.(type) { case nil: diff --git a/internal/runner/lua_kv.go b/internal/runner/lua_kv.go index 3f22840..34af00f 100644 --- a/internal/runner/lua_kv.go +++ b/internal/runner/lua_kv.go @@ -1,7 +1,7 @@ package runner import ( - "github.com/dimiro1/lunar/internal/kv" + "github.com/dimiro1/lunar/internal/services/kv" lua "github.com/yuin/gopher-lua" ) diff --git a/internal/runner/lua_logger.go b/internal/runner/lua_logger.go index f7dd465..7c6780c 100644 --- a/internal/runner/lua_logger.go +++ b/internal/runner/lua_logger.go @@ -1,7 +1,7 @@ package runner import ( - "github.com/dimiro1/lunar/internal/logger" + "github.com/dimiro1/lunar/internal/services/logger" lua "github.com/yuin/gopher-lua" ) diff --git a/internal/runner/lua_random.go b/internal/runner/lua_random.go index 7ee087e..c383b95 100644 --- a/internal/runner/lua_random.go +++ b/internal/runner/lua_random.go @@ -1,21 +1,15 @@ package runner import ( - "crypto/rand" - "encoding/base64" - "encoding/hex" - "math/big" - mathrand "math/rand" - - "github.com/rs/xid" + "github.com/dimiro1/lunar/internal/runtime/random" lua "github.com/yuin/gopher-lua" ) -// registerRandom registers the random module with random generation functions +// registerRandom registers the random module with random generation functions. +// This is a thin wrapper around the stdlib/random package. func registerRandom(L *lua.LState) { randomModule := L.NewTable() - // Register random functions L.SetField(randomModule, "int", L.NewFunction(randomInt)) L.SetField(randomModule, "float", L.NewFunction(randomFloat)) L.SetField(randomModule, "string", L.NewFunction(randomString)) @@ -23,7 +17,6 @@ func registerRandom(L *lua.LState) { L.SetField(randomModule, "hex", L.NewFunction(randomHex)) L.SetField(randomModule, "id", L.NewFunction(randomID)) - // Set the random module as a global L.SetGlobal("random", randomModule) } @@ -33,22 +26,12 @@ func randomInt(L *lua.LState) int { minValue := L.CheckInt(1) maxValue := L.CheckInt(2) - if minValue > maxValue { - L.ArgError(1, "min must be less than or equal to max") - return 0 - } - - // Use crypto/rand for better randomness - rangeSize := int64(maxValue - minValue + 1) - n, err := rand.Int(rand.Reader, big.NewInt(rangeSize)) + result, err := random.Int(minValue, maxValue) if err != nil { - // Fallback to math/rand - result := mathrand.Intn(int(rangeSize)) + minValue - L.Push(lua.LNumber(result)) - return 1 + L.ArgError(1, err.Error()) + return 0 } - result := int(n.Int64()) + minValue L.Push(lua.LNumber(result)) return 1 } @@ -56,8 +39,7 @@ func randomInt(L *lua.LState) int { // randomFloat generates a random float between 0.0 and 1.0 // Usage: local num = random.float() func randomFloat(L *lua.LState) int { - result := mathrand.Float64() - L.Push(lua.LNumber(result)) + L.Push(lua.LNumber(random.Float())) return 1 } @@ -66,26 +48,13 @@ func randomFloat(L *lua.LState) int { func randomString(L *lua.LState) int { length := L.CheckInt(1) - if length <= 0 { - L.ArgError(1, "length must be positive") + result, err := random.String(length) + if err != nil { + L.ArgError(1, err.Error()) return 0 } - // Generate random bytes - bytes := make([]byte, length) - const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" - - for i := range bytes { - n, err := rand.Int(rand.Reader, big.NewInt(int64(len(charset)))) - if err != nil { - // Fallback to math/rand - bytes[i] = charset[mathrand.Intn(len(charset))] - } else { - bytes[i] = charset[n.Int64()] - } - } - - L.Push(lua.LString(string(bytes))) + L.Push(lua.LString(result)) return 1 } @@ -94,22 +63,14 @@ func randomString(L *lua.LState) int { func randomBytes(L *lua.LState) int { length := L.CheckInt(1) - if length <= 0 { - L.ArgError(1, "length must be positive") - return 0 - } - - bytes := make([]byte, length) - _, err := rand.Read(bytes) + result, err := random.Bytes(length) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) return 2 } - // Return as base64 encoded string - encoded := base64.StdEncoding.EncodeToString(bytes) - L.Push(lua.LString(encoded)) + L.Push(lua.LString(result)) return 1 } @@ -118,22 +79,14 @@ func randomBytes(L *lua.LState) int { func randomHex(L *lua.LState) int { length := L.CheckInt(1) - if length <= 0 { - L.ArgError(1, "length must be positive") - return 0 - } - - bytes := make([]byte, length) - _, err := rand.Read(bytes) + result, err := random.Hex(length) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) return 2 } - // Return as hex encoded string - hexStr := hex.EncodeToString(bytes) - L.Push(lua.LString(hexStr)) + L.Push(lua.LString(result)) return 1 } @@ -141,7 +94,6 @@ func randomHex(L *lua.LState) int { // Usage: local id = random.id() // Returns a 20-character string (smaller than UUID, sortable by creation time) func randomID(L *lua.LState) int { - id := xid.New() - L.Push(lua.LString(id.String())) + L.Push(lua.LString(random.ID())) return 1 } diff --git a/internal/runner/lua_router.go b/internal/runner/lua_router.go index 8d778be..2aeb3ad 100644 --- a/internal/runner/lua_router.go +++ b/internal/runner/lua_router.go @@ -1,12 +1,13 @@ package runner import ( - "strings" - "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/runtime/router" lua "github.com/yuin/gopher-lua" ) +// registerRouter registers the router module with path matching and building functions. +// This is a thin wrapper around the stdlib/router package. func registerRouter(L *lua.LState, ctx *events.ExecutionContext) { routerModule := L.NewTable() @@ -18,43 +19,35 @@ func registerRouter(L *lua.LState, ctx *events.ExecutionContext) { L.SetGlobal("router", routerModule) } +// makeRouterPath creates a function that builds paths for the current function func makeRouterPath(functionID string) lua.LGFunction { return func(L *lua.LState) int { pattern := L.CheckString(1) - var params map[string]string - if L.GetTop() >= 2 && L.Get(2).Type() == lua.LTTable { - params = luaTableToStringMap(L, L.CheckTable(2)) - } - fullPath := "/fn/" + functionID + buildPath(pattern, params) - L.Push(lua.LString(fullPath)) + params := extractStringParams(L, 2) + L.Push(lua.LString(router.FunctionPath(functionID, pattern, params))) return 1 } } +// makeRouterURL creates a function that builds URLs for the current function func makeRouterURL(functionID, baseURL string) lua.LGFunction { return func(L *lua.LState) int { pattern := L.CheckString(1) - var params map[string]string - if L.GetTop() >= 2 && L.Get(2).Type() == lua.LTTable { - params = luaTableToStringMap(L, L.CheckTable(2)) - } - fullURL := strings.TrimSuffix(baseURL, "/") + "/fn/" + functionID + buildPath(pattern, params) - L.Push(lua.LString(fullURL)) + params := extractStringParams(L, 2) + L.Push(lua.LString(router.FunctionURL(baseURL, functionID, pattern, params))) return 1 } } -func buildPath(pattern string, params map[string]string) string { - if len(params) == 0 { - return pattern +// extractStringParams extracts string parameters from the Lua stack +func extractStringParams(L *lua.LState, argIndex int) map[string]string { + if L.GetTop() < argIndex || L.Get(argIndex).Type() != lua.LTTable { + return nil } - result := pattern - for key, value := range params { - result = strings.ReplaceAll(result, ":"+key, value) - } - return result + return luaTableToStringMap(L, L.CheckTable(argIndex)) } +// luaTableToStringMap converts a Lua table to a map of strings func luaTableToStringMap(L *lua.LState, tbl *lua.LTable) map[string]string { result := make(map[string]string) tbl.ForEach(func(k, v lua.LValue) { @@ -65,24 +58,28 @@ func luaTableToStringMap(L *lua.LState, tbl *lua.LTable) map[string]string { return result } +// routerMatch checks if a path matches a pattern +// Usage: local matched = router.match(path, pattern) func routerMatch(L *lua.LState) int { path := L.CheckString(1) pattern := L.CheckString(2) - matched, _ := matchPath(path, pattern) - L.Push(lua.LBool(matched)) + result := router.Match(path, pattern) + L.Push(lua.LBool(result.Matched)) return 1 } +// routerParams extracts parameters from a path using a pattern +// Usage: local params = router.params(path, pattern) func routerParams(L *lua.LState) int { path := L.CheckString(1) pattern := L.CheckString(2) - matched, params := matchPath(path, pattern) + result := router.Match(path, pattern) paramsTable := L.NewTable() - if matched { - for key, value := range params { + if result.Matched { + for key, value := range result.Params { L.SetField(paramsTable, key, lua.LString(value)) } } @@ -90,52 +87,3 @@ func routerParams(L *lua.LState) int { L.Push(paramsTable) return 1 } - -// matchPath matches a path against a pattern. Syntax: :name for params, * for wildcard. -func matchPath(path, pattern string) (bool, map[string]string) { - params := make(map[string]string) - - path = strings.TrimSuffix(path, "/") - pattern = strings.TrimSuffix(pattern, "/") - if path == "" { - path = "/" - } - if pattern == "" { - pattern = "/" - } - - pathSegments := splitPath(path) - patternSegments := splitPath(pattern) - hasWildcard := len(patternSegments) > 0 && patternSegments[len(patternSegments)-1] == "*" - - if hasWildcard { - patternSegments = patternSegments[:len(patternSegments)-1] - if len(pathSegments) <= len(patternSegments) { - return false, nil - } - } else if len(pathSegments) != len(patternSegments) { - return false, nil - } - - for i, patternSeg := range patternSegments { - pathSeg := pathSegments[i] - if strings.HasPrefix(patternSeg, ":") { - params[patternSeg[1:]] = pathSeg - } else if pathSeg != patternSeg { - return false, nil - } - } - - return true, params -} - -func splitPath(path string) []string { - parts := strings.Split(path, "/") - segments := make([]string, 0, len(parts)) - for _, part := range parts { - if part != "" { - segments = append(segments, part) - } - } - return segments -} diff --git a/internal/runner/lua_router_test.go b/internal/runner/lua_router_test.go index 41a3a96..7d97f22 100644 --- a/internal/runner/lua_router_test.go +++ b/internal/runner/lua_router_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/runtime/router" lua "github.com/yuin/gopher-lua" ) @@ -368,15 +369,15 @@ func TestMatchPath(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - matched, params := matchPath(tt.path, tt.pattern) + result := router.Match(tt.path, tt.pattern) - if matched != tt.expectMatch { - t.Errorf("matchPath(%q, %q) matched = %v, want %v", tt.path, tt.pattern, matched, tt.expectMatch) + if result.Matched != tt.expectMatch { + t.Errorf("router.Match(%q, %q) matched = %v, want %v", tt.path, tt.pattern, result.Matched, tt.expectMatch) } if tt.expectMatch && tt.expectedParams != nil { for key, expectedValue := range tt.expectedParams { - if got, ok := params[key]; !ok { + if got, ok := result.Params[key]; !ok { t.Errorf("Missing param %q", key) } else if got != expectedValue { t.Errorf("Param %q = %q, want %q", key, got, expectedValue) diff --git a/internal/runner/lua_strings.go b/internal/runner/lua_strings.go index 62f5800..fc8cc63 100644 --- a/internal/runner/lua_strings.go +++ b/internal/runner/lua_strings.go @@ -1,16 +1,15 @@ package runner import ( - "strings" - + stdlibstrings "github.com/dimiro1/lunar/internal/runtime/strings" lua "github.com/yuin/gopher-lua" ) -// registerStrings registers the strings module with string manipulation functions +// registerStrings registers the strings module with string manipulation functions. +// This is a thin wrapper around the stdlib/strings package. func registerStrings(L *lua.LState) { stringsModule := L.NewTable() - // Register string functions L.SetField(stringsModule, "trim", L.NewFunction(stringsTrim)) L.SetField(stringsModule, "trimLeft", L.NewFunction(stringsTrimLeft)) L.SetField(stringsModule, "trimRight", L.NewFunction(stringsTrimRight)) @@ -24,34 +23,27 @@ func registerStrings(L *lua.LState) { L.SetField(stringsModule, "contains", L.NewFunction(stringsContains)) L.SetField(stringsModule, "repeat", L.NewFunction(stringsRepeat)) - // Set the strings module as a global L.SetGlobal("strings", stringsModule) } // stringsTrim removes leading and trailing whitespace // Usage: local result = strings.trim(str) func stringsTrim(L *lua.LState) int { - str := L.CheckString(1) - result := strings.TrimSpace(str) - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.Trim(L.CheckString(1)))) return 1 } // stringsTrimLeft removes leading whitespace // Usage: local result = strings.trimLeft(str) func stringsTrimLeft(L *lua.LState) int { - str := L.CheckString(1) - result := strings.TrimLeft(str, " \t\n\r") - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.TrimLeft(L.CheckString(1)))) return 1 } // stringsTrimRight removes trailing whitespace // Usage: local result = strings.trimRight(str) func stringsTrimRight(L *lua.LState) int { - str := L.CheckString(1) - result := strings.TrimRight(str, " \t\n\r") - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.TrimRight(L.CheckString(1)))) return 1 } @@ -61,9 +53,9 @@ func stringsSplit(L *lua.LState) int { str := L.CheckString(1) sep := L.CheckString(2) - parts := strings.Split(str, sep) + parts := stdlibstrings.Split(str, sep) - // Create Lua array + // Convert to Lua array result := L.NewTable() for i, part := range parts { result.RawSetInt(i+1, lua.LString(part)) @@ -85,28 +77,21 @@ func stringsJoin(L *lua.LState) int { parts = append(parts, lua.LVAsString(v)) }) - result := strings.Join(parts, sep) - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.Join(parts, sep))) return 1 } // stringsHasPrefix checks if string has prefix // Usage: local result = strings.hasPrefix(str, prefix) func stringsHasPrefix(L *lua.LState) int { - str := L.CheckString(1) - prefix := L.CheckString(2) - result := strings.HasPrefix(str, prefix) - L.Push(lua.LBool(result)) + L.Push(lua.LBool(stdlibstrings.HasPrefix(L.CheckString(1), L.CheckString(2)))) return 1 } // stringsHasSuffix checks if string has suffix // Usage: local result = strings.hasSuffix(str, suffix) func stringsHasSuffix(L *lua.LState) int { - str := L.CheckString(1) - suffix := L.CheckString(2) - result := strings.HasSuffix(str, suffix) - L.Push(lua.LBool(result)) + L.Push(lua.LBool(stdlibstrings.HasSuffix(L.CheckString(1), L.CheckString(2)))) return 1 } @@ -117,47 +102,36 @@ func stringsReplace(L *lua.LState) int { str := L.CheckString(1) old := L.CheckString(2) replacement := L.CheckString(3) - n := L.OptInt(4, -1) // default to replace all + n := L.OptInt(4, -1) - result := strings.Replace(str, old, replacement, n) - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.Replace(str, old, replacement, n))) return 1 } // stringsToLower converts string to lowercase // Usage: local result = strings.toLower(str) func stringsToLower(L *lua.LState) int { - str := L.CheckString(1) - result := strings.ToLower(str) - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.ToLower(L.CheckString(1)))) return 1 } // stringsToUpper converts string to uppercase // Usage: local result = strings.toUpper(str) func stringsToUpper(L *lua.LState) int { - str := L.CheckString(1) - result := strings.ToUpper(str) - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.ToUpper(L.CheckString(1)))) return 1 } // stringsContains checks if string contains substring // Usage: local result = strings.contains(str, substr) func stringsContains(L *lua.LState) int { - str := L.CheckString(1) - substr := L.CheckString(2) - result := strings.Contains(str, substr) - L.Push(lua.LBool(result)) + L.Push(lua.LBool(stdlibstrings.Contains(L.CheckString(1), L.CheckString(2)))) return 1 } // stringsRepeat repeats a string n times // Usage: local result = strings.repeat(str, n) func stringsRepeat(L *lua.LState) int { - str := L.CheckString(1) - count := L.CheckInt(2) - result := strings.Repeat(str, count) - L.Push(lua.LString(result)) + L.Push(lua.LString(stdlibstrings.Repeat(L.CheckString(1), L.CheckInt(2)))) return 1 } diff --git a/internal/runner/lua_time.go b/internal/runner/lua_time.go index 5f9116a..56e2c8b 100644 --- a/internal/runner/lua_time.go +++ b/internal/runner/lua_time.go @@ -1,30 +1,27 @@ package runner import ( - "time" - + stdlibtime "github.com/dimiro1/lunar/internal/runtime/time" lua "github.com/yuin/gopher-lua" ) -// registerTime registers the time module with time-related functions +// registerTime registers the time module with time-related functions. +// This is a thin wrapper around the stdlib/time package. func registerTime(L *lua.LState) { timeModule := L.NewTable() - // Register time functions L.SetField(timeModule, "now", L.NewFunction(timeNow)) L.SetField(timeModule, "format", L.NewFunction(timeFormat)) L.SetField(timeModule, "parse", L.NewFunction(timeParse)) - L.SetField(timeModule, "sleep", L.NewFunction(timeSleep)) + L.SetField(timeModule, "sleep", L.NewFunction(timeSleep(L))) - // Set the time module as a global L.SetGlobal("time", timeModule) } // timeNow returns the current Unix timestamp in seconds // Usage: local timestamp = time.now() func timeNow(L *lua.LState) int { - now := time.Now().Unix() - L.Push(lua.LNumber(now)) + L.Push(lua.LNumber(stdlibtime.Now())) return 1 } @@ -35,9 +32,7 @@ func timeFormat(L *lua.LState) int { timestamp := L.CheckNumber(1) layout := L.CheckString(2) - t := time.Unix(int64(timestamp), 0) - formatted := t.Format(layout) - + formatted := stdlibtime.Format(int64(timestamp), layout) L.Push(lua.LString(formatted)) return 1 } @@ -49,30 +44,25 @@ func timeParse(L *lua.LState) int { timeStr := L.CheckString(1) layout := L.CheckString(2) - t, err := time.Parse(layout, timeStr) + timestamp, err := stdlibtime.Parse(timeStr, layout) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) return 2 } - L.Push(lua.LNumber(t.Unix())) + L.Push(lua.LNumber(timestamp)) L.Push(lua.LNil) return 2 } -// timeSleep sleeps for the specified number of milliseconds +// timeSleep returns a function that sleeps for the specified number of milliseconds // Note: This will block the Lua execution // Usage: time.sleep(1000) -- sleep for 1 second -func timeSleep(L *lua.LState) int { - milliseconds := L.CheckNumber(1) - duration := time.Duration(milliseconds) * time.Millisecond - - // Check if context is cancelled to respect timeouts - select { - case <-L.Context().Done(): - return 0 - case <-time.After(duration): +func timeSleep(L *lua.LState) lua.LGFunction { + return func(L *lua.LState) int { + milliseconds := L.CheckNumber(1) + stdlibtime.Sleep(L.Context(), int64(milliseconds)) return 0 } } diff --git a/internal/runner/lua_url.go b/internal/runner/lua_url.go index 153802e..8e7d4cc 100644 --- a/internal/runner/lua_url.go +++ b/internal/runner/lua_url.go @@ -1,73 +1,64 @@ package runner import ( - "net/url" - + stdliburl "github.com/dimiro1/lunar/internal/runtime/url" lua "github.com/yuin/gopher-lua" ) -// registerURL registers the url module with URL parsing and encoding functions +// registerURL registers the url module with URL parsing and encoding functions. +// This is a thin wrapper around the stdlib/url package. func registerURL(L *lua.LState) { urlModule := L.NewTable() - // Register url functions L.SetField(urlModule, "parse", L.NewFunction(urlParse)) L.SetField(urlModule, "encode", L.NewFunction(urlEncode)) L.SetField(urlModule, "decode", L.NewFunction(urlDecode)) - // Set the url module as a global L.SetGlobal("url", urlModule) } // urlParse parses a URL string into components // Usage: local parsed, err = url.parse(urlStr) -// Returns: { scheme, host, path, query, fragment } +// Returns: { scheme, host, path, query, fragment, username, password } func urlParse(L *lua.LState) int { urlStr := L.CheckString(1) - parsedURL, err := url.Parse(urlStr) + parsedURL, err := stdliburl.Parse(urlStr) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) return 2 } - // Create result table + // Convert ParsedURL to Lua table result := L.NewTable() L.SetField(result, "scheme", lua.LString(parsedURL.Scheme)) L.SetField(result, "host", lua.LString(parsedURL.Host)) L.SetField(result, "path", lua.LString(parsedURL.Path)) L.SetField(result, "fragment", lua.LString(parsedURL.Fragment)) - // Parse query parameters into a table - if parsedURL.RawQuery != "" { - queryTable := L.NewTable() - queryValues := parsedURL.Query() - for key, values := range queryValues { - if len(values) == 1 { - L.SetField(queryTable, key, lua.LString(values[0])) - } else { - // Multiple values - create an array - arrayTable := L.NewTable() - for i, v := range values { - arrayTable.RawSetInt(i+1, lua.LString(v)) - } - L.SetField(queryTable, key, arrayTable) + // Convert query parameters to Lua table + queryTable := L.NewTable() + for key, values := range parsedURL.Query { + if len(values) == 1 { + L.SetField(queryTable, key, lua.LString(values[0])) + } else { + // Multiple values - create an array + arrayTable := L.NewTable() + for i, v := range values { + arrayTable.RawSetInt(i+1, lua.LString(v)) } + L.SetField(queryTable, key, arrayTable) } - L.SetField(result, "query", queryTable) - } else { - L.SetField(result, "query", L.NewTable()) } + L.SetField(result, "query", queryTable) // Add username and password if present - if parsedURL.User != nil { - username := parsedURL.User.Username() - L.SetField(result, "username", lua.LString(username)) - - if password, ok := parsedURL.User.Password(); ok { - L.SetField(result, "password", lua.LString(password)) - } + if parsedURL.Username != "" { + L.SetField(result, "username", lua.LString(parsedURL.Username)) + } + if parsedURL.Password != "" { + L.SetField(result, "password", lua.LString(parsedURL.Password)) } L.Push(result) @@ -78,18 +69,14 @@ func urlParse(L *lua.LState) int { // urlEncode URL-encodes a string // Usage: local encoded = url.encode(str) func urlEncode(L *lua.LState) int { - str := L.CheckString(1) - encoded := url.QueryEscape(str) - L.Push(lua.LString(encoded)) + L.Push(lua.LString(stdliburl.Encode(L.CheckString(1)))) return 1 } // urlDecode URL-decodes a string // Usage: local decoded, err = url.decode(str) func urlDecode(L *lua.LState) int { - str := L.CheckString(1) - - decoded, err := url.QueryUnescape(str) + decoded, err := stdliburl.Decode(L.CheckString(1)) if err != nil { L.Push(lua.LNil) L.Push(lua.LString(err.Error())) diff --git a/internal/runner/runner.go b/internal/runner/runner.go index 70561c1..c227970 100644 --- a/internal/runner/runner.go +++ b/internal/runner/runner.go @@ -5,13 +5,13 @@ import ( "fmt" "time" - "github.com/dimiro1/lunar/internal/ai" - "github.com/dimiro1/lunar/internal/email" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/ai" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" "github.com/dimiro1/lunar/internal/events" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" lua "github.com/yuin/gopher-lua" ) diff --git a/internal/runner/runner_test.go b/internal/runner/runner_test.go index d0f5e66..d50029b 100644 --- a/internal/runner/runner_test.go +++ b/internal/runner/runner_test.go @@ -7,11 +7,11 @@ import ( "testing" "time" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/env" "github.com/dimiro1/lunar/internal/events" - internalhttp "github.com/dimiro1/lunar/internal/http" - "github.com/dimiro1/lunar/internal/kv" - "github.com/dimiro1/lunar/internal/logger" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" ) func TestRun_HTTPEvent_Success(t *testing.T) { diff --git a/internal/runtime/ai/doc.go b/internal/runtime/ai/doc.go new file mode 100644 index 0000000..20d5d78 --- /dev/null +++ b/internal/runtime/ai/doc.go @@ -0,0 +1,3 @@ +// Package ai provides a TrackedClient decorator that wraps an AI client +// with automatic request timing and tracking capabilities. +package ai diff --git a/internal/runtime/ai/tracked.go b/internal/runtime/ai/tracked.go new file mode 100644 index 0000000..fa7b7e7 --- /dev/null +++ b/internal/runtime/ai/tracked.go @@ -0,0 +1,93 @@ +package ai + +import ( + "time" + + "github.com/dimiro1/lunar/internal/services/ai" + "github.com/dimiro1/lunar/internal/store" +) + +// TrackedChatResult contains both the response and tracking information. +type TrackedChatResult struct { + Response *ai.ChatResponse + TrackData ai.TrackRequest + Error error +} + +// TrackedClient wraps an ai.Client and automatically tracks all requests. +// This implements the Decorator pattern. +type TrackedClient struct { + client ai.Client + tracker ai.Tracker + executionID string +} + +// NewTrackedClient creates a TrackedClient that wraps the given client and tracks requests. +func NewTrackedClient(client ai.Client, tracker ai.Tracker, executionID string) *TrackedClient { + return &TrackedClient{ + client: client, + tracker: tracker, + executionID: executionID, + } +} + +// Chat executes a chat request with automatic tracking. +// It measures duration, captures request/response data, and tracks the result. +func (tc *TrackedClient) Chat(functionID string, req ai.ChatRequest) (*ai.ChatResponse, error) { + result := tc.ChatWithTracking(functionID, req) + return result.Response, result.Error +} + +// ChatWithTracking executes a chat request and returns both the response and tracking data. +// This is useful when you need access to the tracking information. +func (tc *TrackedClient) ChatWithTracking(functionID string, req ai.ChatRequest) TrackedChatResult { + trackReq := ai.TrackRequest{ + Provider: req.Provider, + Model: req.Model, + } + + startTime := time.Now() + response, err := tc.client.Chat(functionID, req) + trackReq.DurationMs = time.Since(startTime).Milliseconds() + + // Capture tracking info from response even on error (if available) + if response != nil { + trackReq.Endpoint = response.Endpoint + trackReq.RequestJSON = response.RequestJSON + if response.ResponseJSON != "" { + trackReq.ResponseJSON = &response.ResponseJSON + } + } + + if err != nil { + errMsg := err.Error() + trackReq.Status = store.AIRequestStatusError + trackReq.ErrorMessage = &errMsg + + // Track the error + if tc.tracker != nil { + tc.tracker.Track(tc.executionID, trackReq) + } + + return TrackedChatResult{ + Response: nil, + TrackData: trackReq, + Error: err, + } + } + + trackReq.Status = store.AIRequestStatusSuccess + trackReq.InputTokens = &response.Usage.InputTokens + trackReq.OutputTokens = &response.Usage.OutputTokens + + // Track success + if tc.tracker != nil { + tc.tracker.Track(tc.executionID, trackReq) + } + + return TrackedChatResult{ + Response: response, + TrackData: trackReq, + Error: nil, + } +} diff --git a/internal/runtime/ai/tracked_test.go b/internal/runtime/ai/tracked_test.go new file mode 100644 index 0000000..241fe6e --- /dev/null +++ b/internal/runtime/ai/tracked_test.go @@ -0,0 +1,202 @@ +package ai + +import ( + "errors" + "testing" + + "github.com/dimiro1/lunar/internal/services/ai" + "github.com/dimiro1/lunar/internal/store" +) + +// mockClient implements ai.Client for testing +type mockClient struct { + response *ai.ChatResponse + err error +} + +func (m *mockClient) Chat(functionID string, req ai.ChatRequest) (*ai.ChatResponse, error) { + return m.response, m.err +} + +// mockTracker implements ai.Tracker for testing +type mockTracker struct { + tracked []ai.TrackRequest +} + +func (m *mockTracker) Track(executionID string, req ai.TrackRequest) { + m.tracked = append(m.tracked, req) +} + +func (m *mockTracker) Requests(executionID string) []store.AIRequest { + return nil +} + +func (m *mockTracker) RequestsPaginated(executionID string, limit, offset int) ([]store.AIRequest, int64) { + return nil, 0 +} + +func TestNewTrackedClient(t *testing.T) { + client := &mockClient{} + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + + if tc == nil { + t.Fatal("NewTrackedClient returned nil") + } + if tc.client != client { + t.Error("client not set correctly") + } + if tc.tracker != tracker { + t.Error("tracker not set correctly") + } + if tc.executionID != "exec-123" { + t.Errorf("executionID = %q, want %q", tc.executionID, "exec-123") + } +} + +func TestChatWithTracking_Success(t *testing.T) { + client := &mockClient{ + response: &ai.ChatResponse{ + Content: "Hello!", + Model: "gpt-4", + Endpoint: "https://api.openai.com/v1/chat/completions", + RequestJSON: `{"model":"gpt-4"}`, + ResponseJSON: `{"content":"Hello!"}`, + Usage: ai.Usage{ + InputTokens: 10, + OutputTokens: 5, + }, + }, + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + result := tc.ChatWithTracking("func-1", ai.ChatRequest{ + Provider: "openai", + Model: "gpt-4", + Messages: []ai.Message{{Role: "user", Content: "Hi"}}, + }) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.Response == nil { + t.Fatal("response is nil") + } + if result.Response.Content != "Hello!" { + t.Errorf("response.Content = %q, want %q", result.Response.Content, "Hello!") + } + + // Check tracking + if len(tracker.tracked) != 1 { + t.Fatalf("expected 1 tracked request, got %d", len(tracker.tracked)) + } + tracked := tracker.tracked[0] + if tracked.Status != store.AIRequestStatusSuccess { + t.Errorf("tracked.Status = %q, want %q", tracked.Status, store.AIRequestStatusSuccess) + } + if tracked.Provider != "openai" { + t.Errorf("tracked.Provider = %q, want %q", tracked.Provider, "openai") + } + if tracked.Model != "gpt-4" { + t.Errorf("tracked.Model = %q, want %q", tracked.Model, "gpt-4") + } + if tracked.InputTokens == nil || *tracked.InputTokens != 10 { + t.Error("tracked.InputTokens not set correctly") + } + if tracked.OutputTokens == nil || *tracked.OutputTokens != 5 { + t.Error("tracked.OutputTokens not set correctly") + } + if tracked.DurationMs < 0 { + t.Error("tracked.DurationMs should be non-negative") + } +} + +func TestChatWithTracking_Error(t *testing.T) { + client := &mockClient{ + response: &ai.ChatResponse{ + Endpoint: "https://api.openai.com/v1/chat/completions", + RequestJSON: `{"model":"gpt-4"}`, + }, + err: errors.New("API error"), + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + result := tc.ChatWithTracking("func-1", ai.ChatRequest{ + Provider: "openai", + Model: "gpt-4", + Messages: []ai.Message{{Role: "user", Content: "Hi"}}, + }) + + if result.Error == nil { + t.Fatal("expected error, got nil") + } + if result.Response != nil { + t.Error("response should be nil on error") + } + + // Check tracking + if len(tracker.tracked) != 1 { + t.Fatalf("expected 1 tracked request, got %d", len(tracker.tracked)) + } + tracked := tracker.tracked[0] + if tracked.Status != store.AIRequestStatusError { + t.Errorf("tracked.Status = %q, want %q", tracked.Status, store.AIRequestStatusError) + } + if tracked.ErrorMessage == nil || *tracked.ErrorMessage != "API error" { + t.Error("tracked.ErrorMessage not set correctly") + } +} + +func TestChat_Wrapper(t *testing.T) { + client := &mockClient{ + response: &ai.ChatResponse{ + Content: "Hello!", + Model: "gpt-4", + }, + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + response, err := tc.Chat("func-1", ai.ChatRequest{ + Provider: "openai", + Model: "gpt-4", + Messages: []ai.Message{{Role: "user", Content: "Hi"}}, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if response == nil { + t.Fatal("response is nil") + } + if response.Content != "Hello!" { + t.Errorf("response.Content = %q, want %q", response.Content, "Hello!") + } +} + +func TestChatWithTracking_NilTracker(t *testing.T) { + client := &mockClient{ + response: &ai.ChatResponse{ + Content: "Hello!", + Model: "gpt-4", + }, + } + + tc := NewTrackedClient(client, nil, "exec-123") + result := tc.ChatWithTracking("func-1", ai.ChatRequest{ + Provider: "openai", + Model: "gpt-4", + Messages: []ai.Message{{Role: "user", Content: "Hi"}}, + }) + + // Should not panic with nil tracker + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.Response == nil { + t.Fatal("response is nil") + } +} diff --git a/internal/runtime/base64/base64.go b/internal/runtime/base64/base64.go new file mode 100644 index 0000000..6dd7abd --- /dev/null +++ b/internal/runtime/base64/base64.go @@ -0,0 +1,18 @@ +package base64 + +import gobase64 "encoding/base64" + +// Encode encodes a string to base64. +func Encode(s string) string { + return gobase64.StdEncoding.EncodeToString([]byte(s)) +} + +// Decode decodes a base64 string. +// Returns the decoded string or an error. +func Decode(s string) (string, error) { + decoded, err := gobase64.StdEncoding.DecodeString(s) + if err != nil { + return "", err + } + return string(decoded), nil +} diff --git a/internal/runtime/base64/base64_test.go b/internal/runtime/base64/base64_test.go new file mode 100644 index 0000000..dde23bc --- /dev/null +++ b/internal/runtime/base64/base64_test.go @@ -0,0 +1,84 @@ +package base64 + +import "testing" + +func TestEncode(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + {"empty string", "", ""}, + {"hello world", "hello world", "aGVsbG8gd29ybGQ="}, + {"simple text", "test", "dGVzdA=="}, + {"with special chars", "hello@world!", "aGVsbG9Ad29ybGQh"}, + {"unicode", "hello 世界", "aGVsbG8g5LiW55WM"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Encode(tt.input) + if result != tt.expected { + t.Errorf("Encode(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestDecode(t *testing.T) { + tests := []struct { + name string + input string + expected string + expectErr bool + }{ + {"empty string", "", "", false}, + {"hello world", "aGVsbG8gd29ybGQ=", "hello world", false}, + {"simple text", "dGVzdA==", "test", false}, + {"with special chars", "aGVsbG9Ad29ybGQh", "hello@world!", false}, + {"unicode", "aGVsbG8g5LiW55WM", "hello 世界", false}, + {"invalid base64", "not-valid-base64!!!", "", true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Decode(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("Decode(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Errorf("Decode(%q) unexpected error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("Decode(%q) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + inputs := []string{ + "", + "hello", + "hello world", + "special chars: !@#$%^&*()", + "unicode: 你好世界 🌍", + "binary-like: \x00\x01\x02\xff", + } + + for _, input := range inputs { + encoded := Encode(input) + decoded, err := Decode(encoded) + if err != nil { + t.Errorf("Round trip failed for %q: %v", input, err) + continue + } + if decoded != input { + t.Errorf("Round trip failed for %q: got %q", input, decoded) + } + } +} diff --git a/internal/runtime/base64/doc.go b/internal/runtime/base64/doc.go new file mode 100644 index 0000000..8c6fcb9 --- /dev/null +++ b/internal/runtime/base64/doc.go @@ -0,0 +1,2 @@ +// Package base64 provides base64 encoding and decoding utility functions. +package base64 diff --git a/internal/runtime/crypto/crypto.go b/internal/runtime/crypto/crypto.go new file mode 100644 index 0000000..ee530b7 --- /dev/null +++ b/internal/runtime/crypto/crypto.go @@ -0,0 +1,66 @@ +package crypto + +import ( + "crypto/hmac" + "crypto/md5" + "crypto/sha1" + "crypto/sha256" + "crypto/sha512" + "encoding/hex" + "hash" + + "github.com/google/uuid" +) + +// MD5 computes the MD5 hash of the input string and returns it as a hex-encoded string. +func MD5(input string) string { + return hashString(md5.New(), input) +} + +// SHA1 computes the SHA1 hash of the input string and returns it as a hex-encoded string. +func SHA1(input string) string { + return hashString(sha1.New(), input) +} + +// SHA256 computes the SHA256 hash of the input string and returns it as a hex-encoded string. +func SHA256(input string) string { + return hashString(sha256.New(), input) +} + +// SHA512 computes the SHA512 hash of the input string and returns it as a hex-encoded string. +func SHA512(input string) string { + return hashString(sha512.New(), input) +} + +// HMACSHA1 computes the HMAC-SHA1 of a message with a secret key. +func HMACSHA1(message, key string) string { + return hmacString(sha1.New, message, key) +} + +// HMACSHA256 computes the HMAC-SHA256 of a message with a secret key. +func HMACSHA256(message, key string) string { + return hmacString(sha256.New, message, key) +} + +// HMACSHA512 computes the HMAC-SHA512 of a message with a secret key. +func HMACSHA512(message, key string) string { + return hmacString(sha512.New, message, key) +} + +// UUID generates a new UUID v4. +func UUID() string { + return uuid.New().String() +} + +// hashString is a helper function to compute hash of a string. +func hashString(h hash.Hash, input string) string { + h.Write([]byte(input)) + return hex.EncodeToString(h.Sum(nil)) +} + +// hmacString is a helper function to compute HMAC of a string. +func hmacString(hashFunc func() hash.Hash, message, key string) string { + h := hmac.New(hashFunc, []byte(key)) + h.Write([]byte(message)) + return hex.EncodeToString(h.Sum(nil)) +} diff --git a/internal/runtime/crypto/crypto_test.go b/internal/runtime/crypto/crypto_test.go new file mode 100644 index 0000000..a7008b4 --- /dev/null +++ b/internal/runtime/crypto/crypto_test.go @@ -0,0 +1,131 @@ +package crypto + +import ( + "regexp" + "testing" +) + +func TestMD5(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", "d41d8cd98f00b204e9800998ecf8427e"}, + {"hello", "5d41402abc4b2a76b9719d911017c592"}, + {"hello world", "5eb63bbbe01eeed093cb22bb8f5acdc3"}, + } + + for _, tt := range tests { + result := MD5(tt.input) + if result != tt.expected { + t.Errorf("MD5(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestSHA1(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", "da39a3ee5e6b4b0d3255bfef95601890afd80709"}, + {"hello", "aaf4c61ddcc5e8a2dabede0f3b482cd9aea9434d"}, + {"hello world", "2aae6c35c94fcfb415dbe95f408b9ce91ee846ed"}, + } + + for _, tt := range tests { + result := SHA1(tt.input) + if result != tt.expected { + t.Errorf("SHA1(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestSHA256(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"}, + {"hello", "2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824"}, + {"hello world", "b94d27b9934d3e08a52e52d7da7dabfac484efe37a5380ee9088f7ace2efcde9"}, + } + + for _, tt := range tests { + result := SHA256(tt.input) + if result != tt.expected { + t.Errorf("SHA256(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestSHA512(t *testing.T) { + // Just test that it produces the right length (128 hex chars = 64 bytes) + result := SHA512("hello") + if len(result) != 128 { + t.Errorf("SHA512 returned %d chars, want 128", len(result)) + } +} + +func TestHMACSHA1(t *testing.T) { + tests := []struct { + message string + key string + expected string + }{ + {"hello", "secret", "5112055c05f944f85755efc5cd8970e194e9f45b"}, + } + + for _, tt := range tests { + result := HMACSHA1(tt.message, tt.key) + if result != tt.expected { + t.Errorf("HMACSHA1(%q, %q) = %q, want %q", tt.message, tt.key, result, tt.expected) + } + } +} + +func TestHMACSHA256(t *testing.T) { + tests := []struct { + message string + key string + expected string + }{ + {"hello", "secret", "88aab3ede8d3adf94d26ab90d3bafd4a2083070c3bcce9c014ee04a443847c0b"}, + } + + for _, tt := range tests { + result := HMACSHA256(tt.message, tt.key) + if result != tt.expected { + t.Errorf("HMACSHA256(%q, %q) = %q, want %q", tt.message, tt.key, result, tt.expected) + } + } +} + +func TestHMACSHA512(t *testing.T) { + // Just test that it produces the right length (128 hex chars = 64 bytes) + result := HMACSHA512("hello", "secret") + if len(result) != 128 { + t.Errorf("HMACSHA512 returned %d chars, want 128", len(result)) + } +} + +func TestUUID(t *testing.T) { + uuidRegex := regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`) + + for i := 0; i < 10; i++ { + result := UUID() + if !uuidRegex.MatchString(result) { + t.Errorf("UUID() = %q, not a valid UUID v4", result) + } + } + + // Test uniqueness + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + u := UUID() + if seen[u] { + t.Errorf("UUID() produced duplicate: %s", u) + } + seen[u] = true + } +} diff --git a/internal/runtime/crypto/doc.go b/internal/runtime/crypto/doc.go new file mode 100644 index 0000000..93022a0 --- /dev/null +++ b/internal/runtime/crypto/doc.go @@ -0,0 +1,2 @@ +// Package crypto provides cryptographic hashing functions and UUID generation. +package crypto diff --git a/internal/runtime/doc.go b/internal/runtime/doc.go new file mode 100644 index 0000000..4e8cf8d --- /dev/null +++ b/internal/runtime/doc.go @@ -0,0 +1,10 @@ +// Package stdlib provides language-agnostic standard library functions +// that can be used by any language runtime (Lua, JavaScript, Python, etc.). +// +// This package is organized into sub-packages: +// - random: Random number and string generation +// - crypto: Cryptographic hashing and UUID generation +// - router: URL path matching and building +// - ai: AI provider client with automatic tracking +// - email: Email client with automatic tracking +package stdlib diff --git a/internal/runtime/email/doc.go b/internal/runtime/email/doc.go new file mode 100644 index 0000000..f33c2be --- /dev/null +++ b/internal/runtime/email/doc.go @@ -0,0 +1,3 @@ +// Package email provides a TrackedClient decorator that wraps an email client +// with automatic request timing and tracking capabilities, plus validation utilities. +package email diff --git a/internal/runtime/email/tracked.go b/internal/runtime/email/tracked.go new file mode 100644 index 0000000..db0aa3d --- /dev/null +++ b/internal/runtime/email/tracked.go @@ -0,0 +1,98 @@ +package email + +import ( + "encoding/json" + "time" + + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/store" +) + +// TrackedSendResult contains both the response and tracking information. +type TrackedSendResult struct { + Response *email.SendResponse + TrackData email.TrackRequest + Error error +} + +// TrackedClient wraps an email.Client and automatically tracks all requests. +// This implements the Decorator pattern. +type TrackedClient struct { + client email.Client + tracker email.Tracker + executionID string +} + +// NewTrackedClient creates a TrackedClient that wraps the given client and tracks requests. +func NewTrackedClient(client email.Client, tracker email.Tracker, executionID string) *TrackedClient { + return &TrackedClient{ + client: client, + tracker: tracker, + executionID: executionID, + } +} + +// Send sends an email with automatic tracking. +// It measures duration, captures request/response data, and tracks the result. +func (tc *TrackedClient) Send(functionID string, req email.SendRequest) (*email.SendResponse, error) { + result := tc.SendWithTracking(functionID, req) + return result.Response, result.Error +} + +// SendWithTracking sends an email and returns both the response and tracking data. +// This is useful when you need access to the tracking information. +func (tc *TrackedClient) SendWithTracking(functionID string, req email.SendRequest) TrackedSendResult { + startTime := time.Now() + resp, err := tc.client.Send(functionID, req) + durationMs := time.Since(startTime).Milliseconds() + + // Get request JSON for tracking + var requestJSON string + if resp != nil { + requestJSON = resp.RequestJSON + } + + trackReq := email.TrackRequest{ + From: req.From, + To: req.To, + Subject: req.Subject, + HasText: req.Text != "", + HasHTML: req.HTML != "", + RequestJSON: requestJSON, + DurationMs: durationMs, + } + + if err != nil { + errMsg := err.Error() + trackReq.Status = store.EmailRequestStatusError + trackReq.ErrorMessage = &errMsg + + if tc.tracker != nil { + tc.tracker.Track(tc.executionID, trackReq) + } + + return TrackedSendResult{ + Response: nil, + TrackData: trackReq, + Error: err, + } + } + + // Build response JSON + responseJSON, _ := json.Marshal(map[string]string{"id": resp.ID}) + responseJSONStr := string(responseJSON) + + trackReq.Status = store.EmailRequestStatusSuccess + trackReq.EmailID = &resp.ID + trackReq.ResponseJSON = &responseJSONStr + + if tc.tracker != nil { + tc.tracker.Track(tc.executionID, trackReq) + } + + return TrackedSendResult{ + Response: resp, + TrackData: trackReq, + Error: nil, + } +} diff --git a/internal/runtime/email/tracked_test.go b/internal/runtime/email/tracked_test.go new file mode 100644 index 0000000..a2f6b7a --- /dev/null +++ b/internal/runtime/email/tracked_test.go @@ -0,0 +1,226 @@ +package email + +import ( + "errors" + "testing" + + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/store" +) + +var _ email.Tracker = (*mockTracker)(nil) // Compile-time check + +// mockClient implements email.Client for testing +type mockClient struct { + response *email.SendResponse + err error +} + +func (m *mockClient) Send(functionID string, req email.SendRequest) (*email.SendResponse, error) { + return m.response, m.err +} + +// mockTracker implements email.Tracker for testing +type mockTracker struct { + tracked []email.TrackRequest +} + +func (m *mockTracker) Track(executionID string, req email.TrackRequest) { + m.tracked = append(m.tracked, req) +} + +func (m *mockTracker) Requests(executionID string) []store.EmailRequest { + return nil +} + +func (m *mockTracker) RequestsPaginated(executionID string, limit, offset int) ([]store.EmailRequest, int64) { + return nil, 0 +} + +func TestNewTrackedClient(t *testing.T) { + client := &mockClient{} + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + + if tc == nil { + t.Fatal("NewTrackedClient returned nil") + } + if tc.client != client { + t.Error("client not set correctly") + } + if tc.tracker != tracker { + t.Error("tracker not set correctly") + } + if tc.executionID != "exec-123" { + t.Errorf("executionID = %q, want %q", tc.executionID, "exec-123") + } +} + +func TestSendWithTracking_Success(t *testing.T) { + client := &mockClient{ + response: &email.SendResponse{ + ID: "msg-123", + RequestJSON: `{"to":["test@example.com"]}`, + }, + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + result := tc.SendWithTracking("func-1", email.SendRequest{ + From: "sender@example.com", + To: []string{"test@example.com"}, + Subject: "Test", + Text: "Hello", + }) + + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.Response == nil { + t.Fatal("response is nil") + } + if result.Response.ID != "msg-123" { + t.Errorf("response.ID = %q, want %q", result.Response.ID, "msg-123") + } + + // Check tracking + if len(tracker.tracked) != 1 { + t.Fatalf("expected 1 tracked request, got %d", len(tracker.tracked)) + } + tracked := tracker.tracked[0] + if tracked.Status != store.EmailRequestStatusSuccess { + t.Errorf("tracked.Status = %q, want %q", tracked.Status, store.EmailRequestStatusSuccess) + } + if tracked.From != "sender@example.com" { + t.Errorf("tracked.From = %q, want %q", tracked.From, "sender@example.com") + } + if len(tracked.To) != 1 || tracked.To[0] != "test@example.com" { + t.Errorf("tracked.To = %v, want [test@example.com]", tracked.To) + } + if tracked.Subject != "Test" { + t.Errorf("tracked.Subject = %q, want %q", tracked.Subject, "Test") + } + if !tracked.HasText { + t.Error("tracked.HasText should be true") + } + if tracked.HasHTML { + t.Error("tracked.HasHTML should be false") + } + if tracked.EmailID == nil || *tracked.EmailID != "msg-123" { + t.Error("tracked.EmailID not set correctly") + } + if tracked.DurationMs < 0 { + t.Error("tracked.DurationMs should be non-negative") + } +} + +func TestSendWithTracking_Error(t *testing.T) { + client := &mockClient{ + err: errors.New("send failed"), + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + result := tc.SendWithTracking("func-1", email.SendRequest{ + From: "sender@example.com", + To: []string{"test@example.com"}, + Subject: "Test", + Text: "Hello", + }) + + if result.Error == nil { + t.Fatal("expected error, got nil") + } + if result.Response != nil { + t.Error("response should be nil on error") + } + + // Check tracking + if len(tracker.tracked) != 1 { + t.Fatalf("expected 1 tracked request, got %d", len(tracker.tracked)) + } + tracked := tracker.tracked[0] + if tracked.Status != store.EmailRequestStatusError { + t.Errorf("tracked.Status = %q, want %q", tracked.Status, store.EmailRequestStatusError) + } + if tracked.ErrorMessage == nil || *tracked.ErrorMessage != "send failed" { + t.Error("tracked.ErrorMessage not set correctly") + } +} + +func TestSend_Wrapper(t *testing.T) { + client := &mockClient{ + response: &email.SendResponse{ + ID: "msg-123", + }, + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + response, err := tc.Send("func-1", email.SendRequest{ + From: "sender@example.com", + To: []string{"test@example.com"}, + Subject: "Test", + Text: "Hello", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if response == nil { + t.Fatal("response is nil") + } + if response.ID != "msg-123" { + t.Errorf("response.ID = %q, want %q", response.ID, "msg-123") + } +} + +func TestSendWithTracking_NilTracker(t *testing.T) { + client := &mockClient{ + response: &email.SendResponse{ + ID: "msg-123", + }, + } + + tc := NewTrackedClient(client, nil, "exec-123") + result := tc.SendWithTracking("func-1", email.SendRequest{ + From: "sender@example.com", + To: []string{"test@example.com"}, + Subject: "Test", + Text: "Hello", + }) + + // Should not panic with nil tracker + if result.Error != nil { + t.Fatalf("unexpected error: %v", result.Error) + } + if result.Response == nil { + t.Fatal("response is nil") + } +} + +func TestSendWithTracking_HTMLContent(t *testing.T) { + client := &mockClient{ + response: &email.SendResponse{ + ID: "msg-123", + }, + } + tracker := &mockTracker{} + + tc := NewTrackedClient(client, tracker, "exec-123") + tc.SendWithTracking("func-1", email.SendRequest{ + From: "sender@example.com", + To: []string{"test@example.com"}, + Subject: "Test", + HTML: "

Hello

", + }) + + tracked := tracker.tracked[0] + if tracked.HasText { + t.Error("tracked.HasText should be false") + } + if !tracked.HasHTML { + t.Error("tracked.HasHTML should be true") + } +} diff --git a/internal/runtime/email/validation.go b/internal/runtime/email/validation.go new file mode 100644 index 0000000..3eac2dc --- /dev/null +++ b/internal/runtime/email/validation.go @@ -0,0 +1,24 @@ +package email + +import ( + "errors" + + "github.com/dimiro1/lunar/internal/services/email" +) + +// ValidateSendRequest validates a SendRequest and returns an error if invalid. +func ValidateSendRequest(req email.SendRequest) error { + if req.From == "" { + return errors.New("from is required") + } + if len(req.To) == 0 { + return errors.New("to is required") + } + if req.Subject == "" { + return errors.New("subject is required") + } + if req.Text == "" && req.HTML == "" { + return errors.New("either text or html content is required") + } + return nil +} diff --git a/internal/runtime/email/validation_test.go b/internal/runtime/email/validation_test.go new file mode 100644 index 0000000..451f0a8 --- /dev/null +++ b/internal/runtime/email/validation_test.go @@ -0,0 +1,138 @@ +package email + +import ( + "testing" + + "github.com/dimiro1/lunar/internal/services/email" +) + +func TestValidateSendRequest(t *testing.T) { + tests := []struct { + name string + req email.SendRequest + expectErr bool + errMsg string + }{ + { + name: "valid request with text", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"recipient@example.com"}, + Subject: "Test Subject", + Text: "Hello, World!", + }, + expectErr: false, + }, + { + name: "valid request with HTML", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"recipient@example.com"}, + Subject: "Test Subject", + HTML: "

Hello, World!

", + }, + expectErr: false, + }, + { + name: "valid request with both text and HTML", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"recipient@example.com"}, + Subject: "Test Subject", + Text: "Hello, World!", + HTML: "

Hello, World!

", + }, + expectErr: false, + }, + { + name: "multiple recipients", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"a@example.com", "b@example.com"}, + Subject: "Test Subject", + Text: "Hello!", + }, + expectErr: false, + }, + { + name: "missing from", + req: email.SendRequest{ + To: []string{"recipient@example.com"}, + Subject: "Test Subject", + Text: "Hello!", + }, + expectErr: true, + errMsg: "from is required", + }, + { + name: "missing to", + req: email.SendRequest{ + From: "sender@example.com", + Subject: "Test Subject", + Text: "Hello!", + }, + expectErr: true, + errMsg: "to is required", + }, + { + name: "empty to array", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{}, + Subject: "Test Subject", + Text: "Hello!", + }, + expectErr: true, + errMsg: "to is required", + }, + { + name: "missing subject", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"recipient@example.com"}, + Text: "Hello!", + }, + expectErr: true, + errMsg: "subject is required", + }, + { + name: "missing content", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"recipient@example.com"}, + Subject: "Test Subject", + }, + expectErr: true, + errMsg: "either text or html content is required", + }, + { + name: "empty text and HTML", + req: email.SendRequest{ + From: "sender@example.com", + To: []string{"recipient@example.com"}, + Subject: "Test Subject", + Text: "", + HTML: "", + }, + expectErr: true, + errMsg: "either text or html content is required", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := ValidateSendRequest(tt.req) + if tt.expectErr { + if err == nil { + t.Errorf("expected error %q, got nil", tt.errMsg) + } else if err.Error() != tt.errMsg { + t.Errorf("expected error %q, got %q", tt.errMsg, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + } + }) + } +} diff --git a/internal/runtime/json/doc.go b/internal/runtime/json/doc.go new file mode 100644 index 0000000..c03b07f --- /dev/null +++ b/internal/runtime/json/doc.go @@ -0,0 +1,2 @@ +// Package json provides JSON encoding and decoding utility functions. +package json diff --git a/internal/runtime/json/json.go b/internal/runtime/json/json.go new file mode 100644 index 0000000..b558a31 --- /dev/null +++ b/internal/runtime/json/json.go @@ -0,0 +1,28 @@ +package json + +import gojson "encoding/json" + +// Encode converts a Go value to a JSON string. +func Encode(v any) (string, error) { + jsonBytes, err := gojson.Marshal(v) + if err != nil { + return "", err + } + return string(jsonBytes), nil +} + +// Decode converts a JSON string to a Go value. +// Returns the decoded value which will be one of: +// - nil for JSON null +// - bool for JSON boolean +// - float64 for JSON numbers +// - string for JSON strings +// - []any for JSON arrays +// - map[string]any for JSON objects +func Decode(jsonStr string) (any, error) { + var v any + if err := gojson.Unmarshal([]byte(jsonStr), &v); err != nil { + return nil, err + } + return v, nil +} diff --git a/internal/runtime/json/json_test.go b/internal/runtime/json/json_test.go new file mode 100644 index 0000000..6dd9c76 --- /dev/null +++ b/internal/runtime/json/json_test.go @@ -0,0 +1,115 @@ +package json + +import ( + "reflect" + "testing" +) + +func TestEncode(t *testing.T) { + tests := []struct { + name string + input any + expected string + expectErr bool + }{ + {"nil", nil, "null", false}, + {"bool true", true, "true", false}, + {"bool false", false, "false", false}, + {"int", 42, "42", false}, + {"float", 3.14, "3.14", false}, + {"string", "hello", `"hello"`, false}, + {"empty array", []any{}, "[]", false}, + {"array", []any{1, 2, 3}, "[1,2,3]", false}, + {"empty object", map[string]any{}, "{}", false}, + {"object", map[string]any{"key": "value"}, `{"key":"value"}`, false}, + {"nested", map[string]any{"arr": []any{1, 2}}, `{"arr":[1,2]}`, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Encode(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("Encode(%v) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Errorf("Encode(%v) unexpected error: %v", tt.input, err) + return + } + if result != tt.expected { + t.Errorf("Encode(%v) = %q, want %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestDecode(t *testing.T) { + tests := []struct { + name string + input string + expected any + expectErr bool + }{ + {"null", "null", nil, false}, + {"bool true", "true", true, false}, + {"bool false", "false", false, false}, + {"int", "42", float64(42), false}, + {"float", "3.14", 3.14, false}, + {"string", `"hello"`, "hello", false}, + {"empty array", "[]", []any{}, false}, + {"array", "[1,2,3]", []any{float64(1), float64(2), float64(3)}, false}, + {"empty object", "{}", map[string]any{}, false}, + {"object", `{"key":"value"}`, map[string]any{"key": "value"}, false}, + {"invalid json", "not json", nil, true}, + {"incomplete", `{"key":`, nil, true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Decode(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("Decode(%q) expected error, got nil", tt.input) + } + return + } + if err != nil { + t.Errorf("Decode(%q) unexpected error: %v", tt.input, err) + return + } + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("Decode(%q) = %v (%T), want %v (%T)", tt.input, result, result, tt.expected, tt.expected) + } + }) + } +} + +func TestRoundTrip(t *testing.T) { + inputs := []any{ + nil, + true, + false, + float64(42), + "hello world", + []any{float64(1), float64(2), float64(3)}, + map[string]any{"name": "test", "value": float64(123)}, + } + + for _, input := range inputs { + encoded, err := Encode(input) + if err != nil { + t.Errorf("Encode(%v) failed: %v", input, err) + continue + } + decoded, err := Decode(encoded) + if err != nil { + t.Errorf("Decode(%q) failed: %v", encoded, err) + continue + } + if !reflect.DeepEqual(decoded, input) { + t.Errorf("Round trip failed: %v -> %q -> %v", input, encoded, decoded) + } + } +} diff --git a/internal/runtime/random/doc.go b/internal/runtime/random/doc.go new file mode 100644 index 0000000..1f86dbf --- /dev/null +++ b/internal/runtime/random/doc.go @@ -0,0 +1,3 @@ +// Package random provides cryptographically secure random number +// and string generation functions. +package random diff --git a/internal/runtime/random/random.go b/internal/runtime/random/random.go new file mode 100644 index 0000000..39841a2 --- /dev/null +++ b/internal/runtime/random/random.go @@ -0,0 +1,86 @@ +package random + +import ( + "crypto/rand" + "encoding/base64" + "encoding/hex" + "fmt" + "math/big" + mathrand "math/rand" + + "github.com/rs/xid" +) + +const alphanumericCharset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" + +// Int generates a cryptographically secure random integer between min and max (inclusive). +// Falls back to math/rand on crypto/rand errors. +func Int(min, max int) (int, error) { + if min > max { + return 0, fmt.Errorf("min (%d) must be less than or equal to max (%d)", min, max) + } + + rangeSize := int64(max - min + 1) + n, err := rand.Int(rand.Reader, big.NewInt(rangeSize)) + if err != nil { + // Fallback to math/rand + return mathrand.Intn(int(rangeSize)) + min, nil + } + return int(n.Int64()) + min, nil +} + +// Float generates a random float64 between 0.0 and 1.0. +func Float() float64 { + return mathrand.Float64() +} + +// String generates a random alphanumeric string of the specified length. +func String(length int) (string, error) { + if length <= 0 { + return "", fmt.Errorf("length must be positive, got %d", length) + } + + bytes := make([]byte, length) + for i := range bytes { + n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphanumericCharset)))) + if err != nil { + // Fallback to math/rand + bytes[i] = alphanumericCharset[mathrand.Intn(len(alphanumericCharset))] + } else { + bytes[i] = alphanumericCharset[n.Int64()] + } + } + return string(bytes), nil +} + +// Bytes generates random bytes and returns them as a base64-encoded string. +func Bytes(length int) (string, error) { + if length <= 0 { + return "", fmt.Errorf("length must be positive, got %d", length) + } + + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return base64.StdEncoding.EncodeToString(bytes), nil +} + +// Hex generates random bytes and returns them as a hex-encoded string. +func Hex(length int) (string, error) { + if length <= 0 { + return "", fmt.Errorf("length must be positive, got %d", length) + } + + bytes := make([]byte, length) + if _, err := rand.Read(bytes); err != nil { + return "", err + } + return hex.EncodeToString(bytes), nil +} + +// ID generates a globally unique sortable ID using xid. +// Returns a 20-character string that is smaller than UUID and sortable by creation time. +func ID() string { + return xid.New().String() +} diff --git a/internal/runtime/random/random_test.go b/internal/runtime/random/random_test.go new file mode 100644 index 0000000..03c4f1c --- /dev/null +++ b/internal/runtime/random/random_test.go @@ -0,0 +1,177 @@ +package random + +import ( + "encoding/base64" + "encoding/hex" + "regexp" + "testing" +) + +func TestInt(t *testing.T) { + t.Run("valid range", func(t *testing.T) { + for i := 0; i < 100; i++ { + result, err := Int(1, 10) + if err != nil { + t.Fatalf("Int(1, 10) returned error: %v", err) + } + if result < 1 || result > 10 { + t.Errorf("Int(1, 10) = %d, want 1-10", result) + } + } + }) + + t.Run("same min and max", func(t *testing.T) { + result, err := Int(5, 5) + if err != nil { + t.Fatalf("Int(5, 5) returned error: %v", err) + } + if result != 5 { + t.Errorf("Int(5, 5) = %d, want 5", result) + } + }) + + t.Run("invalid range", func(t *testing.T) { + _, err := Int(10, 1) + if err == nil { + t.Error("Int(10, 1) expected error, got nil") + } + }) + + t.Run("negative range", func(t *testing.T) { + for i := 0; i < 100; i++ { + result, err := Int(-10, -1) + if err != nil { + t.Fatalf("Int(-10, -1) returned error: %v", err) + } + if result < -10 || result > -1 { + t.Errorf("Int(-10, -1) = %d, want -10 to -1", result) + } + } + }) +} + +func TestFloat(t *testing.T) { + for i := 0; i < 100; i++ { + result := Float() + if result < 0.0 || result >= 1.0 { + t.Errorf("Float() = %f, want 0.0 <= x < 1.0", result) + } + } +} + +func TestString(t *testing.T) { + alphanumericRegex := regexp.MustCompile(`^[a-zA-Z0-9]+$`) + + t.Run("valid length", func(t *testing.T) { + for _, length := range []int{1, 10, 32, 64} { + result, err := String(length) + if err != nil { + t.Fatalf("String(%d) returned error: %v", length, err) + } + if len(result) != length { + t.Errorf("String(%d) returned length %d", length, len(result)) + } + if !alphanumericRegex.MatchString(result) { + t.Errorf("String(%d) = %q, not alphanumeric", length, result) + } + } + }) + + t.Run("invalid length", func(t *testing.T) { + _, err := String(0) + if err == nil { + t.Error("String(0) expected error, got nil") + } + + _, err = String(-1) + if err == nil { + t.Error("String(-1) expected error, got nil") + } + }) + + t.Run("uniqueness", func(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + s, _ := String(32) + if seen[s] { + t.Errorf("String(32) produced duplicate: %s", s) + } + seen[s] = true + } + }) +} + +func TestBytes(t *testing.T) { + t.Run("valid length", func(t *testing.T) { + for _, length := range []int{1, 16, 32} { + result, err := Bytes(length) + if err != nil { + t.Fatalf("Bytes(%d) returned error: %v", length, err) + } + // Result is base64 encoded, so decode it + decoded, err := base64.StdEncoding.DecodeString(result) + if err != nil { + t.Errorf("Bytes(%d) returned invalid base64: %v", length, err) + continue + } + if len(decoded) != length { + t.Errorf("Bytes(%d) decoded to %d bytes", length, len(decoded)) + } + } + }) + + t.Run("invalid length", func(t *testing.T) { + _, err := Bytes(0) + if err == nil { + t.Error("Bytes(0) expected error, got nil") + } + }) +} + +func TestHex(t *testing.T) { + t.Run("valid length", func(t *testing.T) { + for _, length := range []int{1, 16, 32} { + result, err := Hex(length) + if err != nil { + t.Fatalf("Hex(%d) returned error: %v", length, err) + } + // Result is hex encoded, so decode it + decoded, err := hex.DecodeString(result) + if err != nil { + t.Errorf("Hex(%d) returned invalid hex: %v", length, err) + continue + } + if len(decoded) != length { + t.Errorf("Hex(%d) decoded to %d bytes", length, len(decoded)) + } + } + }) + + t.Run("invalid length", func(t *testing.T) { + _, err := Hex(0) + if err == nil { + t.Error("Hex(0) expected error, got nil") + } + }) +} + +func TestID(t *testing.T) { + t.Run("format", func(t *testing.T) { + result := ID() + // XID is 20 characters + if len(result) != 20 { + t.Errorf("ID() = %q, want 20 characters, got %d", result, len(result)) + } + }) + + t.Run("uniqueness", func(t *testing.T) { + seen := make(map[string]bool) + for i := 0; i < 100; i++ { + id := ID() + if seen[id] { + t.Errorf("ID() produced duplicate: %s", id) + } + seen[id] = true + } + }) +} diff --git a/internal/runtime/router/doc.go b/internal/runtime/router/doc.go new file mode 100644 index 0000000..8e58387 --- /dev/null +++ b/internal/runtime/router/doc.go @@ -0,0 +1,3 @@ +// Package router provides URL path matching and building utilities. +// It supports path parameters (e.g., :id) and wildcard matching (*). +package router diff --git a/internal/runtime/router/router.go b/internal/runtime/router/router.go new file mode 100644 index 0000000..a43ee07 --- /dev/null +++ b/internal/runtime/router/router.go @@ -0,0 +1,87 @@ +package router + +import "strings" + +// MatchResult contains the result of a path match operation. +type MatchResult struct { + Matched bool + Params map[string]string +} + +// Match checks if a path matches a pattern and returns match result with extracted parameters. +// Pattern syntax: +// - :name captures a path segment into params["name"] +// - * at the end matches any remaining path segments +func Match(path, pattern string) MatchResult { + params := make(map[string]string) + + path = strings.TrimSuffix(path, "/") + pattern = strings.TrimSuffix(pattern, "/") + if path == "" { + path = "/" + } + if pattern == "" { + pattern = "/" + } + + pathSegments := SplitPath(path) + patternSegments := SplitPath(pattern) + hasWildcard := len(patternSegments) > 0 && patternSegments[len(patternSegments)-1] == "*" + + if hasWildcard { + patternSegments = patternSegments[:len(patternSegments)-1] + if len(pathSegments) <= len(patternSegments) { + return MatchResult{Matched: false, Params: nil} + } + } else if len(pathSegments) != len(patternSegments) { + return MatchResult{Matched: false, Params: nil} + } + + for i, patternSeg := range patternSegments { + pathSeg := pathSegments[i] + if strings.HasPrefix(patternSeg, ":") { + params[patternSeg[1:]] = pathSeg + } else if pathSeg != patternSeg { + return MatchResult{Matched: false, Params: nil} + } + } + + return MatchResult{Matched: true, Params: params} +} + +// SplitPath splits a path into non-empty segments. +func SplitPath(path string) []string { + parts := strings.Split(path, "/") + segments := make([]string, 0, len(parts)) + for _, part := range parts { + if part != "" { + segments = append(segments, part) + } + } + return segments +} + +// BuildPath substitutes parameter placeholders in a pattern with values from params. +// Example: BuildPath("/users/:id", map[string]string{"id": "42"}) returns "/users/42" +func BuildPath(pattern string, params map[string]string) string { + if len(params) == 0 { + return pattern + } + result := pattern + for key, value := range params { + result = strings.ReplaceAll(result, ":"+key, value) + } + return result +} + +// FunctionPath builds a full path for a function with the given pattern and parameters. +// Returns "/fn/{functionID}{path}" +func FunctionPath(functionID, pattern string, params map[string]string) string { + return "/fn/" + functionID + BuildPath(pattern, params) +} + +// FunctionURL builds a full URL for a function with the given pattern and parameters. +// Returns "{baseURL}/fn/{functionID}{path}" +func FunctionURL(baseURL, functionID, pattern string, params map[string]string) string { + return strings.TrimSuffix(baseURL, "/") + "/fn/" + functionID + BuildPath(pattern, params) +} diff --git a/internal/runtime/router/router_test.go b/internal/runtime/router/router_test.go new file mode 100644 index 0000000..669c6e3 --- /dev/null +++ b/internal/runtime/router/router_test.go @@ -0,0 +1,153 @@ +package router + +import ( + "reflect" + "testing" +) + +func TestMatch(t *testing.T) { + tests := []struct { + name string + path string + pattern string + wantMatched bool + wantParams map[string]string + }{ + // Exact matches + {"exact root", "/", "/", true, map[string]string{}}, + {"exact path", "/users", "/users", true, map[string]string{}}, + {"exact nested", "/users/list", "/users/list", true, map[string]string{}}, + + // No match + {"no match different", "/users", "/posts", false, nil}, + {"no match extra segment", "/users/123", "/users", false, nil}, + {"no match missing segment", "/users", "/users/123", false, nil}, + + // Parameter extraction + {"single param", "/users/123", "/users/:id", true, map[string]string{"id": "123"}}, + {"multiple params", "/users/123/posts/456", "/users/:userId/posts/:postId", true, map[string]string{"userId": "123", "postId": "456"}}, + {"param at start", "/123/profile", "/:id/profile", true, map[string]string{"id": "123"}}, + + // Wildcard + {"wildcard", "/api/v1/users", "/api/*", true, map[string]string{}}, + {"wildcard nested", "/static/css/main.css", "/static/*", true, map[string]string{}}, + + // Trailing slashes + {"trailing slash path", "/users/", "/users", true, map[string]string{}}, + {"trailing slash pattern", "/users", "/users/", true, map[string]string{}}, + + // Edge cases + {"empty vs root", "", "/", true, map[string]string{}}, + {"param with hyphen", "/users/john-doe", "/users/:name", true, map[string]string{"name": "john-doe"}}, + {"param with underscore", "/users/john_doe", "/users/:name", true, map[string]string{"name": "john_doe"}}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Match(tt.path, tt.pattern) + if result.Matched != tt.wantMatched { + t.Errorf("Match(%q, %q).Matched = %v, want %v", tt.path, tt.pattern, result.Matched, tt.wantMatched) + } + if tt.wantMatched && !reflect.DeepEqual(result.Params, tt.wantParams) { + t.Errorf("Match(%q, %q).Params = %v, want %v", tt.path, tt.pattern, result.Params, tt.wantParams) + } + }) + } +} + +func TestSplitPath(t *testing.T) { + tests := []struct { + path string + expected []string + }{ + {"/", []string{}}, + {"", []string{}}, + {"/users", []string{"users"}}, + {"/users/123", []string{"users", "123"}}, + {"/users/123/posts", []string{"users", "123", "posts"}}, + {"users/123", []string{"users", "123"}}, + {"/users/", []string{"users"}}, + {"///multiple///slashes///", []string{"multiple", "slashes"}}, + } + + for _, tt := range tests { + t.Run(tt.path, func(t *testing.T) { + result := SplitPath(tt.path) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("SplitPath(%q) = %v, want %v", tt.path, result, tt.expected) + } + }) + } +} + +func TestBuildPath(t *testing.T) { + tests := []struct { + name string + pattern string + params map[string]string + expected string + }{ + {"no params", "/users", nil, "/users"}, + {"no params empty map", "/users", map[string]string{}, "/users"}, + {"single param", "/users/:id", map[string]string{"id": "123"}, "/users/123"}, + {"multiple params", "/users/:userId/posts/:postId", map[string]string{"userId": "42", "postId": "99"}, "/users/42/posts/99"}, + {"unused param", "/users/:id", map[string]string{"id": "123", "extra": "ignored"}, "/users/123"}, + {"missing param", "/users/:id", map[string]string{"other": "value"}, "/users/:id"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := BuildPath(tt.pattern, tt.params) + if result != tt.expected { + t.Errorf("BuildPath(%q, %v) = %q, want %q", tt.pattern, tt.params, result, tt.expected) + } + }) + } +} + +func TestFunctionPath(t *testing.T) { + tests := []struct { + name string + functionID string + pattern string + params map[string]string + expected string + }{ + {"simple", "my-func", "/", nil, "/fn/my-func/"}, + {"with path", "my-func", "/users", nil, "/fn/my-func/users"}, + {"with params", "my-func", "/users/:id", map[string]string{"id": "123"}, "/fn/my-func/users/123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FunctionPath(tt.functionID, tt.pattern, tt.params) + if result != tt.expected { + t.Errorf("FunctionPath(%q, %q, %v) = %q, want %q", tt.functionID, tt.pattern, tt.params, result, tt.expected) + } + }) + } +} + +func TestFunctionURL(t *testing.T) { + tests := []struct { + name string + baseURL string + functionID string + pattern string + params map[string]string + expected string + }{ + {"simple", "https://api.example.com", "my-func", "/", nil, "https://api.example.com/fn/my-func/"}, + {"with trailing slash", "https://api.example.com/", "my-func", "/users", nil, "https://api.example.com/fn/my-func/users"}, + {"with params", "https://api.example.com", "my-func", "/users/:id", map[string]string{"id": "123"}, "https://api.example.com/fn/my-func/users/123"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FunctionURL(tt.baseURL, tt.functionID, tt.pattern, tt.params) + if result != tt.expected { + t.Errorf("FunctionURL(%q, %q, %q, %v) = %q, want %q", tt.baseURL, tt.functionID, tt.pattern, tt.params, result, tt.expected) + } + }) + } +} diff --git a/internal/runtime/strings/doc.go b/internal/runtime/strings/doc.go new file mode 100644 index 0000000..4f37da3 --- /dev/null +++ b/internal/runtime/strings/doc.go @@ -0,0 +1,2 @@ +// Package strings provides string manipulation utility functions. +package strings diff --git a/internal/runtime/strings/strings.go b/internal/runtime/strings/strings.go new file mode 100644 index 0000000..7542d72 --- /dev/null +++ b/internal/runtime/strings/strings.go @@ -0,0 +1,64 @@ +package strings + +import gostrings "strings" + +// Trim removes leading and trailing whitespace from a string. +func Trim(s string) string { + return gostrings.TrimSpace(s) +} + +// TrimLeft removes leading whitespace from a string. +func TrimLeft(s string) string { + return gostrings.TrimLeft(s, " \t\n\r") +} + +// TrimRight removes trailing whitespace from a string. +func TrimRight(s string) string { + return gostrings.TrimRight(s, " \t\n\r") +} + +// Split splits a string by a separator and returns a slice of parts. +func Split(s, sep string) []string { + return gostrings.Split(s, sep) +} + +// Join joins a slice of strings with a separator. +func Join(parts []string, sep string) string { + return gostrings.Join(parts, sep) +} + +// HasPrefix checks if a string has the given prefix. +func HasPrefix(s, prefix string) bool { + return gostrings.HasPrefix(s, prefix) +} + +// HasSuffix checks if a string has the given suffix. +func HasSuffix(s, suffix string) bool { + return gostrings.HasSuffix(s, suffix) +} + +// Replace replaces occurrences of old with new in s. +// n is the number of replacements: -1 means replace all. +func Replace(s, old, new string, n int) string { + return gostrings.Replace(s, old, new, n) +} + +// ToLower converts a string to lowercase. +func ToLower(s string) string { + return gostrings.ToLower(s) +} + +// ToUpper converts a string to uppercase. +func ToUpper(s string) string { + return gostrings.ToUpper(s) +} + +// Contains checks if a string contains a substring. +func Contains(s, substr string) bool { + return gostrings.Contains(s, substr) +} + +// Repeat repeats a string n times. +func Repeat(s string, count int) string { + return gostrings.Repeat(s, count) +} diff --git a/internal/runtime/strings/strings_test.go b/internal/runtime/strings/strings_test.go new file mode 100644 index 0000000..65ead6e --- /dev/null +++ b/internal/runtime/strings/strings_test.go @@ -0,0 +1,256 @@ +package strings + +import ( + "reflect" + "testing" +) + +func TestTrim(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {" hello ", "hello"}, + {"hello", "hello"}, + {"\t\nhello\n\t", "hello"}, + {"", ""}, + {" ", ""}, + } + + for _, tt := range tests { + result := Trim(tt.input) + if result != tt.expected { + t.Errorf("Trim(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestTrimLeft(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {" hello ", "hello "}, + {"hello", "hello"}, + {"\t\nhello", "hello"}, + {"", ""}, + } + + for _, tt := range tests { + result := TrimLeft(tt.input) + if result != tt.expected { + t.Errorf("TrimLeft(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestTrimRight(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {" hello ", " hello"}, + {"hello", "hello"}, + {"hello\t\n", "hello"}, + {"", ""}, + } + + for _, tt := range tests { + result := TrimRight(tt.input) + if result != tt.expected { + t.Errorf("TrimRight(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestSplit(t *testing.T) { + tests := []struct { + s string + sep string + expected []string + }{ + {"a,b,c", ",", []string{"a", "b", "c"}}, + {"hello world", " ", []string{"hello", "world"}}, + {"no-sep", ",", []string{"no-sep"}}, + {"", ",", []string{""}}, + {"a::b", ":", []string{"a", "", "b"}}, + } + + for _, tt := range tests { + result := Split(tt.s, tt.sep) + if !reflect.DeepEqual(result, tt.expected) { + t.Errorf("Split(%q, %q) = %v, want %v", tt.s, tt.sep, result, tt.expected) + } + } +} + +func TestJoin(t *testing.T) { + tests := []struct { + parts []string + sep string + expected string + }{ + {[]string{"a", "b", "c"}, ",", "a,b,c"}, + {[]string{"hello", "world"}, " ", "hello world"}, + {[]string{"single"}, ",", "single"}, + {[]string{}, ",", ""}, + {[]string{"a", "", "b"}, ":", "a::b"}, + } + + for _, tt := range tests { + result := Join(tt.parts, tt.sep) + if result != tt.expected { + t.Errorf("Join(%v, %q) = %q, want %q", tt.parts, tt.sep, result, tt.expected) + } + } +} + +func TestHasPrefix(t *testing.T) { + tests := []struct { + s string + prefix string + expected bool + }{ + {"hello world", "hello", true}, + {"hello world", "world", false}, + {"hello", "hello", true}, + {"hello", "hello world", false}, + {"", "", true}, + {"hello", "", true}, + } + + for _, tt := range tests { + result := HasPrefix(tt.s, tt.prefix) + if result != tt.expected { + t.Errorf("HasPrefix(%q, %q) = %v, want %v", tt.s, tt.prefix, result, tt.expected) + } + } +} + +func TestHasSuffix(t *testing.T) { + tests := []struct { + s string + suffix string + expected bool + }{ + {"hello world", "world", true}, + {"hello world", "hello", false}, + {"hello", "hello", true}, + {"hello", "hello world", false}, + {"", "", true}, + {"hello", "", true}, + } + + for _, tt := range tests { + result := HasSuffix(tt.s, tt.suffix) + if result != tt.expected { + t.Errorf("HasSuffix(%q, %q) = %v, want %v", tt.s, tt.suffix, result, tt.expected) + } + } +} + +func TestReplace(t *testing.T) { + tests := []struct { + s string + old string + new string + n int + expected string + }{ + {"hello hello", "hello", "hi", -1, "hi hi"}, + {"hello hello", "hello", "hi", 1, "hi hello"}, + {"hello", "x", "y", -1, "hello"}, + {"aaa", "a", "b", 2, "bba"}, + {"", "a", "b", -1, ""}, + } + + for _, tt := range tests { + result := Replace(tt.s, tt.old, tt.new, tt.n) + if result != tt.expected { + t.Errorf("Replace(%q, %q, %q, %d) = %q, want %q", tt.s, tt.old, tt.new, tt.n, result, tt.expected) + } + } +} + +func TestToLower(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"HELLO", "hello"}, + {"Hello World", "hello world"}, + {"hello", "hello"}, + {"", ""}, + {"123ABC", "123abc"}, + } + + for _, tt := range tests { + result := ToLower(tt.input) + if result != tt.expected { + t.Errorf("ToLower(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestToUpper(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello", "HELLO"}, + {"Hello World", "HELLO WORLD"}, + {"HELLO", "HELLO"}, + {"", ""}, + {"123abc", "123ABC"}, + } + + for _, tt := range tests { + result := ToUpper(tt.input) + if result != tt.expected { + t.Errorf("ToUpper(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestContains(t *testing.T) { + tests := []struct { + s string + substr string + expected bool + }{ + {"hello world", "world", true}, + {"hello world", "hello", true}, + {"hello world", "xyz", false}, + {"hello", "hello world", false}, + {"", "", true}, + {"hello", "", true}, + } + + for _, tt := range tests { + result := Contains(tt.s, tt.substr) + if result != tt.expected { + t.Errorf("Contains(%q, %q) = %v, want %v", tt.s, tt.substr, result, tt.expected) + } + } +} + +func TestRepeat(t *testing.T) { + tests := []struct { + s string + count int + expected string + }{ + {"ab", 3, "ababab"}, + {"x", 5, "xxxxx"}, + {"hello", 1, "hello"}, + {"hello", 0, ""}, + {"", 5, ""}, + } + + for _, tt := range tests { + result := Repeat(tt.s, tt.count) + if result != tt.expected { + t.Errorf("Repeat(%q, %d) = %q, want %q", tt.s, tt.count, result, tt.expected) + } + } +} diff --git a/internal/runtime/time/doc.go b/internal/runtime/time/doc.go new file mode 100644 index 0000000..1b59993 --- /dev/null +++ b/internal/runtime/time/doc.go @@ -0,0 +1,2 @@ +// Package time provides time-related utility functions. +package time diff --git a/internal/runtime/time/time.go b/internal/runtime/time/time.go new file mode 100644 index 0000000..008b4bf --- /dev/null +++ b/internal/runtime/time/time.go @@ -0,0 +1,42 @@ +package time + +import ( + "context" + gotime "time" +) + +// Now returns the current Unix timestamp in seconds. +func Now() int64 { + return gotime.Now().Unix() +} + +// Format formats a Unix timestamp to a string using the given layout. +// Uses Go's time format layout (e.g., "2006-01-02 15:04:05"). +func Format(timestamp int64, layout string) string { + t := gotime.Unix(timestamp, 0) + return t.Format(layout) +} + +// Parse parses a time string according to a layout. +// Returns Unix timestamp or an error. +func Parse(timeStr, layout string) (int64, error) { + t, err := gotime.Parse(layout, timeStr) + if err != nil { + return 0, err + } + return t.Unix(), nil +} + +// Sleep sleeps for the specified number of milliseconds. +// Respects the provided context for cancellation. +// Returns true if sleep completed, false if cancelled. +func Sleep(ctx context.Context, milliseconds int64) bool { + duration := gotime.Duration(milliseconds) * gotime.Millisecond + + select { + case <-ctx.Done(): + return false + case <-gotime.After(duration): + return true + } +} diff --git a/internal/runtime/time/time_test.go b/internal/runtime/time/time_test.go new file mode 100644 index 0000000..9548802 --- /dev/null +++ b/internal/runtime/time/time_test.go @@ -0,0 +1,148 @@ +package time + +import ( + "context" + gostrings "strings" + "testing" + gotime "time" +) + +func TestNow(t *testing.T) { + before := gotime.Now().Unix() + result := Now() + after := gotime.Now().Unix() + + if result < before || result > after { + t.Errorf("Now() = %d, expected between %d and %d", result, before, after) + } +} + +func TestFormat(t *testing.T) { + // Test that Format produces valid output (timezone-agnostic) + timestamp := int64(1609459200) // 2021-01-01 00:00:00 UTC + + t.Run("date format", func(t *testing.T) { + result := Format(timestamp, "2006-01-02") + // Just check that it produces a valid date format + if len(result) != 10 { + t.Errorf("Format(%d, date) = %q, expected YYYY-MM-DD format", timestamp, result) + } + }) + + t.Run("custom format", func(t *testing.T) { + result := Format(timestamp, "Jan 2, 2006") + // Should contain "2021" + if !gostrings.Contains(result, "2021") { + t.Errorf("Format(%d, custom) = %q, expected to contain 2021", timestamp, result) + } + }) + + t.Run("time format", func(t *testing.T) { + result := Format(timestamp, "15:04:05") + // Should be a valid time format HH:MM:SS + if len(result) != 8 { + t.Errorf("Format(%d, time) = %q, expected HH:MM:SS format", timestamp, result) + } + }) +} + +func TestParse(t *testing.T) { + t.Run("valid date", func(t *testing.T) { + result, err := Parse("2021-01-01", "2006-01-02") + if err != nil { + t.Errorf("Parse returned error: %v", err) + return + } + // Check it's a reasonable timestamp (around Jan 2021) + if result < 1609400000 || result > 1609600000 { + t.Errorf("Parse returned unexpected timestamp: %d", result) + } + }) + + t.Run("custom format", func(t *testing.T) { + result, err := Parse("Jan 1, 2021", "Jan 2, 2006") + if err != nil { + t.Errorf("Parse returned error: %v", err) + return + } + // Check it's a reasonable timestamp + if result < 1609400000 || result > 1609600000 { + t.Errorf("Parse returned unexpected timestamp: %d", result) + } + }) + + t.Run("invalid format", func(t *testing.T) { + _, err := Parse("not-a-date", "2006-01-02") + if err == nil { + t.Error("Parse expected error, got nil") + } + }) + + t.Run("mismatched layout", func(t *testing.T) { + _, err := Parse("2021-01-01", "Jan 2, 2006") + if err == nil { + t.Error("Parse expected error, got nil") + } + }) +} + +func TestSleep(t *testing.T) { + t.Run("completes successfully", func(t *testing.T) { + ctx := context.Background() + start := gotime.Now() + completed := Sleep(ctx, 50) + elapsed := gotime.Since(start) + + if !completed { + t.Error("Sleep returned false, expected true") + } + if elapsed < 50*gotime.Millisecond { + t.Errorf("Sleep returned too quickly: %v", elapsed) + } + }) + + t.Run("cancelled context", func(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() // Cancel immediately + + completed := Sleep(ctx, 1000) + if completed { + t.Error("Sleep returned true on cancelled context, expected false") + } + }) + + t.Run("context timeout", func(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), 10*gotime.Millisecond) + defer cancel() + + start := gotime.Now() + completed := Sleep(ctx, 1000) // Try to sleep for 1 second + elapsed := gotime.Since(start) + + if completed { + t.Error("Sleep returned true, expected false due to timeout") + } + if elapsed > 100*gotime.Millisecond { + t.Errorf("Sleep took too long to respond to timeout: %v", elapsed) + } + }) +} + +func TestRoundTrip(t *testing.T) { + // Test that formatting and parsing are consistent + // Use date-only layout to avoid timezone issues + layout := "2006-01-02" + timestamp := int64(1609459200) // 2021-01-01 00:00:00 UTC + + formatted := Format(timestamp, layout) + parsed, err := Parse(formatted, layout) + if err != nil { + t.Fatalf("Round trip failed: %v", err) + } + + // Parse and format again - should get the same string + reformatted := Format(parsed, layout) + if formatted != reformatted { + t.Errorf("Round trip mismatch: %q -> %d -> %q", formatted, parsed, reformatted) + } +} diff --git a/internal/runtime/url/doc.go b/internal/runtime/url/doc.go new file mode 100644 index 0000000..95ff0b3 --- /dev/null +++ b/internal/runtime/url/doc.go @@ -0,0 +1,2 @@ +// Package url provides URL parsing, encoding, and decoding utility functions. +package url diff --git a/internal/runtime/url/url.go b/internal/runtime/url/url.go new file mode 100644 index 0000000..d7fc927 --- /dev/null +++ b/internal/runtime/url/url.go @@ -0,0 +1,56 @@ +package url + +import gourl "net/url" + +// ParsedURL represents a parsed URL with its components. +type ParsedURL struct { + Scheme string + Host string + Path string + Fragment string + Query map[string][]string + Username string + Password string +} + +// Parse parses a URL string into its components. +func Parse(rawURL string) (*ParsedURL, error) { + parsedURL, err := gourl.Parse(rawURL) + if err != nil { + return nil, err + } + + result := &ParsedURL{ + Scheme: parsedURL.Scheme, + Host: parsedURL.Host, + Path: parsedURL.Path, + Fragment: parsedURL.Fragment, + Query: make(map[string][]string), + } + + // Parse query parameters + if parsedURL.RawQuery != "" { + result.Query = parsedURL.Query() + } + + // Add username and password if present + if parsedURL.User != nil { + result.Username = parsedURL.User.Username() + if password, ok := parsedURL.User.Password(); ok { + result.Password = password + } + } + + return result, nil +} + +// Encode URL-encodes a string. +func Encode(s string) string { + return gourl.QueryEscape(s) +} + +// Decode URL-decodes a string. +// Returns the decoded string or an error. +func Decode(s string) (string, error) { + return gourl.QueryUnescape(s) +} diff --git a/internal/runtime/url/url_test.go b/internal/runtime/url/url_test.go new file mode 100644 index 0000000..222e8a0 --- /dev/null +++ b/internal/runtime/url/url_test.go @@ -0,0 +1,203 @@ +package url + +import ( + "reflect" + "testing" +) + +func TestParse(t *testing.T) { + tests := []struct { + name string + rawURL string + expected *ParsedURL + expectErr bool + }{ + { + name: "simple URL", + rawURL: "https://example.com/path", + expected: &ParsedURL{ + Scheme: "https", + Host: "example.com", + Path: "/path", + Query: map[string][]string{}, + }, + }, + { + name: "URL with query params", + rawURL: "https://example.com/search?q=hello&page=1", + expected: &ParsedURL{ + Scheme: "https", + Host: "example.com", + Path: "/search", + Query: map[string][]string{ + "q": {"hello"}, + "page": {"1"}, + }, + }, + }, + { + name: "URL with fragment", + rawURL: "https://example.com/page#section", + expected: &ParsedURL{ + Scheme: "https", + Host: "example.com", + Path: "/page", + Fragment: "section", + Query: map[string][]string{}, + }, + }, + { + name: "URL with user info", + rawURL: "https://user:pass@example.com/path", + expected: &ParsedURL{ + Scheme: "https", + Host: "example.com", + Path: "/path", + Username: "user", + Password: "pass", + Query: map[string][]string{}, + }, + }, + { + name: "URL with port", + rawURL: "https://example.com:8080/api", + expected: &ParsedURL{ + Scheme: "https", + Host: "example.com:8080", + Path: "/api", + Query: map[string][]string{}, + }, + }, + { + name: "URL with multiple query values", + rawURL: "https://example.com?tag=a&tag=b&tag=c", + expected: &ParsedURL{ + Scheme: "https", + Host: "example.com", + Query: map[string][]string{ + "tag": {"a", "b", "c"}, + }, + }, + }, + { + name: "invalid URL", + rawURL: "://invalid", + expectErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.rawURL) + if tt.expectErr { + if err == nil { + t.Errorf("Parse(%q) expected error, got nil", tt.rawURL) + } + return + } + if err != nil { + t.Errorf("Parse(%q) unexpected error: %v", tt.rawURL, err) + return + } + if result.Scheme != tt.expected.Scheme { + t.Errorf("Parse(%q).Scheme = %q, want %q", tt.rawURL, result.Scheme, tt.expected.Scheme) + } + if result.Host != tt.expected.Host { + t.Errorf("Parse(%q).Host = %q, want %q", tt.rawURL, result.Host, tt.expected.Host) + } + if result.Path != tt.expected.Path { + t.Errorf("Parse(%q).Path = %q, want %q", tt.rawURL, result.Path, tt.expected.Path) + } + if result.Fragment != tt.expected.Fragment { + t.Errorf("Parse(%q).Fragment = %q, want %q", tt.rawURL, result.Fragment, tt.expected.Fragment) + } + if result.Username != tt.expected.Username { + t.Errorf("Parse(%q).Username = %q, want %q", tt.rawURL, result.Username, tt.expected.Username) + } + if result.Password != tt.expected.Password { + t.Errorf("Parse(%q).Password = %q, want %q", tt.rawURL, result.Password, tt.expected.Password) + } + if !reflect.DeepEqual(result.Query, tt.expected.Query) { + t.Errorf("Parse(%q).Query = %v, want %v", tt.rawURL, result.Query, tt.expected.Query) + } + }) + } +} + +func TestEncode(t *testing.T) { + tests := []struct { + input string + expected string + }{ + {"hello world", "hello+world"}, + {"hello+world", "hello%2Bworld"}, + {"a=b&c=d", "a%3Db%26c%3Dd"}, + {"special!@#$%", "special%21%40%23%24%25"}, + {"unicode 你好", "unicode+%E4%BD%A0%E5%A5%BD"}, + {"", ""}, + {"no-encoding-needed", "no-encoding-needed"}, + } + + for _, tt := range tests { + result := Encode(tt.input) + if result != tt.expected { + t.Errorf("Encode(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestDecode(t *testing.T) { + tests := []struct { + input string + expected string + expectErr bool + }{ + {"hello+world", "hello world", false}, + {"hello%20world", "hello world", false}, + {"hello%2Bworld", "hello+world", false}, + {"a%3Db%26c%3Dd", "a=b&c=d", false}, + {"unicode+%E4%BD%A0%E5%A5%BD", "unicode 你好", false}, + {"", "", false}, + {"no-encoding", "no-encoding", false}, + {"%ZZ", "", true}, // Invalid hex + } + + for _, tt := range tests { + result, err := Decode(tt.input) + if tt.expectErr { + if err == nil { + t.Errorf("Decode(%q) expected error, got nil", tt.input) + } + continue + } + if err != nil { + t.Errorf("Decode(%q) unexpected error: %v", tt.input, err) + continue + } + if result != tt.expected { + t.Errorf("Decode(%q) = %q, want %q", tt.input, result, tt.expected) + } + } +} + +func TestRoundTrip(t *testing.T) { + inputs := []string{ + "hello world", + "special chars: !@#$%^&*()", + "unicode: 你好", + "path/to/resource", + "query=value&other=123", + } + + for _, input := range inputs { + encoded := Encode(input) + decoded, err := Decode(encoded) + if err != nil { + t.Errorf("Round trip failed for %q: %v", input, err) + continue + } + if decoded != input { + t.Errorf("Round trip mismatch: %q -> %q -> %q", input, encoded, decoded) + } + } +} diff --git a/internal/ai/ai.go b/internal/services/ai/ai.go similarity index 97% rename from internal/ai/ai.go rename to internal/services/ai/ai.go index 567aa21..bbe075d 100644 --- a/internal/ai/ai.go +++ b/internal/services/ai/ai.go @@ -4,8 +4,8 @@ import ( "encoding/json" "fmt" - "github.com/dimiro1/lunar/internal/env" - internalhttp "github.com/dimiro1/lunar/internal/http" + "github.com/dimiro1/lunar/internal/services/env" + internalhttp "github.com/dimiro1/lunar/internal/services/http" ) // provider defines the interface for AI provider implementations. diff --git a/internal/ai/ai_test.go b/internal/services/ai/ai_test.go similarity index 98% rename from internal/ai/ai_test.go rename to internal/services/ai/ai_test.go index e56e834..194f1ea 100644 --- a/internal/ai/ai_test.go +++ b/internal/services/ai/ai_test.go @@ -6,8 +6,8 @@ import ( "net/http/httptest" "testing" - "github.com/dimiro1/lunar/internal/env" - internalhttp "github.com/dimiro1/lunar/internal/http" + "github.com/dimiro1/lunar/internal/services/env" + internalhttp "github.com/dimiro1/lunar/internal/services/http" ) func TestNewDefaultClient(t *testing.T) { diff --git a/internal/ai/anthropic.go b/internal/services/ai/anthropic.go similarity index 97% rename from internal/ai/anthropic.go rename to internal/services/ai/anthropic.go index 24c2acd..0657f73 100644 --- a/internal/ai/anthropic.go +++ b/internal/services/ai/anthropic.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - internalhttp "github.com/dimiro1/lunar/internal/http" + internalhttp "github.com/dimiro1/lunar/internal/services/http" ) // anthropicProvider implements provider for Anthropic diff --git a/internal/ai/doc.go b/internal/services/ai/doc.go similarity index 100% rename from internal/ai/doc.go rename to internal/services/ai/doc.go diff --git a/internal/ai/openai.go b/internal/services/ai/openai.go similarity index 96% rename from internal/ai/openai.go rename to internal/services/ai/openai.go index 4d8f77c..aef44af 100644 --- a/internal/ai/openai.go +++ b/internal/services/ai/openai.go @@ -4,7 +4,7 @@ import ( "encoding/json" "fmt" - internalhttp "github.com/dimiro1/lunar/internal/http" + internalhttp "github.com/dimiro1/lunar/internal/services/http" ) // openAIProvider implements provider for OpenAI diff --git a/internal/ai/tracker.go b/internal/services/ai/tracker.go similarity index 100% rename from internal/ai/tracker.go rename to internal/services/ai/tracker.go diff --git a/internal/ai/tracker_test.go b/internal/services/ai/tracker_test.go similarity index 100% rename from internal/ai/tracker_test.go rename to internal/services/ai/tracker_test.go diff --git a/internal/email/doc.go b/internal/services/email/doc.go similarity index 100% rename from internal/email/doc.go rename to internal/services/email/doc.go diff --git a/internal/email/email.go b/internal/services/email/email.go similarity index 98% rename from internal/email/email.go rename to internal/services/email/email.go index 8d5802c..8464a9b 100644 --- a/internal/email/email.go +++ b/internal/services/email/email.go @@ -3,7 +3,7 @@ package email import ( "net/url" - "github.com/dimiro1/lunar/internal/env" + "github.com/dimiro1/lunar/internal/services/env" "github.com/resend/resend-go/v3" ) diff --git a/internal/email/email_test.go b/internal/services/email/email_test.go similarity index 100% rename from internal/email/email_test.go rename to internal/services/email/email_test.go diff --git a/internal/email/tracker.go b/internal/services/email/tracker.go similarity index 100% rename from internal/email/tracker.go rename to internal/services/email/tracker.go diff --git a/internal/email/tracker_test.go b/internal/services/email/tracker_test.go similarity index 100% rename from internal/email/tracker_test.go rename to internal/services/email/tracker_test.go diff --git a/internal/env/doc.go b/internal/services/env/doc.go similarity index 100% rename from internal/env/doc.go rename to internal/services/env/doc.go diff --git a/internal/env/env.go b/internal/services/env/env.go similarity index 100% rename from internal/env/env.go rename to internal/services/env/env.go diff --git a/internal/env/env_test.go b/internal/services/env/env_test.go similarity index 100% rename from internal/env/env_test.go rename to internal/services/env/env_test.go diff --git a/internal/http/doc.go b/internal/services/http/doc.go similarity index 100% rename from internal/http/doc.go rename to internal/services/http/doc.go diff --git a/internal/http/http_client.go b/internal/services/http/http_client.go similarity index 100% rename from internal/http/http_client.go rename to internal/services/http/http_client.go diff --git a/internal/http/http_client_test.go b/internal/services/http/http_client_test.go similarity index 100% rename from internal/http/http_client_test.go rename to internal/services/http/http_client_test.go diff --git a/internal/kv/doc.go b/internal/services/kv/doc.go similarity index 100% rename from internal/kv/doc.go rename to internal/services/kv/doc.go diff --git a/internal/kv/kv.go b/internal/services/kv/kv.go similarity index 100% rename from internal/kv/kv.go rename to internal/services/kv/kv.go diff --git a/internal/kv/kv_test.go b/internal/services/kv/kv_test.go similarity index 100% rename from internal/kv/kv_test.go rename to internal/services/kv/kv_test.go diff --git a/internal/logger/doc.go b/internal/services/logger/doc.go similarity index 100% rename from internal/logger/doc.go rename to internal/services/logger/doc.go diff --git a/internal/logger/logger.go b/internal/services/logger/logger.go similarity index 100% rename from internal/logger/logger.go rename to internal/services/logger/logger.go diff --git a/internal/logger/logger_test.go b/internal/services/logger/logger_test.go similarity index 100% rename from internal/logger/logger_test.go rename to internal/services/logger/logger_test.go From 109aa54b52051f893608f4c9b621c76dd888c733 Mon Sep 17 00:00:00 2001 From: dimiro1 Date: Sun, 14 Dec 2025 23:46:23 +0100 Subject: [PATCH 2/2] Introduce the concept of a engine and runtime. - Currently the only runtime is lua - The work was done to be able to later support other runtimes such as wasm. --- internal/api/handlers.go | 309 +++++++++++---------------------- internal/api/server.go | 41 ++++- internal/engine/doc.go | 40 +++++ internal/engine/engine.go | 230 ++++++++++++++++++++++++ internal/engine/engine_test.go | 241 +++++++++++++++++++++++++ internal/engine/errors.go | 43 +++++ internal/engine/request.go | 21 +++ internal/engine/result.go | 29 ++++ internal/engine/runtime.go | 34 ++++ internal/runner/lua_runtime.go | 88 ++++++++++ 10 files changed, 866 insertions(+), 210 deletions(-) create mode 100644 internal/engine/doc.go create mode 100644 internal/engine/engine.go create mode 100644 internal/engine/engine_test.go create mode 100644 internal/engine/errors.go create mode 100644 internal/engine/request.go create mode 100644 internal/engine/result.go create mode 100644 internal/engine/runtime.go create mode 100644 internal/runner/lua_runtime.go diff --git a/internal/api/handlers.go b/internal/api/handlers.go index ca02136..de957bc 100644 --- a/internal/api/handlers.go +++ b/internal/api/handlers.go @@ -2,41 +2,29 @@ package api import ( "encoding/json" + "errors" "io" "log/slog" "net/http" "strconv" "strings" - "time" - "github.com/dimiro1/lunar/internal/services/ai" internalcron "github.com/dimiro1/lunar/internal/cron" "github.com/dimiro1/lunar/internal/diff" + "github.com/dimiro1/lunar/internal/engine" + "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/services/ai" "github.com/dimiro1/lunar/internal/services/email" "github.com/dimiro1/lunar/internal/services/env" - "github.com/dimiro1/lunar/internal/events" - internalhttp "github.com/dimiro1/lunar/internal/services/http" - "github.com/dimiro1/lunar/internal/services/kv" "github.com/dimiro1/lunar/internal/services/logger" - "github.com/dimiro1/lunar/internal/masking" - "github.com/dimiro1/lunar/internal/runner" "github.com/dimiro1/lunar/internal/store" "github.com/rs/xid" ) // ExecuteFunctionDeps holds dependencies for executing functions type ExecuteFunctionDeps struct { - DB store.DB - Logger logger.Logger - KVStore kv.Store - EnvStore env.Store - HTTPClient internalhttp.Client - AIClient ai.Client - AITracker ai.Tracker - EmailClient email.Client - EmailTracker email.Tracker - ExecutionTimeout time.Duration - BaseURL string + Engine engine.Engine + BaseURL string } // Helper functions @@ -101,30 +89,6 @@ func generateDiff(oldCode, newCode string, oldVersion, newVersion int) VersionDi } } -// MaxResponseBodySize is the maximum size of response body to store (1MB) -const MaxResponseBodySize = 1024 * 1024 - -// serializeHTTPResponse converts an HTTPResponse to a JSON string for storage. -// If the response body exceeds MaxResponseBodySize, it is truncated. -func serializeHTTPResponse(resp *events.HTTPResponse) string { - // Create a copy to avoid modifying the original response - respToStore := *resp - - // Truncate the body if it exceeds the maximum size - if len(respToStore.Body) > MaxResponseBodySize { - respToStore.Body = respToStore.Body[:MaxResponseBodySize] + "\n[TRUNCATED - Response exceeded 1MB]" - } - - jsonBytes, err := json.Marshal(respToStore) - if err != nil { - slog.Error("Failed to serialize HTTP response", "error", err) - return "{}" - } - return string(jsonBytes) -} - -// Functional handler factories - each handler explicitly declares its dependencies - // CreateFunctionHandler returns a handler for creating functions func CreateFunctionHandler(database store.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { @@ -662,201 +626,138 @@ func GetExecutionEmailRequestsHandler(database store.DB, emailTracker email.Trac // ExecuteFunctionHandler returns a handler for executing functions func ExecuteFunctionHandler(deps ExecuteFunctionDeps) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - startTime := time.Now() functionID := r.PathValue("function_id") - executionID := generateID() - - // Get the function - fn, err := deps.DB.GetFunction(r.Context(), functionID) - if err != nil { - writeError(w, http.StatusNotFound, "Function not found") - return - } - - // Check if a function is disabled - if fn.Disabled { - writeError(w, http.StatusForbidden, "Function is disabled") - return - } - - // Get the active version - version, err := deps.DB.GetActiveVersion(r.Context(), functionID) - if err != nil { - writeError(w, http.StatusInternalServerError, "No active version found") - return - } - // Read the request body - body, err := io.ReadAll(r.Body) + // Parse HTTP event from request + httpEvent, err := parseHTTPEvent(r, functionID) if err != nil { writeError(w, http.StatusBadRequest, "Failed to read request body") return } - // Compute relativePath by stripping /fn/{function_id} prefix - prefix := "/fn/" + functionID - relativePath := strings.TrimPrefix(r.URL.Path, prefix) - if relativePath == "" { - relativePath = "/" - } - - // Create an HTTP event from the request - httpEvent := events.HTTPEvent{ - Method: r.Method, - Path: r.URL.Path, - RelativePath: relativePath, - Headers: make(map[string]string), - Body: string(body), - Query: make(map[string]string), - } - - // Copy headers - for key, values := range r.Header { - if len(values) > 0 { - httpEvent.Headers[key] = values[0] - } - } - - // Copy query parameters - for key, values := range r.URL.Query() { - if len(values) > 0 { - httpEvent.Query[key] = values[0] - } - } - - // Create execution context - execContext := &events.ExecutionContext{ - ExecutionID: executionID, - FunctionID: functionID, - StartedAt: time.Now().Unix(), - Version: strconv.Itoa(version.Version), - BaseURL: deps.BaseURL, - } - - // Mask sensitive data in the event before storing - maskedEvent := masking.MaskHTTPEvent(httpEvent) - - // Serialize the masked event to JSON for storage - eventJSONBytes, err := json.Marshal(maskedEvent) - if err != nil { - writeError(w, http.StatusInternalServerError, "Failed to serialize event") - return - } - eventJSONStr := string(eventJSONBytes) - // Determine trigger (from X-Trigger header or default to HTTP) trigger := store.ExecutionTriggerHTTP if r.Header.Get("X-Trigger") == "cron" { trigger = store.ExecutionTriggerCron } - // Create an execution record - execution := store.Execution{ - ID: executionID, - FunctionID: functionID, - FunctionVersionID: version.ID, - Status: store.ExecutionStatusPending, - EventJSON: &eventJSONStr, - Trigger: trigger, - } - - _, err = deps.DB.CreateExecution(r.Context(), execution) + // Execute via engine + result, err := deps.Engine.Execute(r.Context(), engine.ExecutionRequest{ + FunctionID: functionID, + Event: httpEvent, + Trigger: trigger, + BaseURL: deps.BaseURL, + }) + // Handle engine errors if err != nil { - writeError(w, http.StatusInternalServerError, "Failed to create execution record") + handleEngineError(w, err) return } - // Prepare runner dependencies - runnerDeps := runner.Dependencies{ - Logger: deps.Logger, - KV: deps.KVStore, - Env: deps.EnvStore, - HTTP: deps.HTTPClient, - AI: deps.AIClient, - AITracker: deps.AITracker, - Email: deps.EmailClient, - EmailTracker: deps.EmailTracker, - Timeout: deps.ExecutionTimeout, - } + // Set execution metadata headers + w.Header().Set("X-Function-Id", functionID) + w.Header().Set("X-Function-Version-Id", result.FunctionVersionID) + w.Header().Set("X-Execution-Id", result.ExecutionID) + w.Header().Set("X-Execution-Duration-Ms", strconv.FormatInt(result.Duration.Milliseconds(), 10)) - // Execute the function - req := runner.Request{ - Context: execContext, - Event: httpEvent, - Code: version.Code, + // Handle execution errors + if result.Error != nil { + slog.Error("Function execution failed", + "execution_id", result.ExecutionID, + "function_id", functionID, + "error", result.Error) + writeError(w, http.StatusInternalServerError, "Function execution failed") + return } - resp, runErr := runner.Run(r.Context(), runnerDeps, req) + // Write HTTP response + writeExecutionResponse(w, result) + } +} - // Calculate duration - duration := time.Since(startTime).Milliseconds() +// parseHTTPEvent creates an HTTPEvent from an HTTP request +func parseHTTPEvent(r *http.Request, functionID string) (events.HTTPEvent, error) { + body, err := io.ReadAll(r.Body) + if err != nil { + return events.HTTPEvent{}, err + } - // Determine execution status - var errorMsg *string - status := store.ExecutionStatusSuccess + // Compute relativePath by stripping /fn/{function_id} prefix + prefix := "/fn/" + functionID + relativePath := strings.TrimPrefix(r.URL.Path, prefix) + if relativePath == "" { + relativePath = "/" + } - if runErr != nil { - status = store.ExecutionStatusError - errStr := runErr.Error() - errorMsg = &errStr - } else if resp.HTTP != nil && resp.HTTP.StatusCode >= 400 { - // Mark as error if the function returns an error status code - status = store.ExecutionStatusError - } + httpEvent := events.HTTPEvent{ + Method: r.Method, + Path: r.URL.Path, + RelativePath: relativePath, + Headers: make(map[string]string), + Body: string(body), + Query: make(map[string]string), + } - // Save response JSON if function has SaveResponse enabled - var responseJSON *string - if fn.SaveResponse && resp.HTTP != nil { - responseJSONStr := serializeHTTPResponse(resp.HTTP) - responseJSON = &responseJSONStr + // Copy headers + for key, values := range r.Header { + if len(values) > 0 { + httpEvent.Headers[key] = values[0] } + } - if err := deps.DB.UpdateExecution(r.Context(), executionID, status, &duration, errorMsg, responseJSON); err != nil { - slog.Error("Failed to update execution status", "execution_id", executionID, "error", err) + // Copy query parameters + for key, values := range r.URL.Query() { + if len(values) > 0 { + httpEvent.Query[key] = values[0] } + } - // Set custom headers - w.Header().Set("X-Function-Id", functionID) - w.Header().Set("X-Function-Version-Id", version.ID) - w.Header().Set("X-Execution-Id", executionID) - w.Header().Set("X-Execution-Duration-Ms", strconv.FormatInt(duration, 10)) + return httpEvent, nil +} - // If execution failed, log details and return a generic error - if runErr != nil { - deps.Logger.Error(functionID, runErr.Error()) - slog.Error("Function execution failed", - "execution_id", executionID, - "function_id", functionID, - "error", runErr) - writeError(w, http.StatusInternalServerError, "Function execution failed") - return - } +// handleEngineError writes the appropriate HTTP error for engine errors +func handleEngineError(w http.ResponseWriter, err error) { + var fnNotFound *engine.FunctionNotFoundError + var fnDisabled *engine.FunctionDisabledError + var noVersion *engine.NoActiveVersionError + + switch { + case errors.As(err, &fnNotFound): + writeError(w, http.StatusNotFound, "Function not found") + case errors.As(err, &fnDisabled): + writeError(w, http.StatusForbidden, "Function is disabled") + case errors.As(err, &noVersion): + writeError(w, http.StatusInternalServerError, "No active version found") + default: + writeError(w, http.StatusInternalServerError, "Internal server error") + } +} - // Return HTTP response - if resp.HTTP != nil { - // Set custom headers from function response - for key, value := range resp.HTTP.Headers { - w.Header().Set(key, value) - } +// writeExecutionResponse writes the function's HTTP response to the client +func writeExecutionResponse(w http.ResponseWriter, result *engine.ExecutionResult) { + if result.Response == nil { + writeError(w, http.StatusInternalServerError, "Function did not return HTTP response") + return + } - // Set the status code - statusCode := resp.HTTP.StatusCode - if statusCode == 0 { - statusCode = http.StatusOK - } + // Set custom headers from function response + for key, value := range result.Response.Headers { + w.Header().Set(key, value) + } - // Write response - // Only set default Content-Type if the function didn't provide one - if w.Header().Get("Content-Type") == "" { - w.Header().Set("Content-Type", "application/json") - } - w.WriteHeader(statusCode) - _, _ = w.Write([]byte(resp.HTTP.Body)) - } else { - // No HTTP response, return 500 - writeError(w, http.StatusInternalServerError, "Function did not return HTTP response") - } + // Set the status code + statusCode := result.Response.StatusCode + if statusCode == 0 { + statusCode = http.StatusOK } + + // Only set default Content-Type if the function didn't provide one + if w.Header().Get("Content-Type") == "" { + w.Header().Set("Content-Type", "application/json") + } + + w.WriteHeader(statusCode) + _, _ = w.Write([]byte(result.Response.Body)) } // GetNextRunHandler returns a handler for getting the next scheduled run time diff --git a/internal/api/server.go b/internal/api/server.go index 9c48828..5fe6ff5 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -8,11 +8,14 @@ import ( "github.com/dimiro1/lunar/internal/services/ai" internalcron "github.com/dimiro1/lunar/internal/cron" "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/engine" "github.com/dimiro1/lunar/internal/services/env" internalhttp "github.com/dimiro1/lunar/internal/services/http" "github.com/dimiro1/lunar/internal/services/kv" "github.com/dimiro1/lunar/internal/services/logger" + "github.com/dimiro1/lunar/internal/runner" "github.com/dimiro1/lunar/internal/store" + "github.com/rs/xid" ) // Server represents the API server @@ -20,6 +23,7 @@ type Server struct { mux *http.ServeMux db store.DB execDeps *ExecuteFunctionDeps + envStore env.Store logger logger.Logger aiTracker ai.Tracker emailTracker email.Tracker @@ -47,24 +51,49 @@ type ServerConfig struct { // NewServer creates a new API server with full configuration func NewServer(config ServerConfig) *Server { - execDeps := &ExecuteFunctionDeps{ + // Create AI and Email clients + aiClient := ai.NewDefaultClient(config.HTTPClient, config.EnvStore) + emailClient := email.NewDefaultClient(config.EnvStore) + + // Create Lua runtime + luaRuntime := runner.NewLuaRuntime(runner.LuaRuntimeConfig{ + Logger: config.Logger, + KV: config.KVStore, + Env: config.EnvStore, + HTTP: config.HTTPClient, + AI: aiClient, + AITracker: config.AITracker, + Email: emailClient, + EmailTracker: config.EmailTracker, + Timeout: config.ExecutionTimeout, + }) + + // Create execution engine + eng := engine.New(engine.Config{ DB: config.DB, + Runtime: luaRuntime, Logger: config.Logger, KVStore: config.KVStore, EnvStore: config.EnvStore, HTTPClient: config.HTTPClient, - AIClient: ai.NewDefaultClient(config.HTTPClient, config.EnvStore), + AIClient: aiClient, AITracker: config.AITracker, - EmailClient: email.NewDefaultClient(config.EnvStore), + EmailClient: emailClient, EmailTracker: config.EmailTracker, ExecutionTimeout: config.ExecutionTimeout, - BaseURL: config.BaseURL, + IDGenerator: func() string { return xid.New().String() }, + }) + + execDeps := &ExecuteFunctionDeps{ + Engine: eng, + BaseURL: config.BaseURL, } s := &Server{ mux: http.NewServeMux(), db: config.DB, execDeps: execDeps, + envStore: config.EnvStore, logger: config.Logger, aiTracker: config.AITracker, emailTracker: config.EmailTracker, @@ -95,10 +124,10 @@ func (s *Server) setupRoutes() { // Function Management - only need DB s.mux.Handle("POST /api/functions", authMiddleware(http.HandlerFunc(CreateFunctionHandler(s.db)))) s.mux.Handle("GET /api/functions", authMiddleware(http.HandlerFunc(ListFunctionsHandler(s.db)))) - s.mux.Handle("GET /api/functions/{id}", authMiddleware(http.HandlerFunc(GetFunctionHandler(s.db, s.execDeps.EnvStore)))) + s.mux.Handle("GET /api/functions/{id}", authMiddleware(http.HandlerFunc(GetFunctionHandler(s.db, s.envStore)))) s.mux.Handle("PUT /api/functions/{id}", authMiddleware(http.HandlerFunc(UpdateFunctionHandler(s.db, s.scheduler)))) s.mux.Handle("DELETE /api/functions/{id}", authMiddleware(http.HandlerFunc(DeleteFunctionHandler(s.db)))) - s.mux.Handle("PUT /api/functions/{id}/env", authMiddleware(http.HandlerFunc(UpdateEnvVarsHandler(s.db, s.execDeps.EnvStore)))) + s.mux.Handle("PUT /api/functions/{id}/env", authMiddleware(http.HandlerFunc(UpdateEnvVarsHandler(s.db, s.envStore)))) s.mux.Handle("GET /api/functions/{id}/next-run", authMiddleware(http.HandlerFunc(GetNextRunHandler(s.db)))) // Version Management - only need DB diff --git a/internal/engine/doc.go b/internal/engine/doc.go new file mode 100644 index 0000000..5e89d07 --- /dev/null +++ b/internal/engine/doc.go @@ -0,0 +1,40 @@ +// Package engine provides the execution orchestration layer for function execution. +// +// The engine package separates concerns between: +// - HTTP handling (in the api package) +// - Execution orchestration (this package) +// - Language-specific execution (runtime implementations) +// +// # Architecture +// +// The engine package defines two main interfaces: +// +// Runtime: Implemented by language-specific executors (Lua, JavaScript, etc.) +// that handle the actual code execution. +// +// Engine: Orchestrates the complete execution lifecycle including: +// - Function and version retrieval +// - Execution record management +// - Event masking for storage +// - Runtime invocation +// - Status tracking and duration measurement +// +// # Usage +// +// Create an engine with all required dependencies: +// +// eng := engine.New(engine.Config{ +// DB: db, +// Runtime: luaRuntime, +// Logger: logger, +// // ... other dependencies +// }) +// +// Execute a function: +// +// result, err := eng.Execute(ctx, engine.ExecutionRequest{ +// FunctionID: "my-function", +// Event: httpEvent, +// Trigger: store.ExecutionTriggerHTTP, +// }) +package engine diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..1aba4f6 --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,230 @@ +package engine + +import ( + "context" + "encoding/json" + "log/slog" + "strconv" + "time" + + "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/masking" + "github.com/dimiro1/lunar/internal/services/ai" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" + "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" + "github.com/dimiro1/lunar/internal/store" +) + +// Engine orchestrates function execution with full lifecycle management. +type Engine interface { + // Execute runs a function with the given request and returns the result. + Execute(ctx context.Context, req ExecutionRequest) (*ExecutionResult, error) +} + +// Config holds all dependencies needed to create an engine. +type Config struct { + DB store.DB + Runtime Runtime + Logger logger.Logger + KVStore kv.Store + EnvStore env.Store + HTTPClient http.Client + AIClient ai.Client + AITracker ai.Tracker + EmailClient email.Client + EmailTracker email.Tracker + ExecutionTimeout time.Duration + IDGenerator func() string +} + +// DefaultEngine is the default implementation of the Engine interface. +type DefaultEngine struct { + db store.DB + runtime Runtime + logger logger.Logger + kvStore kv.Store + envStore env.Store + httpClient http.Client + aiClient ai.Client + aiTracker ai.Tracker + emailClient email.Client + emailTracker email.Tracker + executionTimeout time.Duration + idGenerator func() string +} + +// New creates a new DefaultEngine with the given configuration. +func New(cfg Config) *DefaultEngine { + return &DefaultEngine{ + db: cfg.DB, + runtime: cfg.Runtime, + logger: cfg.Logger, + kvStore: cfg.KVStore, + envStore: cfg.EnvStore, + httpClient: cfg.HTTPClient, + aiClient: cfg.AIClient, + aiTracker: cfg.AITracker, + emailClient: cfg.EmailClient, + emailTracker: cfg.EmailTracker, + executionTimeout: cfg.ExecutionTimeout, + idGenerator: cfg.IDGenerator, + } +} + +// Execute runs a function with full lifecycle management. +func (e *DefaultEngine) Execute(ctx context.Context, req ExecutionRequest) (*ExecutionResult, error) { + startTime := time.Now() + executionID := e.idGenerator() + + // Get the function + fn, err := e.db.GetFunction(ctx, req.FunctionID) + if err != nil { + return nil, &FunctionNotFoundError{FunctionID: req.FunctionID} + } + + // Check if function is disabled + if fn.Disabled { + return nil, &FunctionDisabledError{FunctionID: req.FunctionID} + } + + // Get the active version + version, err := e.db.GetActiveVersion(ctx, req.FunctionID) + if err != nil { + return nil, &NoActiveVersionError{FunctionID: req.FunctionID} + } + + // Create execution context + execContext := &events.ExecutionContext{ + ExecutionID: executionID, + FunctionID: req.FunctionID, + StartedAt: time.Now().Unix(), + Version: strconv.Itoa(version.Version), + BaseURL: req.BaseURL, + } + + // Mask and serialize the event for storage + eventJSONStr, err := e.serializeEvent(req.Event) + if err != nil { + return nil, err + } + + // Create execution record + execution := store.Execution{ + ID: executionID, + FunctionID: req.FunctionID, + FunctionVersionID: version.ID, + Status: store.ExecutionStatusPending, + EventJSON: &eventJSONStr, + Trigger: req.Trigger, + } + + if _, err := e.db.CreateExecution(ctx, execution); err != nil { + return nil, &ExecutionRecordError{Err: err} + } + + // Execute via runtime + runtimeReq := RuntimeRequest{ + Code: version.Code, + Context: execContext, + Event: req.Event, + } + + runtimeResult, runErr := e.runtime.Execute(ctx, runtimeReq) + + // Calculate duration + duration := time.Since(startTime) + durationMs := duration.Milliseconds() + + // Determine execution status + var errorMsg *string + status := store.ExecutionStatusSuccess + + if runErr != nil { + status = store.ExecutionStatusError + errStr := runErr.Error() + errorMsg = &errStr + } else if runtimeResult != nil && runtimeResult.Response != nil && runtimeResult.Response.StatusCode >= 400 { + status = store.ExecutionStatusError + } + + // Save response JSON if function has SaveResponse enabled + var responseJSON *string + if fn.SaveResponse && runtimeResult != nil && runtimeResult.Response != nil { + responseJSONStr := serializeHTTPResponse(runtimeResult.Response) + responseJSON = &responseJSONStr + } + + // Update execution record + if err := e.db.UpdateExecution(ctx, executionID, status, &durationMs, errorMsg, responseJSON); err != nil { + slog.Error("Failed to update execution status", "execution_id", executionID, "error", err) + } + + // Log error if execution failed + if runErr != nil { + e.logger.Error(req.FunctionID, runErr.Error()) + slog.Error("Function execution failed", + "execution_id", executionID, + "function_id", req.FunctionID, + "error", runErr) + } + + // Build result + result := &ExecutionResult{ + ExecutionID: executionID, + FunctionVersionID: version.ID, + Duration: duration, + Status: status, + Error: runErr, + } + + if runtimeResult != nil { + result.Response = runtimeResult.Response + } + + return result, nil +} + +// serializeEvent masks sensitive data and serializes the event to JSON. +func (e *DefaultEngine) serializeEvent(event events.Event) (string, error) { + switch ev := event.(type) { + case events.HTTPEvent: + maskedEvent := masking.MaskHTTPEvent(ev) + eventJSONBytes, err := json.Marshal(maskedEvent) + if err != nil { + return "", err + } + return string(eventJSONBytes), nil + default: + // For other event types, serialize directly + eventJSONBytes, err := json.Marshal(event) + if err != nil { + return "", err + } + return string(eventJSONBytes), nil + } +} + +// MaxResponseBodySize is the maximum size of response body to store (1MB) +const MaxResponseBodySize = 1024 * 1024 + +// serializeHTTPResponse converts an HTTPResponse to a JSON string for storage. +// If the response body exceeds MaxResponseBodySize, it is truncated. +func serializeHTTPResponse(resp *events.HTTPResponse) string { + // Create a copy to avoid modifying the original response + respToStore := *resp + + // Truncate the body if it exceeds the maximum size + if len(respToStore.Body) > MaxResponseBodySize { + respToStore.Body = respToStore.Body[:MaxResponseBodySize] + "\n[TRUNCATED - Response exceeded 1MB]" + } + + jsonBytes, err := json.Marshal(respToStore) + if err != nil { + slog.Error("Failed to serialize HTTP response", "error", err) + return "{}" + } + return string(jsonBytes) +} diff --git a/internal/engine/engine_test.go b/internal/engine/engine_test.go new file mode 100644 index 0000000..f3c7f97 --- /dev/null +++ b/internal/engine/engine_test.go @@ -0,0 +1,241 @@ +package engine + +import ( + "context" + "errors" + "testing" + + "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/services/logger" + "github.com/dimiro1/lunar/internal/store" +) + +// mockRuntime implements Runtime for testing +type mockRuntime struct { + result *RuntimeResult + err error +} + +func (m *mockRuntime) Execute(ctx context.Context, req RuntimeRequest) (*RuntimeResult, error) { + return m.result, m.err +} + +func TestEngine_Execute_Success(t *testing.T) { + db := store.NewMemoryDB() + ctx := context.Background() + + // Create a function with a version + fn, _ := db.CreateFunction(ctx, store.Function{ + ID: "test-func", + Name: "Test Function", + }) + _, _ = db.CreateVersion(ctx, fn.ID, "return {}", nil) + + runtime := &mockRuntime{ + result: &RuntimeResult{ + Response: &events.HTTPResponse{ + StatusCode: 200, + Body: `{"status":"ok"}`, + }, + }, + } + + eng := New(Config{ + DB: db, + Runtime: runtime, + Logger: logger.NewMemoryLogger(), + IDGenerator: func() string { return "exec-123" }, + }) + + result, err := eng.Execute(ctx, ExecutionRequest{ + FunctionID: fn.ID, + Event: events.HTTPEvent{ + Method: "GET", + Path: "/fn/test-func", + }, + Trigger: store.ExecutionTriggerHTTP, + BaseURL: "http://localhost", + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.ExecutionID != "exec-123" { + t.Errorf("ExecutionID = %q, want %q", result.ExecutionID, "exec-123") + } + + if result.Status != store.ExecutionStatusSuccess { + t.Errorf("Status = %v, want %v", result.Status, store.ExecutionStatusSuccess) + } + + if result.Response == nil { + t.Fatal("Response is nil") + } + + if result.Response.StatusCode != 200 { + t.Errorf("Response.StatusCode = %d, want %d", result.Response.StatusCode, 200) + } +} + +func TestEngine_Execute_FunctionNotFound(t *testing.T) { + db := store.NewMemoryDB() + + eng := New(Config{ + DB: db, + Runtime: &mockRuntime{}, + Logger: logger.NewMemoryLogger(), + IDGenerator: func() string { return "exec-123" }, + }) + + _, err := eng.Execute(context.Background(), ExecutionRequest{ + FunctionID: "nonexistent", + }) + + var fnNotFound *FunctionNotFoundError + if !errors.As(err, &fnNotFound) { + t.Errorf("expected FunctionNotFoundError, got %T: %v", err, err) + } +} + +func TestEngine_Execute_FunctionDisabled(t *testing.T) { + db := store.NewMemoryDB() + ctx := context.Background() + + fn, _ := db.CreateFunction(ctx, store.Function{ + ID: "test-func", + Name: "Test Function", + Disabled: true, + }) + + eng := New(Config{ + DB: db, + Runtime: &mockRuntime{}, + Logger: logger.NewMemoryLogger(), + IDGenerator: func() string { return "exec-123" }, + }) + + _, err := eng.Execute(ctx, ExecutionRequest{ + FunctionID: fn.ID, + }) + + var fnDisabled *FunctionDisabledError + if !errors.As(err, &fnDisabled) { + t.Errorf("expected FunctionDisabledError, got %T: %v", err, err) + } +} + +func TestEngine_Execute_NoActiveVersion(t *testing.T) { + db := store.NewMemoryDB() + ctx := context.Background() + + // Create function without any version + fn, _ := db.CreateFunction(ctx, store.Function{ + ID: "test-func", + Name: "Test Function", + }) + + eng := New(Config{ + DB: db, + Runtime: &mockRuntime{}, + Logger: logger.NewMemoryLogger(), + IDGenerator: func() string { return "exec-123" }, + }) + + _, err := eng.Execute(ctx, ExecutionRequest{ + FunctionID: fn.ID, + }) + + var noVersion *NoActiveVersionError + if !errors.As(err, &noVersion) { + t.Errorf("expected NoActiveVersionError, got %T: %v", err, err) + } +} + +func TestEngine_Execute_RuntimeError(t *testing.T) { + db := store.NewMemoryDB() + ctx := context.Background() + + fn, _ := db.CreateFunction(ctx, store.Function{ + ID: "test-func", + Name: "Test Function", + }) + _, _ = db.CreateVersion(ctx, fn.ID, "invalid code", nil) + + runtime := &mockRuntime{ + err: errors.New("runtime error: syntax error"), + } + + eng := New(Config{ + DB: db, + Runtime: runtime, + Logger: logger.NewMemoryLogger(), + IDGenerator: func() string { return "exec-123" }, + }) + + result, err := eng.Execute(ctx, ExecutionRequest{ + FunctionID: fn.ID, + Event: events.HTTPEvent{ + Method: "GET", + Path: "/fn/test-func", + }, + Trigger: store.ExecutionTriggerHTTP, + }) + + // Engine returns result even with runtime error + if err != nil { + t.Fatalf("Execute returned error: %v", err) + } + + if result.Error == nil { + t.Error("expected result.Error to be set") + } + + if result.Status != store.ExecutionStatusError { + t.Errorf("Status = %v, want %v", result.Status, store.ExecutionStatusError) + } +} + +func TestEngine_Execute_ErrorStatusCode(t *testing.T) { + db := store.NewMemoryDB() + ctx := context.Background() + + fn, _ := db.CreateFunction(ctx, store.Function{ + ID: "test-func", + Name: "Test Function", + }) + _, _ = db.CreateVersion(ctx, fn.ID, "return {statusCode=500}", nil) + + runtime := &mockRuntime{ + result: &RuntimeResult{ + Response: &events.HTTPResponse{ + StatusCode: 500, + Body: `{"error":"internal error"}`, + }, + }, + } + + eng := New(Config{ + DB: db, + Runtime: runtime, + Logger: logger.NewMemoryLogger(), + IDGenerator: func() string { return "exec-123" }, + }) + + result, err := eng.Execute(ctx, ExecutionRequest{ + FunctionID: fn.ID, + Event: events.HTTPEvent{ + Method: "GET", + Path: "/fn/test-func", + }, + }) + + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Status should be error when response has >= 400 status code + if result.Status != store.ExecutionStatusError { + t.Errorf("Status = %v, want %v", result.Status, store.ExecutionStatusError) + } +} diff --git a/internal/engine/errors.go b/internal/engine/errors.go new file mode 100644 index 0000000..ce1b734 --- /dev/null +++ b/internal/engine/errors.go @@ -0,0 +1,43 @@ +package engine + +import "fmt" + +// FunctionNotFoundError indicates the requested function does not exist. +type FunctionNotFoundError struct { + FunctionID string +} + +func (e *FunctionNotFoundError) Error() string { + return fmt.Sprintf("function not found: %s", e.FunctionID) +} + +// FunctionDisabledError indicates the function is disabled. +type FunctionDisabledError struct { + FunctionID string +} + +func (e *FunctionDisabledError) Error() string { + return fmt.Sprintf("function is disabled: %s", e.FunctionID) +} + +// NoActiveVersionError indicates no active version exists for the function. +type NoActiveVersionError struct { + FunctionID string +} + +func (e *NoActiveVersionError) Error() string { + return fmt.Sprintf("no active version found for function: %s", e.FunctionID) +} + +// ExecutionRecordError indicates a failure to create/update execution record. +type ExecutionRecordError struct { + Err error +} + +func (e *ExecutionRecordError) Error() string { + return fmt.Sprintf("execution record error: %v", e.Err) +} + +func (e *ExecutionRecordError) Unwrap() error { + return e.Err +} diff --git a/internal/engine/request.go b/internal/engine/request.go new file mode 100644 index 0000000..0d621c8 --- /dev/null +++ b/internal/engine/request.go @@ -0,0 +1,21 @@ +package engine + +import ( + "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/store" +) + +// ExecutionRequest contains all information needed to execute a function. +type ExecutionRequest struct { + // FunctionID is the unique identifier of the function to execute + FunctionID string + + // Event is the trigger event (HTTP request, cron trigger, etc.) + Event events.Event + + // Trigger indicates how the execution was triggered (HTTP, cron, etc.) + Trigger store.ExecutionTrigger + + // BaseURL is the base URL of the server for generating function URLs + BaseURL string +} diff --git a/internal/engine/result.go b/internal/engine/result.go new file mode 100644 index 0000000..e3389c7 --- /dev/null +++ b/internal/engine/result.go @@ -0,0 +1,29 @@ +package engine + +import ( + "time" + + "github.com/dimiro1/lunar/internal/events" + "github.com/dimiro1/lunar/internal/store" +) + +// ExecutionResult contains the outcome of a function execution. +type ExecutionResult struct { + // ExecutionID is the unique identifier for this execution + ExecutionID string + + // FunctionVersionID is the ID of the function version that was executed + FunctionVersionID string + + // Response is the HTTP response from the function (for HTTP events) + Response *events.HTTPResponse + + // Duration is how long the execution took + Duration time.Duration + + // Status indicates whether execution succeeded or failed + Status store.ExecutionStatus + + // Error contains the error if execution failed + Error error +} diff --git a/internal/engine/runtime.go b/internal/engine/runtime.go new file mode 100644 index 0000000..6c70391 --- /dev/null +++ b/internal/engine/runtime.go @@ -0,0 +1,34 @@ +package engine + +import ( + "context" + + "github.com/dimiro1/lunar/internal/events" +) + +// Runtime is the interface for language-specific code executors. +// Implementations handle the actual execution of function code in a specific +// language runtime (Lua, JavaScript, Python, etc.). +type Runtime interface { + // Execute runs the provided code with the given context and event. + // It returns the execution result or an error if execution failed. + Execute(ctx context.Context, req RuntimeRequest) (*RuntimeResult, error) +} + +// RuntimeRequest contains all information needed to execute function code. +type RuntimeRequest struct { + // Code is the function source code to execute + Code string + + // Context provides execution metadata (function ID, execution ID, etc.) + Context *events.ExecutionContext + + // Event is the trigger event (HTTP request, cron trigger, etc.) + Event events.Event +} + +// RuntimeResult contains the output from executing function code. +type RuntimeResult struct { + // Response is the HTTP response from the function (for HTTP events) + Response *events.HTTPResponse +} diff --git a/internal/runner/lua_runtime.go b/internal/runner/lua_runtime.go new file mode 100644 index 0000000..e07355f --- /dev/null +++ b/internal/runner/lua_runtime.go @@ -0,0 +1,88 @@ +package runner + +import ( + "context" + "time" + + "github.com/dimiro1/lunar/internal/engine" + "github.com/dimiro1/lunar/internal/services/ai" + "github.com/dimiro1/lunar/internal/services/email" + "github.com/dimiro1/lunar/internal/services/env" + internalhttp "github.com/dimiro1/lunar/internal/services/http" + "github.com/dimiro1/lunar/internal/services/kv" + "github.com/dimiro1/lunar/internal/services/logger" +) + +// Compile-time check that LuaRuntime implements engine.Runtime +var _ engine.Runtime = (*LuaRuntime)(nil) + +// LuaRuntime implements the engine.Runtime interface for Lua code execution. +type LuaRuntime struct { + logger logger.Logger + kv kv.Store + env env.Store + http internalhttp.Client + ai ai.Client + aiTracker ai.Tracker + email email.Client + emailTracker email.Tracker + timeout time.Duration +} + +// LuaRuntimeConfig holds the configuration for creating a LuaRuntime. +type LuaRuntimeConfig struct { + Logger logger.Logger + KV kv.Store + Env env.Store + HTTP internalhttp.Client + AI ai.Client + AITracker ai.Tracker + Email email.Client + EmailTracker email.Tracker + Timeout time.Duration +} + +// NewLuaRuntime creates a new LuaRuntime with the given configuration. +func NewLuaRuntime(cfg LuaRuntimeConfig) *LuaRuntime { + return &LuaRuntime{ + logger: cfg.Logger, + kv: cfg.KV, + env: cfg.Env, + http: cfg.HTTP, + ai: cfg.AI, + aiTracker: cfg.AITracker, + email: cfg.Email, + emailTracker: cfg.EmailTracker, + timeout: cfg.Timeout, + } +} + +// Execute implements the engine.Runtime interface. +func (r *LuaRuntime) Execute(ctx context.Context, req engine.RuntimeRequest) (*engine.RuntimeResult, error) { + deps := Dependencies{ + Logger: r.logger, + KV: r.kv, + Env: r.env, + HTTP: r.http, + AI: r.ai, + AITracker: r.aiTracker, + Email: r.email, + EmailTracker: r.emailTracker, + Timeout: r.timeout, + } + + runReq := Request{ + Context: req.Context, + Event: req.Event, + Code: req.Code, + } + + resp, err := Run(ctx, deps, runReq) + if err != nil { + return nil, err + } + + return &engine.RuntimeResult{ + Response: resp.HTTP, + }, nil +}