fix: encode dynamic URL path segments (#5963)
Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
@@ -17,6 +17,10 @@ import {
|
||||
type SearchOptions,
|
||||
} from "./base.js";
|
||||
|
||||
function encodePathSegment(value: unknown): string {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
export class PlatformBackend implements Backend {
|
||||
private baseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
@@ -218,9 +222,13 @@ export class PlatformBackend implements Backend {
|
||||
}
|
||||
|
||||
async get(memoryId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/memories/${memoryId}/`, {
|
||||
params: { source: "CLI" },
|
||||
})) as Record<string, unknown>;
|
||||
return (await this._request(
|
||||
"GET",
|
||||
`/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||
{
|
||||
params: { source: "CLI" },
|
||||
},
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async listMemories(
|
||||
@@ -277,9 +285,13 @@ export class PlatformBackend implements Backend {
|
||||
if (content) payload.text = content;
|
||||
if (metadata) payload.metadata = metadata;
|
||||
payload.source = "CLI";
|
||||
return (await this._request("PUT", `/v1/memories/${memoryId}/`, {
|
||||
json: payload,
|
||||
})) as Record<string, unknown>;
|
||||
return (await this._request(
|
||||
"PUT",
|
||||
`/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||
{
|
||||
json: payload,
|
||||
},
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async delete(
|
||||
@@ -297,9 +309,13 @@ export class PlatformBackend implements Backend {
|
||||
})) as Record<string, unknown>;
|
||||
}
|
||||
if (memoryId) {
|
||||
return (await this._request("DELETE", `/v1/memories/${memoryId}/`, {
|
||||
params: { source: "CLI" },
|
||||
})) as Record<string, unknown>;
|
||||
return (await this._request(
|
||||
"DELETE",
|
||||
`/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||
{
|
||||
params: { source: "CLI" },
|
||||
},
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
throw new Error("Either memoryId or --all is required");
|
||||
}
|
||||
@@ -323,7 +339,7 @@ export class PlatformBackend implements Backend {
|
||||
for (const [entityType, entityId] of entities) {
|
||||
results[entityType] = (await this._request(
|
||||
"DELETE",
|
||||
`/v2/entities/${entityType}/${entityId}/`,
|
||||
`/v2/entities/${encodePathSegment(entityType)}/${encodePathSegment(entityId)}/`,
|
||||
{ params: { source: "CLI" } },
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
@@ -386,9 +402,9 @@ export class PlatformBackend implements Backend {
|
||||
}
|
||||
|
||||
async getEvent(eventId: string): Promise<Record<string, unknown>> {
|
||||
return (await this._request("GET", `/v1/event/${eventId}/`)) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return (await this._request(
|
||||
"GET",
|
||||
`/v1/event/${encodePathSegment(eventId)}/`,
|
||||
)) as Record<string, unknown>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
* Tests for the Platform backend (mem0 Platform API client).
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi } from "vitest";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { PlatformBackend } from "../src/backend/platform.js";
|
||||
import { createDefaultConfig } from "../src/config.js";
|
||||
|
||||
@@ -12,6 +12,22 @@ function makeBackend(): PlatformBackend {
|
||||
return new PlatformBackend(createDefaultConfig().platform);
|
||||
}
|
||||
|
||||
function mockFetch() {
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
headers: { get: vi.fn().mockReturnValue(null) },
|
||||
json: vi.fn().mockResolvedValue({ message: "ok" }),
|
||||
});
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
return fetchMock;
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("deleteEntities", () => {
|
||||
it("returns all results keyed by entity type for a multi-entity delete", async () => {
|
||||
const backend = makeBackend();
|
||||
@@ -50,3 +66,35 @@ describe("deleteEntities", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("PlatformBackend path encoding", () => {
|
||||
it("encodes memory IDs before interpolating them into paths", async () => {
|
||||
const fetchMock = mockFetch();
|
||||
const backend = makeBackend();
|
||||
|
||||
await backend.get("mem/a?b#c");
|
||||
await backend.update("mem/a?b#c", "updated");
|
||||
await backend.delete("mem/a?b#c");
|
||||
|
||||
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
||||
expect(urls).toEqual([
|
||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
|
||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/",
|
||||
"https://api.mem0.ai/v1/memories/mem%2Fa%3Fb%23c/?source=CLI",
|
||||
]);
|
||||
});
|
||||
|
||||
it("encodes entity and event IDs before interpolating them into paths", async () => {
|
||||
const fetchMock = mockFetch();
|
||||
const backend = makeBackend();
|
||||
|
||||
await backend.deleteEntities({ userId: "org/team?active#frag" });
|
||||
await backend.getEvent("evt/a?b#c");
|
||||
|
||||
const urls = fetchMock.mock.calls.map((call) => call[0]);
|
||||
expect(urls).toEqual([
|
||||
"https://api.mem0.ai/v2/entities/user/org%2Fteam%3Factive%23frag/?source=CLI",
|
||||
"https://api.mem0.ai/v1/event/evt%2Fa%3Fb%23c/",
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
|
||||
@@ -11,6 +12,10 @@ from mem0_cli.backend.base import Backend
|
||||
from mem0_cli.config import PlatformConfig
|
||||
|
||||
|
||||
def _encode_path_segment(value: Any) -> str:
|
||||
return quote(str(value), safe="")
|
||||
|
||||
|
||||
class PlatformBackend(Backend):
|
||||
"""Backend that talks to the mem0 Platform API."""
|
||||
|
||||
@@ -196,7 +201,11 @@ class PlatformBackend(Backend):
|
||||
)
|
||||
|
||||
def get(self, memory_id: str) -> dict:
|
||||
return self._request("GET", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
|
||||
return self._request(
|
||||
"GET",
|
||||
f"/v1/memories/{_encode_path_segment(memory_id)}/",
|
||||
params={"source": "CLI"},
|
||||
)
|
||||
|
||||
def list_memories(
|
||||
self,
|
||||
@@ -250,7 +259,11 @@ class PlatformBackend(Backend):
|
||||
if metadata:
|
||||
payload["metadata"] = metadata
|
||||
payload["source"] = "CLI"
|
||||
return self._request("PUT", f"/v1/memories/{memory_id}/", json=payload)
|
||||
return self._request(
|
||||
"PUT",
|
||||
f"/v1/memories/{_encode_path_segment(memory_id)}/",
|
||||
json=payload,
|
||||
)
|
||||
|
||||
def delete(
|
||||
self,
|
||||
@@ -274,7 +287,11 @@ class PlatformBackend(Backend):
|
||||
params["run_id"] = run_id
|
||||
return self._request("DELETE", "/v1/memories/", params=params)
|
||||
elif memory_id:
|
||||
return self._request("DELETE", f"/v1/memories/{memory_id}/", params={"source": "CLI"})
|
||||
return self._request(
|
||||
"DELETE",
|
||||
f"/v1/memories/{_encode_path_segment(memory_id)}/",
|
||||
params={"source": "CLI"},
|
||||
)
|
||||
else:
|
||||
raise ValueError("Either memory_id or --all is required")
|
||||
|
||||
@@ -302,7 +319,9 @@ class PlatformBackend(Backend):
|
||||
results: dict = {}
|
||||
for entity_type, entity_id in entities.items():
|
||||
results[entity_type] = self._request(
|
||||
"DELETE", f"/v2/entities/{entity_type}/{entity_id}/", params={"source": "CLI"}
|
||||
"DELETE",
|
||||
f"/v2/entities/{_encode_path_segment(entity_type)}/{_encode_path_segment(entity_id)}/",
|
||||
params={"source": "CLI"},
|
||||
)
|
||||
return results
|
||||
|
||||
@@ -348,7 +367,7 @@ class PlatformBackend(Backend):
|
||||
return result if isinstance(result, list) else result.get("results", [])
|
||||
|
||||
def get_event(self, event_id: str) -> dict:
|
||||
return self._request("GET", f"/v1/event/{event_id}/")
|
||||
return self._request("GET", f"/v1/event/{_encode_path_segment(event_id)}/")
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from mem0_cli.backend.platform import PlatformBackend
|
||||
|
||||
|
||||
def _backend(sample_config):
|
||||
backend = PlatformBackend(sample_config.platform)
|
||||
backend._client = MagicMock()
|
||||
backend._client.request.return_value = MagicMock(
|
||||
status_code=200,
|
||||
json=lambda: {"message": "ok"},
|
||||
headers={},
|
||||
raise_for_status=lambda: None,
|
||||
)
|
||||
return backend
|
||||
|
||||
|
||||
def test_memory_id_path_segments_are_encoded(sample_config):
|
||||
backend = _backend(sample_config)
|
||||
|
||||
backend.get("mem/a?b#c")
|
||||
backend.update("mem/a?b#c", content="updated")
|
||||
backend.delete("mem/a?b#c")
|
||||
|
||||
paths = [call.args[1] for call in backend._client.request.call_args_list]
|
||||
assert paths == [
|
||||
"/v1/memories/mem%2Fa%3Fb%23c/",
|
||||
"/v1/memories/mem%2Fa%3Fb%23c/",
|
||||
"/v1/memories/mem%2Fa%3Fb%23c/",
|
||||
]
|
||||
|
||||
|
||||
def test_entity_and_event_path_segments_are_encoded(sample_config):
|
||||
backend = _backend(sample_config)
|
||||
|
||||
backend.delete_entities(user_id="org/team?active#frag")
|
||||
backend.get_event("evt/a?b#c")
|
||||
|
||||
paths = [call.args[1] for call in backend._client.request.call_args_list]
|
||||
assert paths == [
|
||||
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
|
||||
"/v1/event/evt%2Fa%3Fb%23c/",
|
||||
]
|
||||
@@ -67,6 +67,10 @@ function rejectTopLevelEntityParams(
|
||||
}
|
||||
}
|
||||
|
||||
function encodePathSegment(value: unknown): string {
|
||||
return encodeURIComponent(String(value));
|
||||
}
|
||||
|
||||
class APIError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
@@ -311,7 +315,7 @@ export default class MemoryClient {
|
||||
this._captureEvent("update", [payloadKeys]);
|
||||
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/memories/${memoryId}/`,
|
||||
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: this.headers,
|
||||
@@ -325,7 +329,7 @@ export default class MemoryClient {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("get", []);
|
||||
return this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/memories/${memoryId}/`,
|
||||
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
|
||||
{
|
||||
headers: this.headers,
|
||||
},
|
||||
@@ -397,7 +401,7 @@ export default class MemoryClient {
|
||||
// @ts-ignore
|
||||
const query = new URLSearchParams(snakeOptions).toString();
|
||||
return this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/memories/${memoryId}/${query ? `?${query}` : ""}`,
|
||||
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/${query ? `?${query}` : ""}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
@@ -428,7 +432,7 @@ export default class MemoryClient {
|
||||
if (this.telemetryId === "") await this.ping();
|
||||
this._captureEvent("history", []);
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/memories/${memoryId}/history/`,
|
||||
`${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
|
||||
{
|
||||
headers: this.headers,
|
||||
},
|
||||
@@ -466,7 +470,7 @@ export default class MemoryClient {
|
||||
data.entity_type = "user";
|
||||
}
|
||||
const response = await this._fetchWithErrorHandling(
|
||||
`${this.host}/v1/entities/${data.entity_type}/${data.entity_id}/`,
|
||||
`${this.host}/v1/entities/${encodePathSegment(data.entity_type)}/${encodePathSegment(data.entity_id)}/`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: this.headers,
|
||||
@@ -510,7 +514,9 @@ export default class MemoryClient {
|
||||
|
||||
for (const entity of to_delete) {
|
||||
try {
|
||||
await this.client.delete(`/v2/entities/${entity.type}/${entity.name}/`);
|
||||
await this.client.delete(
|
||||
`/v2/entities/${encodePathSegment(entity.type)}/${encodePathSegment(entity.name)}/`,
|
||||
);
|
||||
} catch (error: any) {
|
||||
throw new APIError(
|
||||
`Failed to delete ${entity.type} ${entity.name}: ${error.message}`,
|
||||
|
||||
@@ -118,6 +118,20 @@ describe("MemoryClient - get()", () => {
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await expect(client.get("nonexistent")).rejects.toThrow("Memory not found");
|
||||
});
|
||||
|
||||
test("URL-encodes memory ID path segments", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem%2Fa%3Fb%23c/", {
|
||||
status: 200,
|
||||
body: createMockMemory({ id: "mem/a?b#c" }),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.get("mem/a?b#c");
|
||||
|
||||
expect(findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/")).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── update() ────────────────────────────────────────────
|
||||
@@ -213,6 +227,22 @@ describe("MemoryClient - update()", () => {
|
||||
"At least one of text, metadata, timestamp, or expirationDate must be provided",
|
||||
);
|
||||
});
|
||||
|
||||
test("URL-encodes memory ID path segments", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem%2Fa%3Fb%23c/", {
|
||||
status: 200,
|
||||
body: createMockMemory({ id: "mem/a?b#c" }),
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.update("mem/a?b#c", { text: "Updated text" });
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/", "PUT"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ─── delete() ────────────────────────────────────────────
|
||||
@@ -250,6 +280,22 @@ describe("MemoryClient - delete()", () => {
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("delete_linked=true");
|
||||
});
|
||||
|
||||
test("URL-encodes memory ID path segments before adding query params", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem%2Fa%3Fb%23c/", {
|
||||
status: 200,
|
||||
body: { message: "Memory deleted successfully" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.delete("mem/a?b#c", { deleteLinked: true });
|
||||
|
||||
const call = findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/", "DELETE");
|
||||
expect(call).toBeDefined();
|
||||
expect(call![0]).toContain("delete_linked=true");
|
||||
});
|
||||
});
|
||||
|
||||
// ─── deleteAll() ─────────────────────────────────────────
|
||||
@@ -326,4 +372,23 @@ describe("MemoryClient - history()", () => {
|
||||
const result: MemoryHistory[] = await client.history("mem_123");
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test("URL-encodes memory ID path segments", async () => {
|
||||
const historyEntries = [
|
||||
createMockMemoryHistory({ memory_id: "mem/a?b#c" }),
|
||||
];
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/memories/mem%2Fa%3Fb%23c/history/", {
|
||||
status: 200,
|
||||
body: historyEntries,
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.history("mem/a?b#c");
|
||||
|
||||
expect(
|
||||
findFetchCall(mock, "/v1/memories/mem%2Fa%3Fb%23c/history/"),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -71,4 +71,43 @@ describe("MemoryClient - deleteUser() (deprecated)", () => {
|
||||
findFetchCall(mock, "/v1/entities/user/456/", "DELETE"),
|
||||
).toBeDefined();
|
||||
});
|
||||
|
||||
test("URL-encodes entity path segments", async () => {
|
||||
const extra = new Map<string, { status: number; body: unknown }>();
|
||||
extra.set("/v1/entities/team%2Ftype/org%2Fteam%3Factive%23frag/", {
|
||||
status: 200,
|
||||
body: { message: "Entity deleted successfully!" },
|
||||
});
|
||||
const mock = setupMockFetch(extra);
|
||||
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
await client.deleteUser({
|
||||
entity_id: "org/team?active#frag" as never,
|
||||
entity_type: "team/type",
|
||||
});
|
||||
|
||||
expect(
|
||||
findFetchCall(
|
||||
mock,
|
||||
"/v1/entities/team%2Ftype/org%2Fteam%3Factive%23frag/",
|
||||
"DELETE",
|
||||
),
|
||||
).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("MemoryClient - deleteUsers()", () => {
|
||||
test("URL-encodes entity path segments", async () => {
|
||||
setupMockFetch();
|
||||
const client = new MemoryClient({ apiKey: TEST_API_KEY });
|
||||
client.telemetryId = "test@example.com";
|
||||
const deleteMock = jest.fn().mockResolvedValue({});
|
||||
client.client = { delete: deleteMock };
|
||||
|
||||
await client.deleteUsers({ userId: "org/team?active#frag" });
|
||||
|
||||
expect(deleteMock).toHaveBeenCalledWith(
|
||||
"/v2/entities/user/org%2Fteam%3Factive%23frag/",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
+32
-12
@@ -3,6 +3,7 @@ import logging
|
||||
import os
|
||||
import warnings
|
||||
from typing import Any, Dict, List, Optional
|
||||
from urllib.parse import quote
|
||||
|
||||
import httpx
|
||||
import requests
|
||||
@@ -42,6 +43,10 @@ def _validate_and_trim_search_query(query: str) -> str:
|
||||
return trimmed
|
||||
|
||||
|
||||
def _encode_path_segment(value: Any) -> str:
|
||||
return quote(str(value), safe="")
|
||||
|
||||
|
||||
def _maybe_alias_anon_to_email(user_email):
|
||||
"""Fire $identify per prior anon ID so PostHog merges them into email.
|
||||
|
||||
@@ -229,7 +234,7 @@ class MemoryClient:
|
||||
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
|
||||
"""
|
||||
params = self._prepare_params()
|
||||
response = self.client.get(f"/v1/memories/{memory_id}/", params=params)
|
||||
response = self.client.get(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
return response.json()
|
||||
@@ -367,7 +372,7 @@ class MemoryClient:
|
||||
|
||||
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
params = self._prepare_params()
|
||||
response = self.client.put(f"/v1/memories/{memory_id}/", json=payload, params=params)
|
||||
response = self.client.put(f"/v1/memories/{_encode_path_segment(memory_id)}/", json=payload, params=params)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@@ -395,7 +400,7 @@ class MemoryClient:
|
||||
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
|
||||
"""
|
||||
params = self._prepare_params({"delete_linked": delete_linked or None})
|
||||
response = self.client.delete(f"/v1/memories/{memory_id}/", params=params)
|
||||
response = self.client.delete(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params)
|
||||
response.raise_for_status()
|
||||
capture_client_event(
|
||||
"client.delete", self, {"memory_id": memory_id, "delete_linked": delete_linked, "sync_type": "sync"}
|
||||
@@ -452,7 +457,7 @@ class MemoryClient:
|
||||
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
|
||||
"""
|
||||
params = self._prepare_params()
|
||||
response = self.client.get(f"/v1/memories/{memory_id}/history/", params=params)
|
||||
response = self.client.get(f"/v1/memories/{_encode_path_segment(memory_id)}/history/", params=params)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "sync"})
|
||||
return response.json()
|
||||
@@ -513,7 +518,10 @@ class MemoryClient:
|
||||
|
||||
# Delete entities and check response immediately
|
||||
for entity in to_delete:
|
||||
response = self.client.delete(f"/v2/entities/{entity['type']}/{entity['name']}/", params=params)
|
||||
response = self.client.delete(
|
||||
f"/v2/entities/{_encode_path_segment(entity['type'])}/{_encode_path_segment(entity['name'])}/",
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
capture_client_event(
|
||||
@@ -691,7 +699,9 @@ class MemoryClient:
|
||||
ValueError: If org_id or project_id are not set.
|
||||
"""
|
||||
logger.warning(
|
||||
"get_project() method is going to be deprecated in version v1.0 of the package. Please use the client.project.get() method instead."
|
||||
"get_project() method is going to be deprecated in version v1.0 "
|
||||
"of the package. Please use the client.project.get() method "
|
||||
"instead."
|
||||
)
|
||||
if not (self.org_id and self.project_id):
|
||||
raise ValueError("org_id and project_id must be set to access instructions or categories")
|
||||
@@ -1147,7 +1157,7 @@ class AsyncMemoryClient:
|
||||
@api_error_handler
|
||||
async def get(self, memory_id: str) -> Dict[str, Any]:
|
||||
params = self._prepare_params()
|
||||
response = await self.async_client.get(f"/v1/memories/{memory_id}/", params=params)
|
||||
response = await self.async_client.get(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
return response.json()
|
||||
@@ -1285,7 +1295,9 @@ class AsyncMemoryClient:
|
||||
|
||||
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
params = self._prepare_params()
|
||||
response = await self.async_client.put(f"/v1/memories/{memory_id}/", json=payload, params=params)
|
||||
response = await self.async_client.put(
|
||||
f"/v1/memories/{_encode_path_segment(memory_id)}/", json=payload, params=params
|
||||
)
|
||||
response.raise_for_status()
|
||||
return response.json()
|
||||
|
||||
@@ -1313,7 +1325,7 @@ class AsyncMemoryClient:
|
||||
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
|
||||
"""
|
||||
params = self._prepare_params({"delete_linked": delete_linked or None})
|
||||
response = await self.async_client.delete(f"/v1/memories/{memory_id}/", params=params)
|
||||
response = await self.async_client.delete(f"/v1/memories/{_encode_path_segment(memory_id)}/", params=params)
|
||||
response.raise_for_status()
|
||||
capture_client_event(
|
||||
"client.delete", self, {"memory_id": memory_id, "delete_linked": delete_linked, "sync_type": "async"}
|
||||
@@ -1365,7 +1377,10 @@ class AsyncMemoryClient:
|
||||
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
|
||||
"""
|
||||
params = self._prepare_params()
|
||||
response = await self.async_client.get(f"/v1/memories/{memory_id}/history/", params=params)
|
||||
response = await self.async_client.get(
|
||||
f"/v1/memories/{_encode_path_segment(memory_id)}/history/",
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "async"})
|
||||
return response.json()
|
||||
@@ -1426,7 +1441,10 @@ class AsyncMemoryClient:
|
||||
|
||||
# Delete entities and check response immediately
|
||||
for entity in to_delete:
|
||||
response = await self.async_client.delete(f"/v2/entities/{entity['type']}/{entity['name']}/", params=params)
|
||||
response = await self.async_client.delete(
|
||||
f"/v2/entities/{_encode_path_segment(entity['type'])}/{_encode_path_segment(entity['name'])}/",
|
||||
params=params,
|
||||
)
|
||||
response.raise_for_status()
|
||||
|
||||
capture_client_event(
|
||||
@@ -1590,7 +1608,9 @@ class AsyncMemoryClient:
|
||||
ValueError: If org_id or project_id are not set.
|
||||
"""
|
||||
logger.warning(
|
||||
"get_project() method is going to be deprecated in version v1.0 of the package. Please use the client.project.get() method instead."
|
||||
"get_project() method is going to be deprecated in version v1.0 "
|
||||
"of the package. Please use the client.project.get() method "
|
||||
"instead."
|
||||
)
|
||||
if not (self.org_id and self.project_id):
|
||||
raise ValueError("org_id and project_id must be set to access instructions or categories")
|
||||
|
||||
+80
-1
@@ -1,7 +1,8 @@
|
||||
"""Tests for MemoryClient entity parameter rejection."""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
@@ -259,6 +260,84 @@ class TestDeleteLinked:
|
||||
assert "delete_linked" not in call_args.kwargs.get("params", {})
|
||||
|
||||
|
||||
class TestPathSegmentEncoding:
|
||||
"""Path params should remain one URL segment even when IDs contain URL syntax."""
|
||||
|
||||
def _setup_response(self):
|
||||
return MagicMock(json=lambda: {"message": "ok"}, raise_for_status=lambda: None)
|
||||
|
||||
def _called_paths(self, mock_method):
|
||||
return [call.args[0] for call in mock_method.call_args_list]
|
||||
|
||||
def test_sync_memory_id_path_segments_are_encoded(self, mock_memory_client):
|
||||
mock_memory_client.client.get.return_value = self._setup_response()
|
||||
mock_memory_client.client.put.return_value = self._setup_response()
|
||||
mock_memory_client.client.delete.return_value = self._setup_response()
|
||||
|
||||
memory_id = "mem/a?b#c"
|
||||
|
||||
mock_memory_client.get(memory_id)
|
||||
mock_memory_client.update(memory_id, text="updated")
|
||||
mock_memory_client.delete(memory_id)
|
||||
mock_memory_client.history(memory_id)
|
||||
|
||||
assert "/v1/memories/mem%2Fa%3Fb%23c/" in self._called_paths(mock_memory_client.client.get)
|
||||
assert mock_memory_client.client.put.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
||||
assert mock_memory_client.client.delete.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
||||
assert "/v1/memories/mem%2Fa%3Fb%23c/history/" in self._called_paths(mock_memory_client.client.get)
|
||||
|
||||
def test_sync_entity_path_segments_are_encoded(self, mock_memory_client):
|
||||
mock_memory_client.client.delete.return_value = self._setup_response()
|
||||
|
||||
mock_memory_client.delete_users(user_id="org/team?active#frag")
|
||||
|
||||
assert mock_memory_client.client.delete.call_args.args[0] == (
|
||||
"/v2/entities/user/org%2Fteam%3Factive%23frag/"
|
||||
)
|
||||
|
||||
def test_async_memory_id_path_segments_are_encoded(self):
|
||||
asyncio.run(self._assert_async_memory_id_path_segments_are_encoded())
|
||||
|
||||
async def _assert_async_memory_id_path_segments_are_encoded(self):
|
||||
from mem0.client.main import AsyncMemoryClient
|
||||
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
client.async_client.get = AsyncMock(return_value=self._setup_response())
|
||||
client.async_client.put = AsyncMock(return_value=self._setup_response())
|
||||
client.async_client.delete = AsyncMock(return_value=self._setup_response())
|
||||
|
||||
memory_id = "mem/a?b#c"
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.get(memory_id)
|
||||
await client.update(memory_id, text="updated")
|
||||
await client.delete(memory_id)
|
||||
await client.history(memory_id)
|
||||
|
||||
assert client.async_client.get.call_args_list[0].args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
||||
assert client.async_client.put.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
||||
assert client.async_client.delete.call_args.args[0] == "/v1/memories/mem%2Fa%3Fb%23c/"
|
||||
assert client.async_client.get.call_args_list[1].args[0] == "/v1/memories/mem%2Fa%3Fb%23c/history/"
|
||||
|
||||
def test_async_entity_path_segments_are_encoded(self):
|
||||
asyncio.run(self._assert_async_entity_path_segments_are_encoded())
|
||||
|
||||
async def _assert_async_entity_path_segments_are_encoded(self):
|
||||
from mem0.client.main import AsyncMemoryClient
|
||||
|
||||
client = AsyncMemoryClient.__new__(AsyncMemoryClient)
|
||||
client.async_client = MagicMock()
|
||||
client.async_client.delete = AsyncMock(return_value=self._setup_response())
|
||||
|
||||
with patch("mem0.client.main.capture_client_event"):
|
||||
await client.delete_users(user_id="org/team?active#frag")
|
||||
|
||||
assert client.async_client.delete.call_args.args[0] == (
|
||||
"/v2/entities/user/org%2Fteam%3Factive%23frag/"
|
||||
)
|
||||
|
||||
|
||||
class TestValidateApiKeyHttpError:
|
||||
"""_validate_api_key should surface a clear ValueError on a non-JSON HTTP error.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user