feat(tools): implement CRUD operations for thoughts and projects
* Add tools for creating, retrieving, updating, and deleting thoughts. * Implement project management tools for creating and listing projects. * Introduce linking functionality between thoughts. * Add search and recall capabilities for thoughts based on semantic queries. * Implement statistics and summarization tools for thought analysis. * Create database migrations for thoughts, projects, and links. * Add helper functions for UUID parsing and project resolution.
This commit is contained in:
110
internal/observability/http.go
Normal file
110
internal/observability/http.go
Normal file
@@ -0,0 +1,110 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"runtime/debug"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type contextKey string
|
||||
|
||||
const requestIDContextKey contextKey = "request_id"
|
||||
|
||||
func Chain(h http.Handler, middlewares ...func(http.Handler) http.Handler) http.Handler {
|
||||
for i := len(middlewares) - 1; i >= 0; i-- {
|
||||
h = middlewares[i](h)
|
||||
}
|
||||
return h
|
||||
}
|
||||
|
||||
func RequestID() func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
requestID := r.Header.Get("X-Request-Id")
|
||||
if requestID == "" {
|
||||
requestID = uuid.NewString()
|
||||
}
|
||||
w.Header().Set("X-Request-Id", requestID)
|
||||
ctx := context.WithValue(r.Context(), requestIDContextKey, requestID)
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Recover(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
defer func() {
|
||||
if recovered := recover(); recovered != nil {
|
||||
log.Error("panic recovered",
|
||||
slog.Any("panic", recovered),
|
||||
slog.String("request_id", RequestIDFromContext(r.Context())),
|
||||
slog.String("stack", string(debug.Stack())),
|
||||
)
|
||||
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||
}
|
||||
}()
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func AccessLog(log *slog.Logger) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
recorder := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
|
||||
started := time.Now()
|
||||
next.ServeHTTP(recorder, r)
|
||||
|
||||
log.Info("http request",
|
||||
slog.String("request_id", RequestIDFromContext(r.Context())),
|
||||
slog.String("method", r.Method),
|
||||
slog.String("path", r.URL.Path),
|
||||
slog.Int("status", recorder.status),
|
||||
slog.Duration("duration", time.Since(started)),
|
||||
slog.String("remote_addr", stripPort(r.RemoteAddr)),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func Timeout(timeout time.Duration) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
if timeout <= 0 {
|
||||
return next
|
||||
}
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), timeout)
|
||||
defer cancel()
|
||||
next.ServeHTTP(w, r.WithContext(ctx))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func RequestIDFromContext(ctx context.Context) string {
|
||||
value, _ := ctx.Value(requestIDContextKey).(string)
|
||||
return value
|
||||
}
|
||||
|
||||
type statusRecorder struct {
|
||||
http.ResponseWriter
|
||||
status int
|
||||
}
|
||||
|
||||
func (s *statusRecorder) WriteHeader(statusCode int) {
|
||||
s.status = statusCode
|
||||
s.ResponseWriter.WriteHeader(statusCode)
|
||||
}
|
||||
|
||||
func stripPort(remote string) string {
|
||||
host, _, err := net.SplitHostPort(remote)
|
||||
if err != nil {
|
||||
return remote
|
||||
}
|
||||
return host
|
||||
}
|
||||
59
internal/observability/http_test.go
Normal file
59
internal/observability/http_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"io"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRequestIDSetsHeaderAndContext(t *testing.T) {
|
||||
handler := RequestID()(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := RequestIDFromContext(r.Context()); got == "" {
|
||||
t.Fatal("RequestIDFromContext() = empty, want non-empty")
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Header().Get("X-Request-Id") == "" {
|
||||
t.Fatal("X-Request-Id header = empty, want non-empty")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTimeoutAddsContextDeadline(t *testing.T) {
|
||||
handler := Timeout(50 * time.Millisecond)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if _, ok := r.Context().Deadline(); !ok {
|
||||
t.Fatal("context deadline missing")
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecoverHandlesPanic(t *testing.T) {
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
handler := Recover(logger)(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
panic("boom")
|
||||
}))
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
handler.ServeHTTP(rec, req)
|
||||
|
||||
if rec.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("status = %d, want %d", rec.Code, http.StatusInternalServerError)
|
||||
}
|
||||
}
|
||||
52
internal/observability/logger.go
Normal file
52
internal/observability/logger.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package observability
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.warky.dev/wdevs/amcs/internal/config"
|
||||
)
|
||||
|
||||
func NewLogger(cfg config.LoggingConfig) (*slog.Logger, error) {
|
||||
level, err := parseLevel(cfg.Level)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
options := &slog.HandlerOptions{Level: level}
|
||||
handler, err := newHandler(cfg.Format, os.Stdout, options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return slog.New(handler), nil
|
||||
}
|
||||
|
||||
func parseLevel(value string) (slog.Leveler, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(value)) {
|
||||
case "", "info":
|
||||
return slog.LevelInfo, nil
|
||||
case "debug":
|
||||
return slog.LevelDebug, nil
|
||||
case "warn", "warning":
|
||||
return slog.LevelWarn, nil
|
||||
case "error":
|
||||
return slog.LevelError, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid logging.level %q", value)
|
||||
}
|
||||
}
|
||||
|
||||
func newHandler(format string, w io.Writer, opts *slog.HandlerOptions) (slog.Handler, error) {
|
||||
switch strings.ToLower(strings.TrimSpace(format)) {
|
||||
case "", "json":
|
||||
return slog.NewJSONHandler(w, opts), nil
|
||||
case "text":
|
||||
return slog.NewTextHandler(w, opts), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("invalid logging.format %q", format)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user