fix: encode dynamic URL path segments (#5963)

Co-authored-by: kartik-mem0 <kartik.labhshetwar@mem0.ai>
This commit is contained in:
VectorPeak
2026-07-07 16:11:54 +05:30
committed by GitHub
co-authored by kartik-mem0
parent 87276ef968
commit cc52f0e367
9 changed files with 374 additions and 39 deletions
+30 -14
View File
@@ -17,6 +17,10 @@ import {
type SearchOptions, type SearchOptions,
} from "./base.js"; } from "./base.js";
function encodePathSegment(value: unknown): string {
return encodeURIComponent(String(value));
}
export class PlatformBackend implements Backend { export class PlatformBackend implements Backend {
private baseUrl: string; private baseUrl: string;
private headers: Record<string, string>; private headers: Record<string, string>;
@@ -218,9 +222,13 @@ export class PlatformBackend implements Backend {
} }
async get(memoryId: string): Promise<Record<string, unknown>> { async get(memoryId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/memories/${memoryId}/`, { return (await this._request(
params: { source: "CLI" }, "GET",
})) as Record<string, unknown>; `/v1/memories/${encodePathSegment(memoryId)}/`,
{
params: { source: "CLI" },
},
)) as Record<string, unknown>;
} }
async listMemories( async listMemories(
@@ -277,9 +285,13 @@ export class PlatformBackend implements Backend {
if (content) payload.text = content; if (content) payload.text = content;
if (metadata) payload.metadata = metadata; if (metadata) payload.metadata = metadata;
payload.source = "CLI"; payload.source = "CLI";
return (await this._request("PUT", `/v1/memories/${memoryId}/`, { return (await this._request(
json: payload, "PUT",
})) as Record<string, unknown>; `/v1/memories/${encodePathSegment(memoryId)}/`,
{
json: payload,
},
)) as Record<string, unknown>;
} }
async delete( async delete(
@@ -297,9 +309,13 @@ export class PlatformBackend implements Backend {
})) as Record<string, unknown>; })) as Record<string, unknown>;
} }
if (memoryId) { if (memoryId) {
return (await this._request("DELETE", `/v1/memories/${memoryId}/`, { return (await this._request(
params: { source: "CLI" }, "DELETE",
})) as Record<string, unknown>; `/v1/memories/${encodePathSegment(memoryId)}/`,
{
params: { source: "CLI" },
},
)) as Record<string, unknown>;
} }
throw new Error("Either memoryId or --all is required"); throw new Error("Either memoryId or --all is required");
} }
@@ -323,7 +339,7 @@ export class PlatformBackend implements Backend {
for (const [entityType, entityId] of entities) { for (const [entityType, entityId] of entities) {
results[entityType] = (await this._request( results[entityType] = (await this._request(
"DELETE", "DELETE",
`/v2/entities/${entityType}/${entityId}/`, `/v2/entities/${encodePathSegment(entityType)}/${encodePathSegment(entityId)}/`,
{ params: { source: "CLI" } }, { params: { source: "CLI" } },
)) as Record<string, unknown>; )) as Record<string, unknown>;
} }
@@ -386,9 +402,9 @@ export class PlatformBackend implements Backend {
} }
async getEvent(eventId: string): Promise<Record<string, unknown>> { async getEvent(eventId: string): Promise<Record<string, unknown>> {
return (await this._request("GET", `/v1/event/${eventId}/`)) as Record< return (await this._request(
string, "GET",
unknown `/v1/event/${encodePathSegment(eventId)}/`,
>; )) as Record<string, unknown>;
} }
} }
+49 -1
View File
@@ -2,7 +2,7 @@
* Tests for the Platform backend (mem0 Platform API client). * 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 { PlatformBackend } from "../src/backend/platform.js";
import { createDefaultConfig } from "../src/config.js"; import { createDefaultConfig } from "../src/config.js";
@@ -12,6 +12,22 @@ function makeBackend(): PlatformBackend {
return new PlatformBackend(createDefaultConfig().platform); 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", () => { describe("deleteEntities", () => {
it("returns all results keyed by entity type for a multi-entity delete", async () => { it("returns all results keyed by entity type for a multi-entity delete", async () => {
const backend = makeBackend(); 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/",
]);
});
});
+24 -5
View File
@@ -3,6 +3,7 @@
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from urllib.parse import quote
import httpx import httpx
@@ -11,6 +12,10 @@ from mem0_cli.backend.base import Backend
from mem0_cli.config import PlatformConfig from mem0_cli.config import PlatformConfig
def _encode_path_segment(value: Any) -> str:
return quote(str(value), safe="")
class PlatformBackend(Backend): class PlatformBackend(Backend):
"""Backend that talks to the mem0 Platform API.""" """Backend that talks to the mem0 Platform API."""
@@ -196,7 +201,11 @@ class PlatformBackend(Backend):
) )
def get(self, memory_id: str) -> dict: 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( def list_memories(
self, self,
@@ -250,7 +259,11 @@ class PlatformBackend(Backend):
if metadata: if metadata:
payload["metadata"] = metadata payload["metadata"] = metadata
payload["source"] = "CLI" 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( def delete(
self, self,
@@ -274,7 +287,11 @@ class PlatformBackend(Backend):
params["run_id"] = run_id params["run_id"] = run_id
return self._request("DELETE", "/v1/memories/", params=params) return self._request("DELETE", "/v1/memories/", params=params)
elif memory_id: 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: else:
raise ValueError("Either memory_id or --all is required") raise ValueError("Either memory_id or --all is required")
@@ -302,7 +319,9 @@ class PlatformBackend(Backend):
results: dict = {} results: dict = {}
for entity_type, entity_id in entities.items(): for entity_type, entity_id in entities.items():
results[entity_type] = self._request( 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 return results
@@ -348,7 +367,7 @@ class PlatformBackend(Backend):
return result if isinstance(result, list) else result.get("results", []) return result if isinstance(result, list) else result.get("results", [])
def get_event(self, event_id: str) -> dict: 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): 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/",
]
+12 -6
View File
@@ -67,6 +67,10 @@ function rejectTopLevelEntityParams(
} }
} }
function encodePathSegment(value: unknown): string {
return encodeURIComponent(String(value));
}
class APIError extends Error { class APIError extends Error {
constructor(message: string) { constructor(message: string) {
super(message); super(message);
@@ -311,7 +315,7 @@ export default class MemoryClient {
this._captureEvent("update", [payloadKeys]); this._captureEvent("update", [payloadKeys]);
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${memoryId}/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
{ {
method: "PUT", method: "PUT",
headers: this.headers, headers: this.headers,
@@ -325,7 +329,7 @@ export default class MemoryClient {
if (this.telemetryId === "") await this.ping(); if (this.telemetryId === "") await this.ping();
this._captureEvent("get", []); this._captureEvent("get", []);
return this._fetchWithErrorHandling( return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${memoryId}/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/`,
{ {
headers: this.headers, headers: this.headers,
}, },
@@ -397,7 +401,7 @@ export default class MemoryClient {
// @ts-ignore // @ts-ignore
const query = new URLSearchParams(snakeOptions).toString(); const query = new URLSearchParams(snakeOptions).toString();
return this._fetchWithErrorHandling( return this._fetchWithErrorHandling(
`${this.host}/v1/memories/${memoryId}/${query ? `?${query}` : ""}`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/${query ? `?${query}` : ""}`,
{ {
method: "DELETE", method: "DELETE",
headers: this.headers, headers: this.headers,
@@ -428,7 +432,7 @@ export default class MemoryClient {
if (this.telemetryId === "") await this.ping(); if (this.telemetryId === "") await this.ping();
this._captureEvent("history", []); this._captureEvent("history", []);
const response = await this._fetchWithErrorHandling( const response = await this._fetchWithErrorHandling(
`${this.host}/v1/memories/${memoryId}/history/`, `${this.host}/v1/memories/${encodePathSegment(memoryId)}/history/`,
{ {
headers: this.headers, headers: this.headers,
}, },
@@ -466,7 +470,7 @@ export default class MemoryClient {
data.entity_type = "user"; data.entity_type = "user";
} }
const response = await this._fetchWithErrorHandling( 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", method: "DELETE",
headers: this.headers, headers: this.headers,
@@ -510,7 +514,9 @@ export default class MemoryClient {
for (const entity of to_delete) { for (const entity of to_delete) {
try { 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) { } catch (error: any) {
throw new APIError( throw new APIError(
`Failed to delete ${entity.type} ${entity.name}: ${error.message}`, `Failed to delete ${entity.type} ${entity.name}: ${error.message}`,
@@ -118,6 +118,20 @@ describe("MemoryClient - get()", () => {
const client = new MemoryClient({ apiKey: TEST_API_KEY }); const client = new MemoryClient({ apiKey: TEST_API_KEY });
await expect(client.get("nonexistent")).rejects.toThrow("Memory not found"); 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() ──────────────────────────────────────────── // ─── update() ────────────────────────────────────────────
@@ -213,6 +227,22 @@ describe("MemoryClient - update()", () => {
"At least one of text, metadata, timestamp, or expirationDate must be provided", "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() ──────────────────────────────────────────── // ─── delete() ────────────────────────────────────────────
@@ -250,6 +280,22 @@ describe("MemoryClient - delete()", () => {
expect(call).toBeDefined(); expect(call).toBeDefined();
expect(call![0]).toContain("delete_linked=true"); 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() ───────────────────────────────────────── // ─── deleteAll() ─────────────────────────────────────────
@@ -326,4 +372,23 @@ describe("MemoryClient - history()", () => {
const result: MemoryHistory[] = await client.history("mem_123"); const result: MemoryHistory[] = await client.history("mem_123");
expect(result).toEqual([]); 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"), findFetchCall(mock, "/v1/entities/user/456/", "DELETE"),
).toBeDefined(); ).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
View File
@@ -3,6 +3,7 @@ import logging
import os import os
import warnings import warnings
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from urllib.parse import quote
import httpx import httpx
import requests import requests
@@ -42,6 +43,10 @@ def _validate_and_trim_search_query(query: str) -> str:
return trimmed return trimmed
def _encode_path_segment(value: Any) -> str:
return quote(str(value), safe="")
def _maybe_alias_anon_to_email(user_email): def _maybe_alias_anon_to_email(user_email):
"""Fire $identify per prior anon ID so PostHog merges them into 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). MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
""" """
params = self._prepare_params() 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() response.raise_for_status()
capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "sync"}) capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "sync"})
return response.json() return response.json()
@@ -367,7 +372,7 @@ class MemoryClient:
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"}) capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "sync"})
params = self._prepare_params() 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() response.raise_for_status()
return response.json() return response.json()
@@ -395,7 +400,7 @@ class MemoryClient:
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
""" """
params = self._prepare_params({"delete_linked": delete_linked or None}) 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() response.raise_for_status()
capture_client_event( capture_client_event(
"client.delete", self, {"memory_id": memory_id, "delete_linked": delete_linked, "sync_type": "sync"} "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). MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
""" """
params = self._prepare_params() 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() response.raise_for_status()
capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "sync"}) capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "sync"})
return response.json() return response.json()
@@ -513,7 +518,10 @@ class MemoryClient:
# Delete entities and check response immediately # Delete entities and check response immediately
for entity in to_delete: 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() response.raise_for_status()
capture_client_event( capture_client_event(
@@ -691,7 +699,9 @@ class MemoryClient:
ValueError: If org_id or project_id are not set. ValueError: If org_id or project_id are not set.
""" """
logger.warning( 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): if not (self.org_id and self.project_id):
raise ValueError("org_id and project_id must be set to access instructions or categories") raise ValueError("org_id and project_id must be set to access instructions or categories")
@@ -1147,7 +1157,7 @@ class AsyncMemoryClient:
@api_error_handler @api_error_handler
async def get(self, memory_id: str) -> Dict[str, Any]: async def get(self, memory_id: str) -> Dict[str, Any]:
params = self._prepare_params() 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() response.raise_for_status()
capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "async"}) capture_client_event("client.get", self, {"memory_id": memory_id, "sync_type": "async"})
return response.json() return response.json()
@@ -1285,7 +1295,9 @@ class AsyncMemoryClient:
capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"}) capture_client_event("client.update", self, {"memory_id": memory_id, "sync_type": "async"})
params = self._prepare_params() 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() response.raise_for_status()
return response.json() return response.json()
@@ -1313,7 +1325,7 @@ class AsyncMemoryClient:
MemoryNotFoundError: If the memory doesn't exist (for updates/deletes). MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
""" """
params = self._prepare_params({"delete_linked": delete_linked or None}) 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() response.raise_for_status()
capture_client_event( capture_client_event(
"client.delete", self, {"memory_id": memory_id, "delete_linked": delete_linked, "sync_type": "async"} "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). MemoryNotFoundError: If the memory doesn't exist (for updates/deletes).
""" """
params = self._prepare_params() 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() response.raise_for_status()
capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "async"}) capture_client_event("client.history", self, {"memory_id": memory_id, "sync_type": "async"})
return response.json() return response.json()
@@ -1426,7 +1441,10 @@ class AsyncMemoryClient:
# Delete entities and check response immediately # Delete entities and check response immediately
for entity in to_delete: 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() response.raise_for_status()
capture_client_event( capture_client_event(
@@ -1590,7 +1608,9 @@ class AsyncMemoryClient:
ValueError: If org_id or project_id are not set. ValueError: If org_id or project_id are not set.
""" """
logger.warning( 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): if not (self.org_id and self.project_id):
raise ValueError("org_id and project_id must be set to access instructions or categories") raise ValueError("org_id and project_id must be set to access instructions or categories")
+80 -1
View File
@@ -1,7 +1,8 @@
"""Tests for MemoryClient entity parameter rejection.""" """Tests for MemoryClient entity parameter rejection."""
import asyncio
import json import json
from unittest.mock import MagicMock, patch from unittest.mock import AsyncMock, MagicMock, patch
import httpx import httpx
import pytest import pytest
@@ -259,6 +260,84 @@ class TestDeleteLinked:
assert "delete_linked" not in call_args.kwargs.get("params", {}) 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: class TestValidateApiKeyHttpError:
"""_validate_api_key should surface a clear ValueError on a non-JSON HTTP error. """_validate_api_key should surface a clear ValueError on a non-JSON HTTP error.