gragdb 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
grag/__init__.py ADDED
@@ -0,0 +1,8 @@
1
+ """grag — LLM-first graph knowledgebase."""
2
+
3
+ __version__ = "0.1.0"
4
+
5
+ from grag.config import GragConfig
6
+ from grag.core.engine import Engine
7
+
8
+ __all__ = ["GragConfig", "Engine", "__version__"]
grag/api/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """grag REST API (FastAPI)."""
grag/api/main.py ADDED
@@ -0,0 +1,204 @@
1
+ """REST API for grag — thin FastAPI layer over GragService.
2
+
3
+ Every endpoint maps 1:1 to a frozen contract model in grag.core.types and to
4
+ an MCP tool of the same payload. The app object is built here; uvicorn is
5
+ started by the CLI, not this module.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from contextlib import asynccontextmanager
11
+ from pathlib import Path
12
+
13
+ import fastapi
14
+ from fastapi import FastAPI, Query, Request
15
+ from fastapi.middleware.cors import CORSMiddleware
16
+ from fastapi.responses import JSONResponse, PlainTextResponse
17
+ from fastapi.staticfiles import StaticFiles
18
+
19
+ import grag
20
+ from grag.config import GragConfig
21
+ from grag.core.errors import GragError, NotFoundError, ReadOnlyViolation
22
+ from grag.core.types import (
23
+ ContextRequest,
24
+ ContextResponse,
25
+ DefineSchemaRequest,
26
+ GraphSample,
27
+ IngestRequest,
28
+ IngestResponse,
29
+ MutationSummary,
30
+ QueryRequest,
31
+ QueryResponse,
32
+ SchemaDocument,
33
+ SearchRequest,
34
+ SearchResponse,
35
+ UpsertEdgesRequest,
36
+ UpsertNodesRequest,
37
+ )
38
+ from grag.registry import ServiceRegistry
39
+ from grag.service import GragService
40
+
41
+ _STATIC_DIR = Path(__file__).parent / "static"
42
+
43
+ DB_HEADER = "x-grag-db"
44
+
45
+
46
+ def _error_body(message: str, hint: str | None) -> dict:
47
+ return {"error": message, "hint": hint}
48
+
49
+
50
+ def create_app(config: GragConfig) -> FastAPI:
51
+ @asynccontextmanager
52
+ async def lifespan(app: FastAPI):
53
+ yield
54
+ app.state.registry.close()
55
+
56
+ app = FastAPI(title="grag", version=grag.__version__, lifespan=lifespan)
57
+ app.state.registry = ServiceRegistry(config)
58
+ # Resolve the default lazily and tolerate failure: in multi-db mode with no
59
+ # determinable default (2+ DBs, none named after db_path), registry.get()
60
+ # raises at startup and the server would never come up — taking /api/dbs
61
+ # (discovery) and all explicitly-selected requests down with it. Startup must
62
+ # not depend on a default existing; only a request with no selector does.
63
+ try:
64
+ app.state.service = app.state.registry.get()
65
+ except GragError:
66
+ app.state.service = None
67
+
68
+ app.add_middleware(
69
+ CORSMiddleware,
70
+ allow_origins=["*"],
71
+ allow_credentials=True,
72
+ allow_methods=["*"],
73
+ allow_headers=["*"],
74
+ )
75
+
76
+ def resolve(request: Request) -> GragService:
77
+ # Single-db mode: db selectors are ignored, never an error.
78
+ if config.db_dir is None:
79
+ return app.state.registry.get()
80
+ name = request.query_params.get("db") or request.headers.get(DB_HEADER)
81
+ if name:
82
+ return app.state.registry.get(name)
83
+ # No selector: use the pre-resolved default if one exists, else surface
84
+ # the registry's ConfigurationError (400) listing available DBs.
85
+ if app.state.service is not None:
86
+ return app.state.service
87
+ return app.state.registry.get()
88
+
89
+ # -- error mapping ---------------------------------------------------------
90
+
91
+ @app.exception_handler(GragError)
92
+ async def grag_error_handler(_: Request, exc: GragError) -> JSONResponse:
93
+ if isinstance(exc, NotFoundError):
94
+ status = 404
95
+ elif isinstance(exc, ReadOnlyViolation):
96
+ status = 403
97
+ else:
98
+ status = 400
99
+ return JSONResponse(
100
+ status_code=status, content=_error_body(exc.message, exc.hint)
101
+ )
102
+
103
+ @app.exception_handler(Exception)
104
+ async def unhandled_error_handler(_: Request, exc: Exception) -> JSONResponse:
105
+ return JSONResponse(
106
+ status_code=500, content=_error_body(str(exc), None)
107
+ )
108
+
109
+ # -- endpoints (contract: see grag.core.types docstring) --------------------
110
+
111
+ @app.get("/api/health")
112
+ def health() -> dict:
113
+ return {"status": "ok", "version": grag.__version__}
114
+
115
+ @app.get("/api/dbs")
116
+ def list_dbs() -> dict:
117
+ default = None
118
+ if config.db_dir is not None and app.state.service is not None:
119
+ # The default service was resolved by the registry at startup, so
120
+ # its db_path is the resolved default .lbdb file. None when no
121
+ # default could be determined (2+ DBs, none preferred).
122
+ default = app.state.service.config.db_path.stem
123
+ return {"dbs": app.state.registry.list_dbs(), "default": default}
124
+
125
+ @app.get("/api/schema")
126
+ def describe_schema(request: Request, format: str | None = Query(default=None)):
127
+ doc = resolve(request).describe_schema()
128
+ if format == "text":
129
+ return PlainTextResponse(doc.text)
130
+ return doc
131
+
132
+ @app.post("/api/schema/define", response_model=SchemaDocument)
133
+ def define_schema(request: Request, req: DefineSchemaRequest) -> SchemaDocument:
134
+ return resolve(request).define_schema(req)
135
+
136
+ @app.post("/api/nodes/upsert", response_model=MutationSummary)
137
+ def upsert_nodes(request: Request, req: UpsertNodesRequest) -> MutationSummary:
138
+ return resolve(request).upsert_nodes(req)
139
+
140
+ @app.post("/api/edges/upsert", response_model=MutationSummary)
141
+ def upsert_edges(request: Request, req: UpsertEdgesRequest) -> MutationSummary:
142
+ return resolve(request).upsert_edges(req)
143
+
144
+ @app.post("/api/query", response_model=QueryResponse)
145
+ def cypher_query(request: Request, req: QueryRequest) -> QueryResponse:
146
+ return resolve(request).cypher_query(req)
147
+
148
+ @app.post("/api/search", response_model=SearchResponse)
149
+ def search_knowledge(request: Request, req: SearchRequest) -> SearchResponse:
150
+ return resolve(request).search_knowledge(req)
151
+
152
+ @app.post("/api/context", response_model=ContextResponse)
153
+ def get_context(request: Request, req: ContextRequest) -> ContextResponse:
154
+ return resolve(request).get_context(req)
155
+
156
+ @app.post("/api/ingest", response_model=IngestResponse)
157
+ def ingest(request: Request, req: IngestRequest) -> IngestResponse:
158
+ return resolve(request).ingest(req)
159
+
160
+ @app.get("/api/graph/sample", response_model=GraphSample)
161
+ def graph_sample(
162
+ request: Request,
163
+ limit: int = Query(default=200),
164
+ label: str | None = Query(default=None),
165
+ ) -> GraphSample:
166
+ return resolve(request).graph_sample(limit=limit, label=label)
167
+
168
+ # -- UI statics --------------------------------------------------------------
169
+
170
+ if (_STATIC_DIR / "index.html").is_file():
171
+ from fastapi.responses import FileResponse
172
+ from starlette.exceptions import HTTPException as StarletteHTTPException
173
+
174
+ @app.exception_handler(StarletteHTTPException)
175
+ async def spa_fallback(_: Request, exc: StarletteHTTPException):
176
+ # Unknown non-API GET paths serve the SPA (deep links); real API
177
+ # misses and non-GETs keep their 404.
178
+ request = _
179
+ if (
180
+ exc.status_code == 404
181
+ and request.method == "GET"
182
+ and not request.url.path.startswith("/api/")
183
+ ):
184
+ return FileResponse(_STATIC_DIR / "index.html")
185
+ return JSONResponse(
186
+ status_code=exc.status_code, content={"detail": exc.detail}
187
+ )
188
+
189
+ app.mount(
190
+ "/",
191
+ StaticFiles(directory=_STATIC_DIR, html=True),
192
+ name="ui",
193
+ )
194
+ else:
195
+
196
+ @app.get("/")
197
+ def root() -> dict:
198
+ return {
199
+ "service": "grag",
200
+ "version": grag.__version__,
201
+ "ui": "not built — see ui/",
202
+ }
203
+
204
+ return app