Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 29 additions & 21 deletions sdk/go/documents_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,21 @@ import (
"testing"
)

const (
errUnexpectedPath = "unexpected path: %s"
headerContentType = "Content-Type"
mimeJSON = "application/json"
errUnexpected = "unexpected error: %v"
testGoDevDocURL = "https://go.dev/doc/"
)

func TestAddText(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
t.Errorf("expected POST, got %s", r.Method)
}
if r.URL.Path != "/api/v1/documents" {
t.Errorf("unexpected path: %s", r.URL.Path)
t.Errorf(errUnexpectedPath, r.URL.Path)
}

var body map[string]interface{}
Expand All @@ -26,7 +34,7 @@ func TestAddText(t *testing.T) {
t.Errorf("expected content 'hello world', got %v", body["content"])
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.WriteHeader(201)
w.Write([]byte(`{"data":{"id":"doc-1","title":"Test Doc","source":"manual"}}`))
}))
Expand All @@ -35,7 +43,7 @@ func TestAddText(t *testing.T) {
c := NewClient(WithBaseURL(srv.URL))
doc, err := c.AddText(context.Background(), "Test Doc", "hello world")
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if doc.ID != "doc-1" {
t.Errorf("expected id doc-1, got %q", doc.ID)
Expand All @@ -54,7 +62,7 @@ func TestAddTextWithOptions(t *testing.T) {
t.Errorf("expected 2 tags, got %v", body["tags"])
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.WriteHeader(201)
w.Write([]byte(`{"data":{"id":"doc-2","title":"Go Guide","topic":"golang","tags":["go","tutorial"]}}`))
}))
Expand All @@ -66,7 +74,7 @@ func TestAddTextWithOptions(t *testing.T) {
WithTextTags("go", "tutorial"),
)
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if doc.Topic != "golang" {
t.Errorf("expected topic golang, got %q", doc.Topic)
Expand All @@ -76,44 +84,44 @@ func TestAddTextWithOptions(t *testing.T) {
func TestAddDocument(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/documents/url" {
t.Errorf("unexpected path: %s", r.URL.Path)
t.Errorf(errUnexpectedPath, r.URL.Path)
}
var body map[string]interface{}
json.NewDecoder(r.Body).Decode(&body)
if body["url"] != "https://go.dev/doc/" {
if body["url"] != testGoDevDocURL {
t.Errorf("expected url, got %v", body["url"])
}

w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.WriteHeader(201)
w.Write([]byte(`{"data":{"id":"doc-3","title":"Go Documentation","url":"https://go.dev/doc/"}}`))
w.Write([]byte(`{"data":{"id":"doc-3","title":"Go Documentation","url":"` + testGoDevDocURL + `"}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
doc, err := c.AddDocument(context.Background(), "https://go.dev/doc/")
doc, err := c.AddDocument(context.Background(), testGoDevDocURL)
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if doc.URL != "https://go.dev/doc/" {
if doc.URL != testGoDevDocURL {
t.Errorf("expected url, got %q", doc.URL)
}
}

func TestGetDocument(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/v1/documents/doc-1" {
t.Errorf("unexpected path: %s", r.URL.Path)
t.Errorf(errUnexpectedPath, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":{"id":"doc-1","title":"Test"}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
doc, err := c.GetDocument(context.Background(), "doc-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if doc.ID != "doc-1" {
t.Errorf("expected doc-1, got %q", doc.ID)
Expand All @@ -122,7 +130,7 @@ func TestGetDocument(t *testing.T) {

func TestGetDocumentNotFound(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.WriteHeader(404)
w.Write([]byte(`{"error":{"code":"NOT_FOUND","message":"Document not found"}}`))
}))
Expand Down Expand Up @@ -150,15 +158,15 @@ func TestListDocuments(t *testing.T) {
if r.URL.Query().Get("limit") != "10" {
t.Errorf("expected limit=10, got %q", r.URL.Query().Get("limit"))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":[{"id":"doc-1","title":"Doc 1"},{"id":"doc-2","title":"Doc 2"}]}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
docs, err := c.ListDocuments(context.Background(), WithListTopic("go"), WithListLimit(10))
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if len(docs) != 2 {
t.Errorf("expected 2 docs, got %d", len(docs))
Expand All @@ -171,16 +179,16 @@ func TestDeleteDocument(t *testing.T) {
t.Errorf("expected DELETE, got %s", r.Method)
}
if r.URL.Path != "/api/v1/documents/doc-1" {
t.Errorf("unexpected path: %s", r.URL.Path)
t.Errorf(errUnexpectedPath, r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":{"deleted":true}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
err := c.DeleteDocument(context.Background(), "doc-1")
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
}
18 changes: 9 additions & 9 deletions sdk/go/search_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,15 +15,15 @@ func TestSearch(t *testing.T) {
if r.URL.Query().Get("limit") != "5" {
t.Errorf("expected limit=5, got %q", r.URL.Query().Get("limit"))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":{"results":[{"document":{"id":"doc-1","title":"Go Concurrency"},"score":0.95,"chunk_text":"goroutines are..."}],"total":1,"query":"goroutines"}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
result, err := c.Search(context.Background(), "goroutines", WithLimit(5))
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if result.Total != 1 {
t.Errorf("expected 1 result, got %d", result.Total)
Expand All @@ -38,15 +38,15 @@ func TestSearchWithTopic(t *testing.T) {
if r.URL.Query().Get("topic") != "golang" {
t.Errorf("expected topic=golang, got %q", r.URL.Query().Get("topic"))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":{"results":[],"total":0,"query":"test"}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
result, err := c.Search(context.Background(), "test", WithTopic("golang"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if result.Total != 0 {
t.Errorf("expected 0 results, got %d", result.Total)
Expand All @@ -58,29 +58,29 @@ func TestSearchWithTags(t *testing.T) {
if r.URL.Query().Get("tag") != "tutorial" {
t.Errorf("expected tag=tutorial, got %q", r.URL.Query().Get("tag"))
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":{"results":[],"total":0,"query":"test"}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
_, err := c.Search(context.Background(), "test", WithTags("tutorial"))
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
}

func TestSearchWithMinScore(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.Write([]byte(`{"data":{"results":[{"document":{"id":"d1","title":"High"},"score":0.9,"chunk_text":"high"},{"document":{"id":"d2","title":"Low"},"score":0.3,"chunk_text":"low"}],"total":2,"query":"test"}}`))
}))
defer srv.Close()

c := NewClient(WithBaseURL(srv.URL))
result, err := c.Search(context.Background(), "test", WithMinScore(0.5))
if err != nil {
t.Fatalf("unexpected error: %v", err)
t.Fatalf(errUnexpected, err)
}
if len(result.Results) != 1 {
t.Errorf("expected 1 result after min score filter, got %d", len(result.Results))
Expand All @@ -92,7 +92,7 @@ func TestSearchWithMinScore(t *testing.T) {

func TestSearchError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set(headerContentType, mimeJSON)
w.WriteHeader(400)
w.Write([]byte(`{"error":{"code":"VALIDATION_ERROR","message":"Query parameter 'q' is required"}}`))
}))
Expand Down
18 changes: 10 additions & 8 deletions sdk/python/src/pylibscope/client.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@
)

_API = "/api/v1"
_PATH_DOCUMENTS = "/documents"
_PATH_TOPICS = "/topics"


def _raise_for_error(response: httpx.Response) -> None:
Expand Down Expand Up @@ -215,7 +217,7 @@ def add_text(
payload["topic"] = topic
if tags:
payload["tags"] = tags
resp = self._request("POST", "/documents", json=payload)
resp = self._request("POST", _PATH_DOCUMENTS, json=payload)
return _parse_document(_extract_data(resp))

def get_document(self, doc_id: str) -> Document:
Expand All @@ -234,7 +236,7 @@ def list_documents(
params: Dict[str, Any] = {"limit": limit, "offset": offset}
if topic is not None:
params["topic"] = topic
resp = self._request("GET", "/documents", params=params)
resp = self._request("GET", _PATH_DOCUMENTS, params=params)
data = _extract_data(resp)
if isinstance(data, list):
return [_parse_document(d) for d in data]
Expand All @@ -248,7 +250,7 @@ def delete_document(self, doc_id: str) -> None:

def list_topics(self) -> List[Topic]:
"""List all topics."""
resp = self._request("GET", "/topics")
resp = self._request("GET", _PATH_TOPICS)
data = _extract_data(resp)
if isinstance(data, list):
return [_parse_topic(t) for t in data]
Expand All @@ -259,7 +261,7 @@ def create_topic(self, name: str, *, parent_id: Optional[str] = None) -> Topic:
payload: Dict[str, Any] = {"name": name}
if parent_id is not None:
payload["parentId"] = parent_id
resp = self._request("POST", "/topics", json=payload)
resp = self._request("POST", _PATH_TOPICS, json=payload)
return _parse_topic(_extract_data(resp))

# -- tag operations ------------------------------------------------------
Expand Down Expand Up @@ -442,7 +444,7 @@ async def add_text(
payload["topic"] = topic
if tags:
payload["tags"] = tags
resp = await self._request("POST", "/documents", json=payload)
resp = await self._request("POST", _PATH_DOCUMENTS, json=payload)
return _parse_document(_extract_data(resp))

async def get_document(self, doc_id: str) -> Document:
Expand All @@ -459,7 +461,7 @@ async def list_documents(
params: Dict[str, Any] = {"limit": limit, "offset": offset}
if topic is not None:
params["topic"] = topic
resp = await self._request("GET", "/documents", params=params)
resp = await self._request("GET", _PATH_DOCUMENTS, params=params)
data = _extract_data(resp)
if isinstance(data, list):
return [_parse_document(d) for d in data]
Expand All @@ -471,7 +473,7 @@ async def delete_document(self, doc_id: str) -> None:
# -- topic operations ----------------------------------------------------

async def list_topics(self) -> List[Topic]:
resp = await self._request("GET", "/topics")
resp = await self._request("GET", _PATH_TOPICS)
data = _extract_data(resp)
if isinstance(data, list):
return [_parse_topic(t) for t in data]
Expand All @@ -481,7 +483,7 @@ async def create_topic(self, name: str, *, parent_id: Optional[str] = None) -> T
payload: Dict[str, Any] = {"name": name}
if parent_id is not None:
payload["parentId"] = parent_id
resp = await self._request("POST", "/topics", json=payload)
resp = await self._request("POST", _PATH_TOPICS, json=payload)
return _parse_topic(_extract_data(resp))

# -- tag operations ------------------------------------------------------
Expand Down
40 changes: 20 additions & 20 deletions src/api/middleware.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,24 @@ const rateLimitMap = new Map<string, RateLimitEntry>();
const RATE_LIMIT_WINDOW_MS = 60_000;
const RATE_LIMIT_MAX_REQUESTS = 120;

/** Evict expired and oldest entries from the rate limit map when it reaches capacity. */
function evictRateLimitEntries(now: number): void {
// First pass: delete expired entries
for (const [key, val] of rateLimitMap) {
if (now - val.windowStart >= RATE_LIMIT_WINDOW_MS) {
rateLimitMap.delete(key);
}
}
// If still over limit, evict oldest entries
if (rateLimitMap.size >= MAX_RATE_LIMIT_ENTRIES) {
const sorted = [...rateLimitMap.entries()].sort((a, b) => a[1].windowStart - b[1].windowStart);
const toDelete = sorted.slice(0, 1000);
for (const [key] of toDelete) {
rateLimitMap.delete(key);
}
}
}

/** Check rate limit for a given IP. Returns true if request is allowed. */
export function checkRateLimit(ip: string): boolean {
const now = Date.now();
Expand All @@ -62,10 +80,7 @@ export function checkRateLimit(ip: string): boolean {
if (entry) {
if (now - entry.windowStart < RATE_LIMIT_WINDOW_MS) {
entry.count++;
if (entry.count > RATE_LIMIT_MAX_REQUESTS) {
return false;
}
return true;
return entry.count <= RATE_LIMIT_MAX_REQUESTS;
}
// Window expired — reset
entry.count = 1;
Expand All @@ -75,22 +90,7 @@ export function checkRateLimit(ip: string): boolean {

// New IP — evict expired/oldest entries if map is full
if (rateLimitMap.size >= MAX_RATE_LIMIT_ENTRIES) {
// First pass: delete expired entries
for (const [key, val] of rateLimitMap) {
if (now - val.windowStart >= RATE_LIMIT_WINDOW_MS) {
rateLimitMap.delete(key);
}
}
// If still over limit, evict oldest entries
if (rateLimitMap.size >= MAX_RATE_LIMIT_ENTRIES) {
const sorted = [...rateLimitMap.entries()].sort(
(a, b) => a[1].windowStart - b[1].windowStart,
);
const toDelete = sorted.slice(0, 1000);
for (const [key] of toDelete) {
rateLimitMap.delete(key);
}
}
evictRateLimitEntries(now);
}

rateLimitMap.set(ip, { count: 1, windowStart: now });
Expand Down
Loading
Loading