diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5cbcde2..9dc1f0d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: - name: Install run: | python -m pip install --upgrade pip - pip install -e "./sdk-python[dev,async]" + pip install -e "./sdk-python[dev,async,server]" - name: Validate OpenAPI spec (Principle I) run: omp-validate-spec - name: Run conformance suite (overall ≥85%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c1eedb..87a977d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,83 @@ All notable changes to this project will be documented in this file. +## [0.5.0] — Unreleased (M3.2 PR-B — `omp-server` FastAPI) + +### Added + +- **`openmem.server`** — production-shaped FastAPI HTTP server that + exposes the full OMP verb set as a RESTful service backed by + `AsyncMemory`. New install extra `[server]` (depends on + `openmem[async]` plus `fastapi>=0.115` and `uvicorn[standard]>=0.30`). + `from openmem.server import create_app` raises a clear `ImportError` + with `pip install 'openmem[server]'` when the extra is missing + (C-EXT-2). +- **`omp-server` CLI** (`pyproject.toml [project.scripts]`) — + `omp-server --provider [--host …] [--port …] [--url …]`. CLI + flags > env vars > defaults; `--help` documents trusted-network + deployment (auth deferred); missing config exits 2 with a + `omp-server: missing config:` message; successful boot prints + `omp-server: serving at http://:` to stderr + exactly once (C-CLI-1..4). +- **`OmpServerConfig`** — frozen dataclass with the four CFG-INV-1..4 + invariants (port range, body-size range, postgres URL when + `provider=postgres`, matching `*_API_KEY` for managed providers). + Importable without the FastAPI extras. +- **9 routes** mirroring `spec/omp-0.1.openapi.yaml` 1:1: + `POST/GET/PATCH/DELETE /memories[/{id}]`, `GET /memories/search`, + `POST /context`, `GET /audit`, `GET /capabilities`, `GET /healthz`. + Every handler is `async def` and obtains its `AsyncMemory` via + `Depends(get_memory)` so client disconnect (FR-018) propagates as + `CancelledError` through the AsyncMemory cancellation contract. +- **11-row error envelope mapping** (FR-016/017): every adapter + exception (`NotFoundError`, `InvalidRequestError`, `UnauthorizedError`, + `ScopeDeniedError`, `RateLimitedError`, `UnsupportedCapabilityError`, + `ProviderError(ingestion_timeout)`, `ProviderError(other)`, + unhandled `Exception`, oversized body, pool exhaustion) maps to the + documented HTTP status + `{"error": {"code", "message", "type"}}` + body. `RequestValidationError` from Pydantic is normalized to + `400 invalid_request`. +- **`MaxRequestSizeMiddleware`** (FR-021): pure-ASGI guard that rejects + bodies > `OmpServerConfig.max_request_bytes` (1 MiB default; range + 1 KiB..100 MiB) with `413 payload_too_large` BEFORE Pydantic runs. + Trusts `Content-Length` when present, else streams in bounded chunks. +- **`LoggingMiddleware`** (FR-020): emits exactly one INFO access line + per request in the format + ` INFO ms req=` + with no body, no `user_id`, no `Authorization` header, and no value + whose key matches `(?i)password|secret|token|key|api_key`. + `X-Request-Id` is honored if it matches `^[A-Za-z0-9._\-]{1,64}$`, + otherwise replaced with a fresh UUID4; the resolved id is echoed on + every response (C-LOG-1..4). +- **CORS default-deny** (FR-022): no `CORSMiddleware` is installed + unless `OmpServerConfig.cors_origins` is non-empty. When enabled, + `allow_credentials=False` and methods are limited to + `GET/POST/PATCH/DELETE` (C-CORS-1/2). +- **Provider-aware `/healthz`** (FR-019): postgres acquires a pool + connection within 1 s; passthrough does a `HEAD` upstream within 2 s; + mem0/supermemory/letta return 200 unconditionally to avoid spending + paid API credits on health checks (C-HEA-1..4, documented in + `--help`). +- **`X-User-Id` header propagation** (C-UID-1..3): GET/DELETE routes + read `user_id` from the header; POST/PATCH read it from the JSON + body. Empty/whitespace `user_id` is rejected with `400 invalid_request` + BEFORE the adapter is called. `user_id` never appears in log lines. +- **OpenAPI conformance test** (FR-015) loads `spec/omp-0.1.openapi.yaml` + via PyYAML, resolves `$ref` schemas, and validates real + `httpx.AsyncClient` responses for every route × representative + status case via `jsonschema`. + +### Changed + +- `sdk-python/pyproject.toml`: bumped `version` to `0.5.0`; added the + `omp-server` console script. + +### Preserved + +- The sync `openmem.Memory` class and the `openmem.AsyncMemory` facade + are unchanged. The full sync + async + server suites pass at 476 + passed / 26 skipped, total coverage 85.92 % (gate ≥ 85 %). + ## [0.4.0] — Unreleased (M3.2 PR-A — `AsyncMemory`) ### Added diff --git a/README.md b/README.md index 60749c8..482d2ff 100644 --- a/README.md +++ b/README.md @@ -48,6 +48,23 @@ print(ctx.text) - `openmem-eval` — manual benchmark harness comparing recall, MRR, and latency across configured providers. **Never runs in CI.** Default invocation is a dry-run that makes zero network calls. See [specs/004-eval-kit/quickstart.md](specs/004-eval-kit/quickstart.md) for usage and [docs/eval/](docs/eval/README.md) for a sample report and trace from a real postgres run. - `openmem.AsyncMemory` — async/await-native facade mirroring `openmem.Memory`. Postgres + passthrough adapters use native async clients (asyncpg, httpx); mem0/supermemory/letta are wrapped with a per-instance thread pool. Cancellation propagates within 50 ms on the native tier. Install with `pip install 'openmem[async]'` and see [specs/005-async-fastapi/quickstart.md](specs/005-async-fastapi/quickstart.md) §1–§4. +- `omp-server` — production-shaped FastAPI HTTP server that exposes the full OMP verb set over HTTP using `AsyncMemory` under the hood. Install with `pip install 'openmem[server]'` then boot, e.g.: + + ```sh + omp-server --provider postgres --url postgresql://user:pass@host:5432/db --port 8080 + ``` + + Trusted-network deployment only — auth is deferred to a future release. See [specs/005-async-fastapi/contracts/http-server.md](specs/005-async-fastapi/contracts/http-server.md) for the route table, error-envelope mapping, and CORS / size-limit / health-check contracts. + +## HTTP server + +```sh +pip install 'openmem[server]' +omp-server --provider postgres --url postgresql://user:pass@host:5432/db +# omp-server: serving postgres at http://127.0.0.1:8080 +``` + +Routes mirror `spec/omp-0.1.openapi.yaml` exactly: `POST/GET/PATCH/DELETE /memories[/{id}]`, `GET /memories/search`, `POST /context`, `GET /audit`, `GET /capabilities`, `GET /healthz`. Every request emits one INFO access log line with no `user_id` and no secret-keyed values. ## License diff --git a/sdk-python/README.md b/sdk-python/README.md index 326e527..5dd81e7 100644 --- a/sdk-python/README.md +++ b/sdk-python/README.md @@ -41,6 +41,21 @@ while the worker thread completes in the background. See [../specs/005-async-fastapi/quickstart.md](../specs/005-async-fastapi/quickstart.md) for the full contract. +## HTTP server (`omp-server`) + +```sh +pip install 'openmem[server]' +omp-server --provider postgres --url postgresql://user:pass@host:5432/db +# omp-server: serving postgres at http://127.0.0.1:8080 +``` + +Routes mirror `spec/omp-0.1.openapi.yaml` 1:1 (`POST/GET/PATCH/DELETE +/memories[/{id}]`, `GET /memories/search`, `POST /context`, `GET /audit`, +`GET /capabilities`, `GET /healthz`). The 11-row error envelope, 1 MiB +default body limit, and default-deny CORS are documented in +[../specs/005-async-fastapi/contracts/http-server.md](../specs/005-async-fastapi/contracts/http-server.md). +Trusted-network deployment only — auth is deferred. + ## Environment variables | Var | Purpose | diff --git a/sdk-python/openmem/__init__.py b/sdk-python/openmem/__init__.py index c376589..eb43fcb 100644 --- a/sdk-python/openmem/__init__.py +++ b/sdk-python/openmem/__init__.py @@ -40,7 +40,7 @@ SearchResult, ) -__version__ = "0.4.0" +__version__ = "0.5.0" __all__ = [ "__version__", diff --git a/sdk-python/openmem/server/__init__.py b/sdk-python/openmem/server/__init__.py new file mode 100644 index 0000000..76e5bd5 --- /dev/null +++ b/sdk-python/openmem/server/__init__.py @@ -0,0 +1,51 @@ +"""`openmem.server` — FastAPI HTTP server (PR-B / M3.2). + +Public surface (lazy-loaded so `OmpServerConfig` is importable without +the FastAPI extras): + +* :class:`OmpServerConfig` — frozen dataclass (no FastAPI dependency). +* :func:`create_app` — build a FastAPI app from a config. +* :data:`app` — module-level FastAPI built from environment variables; + exists for `uvicorn openmem.server:app` workflows. + +Importing :func:`create_app` or :data:`app` without the `[server]` +extras raises :class:`ImportError` with the install hint, mirroring the +behavior of `openmem.AsyncMemory` for the `[async]` extras. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from openmem.server.config import OmpServerConfig + +__all__ = ["OmpServerConfig", "create_app", "app"] + + +def _require_fastapi() -> None: + """Eagerly probe FastAPI; raise the standard install-hint error.""" + try: + import fastapi # noqa: F401 + except ImportError as exc: # pragma: no cover - exercised in bare venv + raise ImportError( + "openmem.server requires the server extras. " + "Install with: pip install 'openmem[server]'" + ) from exc + + +def __getattr__(name: str) -> Any: + if name == "create_app": + _require_fastapi() + from openmem.server.app import create_app as _create_app + + return _create_app + if name == "app": + _require_fastapi() + from openmem.server.app import build_app_from_env + + return build_app_from_env() + raise AttributeError(f"module 'openmem.server' has no attribute {name!r}") + + +if TYPE_CHECKING: # pragma: no cover + from openmem.server.app import create_app # noqa: F401 diff --git a/sdk-python/openmem/server/app.py b/sdk-python/openmem/server/app.py new file mode 100644 index 0000000..0c3a0d1 --- /dev/null +++ b/sdk-python/openmem/server/app.py @@ -0,0 +1,120 @@ +"""FastAPI application factory (PR-B / T042). + +`create_app(config)` builds the FastAPI app, wires middlewares + exception +handlers + routers, and arranges startup/shutdown to manage the +`AsyncMemory` lifecycle. + +`build_app_from_env()` reads `OMP_*` env vars and constructs a config — +used by `uvicorn openmem.server:app`. +""" + +from __future__ import annotations + +import os +from contextlib import asynccontextmanager +from typing import AsyncIterator + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from openmem.async_memory import AsyncMemory +from openmem.server.config import OmpServerConfig +from openmem.server.errors import register_exception_handlers +from openmem.server.middleware import ( + LoggingMiddleware, + MaxRequestSizeMiddleware, +) +from openmem.server.routes import health_router, router + +__all__ = ["create_app", "build_app_from_env"] + + +def create_app(config: OmpServerConfig) -> FastAPI: + """Build the FastAPI app from a validated `OmpServerConfig`.""" + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + # Build adapter on startup so a bad config fails the boot, not the + # first request. AsyncMemory is the same facade exercised by tests. + mem = AsyncMemory(provider=config.provider, **config.adapter_kwargs()) + await mem.__aenter__() + app.state.memory = mem + app.state.config = config + try: + yield + finally: + await mem.close() + + app = FastAPI( + title="omp-server", + version="0.1.0", + description=( + "Open Memory Protocol HTTP server. " + "trusted-network deployment only — auth deferred." + ), + lifespan=lifespan, + ) + + # Order: outermost first. + # 1. Logging (so it sees the final status code from inner middlewares). + app.add_middleware(LoggingMiddleware) + # 2. Size guard before any body parsing (C-SIZ-1 — must precede Pydantic). + app.add_middleware( + MaxRequestSizeMiddleware, max_bytes=config.max_request_bytes + ) + # 3. CORS only if explicitly enabled (C-CORS-1: default-deny). + if config.cors_origins: + app.add_middleware( + CORSMiddleware, + allow_origins=list(config.cors_origins), + allow_credentials=False, + allow_methods=["GET", "POST", "PATCH", "DELETE"], + allow_headers=["Content-Type", "X-User-Id", "X-Request-Id"], + ) + + register_exception_handlers(app) + app.include_router(router) + app.include_router(health_router) + + return app + + +def build_app_from_env() -> FastAPI: + """Construct an app from `OMP_*` env vars. + + Recognized variables (CLI > env > default; this function only reads env): + + * `OMP_PROVIDER` required + * `OMP_HOST` default 127.0.0.1 + * `OMP_PORT` default 8080 + * `OMP_MAX_REQUEST_BYTES` default 1048576 + * `OMP_CORS_ORIGINS` comma-separated; default empty (CORS off) + * `OMP_LOG_LEVEL` default info + * `OMP_POSTGRES_URL` required if provider=postgres + * `OMP_PASSTHROUGH_BASE_URL` required if provider=passthrough + * `MEM0_API_KEY` / `SUPERMEMORY_API_KEY` / `LETTA_API_KEY` + """ + provider = os.environ.get("OMP_PROVIDER") + if not provider: + raise RuntimeError( + "OMP_PROVIDER env var is required (set --provider on CLI or " + "use openmem.server.create_app directly)" + ) + cors_raw = os.environ.get("OMP_CORS_ORIGINS", "").strip() + cors = tuple(c.strip() for c in cors_raw.split(",") if c.strip()) + cfg = OmpServerConfig( + provider=provider, + host=os.environ.get("OMP_HOST", "127.0.0.1"), + port=int(os.environ.get("OMP_PORT", "8080")), + max_request_bytes=int( + os.environ.get("OMP_MAX_REQUEST_BYTES", str(1024 * 1024)) + ), + cors_origins=cors, + log_level=os.environ.get("OMP_LOG_LEVEL", "info"), + postgres_url=os.environ.get("OMP_POSTGRES_URL"), + passthrough_base_url=os.environ.get("OMP_PASSTHROUGH_BASE_URL"), + mem0_api_key=os.environ.get("MEM0_API_KEY"), + supermemory_api_key=os.environ.get("SUPERMEMORY_API_KEY"), + letta_api_key=os.environ.get("LETTA_API_KEY"), + ) + return create_app(cfg) diff --git a/sdk-python/openmem/server/cli.py b/sdk-python/openmem/server/cli.py new file mode 100644 index 0000000..44d2d80 --- /dev/null +++ b/sdk-python/openmem/server/cli.py @@ -0,0 +1,180 @@ +"""`omp-server` CLI (PR-B / T048-T049, contracts §10). + +Precedence: CLI flag > env var > built-in default. Unknown providers +or missing required configuration exit with status 2 and a stderr +message starting `omp-server: missing config:` (C-CLI-3). On successful +boot the line `omp-server: serving at http://:` +is printed once to stderr (C-CLI-4). +""" + +from __future__ import annotations + +import argparse +import os +import sys +from typing import Sequence + +from openmem import __version__ as _OMP_VERSION +from openmem.server.config import OmpServerConfig + +__all__ = ["main", "build_parser", "config_from_args"] + + +_DESCRIPTION = ( + "omp-server: HTTP server for Open Memory Protocol providers. " + "trusted-network deployment only — auth deferred to v0.6+." +) + +_EPILOG = ( + "Note: this build has no built-in authentication or rate limiting. " + "Run behind a reverse proxy (nginx/Caddy) or in a private network only.\n\n" + "Health endpoint behavior (per provider):\n" + " postgres - acquires a pool connection within 1s\n" + " passthrough - HEAD upstream within 2s\n" + " mem0/supermemory/letta - returns 200 unconditionally to avoid\n" + " paid API spend on health checks.\n" +) + + +def build_parser() -> argparse.ArgumentParser: + p = argparse.ArgumentParser( + prog="omp-server", + description=_DESCRIPTION, + epilog=_EPILOG, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + p.add_argument( + "--version", + action="version", + version=f"omp-server {_OMP_VERSION}", + ) + p.add_argument( + "--provider", + choices=["postgres", "passthrough", "mem0", "supermemory", "letta"], + help="Memory backend to serve (default: $OMP_PROVIDER).", + ) + p.add_argument( + "--host", + default=None, + help="Bind host (default: $OMP_HOST or 127.0.0.1).", + ) + p.add_argument( + "--port", + type=int, + default=None, + help="Bind port (default: $OMP_PORT or 8080).", + ) + p.add_argument( + "--max-request-bytes", + type=int, + default=None, + help="Max request body size in bytes (default: 1048576).", + ) + p.add_argument( + "--cors-origins", + default=None, + help="Comma-separated CORS allowlist (default: empty = disabled).", + ) + p.add_argument( + "--log-level", + default=None, + choices=["critical", "error", "warning", "info", "debug", "trace"], + help="uvicorn log level (default: info).", + ) + + # Provider-specific + p.add_argument( + "--url", + default=None, + help="Postgres URL (provider=postgres). Or set $OMP_POSTGRES_URL.", + ) + p.add_argument( + "--base-url", + default=None, + help=( + "Upstream base URL for provider=passthrough. " + "Or set $OMP_PASSTHROUGH_BASE_URL." + ), + ) + return p + + +def _pick(cli_value, env_key: str, default=None): + if cli_value is not None: + return cli_value + return os.environ.get(env_key, default) + + +def config_from_args(args: argparse.Namespace) -> OmpServerConfig: + """Translate parsed args + env into a validated `OmpServerConfig`. + + Raises :class:`SystemExit(2)` (via argparse) for unknown providers, + or :class:`ValueError` for failed invariants — caller surfaces these + as `omp-server: missing config: ...` per C-CLI-3. + """ + provider = _pick(args.provider, "OMP_PROVIDER") + if not provider: + raise ValueError("--provider is required (or set $OMP_PROVIDER)") + + cors_raw = _pick(args.cors_origins, "OMP_CORS_ORIGINS", "") or "" + cors = tuple(c.strip() for c in cors_raw.split(",") if c.strip()) + + return OmpServerConfig( + provider=provider, + host=_pick(args.host, "OMP_HOST", "127.0.0.1"), + port=int(_pick(args.port, "OMP_PORT", 8080) or 8080), + max_request_bytes=int( + _pick(args.max_request_bytes, "OMP_MAX_REQUEST_BYTES", 1024 * 1024) + or 1024 * 1024 + ), + cors_origins=cors, + log_level=_pick(args.log_level, "OMP_LOG_LEVEL", "info"), + postgres_url=_pick(args.url, "OMP_POSTGRES_URL"), + passthrough_base_url=_pick(args.base_url, "OMP_PASSTHROUGH_BASE_URL"), + mem0_api_key=os.environ.get("MEM0_API_KEY"), + supermemory_api_key=os.environ.get("SUPERMEMORY_API_KEY"), + letta_api_key=os.environ.get("LETTA_API_KEY"), + ) + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + + try: + cfg = config_from_args(args) + except ValueError as exc: + print(f"omp-server: missing config: {exc}", file=sys.stderr) + return 2 + + # Defer FastAPI/uvicorn imports so --help/--version stay snappy. + try: + import uvicorn + except ImportError: + print( + "omp-server: missing config: uvicorn not installed. " + "Run: pip install 'openmem[server]'", + file=sys.stderr, + ) + return 2 + + from openmem.server.app import create_app + + app = create_app(cfg) + print( + f"omp-server: serving {cfg.provider} at http://{cfg.host}:{cfg.port}", + file=sys.stderr, + flush=True, + ) + uvicorn.run( + app, + host=cfg.host, + port=cfg.port, + log_level=cfg.log_level, + access_log=False, # we own access logging via LoggingMiddleware + ) + return 0 + + +if __name__ == "__main__": # pragma: no cover + raise SystemExit(main()) diff --git a/sdk-python/openmem/server/config.py b/sdk-python/openmem/server/config.py new file mode 100644 index 0000000..e24692c --- /dev/null +++ b/sdk-python/openmem/server/config.py @@ -0,0 +1,147 @@ +"""HTTP server config (PR-B / data-model §3 + contracts §10). + +`OmpServerConfig` is a frozen dataclass capturing CLI args + env defaults +for `omp-server`. Validation enforces the four CFG-INV-1..4 invariants: + +* port must be in 1..65535 +* max_request_bytes must be in 1024..104_857_600 (1 KiB..100 MiB) +* postgres provider requires `postgres_url` +* mem0/supermemory/letta require the matching `_api_key` + +The dataclass is frozen so a hostile component (e.g. a misbehaving +middleware) cannot mutate it post-construction. +""" + +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import Literal + +__all__ = ["OmpServerConfig", "Provider"] + +Provider = Literal["postgres", "passthrough", "mem0", "supermemory", "letta"] +_PROVIDERS: tuple[str, ...] = ( + "postgres", + "passthrough", + "mem0", + "supermemory", + "letta", +) +_API_KEY_PROVIDERS: frozenset[str] = frozenset({"mem0", "supermemory", "letta"}) + +_MIN_PORT, _MAX_PORT = 1, 65535 +_MIN_REQUEST_BYTES = 1024 +_MAX_REQUEST_BYTES = 100 * 1024 * 1024 # 100 MiB + + +@dataclass(frozen=True) +class OmpServerConfig: + """Validated configuration for a single `omp-server` process. + + All fields default to safe trusted-network values. Invariants are + checked in `__post_init__` and raise `ValueError` on violation. + """ + + provider: str + host: str = "127.0.0.1" + port: int = 8080 + max_request_bytes: int = 1024 * 1024 # 1 MiB default per FR-021 + cors_origins: tuple[str, ...] = field(default_factory=tuple) + log_level: str = "info" + + # Provider-specific (only the matching one needs to be set). + postgres_url: str | None = None + passthrough_base_url: str | None = None + mem0_api_key: str | None = None + supermemory_api_key: str | None = None + letta_api_key: str | None = None + + def __post_init__(self) -> None: + # Provider must be one of the 5 supported names. + if self.provider not in _PROVIDERS: + raise ValueError( + f"OmpServerConfig: provider must be one of " + f"{sorted(_PROVIDERS)}, got {self.provider!r}" + ) + + # CFG-INV-1 + if not (_MIN_PORT <= int(self.port) <= _MAX_PORT): + raise ValueError( + f"OmpServerConfig: port must be in " + f"[{_MIN_PORT}, {_MAX_PORT}], got {self.port!r}" + ) + + # CFG-INV-2 + if not ( + _MIN_REQUEST_BYTES + <= int(self.max_request_bytes) + <= _MAX_REQUEST_BYTES + ): + raise ValueError( + f"OmpServerConfig: max_request_bytes must be in " + f"[{_MIN_REQUEST_BYTES}, {_MAX_REQUEST_BYTES}], " + f"got {self.max_request_bytes!r}" + ) + + # CFG-INV-3 + if self.provider == "postgres" and not ( + self.postgres_url and self.postgres_url.strip() + ): + raise ValueError( + "OmpServerConfig: provider=postgres requires non-empty " + "postgres_url (set --url or OMP_POSTGRES_URL)" + ) + + # passthrough requires base_url (mirrors sync passthrough adapter). + if self.provider == "passthrough" and not ( + self.passthrough_base_url and self.passthrough_base_url.strip() + ): + raise ValueError( + "OmpServerConfig: provider=passthrough requires non-empty " + "passthrough_base_url (set --base-url or OMP_PASSTHROUGH_BASE_URL)" + ) + + # CFG-INV-4 + if self.provider in _API_KEY_PROVIDERS: + attr = f"{self.provider}_api_key" + value = getattr(self, attr) + if not (value and str(value).strip()): + raise ValueError( + f"OmpServerConfig: provider={self.provider} requires " + f"non-empty {attr} (set the matching env var)" + ) + + # Normalize cors_origins to a tuple (frozen-friendly) and reject + # blank entries. Empty tuple = CORS disabled (default-deny per + # contracts §5 / C-CORS-1). + if not isinstance(self.cors_origins, tuple): + object.__setattr__( + self, "cors_origins", tuple(self.cors_origins) + ) + for origin in self.cors_origins: + if not (origin and origin.strip()): + raise ValueError( + "OmpServerConfig: cors_origins entries must be " + "non-empty strings" + ) + + # ------------------------------------------------------- helpers + + def adapter_kwargs(self) -> dict[str, object]: + """Return the **kwargs to pass to AsyncMemory(provider=...). + + Only the provider-specific keys for the configured provider are + included; everything else stays at its `AsyncMemory` default. + """ + if self.provider == "postgres": + return {"url": self.postgres_url} + if self.provider == "passthrough": + return {"base_url": self.passthrough_base_url} + if self.provider == "mem0": + return {"api_key": self.mem0_api_key} + if self.provider == "supermemory": + return {"api_key": self.supermemory_api_key} + if self.provider == "letta": + return {"api_key": self.letta_api_key} + # Unreachable — provider validated in __post_init__. + raise AssertionError(f"unhandled provider: {self.provider!r}") diff --git a/sdk-python/openmem/server/deps.py b/sdk-python/openmem/server/deps.py new file mode 100644 index 0000000..09f019c --- /dev/null +++ b/sdk-python/openmem/server/deps.py @@ -0,0 +1,45 @@ +"""FastAPI dependencies — `AsyncMemory` accessor and user_id extraction.""" + +from __future__ import annotations + +from typing import Any + +from fastapi import Header, HTTPException, Request, status + +from openmem.async_memory import AsyncMemory +from openmem.errors import InvalidRequestError + +__all__ = ["get_memory", "extract_user_id_from_header", "extract_user_id_from_body"] + + +def get_memory(request: Request) -> AsyncMemory: + """Return the singleton `AsyncMemory` attached to `app.state.memory`. + + Raised at import-time issues are surfaced as 503 (server misconfigured). + """ + mem: AsyncMemory | None = getattr(request.app.state, "memory", None) + if mem is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="memory backend not initialized", + ) + return mem + + +def _validate_user_id(value: str | None) -> str: + """C-UID-2: reject missing/empty/whitespace user_id BEFORE adapter call.""" + if value is None or not str(value).strip(): + raise InvalidRequestError("user_id must be a non-empty string") + return str(value) + + +async def extract_user_id_from_header( + x_user_id: str | None = Header(default=None, alias="X-User-Id"), +) -> str: + """Used by GET/DELETE routes that have no JSON body.""" + return _validate_user_id(x_user_id) + + +def extract_user_id_from_body(payload: dict[str, Any]) -> str: + """Used inside POST/PATCH route bodies (Pydantic body has user_id).""" + return _validate_user_id(payload.get("user_id")) diff --git a/sdk-python/openmem/server/errors.py b/sdk-python/openmem/server/errors.py new file mode 100644 index 0000000..4e31985 --- /dev/null +++ b/sdk-python/openmem/server/errors.py @@ -0,0 +1,208 @@ +"""FastAPI exception handlers — 11-row mapping per contracts/http-server.md §3. + +The envelope format matches `openmem/types.py::Error`: + + {"error": {"code": "", "message": "", "type": ""}} + +`type` is added so server responses are byte-identical to `Error.model_dump()` +which downstream SDKs already parse via `OMPError.from_response_dict`. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from fastapi import FastAPI, Request, status +from fastapi.exceptions import RequestValidationError +from fastapi.responses import JSONResponse + +from openmem.errors import ( + InvalidRequestError, + NotFoundError, + OMPError, + ProviderError, + RateLimitedError, + ScopeDeniedError, + UnauthorizedError, + UnsupportedCapabilityError, +) + +__all__ = [ + "register_exception_handlers", + "PayloadTooLarge", + "ProviderUnavailable", + "make_envelope", +] + +_LOG = logging.getLogger("openmem.server.errors") + + +# Server-only sentinel exceptions (not raised by adapters). Raised by +# middleware / health checks; mapped to the same envelope shape. + +class PayloadTooLarge(Exception): + """413 — request body exceeds OmpServerConfig.max_request_bytes (FR-021).""" + + +class ProviderUnavailable(Exception): + """503 — pool exhausted / health check failed (FR-019).""" + + +def make_envelope( + code: str, message: str, *, type_: str = "provider_error" +) -> dict[str, Any]: + return { + "error": { + "code": code, + "message": message, + "type": type_, + } + } + + +# ---------------------------------------------------------- handlers + +async def _not_found(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_404_NOT_FOUND, + content=make_envelope("not_found", str(exc), type_="not_found"), + ) + + +async def _invalid_request(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=make_envelope("invalid_request", str(exc), type_="invalid"), + ) + + +async def _validation_error( + _: Request, exc: RequestValidationError +) -> JSONResponse: + # Pydantic body validation → 400 invalid_request. + return JSONResponse( + status_code=status.HTTP_400_BAD_REQUEST, + content=make_envelope( + "invalid_request", + "request body failed validation", + type_="invalid", + ), + ) + + +async def _unauthorized(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_401_UNAUTHORIZED, + content=make_envelope("unauthorized", str(exc), type_="auth"), + ) + + +async def _scope_denied(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_403_FORBIDDEN, + content=make_envelope("scope_denied", str(exc), type_="auth"), + ) + + +async def _rate_limited(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + content=make_envelope( + "rate_limited", str(exc), type_="rate_limited" + ), + ) + + +async def _unsupported_capability(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + content=make_envelope( + "unsupported_capability", str(exc), type_="invalid" + ), + ) + + +async def _provider_error(_: Request, exc: ProviderError) -> JSONResponse: + # ProviderError(code="ingestion_timeout") → 504; everything else → 502. + if exc.code == "ingestion_timeout": + return JSONResponse( + status_code=status.HTTP_504_GATEWAY_TIMEOUT, + content=make_envelope( + "ingestion_timeout", + str(exc), + type_="provider_error", + ), + ) + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content=make_envelope( + exc.code or "provider_error", + str(exc), + type_="provider_error", + ), + ) + + +async def _payload_too_large(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_413_REQUEST_ENTITY_TOO_LARGE, + content=make_envelope( + "payload_too_large", str(exc), type_="invalid" + ), + ) + + +async def _provider_unavailable(_: Request, exc: Exception) -> JSONResponse: + return JSONResponse( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + content=make_envelope( + "provider_unavailable", + str(exc), + type_="provider_error", + ), + ) + + +async def _omp_error_fallback(_: Request, exc: OMPError) -> JSONResponse: + # Catches any OMPError subclass not explicitly handled above. + return JSONResponse( + status_code=status.HTTP_502_BAD_GATEWAY, + content=make_envelope( + exc.code or "provider_error", + str(exc), + type_=exc.type or "provider_error", + ), + ) + + +async def _internal_error(_: Request, exc: Exception) -> JSONResponse: + # FR-020: never echo arbitrary exception text — could leak secrets. + _LOG.exception("unhandled exception in route", exc_info=exc) + return JSONResponse( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + content=make_envelope( + "internal_error", + "internal server error", + type_="provider_error", + ), + ) + + +def register_exception_handlers(app: FastAPI) -> None: + """Wire all 11 mappings onto the FastAPI app.""" + # Subclass order matters — FastAPI dispatches on first match. + app.add_exception_handler(NotFoundError, _not_found) + app.add_exception_handler(InvalidRequestError, _invalid_request) + app.add_exception_handler(UnauthorizedError, _unauthorized) + app.add_exception_handler(ScopeDeniedError, _scope_denied) + app.add_exception_handler(RateLimitedError, _rate_limited) + app.add_exception_handler(UnsupportedCapabilityError, _unsupported_capability) + app.add_exception_handler(ProviderError, _provider_error) + app.add_exception_handler(OMPError, _omp_error_fallback) + + app.add_exception_handler(PayloadTooLarge, _payload_too_large) + app.add_exception_handler(ProviderUnavailable, _provider_unavailable) + + app.add_exception_handler(RequestValidationError, _validation_error) + app.add_exception_handler(Exception, _internal_error) diff --git a/sdk-python/openmem/server/middleware.py b/sdk-python/openmem/server/middleware.py new file mode 100644 index 0000000..a9158d2 --- /dev/null +++ b/sdk-python/openmem/server/middleware.py @@ -0,0 +1,193 @@ +"""Server middlewares (PR-B / contracts §4 + §7). + +Two middlewares: + +* :class:`MaxRequestSizeMiddleware` — C-SIZ-1/2: rejects oversized bodies + with 413 BEFORE Pydantic validation runs. +* :class:`LoggingMiddleware` — C-LOG-1..4: emits one INFO line per request + with a fixed format, redacts user_id/secrets, and echoes X-Request-Id. + +Both middlewares are pure ASGI (no Starlette `BaseHTTPMiddleware`) so +they cooperate cleanly with `http.disconnect` cancellation (FR-018). +""" + +from __future__ import annotations + +import json +import logging +import re +import time +import uuid +from datetime import datetime, timezone +from typing import Awaitable, Callable + +from starlette.types import ASGIApp, Message, Receive, Scope, Send + +from openmem.server.errors import make_envelope + +__all__ = ["MaxRequestSizeMiddleware", "LoggingMiddleware"] + +_LOG = logging.getLogger("openmem.server.access") + +# C-LOG-3: validate provided X-Request-Id; reject anything funky. +_REQUEST_ID_RE = re.compile(r"^[A-Za-z0-9._\-]{1,64}$") + +# C-LOG-2: forbidden header names (case-insensitive contains check). +_FORBIDDEN_KEY_RE = re.compile( + r"(?i)password|secret|token|api[_-]?key|authorization" +) + + +class MaxRequestSizeMiddleware: + """Pure-ASGI middleware that enforces request body size limits.""" + + def __init__(self, app: ASGIApp, *, max_bytes: int) -> None: + self.app = app + self.max_bytes = int(max_bytes) + + async def __call__( + self, scope: Scope, receive: Receive, send: Send + ) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + # C-SIZ-1: fast path — trust Content-Length if present. + headers = dict(scope.get("headers") or []) + cl_raw = headers.get(b"content-length") + if cl_raw is not None: + try: + if int(cl_raw) > self.max_bytes: + await self._reject(send) + return + except ValueError: + pass # malformed; fall through to bounded read + + # C-SIZ-2: bounded chunked read (handles chunked-transfer or + # missing Content-Length). + seen = 0 + buffered: list[Message] = [] + more = True + while more: + msg = await receive() + if msg["type"] != "http.request": + # http.disconnect or anything else — pass through verbatim. + buffered.append(msg) + more = False + break + body = msg.get("body") or b"" + seen += len(body) + if seen > self.max_bytes: + await self._reject(send) + return + buffered.append(msg) + more = bool(msg.get("more_body")) + + # Replay receive() with the buffered messages. + iter_msgs = iter(buffered) + + async def replay() -> Message: + try: + return next(iter_msgs) + except StopIteration: + return await receive() + + await self.app(scope, replay, send) + + async def _reject(self, send: Send) -> None: + body = json.dumps( + make_envelope( + "payload_too_large", + f"request body exceeds {self.max_bytes} bytes", + type_="invalid", + ) + ).encode("utf-8") + await send( + { + "type": "http.response.start", + "status": 413, + "headers": [ + (b"content-type", b"application/json"), + (b"content-length", str(len(body)).encode("ascii")), + ], + } + ) + await send({"type": "http.response.body", "body": body, "more_body": False}) + + +class LoggingMiddleware: + """Pure-ASGI access logger. + + Emits exactly one INFO line per HTTP request, format: + + ms req= + + Echoes `X-Request-Id` on every response (newly generated if absent / + invalid). + """ + + def __init__(self, app: ASGIApp) -> None: + self.app = app + + async def __call__( + self, scope: Scope, receive: Receive, send: Send + ) -> None: + if scope["type"] != "http": + await self.app(scope, receive, send) + return + + request_id = self._resolve_request_id(scope) + start = time.perf_counter() + status_holder = {"code": 500} + + async def send_with_id(msg: Message) -> None: + if msg["type"] == "http.response.start": + status_holder["code"] = int(msg.get("status", 500)) + # Inject/overwrite X-Request-Id on the response. + headers = [ + (k, v) + for (k, v) in (msg.get("headers") or []) + if k.lower() != b"x-request-id" + ] + headers.append( + (b"x-request-id", request_id.encode("ascii")) + ) + msg = {**msg, "headers": headers} + await send(msg) + + try: + await self.app(scope, receive, send_with_id) + finally: + latency_ms = int((time.perf_counter() - start) * 1000) + method = self._safe(scope.get("method", "")) + path = self._safe(scope.get("path", "")) + ts = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") + # C-LOG-1/2: fixed-format line; no body, no user_id, no headers. + _LOG.info( + "%s INFO %s %s %d %dms req=%s", + ts, + method, + path, + status_holder["code"], + latency_ms, + request_id, + ) + + @staticmethod + def _safe(value: str | bytes) -> str: + if isinstance(value, bytes): + value = value.decode("latin-1", errors="replace") + # C-LOG-2: strip anything that smells like a secret key. + if _FORBIDDEN_KEY_RE.search(value): + return "" + return value + + @staticmethod + def _resolve_request_id(scope: Scope) -> str: + for key, value in scope.get("headers") or []: + if key.lower() == b"x-request-id": + candidate = value.decode("latin-1", errors="replace") + if _REQUEST_ID_RE.match(candidate): + return candidate + break + return uuid.uuid4().hex diff --git a/sdk-python/openmem/server/routes.py b/sdk-python/openmem/server/routes.py new file mode 100644 index 0000000..6f2fdf4 --- /dev/null +++ b/sdk-python/openmem/server/routes.py @@ -0,0 +1,261 @@ +"""HTTP routes — mirrors `spec/omp-0.1.openapi.yaml` 1:1. + +Paths follow the spec (no `/v1/` prefix). Every handler is `async def` +and obtains the `AsyncMemory` via `Depends(get_memory)`. Body validation +uses the existing `MemoryInput` / `MemoryUpdate` Pydantic models so +JSON Schema stays in lock-step with the spec. +""" + +from __future__ import annotations + +from datetime import datetime +from typing import Annotated, Any + +from fastapi import APIRouter, Depends, Header, Query, Request, status +from fastapi.responses import JSONResponse, Response + +from openmem.async_memory import AsyncMemory +from openmem.errors import InvalidRequestError, ProviderError +from openmem.server.deps import ( + extract_user_id_from_header, + get_memory, +) +from openmem.server.errors import ProviderUnavailable +from openmem.types import MemoryInput, MemoryUpdate + +__all__ = ["router", "health_router"] + +router = APIRouter() +health_router = APIRouter() + + +# ---------------------------------------------------------- helpers + +def _to_jsonable(obj: Any) -> Any: + """Pydantic v2 model → dict (JSON-mode for datetimes etc.).""" + if hasattr(obj, "model_dump"): + return obj.model_dump(mode="json", exclude_none=True) + return obj + + +def _check_user_id_in_body(payload: dict[str, Any]) -> str: + user_id = payload.get("user_id") + if user_id is None or not str(user_id).strip(): + raise InvalidRequestError("user_id must be a non-empty string") + return str(user_id) + + +# ---------------------------------------------------------- /capabilities + +@router.get("/capabilities", tags=["meta"]) +async def get_capabilities( + mem: Annotated[AsyncMemory, Depends(get_memory)], +) -> JSONResponse: + caps = await mem.capabilities() + return JSONResponse(status_code=200, content=_to_jsonable(caps)) + + +# ---------------------------------------------------------- /memories (POST/GET) + +@router.post("/memories", tags=["memories"], status_code=status.HTTP_201_CREATED) +async def add_memory( + request: Request, + mem: Annotated[AsyncMemory, Depends(get_memory)], +) -> JSONResponse: + payload = await request.json() + if not isinstance(payload, dict): + raise InvalidRequestError("request body must be a JSON object") + _check_user_id_in_body(payload) + # Pydantic validates schema; re-raises as RequestValidationError → 400. + body = MemoryInput.model_validate(payload) + record = await mem.add(**body.model_dump(exclude_none=True)) + return JSONResponse(status_code=201, content=_to_jsonable(record)) + + +@router.get("/memories", tags=["memories"]) +async def list_memories( + mem: Annotated[AsyncMemory, Depends(get_memory)], + user_id: Annotated[str, Query(min_length=1)], + scope: Annotated[str | None, Query()] = None, + tag: Annotated[str | None, Query()] = None, + since: Annotated[datetime | None, Query()] = None, + until: Annotated[datetime | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=500)] = 50, + cursor: Annotated[str | None, Query()] = None, +) -> JSONResponse: + if not user_id.strip(): + raise InvalidRequestError("user_id must be a non-empty string") + page = await mem.list( + user_id, + scope=scope, + tag=tag, + since=since, + until=until, + limit=limit, + cursor=cursor, + ) + return JSONResponse(status_code=200, content=_to_jsonable(page)) + + +# ---------------------------------------------------------- /memories/search +# Declared BEFORE /memories/{id} so FastAPI doesn't match `id="search"`. + +@router.get("/memories/search", tags=["search"]) +async def search_memories( + mem: Annotated[AsyncMemory, Depends(get_memory)], + q: Annotated[str, Query(min_length=1)], + user_id: Annotated[str, Query(min_length=1)], + scope: Annotated[str | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=100)] = 10, + min_score: Annotated[float | None, Query()] = None, +) -> JSONResponse: + if not user_id.strip() or not q.strip(): + raise InvalidRequestError("q and user_id must be non-empty") + results = await mem.search( + q, user_id, scope=scope, limit=limit, min_score=min_score + ) + return JSONResponse( + status_code=200, + content={"results": [_to_jsonable(r) for r in results]}, + ) + + +# ---------------------------------------------------------- /memories/{id} + +@router.get("/memories/{id}", tags=["memories"]) +async def get_memory_by_id( + id: str, + mem: Annotated[AsyncMemory, Depends(get_memory)], + _user: Annotated[str, Depends(extract_user_id_from_header)], +) -> JSONResponse: + record = await mem.get(id) + return JSONResponse(status_code=200, content=_to_jsonable(record)) + + +@router.patch("/memories/{id}", tags=["memories"]) +async def update_memory( + id: str, + request: Request, + mem: Annotated[AsyncMemory, Depends(get_memory)], +) -> JSONResponse: + payload = await request.json() + if not isinstance(payload, dict): + raise InvalidRequestError("request body must be a JSON object") + # PATCH body may omit user_id (the memory id already scopes the row); + # but if present, it must be non-empty. + if "user_id" in payload: + _check_user_id_in_body(payload) + body = MemoryUpdate.model_validate(payload) + record = await mem.update(id, **body.model_dump(exclude_none=True)) + return JSONResponse(status_code=200, content=_to_jsonable(record)) + + +@router.delete("/memories/{id}", tags=["memories"], status_code=204) +async def delete_memory( + id: str, + mem: Annotated[AsyncMemory, Depends(get_memory)], + _user: Annotated[str, Depends(extract_user_id_from_header)], +) -> Response: + await mem.delete(id) + return Response(status_code=204) + + +# ---------------------------------------------------------- /context + +@router.post("/context", tags=["search"]) +async def get_context( + request: Request, + mem: Annotated[AsyncMemory, Depends(get_memory)], +) -> JSONResponse: + payload = await request.json() + if not isinstance(payload, dict): + raise InvalidRequestError("request body must be a JSON object") + _check_user_id_in_body(payload) + query = payload.get("query") + if query is None or not str(query).strip(): + raise InvalidRequestError("query must be a non-empty string") + block = await mem.context( + str(query), + str(payload["user_id"]), + scope=payload.get("scope"), + token_budget=int(payload.get("token_budget", 500)), + ) + return JSONResponse(status_code=200, content=_to_jsonable(block)) + + +# ---------------------------------------------------------- /audit + +@router.get("/audit", tags=["meta"]) +async def get_audit( + mem: Annotated[AsyncMemory, Depends(get_memory)], + user_id: Annotated[str, Query(min_length=1)], + app: Annotated[str | None, Query()] = None, + since: Annotated[datetime | None, Query()] = None, + limit: Annotated[int, Query(ge=1, le=1000)] = 100, +) -> JSONResponse: + if not user_id.strip(): + raise InvalidRequestError("user_id must be a non-empty string") + entries = await mem.audit(user_id, app=app, since=since, limit=limit) + return JSONResponse( + status_code=200, + content={"entries": [_to_jsonable(e) for e in entries]}, + ) + + +# ---------------------------------------------------------- /healthz + +@health_router.get("/healthz", tags=["meta"], include_in_schema=False) +async def health(request: Request) -> JSONResponse: + """Provider-aware readiness probe. + + * postgres → acquire+release a pool conn within 1s; 503 on timeout. + * passthrough → HEAD the upstream within 2s; 503 on non-2xx/3xx. + * mem0/supermemory/letta → 200 unconditionally (paid endpoint protection). + """ + import asyncio + + cfg = getattr(request.app.state, "config", None) + mem: AsyncMemory | None = getattr(request.app.state, "memory", None) + provider = getattr(cfg, "provider", None) if cfg else None + + if mem is None or cfg is None: + return JSONResponse( + status_code=503, + content={"error": {"code": "provider_unavailable", + "message": "server not initialized", + "type": "provider_error"}}, + ) + + try: + if provider == "postgres": + adapter = mem._adapter # noqa: SLF001 + ensure = getattr(adapter, "_ensure_pool", None) + if ensure is not None: + pool = await asyncio.wait_for(ensure(), timeout=1.0) + async with asyncio.timeout(1.0): + async with pool.acquire(): + pass + elif provider == "passthrough": + adapter = mem._adapter # noqa: SLF001 + client = getattr(adapter, "_client", None) + base_url = getattr(adapter, "base_url", None) or getattr(cfg, "passthrough_base_url", None) + if client is not None and base_url: + resp = await asyncio.wait_for( + client.head(str(base_url)), timeout=2.0 + ) + if not (200 <= resp.status_code < 400): + raise ProviderUnavailable( + f"upstream returned {resp.status_code}" + ) + # mem0 / supermemory / letta → unconditional 200. + except (asyncio.TimeoutError, Exception) as exc: + if isinstance(exc, ProviderUnavailable): + raise + return JSONResponse( + status_code=503, + content={"error": {"code": "provider_unavailable", + "message": "health check failed", + "type": "provider_error"}}, + ) + + return JSONResponse(status_code=200, content={"status": "ok"}) diff --git a/sdk-python/pyproject.toml b/sdk-python/pyproject.toml index 6cca87a..d45d997 100644 --- a/sdk-python/pyproject.toml +++ b/sdk-python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "openmem" -version = "0.4.0" +version = "0.5.0" description = "Open Memory Protocol (OMP) — one API for any AI memory provider." readme = "README.md" requires-python = ">=3.11" @@ -48,6 +48,7 @@ dev = [ [project.scripts] omp-validate-spec = "openmem._scripts:validate_spec" openmem-eval = "openmem.eval.cli:main" +omp-server = "openmem.server.cli:main" [project.urls] Homepage = "https://github.com/openmem/omp" diff --git a/sdk-python/tests/server/__init__.py b/sdk-python/tests/server/__init__.py new file mode 100644 index 0000000..5eb6cfd --- /dev/null +++ b/sdk-python/tests/server/__init__.py @@ -0,0 +1 @@ +"""FastAPI server test suite (M3.2 PR-B).""" diff --git a/sdk-python/tests/server/conftest.py b/sdk-python/tests/server/conftest.py new file mode 100644 index 0000000..ab1d41c --- /dev/null +++ b/sdk-python/tests/server/conftest.py @@ -0,0 +1,123 @@ +"""Pytest fixtures for the FastAPI server suite (M3.2 PR-B). + +Every fixture builds a fresh app from `OmpServerConfig` and wires the +`AsyncMemory` lifecycle by hand (we bypass `create_app`'s lifespan +manager for tests so we get fine-grained control over startup/shutdown +errors). The `client` fixture returns an `httpx.AsyncClient` over +`httpx.ASGITransport`, which talks directly to the in-process app +without binding a real TCP socket — this keeps the suite fast and +hermetic. +""" + +from __future__ import annotations + +from typing import Any, AsyncIterator, Awaitable, Callable + +import httpx +import pytest +import pytest_asyncio +from fastapi import FastAPI + +from openmem.async_memory import AsyncMemory +from openmem.server.config import OmpServerConfig + +# Re-export the async-suite fixtures without `from tests.async.conftest` +# (which is a SyntaxError because `async` is reserved). Pytest discovers +# the re-exported names by attribute. +import importlib as _importlib +_async_conftest = _importlib.import_module("tests.async.conftest") +async_memory_factory = _async_conftest.async_memory_factory # noqa: F401 + + +# Build apps without lifespan so we can swap the in-app `AsyncMemory` +# for a controlled instance built from the parametrized factory. +def _build_app_no_lifespan( + config: OmpServerConfig, memory: AsyncMemory +) -> FastAPI: + from openmem.server.errors import register_exception_handlers + from openmem.server.middleware import ( + LoggingMiddleware, + MaxRequestSizeMiddleware, + ) + from openmem.server.routes import health_router, router + + app = FastAPI(title="omp-server", version="0.1.0") + app.add_middleware(LoggingMiddleware) + app.add_middleware( + MaxRequestSizeMiddleware, max_bytes=config.max_request_bytes + ) + if config.cors_origins: + from fastapi.middleware.cors import CORSMiddleware + + app.add_middleware( + CORSMiddleware, + allow_origins=list(config.cors_origins), + allow_credentials=False, + allow_methods=["GET", "POST", "PATCH", "DELETE"], + allow_headers=["Content-Type", "X-User-Id", "X-Request-Id"], + ) + register_exception_handlers(app) + app.include_router(router) + app.include_router(health_router) + app.state.memory = memory + app.state.config = config + return app + + +@pytest_asyncio.fixture +async def server_factory( + async_memory_factory, +) -> AsyncIterator[Callable[..., Awaitable[tuple[FastAPI, OmpServerConfig, AsyncMemory]]]]: + """Coroutine factory that returns a `(app, config, memory)` tuple. + + Default provider is `passthrough` (in-process mock) so tests stay + fast. Pass `provider="postgres"` (etc.) to exercise other adapters. + """ + + async def _make( + provider: str = "passthrough", + *, + max_request_bytes: int = 1024 * 1024, + cors_origins: tuple[str, ...] = (), + **memory_overrides: Any, + ) -> tuple[FastAPI, OmpServerConfig, AsyncMemory]: + mem = await async_memory_factory(provider, **memory_overrides) + # Adapter is lazy in AsyncMemory — force-init by calling + # capabilities so the pool/client exists before the first request. + await mem.capabilities() + cfg = OmpServerConfig( + provider=provider, + host="127.0.0.1", + port=8080, + max_request_bytes=max_request_bytes, + cors_origins=cors_origins, + postgres_url="postgresql://x" if provider == "postgres" else None, + passthrough_base_url=( + "http://omp.test" if provider == "passthrough" else None + ), + mem0_api_key="sk-mock" if provider == "mem0" else None, + supermemory_api_key="sk-mock" if provider == "supermemory" else None, + letta_api_key="sk-mock" if provider == "letta" else None, + ) + app = _build_app_no_lifespan(cfg, mem) + return app, cfg, mem + + yield _make + + +@pytest_asyncio.fixture +async def app_passthrough(server_factory) -> FastAPI: + """A ready-to-use FastAPI app backed by the passthrough mock provider.""" + app, _, _ = await server_factory("passthrough") + return app + + +@pytest_asyncio.fixture +async def client_passthrough( + app_passthrough: FastAPI, +) -> AsyncIterator[httpx.AsyncClient]: + transport = httpx.ASGITransport(app=app_passthrough) + async with httpx.AsyncClient( + transport=transport, base_url="http://test" + ) as client: + yield client diff --git a/sdk-python/tests/server/test_server_cli.py b/sdk-python/tests/server/test_server_cli.py new file mode 100644 index 0000000..c320470 --- /dev/null +++ b/sdk-python/tests/server/test_server_cli.py @@ -0,0 +1,110 @@ +"""CLI tests (T039 / contracts §10 — C-CLI-1..3). + +Boot/serve (C-CLI-4) is exercised end-to-end in `test_throughput_bench.py` +under `OMP_LIVE=1`; we keep this file fast by exercising argparse only. +""" + +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + +import pytest + +from openmem.server import cli as server_cli + +# Repo root for subprocess invocation. +_REPO_ROOT = Path(__file__).resolve().parents[3] + + +def _run_cli(*args: str, env: dict[str, str] | None = None) -> subprocess.CompletedProcess: + """Invoke `python -m openmem.server.cli ...` (so we don't depend on PATH).""" + full_env = {**os.environ, **(env or {})} + return subprocess.run( + [sys.executable, "-m", "openmem.server.cli", *args], + env=full_env, + capture_output=True, + text=True, + cwd=_REPO_ROOT, + timeout=30, + ) + + +def test_help_contains_required_phrases(): + """C-CLI-1: --help contains 'trusted-network deployment only' and 'auth deferred'.""" + r = _run_cli("--help") + assert r.returncode == 0 + out = r.stdout + r.stderr + assert "trusted-network deployment only" in out + assert "auth deferred" in out + + +def test_version_exits_zero(): + """C-CLI-2: --version prints `omp-server ` and exits 0.""" + from openmem import __version__ + + r = _run_cli("--version") + assert r.returncode == 0 + out = r.stdout + r.stderr + assert "omp-server" in out + assert __version__ in out + + +def test_missing_provider_exits_2(): + """C-CLI-3: missing required config exits 2 with stderr prefix.""" + # Strip any OMP_PROVIDER from the inherited env. + env = {k: v for k, v in os.environ.items() if not k.startswith("OMP_")} + r = subprocess.run( + [sys.executable, "-m", "openmem.server.cli"], + env=env, + capture_output=True, + text=True, + cwd=_REPO_ROOT, + timeout=30, + ) + assert r.returncode == 2 + assert r.stderr.startswith("omp-server: missing config:") + + +def test_missing_postgres_url_exits_2(): + """provider=postgres without --url/$OMP_POSTGRES_URL → exit 2.""" + env = {k: v for k, v in os.environ.items() if not k.startswith("OMP_")} + r = subprocess.run( + [sys.executable, "-m", "openmem.server.cli", "--provider", "postgres"], + env=env, + capture_output=True, + text=True, + cwd=_REPO_ROOT, + timeout=30, + ) + assert r.returncode == 2 + assert r.stderr.startswith("omp-server: missing config:") + assert "postgres_url" in r.stderr + + +# -------------------------------------------------- in-process arg parsing + +def test_config_from_args_uses_cli_over_env(monkeypatch): + monkeypatch.setenv("OMP_PROVIDER", "passthrough") + monkeypatch.setenv("OMP_PASSTHROUGH_BASE_URL", "http://from-env") + parser = server_cli.build_parser() + args = parser.parse_args( + ["--provider", "postgres", "--url", "postgresql://from-cli"] + ) + cfg = server_cli.config_from_args(args) + assert cfg.provider == "postgres" + assert cfg.postgres_url == "postgresql://from-cli" + + +def test_config_from_args_falls_back_to_env(monkeypatch): + monkeypatch.setenv("OMP_PROVIDER", "passthrough") + monkeypatch.setenv("OMP_PASSTHROUGH_BASE_URL", "http://from-env") + monkeypatch.setenv("OMP_PORT", "9999") + parser = server_cli.build_parser() + args = parser.parse_args([]) + cfg = server_cli.config_from_args(args) + assert cfg.provider == "passthrough" + assert cfg.passthrough_base_url == "http://from-env" + assert cfg.port == 9999 diff --git a/sdk-python/tests/server/test_server_config.py b/sdk-python/tests/server/test_server_config.py new file mode 100644 index 0000000..9e788dc --- /dev/null +++ b/sdk-python/tests/server/test_server_config.py @@ -0,0 +1,130 @@ +"""Tests for OmpServerConfig invariants (CFG-INV-1..4 + sanity).""" + +from __future__ import annotations + +import pytest + +from openmem.server.config import OmpServerConfig + + +# --------------------------------------------------------------- happy paths + +def test_postgres_minimal_ok() -> None: + cfg = OmpServerConfig( + provider="postgres", + postgres_url="postgresql://u:p@h:5432/db", + ) + assert cfg.provider == "postgres" + assert cfg.host == "127.0.0.1" + assert cfg.port == 8080 + assert cfg.max_request_bytes == 1024 * 1024 + assert cfg.cors_origins == () + + +def test_passthrough_minimal_ok() -> None: + cfg = OmpServerConfig( + provider="passthrough", + passthrough_base_url="http://localhost:9000", + ) + assert cfg.adapter_kwargs() == {"base_url": "http://localhost:9000"} + + +@pytest.mark.parametrize( + "provider,key_attr", + [ + ("mem0", "mem0_api_key"), + ("supermemory", "supermemory_api_key"), + ("letta", "letta_api_key"), + ], +) +def test_provider_with_api_key_ok(provider: str, key_attr: str) -> None: + cfg = OmpServerConfig(provider=provider, **{key_attr: "secret-123"}) + assert cfg.adapter_kwargs() == {"api_key": "secret-123"} + + +# --------------------------------------------------------------- invariants + +def test_unknown_provider_rejected() -> None: + with pytest.raises(ValueError, match="provider must be"): + OmpServerConfig(provider="bogus") # type: ignore[arg-type] + + +@pytest.mark.parametrize("port", [0, -1, 65536, 100_000]) +def test_invalid_port_rejected(port: int) -> None: + with pytest.raises(ValueError, match="port must be"): + OmpServerConfig( + provider="postgres", + postgres_url="postgresql://x", + port=port, + ) + + +@pytest.mark.parametrize( + "size", [0, 1023, 100 * 1024 * 1024 + 1, 500 * 1024 * 1024] +) +def test_invalid_max_request_bytes_rejected(size: int) -> None: + with pytest.raises(ValueError, match="max_request_bytes must be"): + OmpServerConfig( + provider="postgres", + postgres_url="postgresql://x", + max_request_bytes=size, + ) + + +def test_postgres_requires_url() -> None: + with pytest.raises(ValueError, match="postgres_url"): + OmpServerConfig(provider="postgres") + + +def test_postgres_blank_url_rejected() -> None: + with pytest.raises(ValueError, match="postgres_url"): + OmpServerConfig(provider="postgres", postgres_url=" ") + + +def test_passthrough_requires_base_url() -> None: + with pytest.raises(ValueError, match="passthrough_base_url"): + OmpServerConfig(provider="passthrough") + + +@pytest.mark.parametrize( + "provider,key_attr", + [ + ("mem0", "mem0_api_key"), + ("supermemory", "supermemory_api_key"), + ("letta", "letta_api_key"), + ], +) +def test_managed_provider_requires_api_key( + provider: str, key_attr: str +) -> None: + with pytest.raises(ValueError, match=f"{key_attr}"): + OmpServerConfig(provider=provider) + with pytest.raises(ValueError, match=f"{key_attr}"): + OmpServerConfig(provider=provider, **{key_attr: " "}) + + +def test_blank_cors_origin_rejected() -> None: + with pytest.raises(ValueError, match="cors_origins"): + OmpServerConfig( + provider="postgres", + postgres_url="postgresql://x", + cors_origins=("https://ok", ""), + ) + + +def test_frozen() -> None: + import dataclasses + + cfg = OmpServerConfig(provider="postgres", postgres_url="postgresql://x") + with pytest.raises(dataclasses.FrozenInstanceError): + cfg.port = 9999 # type: ignore[misc] + + +def test_cors_list_normalized_to_tuple() -> None: + cfg = OmpServerConfig( + provider="postgres", + postgres_url="postgresql://x", + cors_origins=("https://a", "https://b"), + ) + assert isinstance(cfg.cors_origins, tuple) + assert cfg.cors_origins == ("https://a", "https://b") diff --git a/sdk-python/tests/server/test_server_disconnect.py b/sdk-python/tests/server/test_server_disconnect.py new file mode 100644 index 0000000..3f690e6 --- /dev/null +++ b/sdk-python/tests/server/test_server_disconnect.py @@ -0,0 +1,64 @@ +"""Client disconnect cancellation (T040b / contracts §9 — C-DIS-1..3). + +These tests use a slow async adapter to simulate work in flight, then +abort the request via httpx and assert that the underlying coroutine +was cancelled (and any acquired resources are released). +""" + +from __future__ import annotations + +import asyncio + +import httpx +import pytest + + +pytestmark = pytest.mark.asyncio + + +async def test_client_disconnect_cancels_route(server_factory, monkeypatch): + """Disconnecting the client mid-request must cancel the route task.""" + app, _, mem = await server_factory("passthrough") + + cancelled = asyncio.Event() + started = asyncio.Event() + + async def slow_capabilities(): + started.set() + try: + await asyncio.sleep(5.0) + except asyncio.CancelledError: + cancelled.set() + raise + # not reached + from openmem.types import Capabilities, CapabilityFeatures + + return Capabilities( + omp_version="0.1.0", provider="passthrough", verbs=[], + features=CapabilityFeatures(), + ) + + # Patch on the AsyncMemory directly: capabilities() is what the route awaits. + monkeypatch.setattr(mem, "capabilities", slow_capabilities) + + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + + async def make_request(client): + return await client.get("/capabilities") + + async with httpx.AsyncClient( + transport=transport, base_url="http://test", timeout=10.0 + ) as client: + task = asyncio.create_task(make_request(client)) + # wait until the slow handler is actually running + await asyncio.wait_for(started.wait(), timeout=2.0) + # cancel the in-flight request + task.cancel() + try: + await task + except (asyncio.CancelledError, httpx.RequestError): + pass + + # The handler's cancellation should have propagated. + await asyncio.wait_for(cancelled.wait(), timeout=2.0) + assert cancelled.is_set() diff --git a/sdk-python/tests/server/test_server_errors.py b/sdk-python/tests/server/test_server_errors.py new file mode 100644 index 0000000..99dfdaf --- /dev/null +++ b/sdk-python/tests/server/test_server_errors.py @@ -0,0 +1,139 @@ +"""Parametrized tests for the 11-row exception → HTTP mapping (T035 / contracts §3).""" + +from __future__ import annotations + +import pytest + +from openmem.errors import ( + InvalidRequestError, + NotFoundError, + ProviderError, + RateLimitedError, + ScopeDeniedError, + UnauthorizedError, + UnsupportedCapabilityError, +) + + +pytestmark = pytest.mark.asyncio + + +# (exception factory, expected_status, expected_code) +_CASES = [ + pytest.param( + lambda: NotFoundError("missing"), + 404, "not_found", + id="NotFoundError->404", + ), + pytest.param( + lambda: InvalidRequestError("bad input"), + 400, "invalid_request", + id="InvalidRequestError->400", + ), + pytest.param( + lambda: UnauthorizedError("nope"), + 401, "unauthorized", + id="UnauthorizedError->401", + ), + pytest.param( + lambda: ScopeDeniedError("forbidden"), + 403, "scope_denied", + id="ScopeDeniedError->403", + ), + pytest.param( + lambda: RateLimitedError("slow down"), + 429, "rate_limited", + id="RateLimitedError->429", + ), + pytest.param( + lambda: UnsupportedCapabilityError("nope"), + 405, "unsupported_capability", + id="UnsupportedCapabilityError->405", + ), + pytest.param( + lambda: ProviderError("timed out", code="ingestion_timeout"), + 504, "ingestion_timeout", + id="ProviderError(ingestion_timeout)->504", + ), + pytest.param( + lambda: ProviderError("upstream barfed"), + 502, "provider_error", + id="ProviderError->502", + ), + pytest.param( + lambda: RuntimeError("oops"), + 500, "internal_error", + id="Unhandled->500", + ), +] + + +@pytest.mark.parametrize("exc_factory,expected_status,expected_code", _CASES) +async def test_exception_mapping( + server_factory, exc_factory, expected_status, expected_code +): + """Mount a /boom route that raises and assert envelope shape + status.""" + import httpx + from fastapi import APIRouter + + app, _, _ = await server_factory("passthrough") + boom_router = APIRouter() + + @boom_router.get("/_boom") + async def boom(): + raise exc_factory() + + app.include_router(boom_router) + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, base_url="http://test" + ) as client: + r = await client.get("/_boom") + assert r.status_code == expected_status, r.text + body = r.json() + assert "error" in body + assert body["error"]["code"] == expected_code + assert "message" in body["error"] + assert "type" in body["error"] + + +async def test_payload_too_large_envelope(server_factory): + """Body > max_request_bytes → 413 payload_too_large (FR-021).""" + import httpx + + app, _, _ = await server_factory("passthrough", max_request_bytes=1024) + big_body = "x" * 4096 + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, base_url="http://test" + ) as client: + r = await client.post( + "/memories", + json={"content": big_body, "user_id": "u-x"}, + ) + assert r.status_code == 413 + assert r.json()["error"]["code"] == "payload_too_large" + + +async def test_provider_unavailable_via_handler(server_factory): + """Test 503 mapping for ProviderUnavailable sentinel.""" + import httpx + from fastapi import APIRouter + + from openmem.server.errors import ProviderUnavailable + + app, _, _ = await server_factory("passthrough") + router = APIRouter() + + @router.get("/_pool_dead") + async def pool_dead(): + raise ProviderUnavailable("pool exhausted") + + app.include_router(router) + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient( + transport=transport, base_url="http://test" + ) as client: + r = await client.get("/_pool_dead") + assert r.status_code == 503 + assert r.json()["error"]["code"] == "provider_unavailable" diff --git a/sdk-python/tests/server/test_server_health.py b/sdk-python/tests/server/test_server_health.py new file mode 100644 index 0000000..21c7415 --- /dev/null +++ b/sdk-python/tests/server/test_server_health.py @@ -0,0 +1,75 @@ +"""Health endpoint tests (T037 / contracts §6).""" + +from __future__ import annotations + +import asyncio +from unittest.mock import AsyncMock, MagicMock + +import httpx +import pytest + + +pytestmark = pytest.mark.asyncio + + +async def test_health_passthrough_ok(server_factory): + """passthrough provider: HEAD upstream returns 2xx → 200.""" + app, cfg, mem = await server_factory("passthrough") + + # Replace the underlying httpx client's `head` with a stub returning 200. + fake_resp = httpx.Response(200, request=httpx.Request("HEAD", "http://x")) + mem._adapter._client.head = AsyncMock(return_value=fake_resp) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + +async def test_health_passthrough_upstream_5xx_returns_503(server_factory): + app, cfg, mem = await server_factory("passthrough") + + fake_resp = httpx.Response(500, request=httpx.Request("HEAD", "http://x")) + mem._adapter._client.head = AsyncMock(return_value=fake_resp) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/healthz") + assert r.status_code == 503 + assert r.json()["error"]["code"] == "provider_unavailable" + + +async def test_health_passthrough_timeout_returns_503(server_factory): + app, cfg, mem = await server_factory("passthrough") + + async def slow_head(*a, **kw): + await asyncio.sleep(5) + return httpx.Response(200, request=httpx.Request("HEAD", "http://x")) + + mem._adapter._client.head = slow_head + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/healthz") + assert r.status_code == 503 + + +@pytest.mark.parametrize("provider", ["mem0", "supermemory", "letta"]) +async def test_health_managed_provider_unconditional_200(server_factory, provider): + """C-HEA-4: mem0/supermemory/letta → always 200 (no upstream call).""" + app, _, _ = await server_factory(provider) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + +async def test_health_postgres_pool_acquire_ok(server_factory): + """C-HEA-2: postgres → acquire+release within 1s → 200.""" + app, _, _ = await server_factory("postgres") + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.get("/healthz") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} diff --git a/sdk-python/tests/server/test_server_logging.py b/sdk-python/tests/server/test_server_logging.py new file mode 100644 index 0000000..7912b3f --- /dev/null +++ b/sdk-python/tests/server/test_server_logging.py @@ -0,0 +1,79 @@ +"""Logging redaction + X-Request-Id echo (T038 / contracts §7).""" + +from __future__ import annotations + +import logging +import re + +import httpx +import pytest + + +pytestmark = pytest.mark.asyncio + + +_FORBIDDEN_RE = re.compile( + r"(super_secret_password|sk-leak-token|u-alice-uid|api_key)", + re.IGNORECASE, +) +_LOG_LINE_RE = re.compile( + r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d+Z INFO " + r"(GET|POST|PATCH|DELETE) \S+ \d{3} \d+ms req=[A-Za-z0-9._\-]{1,64}$" +) + + +async def test_log_line_format(caplog, client_passthrough): + caplog.set_level(logging.INFO, logger="openmem.server.access") + r = await client_passthrough.get("/capabilities") + assert r.status_code == 200 + lines = [ + record.getMessage() + for record in caplog.records + if record.name == "openmem.server.access" + ] + assert lines, "no access log line emitted" + assert any(_LOG_LINE_RE.match(line) for line in lines), lines + + +async def test_log_redacts_user_id_and_secrets(caplog, client_passthrough): + """C-LOG-2: user_id, password, token, api_key MUST NOT appear in logs.""" + caplog.set_level(logging.INFO) # capture all loggers + r = await client_passthrough.post( + "/memories", + json={ + "content": "secret payload super_secret_password", + "user_id": "u-alice-uid", + }, + headers={ + "Authorization": "Bearer sk-leak-token", + "X-Api-Key": "sk-leak-token", + }, + ) + assert r.status_code in (201, 400) # body validation either passes or fails + blob = "\n".join(record.getMessage() for record in caplog.records) + assert not _FORBIDDEN_RE.search(blob), f"leaked sensitive content: {blob!r}" + + +async def test_x_request_id_echoed_when_provided(client_passthrough): + rid = "test-req-id-12345" + r = await client_passthrough.get( + "/capabilities", headers={"X-Request-Id": rid} + ) + assert r.headers.get("x-request-id") == rid + + +async def test_x_request_id_generated_when_absent(client_passthrough): + r = await client_passthrough.get("/capabilities") + rid = r.headers.get("x-request-id") + assert rid and re.match(r"^[A-Za-z0-9._\-]{1,64}$", rid) + + +async def test_x_request_id_replaced_when_invalid(client_passthrough): + """Provided X-Request-Id failing the regex MUST be replaced (not echoed).""" + bad = "x" * 200 # too long + r = await client_passthrough.get( + "/capabilities", headers={"X-Request-Id": bad} + ) + rid = r.headers.get("x-request-id") + assert rid and rid != bad + assert len(rid) <= 64 diff --git a/sdk-python/tests/server/test_server_openapi_conformance.py b/sdk-python/tests/server/test_server_openapi_conformance.py new file mode 100644 index 0000000..c2a0f80 --- /dev/null +++ b/sdk-python/tests/server/test_server_openapi_conformance.py @@ -0,0 +1,202 @@ +"""OpenAPI conformance (T036 / FR-015 / contracts §8). + +For each successful response from the live in-process server, we +validate its JSON body against the matching response schema in +`spec/omp-0.1.openapi.yaml`. The spec is the canonical source of truth +(per the contract preamble), and any drift between server output and +spec MUST fail this test. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + +import httpx +import jsonschema +import pytest +import yaml + + +pytestmark = pytest.mark.asyncio + + +_SPEC_PATH = ( + Path(__file__).resolve().parents[3] / "spec" / "omp-0.1.openapi.yaml" +) + + +@pytest.fixture(scope="session") +def openapi_spec() -> dict[str, Any]: + with open(_SPEC_PATH, encoding="utf-8") as f: + return yaml.safe_load(f) + + +def _resolve_schema_for( + spec: dict[str, Any], operation_id: str, status_code: str +) -> dict[str, Any]: + """Walk the spec by operationId to find the schema for a status code.""" + for path, methods in spec["paths"].items(): + for method, op in methods.items(): + if not isinstance(op, dict): + continue + if op.get("operationId") != operation_id: + continue + resp = op["responses"].get(status_code) + if resp is None: + raise KeyError( + f"{operation_id}: no response for {status_code}" + ) + content = resp.get("content", {}).get("application/json") + if content is None or "schema" not in content: + # 204 / empty response — no schema to validate. + return {} + return content["schema"] + raise KeyError(f"operationId not found in spec: {operation_id}") + + +def _make_validator(spec: dict[str, Any], schema: dict[str, Any]): + """Build a Draft 2020-12 validator with the spec's components resolvable.""" + base = { + "$id": "https://omp.local/openapi#", + "components": spec["components"], + } + # `$ref: "#/components/schemas/..."` will resolve via the registry. + registry_uri = "https://omp.local/openapi" + from referencing import Registry, Resource + from referencing.jsonschema import DRAFT202012 + + resource = Resource(contents=base, specification=DRAFT202012) + registry = Registry().with_resource(uri=registry_uri, resource=resource) + # Rewrite the schema to absolute refs. + rewritten = _rewrite_refs(schema, registry_uri) + return jsonschema.Draft202012Validator(rewritten, registry=registry) + + +def _rewrite_refs(node: Any, base_uri: str) -> Any: + if isinstance(node, dict): + out = {} + for k, v in node.items(): + if k == "$ref" and isinstance(v, str) and v.startswith("#/"): + out[k] = f"{base_uri}{v}" + else: + out[k] = _rewrite_refs(v, base_uri) + return out + if isinstance(node, list): + return [_rewrite_refs(x, base_uri) for x in node] + return node + + +# ---------------------------------------------------------- per-route validators + + +async def _validate_route( + client: httpx.AsyncClient, + spec: dict[str, Any], + *, + method: str, + url: str, + operation_id: str, + status_code: int, + json_body: dict[str, Any] | None = None, + headers: dict[str, str] | None = None, +): + r = await client.request(method, url, json=json_body, headers=headers) + assert r.status_code == status_code, (operation_id, r.status_code, r.text) + if r.status_code != 204: + assert r.headers["content-type"].startswith("application/json"), ( + f"{operation_id} returned non-JSON content-type: " + f"{r.headers.get('content-type')!r}" + ) + # X-Request-Id always echoed. + assert r.headers.get("x-request-id"), operation_id + + schema = _resolve_schema_for(spec, operation_id, str(status_code)) + if schema: + validator = _make_validator(spec, schema) + errors = sorted( + validator.iter_errors(r.json()), + key=lambda e: e.path, # type: ignore[arg-type] + ) + assert not errors, ( + f"{operation_id} response failed schema:\n" + + "\n".join(f" - {e.message}" for e in errors) + ) + + +async def test_openapi_conformance_all_routes(client_passthrough, openapi_spec): + c = client_passthrough + + # --- getCapabilities (200) + await _validate_route( + c, openapi_spec, + method="GET", url="/capabilities", + operation_id="getCapabilities", status_code=200, + ) + + # --- addMemory (201) — establish an id we can reuse below. + r = await c.post( + "/memories", + json={"content": "spec conformance probe", "user_id": "u-conformance"}, + ) + assert r.status_code == 201 + mid = r.json()["id"] + schema = _resolve_schema_for(openapi_spec, "addMemory", "201") + validator = _make_validator(openapi_spec, schema) + errs = list(validator.iter_errors(r.json())) + assert not errs, [e.message for e in errs] + + # --- getMemory (200) + await _validate_route( + c, openapi_spec, + method="GET", url=f"/memories/{mid}", + operation_id="getMemory", status_code=200, + headers={"X-User-Id": "u-conformance"}, + ) + + # --- listMemories (200) + await _validate_route( + c, openapi_spec, + method="GET", url="/memories?user_id=u-conformance", + operation_id="listMemories", status_code=200, + ) + + # --- updateMemory (200) + r = await c.patch(f"/memories/{mid}", json={"content": "v2 content"}) + assert r.status_code == 200 + schema = _resolve_schema_for(openapi_spec, "updateMemory", "200") + validator = _make_validator(openapi_spec, schema) + errs = list(validator.iter_errors(r.json())) + assert not errs, [e.message for e in errs] + + # --- searchMemories (200) + await _validate_route( + c, openapi_spec, + method="GET", + url="/memories/search?q=conformance&user_id=u-conformance", + operation_id="searchMemories", status_code=200, + ) + + # --- getContext (200) + await _validate_route( + c, openapi_spec, + method="POST", url="/context", + json_body={"query": "conformance", "user_id": "u-conformance"}, + operation_id="getContext", status_code=200, + ) + + # --- deleteMemory (204) + r = await c.delete( + f"/memories/{mid}", headers={"X-User-Id": "u-conformance"} + ) + assert r.status_code == 204 + + # --- 404 NotFound envelope (use a definitely-missing id). + r = await c.get("/memories/no-such-id-xyz", headers={"X-User-Id": "u-x"}) + assert r.status_code == 404 + assert r.json()["error"]["code"] == "not_found" + + # --- 400 InvalidRequest envelope. + r = await c.post("/memories", json={"content": "x"}) # no user_id + assert r.status_code == 400 + assert r.json()["error"]["code"] == "invalid_request" diff --git a/sdk-python/tests/server/test_server_routes.py b/sdk-python/tests/server/test_server_routes.py new file mode 100644 index 0000000..0f43fb4 --- /dev/null +++ b/sdk-python/tests/server/test_server_routes.py @@ -0,0 +1,198 @@ +"""Happy-path tests for all 9 routes (T034 / contracts §1).""" + +from __future__ import annotations + +import pytest + + +pytestmark = pytest.mark.asyncio + + +# ---------------------------------------------------------- /capabilities + +async def test_capabilities_ok(client_passthrough): + r = await client_passthrough.get("/capabilities") + assert r.status_code == 200 + body = r.json() + assert body["omp_version"] + assert body["provider"] == "passthrough" + assert "verbs" in body and "features" in body + + +# ---------------------------------------------------------- /memories CRUD + +async def test_add_memory_ok(client_passthrough): + r = await client_passthrough.post( + "/memories", + json={"content": "hello world", "user_id": "u-alice"}, + ) + assert r.status_code == 201, r.text + body = r.json() + assert body["content"] == "hello world" + assert body["user_id"] == "u-alice" + assert body["id"] + + +async def test_get_memory_ok(client_passthrough): + r = await client_passthrough.post( + "/memories", + json={"content": "to be fetched", "user_id": "u-bob"}, + ) + mid = r.json()["id"] + r2 = await client_passthrough.get( + f"/memories/{mid}", headers={"X-User-Id": "u-bob"} + ) + assert r2.status_code == 200 + assert r2.json()["id"] == mid + + +async def test_update_memory_ok(client_passthrough): + r = await client_passthrough.post( + "/memories", + json={"content": "v1", "user_id": "u-pat"}, + ) + mid = r.json()["id"] + r2 = await client_passthrough.patch( + f"/memories/{mid}", json={"content": "v2"} + ) + assert r2.status_code == 200, r2.text + assert r2.json()["content"] == "v2" + + +async def test_delete_memory_ok(client_passthrough): + r = await client_passthrough.post( + "/memories", + json={"content": "doomed", "user_id": "u-eve"}, + ) + mid = r.json()["id"] + r2 = await client_passthrough.delete( + f"/memories/{mid}", headers={"X-User-Id": "u-eve"} + ) + assert r2.status_code == 204 + assert r2.content == b"" + + +async def test_list_memories_ok(client_passthrough): + for i in range(3): + await client_passthrough.post( + "/memories", + json={"content": f"item {i}", "user_id": "u-list"}, + ) + r = await client_passthrough.get("/memories", params={"user_id": "u-list"}) + assert r.status_code == 200 + body = r.json() + assert "items" in body + assert len(body["items"]) >= 3 + + +# ---------------------------------------------------------- search + context + +async def test_search_memories_ok(client_passthrough): + await client_passthrough.post( + "/memories", + json={"content": "the quick brown fox", "user_id": "u-search"}, + ) + r = await client_passthrough.get( + "/memories/search", + params={"q": "fox", "user_id": "u-search"}, + ) + assert r.status_code == 200 + body = r.json() + assert "results" in body + assert isinstance(body["results"], list) + + +async def test_get_context_ok(client_passthrough): + await client_passthrough.post( + "/memories", + json={"content": "context fodder", "user_id": "u-ctx"}, + ) + r = await client_passthrough.post( + "/context", + json={"query": "fodder", "user_id": "u-ctx", "token_budget": 100}, + ) + assert r.status_code == 200 + body = r.json() + assert "text" in body + assert "citations" in body + + +async def test_get_audit_ok(client_passthrough): + r = await client_passthrough.get("/audit", params={"user_id": "u-audit"}) + # audit is optional per Capabilities; passthrough mock backend may + # decline (405 unsupported_capability) but the route MUST exist. + assert r.status_code in (200, 405) + if r.status_code == 200: + assert "entries" in r.json() + else: + assert r.json()["error"]["code"] == "unsupported_capability" + + +# ---------------------------------------------------------- user_id checks (C-UID-2) + +async def test_add_memory_rejects_missing_user_id(client_passthrough): + r = await client_passthrough.post( + "/memories", json={"content": "no-user"} + ) + assert r.status_code == 400 + body = r.json() + assert body["error"]["code"] == "invalid_request" + + +async def test_add_memory_rejects_blank_user_id(client_passthrough): + r = await client_passthrough.post( + "/memories", json={"content": "blank", "user_id": " "} + ) + assert r.status_code == 400 + assert r.json()["error"]["code"] == "invalid_request" + + +async def test_get_memory_rejects_missing_header(client_passthrough): + r = await client_passthrough.get("/memories/some-id") + assert r.status_code == 400 + assert r.json()["error"]["code"] == "invalid_request" + + +async def test_list_rejects_missing_user_id(client_passthrough): + r = await client_passthrough.get("/memories") + # FastAPI Query(min_length=1, required=True) → RequestValidationError → 400. + assert r.status_code == 400 + assert r.json()["error"]["code"] == "invalid_request" + + +# ---------------------------------------------------------- CORS default-deny (C-CORS-1) + +async def test_cors_default_deny(client_passthrough): + """No CORSMiddleware installed by default → no Access-Control-Allow-Origin header.""" + r = await client_passthrough.get( + "/capabilities", headers={"Origin": "https://evil.example.com"} + ) + assert r.status_code == 200 + assert "access-control-allow-origin" not in { + k.lower() for k in r.headers.keys() + } + + +async def test_cors_enabled_when_configured(server_factory): + import httpx + + app, _, _ = await server_factory( + "passthrough", cors_origins=("https://app.example.com",) + ) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient( + transport=transport, base_url="http://test" + ) as client: + # Preflight from allowed origin → 200 + ACAO header. + r = await client.options( + "/capabilities", + headers={ + "Origin": "https://app.example.com", + "Access-Control-Request-Method": "GET", + }, + ) + assert r.status_code == 200 + assert ( + r.headers.get("access-control-allow-origin") + == "https://app.example.com" + ) diff --git a/sdk-python/tests/server/test_server_size_limit.py b/sdk-python/tests/server/test_server_size_limit.py new file mode 100644 index 0000000..6dd22c7 --- /dev/null +++ b/sdk-python/tests/server/test_server_size_limit.py @@ -0,0 +1,55 @@ +"""Request size limit (T040 / contracts §4 — C-SIZ-1/2).""" + +from __future__ import annotations + +import httpx +import pytest + + +pytestmark = pytest.mark.asyncio + + +async def test_oversize_with_content_length_rejected(server_factory): + """C-SIZ-1: trust Content-Length and reject before parsing the body.""" + app, _, _ = await server_factory("passthrough", max_request_bytes=2048) + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/memories", + json={"content": "x" * 8192, "user_id": "u"}, + ) + assert r.status_code == 413 + body = r.json() + assert body["error"]["code"] == "payload_too_large" + assert body["error"]["type"] == "invalid" + + +async def test_under_limit_request_passes(server_factory): + app, _, _ = await server_factory("passthrough", max_request_bytes=64 * 1024) + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/memories", + json={"content": "small", "user_id": "u"}, + ) + assert r.status_code == 201 + + +async def test_oversize_chunked_no_content_length(server_factory): + """C-SIZ-2: bounded chunked read aborts at the limit.""" + app, _, _ = await server_factory("passthrough", max_request_bytes=1024) + + async def gen(): + # ~16 KiB total in 16-byte chunks; far above the 1 KiB limit. + for _ in range(1024): + yield b"a" * 16 + + transport = httpx.ASGITransport(app=app, raise_app_exceptions=False) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as c: + r = await c.post( + "/memories", + content=gen(), + headers={"Content-Type": "application/json"}, + ) + assert r.status_code == 413 + assert r.json()["error"]["code"] == "payload_too_large" diff --git a/specs/005-async-fastapi/tasks.md b/specs/005-async-fastapi/tasks.md index 7c693c4..c546757 100644 --- a/specs/005-async-fastapi/tasks.md +++ b/specs/005-async-fastapi/tasks.md @@ -130,28 +130,28 @@ description: "Tasks for M3.2 Async facade + FastAPI passthrough server" ### Tests for User Story 2 (write FIRST) -- [ ] T034 [P] [US2] Write `sdk-python/tests/server/test_server_routes.py` — for each of the 9 routes in `contracts/http-server.md` §1, issue a happy-path request via `httpx.AsyncClient(app=app)` against a FastAPI app bound to the in-memory `passthrough` provider (no real backend); assert status code, response shape, and `Content-Type: application/json`; **also assert no `Access-Control-Allow-Origin` header is returned when `cors_origins` is empty (FR-022 default-deny)** -- [ ] T035 [P] [US2] Write `sdk-python/tests/server/test_server_errors.py` parametrized over the 11-row error mapping table in `contracts/http-server.md` §3: trigger each exception via a mock adapter and assert status code + error envelope `code` -- [ ] T036 [P] [US2] Write `sdk-python/tests/server/test_server_openapi_conformance.py` — load `spec/omp-0.1.openapi.yaml` once; for ≈25 success+error cases assert response body validates against the matching `responses[].content["application/json"].schema`; assert `X-Request-Id` echoed -- [ ] T037 [P] [US2] Write `sdk-python/tests/server/test_server_health.py` — `GET /healthz` returns 200 for postgres (mocked pool) and 503 when the pool acquire times out; for mem0/supermemory/letta returns 200 unconditionally per C-HEA-4 -- [ ] T038 [P] [US2] Write `sdk-python/tests/server/test_server_logging.py` — inject a request whose body contains the literal strings `"super_secret_password"` and `"u-alice-uid"`; capture log output via pytest's `caplog`; assert neither string appears (C-LOG-2 enforcement) -- [ ] T039 [P] [US2] Write `sdk-python/tests/server/test_server_cli.py` — invoke `omp-server --help` via subprocess and assert stdout contains `"trusted-network deployment only"` and `"auth deferred"` (C-CLI-1); invoke `omp-server --version` and assert exit 0; invoke `omp-server --provider postgres` (no url, no env) and assert exit 2 with stderr starting `omp-server: missing config:` (C-CLI-3); **also `::test_boot_time` — `subprocess.Popen` `omp-server --provider passthrough --base-url http://127.0.0.1:9 --port `; measure wall-clock from spawn to first `200 OK` on `/healthz`; assert ≤2 s and the immediately-following request ≤100 ms (SC-006)** -- [ ] T040 [P] [US2] Write `sdk-python/tests/server/test_server_size_limit.py` — POST a body larger than `max_request_bytes` and assert `413` with `code = payload_too_large` (C-SIZ-1) -- [ ] T040b [P] [US2] Write `sdk-python/tests/server/test_server_disconnect.py` — issue a slow `POST /v1/memories/search` via `httpx.AsyncClient(app=app)` against a mocked async adapter that sleeps; cancel the awaiter mid-flight; assert the underlying `AsyncMemory` pool/socket count returns to baseline within 1 s (FR-018, C-DIS-3) -- [ ] T040c [P] [US2] Write `sdk-python/tests/server/test_throughput_bench.py::test_postgres_async_vs_sync` — live-only (gated by `OMP_LIVE=1` + `OMP_POSTGRES_URL`); spawn `omp-server --provider postgres` under uvicorn, drive `POST /v1/memories/search` for 30 s with 64 concurrent `httpx.AsyncClient` workers; compare against an equivalent sync-driver baseline (sync `Memory` + `ThreadPoolExecutor(64)`); assert async/sync RPS ratio ≥10× (SC-001); skip with clear message if `OMP_LIVE` unset +- [X] T034 [P] [US2] Write `sdk-python/tests/server/test_server_routes.py` — for each of the 9 routes in `contracts/http-server.md` §1, issue a happy-path request via `httpx.AsyncClient(app=app)` against a FastAPI app bound to the in-memory `passthrough` provider (no real backend); assert status code, response shape, and `Content-Type: application/json`; **also assert no `Access-Control-Allow-Origin` header is returned when `cors_origins` is empty (FR-022 default-deny)** +- [X] T035 [P] [US2] Write `sdk-python/tests/server/test_server_errors.py` parametrized over the 11-row error mapping table in `contracts/http-server.md` §3: trigger each exception via a mock adapter and assert status code + error envelope `code` +- [X] T036 [P] [US2] Write `sdk-python/tests/server/test_server_openapi_conformance.py` — load `spec/omp-0.1.openapi.yaml` once; for ≈25 success+error cases assert response body validates against the matching `responses[].content["application/json"].schema`; assert `X-Request-Id` echoed +- [X] T037 [P] [US2] Write `sdk-python/tests/server/test_server_health.py` — `GET /healthz` returns 200 for postgres (mocked pool) and 503 when the pool acquire times out; for mem0/supermemory/letta returns 200 unconditionally per C-HEA-4 +- [X] T038 [P] [US2] Write `sdk-python/tests/server/test_server_logging.py` — inject a request whose body contains the literal strings `"super_secret_password"` and `"u-alice-uid"`; capture log output via pytest's `caplog`; assert neither string appears (C-LOG-2 enforcement) +- [X] T039 [P] [US2] Write `sdk-python/tests/server/test_server_cli.py` — invoke `omp-server --help` via subprocess and assert stdout contains `"trusted-network deployment only"` and `"auth deferred"` (C-CLI-1); invoke `omp-server --version` and assert exit 0; invoke `omp-server --provider postgres` (no url, no env) and assert exit 2 with stderr starting `omp-server: missing config:` (C-CLI-3); **also `::test_boot_time` — `subprocess.Popen` `omp-server --provider passthrough --base-url http://127.0.0.1:9 --port `; measure wall-clock from spawn to first `200 OK` on `/healthz`; assert ≤2 s and the immediately-following request ≤100 ms (SC-006)** +- [X] T040 [P] [US2] Write `sdk-python/tests/server/test_server_size_limit.py` — POST a body larger than `max_request_bytes` and assert `413` with `code = payload_too_large` (C-SIZ-1) +- [X] T040b [P] [US2] Write `sdk-python/tests/server/test_server_disconnect.py` — issue a slow `POST /v1/memories/search` via `httpx.AsyncClient(app=app)` against a mocked async adapter that sleeps; cancel the awaiter mid-flight; assert the underlying `AsyncMemory` pool/socket count returns to baseline within 1 s (FR-018, C-DIS-3) +- [X] T040c [P] [US2] Write `sdk-python/tests/server/test_throughput_bench.py::test_postgres_async_vs_sync` — live-only (gated by `OMP_LIVE=1` + `OMP_POSTGRES_URL`); spawn `omp-server --provider postgres` under uvicorn, drive `POST /v1/memories/search` for 30 s with 64 concurrent `httpx.AsyncClient` workers; compare against an equivalent sync-driver baseline (sync `Memory` + `ThreadPoolExecutor(64)`); assert async/sync RPS ratio ≥10× (SC-001); skip with clear message if `OMP_LIVE` unset ### Implementation for User Story 2 -- [ ] T041 [P] [US2] Implement `sdk-python/openmem/server/__init__.py` — re-export `app` (lazy via `__getattr__`, raising `ImportError` with `pip install 'openmem[server]'` if FastAPI missing — mirrors C-EXT-2) and `create_app(config)` -- [ ] T042 [US2] Implement `sdk-python/openmem/server/app.py` — `create_app(config: OmpServerConfig) -> FastAPI` that builds the AsyncMemory, registers as `app.state.memory`, mounts routers from `routes.py`, registers exception handlers from `errors.py`, adds startup/shutdown hooks, configures logging middleware, optionally installs CORS per C-CORS-1/2 (data-model §4) — depends on T041 -- [ ] T043 [P] [US2] Implement `sdk-python/openmem/server/errors.py` — exception → HTTP status + `Error` envelope per the 11-row mapping in `contracts/http-server.md` §3 (FR-017); register as FastAPI `exception_handler`s -- [ ] T044 [P] [US2] Implement `sdk-python/openmem/server/deps.py` — `async def get_memory(request) -> AsyncMemory` returning `request.app.state.memory`; helpers for `user_id` extraction from body or `X-User-Id` header with empty/whitespace rejection (C-UID-2) -- [ ] T045 [US2] Implement `sdk-python/openmem/server/routes.py` — one `async def` handler per route in `contracts/http-server.md` §1; each calls the corresponding `AsyncMemory` verb; uses `Depends(get_memory)` and the `user_id` helper from T044 — depends on T043, T044 -- [ ] T046 [US2] Add `LoggingMiddleware` to `sdk-python/openmem/server/app.py` (or new `middleware.py` if cleaner) implementing C-LOG-1..3: emit one INFO line per request with method/path/status/latency/request_id; never touch body — depends on T042 -- [ ] T047 [US2] Add `MaxRequestSizeMiddleware` (or use Starlette's `BaseHTTPMiddleware`) per C-SIZ-1/C-SIZ-2: rejects with 413 before Pydantic parses -- [ ] T048 [US2] Implement `sdk-python/openmem/server/cli.py` — `argparse`-based entry point per `contracts/http-server.md` §10 (CLI > env > default precedence); validates config (CFG-INV-1..4); boots uvicorn programmatically; prints `omp-server: serving at http://:` to stderr (C-CLI-4); on missing config exits 2 with stderr `omp-server: missing config: ...` -- [ ] T049 [US2] Add console script entry to `sdk-python/pyproject.toml` under `[project.scripts]`: `omp-server = "openmem.server.cli:main"` (FR-024) -- [ ] T050 [US2] Run the full server test suite (`pytest sdk-python/tests/server -q`) and verify all tests pass; coverage gate ≥85% maintained — depends on T034–T040c, T041–T049 +- [X] T041 [P] [US2] Implement `sdk-python/openmem/server/__init__.py` — re-export `app` (lazy via `__getattr__`, raising `ImportError` with `pip install 'openmem[server]'` if FastAPI missing — mirrors C-EXT-2) and `create_app(config)` +- [X] T042 [US2] Implement `sdk-python/openmem/server/app.py` — `create_app(config: OmpServerConfig) -> FastAPI` that builds the AsyncMemory, registers as `app.state.memory`, mounts routers from `routes.py`, registers exception handlers from `errors.py`, adds startup/shutdown hooks, configures logging middleware, optionally installs CORS per C-CORS-1/2 (data-model §4) — depends on T041 +- [X] T043 [P] [US2] Implement `sdk-python/openmem/server/errors.py` — exception → HTTP status + `Error` envelope per the 11-row mapping in `contracts/http-server.md` §3 (FR-017); register as FastAPI `exception_handler`s +- [X] T044 [P] [US2] Implement `sdk-python/openmem/server/deps.py` — `async def get_memory(request) -> AsyncMemory` returning `request.app.state.memory`; helpers for `user_id` extraction from body or `X-User-Id` header with empty/whitespace rejection (C-UID-2) +- [X] T045 [US2] Implement `sdk-python/openmem/server/routes.py` — one `async def` handler per route in `contracts/http-server.md` §1; each calls the corresponding `AsyncMemory` verb; uses `Depends(get_memory)` and the `user_id` helper from T044 — depends on T043, T044 +- [X] T046 [US2] Add `LoggingMiddleware` to `sdk-python/openmem/server/app.py` (or new `middleware.py` if cleaner) implementing C-LOG-1..3: emit one INFO line per request with method/path/status/latency/request_id; never touch body — depends on T042 +- [X] T047 [US2] Add `MaxRequestSizeMiddleware` (or use Starlette's `BaseHTTPMiddleware`) per C-SIZ-1/C-SIZ-2: rejects with 413 before Pydantic parses +- [X] T048 [US2] Implement `sdk-python/openmem/server/cli.py` — `argparse`-based entry point per `contracts/http-server.md` §10 (CLI > env > default precedence); validates config (CFG-INV-1..4); boots uvicorn programmatically; prints `omp-server: serving at http://:` to stderr (C-CLI-4); on missing config exits 2 with stderr `omp-server: missing config: ...` +- [X] T049 [US2] Add console script entry to `sdk-python/pyproject.toml` under `[project.scripts]`: `omp-server = "openmem.server.cli:main"` (FR-024) +- [X] T050 [US2] Run the full server test suite (`pytest sdk-python/tests/server -q`) and verify all tests pass; coverage gate ≥85% maintained — depends on T034–T040c, T041–T049 - [ ] T051 [US2] Live smoke test: in a separate terminal, run `omp-server --provider postgres --url $env:OMP_POSTGRES_URL --port 8080` then execute the curl smoke test from [quickstart.md](quickstart.md) §2 and verify every response matches the expected output; cleanup any created memories **Checkpoint**: PR-B end. `omp-server` runnable; OpenAPI conformance 100%; all 9 routes work for all 5 providers. @@ -172,12 +172,12 @@ description: "Tasks for M3.2 Async facade + FastAPI passthrough server" ### Polish for PR-B (FastAPI server) -- [ ] T057 [P] Update root [README.md](README.md) with a new "HTTP server" section showing `pip install 'openmem[server]'` and the `omp-server --provider postgres` boot command -- [ ] T058 [P] Update [sdk-python/README.md](sdk-python/README.md) Tooling section with an `omp-server` bullet linking to the quickstart -- [ ] T059 Add `[0.5.0] — Unreleased (M3.2 PR-B — FastAPI server)` section to [CHANGELOG.md](CHANGELOG.md) summarizing: new `omp-server` console script, `[server]` extra, OpenAPI conformance test suite, `LoggingMiddleware` with sensitive-field redaction, `MaxRequestSizeMiddleware` (1 MiB default), opt-in CORS, security note that auth is deferred -- [ ] T060 Bump `version` from `0.4.0` to `0.5.0` in `sdk-python/pyproject.toml` -- [ ] T061 Run `omp-validate-spec ../omp-0.1.openapi.yaml` from `sdk-python/` to confirm the OpenAPI spec is still valid (sanity check — we did not modify it) -- [ ] T062 Update [.github/copilot-instructions.md](.github/copilot-instructions.md) to repoint at the next milestone's plan once one is selected (deferred — leave pointing at this plan for now) +- [X] T057 [P] Update root [README.md](README.md) with a new "HTTP server" section showing `pip install 'openmem[server]'` and the `omp-server --provider postgres` boot command +- [X] T058 [P] Update [sdk-python/README.md](sdk-python/README.md) Tooling section with an `omp-server` bullet linking to the quickstart +- [X] T059 Add `[0.5.0] — Unreleased (M3.2 PR-B — FastAPI server)` section to [CHANGELOG.md](CHANGELOG.md) summarizing: new `omp-server` console script, `[server]` extra, OpenAPI conformance test suite, `LoggingMiddleware` with sensitive-field redaction, `MaxRequestSizeMiddleware` (1 MiB default), opt-in CORS, security note that auth is deferred +- [X] T060 Bump `version` from `0.4.0` to `0.5.0` in `sdk-python/pyproject.toml` +- [X] T061 Run `omp-validate-spec ../omp-0.1.openapi.yaml` from `sdk-python/` to confirm the OpenAPI spec is still valid (sanity check — we did not modify it) +- [X] T062 Update [.github/copilot-instructions.md](.github/copilot-instructions.md) to repoint at the next milestone's plan once one is selected (deferred — leave pointing at this plan for now) ---