collections_router.py 47 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213
  1. import logging
  2. import textwrap
  3. from enum import Enum
  4. from typing import Optional
  5. from uuid import UUID
  6. from fastapi import Body, Depends, Path, Query
  7. from fastapi.background import BackgroundTasks
  8. from fastapi.responses import FileResponse
  9. from core.base import R2RException
  10. from core.base.abstractions import GraphCreationSettings
  11. from core.base.api.models import (
  12. GenericBooleanResponse,
  13. WrappedBooleanResponse,
  14. WrappedCollectionResponse,
  15. WrappedCollectionsResponse,
  16. WrappedDocumentsResponse,
  17. WrappedGenericMessageResponse,
  18. WrappedUsersResponse,
  19. )
  20. from core.utils import (
  21. generate_default_user_collection_id,
  22. update_settings_from_dict,
  23. )
  24. from ...abstractions import R2RProviders, R2RServices
  25. from ...config import R2RConfig
  26. from .base_router import BaseRouterV3
  27. logger = logging.getLogger()
  28. class CollectionAction(str, Enum):
  29. VIEW = "view"
  30. EDIT = "edit"
  31. DELETE = "delete"
  32. MANAGE_USERS = "manage_users"
  33. ADD_DOCUMENT = "add_document"
  34. REMOVE_DOCUMENT = "remove_document"
  35. async def authorize_collection_action(
  36. auth_user, collection_id: UUID, action: CollectionAction, services
  37. ) -> bool:
  38. """Authorize a user's action on a given collection based on:
  39. - If user is superuser (admin): Full access.
  40. - If user is owner of the collection: Full access.
  41. - If user is a member of the collection (in `collection_ids`): VIEW only.
  42. - Otherwise: No access.
  43. """
  44. # Superusers have complete access
  45. if auth_user.is_superuser:
  46. return True
  47. # Fetch collection details: owner_id and members
  48. results = (
  49. await services.management.collections_overview(
  50. 0, 1, collection_ids=[collection_id]
  51. )
  52. )["results"]
  53. if len(results) == 0:
  54. raise R2RException("The specified collection does not exist.", 404)
  55. details = results[0]
  56. owner_id = details.owner_id
  57. # Check if user is owner
  58. if auth_user.id == owner_id:
  59. # Owner can do all actions
  60. return True
  61. # Check if user is a member (non-owner)
  62. if collection_id in auth_user.collection_ids:
  63. # Members can only view
  64. if action == CollectionAction.VIEW:
  65. return True
  66. else:
  67. raise R2RException(
  68. "Insufficient permissions for this action.", 403
  69. )
  70. # User is neither owner nor member
  71. raise R2RException("You do not have access to this collection.", 403)
  72. class CollectionsRouter(BaseRouterV3):
  73. def __init__(
  74. self, providers: R2RProviders, services: R2RServices, config: R2RConfig
  75. ):
  76. logging.info("Initializing CollectionsRouter")
  77. super().__init__(providers, services, config)
  78. def _setup_routes(self):
  79. @self.router.post(
  80. "/collections",
  81. summary="Create a new collection",
  82. dependencies=[Depends(self.rate_limit_dependency)],
  83. openapi_extra={
  84. "x-codeSamples": [
  85. {
  86. "lang": "Python",
  87. "source": textwrap.dedent("""
  88. from r2r import R2RClient
  89. client = R2RClient()
  90. # when using auth, do client.login(...)
  91. result = client.collections.create(
  92. name="My New Collection",
  93. description="This is a sample collection"
  94. )
  95. """),
  96. },
  97. {
  98. "lang": "JavaScript",
  99. "source": textwrap.dedent("""
  100. const { r2rClient } = require("r2r-js");
  101. const client = new r2rClient();
  102. function main() {
  103. const response = await client.collections.create({
  104. name: "My New Collection",
  105. description: "This is a sample collection"
  106. });
  107. }
  108. main();
  109. """),
  110. },
  111. {
  112. "lang": "cURL",
  113. "source": textwrap.dedent("""
  114. curl -X POST "https://api.example.com/v3/collections" \\
  115. -H "Content-Type: application/json" \\
  116. -H "Authorization: Bearer YOUR_API_KEY" \\
  117. -d '{"name": "My New Collection", "description": "This is a sample collection"}'
  118. """),
  119. },
  120. ]
  121. },
  122. )
  123. @self.base_endpoint
  124. async def create_collection(
  125. name: str = Body(..., description="The name of the collection"),
  126. description: Optional[str] = Body(
  127. None, description="An optional description of the collection"
  128. ),
  129. auth_user=Depends(self.providers.auth.auth_wrapper()),
  130. ) -> WrappedCollectionResponse:
  131. """Create a new collection and automatically add the creating user
  132. to it.
  133. This endpoint allows authenticated users to create a new collection
  134. with a specified name and optional description. The user creating
  135. the collection is automatically added as a member.
  136. """
  137. user_collections_count = (
  138. await self.services.management.collections_overview(
  139. user_ids=[auth_user.id], limit=1, offset=0
  140. )
  141. )["total_entries"]
  142. user_max_collections = (
  143. await self.services.management.get_user_max_collections(
  144. auth_user.id
  145. )
  146. )
  147. if (user_collections_count + 1) >= user_max_collections: # type: ignore
  148. raise R2RException(
  149. f"User has reached the maximum number of collections allowed ({user_max_collections}).",
  150. 400,
  151. )
  152. collection = await self.services.management.create_collection(
  153. owner_id=auth_user.id,
  154. name=name,
  155. description=description,
  156. )
  157. # Add the creating user to the collection
  158. await self.services.management.add_user_to_collection(
  159. auth_user.id, collection.id
  160. )
  161. return collection # type: ignore
  162. @self.router.post(
  163. "/collections/export",
  164. summary="Export collections to CSV",
  165. dependencies=[Depends(self.rate_limit_dependency)],
  166. openapi_extra={
  167. "x-codeSamples": [
  168. {
  169. "lang": "Python",
  170. "source": textwrap.dedent("""
  171. from r2r import R2RClient
  172. client = R2RClient("http://localhost:7272")
  173. # when using auth, do client.login(...)
  174. response = client.collections.export(
  175. output_path="export.csv",
  176. columns=["id", "name", "created_at"],
  177. include_header=True,
  178. )
  179. """),
  180. },
  181. {
  182. "lang": "JavaScript",
  183. "source": textwrap.dedent("""
  184. const { r2rClient } = require("r2r-js");
  185. const client = new r2rClient("http://localhost:7272");
  186. function main() {
  187. await client.collections.export({
  188. outputPath: "export.csv",
  189. columns: ["id", "name", "created_at"],
  190. includeHeader: true,
  191. });
  192. }
  193. main();
  194. """),
  195. },
  196. {
  197. "lang": "cURL",
  198. "source": textwrap.dedent("""
  199. curl -X POST "http://127.0.0.1:7272/v3/collections/export" \
  200. -H "Authorization: Bearer YOUR_API_KEY" \
  201. -H "Content-Type: application/json" \
  202. -H "Accept: text/csv" \
  203. -d '{ "columns": ["id", "name", "created_at"], "include_header": true }' \
  204. --output export.csv
  205. """),
  206. },
  207. ]
  208. },
  209. )
  210. @self.base_endpoint
  211. async def export_collections(
  212. background_tasks: BackgroundTasks,
  213. columns: Optional[list[str]] = Body(
  214. None, description="Specific columns to export"
  215. ),
  216. filters: Optional[dict] = Body(
  217. None, description="Filters to apply to the export"
  218. ),
  219. include_header: Optional[bool] = Body(
  220. True, description="Whether to include column headers"
  221. ),
  222. auth_user=Depends(self.providers.auth.auth_wrapper()),
  223. ) -> FileResponse:
  224. """Export collections as a CSV file."""
  225. if not auth_user.is_superuser:
  226. raise R2RException(
  227. "Only a superuser can export data.",
  228. 403,
  229. )
  230. (
  231. csv_file_path,
  232. temp_file,
  233. ) = await self.services.management.export_collections(
  234. columns=columns,
  235. filters=filters,
  236. include_header=include_header
  237. if include_header is not None
  238. else True,
  239. )
  240. background_tasks.add_task(temp_file.close)
  241. return FileResponse(
  242. path=csv_file_path,
  243. media_type="text/csv",
  244. filename="collections_export.csv",
  245. )
  246. @self.router.get(
  247. "/collections",
  248. summary="List collections",
  249. dependencies=[Depends(self.rate_limit_dependency)],
  250. openapi_extra={
  251. "x-codeSamples": [
  252. {
  253. "lang": "Python",
  254. "source": textwrap.dedent("""
  255. from r2r import R2RClient
  256. client = R2RClient()
  257. # when using auth, do client.login(...)
  258. result = client.collections.list(
  259. offset=0,
  260. limit=10,
  261. )
  262. """),
  263. },
  264. {
  265. "lang": "JavaScript",
  266. "source": textwrap.dedent("""
  267. const { r2rClient } = require("r2r-js");
  268. const client = new r2rClient();
  269. function main() {
  270. const response = await client.collections.list();
  271. }
  272. main();
  273. """),
  274. },
  275. {
  276. "lang": "cURL",
  277. "source": textwrap.dedent("""
  278. curl -X GET "https://api.example.com/v3/collections?offset=0&limit=10&name=Sample" \\
  279. -H "Authorization: Bearer YOUR_API_KEY"
  280. """),
  281. },
  282. ]
  283. },
  284. )
  285. @self.base_endpoint
  286. async def list_collections(
  287. ids: list[str] = Query(
  288. [],
  289. description="A list of collection IDs to retrieve. If not provided, all collections will be returned.",
  290. ),
  291. offset: int = Query(
  292. 0,
  293. ge=0,
  294. description="Specifies the number of objects to skip. Defaults to 0.",
  295. ),
  296. limit: int = Query(
  297. 100,
  298. ge=1,
  299. le=1000,
  300. description="Specifies a limit on the number of objects to return, ranging between 1 and 100. Defaults to 100.",
  301. ),
  302. owner_only: bool = Query(
  303. False,
  304. description="If true, only returns collections owned by the user, not all accessible collections.",
  305. ),
  306. auth_user=Depends(self.providers.auth.auth_wrapper()),
  307. ) -> WrappedCollectionsResponse:
  308. """Returns a paginated list of collections the authenticated user
  309. has access to.
  310. Results can be filtered by providing specific collection IDs.
  311. Regular users will only see collections they own or have access to.
  312. Superusers can see all collections.
  313. The collections are returned in order of last modification, with
  314. most recent first.
  315. """
  316. if auth_user.is_superuser:
  317. requesting_user_id = [auth_user.id] if owner_only else None
  318. else:
  319. requesting_user_id = [auth_user.id]
  320. collection_uuids = [UUID(collection_id) for collection_id in ids]
  321. collections_overview_response = (
  322. await self.services.management.collections_overview(
  323. user_ids=requesting_user_id,
  324. collection_ids=collection_uuids,
  325. offset=offset,
  326. limit=limit,
  327. owner_only=owner_only,
  328. )
  329. )
  330. return ( # type: ignore
  331. collections_overview_response["results"],
  332. {
  333. "total_entries": collections_overview_response[
  334. "total_entries"
  335. ]
  336. },
  337. )
  338. @self.router.get(
  339. "/collections/{id}",
  340. summary="Get collection details",
  341. dependencies=[Depends(self.rate_limit_dependency)],
  342. openapi_extra={
  343. "x-codeSamples": [
  344. {
  345. "lang": "Python",
  346. "source": textwrap.dedent("""
  347. from r2r import R2RClient
  348. client = R2RClient()
  349. # when using auth, do client.login(...)
  350. result = client.collections.retrieve("123e4567-e89b-12d3-a456-426614174000")
  351. """),
  352. },
  353. {
  354. "lang": "JavaScript",
  355. "source": textwrap.dedent("""
  356. const { r2rClient } = require("r2r-js");
  357. const client = new r2rClient();
  358. function main() {
  359. const response = await client.collections.retrieve({id: "123e4567-e89b-12d3-a456-426614174000"});
  360. }
  361. main();
  362. """),
  363. },
  364. {
  365. "lang": "cURL",
  366. "source": textwrap.dedent("""
  367. curl -X GET "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000" \\
  368. -H "Authorization: Bearer YOUR_API_KEY"
  369. """),
  370. },
  371. ]
  372. },
  373. )
  374. @self.base_endpoint
  375. async def get_collection(
  376. id: UUID = Path(
  377. ..., description="The unique identifier of the collection"
  378. ),
  379. auth_user=Depends(self.providers.auth.auth_wrapper()),
  380. ) -> WrappedCollectionResponse:
  381. """Get details of a specific collection.
  382. This endpoint retrieves detailed information about a single
  383. collection identified by its UUID. The user must have access to the
  384. collection to view its details.
  385. """
  386. await authorize_collection_action(
  387. auth_user, id, CollectionAction.VIEW, self.services
  388. )
  389. collections_overview_response = (
  390. await self.services.management.collections_overview(
  391. user_ids=None,
  392. collection_ids=[id],
  393. offset=0,
  394. limit=1,
  395. )
  396. )
  397. overview = collections_overview_response["results"]
  398. if len(overview) == 0: # type: ignore
  399. raise R2RException(
  400. "The specified collection does not exist.",
  401. 404,
  402. )
  403. return overview[0] # type: ignore
  404. @self.router.post(
  405. "/collections/{id}",
  406. summary="Update collection",
  407. dependencies=[Depends(self.rate_limit_dependency)],
  408. openapi_extra={
  409. "x-codeSamples": [
  410. {
  411. "lang": "Python",
  412. "source": textwrap.dedent("""
  413. from r2r import R2RClient
  414. client = R2RClient()
  415. # when using auth, do client.login(...)
  416. result = client.collections.update(
  417. "123e4567-e89b-12d3-a456-426614174000",
  418. name="Updated Collection Name",
  419. description="Updated description"
  420. )
  421. """),
  422. },
  423. {
  424. "lang": "JavaScript",
  425. "source": textwrap.dedent("""
  426. const { r2rClient } = require("r2r-js");
  427. const client = new r2rClient();
  428. function main() {
  429. const response = await client.collections.update({
  430. id: "123e4567-e89b-12d3-a456-426614174000",
  431. name: "Updated Collection Name",
  432. description: "Updated description"
  433. });
  434. }
  435. main();
  436. """),
  437. },
  438. {
  439. "lang": "cURL",
  440. "source": textwrap.dedent("""
  441. curl -X POST "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000" \\
  442. -H "Content-Type: application/json" \\
  443. -H "Authorization: Bearer YOUR_API_KEY" \\
  444. -d '{"name": "Updated Collection Name", "description": "Updated description"}'
  445. """),
  446. },
  447. ]
  448. },
  449. )
  450. @self.base_endpoint
  451. async def update_collection(
  452. id: UUID = Path(
  453. ...,
  454. description="The unique identifier of the collection to update",
  455. ),
  456. name: Optional[str] = Body(
  457. None, description="The name of the collection"
  458. ),
  459. description: Optional[str] = Body(
  460. None, description="An optional description of the collection"
  461. ),
  462. generate_description: Optional[bool] = Body(
  463. False,
  464. description="Whether to generate a new synthetic description for the collection",
  465. ),
  466. auth_user=Depends(self.providers.auth.auth_wrapper()),
  467. ) -> WrappedCollectionResponse:
  468. """Update an existing collection's configuration.
  469. This endpoint allows updating the name and description of an
  470. existing collection. The user must have appropriate permissions to
  471. modify the collection.
  472. """
  473. await authorize_collection_action(
  474. auth_user, id, CollectionAction.EDIT, self.services
  475. )
  476. if generate_description and description is not None:
  477. raise R2RException(
  478. "Cannot provide both a description and request to synthetically generate a new one.",
  479. 400,
  480. )
  481. return await self.services.management.update_collection( # type: ignore
  482. id,
  483. name=name,
  484. description=description,
  485. generate_description=generate_description or False,
  486. )
  487. @self.router.delete(
  488. "/collections/{id}",
  489. summary="Delete collection",
  490. dependencies=[Depends(self.rate_limit_dependency)],
  491. openapi_extra={
  492. "x-codeSamples": [
  493. {
  494. "lang": "Python",
  495. "source": textwrap.dedent("""
  496. from r2r import R2RClient
  497. client = R2RClient()
  498. # when using auth, do client.login(...)
  499. result = client.collections.delete("123e4567-e89b-12d3-a456-426614174000")
  500. """),
  501. },
  502. {
  503. "lang": "JavaScript",
  504. "source": textwrap.dedent("""
  505. const { r2rClient } = require("r2r-js");
  506. const client = new r2rClient();
  507. function main() {
  508. const response = await client.collections.delete({id: "123e4567-e89b-12d3-a456-426614174000"});
  509. }
  510. main();
  511. """),
  512. },
  513. {
  514. "lang": "cURL",
  515. "source": textwrap.dedent("""
  516. curl -X DELETE "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000" \\
  517. -H "Authorization: Bearer YOUR_API_KEY"
  518. """),
  519. },
  520. ]
  521. },
  522. )
  523. @self.base_endpoint
  524. async def delete_collection(
  525. id: UUID = Path(
  526. ...,
  527. description="The unique identifier of the collection to delete",
  528. ),
  529. auth_user=Depends(self.providers.auth.auth_wrapper()),
  530. ) -> WrappedBooleanResponse:
  531. """Delete an existing collection.
  532. This endpoint allows deletion of a collection identified by its
  533. UUID. The user must have appropriate permissions to delete the
  534. collection. Deleting a collection removes all associations but does
  535. not delete the documents within it.
  536. """
  537. if id == generate_default_user_collection_id(auth_user.id):
  538. raise R2RException(
  539. "Cannot delete the default user collection.",
  540. 400,
  541. )
  542. await authorize_collection_action(
  543. auth_user, id, CollectionAction.DELETE, self.services
  544. )
  545. await self.services.management.delete_collection(collection_id=id)
  546. return GenericBooleanResponse(success=True) # type: ignore
  547. @self.router.post(
  548. "/collections/{id}/documents/{document_id}",
  549. summary="Add document to collection",
  550. dependencies=[Depends(self.rate_limit_dependency)],
  551. openapi_extra={
  552. "x-codeSamples": [
  553. {
  554. "lang": "Python",
  555. "source": textwrap.dedent("""
  556. from r2r import R2RClient
  557. client = R2RClient()
  558. # when using auth, do client.login(...)
  559. result = client.collections.add_document(
  560. "123e4567-e89b-12d3-a456-426614174000",
  561. "456e789a-b12c-34d5-e678-901234567890"
  562. )
  563. """),
  564. },
  565. {
  566. "lang": "JavaScript",
  567. "source": textwrap.dedent("""
  568. const { r2rClient } = require("r2r-js");
  569. const client = new r2rClient();
  570. function main() {
  571. const response = await client.collections.addDocument({
  572. id: "123e4567-e89b-12d3-a456-426614174000"
  573. documentId: "456e789a-b12c-34d5-e678-901234567890"
  574. });
  575. }
  576. main();
  577. """),
  578. },
  579. {
  580. "lang": "cURL",
  581. "source": textwrap.dedent("""
  582. curl -X POST "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000/documents/456e789a-b12c-34d5-e678-901234567890" \\
  583. -H "Authorization: Bearer YOUR_API_KEY"
  584. """),
  585. },
  586. ]
  587. },
  588. )
  589. @self.base_endpoint
  590. async def add_document_to_collection(
  591. id: UUID = Path(...),
  592. document_id: UUID = Path(...),
  593. auth_user=Depends(self.providers.auth.auth_wrapper()),
  594. ) -> WrappedGenericMessageResponse:
  595. """Add a document to a collection."""
  596. await authorize_collection_action(
  597. auth_user, id, CollectionAction.ADD_DOCUMENT, self.services
  598. )
  599. return (
  600. await self.services.management.assign_document_to_collection(
  601. document_id, id
  602. )
  603. )
  604. @self.router.get(
  605. "/collections/{id}/documents",
  606. summary="List documents in collection",
  607. dependencies=[Depends(self.rate_limit_dependency)],
  608. openapi_extra={
  609. "x-codeSamples": [
  610. {
  611. "lang": "Python",
  612. "source": textwrap.dedent("""
  613. from r2r import R2RClient
  614. client = R2RClient()
  615. # when using auth, do client.login(...)
  616. result = client.collections.list_documents(
  617. "123e4567-e89b-12d3-a456-426614174000",
  618. offset=0,
  619. limit=10,
  620. )
  621. """),
  622. },
  623. {
  624. "lang": "JavaScript",
  625. "source": textwrap.dedent("""
  626. const { r2rClient } = require("r2r-js");
  627. const client = new r2rClient();
  628. function main() {
  629. const response = await client.collections.listDocuments({id: "123e4567-e89b-12d3-a456-426614174000"});
  630. }
  631. main();
  632. """),
  633. },
  634. {
  635. "lang": "cURL",
  636. "source": textwrap.dedent("""
  637. curl -X GET "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000/documents?offset=0&limit=10" \\
  638. -H "Authorization: Bearer YOUR_API_KEY"
  639. """),
  640. },
  641. ]
  642. },
  643. )
  644. @self.base_endpoint
  645. async def get_collection_documents(
  646. id: UUID = Path(
  647. ..., description="The unique identifier of the collection"
  648. ),
  649. offset: int = Query(
  650. 0,
  651. ge=0,
  652. description="Specifies the number of objects to skip. Defaults to 0.",
  653. ),
  654. limit: int = Query(
  655. 100,
  656. ge=1,
  657. le=1000,
  658. description="Specifies a limit on the number of objects to return, ranging between 1 and 100. Defaults to 100.",
  659. ),
  660. auth_user=Depends(self.providers.auth.auth_wrapper()),
  661. ) -> WrappedDocumentsResponse:
  662. """Get all documents in a collection with pagination and sorting
  663. options.
  664. This endpoint retrieves a paginated list of documents associated
  665. with a specific collection. It supports sorting options to
  666. customize the order of returned documents.
  667. """
  668. await authorize_collection_action(
  669. auth_user, id, CollectionAction.VIEW, self.services
  670. )
  671. documents_in_collection_response = (
  672. await self.services.management.documents_in_collection(
  673. id, offset, limit
  674. )
  675. )
  676. return documents_in_collection_response["results"], { # type: ignore
  677. "total_entries": documents_in_collection_response[
  678. "total_entries"
  679. ]
  680. }
  681. @self.router.delete(
  682. "/collections/{id}/documents/{document_id}",
  683. summary="Remove document from collection",
  684. dependencies=[Depends(self.rate_limit_dependency)],
  685. openapi_extra={
  686. "x-codeSamples": [
  687. {
  688. "lang": "Python",
  689. "source": textwrap.dedent("""
  690. from r2r import R2RClient
  691. client = R2RClient()
  692. # when using auth, do client.login(...)
  693. result = client.collections.remove_document(
  694. "123e4567-e89b-12d3-a456-426614174000",
  695. "456e789a-b12c-34d5-e678-901234567890"
  696. )
  697. """),
  698. },
  699. {
  700. "lang": "JavaScript",
  701. "source": textwrap.dedent("""
  702. const { r2rClient } = require("r2r-js");
  703. const client = new r2rClient();
  704. function main() {
  705. const response = await client.collections.removeDocument({
  706. id: "123e4567-e89b-12d3-a456-426614174000"
  707. documentId: "456e789a-b12c-34d5-e678-901234567890"
  708. });
  709. }
  710. main();
  711. """),
  712. },
  713. {
  714. "lang": "cURL",
  715. "source": textwrap.dedent("""
  716. curl -X DELETE "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000/documents/456e789a-b12c-34d5-e678-901234567890" \\
  717. -H "Authorization: Bearer YOUR_API_KEY"
  718. """),
  719. },
  720. ]
  721. },
  722. )
  723. @self.base_endpoint
  724. async def remove_document_from_collection(
  725. id: UUID = Path(
  726. ..., description="The unique identifier of the collection"
  727. ),
  728. document_id: UUID = Path(
  729. ...,
  730. description="The unique identifier of the document to remove",
  731. ),
  732. auth_user=Depends(self.providers.auth.auth_wrapper()),
  733. ) -> WrappedBooleanResponse:
  734. """Remove a document from a collection.
  735. This endpoint removes the association between a document and a
  736. collection. It does not delete the document itself. The user must
  737. have permissions to modify the collection.
  738. """
  739. await authorize_collection_action(
  740. auth_user, id, CollectionAction.REMOVE_DOCUMENT, self.services
  741. )
  742. await self.services.management.remove_document_from_collection(
  743. document_id, id
  744. )
  745. return GenericBooleanResponse(success=True) # type: ignore
  746. @self.router.get(
  747. "/collections/{id}/users",
  748. summary="List users in collection",
  749. dependencies=[Depends(self.rate_limit_dependency)],
  750. openapi_extra={
  751. "x-codeSamples": [
  752. {
  753. "lang": "Python",
  754. "source": textwrap.dedent("""
  755. from r2r import R2RClient
  756. client = R2RClient()
  757. # when using auth, do client.login(...)
  758. result = client.collections.list_users(
  759. "123e4567-e89b-12d3-a456-426614174000",
  760. offset=0,
  761. limit=10,
  762. )
  763. """),
  764. },
  765. {
  766. "lang": "JavaScript",
  767. "source": textwrap.dedent("""
  768. const { r2rClient } = require("r2r-js");
  769. const client = new r2rClient();
  770. function main() {
  771. const response = await client.collections.listUsers({
  772. id: "123e4567-e89b-12d3-a456-426614174000"
  773. });
  774. }
  775. main();
  776. """),
  777. },
  778. {
  779. "lang": "cURL",
  780. "source": textwrap.dedent("""
  781. curl -X GET "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000/users?offset=0&limit=10" \\
  782. -H "Authorization: Bearer YOUR_API_KEY"
  783. """),
  784. },
  785. ]
  786. },
  787. )
  788. @self.base_endpoint
  789. async def get_collection_users(
  790. id: UUID = Path(
  791. ..., description="The unique identifier of the collection"
  792. ),
  793. offset: int = Query(
  794. 0,
  795. ge=0,
  796. description="Specifies the number of objects to skip. Defaults to 0.",
  797. ),
  798. limit: int = Query(
  799. 100,
  800. ge=1,
  801. le=1000,
  802. description="Specifies a limit on the number of objects to return, ranging between 1 and 100. Defaults to 100.",
  803. ),
  804. auth_user=Depends(self.providers.auth.auth_wrapper()),
  805. ) -> WrappedUsersResponse:
  806. """Get all users in a collection with pagination and sorting
  807. options.
  808. This endpoint retrieves a paginated list of users who have access
  809. to a specific collection. It supports sorting options to customize
  810. the order of returned users.
  811. """
  812. await authorize_collection_action(
  813. auth_user, id, CollectionAction.VIEW, self.services
  814. )
  815. users_in_collection_response = (
  816. await self.services.management.get_users_in_collection(
  817. collection_id=id,
  818. offset=offset,
  819. limit=min(max(limit, 1), 1000),
  820. )
  821. )
  822. return users_in_collection_response["results"], { # type: ignore
  823. "total_entries": users_in_collection_response["total_entries"]
  824. }
  825. @self.router.post(
  826. "/collections/{id}/users/{user_id}",
  827. summary="Add user to collection",
  828. dependencies=[Depends(self.rate_limit_dependency)],
  829. openapi_extra={
  830. "x-codeSamples": [
  831. {
  832. "lang": "Python",
  833. "source": textwrap.dedent("""
  834. from r2r import R2RClient
  835. client = R2RClient()
  836. # when using auth, do client.login(...)
  837. result = client.collections.add_user(
  838. "123e4567-e89b-12d3-a456-426614174000",
  839. "789a012b-c34d-5e6f-g789-012345678901"
  840. )
  841. """),
  842. },
  843. {
  844. "lang": "JavaScript",
  845. "source": textwrap.dedent("""
  846. const { r2rClient } = require("r2r-js");
  847. const client = new r2rClient();
  848. function main() {
  849. const response = await client.collections.addUser({
  850. id: "123e4567-e89b-12d3-a456-426614174000"
  851. userId: "789a012b-c34d-5e6f-g789-012345678901"
  852. });
  853. }
  854. main();
  855. """),
  856. },
  857. {
  858. "lang": "cURL",
  859. "source": textwrap.dedent("""
  860. curl -X POST "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000/users/789a012b-c34d-5e6f-g789-012345678901" \\
  861. -H "Authorization: Bearer YOUR_API_KEY"
  862. """),
  863. },
  864. ]
  865. },
  866. )
  867. @self.base_endpoint
  868. async def add_user_to_collection(
  869. id: UUID = Path(
  870. ..., description="The unique identifier of the collection"
  871. ),
  872. user_id: UUID = Path(
  873. ..., description="The unique identifier of the user to add"
  874. ),
  875. auth_user=Depends(self.providers.auth.auth_wrapper()),
  876. ) -> WrappedBooleanResponse:
  877. """Add a user to a collection.
  878. This endpoint grants a user access to a specific collection. The
  879. authenticated user must have admin permissions for the collection
  880. to add new users.
  881. """
  882. await authorize_collection_action(
  883. auth_user, id, CollectionAction.MANAGE_USERS, self.services
  884. )
  885. result = await self.services.management.add_user_to_collection(
  886. user_id, id
  887. )
  888. return GenericBooleanResponse(success=result) # type: ignore
  889. @self.router.delete(
  890. "/collections/{id}/users/{user_id}",
  891. summary="Remove user from collection",
  892. dependencies=[Depends(self.rate_limit_dependency)],
  893. openapi_extra={
  894. "x-codeSamples": [
  895. {
  896. "lang": "Python",
  897. "source": textwrap.dedent("""
  898. from r2r import R2RClient
  899. client = R2RClient()
  900. # when using auth, do client.login(...)
  901. result = client.collections.remove_user(
  902. "123e4567-e89b-12d3-a456-426614174000",
  903. "789a012b-c34d-5e6f-g789-012345678901"
  904. )
  905. """),
  906. },
  907. {
  908. "lang": "JavaScript",
  909. "source": textwrap.dedent("""
  910. const { r2rClient } = require("r2r-js");
  911. const client = new r2rClient();
  912. function main() {
  913. const response = await client.collections.removeUser({
  914. id: "123e4567-e89b-12d3-a456-426614174000"
  915. userId: "789a012b-c34d-5e6f-g789-012345678901"
  916. });
  917. }
  918. main();
  919. """),
  920. },
  921. {
  922. "lang": "cURL",
  923. "source": textwrap.dedent("""
  924. curl -X DELETE "https://api.example.com/v3/collections/123e4567-e89b-12d3-a456-426614174000/users/789a012b-c34d-5e6f-g789-012345678901" \\
  925. -H "Authorization: Bearer YOUR_API_KEY"
  926. """),
  927. },
  928. ]
  929. },
  930. )
  931. @self.base_endpoint
  932. async def remove_user_from_collection(
  933. id: UUID = Path(
  934. ..., description="The unique identifier of the collection"
  935. ),
  936. user_id: UUID = Path(
  937. ..., description="The unique identifier of the user to remove"
  938. ),
  939. auth_user=Depends(self.providers.auth.auth_wrapper()),
  940. ) -> WrappedBooleanResponse:
  941. """Remove a user from a collection.
  942. This endpoint revokes a user's access to a specific collection. The
  943. authenticated user must have admin permissions for the collection
  944. to remove users.
  945. """
  946. await authorize_collection_action(
  947. auth_user, id, CollectionAction.MANAGE_USERS, self.services
  948. )
  949. result = (
  950. await self.services.management.remove_user_from_collection(
  951. user_id, id
  952. )
  953. )
  954. return GenericBooleanResponse(success=True) # type: ignore
  955. @self.router.post(
  956. "/collections/{id}/extract",
  957. summary="Extract entities and relationships",
  958. dependencies=[Depends(self.rate_limit_dependency)],
  959. openapi_extra={
  960. "x-codeSamples": [
  961. {
  962. "lang": "Python",
  963. "source": textwrap.dedent("""
  964. from r2r import R2RClient
  965. client = R2RClient()
  966. # when using auth, do client.login(...)
  967. result = client.documents.extract(
  968. id="9fbe403b-c11c-5aae-8ade-ef22980c3ad1"
  969. )
  970. """),
  971. },
  972. ],
  973. },
  974. )
  975. @self.base_endpoint
  976. async def extract(
  977. id: UUID = Path(
  978. ...,
  979. description="The ID of the document to extract entities and relationships from.",
  980. ),
  981. settings: Optional[GraphCreationSettings] = Body(
  982. default=None,
  983. description="Settings for the entities and relationships extraction process.",
  984. ),
  985. run_with_orchestration: Optional[bool] = Query(
  986. default=True,
  987. description="Whether to run the entities and relationships extraction process with orchestration.",
  988. ),
  989. auth_user=Depends(self.providers.auth.auth_wrapper()),
  990. ) -> WrappedGenericMessageResponse:
  991. """Extracts entities and relationships from a document.
  992. The entities and relationships extraction process involves:
  993. 1. Parsing documents into semantic chunks
  994. 2. Extracting entities and relationships using LLMs
  995. """
  996. await authorize_collection_action(
  997. auth_user, id, CollectionAction.EDIT, self.services
  998. )
  999. settings = settings.dict() if settings else None # type: ignore
  1000. if not auth_user.is_superuser:
  1001. logger.warning("Implement permission checks here.")
  1002. # Apply runtime settings overrides
  1003. server_graph_creation_settings = (
  1004. self.providers.database.config.graph_creation_settings
  1005. )
  1006. if settings:
  1007. server_graph_creation_settings = update_settings_from_dict(
  1008. server_settings=server_graph_creation_settings,
  1009. settings_dict=settings, # type: ignore
  1010. )
  1011. if run_with_orchestration:
  1012. try:
  1013. workflow_input = {
  1014. "collection_id": str(id),
  1015. "graph_creation_settings": server_graph_creation_settings.model_dump_json(),
  1016. "user": auth_user.json(),
  1017. }
  1018. return await self.providers.orchestration.run_workflow( # type: ignore
  1019. "graph-extraction", {"request": workflow_input}, {}
  1020. )
  1021. except Exception as e: # TODO: Need to find specific error (gRPC most likely?)
  1022. logger.error(
  1023. f"Error running orchestrated extraction: {e} \n\nAttempting to run without orchestration."
  1024. )
  1025. from core.main.orchestration import (
  1026. simple_graph_search_results_factory,
  1027. )
  1028. logger.info("Running extract-triples without orchestration.")
  1029. simple_graph_search_results = simple_graph_search_results_factory(
  1030. self.services.graph
  1031. )
  1032. await simple_graph_search_results["graph-extraction"](
  1033. workflow_input
  1034. ) # type: ignore
  1035. return { # type: ignore
  1036. "message": "Graph created successfully.",
  1037. "task_id": None,
  1038. }
  1039. @self.router.get(
  1040. "/collections/name/{collection_name}",
  1041. summary="Get a collection by name",
  1042. dependencies=[Depends(self.rate_limit_dependency)],
  1043. )
  1044. @self.base_endpoint
  1045. async def get_collection_by_name(
  1046. collection_name: str = Path(
  1047. ..., description="The name of the collection"
  1048. ),
  1049. owner_id: Optional[UUID] = Query(
  1050. None,
  1051. description="(Superuser only) Specify the owner_id to retrieve a collection by name",
  1052. ),
  1053. auth_user=Depends(self.providers.auth.auth_wrapper()),
  1054. ) -> WrappedCollectionResponse:
  1055. """Retrieve a collection by its (owner_id, name) combination.
  1056. The authenticated user can only fetch collections they own, or, if
  1057. superuser, from anyone.
  1058. """
  1059. if auth_user.is_superuser:
  1060. if not owner_id:
  1061. owner_id = auth_user.id
  1062. else:
  1063. owner_id = auth_user.id
  1064. # If not superuser, fetch by (owner_id, name). Otherwise, maybe pass `owner_id=None`.
  1065. # Decide on the logic for superusers.
  1066. if not owner_id: # is_superuser
  1067. # If you want superusers to do /collections/name/<string>?owner_id=...
  1068. # just parse it from the query. For now, let's say it's not implemented.
  1069. raise R2RException(
  1070. "Superuser must specify an owner_id to fetch by name.", 400
  1071. )
  1072. collection = await self.providers.database.collections_handler.get_collection_by_name(
  1073. owner_id, collection_name
  1074. )
  1075. if not collection:
  1076. raise R2RException("Collection not found.", 404)
  1077. # Now, authorize the 'view' action just in case:
  1078. # e.g. await authorize_collection_action(auth_user, collection.id, CollectionAction.VIEW, self.services)
  1079. return collection # type: ignore