feat(ingestion): add query and write HTTP handlers
Implements POST /query (BM25 search via internal/search) and POST /write (raw file persistence to brain/raw/) as an api.Handler struct. Filename is auto-generated when absent. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
96
ingestion/internal/api/handler.go
Normal file
96
ingestion/internal/api/handler.go
Normal file
@@ -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
|
||||
}
|
||||
83
ingestion/internal/api/handler_test.go
Normal file
83
ingestion/internal/api/handler_test.go
Normal file
@@ -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"))
|
||||
}
|
||||
Reference in New Issue
Block a user