diff --git a/ingestion/internal/api/handler.go b/ingestion/internal/api/handler.go new file mode 100644 index 0000000..631710c --- /dev/null +++ b/ingestion/internal/api/handler.go @@ -0,0 +1,96 @@ +// ingestion/internal/api/handler.go +package api + +import ( + "encoding/json" + "fmt" + "log/slog" + "net/http" + "os" + "path/filepath" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/search" +) + +// Handler serves the ingestion HTTP API. +type Handler struct { + brainDir string + logger *slog.Logger +} + +// NewHandler constructs a Handler. brainDir is the absolute path to brain/. +func NewHandler(brainDir string, logger *slog.Logger) *Handler { + return &Handler{brainDir: brainDir, logger: logger} +} + +type queryRequest struct { + Query string `json:"query"` + Domain string `json:"domain,omitempty"` + Limit int `json:"limit,omitempty"` +} + +type writeRequest struct { + Content string `json:"content"` + Filename string `json:"filename,omitempty"` +} + +// Query handles POST /query — full-text search across the brain wiki. +func (h *Handler) Query(w http.ResponseWriter, r *http.Request) { + var req queryRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if req.Limit == 0 { + req.Limit = 5 + } + + results, err := search.Query(h.brainDir, req.Query, req.Limit) + if err != nil { + h.logger.Error("query failed", "err", err) + http.Error(w, "search error", http.StatusInternalServerError) + return + } + + writeJSON(w, map[string]any{"results": results}) +} + +// Write handles POST /write — write raw content to brain/raw/. +func (h *Handler) Write(w http.ResponseWriter, r *http.Request) { + var req writeRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "invalid JSON", http.StatusBadRequest) + return + } + if req.Content == "" { + http.Error(w, "content is required", http.StatusBadRequest) + return + } + + filename := req.Filename + if filename == "" { + filename = fmt.Sprintf("%s-auto.md", time.Now().UTC().Format("2006-01-02-150405")) + } + + rawDir := filepath.Join(h.brainDir, "raw") + if err := os.MkdirAll(rawDir, 0o755); err != nil { + http.Error(w, "failed to create raw dir", http.StatusInternalServerError) + return + } + + dest := filepath.Join(rawDir, filepath.Base(filename)) + if err := os.WriteFile(dest, []byte(req.Content), 0o644); err != nil { + h.logger.Error("write failed", "err", err) + http.Error(w, "write error", http.StatusInternalServerError) + return + } + + rel, _ := filepath.Rel(h.brainDir, dest) + writeJSON(w, map[string]string{"path": filepath.ToSlash(rel)}) +} + +func writeJSON(w http.ResponseWriter, v any) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(v) //nolint:errcheck +} diff --git a/ingestion/internal/api/handler_test.go b/ingestion/internal/api/handler_test.go new file mode 100644 index 0000000..e153c0a --- /dev/null +++ b/ingestion/internal/api/handler_test.go @@ -0,0 +1,83 @@ +// ingestion/internal/api/handler_test.go +package api_test + +import ( + "bytes" + "encoding/json" + "log/slog" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/mathiasbq/hyperguild/ingestion/internal/api" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func setup(t *testing.T) (string, *api.Handler) { + t.Helper() + dir := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki", "concepts"), 0o755)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755)) + require.NoError(t, os.WriteFile( + filepath.Join(dir, "wiki", "concepts", "tdd.md"), + []byte("---\ntitle: TDD\ndomain: software\n---\n\nTest-driven development is a discipline.\n"), + 0o644, + )) + logger := slog.New(slog.NewTextHandler(os.Stderr, nil)) + return dir, api.NewHandler(dir, logger) +} + +func TestQuery_ReturnsResults(t *testing.T) { + _, h := setup(t) + body, _ := json.Marshal(map[string]any{"query": "test driven", "limit": 5}) + req := httptest.NewRequest(http.MethodPost, "/query", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Query(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp map[string]any + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + results := resp["results"].([]any) + assert.NotEmpty(t, results) +} + +func TestWrite_CreatesRawFile(t *testing.T) { + dir, h := setup(t) + body, _ := json.Marshal(map[string]any{ + "content": "# Test note\n\nSome content.", + "filename": "test-note.md", + }) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Write(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + var resp map[string]string + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &resp)) + assert.NotEmpty(t, resp["path"]) + + written := filepath.Join(dir, "raw", "test-note.md") + content, err := os.ReadFile(written) + require.NoError(t, err) + assert.Contains(t, string(content), "Some content.") +} + +func TestWrite_GeneratesFilenameIfAbsent(t *testing.T) { + dir, h := setup(t) + body, _ := json.Marshal(map[string]any{"content": "auto name"}) + req := httptest.NewRequest(http.MethodPost, "/write", bytes.NewReader(body)) + rec := httptest.NewRecorder() + + h.Write(rec, req) + + assert.Equal(t, http.StatusOK, rec.Code) + entries, _ := os.ReadDir(filepath.Join(dir, "raw")) + assert.Len(t, entries, 1) + assert.True(t, strings.HasSuffix(entries[0].Name(), ".md")) +}