client.py 121 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514151515161517151815191520152115221523152415251526152715281529153015311532153315341535153615371538153915401541154215431544154515461547154815491550155115521553155415551556155715581559156015611562156315641565156615671568156915701571157215731574157515761577157815791580158115821583158415851586158715881589159015911592159315941595159615971598159916001601160216031604160516061607160816091610161116121613161416151616161716181619162016211622162316241625162616271628162916301631163216331634163516361637163816391640164116421643164416451646164716481649165016511652165316541655165616571658165916601661166216631664166516661667166816691670167116721673167416751676167716781679168016811682168316841685168616871688168916901691169216931694169516961697169816991700170117021703170417051706170717081709171017111712171317141715171617171718171917201721172217231724172517261727172817291730173117321733173417351736173717381739174017411742174317441745174617471748174917501751175217531754175517561757175817591760176117621763176417651766176717681769177017711772177317741775177617771778177917801781178217831784178517861787178817891790179117921793179417951796179717981799180018011802180318041805180618071808180918101811181218131814181518161817181818191820182118221823182418251826182718281829183018311832183318341835183618371838183918401841184218431844184518461847184818491850185118521853185418551856185718581859186018611862186318641865186618671868186918701871187218731874187518761877187818791880188118821883188418851886188718881889189018911892189318941895189618971898189919001901190219031904190519061907190819091910191119121913191419151916191719181919192019211922192319241925192619271928192919301931193219331934193519361937193819391940194119421943194419451946194719481949195019511952195319541955195619571958195919601961196219631964196519661967196819691970197119721973197419751976197719781979198019811982198319841985198619871988198919901991199219931994199519961997199819992000200120022003200420052006200720082009201020112012201320142015201620172018201920202021202220232024202520262027202820292030203120322033203420352036203720382039204020412042204320442045204620472048204920502051205220532054205520562057205820592060206120622063206420652066206720682069207020712072207320742075207620772078207920802081208220832084208520862087208820892090209120922093209420952096209720982099210021012102210321042105210621072108210921102111211221132114211521162117211821192120212121222123212421252126212721282129213021312132213321342135213621372138213921402141214221432144214521462147214821492150215121522153215421552156215721582159216021612162216321642165216621672168216921702171217221732174217521762177217821792180218121822183218421852186218721882189219021912192219321942195219621972198219922002201220222032204220522062207220822092210221122122213221422152216221722182219222022212222222322242225222622272228222922302231223222332234223522362237223822392240224122422243224422452246224722482249225022512252225322542255225622572258225922602261226222632264226522662267226822692270227122722273227422752276227722782279228022812282228322842285228622872288228922902291229222932294229522962297229822992300230123022303230423052306230723082309231023112312231323142315231623172318231923202321232223232324232523262327232823292330233123322333233423352336233723382339234023412342234323442345234623472348234923502351235223532354235523562357235823592360236123622363236423652366236723682369237023712372237323742375237623772378237923802381238223832384238523862387238823892390239123922393239423952396239723982399240024012402240324042405240624072408240924102411241224132414241524162417241824192420242124222423242424252426242724282429243024312432243324342435243624372438243924402441244224432444244524462447244824492450245124522453245424552456245724582459246024612462246324642465246624672468246924702471247224732474247524762477247824792480248124822483248424852486248724882489249024912492249324942495249624972498249925002501250225032504250525062507250825092510251125122513251425152516251725182519252025212522252325242525252625272528252925302531253225332534253525362537253825392540254125422543254425452546254725482549255025512552255325542555255625572558255925602561256225632564256525662567256825692570257125722573257425752576257725782579258025812582258325842585258625872588258925902591259225932594259525962597259825992600260126022603260426052606260726082609261026112612261326142615261626172618261926202621262226232624262526262627262826292630263126322633263426352636263726382639264026412642264326442645264626472648264926502651265226532654265526562657265826592660266126622663266426652666266726682669267026712672267326742675267626772678267926802681268226832684268526862687268826892690269126922693269426952696269726982699270027012702270327042705270627072708270927102711271227132714271527162717271827192720272127222723272427252726272727282729273027312732273327342735273627372738273927402741274227432744274527462747274827492750275127522753275427552756275727582759276027612762276327642765276627672768276927702771277227732774277527762777277827792780278127822783278427852786278727882789279027912792279327942795279627972798279928002801280228032804280528062807280828092810281128122813281428152816281728182819282028212822282328242825282628272828282928302831283228332834283528362837283828392840284128422843284428452846284728482849285028512852285328542855285628572858285928602861286228632864286528662867286828692870287128722873287428752876287728782879288028812882288328842885288628872888288928902891289228932894289528962897289828992900290129022903290429052906290729082909291029112912291329142915291629172918291929202921292229232924292529262927292829292930293129322933293429352936293729382939294029412942294329442945294629472948294929502951295229532954295529562957295829592960296129622963296429652966296729682969297029712972297329742975297629772978297929802981298229832984298529862987298829892990299129922993299429952996299729982999300030013002300330043005300630073008300930103011301230133014301530163017301830193020302130223023302430253026302730283029303030313032
  1. """GraphDB client module."""
  2. from __future__ import annotations
  3. import json
  4. import os
  5. import pathlib
  6. import typing as t
  7. import httpx
  8. import rdflib.contrib.rdf4j
  9. from rdflib import Graph, Literal, URIRef
  10. from rdflib.contrib.graphdb.exceptions import (
  11. BadRequestError,
  12. ConflictError,
  13. ForbiddenError,
  14. InternalServerError,
  15. NotFoundError,
  16. PreconditionFailedError,
  17. RepositoryNotFoundError,
  18. RepositoryNotHealthyError,
  19. ResponseFormatError,
  20. ServiceUnavailableError,
  21. UnauthorisedError,
  22. )
  23. from rdflib.contrib.graphdb.models import (
  24. AccessControlEntry,
  25. AuthenticatedUser,
  26. BackupOperationBean,
  27. ClearGraphAccessControlEntry,
  28. ClusterRequest,
  29. FreeAccessSettings,
  30. GraphDBRepository,
  31. ImportSettings,
  32. InfrastructureStatistics,
  33. NodeStatus,
  34. ParserSettings,
  35. PluginAccessControlEntry,
  36. RepositoryConfigBean,
  37. RepositoryConfigBeanCreate,
  38. RepositorySizeInfo,
  39. RepositoryStatistics,
  40. ServerImportBody,
  41. StatementAccessControlEntry,
  42. StructuresStatistics,
  43. SystemAccessControlEntry,
  44. User,
  45. UserCreate,
  46. UserUpdate,
  47. _parse_operation,
  48. _parse_plugin,
  49. _parse_policy,
  50. _parse_role,
  51. )
  52. from rdflib.contrib.graphdb.util import (
  53. extract_filename_from_content_disposition,
  54. infer_filename_from_fileobj,
  55. )
  56. from rdflib.contrib.rdf4j import RDF4JClient
  57. FileContent = t.Union[
  58. bytes,
  59. str,
  60. t.IO[bytes],
  61. t.Tuple[t.Optional[str], t.Union[bytes, str, t.IO[bytes]]],
  62. t.Tuple[t.Optional[str], t.Union[bytes, str, t.IO[bytes]], t.Optional[str]],
  63. ]
  64. FilesType = t.Union[
  65. t.Mapping[str, FileContent],
  66. t.Iterable[t.Tuple[str, FileContent]],
  67. ]
  68. _ALLOWED_FGAC_SCOPES = {"statement", "clear_graph", "plugin", "system"}
  69. class AccessControlListManagement:
  70. """Manage fine-grained access control lists for GraphDB repositories."""
  71. def __init__(self, identifier: str, http_client: httpx.Client):
  72. self._identifier = identifier
  73. self._http_client = http_client
  74. @property
  75. def http_client(self):
  76. return self._http_client
  77. @property
  78. def identifier(self):
  79. """Repository identifier."""
  80. return self._identifier
  81. def list(
  82. self,
  83. scope: t.Literal["statement", "clear_graph", "plugin", "system"] | None = None,
  84. operation: t.Literal["read", "write", "*"] | None = None,
  85. subject: str | URIRef | None = None,
  86. predicate: str | URIRef | None = None,
  87. obj: str | URIRef | Literal | None = None,
  88. graph: t.Literal["*", "named", "default"] | URIRef | Graph | None = None,
  89. plugin: str | None = None,
  90. role: str | None = None,
  91. policy: t.Literal["allow", "deny", "abstain"] | None = None,
  92. ) -> t.List[
  93. SystemAccessControlEntry
  94. | StatementAccessControlEntry
  95. | PluginAccessControlEntry
  96. | ClearGraphAccessControlEntry
  97. ]:
  98. """
  99. List ACL rules for the repository.
  100. Parameters:
  101. scope: The scope of the FGAC rule (`statement`, `clear_graph`, `plugin`, `system`).
  102. operation: The operation of the FGAC rule (`read`, `write`, `*`).
  103. subject: The subject of the FGAC rule in Turtle format (for example, `<http://example.com/Mary>`).
  104. predicate: The predicate of the FGAC rule in Turtle format (for example, `<http://www.w3.org/2000/01/rdf-schema#label>`).
  105. obj: The object of the FGAC rule in Turtle format (for example, `"Mary"@en`).
  106. graph: The graph of the FGAC rule in Turtle format (for example, `<http://example.org/graphs/graph1>`).
  107. plugin: The plugin name for the FGAC rule with plugin scope (for example, `elasticsearch-connector`).
  108. role: The role associated with the FGAC rule.
  109. policy: The policy for the FGAC rule (`allow`, `deny`, `abstain`).
  110. Returns:
  111. A list of FGAC rules.
  112. Raises:
  113. ValueError: If the parameters are invalid.
  114. UnauthorisedError: If the request is unauthorised.
  115. ForbiddenError: If the request is forbidden.
  116. InternalServerError: If the server returns an internal error.
  117. ResponseFormatError: If the response cannot be parsed.
  118. """
  119. params: dict[str, str] = {}
  120. if scope is not None:
  121. if scope not in _ALLOWED_FGAC_SCOPES:
  122. raise ValueError(f"Invalid FGAC scope filter: {scope!r}")
  123. params["scope"] = scope
  124. if operation is not None:
  125. params["operation"] = _parse_operation(operation)
  126. if subject is not None:
  127. if isinstance(subject, URIRef):
  128. params["subject"] = subject.n3()
  129. elif isinstance(subject, str):
  130. params["subject"] = subject
  131. else:
  132. raise ValueError(f"Invalid FGAC subject filter: {subject!r}")
  133. if predicate is not None:
  134. if isinstance(predicate, URIRef):
  135. params["predicate"] = predicate.n3()
  136. elif isinstance(predicate, str):
  137. params["predicate"] = predicate
  138. else:
  139. raise ValueError(f"Invalid FGAC predicate filter: {predicate!r}")
  140. if obj is not None:
  141. if isinstance(obj, (URIRef, Literal)):
  142. params["object"] = obj.n3()
  143. elif isinstance(obj, str):
  144. params["object"] = obj
  145. else:
  146. raise ValueError(f"Invalid FGAC object filter: {obj!r}")
  147. if graph is not None:
  148. if isinstance(graph, URIRef):
  149. params["context"] = graph.n3()
  150. elif isinstance(graph, Graph):
  151. params["context"] = graph.identifier.n3()
  152. elif isinstance(graph, str):
  153. params["context"] = graph
  154. else:
  155. raise ValueError(f"Invalid FGAC graph filter: {graph!r}")
  156. if plugin is not None:
  157. params["plugin"] = _parse_plugin(plugin)
  158. if role is not None:
  159. params["role"] = _parse_role(role)
  160. if policy is not None:
  161. params["policy"] = _parse_policy(policy)
  162. try:
  163. response = self._http_client.get(
  164. f"/rest/repositories/{self.identifier}/acl", params=params
  165. )
  166. response.raise_for_status()
  167. try:
  168. payload = response.json()
  169. except (ValueError, TypeError) as err:
  170. raise ResponseFormatError(
  171. f"Failed to parse GraphDB response: {err}"
  172. ) from err
  173. if not isinstance(payload, list):
  174. raise ResponseFormatError(
  175. "Failed to parse GraphDB response: expected a list of ACL entries."
  176. )
  177. try:
  178. return [AccessControlEntry.from_dict(acl) for acl in payload]
  179. except (ValueError, TypeError) as err:
  180. raise ResponseFormatError(
  181. f"Failed to parse GraphDB response: {err}"
  182. ) from err
  183. except httpx.HTTPStatusError as err:
  184. status = err.response.status_code
  185. if status == 401:
  186. raise UnauthorisedError(
  187. f"Request is unauthorised: {err.response.text}"
  188. ) from err
  189. elif status == 403:
  190. raise ForbiddenError(
  191. f"Request is forbidden: {err.response.text}"
  192. ) from err
  193. elif status == 500:
  194. raise InternalServerError(
  195. f"Internal server error: {err.response.text}"
  196. ) from err
  197. raise
  198. def set(self, acl_rules: t.Sequence[AccessControlEntry]):
  199. """
  200. Set ACL rules for the repository.
  201. !!! Note
  202. This method overwrites existing ACL rules in the repository.
  203. If you want to add new rules without overwriting existing ones,
  204. use the [`add`][rdflib.contrib.graphdb.client.AccessControlListManagement.add] method.
  205. Parameters:
  206. acl_rules: The list of ACL rules to set.
  207. Raises:
  208. BadRequestError: If the request is invalid.
  209. UnauthorisedError: If the request is unauthorised.
  210. ForbiddenError: If the request is forbidden.
  211. InternalServerError: If the server returns an internal error.
  212. ValueError: If the ACL rules are not provided as a sequence or are not AccessControlEntry instances.
  213. """
  214. acl_rules_list = list(acl_rules)
  215. if any(not isinstance(rule, AccessControlEntry) for rule in acl_rules_list):
  216. raise ValueError("All ACL rules must be AccessControlEntry instances.")
  217. payload = [rule.as_dict() for rule in acl_rules_list]
  218. headers = {"Content-Type": "application/json"}
  219. try:
  220. response = self._http_client.put(
  221. f"/rest/repositories/{self.identifier}/acl",
  222. headers=headers,
  223. json=payload,
  224. )
  225. response.raise_for_status()
  226. except httpx.HTTPStatusError as err:
  227. status = err.response.status_code
  228. if status == 400:
  229. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  230. elif status == 401:
  231. raise UnauthorisedError(
  232. f"Request is unauthorised: {err.response.text}"
  233. ) from err
  234. elif status == 403:
  235. raise ForbiddenError(
  236. f"Request is forbidden: {err.response.text}"
  237. ) from err
  238. elif status == 500:
  239. raise InternalServerError(
  240. f"Internal server error: {err.response.text}"
  241. ) from err
  242. raise
  243. def add(
  244. self, acl_rules: t.Sequence[AccessControlEntry], position: int | None = None
  245. ):
  246. """
  247. Add ACL rules to the repository.
  248. You can also provide an optional URL request parameter position that specifies the position of the rules to be added.
  249. The position is zero-based (0 is the first position). If the position parameter is not provided, the rules are added at
  250. the end of the list.
  251. Parameters:
  252. acl_rules: The list of ACL rules to add.
  253. position: The zero-based position to add the rules at.
  254. Raises:
  255. BadRequestError: If the request is invalid.
  256. UnauthorisedError: If the request is unauthorised.
  257. ForbiddenError: If the request is forbidden.
  258. InternalServerError: If the server returns an internal error.
  259. ValueError: If the position is not an integer or is a negative integer.
  260. ValueError: If the ACL rules are not provided as a sequence or are not AccessControlEntry instances.
  261. """
  262. acl_rules_list = list(acl_rules)
  263. if any(not isinstance(rule, AccessControlEntry) for rule in acl_rules_list):
  264. raise ValueError("All ACL rules must be AccessControlEntry instances.")
  265. payload = [rule.as_dict() for rule in acl_rules_list]
  266. headers = {"Content-Type": "application/json"}
  267. params = {}
  268. if position is not None:
  269. if not isinstance(position, int):
  270. raise ValueError("Position must be an integer.")
  271. if position < 0:
  272. raise ValueError("Position must be a positive integer.")
  273. params["position"] = str(position)
  274. try:
  275. response = self._http_client.post(
  276. f"/rest/repositories/{self.identifier}/acl",
  277. headers=headers,
  278. json=payload,
  279. params=params,
  280. )
  281. response.raise_for_status()
  282. except httpx.HTTPStatusError as err:
  283. status = err.response.status_code
  284. if status == 400:
  285. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  286. elif status == 401:
  287. raise UnauthorisedError(
  288. f"Request is unauthorised: {err.response.text}"
  289. ) from err
  290. elif status == 403:
  291. raise ForbiddenError(
  292. f"Request is forbidden: {err.response.text}"
  293. ) from err
  294. elif status == 500:
  295. raise InternalServerError(
  296. f"Internal server error: {err.response.text}"
  297. ) from err
  298. raise
  299. def delete(self, acl_rules: t.Sequence[AccessControlEntry]):
  300. """
  301. Delete ACL rules from the repository.
  302. The provided FGAC rules are removed from the list regardless of their position.
  303. Parameters:
  304. acl_rules: The list of ACL rules to delete.
  305. Raises:
  306. BadRequestError: If the request is invalid.
  307. UnauthorisedError: If the request is unauthorised.
  308. ForbiddenError: If the request is forbidden.
  309. InternalServerError: If the server returns an internal error.
  310. ValueError: If the ACL rules are not provided as a sequence or are not AccessControlEntry instances.
  311. """
  312. acl_rules_list = list(acl_rules)
  313. if any(not isinstance(rule, AccessControlEntry) for rule in acl_rules_list):
  314. raise ValueError("All ACL rules must be AccessControlEntry instances.")
  315. payload = [rule.as_dict() for rule in acl_rules_list]
  316. headers = {"Content-Type": "application/json"}
  317. try:
  318. response = self._http_client.request(
  319. method="DELETE",
  320. url=f"/rest/repositories/{self.identifier}/acl",
  321. headers=headers,
  322. json=payload,
  323. )
  324. response.raise_for_status()
  325. except httpx.HTTPStatusError as err:
  326. status = err.response.status_code
  327. if status == 400:
  328. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  329. elif status == 401:
  330. raise UnauthorisedError(
  331. f"Request is unauthorised: {err.response.text}"
  332. ) from err
  333. elif status == 403:
  334. raise ForbiddenError(
  335. f"Request is forbidden: {err.response.text}"
  336. ) from err
  337. elif status == 500:
  338. raise InternalServerError(
  339. f"Internal server error: {err.response.text}"
  340. ) from err
  341. raise
  342. class ClusterGroupManagement:
  343. """Manage and monitor GraphDB cluster configurations."""
  344. def __init__(self, http_client: httpx.Client):
  345. self._http_client = http_client
  346. @property
  347. def http_client(self):
  348. return self._http_client
  349. def truncate_log(self) -> None:
  350. """Truncate the GraphDB cluster log.
  351. The truncate log operation is used to free up storage space on all cluster nodes by clearing the
  352. current transaction log and removing cached recovery snapshots.
  353. Raises:
  354. ResponseFormatError: If the response is not in the expected format.
  355. BadRequestError: If the request is invalid.
  356. UnauthorisedError: If the request is unauthorised.
  357. ForbiddenError: If the request is forbidden.
  358. """
  359. try:
  360. response = self.http_client.post("/rest/cluster/truncate-log")
  361. response.raise_for_status()
  362. except httpx.HTTPStatusError as err:
  363. status = err.response.status_code
  364. if status == 400:
  365. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  366. elif status == 401:
  367. raise UnauthorisedError(
  368. f"Request is unauthorised: {err.response.text}"
  369. ) from err
  370. elif status == 403:
  371. raise ForbiddenError(
  372. f"Request is forbidden: {err.response.text}"
  373. ) from err
  374. raise
  375. def get_config(self) -> ClusterRequest:
  376. """Get the GraphDB cluster configuration.
  377. Returns:
  378. Cluster configuration.
  379. Raises:
  380. ResponseFormatError: If the response is not in the expected format.
  381. UnauthorisedError: If the request is unauthorised.
  382. NotFoundError: If the cluster configuration is not found.
  383. InternalServerError: If the server returns an internal error.
  384. """
  385. try:
  386. headers = {"Accept": "application/json"}
  387. response = self.http_client.get("/rest/cluster/config", headers=headers)
  388. response.raise_for_status()
  389. try:
  390. return ClusterRequest.from_dict(response.json())
  391. except (KeyError, TypeError, ValueError) as err:
  392. raise ResponseFormatError(
  393. f"Failed to parse cluster configuration: {err}"
  394. ) from err
  395. except httpx.HTTPStatusError as err:
  396. status = err.response.status_code
  397. if status == 401:
  398. raise UnauthorisedError(
  399. f"Request is unauthorised: {err.response.text}"
  400. ) from err
  401. elif status == 404:
  402. raise NotFoundError(
  403. f"Cluster configuration not found: {err.response.text}"
  404. ) from err
  405. elif status == 500:
  406. raise InternalServerError(
  407. f"Internal server error: {err.response.text}"
  408. ) from err
  409. raise
  410. def create_config(
  411. self,
  412. nodes: list[str],
  413. election_min_timeout: int | None = None,
  414. election_range_timeout: int | None = None,
  415. heartbeat_interval: int | None = None,
  416. message_size_kb: int | None = None,
  417. verification_timeout: int | None = None,
  418. transaction_log_maximum_size_gb: int | None = None,
  419. batch_update_interval: int | None = None,
  420. ) -> None:
  421. """Create a GraphDB cluster.
  422. Parameters:
  423. nodes: List of node addresses.
  424. election_min_timeout: The minimum wait time in milliseconds for a heartbeat from a leader.
  425. election_range_timeout: The variable portion of each waiting period in milliseconds for a heartbeat.
  426. heartbeat_interval: The interval in milliseconds between each heartbeat that is sent to follower nodes by the leader.
  427. message_size_kb: The size of the data blocks, in kilobytes, transferred during data replication streaming through the RPC protocol.
  428. verification_timeout: The amount of time in milliseconds that a follower node would wait before attempting to verify the last committed
  429. entry when the first verification is unsuccessful.
  430. transaction_log_maximum_size_gb: Maximum size of the transaction log in GBs. The transaction log will be automatically truncated if it
  431. becomes bigger than this value. The minimum transaction log size is 1 GB. Setting it to a negative value will disable automatic transaction
  432. log truncation.
  433. batch_update_interval: The interval in milliseconds between requesting updates from the primary cluster. Used only when the cluster is in
  434. secondary mode.
  435. Raises:
  436. BadRequestError: If the request is invalid.
  437. UnauthorisedError: If the request is unauthorised.
  438. ForbiddenError: If the request is forbidden.
  439. ConflictError: If the cluster configuration already exists.
  440. PreconditionFailedError: If the cluster is not in a state to create a configuration.
  441. """
  442. try:
  443. if not isinstance(nodes, list) or any(
  444. not isinstance(node, str) for node in nodes
  445. ):
  446. raise TypeError("nodes must be a list[str].")
  447. _int_fields: list[tuple[str, int | None]] = [
  448. ("election_min_timeout", election_min_timeout),
  449. ("election_range_timeout", election_range_timeout),
  450. ("heartbeat_interval", heartbeat_interval),
  451. ("message_size_kb", message_size_kb),
  452. ("verification_timeout", verification_timeout),
  453. ("transaction_log_maximum_size_gb", transaction_log_maximum_size_gb),
  454. ("batch_update_interval", batch_update_interval),
  455. ]
  456. for field_name, value in _int_fields:
  457. if value is not None and type(value) is not int:
  458. raise TypeError(f"{field_name} must be an int if provided.")
  459. payload: dict[str, t.Any] = {"nodes": nodes}
  460. if election_min_timeout is not None:
  461. payload["electionMinTimeout"] = election_min_timeout
  462. if election_range_timeout is not None:
  463. payload["electionRangeTimeout"] = election_range_timeout
  464. if heartbeat_interval is not None:
  465. payload["heartbeatInterval"] = heartbeat_interval
  466. if message_size_kb is not None:
  467. payload["messageSizeKB"] = message_size_kb
  468. if verification_timeout is not None:
  469. payload["verificationTimeout"] = verification_timeout
  470. if transaction_log_maximum_size_gb is not None:
  471. payload["transactionLogMaximumSizeGB"] = transaction_log_maximum_size_gb
  472. if batch_update_interval is not None:
  473. payload["batchUpdateInterval"] = batch_update_interval
  474. headers = {"Content-Type": "application/json"}
  475. response = self.http_client.post(
  476. "/rest/cluster/config", headers=headers, json=payload
  477. )
  478. response.raise_for_status()
  479. except httpx.HTTPStatusError as err:
  480. status = err.response.status_code
  481. if status == 400:
  482. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  483. elif status == 401:
  484. raise UnauthorisedError(
  485. f"Request is unauthorised: {err.response.text}"
  486. ) from err
  487. elif status == 403:
  488. raise ForbiddenError(
  489. f"Request is forbidden: {err.response.text}"
  490. ) from err
  491. elif status == 409:
  492. raise ConflictError(
  493. f"Cluster configuration already exists: {err.response.text}"
  494. ) from err
  495. elif status == 412:
  496. raise PreconditionFailedError(
  497. f"Precondition failed: {err.response.text}"
  498. ) from err
  499. raise
  500. def delete_config(self, force: bool | None = None) -> dict[str, str]:
  501. """Delete the GraphDB cluster.
  502. By default, the cluster group cannot be deleted if one or more nodes are unreachable.
  503. Reachable here means that the nodes are not in status `NO_CONNECTION`, therefore there is an
  504. RPC connection to them.
  505. Parameter:
  506. force: When set to `True`, the cluster configuration will be deleted only on the reachable nodes and
  507. the response will always succeed.
  508. Returns:
  509. A dictionary where the node address is the key and the deletion status message is the value.
  510. Raises:
  511. ResponseFormatError: If the response is not in the expected format.
  512. UnauthorisedError: If the request is unauthorised.
  513. ForbiddenError: If the request is forbidden.
  514. PreconditionFailedError: If one or more nodes in the group are not reachable and force is not set to `True`.
  515. """
  516. params: dict[str, str] | None = None
  517. if force is not None:
  518. params = {"force": str(force).lower()}
  519. try:
  520. headers = {"Accept": "application/json"}
  521. if params is None:
  522. response = self.http_client.delete(
  523. "/rest/cluster/config", headers=headers
  524. )
  525. else:
  526. response = self.http_client.delete(
  527. "/rest/cluster/config", headers=headers, params=params
  528. )
  529. response.raise_for_status()
  530. try:
  531. payload = response.json()
  532. except (ValueError, TypeError) as err:
  533. raise ResponseFormatError(
  534. f"Failed to parse cluster deletion response: {err}"
  535. ) from err
  536. if not isinstance(payload, dict):
  537. raise ResponseFormatError(
  538. "Failed to parse cluster deletion response: expected a JSON object."
  539. )
  540. if not all(
  541. isinstance(key, str) and isinstance(value, str)
  542. for key, value in payload.items()
  543. ):
  544. raise ResponseFormatError(
  545. "Failed to parse cluster deletion response: expected string keys and values."
  546. )
  547. return t.cast(dict[str, str], payload)
  548. except httpx.HTTPStatusError as err:
  549. status = err.response.status_code
  550. if status == 401:
  551. raise UnauthorisedError(
  552. f"Request is unauthorised: {err.response.text}"
  553. ) from err
  554. elif status == 403:
  555. raise ForbiddenError(
  556. f"Request is forbidden: {err.response.text}"
  557. ) from err
  558. elif status == 412:
  559. raise PreconditionFailedError(
  560. f"Precondition failed: {err.response.text}"
  561. ) from err
  562. raise
  563. def update_config(
  564. self,
  565. election_min_timeout: int | None = None,
  566. election_range_timeout: int | None = None,
  567. heartbeat_interval: int | None = None,
  568. message_size_kb: int | None = None,
  569. verification_timeout: int | None = None,
  570. transaction_log_maximum_size_gb: int | None = None,
  571. batch_update_interval: int | None = None,
  572. ) -> ClusterRequest:
  573. """Update the GraphDB cluster configuration.
  574. If one of the cluster nodes is down or was not able to accept the new configuration,
  575. the operation will not be successful. A total consensus between the nodes is required.
  576. In case one or more of them cannot append the new configuration, it will be rejected
  577. by all of the nodes.
  578. Parameters:
  579. election_min_timeout: The minimum wait time in milliseconds for a heartbeat from a leader.
  580. election_range_timeout: The variable portion of each waiting period in milliseconds for a heartbeat.
  581. heartbeat_interval: The interval in milliseconds between each heartbeat that is sent to follower nodes by the leader.
  582. message_size_kb: The size of the data blocks, in kilobytes, transferred during data replication streaming through the RPC protocol.
  583. verification_timeout: The amount of time in milliseconds that a follower node would wait before attempting to verify the last committed
  584. entry when the first verification is unsuccessful.
  585. transaction_log_maximum_size_gb: Maximum size of the transaction log in GBs. The transaction log will be automatically truncated if it
  586. becomes bigger than this value. The minimum transaction log size is 1 GB. Setting it to a negative value will disable automatic transaction
  587. log truncation.
  588. batch_update_interval: The interval in milliseconds between requesting updates from the primary cluster. Used only when the cluster is in
  589. secondary mode.
  590. Returns:
  591. The updated cluster configuration.
  592. Raises:
  593. BadRequestError: If the request is invalid.
  594. UnauthorisedError: If the request is unauthorised.
  595. ForbiddenError: If the request is forbidden.
  596. PreconditionFailedError: If one or more nodes in the group are not reachable.
  597. """
  598. _int_fields: list[tuple[str, int | None]] = [
  599. ("election_min_timeout", election_min_timeout),
  600. ("election_range_timeout", election_range_timeout),
  601. ("heartbeat_interval", heartbeat_interval),
  602. ("message_size_kb", message_size_kb),
  603. ("verification_timeout", verification_timeout),
  604. ("transaction_log_maximum_size_gb", transaction_log_maximum_size_gb),
  605. ("batch_update_interval", batch_update_interval),
  606. ]
  607. for field_name, value in _int_fields:
  608. if value is not None and type(value) is not int:
  609. raise TypeError(f"{field_name} must be an int if provided.")
  610. payload: dict[str, t.Any] = {}
  611. if election_min_timeout is not None:
  612. payload["electionMinTimeout"] = election_min_timeout
  613. if election_range_timeout is not None:
  614. payload["electionRangeTimeout"] = election_range_timeout
  615. if heartbeat_interval is not None:
  616. payload["heartbeatInterval"] = heartbeat_interval
  617. if message_size_kb is not None:
  618. payload["messageSizeKB"] = message_size_kb
  619. if verification_timeout is not None:
  620. payload["verificationTimeout"] = verification_timeout
  621. if transaction_log_maximum_size_gb is not None:
  622. payload["transactionLogMaximumSizeGB"] = transaction_log_maximum_size_gb
  623. if batch_update_interval is not None:
  624. payload["batchUpdateInterval"] = batch_update_interval
  625. headers = {"Accept": "application/json"}
  626. try:
  627. response = self.http_client.patch(
  628. "/rest/cluster/config", headers=headers, json=payload
  629. )
  630. response.raise_for_status()
  631. try:
  632. return ClusterRequest.from_dict(response.json())
  633. except (KeyError, TypeError, ValueError) as err:
  634. raise ResponseFormatError(
  635. f"Failed to parse updated cluster configuration: {err}"
  636. ) from err
  637. except httpx.HTTPStatusError as err:
  638. status = err.response.status_code
  639. if status == 400:
  640. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  641. elif status == 401:
  642. raise UnauthorisedError(
  643. f"Request is unauthorised: {err.response.text}"
  644. ) from err
  645. elif status == 403:
  646. raise ForbiddenError(
  647. f"Request is forbidden: {err.response.text}"
  648. ) from err
  649. elif status == 412:
  650. raise PreconditionFailedError(
  651. f"Precondition failed: {err.response.text}"
  652. ) from err
  653. raise
  654. def add_tag(self, tag: str) -> None:
  655. """Add primary cluster tag.
  656. Parameters:
  657. tag: The identifier tag to add to the primary cluster.
  658. Raises:
  659. TypeError: If tag is not a string.
  660. ValueError: If tag is an empty string.
  661. BadRequestError: If the request is invalid.
  662. UnauthorisedError: If the request is unauthorised.
  663. ForbiddenError: If the request is forbidden.
  664. PreconditionFailedError: If the cluster is not in a state to add a tag.
  665. """
  666. if not isinstance(tag, str):
  667. raise TypeError("tag must be a string")
  668. if not tag:
  669. raise ValueError("tag must be a non-empty string")
  670. try:
  671. response = self.http_client.post(
  672. "/rest/cluster/config/tag", json={"tag": tag}
  673. )
  674. response.raise_for_status()
  675. except httpx.HTTPStatusError as err:
  676. status = err.response.status_code
  677. if status == 400:
  678. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  679. elif status == 401:
  680. raise UnauthorisedError(
  681. f"Request is unauthorised: {err.response.text}"
  682. ) from err
  683. elif status == 403:
  684. raise ForbiddenError(
  685. f"Request is forbidden: {err.response.text}"
  686. ) from err
  687. elif status == 412:
  688. raise PreconditionFailedError(
  689. f"Precondition failed: {err.response.text}"
  690. ) from err
  691. raise
  692. def delete_tag(self, tag: str) -> None:
  693. """Remove primary cluster tag.
  694. Parameters:
  695. tag: The identifier tag to remove from the primary cluster.
  696. Raises:
  697. TypeError: If tag is not a string.
  698. ValueError: If tag is an empty string.
  699. BadRequestError: If the request is invalid.
  700. UnauthorisedError: If the request is unauthorised.
  701. ForbiddenError: If the request is forbidden.
  702. PreconditionFailedError: If the cluster is not in a state to delete a tag.
  703. """
  704. if not isinstance(tag, str):
  705. raise TypeError("tag must be a string")
  706. if not tag:
  707. raise ValueError("tag must be a non-empty string")
  708. headers = {"Content-Type": "application/json"}
  709. try:
  710. response = self.http_client.request(
  711. method="DELETE",
  712. url="/rest/cluster/config/tag",
  713. headers=headers,
  714. json={"tag": tag},
  715. )
  716. response.raise_for_status()
  717. except httpx.HTTPStatusError as err:
  718. status = err.response.status_code
  719. if status == 400:
  720. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  721. elif status == 401:
  722. raise UnauthorisedError(
  723. f"Request is unauthorised: {err.response.text}"
  724. ) from err
  725. elif status == 403:
  726. raise ForbiddenError(
  727. f"Request is forbidden: {err.response.text}"
  728. ) from err
  729. elif status == 412:
  730. raise PreconditionFailedError(
  731. f"Precondition failed: {err.response.text}"
  732. ) from err
  733. raise
  734. def enable_secondary_mode(self, primary_node: str, tag: str) -> None:
  735. """Enable cluster secondary mode.
  736. You can switch any healthy primary cluster to secondary mode by providing the RPC address
  737. of the primary cluster node that it will replicate and then declaring the tag used to identify
  738. it. Once you have switched a cluster to secondary mode, all of its current repositories and
  739. data will be removed and replaced with the data and state of the primary cluster.
  740. !!! Note
  741. You can enable the secondary cluster mode from any of the nodes of a cluster, not just the
  742. leader node. You can also use any of the healthy nodes of the primary cluster to connect to
  743. that cluster.
  744. !!! Warning
  745. Enabling the secondary mode will delete all data on the secondary cluster and replicate
  746. the state of the primary cluster.
  747. Parameters:
  748. primary_node: The RPC address of one of the healthy nodes of the primary cluster.
  749. tag: The identifier tag of the primary cluster.
  750. Raises:
  751. TypeError: If primary_node or tag is not a string.
  752. ValueError: If primary_node or tag is an empty string.
  753. BadRequestError: If the request is invalid.
  754. UnauthorisedError: If the request is unauthorised.
  755. ForbiddenError: If the request is forbidden.
  756. PreconditionFailedError: If the cluster is not in a state to enable secondary mode.
  757. """
  758. if not isinstance(primary_node, str):
  759. raise TypeError("primary_node must be a string")
  760. if not primary_node:
  761. raise ValueError("primary_node must be a non-empty string")
  762. if not isinstance(tag, str):
  763. raise TypeError("tag must be a string")
  764. if not tag:
  765. raise ValueError("tag must be a non-empty string")
  766. payload = {"primaryNode": primary_node, "tag": tag}
  767. try:
  768. response = self.http_client.post(
  769. "/rest/cluster/config/secondary-mode", json=payload
  770. )
  771. response.raise_for_status()
  772. except httpx.HTTPStatusError as err:
  773. status = err.response.status_code
  774. if status == 400:
  775. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  776. elif status == 401:
  777. raise UnauthorisedError(
  778. f"Request is unauthorised: {err.response.text}"
  779. ) from err
  780. elif status == 403:
  781. raise ForbiddenError(
  782. f"Request is forbidden: {err.response.text}"
  783. ) from err
  784. elif status == 412:
  785. raise PreconditionFailedError(
  786. f"Precondition failed: {err.response.text}"
  787. ) from err
  788. raise
  789. def disable_secondary_mode(self) -> None:
  790. """Disable cluster secondary mode.
  791. Once in secondary mode, you can disable secondary mode and then you will no longer be in
  792. that mode. Doing this will stop the pulling of updates from the primary cluster.
  793. Raises:
  794. BadRequestError: If the request is invalid.
  795. UnauthorisedError: If the request is unauthorised.
  796. ForbiddenError: If the request is forbidden.
  797. PreconditionFailedError: If the cluster is not in a state to disable secondary mode.
  798. """
  799. try:
  800. response = self.http_client.delete("/rest/cluster/config/secondary-mode")
  801. response.raise_for_status()
  802. except httpx.HTTPStatusError as err:
  803. status = err.response.status_code
  804. if status == 400:
  805. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  806. elif status == 401:
  807. raise UnauthorisedError(
  808. f"Request is unauthorised: {err.response.text}"
  809. ) from err
  810. elif status == 403:
  811. raise ForbiddenError(
  812. f"Request is forbidden: {err.response.text}"
  813. ) from err
  814. elif status == 412:
  815. raise PreconditionFailedError(
  816. f"Precondition failed: {err.response.text}"
  817. ) from err
  818. raise
  819. def add_nodes(self, nodes: list[str]) -> None:
  820. """Add nodes to the GraphDB cluster.
  821. Parameters:
  822. nodes: List of node addresses to add to the cluster.
  823. Raises:
  824. TypeError: If nodes is not a list of strings.
  825. BadRequestError: If the request is invalid.
  826. UnauthorisedError: If the request is unauthorised.
  827. ForbiddenError: If the request is forbidden.
  828. PreconditionFailedError: If one or more nodes in the group are not reachable.
  829. """
  830. if not isinstance(nodes, list) or any(
  831. not isinstance(node, str) for node in nodes
  832. ):
  833. raise TypeError("nodes must be a list[str].")
  834. payload = {"nodes": nodes}
  835. headers = {"Content-Type": "application/json"}
  836. try:
  837. response = self.http_client.post(
  838. "/rest/cluster/config/node", headers=headers, json=payload
  839. )
  840. response.raise_for_status()
  841. except httpx.HTTPStatusError as err:
  842. status = err.response.status_code
  843. if status == 400:
  844. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  845. elif status == 401:
  846. raise UnauthorisedError(
  847. f"Request is unauthorised: {err.response.text}"
  848. ) from err
  849. elif status == 403:
  850. raise ForbiddenError(
  851. f"Request is forbidden: {err.response.text}"
  852. ) from err
  853. elif status == 412:
  854. raise PreconditionFailedError(
  855. f"Precondition failed: {err.response.text}"
  856. ) from err
  857. raise
  858. def remove_nodes(self, nodes: list[str]) -> None:
  859. """Remove nodes from the GraphDB cluster.
  860. Parameters:
  861. nodes: List of node addresses to remove from the cluster.
  862. Raises:
  863. TypeError: If nodes is not a list of strings.
  864. BadRequestError: If the request is invalid.
  865. UnauthorisedError: If the request is unauthorised.
  866. ForbiddenError: If the request is forbidden.
  867. PreconditionFailedError: If one or more nodes in the group are not reachable.
  868. """
  869. if not isinstance(nodes, list) or any(
  870. not isinstance(node, str) for node in nodes
  871. ):
  872. raise TypeError("nodes must be a list[str].")
  873. payload = {"nodes": nodes}
  874. try:
  875. response = self.http_client.request(
  876. method="DELETE",
  877. url="/rest/cluster/config/node",
  878. json=payload,
  879. )
  880. response.raise_for_status()
  881. except httpx.HTTPStatusError as err:
  882. status = err.response.status_code
  883. if status == 400:
  884. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  885. elif status == 401:
  886. raise UnauthorisedError(
  887. f"Request is unauthorised: {err.response.text}"
  888. ) from err
  889. elif status == 403:
  890. raise ForbiddenError(
  891. f"Request is forbidden: {err.response.text}"
  892. ) from err
  893. elif status == 412:
  894. raise PreconditionFailedError(
  895. f"Precondition failed: {err.response.text}"
  896. ) from err
  897. raise
  898. def replace_nodes(self, add_nodes: list[str], remove_nodes: list[str]) -> None:
  899. """Replace nodes in the GraphDB cluster.
  900. Parameters:
  901. add_nodes: List of node addresses to add to the cluster.
  902. remove_nodes: List of node addresses to remove from the cluster.
  903. Raises:
  904. TypeError: If add_nodes or remove_nodes is not a list of strings.
  905. BadRequestError: If the request is invalid.
  906. UnauthorisedError: If the request is unauthorised.
  907. ForbiddenError: If the request is forbidden.
  908. PreconditionFailedError: If one or more nodes in the group are not reachable.
  909. """
  910. if not isinstance(add_nodes, list) or any(
  911. not isinstance(node, str) for node in add_nodes
  912. ):
  913. raise TypeError("add_nodes must be a list[str].")
  914. if not isinstance(remove_nodes, list) or any(
  915. not isinstance(node, str) for node in remove_nodes
  916. ):
  917. raise TypeError("remove_nodes must be a list[str].")
  918. payload = {"removeNodes": remove_nodes, "addNodes": add_nodes}
  919. try:
  920. response = self.http_client.patch("/rest/cluster/config/node", json=payload)
  921. response.raise_for_status()
  922. except httpx.HTTPStatusError as err:
  923. status = err.response.status_code
  924. if status == 400:
  925. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  926. elif status == 401:
  927. raise UnauthorisedError(
  928. f"Request is unauthorised: {err.response.text}"
  929. ) from err
  930. elif status == 403:
  931. raise ForbiddenError(
  932. f"Request is forbidden: {err.response.text}"
  933. ) from err
  934. elif status == 412:
  935. raise PreconditionFailedError(
  936. f"Precondition failed: {err.response.text}"
  937. ) from err
  938. raise
  939. def node_status(self) -> NodeStatus:
  940. """Get the status of this GraphDB cluster node.
  941. Returns:
  942. NodeStatus: The status of this GraphDB cluster node.
  943. Raises:
  944. ResponseFormatError: If the response cannot be parsed.
  945. BadRequestError: If the request is invalid.
  946. NotFoundError: If the node status is not found.
  947. InternalServerError: If the internal server error.
  948. """
  949. try:
  950. headers = {"Accept": "application/json"}
  951. response = self.http_client.get(
  952. "/rest/cluster/node/status", headers=headers
  953. )
  954. response.raise_for_status()
  955. try:
  956. return NodeStatus.from_dict(response.json())
  957. except (KeyError, TypeError, ValueError) as err:
  958. raise ResponseFormatError(
  959. f"Failed to parse node status: {err}"
  960. ) from err
  961. except httpx.HTTPStatusError as err:
  962. status = err.response.status_code
  963. if status == 400:
  964. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  965. elif status == 404:
  966. raise NotFoundError(
  967. f"Node status not found: {err.response.text}"
  968. ) from err
  969. elif status == 500:
  970. raise InternalServerError(
  971. f"Internal server error: {err.response.text}"
  972. ) from err
  973. raise
  974. def group_status(self) -> list[NodeStatus]:
  975. """Get the status of the GraphDB cluster.
  976. Returns:
  977. A list of node statuses for the cluster group.
  978. Raises:
  979. ResponseFormatError: If the response cannot be parsed.
  980. BadRequestError: If the request is invalid.
  981. NotFoundError: If the group status is not found.
  982. InternalServerError: If the internal server error.
  983. """
  984. try:
  985. headers = {"Accept": "application/json"}
  986. response = self.http_client.get(
  987. "/rest/cluster/group/status", headers=headers
  988. )
  989. response.raise_for_status()
  990. try:
  991. data = response.json()
  992. return [NodeStatus.from_dict(item) for item in data]
  993. except (KeyError, TypeError, ValueError) as err:
  994. raise ResponseFormatError(
  995. f"Failed to parse group status: {err}"
  996. ) from err
  997. except httpx.HTTPStatusError as err:
  998. status = err.response.status_code
  999. if status == 400:
  1000. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  1001. elif status == 404:
  1002. raise NotFoundError(
  1003. f"Group status not found: {err.response.text}"
  1004. ) from err
  1005. elif status == 500:
  1006. raise InternalServerError(
  1007. f"Internal server error: {err.response.text}"
  1008. ) from err
  1009. raise
  1010. class Repository(rdflib.contrib.rdf4j.client.Repository):
  1011. """GraphDB Repository client.
  1012. Overrides specific methods of the RDF4J Repository class and also provides GraphDB-only functionality
  1013. (such as FGAC ACL management) made available through the GraphDB REST API.
  1014. Parameters:
  1015. identifier: The identifier of the repository.
  1016. http_client: The httpx.Client instance.
  1017. """
  1018. def __init__(self, identifier: str, http_client: httpx.Client):
  1019. super().__init__(identifier, http_client)
  1020. self._access_control_list_management = AccessControlListManagement(
  1021. self.identifier, self.http_client
  1022. )
  1023. @property
  1024. def acl(self) -> AccessControlListManagement:
  1025. return self._access_control_list_management
  1026. def health(self, timeout: int = 5) -> bool:
  1027. """Repository health check.
  1028. Parameters:
  1029. timeout: A timeout parameter in seconds. If provided, the endpoint attempts
  1030. to retrieve the repository within this timeout. If not, the passive
  1031. check is performed.
  1032. Returns:
  1033. bool: True if the repository is healthy, otherwise an error is raised.
  1034. Raises:
  1035. RepositoryNotFoundError: If the repository is not found.
  1036. RepositoryNotHealthyError: If the repository is not healthy.
  1037. """
  1038. try:
  1039. params = {"passive": str(timeout)}
  1040. response = self.http_client.get(
  1041. f"/repositories/{self.identifier}/health", params=params
  1042. )
  1043. response.raise_for_status()
  1044. return True
  1045. except httpx.HTTPStatusError as err:
  1046. if err.response.status_code == 404:
  1047. raise RepositoryNotFoundError(
  1048. f"Repository {self._identifier} not found."
  1049. )
  1050. raise RepositoryNotHealthyError(
  1051. f"Repository {self._identifier} is not healthy. {err.response.status_code} - {err.response.text}"
  1052. )
  1053. def get_server_import_files(self) -> list[ImportSettings]:
  1054. """Get server files available for import.
  1055. Returns:
  1056. A list of files available for import.
  1057. Raises:
  1058. ResponseFormatError: If the response format is invalid.
  1059. UnauthorisedError: If the request is unauthorised.
  1060. ForbiddenError: If the request is forbidden.
  1061. InternalServerError: If the server returns an internal error.
  1062. """
  1063. try:
  1064. headers = {"Accept": "application/json"}
  1065. response = self.http_client.get(
  1066. f"/rest/repositories/{self.identifier}/import/server",
  1067. headers=headers,
  1068. )
  1069. response.raise_for_status()
  1070. try:
  1071. result = []
  1072. for item in response.json():
  1073. # Convert nested parserSettings dict to ParserSettings instance
  1074. if "parserSettings" in item and isinstance(
  1075. item["parserSettings"], dict
  1076. ):
  1077. item["parserSettings"] = ParserSettings(
  1078. **item["parserSettings"]
  1079. )
  1080. result.append(ImportSettings(**item))
  1081. return result
  1082. except (ValueError, TypeError) as err:
  1083. raise ResponseFormatError(f"Failed to parse response: {err}") from err
  1084. except httpx.HTTPStatusError as err:
  1085. if err.response.status_code == 401:
  1086. raise UnauthorisedError(
  1087. f"Request is unauthorised: {err.response.text}"
  1088. ) from err
  1089. elif err.response.status_code == 403:
  1090. raise ForbiddenError(
  1091. f"Request is forbidden: {err.response.text}"
  1092. ) from err
  1093. elif err.response.status_code == 500:
  1094. raise InternalServerError(
  1095. f"Internal server error: {err.response.text}"
  1096. ) from err
  1097. raise
  1098. def import_server_import_file(self, server_import_body: ServerImportBody) -> None:
  1099. """Import a server file into the repository.
  1100. Parameters:
  1101. server_import_body: The server import body.
  1102. Raises:
  1103. BadRequestError: If the request is bad.
  1104. UnauthorisedError: If the request is unauthorised.
  1105. ForbiddenError: If the request is forbidden.
  1106. NotFoundError: If the request is not found.
  1107. """
  1108. try:
  1109. headers = {"Content-Type": "application/json"}
  1110. response = self.http_client.post(
  1111. f"/rest/repositories/{self.identifier}/import/server",
  1112. headers=headers,
  1113. json=server_import_body.as_dict(),
  1114. )
  1115. response.raise_for_status()
  1116. except httpx.HTTPStatusError as err:
  1117. status = err.response.status_code
  1118. if status == 400:
  1119. raise BadRequestError(f"Bad request: {err.response.text}") from err
  1120. elif status == 401:
  1121. raise UnauthorisedError(
  1122. f"Request is unauthorised: {err.response.text}"
  1123. ) from err
  1124. elif status == 403:
  1125. raise ForbiddenError(
  1126. f"Request is forbidden: {err.response.text}"
  1127. ) from err
  1128. elif status == 404:
  1129. raise NotFoundError(
  1130. f"Request is not found: {err.response.text}"
  1131. ) from err
  1132. raise
  1133. def cancel_server_import_file(self, name: str) -> None:
  1134. """Cancel server file import operation.
  1135. Parameters:
  1136. name: The name of the file import to interrupt.
  1137. Raises:
  1138. BadRequestError: If the request is bad.
  1139. UnauthorisedError: If the request is unauthorised.
  1140. ForbiddenError: If the request is forbidden.
  1141. """
  1142. try:
  1143. params = {"name": name}
  1144. response = self.http_client.delete(
  1145. f"/rest/repositories/{self.identifier}/import/server",
  1146. params=params,
  1147. )
  1148. response.raise_for_status()
  1149. except httpx.HTTPStatusError as err:
  1150. status = err.response.status_code
  1151. if status == 400:
  1152. raise BadRequestError(f"Bad request: {err.response.text}") from err
  1153. elif status == 401:
  1154. raise UnauthorisedError(
  1155. f"Request is unauthorised: {err.response.text}"
  1156. ) from err
  1157. elif status == 403:
  1158. raise ForbiddenError(
  1159. f"Request is forbidden: {err.response.text}"
  1160. ) from err
  1161. raise
  1162. class RepositoryManager(rdflib.contrib.rdf4j.client.RepositoryManager):
  1163. """GraphDB Repository Manager.
  1164. This manager client overrides specific RDF4J RepositoryManager class methods to provide GraphDB-specific implementations.
  1165. """
  1166. def get(self, repository_id: str) -> Repository:
  1167. """Get a repository by ID.
  1168. !!! Note
  1169. This performs a health check before returning the repository object.
  1170. Parameters:
  1171. repository_id: The identifier of the repository.
  1172. Returns:
  1173. Repository: The repository instance.
  1174. Raises:
  1175. RepositoryNotFoundError: If the repository is not found.
  1176. RepositoryNotHealthyError: If the repository is not healthy.
  1177. """
  1178. _repo = super().get(repository_id)
  1179. return Repository(_repo.identifier, _repo.http_client)
  1180. class MonitoringManagement:
  1181. """
  1182. Monitor different GraphDB processes.
  1183. """
  1184. def __init__(self, http_client: httpx.Client):
  1185. self._http_client = http_client
  1186. @property
  1187. def http_client(self):
  1188. return self._http_client
  1189. @property
  1190. def structures(self) -> StructuresStatistics:
  1191. """Get structures statistics.
  1192. Returns:
  1193. The structures statistics.
  1194. Raises:
  1195. ResponseFormatError: If the response format is invalid.
  1196. UnauthorisedError: If the request is unauthorised.
  1197. ForbiddenError: If the request is forbidden.
  1198. InternalServerError: If the server returns an internal error.
  1199. ServiceUnavailableError: If the server is unavailable.
  1200. """
  1201. try:
  1202. headers = {"Accept": "application/json"}
  1203. response = self.http_client.get("/rest/monitor/structures", headers=headers)
  1204. response.raise_for_status()
  1205. try:
  1206. return StructuresStatistics(**response.json())
  1207. except (ValueError, TypeError) as err:
  1208. raise ResponseFormatError(f"Failed to parse response: {err}") from err
  1209. except httpx.HTTPStatusError as err:
  1210. status = err.response.status_code
  1211. if status == 401:
  1212. raise UnauthorisedError(
  1213. f"Request is unauthorised: {err.response.text}"
  1214. ) from err
  1215. elif status == 403:
  1216. raise ForbiddenError(
  1217. f"Request is forbidden: {err.response.text}"
  1218. ) from err
  1219. elif status == 500:
  1220. raise InternalServerError(
  1221. f"Internal server error: {err.response.text}"
  1222. ) from err
  1223. elif status == 503:
  1224. raise ServiceUnavailableError(
  1225. f"Service is unavailable: {err.response.text}"
  1226. ) from err
  1227. raise
  1228. def get_repo_stats(self, repository_id: str):
  1229. """Get repository statistics.
  1230. Parameters:
  1231. repository_id: The identifier of the repository.
  1232. Returns:
  1233. RepositoryStatistics: The repository statistics.
  1234. Raises:
  1235. ResponseFormatError: If the response cannot be parsed.
  1236. UnauthorisedError: If the request is unauthorised.
  1237. ForbiddenError: If the request is forbidden.
  1238. InternalServerError: If the server returns an internal error.
  1239. """
  1240. try:
  1241. headers = {"Accept": "application/json"}
  1242. response = self.http_client.get(
  1243. f"/rest/monitor/repository/{repository_id}", headers=headers
  1244. )
  1245. response.raise_for_status()
  1246. try:
  1247. return RepositoryStatistics.from_dict(response.json())
  1248. except (ValueError, TypeError, KeyError) as err:
  1249. raise ResponseFormatError(f"Failed to parse response: {err}") from err
  1250. except httpx.HTTPStatusError as err:
  1251. status = err.response.status_code
  1252. if status == 401:
  1253. raise UnauthorisedError(
  1254. f"Request is unauthorised: {err.response.text}"
  1255. ) from err
  1256. elif status == 403:
  1257. raise ForbiddenError(
  1258. f"Request is forbidden: {err.response.text}"
  1259. ) from err
  1260. elif status == 500:
  1261. raise InternalServerError(
  1262. f"Internal server error: {err.response.text}"
  1263. ) from err
  1264. raise
  1265. @property
  1266. def infrastructure(self) -> InfrastructureStatistics:
  1267. """Get all infrastructure statistics.
  1268. Returns:
  1269. InfrastructureStatistics: The infrastructure statistics.
  1270. Raises:
  1271. ResponseFormatError: If the response cannot be parsed.
  1272. UnauthorisedError: If the request is unauthorised.
  1273. ForbiddenError: If the request is forbidden.
  1274. InternalServerError: If the server returns an internal error.
  1275. """
  1276. try:
  1277. headers = {"Accept": "application/json"}
  1278. response = self.http_client.get(
  1279. "/rest/monitor/infrastructure", headers=headers
  1280. )
  1281. response.raise_for_status()
  1282. try:
  1283. return InfrastructureStatistics.from_dict(response.json())
  1284. except (ValueError, TypeError, KeyError) as err:
  1285. raise ResponseFormatError(f"Failed to parse response: {err}") from err
  1286. except httpx.HTTPStatusError as err:
  1287. status = err.response.status_code
  1288. if status == 401:
  1289. raise UnauthorisedError(
  1290. f"Request is unauthorised: {err.response.text}"
  1291. ) from err
  1292. elif status == 403:
  1293. raise ForbiddenError(
  1294. f"Request is forbidden: {err.response.text}"
  1295. ) from err
  1296. elif status == 500:
  1297. raise InternalServerError(
  1298. f"Internal server error: {err.response.text}"
  1299. ) from err
  1300. raise
  1301. def cluster(self) -> str:
  1302. """Get cluster statistics.
  1303. !!! Note
  1304. This endpoint is only available in GraphDB EE.
  1305. Returns:
  1306. Prometheus-style metrics.
  1307. Raises:
  1308. UnauthorisedError: If the request is unauthorised.
  1309. ForbiddenError: If the request is forbidden.
  1310. InternalServerError: If the server returns an internal error.
  1311. ServiceUnavailableError: If the server is unavailable.
  1312. """
  1313. try:
  1314. headers = {"Accept": "text/plain"}
  1315. response = self.http_client.get("/rest/monitor/cluster", headers=headers)
  1316. response.raise_for_status()
  1317. return response.text
  1318. except httpx.HTTPStatusError as err:
  1319. status = err.response.status_code
  1320. if status == 401:
  1321. raise UnauthorisedError(
  1322. f"Request is unauthorised: {err.response.text}"
  1323. ) from err
  1324. elif status == 403:
  1325. raise ForbiddenError(
  1326. f"Request is forbidden: {err.response.text}"
  1327. ) from err
  1328. elif status == 500:
  1329. raise InternalServerError(
  1330. f"Internal server error: {err.response.text}"
  1331. ) from err
  1332. elif status == 503:
  1333. raise ServiceUnavailableError(
  1334. f"Service is unavailable: {err.response.text}"
  1335. ) from err
  1336. raise
  1337. def backup(self) -> BackupOperationBean | None:
  1338. """Track backup operations.
  1339. Returns:
  1340. On-going backup operations or `None` if no backup is in progress.
  1341. Raises:
  1342. ResponseFormatError: If the response cannot be parsed.
  1343. UnauthorisedError: If the request is unauthorised.
  1344. ForbiddenError: If the request is forbidden.
  1345. InternalServerError: If the server returns an internal error.
  1346. ServiceUnavailableError: If the server is unavailable.
  1347. """
  1348. try:
  1349. headers = {"Accept": "application/json"}
  1350. response = self.http_client.get("/rest/monitor/backup", headers=headers)
  1351. response.raise_for_status()
  1352. try:
  1353. data = response.json()
  1354. if not data:
  1355. return None
  1356. return BackupOperationBean.from_dict(data)
  1357. except (ValueError, TypeError, KeyError) as err:
  1358. raise ResponseFormatError(f"Failed to parse response: {err}") from err
  1359. except httpx.HTTPStatusError as err:
  1360. status = err.response.status_code
  1361. if status == 401:
  1362. raise UnauthorisedError(
  1363. f"Request is unauthorised: {err.response.text}"
  1364. ) from err
  1365. elif status == 403:
  1366. raise ForbiddenError(
  1367. f"Request is forbidden: {err.response.text}"
  1368. ) from err
  1369. elif status == 500:
  1370. raise InternalServerError(
  1371. f"Internal server error: {err.response.text}"
  1372. ) from err
  1373. elif status == 503:
  1374. raise ServiceUnavailableError(
  1375. f"Service is unavailable: {err.response.text}"
  1376. ) from err
  1377. raise
  1378. class RecoveryManagement:
  1379. """
  1380. GraphDB Recovery Management client.
  1381. """
  1382. def __init__(self, http_client: httpx.Client):
  1383. self._http_client = http_client
  1384. @property
  1385. def http_client(self):
  1386. return self._http_client
  1387. def restore(
  1388. self,
  1389. backup: bytes | bytearray | memoryview | t.IO[bytes] | str | os.PathLike[str],
  1390. repositories: list[str] | None = None,
  1391. restore_system_data: bool | None = None,
  1392. remove_stale_repositories: bool | None = None,
  1393. ) -> None:
  1394. """Restore GraphDB instance from a backup archive.
  1395. Parameters:
  1396. backup: Backup archive content as bytes/bytes-like, a binary file-like
  1397. object, or a filesystem path to a `.tar` produced by
  1398. [`backup`][rdflib.contrib.graphdb.client.RecoveryManagement.backup].
  1399. repositories: List of repositories to restore. If `None` (default), the
  1400. parameter is omitted and all repositories found in the backup are
  1401. restored. If an empty list (`[]`) is provided, no repositories from
  1402. the backup are restored.
  1403. restore_system_data: Whether to restore system data (users, saved queries,
  1404. visual graphs, etc.). If omitted, GraphDB defaults to `false`. If
  1405. `true` and no system data exists in the backup, GraphDB returns an
  1406. error.
  1407. remove_stale_repositories: Whether to remove repositories on the target
  1408. instance that are not restored from the backup. If omitted, GraphDB
  1409. defaults to `false`.
  1410. Raises:
  1411. ValueError: If parameters are invalid, or if backup is not a supported type.
  1412. BadRequestError: If the request is bad.
  1413. UnauthorisedError: If the request is unauthorised.
  1414. ForbiddenError: If the request is forbidden.
  1415. """
  1416. if repositories is not None:
  1417. if not isinstance(repositories, list):
  1418. raise ValueError("repositories must be a list or None")
  1419. if not all(isinstance(r, str) for r in repositories):
  1420. raise ValueError("repositories must be a list of strings")
  1421. if (
  1422. not isinstance(restore_system_data, bool)
  1423. and restore_system_data is not None
  1424. ):
  1425. raise ValueError("restore_system_data must be a bool or None")
  1426. if (
  1427. not isinstance(remove_stale_repositories, bool)
  1428. and remove_stale_repositories is not None
  1429. ):
  1430. raise ValueError("remove_stale_repositories must be a bool or None")
  1431. payload: dict[str, t.Any] = {}
  1432. if repositories is not None:
  1433. payload["repositories"] = repositories
  1434. if restore_system_data is not None:
  1435. payload["restoreSystemData"] = restore_system_data
  1436. if remove_stale_repositories is not None:
  1437. payload["removeStaleRepositories"] = remove_stale_repositories
  1438. params_part: FileContent = (None, json.dumps(payload), "application/json")
  1439. def _post(file_part: FileContent) -> None:
  1440. try:
  1441. response = self.http_client.post(
  1442. "/rest/recovery/restore",
  1443. files=[("params", params_part), ("file", file_part)],
  1444. )
  1445. response.raise_for_status()
  1446. except httpx.HTTPStatusError as err:
  1447. status = err.response.status_code
  1448. if status == 400:
  1449. raise BadRequestError(f"Bad request: {err.response.text}") from err
  1450. elif status == 401:
  1451. raise UnauthorisedError(
  1452. f"Request is unauthorised: {err.response.text}"
  1453. ) from err
  1454. elif status == 403:
  1455. raise ForbiddenError(
  1456. f"Request is forbidden: {err.response.text}"
  1457. ) from err
  1458. raise err
  1459. if isinstance(backup, (bytes, bytearray, memoryview)):
  1460. content = bytes(backup)
  1461. _post(("graphdb-backup.tar", content, "application/x-tar"))
  1462. return
  1463. if hasattr(backup, "read") and not isinstance(backup, (str, os.PathLike)):
  1464. file_obj = t.cast(t.IO[bytes], backup)
  1465. filename = infer_filename_from_fileobj(file_obj, "graphdb-backup.tar")
  1466. _post((filename, file_obj, "application/x-tar"))
  1467. return
  1468. if isinstance(backup, (str, os.PathLike)):
  1469. backup_path = pathlib.Path(backup).expanduser()
  1470. with open(backup_path, "rb") as f:
  1471. filename = backup_path.name or infer_filename_from_fileobj(
  1472. f, "graphdb-backup.tar"
  1473. )
  1474. _post((filename, f, "application/x-tar"))
  1475. return
  1476. raise ValueError(
  1477. "backup must be bytes/bytes-like, a binary file-like object, or a filesystem path"
  1478. )
  1479. def cloud_restore(
  1480. self,
  1481. bucket_uri: str,
  1482. repositories: list[str] | None = None,
  1483. restore_system_data: bool | None = None,
  1484. remove_stale_repositories: bool | None = None,
  1485. authentication_file: (
  1486. bytes | bytearray | memoryview | t.IO[bytes] | str | os.PathLike[str] | None
  1487. ) = None,
  1488. ) -> None:
  1489. """Restore GraphDB instance from a cloud backup archive.
  1490. Parameters:
  1491. bucket_uri: Cloud bucket URI including provider-specific parameters (required).
  1492. repositories: List of repositories to restore. If `None` (default), the
  1493. parameter is omitted and all repositories found in the backup are
  1494. restored. If an empty list (`[]`) is provided, no repositories from
  1495. the backup are restored.
  1496. restore_system_data: Whether to restore system data (users, saved queries,
  1497. visual graphs, etc.). If omitted, GraphDB defaults to `false`. If
  1498. `true` and no system data exists in the backup, GraphDB returns an
  1499. error.
  1500. remove_stale_repositories: Whether to remove repositories on the target
  1501. instance that are not restored from the backup. If omitted, GraphDB
  1502. defaults to `false`.
  1503. authentication_file: Optional credential file content as bytes/bytes-like, a
  1504. binary file-like object, or a filesystem path.
  1505. Raises:
  1506. ValueError: If parameters are invalid, or authentication_file is not a supported type.
  1507. BadRequestError: If the request is bad.
  1508. UnauthorisedError: If the request is unauthorised.
  1509. ForbiddenError: If the request is forbidden.
  1510. """
  1511. if not isinstance(bucket_uri, str) or not bucket_uri:
  1512. raise ValueError("bucket_uri must be a non-empty string")
  1513. if repositories is not None:
  1514. if not isinstance(repositories, list):
  1515. raise ValueError("repositories must be a list or None")
  1516. if not all(isinstance(r, str) for r in repositories):
  1517. raise ValueError("repositories must be a list of strings")
  1518. if (
  1519. not isinstance(restore_system_data, bool)
  1520. and restore_system_data is not None
  1521. ):
  1522. raise ValueError("restore_system_data must be a bool or None")
  1523. if (
  1524. not isinstance(remove_stale_repositories, bool)
  1525. and remove_stale_repositories is not None
  1526. ):
  1527. raise ValueError("remove_stale_repositories must be a bool or None")
  1528. payload: dict[str, t.Any] = {}
  1529. if repositories is not None:
  1530. payload["repositories"] = repositories
  1531. if restore_system_data is not None:
  1532. payload["restoreSystemData"] = restore_system_data
  1533. if remove_stale_repositories is not None:
  1534. payload["removeStaleRepositories"] = remove_stale_repositories
  1535. payload["bucketUri"] = bucket_uri
  1536. params_part: FileContent = (None, json.dumps(payload), "application/json")
  1537. def _post(authentication_part: FileContent | None) -> None:
  1538. files: list[tuple[str, FileContent]] = [("params", params_part)]
  1539. if authentication_part is not None:
  1540. files.append(("authenticationFile", authentication_part))
  1541. try:
  1542. response = self.http_client.post(
  1543. "/rest/recovery/cloud-restore",
  1544. headers={"Accept": "application/json"},
  1545. files=files,
  1546. )
  1547. response.raise_for_status()
  1548. except httpx.HTTPStatusError as err:
  1549. status = err.response.status_code
  1550. if status == 400:
  1551. raise BadRequestError(f"Bad request: {err.response.text}") from err
  1552. elif status == 401:
  1553. raise UnauthorisedError(
  1554. f"Request is unauthorised: {err.response.text}"
  1555. ) from err
  1556. elif status == 403:
  1557. raise ForbiddenError(
  1558. f"Request is forbidden: {err.response.text}"
  1559. ) from err
  1560. raise err
  1561. if authentication_file is None:
  1562. _post(None)
  1563. return
  1564. if isinstance(authentication_file, (bytes, bytearray, memoryview)):
  1565. content = bytes(authentication_file)
  1566. _post(("authentication-file", content, "application/octet-stream"))
  1567. return
  1568. if hasattr(authentication_file, "read") and not isinstance(
  1569. authentication_file, (str, os.PathLike)
  1570. ):
  1571. file_obj = t.cast(t.IO[bytes], authentication_file)
  1572. filename = infer_filename_from_fileobj(file_obj, "authentication-file")
  1573. _post((filename, file_obj, "application/octet-stream"))
  1574. return
  1575. if isinstance(authentication_file, (str, os.PathLike)):
  1576. auth_path = pathlib.Path(authentication_file).expanduser()
  1577. with open(auth_path, "rb") as f:
  1578. filename = auth_path.name or infer_filename_from_fileobj(
  1579. f, "authentication-file"
  1580. )
  1581. _post((filename, f, "application/octet-stream"))
  1582. return
  1583. raise ValueError(
  1584. "authentication_file must be bytes/bytes-like, a binary file-like object, or a filesystem path"
  1585. )
  1586. @t.overload
  1587. def backup(
  1588. self,
  1589. repositories: list[str] | None = None,
  1590. backup_system_data: bool | None = None,
  1591. *,
  1592. dest: None = None,
  1593. ) -> bytes: ...
  1594. @t.overload
  1595. def backup(
  1596. self,
  1597. repositories: list[str] | None = None,
  1598. backup_system_data: bool | None = None,
  1599. *,
  1600. dest: str | os.PathLike[str],
  1601. ) -> pathlib.Path: ...
  1602. def backup(
  1603. self,
  1604. repositories: list[str] | None = None,
  1605. backup_system_data: bool | None = None,
  1606. *,
  1607. dest: str | os.PathLike[str] | None = None,
  1608. ) -> bytes | pathlib.Path:
  1609. """Create a new backup of GraphDB instance.
  1610. The backup is returned as a tar archive containing repository data and
  1611. optionally system data (user accounts, saved queries, visual graphs, etc.).
  1612. Parameters:
  1613. repositories: List of repositories to be backed up, specified by their repository identifiers.
  1614. If `None`, all repositories will be included in the backup.
  1615. backup_system_data: Determines whether user account data such as user accounts, saved queries,
  1616. or visual graphs, among others, should be included in the backup.
  1617. dest: Destination path to save the backup archive. If `None`, the backup
  1618. content is returned as bytes (suitable for small backups). If a directory
  1619. path is provided, the filename from the response Content-Disposition header
  1620. is used (e.g., backup-2026-01-16-10-30-00.tar). If a file path is provided,
  1621. that exact path is used. The response is streamed directly to disk
  1622. (recommended for large backups).
  1623. Returns:
  1624. bytes: If `dest` is `None`, returns the backup archive as bytes.
  1625. Path: If `dest` is provided, returns the resolved `pathlib.Path` where the backup was saved.
  1626. Raises:
  1627. ValueError: If repositories is not a list or None, or if backup_system_data is not a bool or None.
  1628. BadRequestError: If the request is bad.
  1629. UnauthorisedError: If the request is unauthorised.
  1630. ForbiddenError: If the request is forbidden.
  1631. OSError: If the destination path cannot be written to.
  1632. """
  1633. if repositories is not None:
  1634. if not isinstance(repositories, list):
  1635. raise ValueError("repositories must be a list or None")
  1636. if not all(isinstance(r, str) for r in repositories):
  1637. raise ValueError("repositories must be a list of strings")
  1638. if not isinstance(backup_system_data, bool) and backup_system_data is not None:
  1639. raise ValueError("backup_system_data must be a bool")
  1640. payload: dict[str, t.Any] = {}
  1641. if repositories is not None:
  1642. payload["repositories"] = repositories
  1643. if backup_system_data is not None:
  1644. payload["backupSystemData"] = backup_system_data
  1645. def _handle_http_error(err: httpx.HTTPStatusError) -> t.NoReturn:
  1646. status = err.response.status_code
  1647. if status == 400:
  1648. raise BadRequestError(f"Bad request: {err.response.text}") from err
  1649. elif status == 401:
  1650. raise UnauthorisedError(
  1651. f"Request is unauthorised: {err.response.text}"
  1652. ) from err
  1653. elif status == 403:
  1654. raise ForbiddenError(
  1655. f"Request is forbidden: {err.response.text}"
  1656. ) from err
  1657. raise err
  1658. if dest is None:
  1659. # Return bytes directly (suitable for small backups)
  1660. try:
  1661. response = self.http_client.post("/rest/recovery/backup", json=payload)
  1662. response.raise_for_status()
  1663. return response.content
  1664. except httpx.HTTPStatusError as err:
  1665. _handle_http_error(err)
  1666. else:
  1667. # Stream to disk (recommended for large backups)
  1668. dest_path = pathlib.Path(dest).expanduser().resolve()
  1669. try:
  1670. with self.http_client.stream(
  1671. "POST", "/rest/recovery/backup", json=payload
  1672. ) as response:
  1673. response.raise_for_status()
  1674. # If dest is a directory, extract filename from Content-Disposition
  1675. if dest_path.is_dir():
  1676. content_disposition = response.headers.get(
  1677. "Content-Disposition", ""
  1678. )
  1679. filename = extract_filename_from_content_disposition(
  1680. content_disposition
  1681. )
  1682. if filename:
  1683. dest_path = dest_path / filename
  1684. else:
  1685. # Fallback to a default name if header parsing fails
  1686. dest_path = dest_path / "graphdb-backup.tar"
  1687. # Use a temporary file during download, then rename atomically
  1688. tmp_path = dest_path.with_suffix(dest_path.suffix + ".partial")
  1689. with open(tmp_path, "wb") as f:
  1690. for chunk in response.iter_bytes():
  1691. f.write(chunk)
  1692. tmp_path.replace(dest_path)
  1693. return dest_path
  1694. except httpx.HTTPStatusError as err:
  1695. _handle_http_error(err)
  1696. def cloud_backup(
  1697. self,
  1698. bucket_uri: str,
  1699. repositories: list[str] | None = None,
  1700. backup_system_data: bool | None = None,
  1701. authentication_file: (
  1702. bytes | bytearray | memoryview | t.IO[bytes] | str | os.PathLike[str] | None
  1703. ) = None,
  1704. ) -> None:
  1705. """Create a new backup of GraphDB instance uploaded to a cloud bucket.
  1706. GraphDB uploads the resulting `.tar` archive directly to the configured
  1707. cloud bucket. This uses the GraphDB endpoint `/rest/recovery/cloud-backup`.
  1708. Parameters:
  1709. bucket_uri: Cloud bucket URI including provider-specific parameters (required).
  1710. repositories: Optional list of repositories to be backed up.
  1711. backup_system_data: Optional flag to include system data (users, saved queries, etc.).
  1712. authentication_file: Optional credential file content as bytes/bytes-like, a
  1713. binary file-like object, or a filesystem path.
  1714. Raises:
  1715. ValueError: If parameters are invalid, or authentication_file is not a supported type.
  1716. BadRequestError: If the request is bad.
  1717. UnauthorisedError: If the request is unauthorised.
  1718. ForbiddenError: If the request is forbidden.
  1719. """
  1720. if not isinstance(bucket_uri, str) or not bucket_uri:
  1721. raise ValueError("bucket_uri must be a non-empty string")
  1722. if repositories is not None:
  1723. if not isinstance(repositories, list):
  1724. raise ValueError("repositories must be a list or None")
  1725. if not all(isinstance(r, str) for r in repositories):
  1726. raise ValueError("repositories must be a list of strings")
  1727. if not isinstance(backup_system_data, bool) and backup_system_data is not None:
  1728. raise ValueError("backup_system_data must be a bool")
  1729. payload: dict[str, t.Any] = {}
  1730. if repositories is not None:
  1731. payload["repositories"] = repositories
  1732. if backup_system_data is not None:
  1733. payload["backupSystemData"] = backup_system_data
  1734. payload["bucketUri"] = bucket_uri
  1735. params_part: FileContent = (None, json.dumps(payload), "application/json")
  1736. def _post(authentication_part: FileContent | None) -> None:
  1737. files: list[tuple[str, FileContent]] = [("params", params_part)]
  1738. if authentication_part is not None:
  1739. files.append(("authenticationFile", authentication_part))
  1740. try:
  1741. response = self.http_client.post(
  1742. "/rest/recovery/cloud-backup",
  1743. headers={"Accept": "application/json"},
  1744. files=files,
  1745. )
  1746. response.raise_for_status()
  1747. except httpx.HTTPStatusError as err:
  1748. status = err.response.status_code
  1749. if status == 400:
  1750. raise BadRequestError(f"Bad request: {err.response.text}") from err
  1751. elif status == 401:
  1752. raise UnauthorisedError(
  1753. f"Request is unauthorised: {err.response.text}"
  1754. ) from err
  1755. elif status == 403:
  1756. raise ForbiddenError(
  1757. f"Request is forbidden: {err.response.text}"
  1758. ) from err
  1759. raise err
  1760. if authentication_file is None:
  1761. _post(None)
  1762. return
  1763. if isinstance(authentication_file, (bytes, bytearray, memoryview)):
  1764. content = bytes(authentication_file)
  1765. _post(("authentication-file", content, "application/octet-stream"))
  1766. return
  1767. if hasattr(authentication_file, "read") and not isinstance(
  1768. authentication_file, (str, os.PathLike)
  1769. ):
  1770. file_obj = t.cast(t.IO[bytes], authentication_file)
  1771. filename = infer_filename_from_fileobj(file_obj, "authentication-file")
  1772. _post((filename, file_obj, "application/octet-stream"))
  1773. return
  1774. if isinstance(authentication_file, (str, os.PathLike)):
  1775. auth_path = pathlib.Path(authentication_file).expanduser()
  1776. with open(auth_path, "rb") as f:
  1777. filename = auth_path.name or infer_filename_from_fileobj(
  1778. f, "authentication-file"
  1779. )
  1780. _post((filename, f, "application/octet-stream"))
  1781. return
  1782. raise ValueError(
  1783. "authentication_file must be bytes/bytes-like, a binary file-like object, or a filesystem path"
  1784. )
  1785. class RepositoryManagement:
  1786. """GraphDB Repository Management client.
  1787. The functionality provided by this management client accepts an optional location parameter to operate on external
  1788. GraphDB locations.
  1789. """
  1790. def __init__(self, http_client: httpx.Client):
  1791. self._http_client = http_client
  1792. def list(self, location: str | None = None) -> list[GraphDBRepository]:
  1793. """List all repositories.
  1794. Parameters:
  1795. location: The location of the repositories.
  1796. Returns:
  1797. list[GraphDBRepository]: List of GraphDB repositories.
  1798. Raises:
  1799. InternalServerError: If the server returns an internal error.
  1800. ResponseFormatError: If the response cannot be parsed.
  1801. """
  1802. params = {}
  1803. if location is not None:
  1804. params["location"] = location
  1805. try:
  1806. response = self.http_client.get("/rest/repositories", params=params)
  1807. response.raise_for_status()
  1808. try:
  1809. return [GraphDBRepository.from_dict(repo) for repo in response.json()]
  1810. except (ValueError, TypeError) as err:
  1811. raise ResponseFormatError(
  1812. f"Failed to parse GraphDB response: {err}"
  1813. ) from err
  1814. except httpx.HTTPStatusError as err:
  1815. if err.response.status_code == 500:
  1816. raise InternalServerError(
  1817. f"Internal server error: {err.response.text}"
  1818. ) from err
  1819. raise
  1820. def create(
  1821. self,
  1822. config: RepositoryConfigBeanCreate | str,
  1823. location: str | None = None,
  1824. files: FilesType | None = None,
  1825. ):
  1826. """Create a new repository.
  1827. Parameters:
  1828. config: Repository configuration. When a `RepositoryConfigBeanCreate` is
  1829. provided, the request is sent as JSON. When a string is provided, it
  1830. is treated as Turtle content and sent as multipart/form-data part
  1831. `config` (required by GraphDB) with the content type `text/turtle`.
  1832. location: Optional repository location (query param `location`).
  1833. files: Optional extra multipart parts for GraphDB-specific files (e.g.
  1834. `obdaFile`, `owlFile`, `propertiesFile`, `constraintFile`,
  1835. `dbMetadataFile`, `lensesFile`). Keys must be the form part names;
  1836. values may be file content or httpx-style file tuples. Ignored when
  1837. `config` is a dataclass (JSON payload).
  1838. Raises:
  1839. BadRequestError: If the request is invalid.
  1840. UnauthorisedError: If the request is unauthorised.
  1841. ForbiddenError: If the request is forbidden.
  1842. InternalServerError: If the server returns an internal error.
  1843. """
  1844. params = {}
  1845. if location is not None:
  1846. params["location"] = location
  1847. def _normalize_file_content(field: str, value: FileContent) -> FileContent:
  1848. if isinstance(value, tuple):
  1849. # fill in the missing filename with the field name
  1850. if len(value) == 2:
  1851. filename, content = value
  1852. return (filename or field, content)
  1853. filename, content, content_type = value
  1854. return (filename or field, content, content_type)
  1855. return (field, value)
  1856. try:
  1857. if isinstance(config, str):
  1858. extra_parts: list[tuple[str, FileContent]] = []
  1859. if files is not None:
  1860. if isinstance(files, t.Mapping):
  1861. extra_parts = list(files.items())
  1862. else:
  1863. extra_parts = list(files)
  1864. if any(field == "config" for field, _ in extra_parts):
  1865. raise ValueError(
  1866. "Do not pass a 'config' multipart part via files; use the config argument instead."
  1867. )
  1868. multipart_files: list[tuple[str, FileContent]] = [
  1869. ("config", ("config.ttl", config, "text/turtle"))
  1870. ]
  1871. for field, content in extra_parts:
  1872. multipart_files.append(
  1873. (field, _normalize_file_content(field, content))
  1874. )
  1875. response = self.http_client.post(
  1876. "/rest/repositories",
  1877. params=params,
  1878. files=multipart_files,
  1879. )
  1880. else:
  1881. if files is not None:
  1882. raise ValueError(
  1883. "Additional files can only be provided when config is a Turtle string."
  1884. )
  1885. response = self.http_client.post(
  1886. "/rest/repositories",
  1887. headers={"Content-Type": "application/json"},
  1888. json=config.as_dict(),
  1889. params=params,
  1890. )
  1891. response.raise_for_status()
  1892. except httpx.HTTPStatusError as err:
  1893. status = err.response.status_code
  1894. if status == 400:
  1895. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  1896. elif status == 401:
  1897. raise UnauthorisedError(
  1898. f"Request is unauthorised: {err.response.text}"
  1899. ) from err
  1900. elif status == 403:
  1901. raise ForbiddenError(
  1902. f"Request is forbidden: {err.response.text}"
  1903. ) from err
  1904. elif status == 500:
  1905. raise InternalServerError(
  1906. f"Internal server error: {err.response.text}"
  1907. ) from err
  1908. raise
  1909. @property
  1910. def http_client(self):
  1911. return self._http_client
  1912. @t.overload
  1913. def get(
  1914. self,
  1915. repository_id: str,
  1916. content_type: t.Literal["application/json"],
  1917. location: str | None = None,
  1918. ) -> RepositoryConfigBean: ...
  1919. @t.overload
  1920. def get(
  1921. self,
  1922. repository_id: str,
  1923. content_type: None = None,
  1924. location: str | None = None,
  1925. ) -> RepositoryConfigBean: ...
  1926. @t.overload
  1927. def get(
  1928. self,
  1929. repository_id: str,
  1930. content_type: t.Literal["text/turtle"],
  1931. location: str | None = None,
  1932. ) -> Graph: ...
  1933. @t.overload
  1934. def get(
  1935. self,
  1936. repository_id: str,
  1937. content_type: str,
  1938. location: str | None = None,
  1939. ) -> RepositoryConfigBean | Graph: ...
  1940. def get(
  1941. self,
  1942. repository_id: str,
  1943. content_type: str | None = None,
  1944. location: str | None = None,
  1945. ) -> RepositoryConfigBean | Graph:
  1946. """Get a repository's configuration.
  1947. Parameters:
  1948. repository_id: The identifier of the repository.
  1949. content_type: The content type of the response. Can be `application/json` or
  1950. `text/turtle`. Defaults to `application/json`.
  1951. location: The location of the repository.
  1952. Returns:
  1953. RepositoryConfigBean: The repository configuration.
  1954. Graph: The repository configuration in RDF.
  1955. Raises:
  1956. BadRequestError: If the content type is not supported.
  1957. ResponseFormatError: If the response cannot be parsed.
  1958. RepositoryNotFoundError: If the repository is not found.
  1959. InternalServerError: If the server returns an internal error.
  1960. """
  1961. if content_type is None:
  1962. content_type = "application/json"
  1963. if content_type not in ("application/json", "text/turtle"):
  1964. raise ValueError(f"Unsupported content type: {content_type}.")
  1965. headers = {"Accept": content_type}
  1966. params = {}
  1967. if location is not None:
  1968. params["location"] = location
  1969. try:
  1970. response = self.http_client.get(
  1971. f"/rest/repositories/{repository_id}", headers=headers, params=params
  1972. )
  1973. response.raise_for_status()
  1974. if content_type == "application/json":
  1975. try:
  1976. return RepositoryConfigBean(**response.json())
  1977. except (ValueError, TypeError) as err:
  1978. raise ResponseFormatError(
  1979. "Failed to parse GraphDB response."
  1980. ) from err
  1981. elif content_type == "text/turtle":
  1982. try:
  1983. return Graph().parse(data=response.text, format="turtle")
  1984. except Exception as err:
  1985. raise ResponseFormatError(f"Error parsing RDF: {err}") from err
  1986. else:
  1987. raise ValueError(f"Unhandled content type: {content_type}.")
  1988. except httpx.HTTPStatusError as err:
  1989. if err.response.status_code == 404:
  1990. raise RepositoryNotFoundError(
  1991. f"Repository {repository_id} not found."
  1992. ) from err
  1993. elif err.response.status_code == 500:
  1994. raise InternalServerError("Internal server error.") from err
  1995. raise
  1996. def edit(self, repository_id: str, config: RepositoryConfigBeanCreate) -> None:
  1997. """Edit a repository's configuration.
  1998. Parameters:
  1999. repository_id: The identifier of the repository.
  2000. config: The repository configuration.
  2001. Raises:
  2002. BadRequestError: If the request is invalid.
  2003. UnauthorisedError: If the request is unauthorised.
  2004. ForbiddenError: If the request is forbidden.
  2005. InternalServerError: If the server returns an internal error.
  2006. """
  2007. headers = {
  2008. "Content-Type": "application/json",
  2009. }
  2010. try:
  2011. response = self.http_client.put(
  2012. f"/rest/repositories/{repository_id}",
  2013. headers=headers,
  2014. json=config.as_dict(),
  2015. )
  2016. response.raise_for_status()
  2017. except httpx.HTTPStatusError as err:
  2018. if err.response.status_code == 400:
  2019. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  2020. elif err.response.status_code == 401:
  2021. raise UnauthorisedError(
  2022. f"Request is unauthorised: {err.response.text}"
  2023. ) from err
  2024. elif err.response.status_code == 403:
  2025. raise ForbiddenError(
  2026. f"Request is forbidden: {err.response.text}"
  2027. ) from err
  2028. elif err.response.status_code == 500:
  2029. raise InternalServerError(
  2030. f"Internal server error: {err.response.text}"
  2031. ) from err
  2032. raise
  2033. def delete(self, repository_id: str, location: str | None = None) -> None:
  2034. """Delete a repository.
  2035. Parameters:
  2036. repository_id: The identifier of the repository.
  2037. location: The location of the repository.
  2038. Raises:
  2039. BadRequestError: If the request is invalid.
  2040. UnauthorisedError: If the request is unauthorised.
  2041. ForbiddenError: If the request is forbidden.
  2042. InternalServerError: If the server returns an internal error.
  2043. """
  2044. params = {}
  2045. if location is not None:
  2046. params["location"] = location
  2047. try:
  2048. response = self.http_client.delete(
  2049. f"/rest/repositories/{repository_id}", params=params
  2050. )
  2051. response.raise_for_status()
  2052. except httpx.HTTPStatusError as err:
  2053. if err.response.status_code == 400:
  2054. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  2055. elif err.response.status_code == 401:
  2056. raise UnauthorisedError(
  2057. f"Request is unauthorised: {err.response.text}"
  2058. ) from err
  2059. elif err.response.status_code == 403:
  2060. raise ForbiddenError(
  2061. f"Request is forbidden: {err.response.text}"
  2062. ) from err
  2063. elif err.response.status_code == 500:
  2064. raise InternalServerError(
  2065. f"Internal server error: {err.response.text}"
  2066. ) from err
  2067. raise
  2068. def restart(
  2069. self, repository_id: str, sync: bool | None = None, location: str | None = None
  2070. ) -> str:
  2071. """Restart a repository.
  2072. Parameters:
  2073. repository_id: The identifier of the repository.
  2074. sync: Whether to sync the repository.
  2075. location: The location of the repository.
  2076. Returns:
  2077. str: The response text.
  2078. Raises:
  2079. UnauthorisedError: If the request is unauthorised.
  2080. ForbiddenError: If the request is forbidden.
  2081. RepositoryNotFoundError: If the repository is not found.
  2082. """
  2083. params = {}
  2084. if sync is not None:
  2085. params["sync"] = str(sync).lower()
  2086. if location is not None:
  2087. params["location"] = location
  2088. try:
  2089. response = self.http_client.post(
  2090. f"/rest/repositories/{repository_id}/restart", params=params
  2091. )
  2092. response.raise_for_status()
  2093. return response.text
  2094. except httpx.HTTPStatusError as err:
  2095. if err.response.status_code == 401:
  2096. raise UnauthorisedError("Request is unauthorised.") from err
  2097. elif err.response.status_code == 403:
  2098. raise ForbiddenError("Request is forbidden.") from err
  2099. elif err.response.status_code == 404:
  2100. raise RepositoryNotFoundError(
  2101. f"Repository {repository_id} not found."
  2102. ) from err
  2103. raise
  2104. def size(
  2105. self, repository_id: str, location: str | None = None
  2106. ) -> RepositorySizeInfo:
  2107. """Get repository size.
  2108. Parameters:
  2109. repository_id: The identifier of the repository.
  2110. location: The location of the repository.
  2111. Raises:
  2112. ResponseFormatError: If the response cannot be parsed.
  2113. InternalServerError: If the server returns an internal error.
  2114. """
  2115. params = {}
  2116. if location:
  2117. params["location"] = location
  2118. try:
  2119. response = self.http_client.get(
  2120. f"/rest/repositories/{repository_id}/size", params=params
  2121. )
  2122. response.raise_for_status()
  2123. try:
  2124. return RepositorySizeInfo(**response.json())
  2125. except (ValueError, TypeError) as err:
  2126. raise ResponseFormatError("Failed to parse GraphDB response.") from err
  2127. except httpx.HTTPStatusError as err:
  2128. if err.response.status_code == 500:
  2129. raise InternalServerError(
  2130. f"Internal server error: {err.response.text}"
  2131. ) from err
  2132. raise
  2133. @t.overload
  2134. def validate(
  2135. self,
  2136. repository_id: str,
  2137. *,
  2138. content_type: str,
  2139. content: str,
  2140. location: str | None = None,
  2141. shapes_repository_id: None = None,
  2142. ) -> str: ...
  2143. @t.overload
  2144. def validate(
  2145. self,
  2146. repository_id: str,
  2147. *,
  2148. content: t.IO[bytes],
  2149. content_type: str | None = None,
  2150. location: str | None = None,
  2151. shapes_repository_id: None = None,
  2152. ) -> str: ...
  2153. @t.overload
  2154. def validate(
  2155. self,
  2156. repository_id: str,
  2157. *,
  2158. shapes_repository_id: str,
  2159. location: str | None = None,
  2160. ) -> str: ...
  2161. def validate(
  2162. self,
  2163. repository_id: str,
  2164. *,
  2165. content_type: str | None = None,
  2166. content: str | t.IO[bytes] | None = None,
  2167. location: str | None = None,
  2168. shapes_repository_id: str | None = None,
  2169. ) -> str:
  2170. """Validate repository data using SHACL shapes.
  2171. Parameters:
  2172. repository_id: The identifier of the repository.
  2173. content_type: Content type of the request body (required for text payloads).
  2174. content: SHACL shapes payload; string for text-based validation or file-like
  2175. (binary) object for multipart validation.
  2176. location: Optional repository location.
  2177. shapes_repository_id: ID of repository containing SHACL shapes; when
  2178. provided, no content is sent and shapes are fetched from that
  2179. repository.
  2180. Returns:
  2181. str: Validation report as RDF Turtle.
  2182. Raises:
  2183. UnauthorisedError: If the request is unauthorised.
  2184. ForbiddenError: If the request is forbidden.
  2185. InternalServerError: If the server returns an internal error.
  2186. """
  2187. params = {}
  2188. if location is not None:
  2189. params["location"] = location
  2190. if shapes_repository_id is not None:
  2191. if content is not None:
  2192. raise ValueError(
  2193. "content must not be provided when shapes_repository_id is set."
  2194. )
  2195. if content_type is not None:
  2196. raise ValueError(
  2197. "content_type must not be provided when shapes_repository_id is set."
  2198. )
  2199. headers = {"Accept": "text/turtle"}
  2200. try:
  2201. response = self.http_client.post(
  2202. url=f"/rest/repositories/{repository_id}/validate/repository/{shapes_repository_id}",
  2203. params=params,
  2204. headers=headers,
  2205. )
  2206. response.raise_for_status()
  2207. return response.text
  2208. except httpx.HTTPStatusError as err:
  2209. status = err.response.status_code
  2210. if status == 401:
  2211. raise UnauthorisedError("Request is unauthorised.") from err
  2212. elif status == 403:
  2213. raise ForbiddenError("Request is forbidden.") from err
  2214. elif status == 500:
  2215. raise InternalServerError(
  2216. f"Internal server error: {err.response.text}"
  2217. ) from err
  2218. raise
  2219. if content is None:
  2220. raise ValueError("content must be provided.")
  2221. is_file_like = hasattr(content, "read") and not isinstance(content, str)
  2222. if is_file_like:
  2223. headers = {"Accept": "text/turtle"}
  2224. file_part = (
  2225. "shapes.ttl",
  2226. t.cast(t.IO[bytes], content),
  2227. content_type or "text/turtle",
  2228. )
  2229. files: FilesType = {"file": file_part}
  2230. try:
  2231. response = self.http_client.post(
  2232. url=f"/rest/repositories/{repository_id}/validate/file",
  2233. params=params,
  2234. headers=headers,
  2235. files=files,
  2236. )
  2237. response.raise_for_status()
  2238. return response.text
  2239. except httpx.HTTPStatusError as err:
  2240. status = err.response.status_code
  2241. if status == 401:
  2242. raise UnauthorisedError("Request is unauthorised.") from err
  2243. elif status == 403:
  2244. raise ForbiddenError("Request is forbidden.") from err
  2245. elif status == 500:
  2246. raise InternalServerError(
  2247. f"Internal server error: {err.response.text}"
  2248. ) from err
  2249. raise
  2250. else:
  2251. if not content_type:
  2252. raise ValueError("content_type must be provided for text validation.")
  2253. headers = {"Content-Type": content_type, "Accept": "text/turtle"}
  2254. try:
  2255. response = self.http_client.post(
  2256. url=f"/rest/repositories/{repository_id}/validate/text",
  2257. params=params,
  2258. headers=headers,
  2259. content=content,
  2260. )
  2261. response.raise_for_status()
  2262. return response.text
  2263. except httpx.HTTPStatusError as err:
  2264. status = err.response.status_code
  2265. if status == 401:
  2266. raise UnauthorisedError("Request is unauthorised.") from err
  2267. elif status == 403:
  2268. raise ForbiddenError("Request is forbidden.") from err
  2269. elif status == 500:
  2270. raise InternalServerError(
  2271. f"Internal server error: {err.response.text}"
  2272. ) from err
  2273. raise
  2274. class SecurityManagement:
  2275. """GraphDB Security Management client."""
  2276. def __init__(self, http_client: httpx.Client):
  2277. self._http_client = http_client
  2278. @property
  2279. def http_client(self):
  2280. return self._http_client
  2281. @property
  2282. def enabled(self) -> bool:
  2283. """Check if security is enabled.
  2284. Returns:
  2285. bool: True if security is enabled, False otherwise.
  2286. Raises:
  2287. ResponseFormatError: If the response format is invalid.
  2288. InternalServerError: If the server returns an internal error.
  2289. """
  2290. try:
  2291. headers = {"Accept": "application/json"}
  2292. response = self.http_client.get("/rest/security", headers=headers)
  2293. response.raise_for_status()
  2294. try:
  2295. value = response.json()
  2296. if not isinstance(value, bool):
  2297. raise ResponseFormatError("Response is not a boolean.")
  2298. return value
  2299. except (ValueError, TypeError) as err:
  2300. raise ResponseFormatError(
  2301. f"Failed to parse GraphDB response: {err}"
  2302. ) from err
  2303. except httpx.HTTPStatusError as err:
  2304. if err.response.status_code == 500:
  2305. raise InternalServerError(
  2306. f"Internal server error: {err.response.text}"
  2307. ) from err
  2308. raise
  2309. @enabled.setter
  2310. def enabled(self, value: bool):
  2311. """Enable or disable security.
  2312. Parameters:
  2313. value: The value to set.
  2314. Raises:
  2315. TypeError: If the value is not a boolean.
  2316. UnauthorisedError: If the request is unauthorised.
  2317. ForbiddenError: If the request is forbidden.
  2318. PreconditionFailedError: If the precondition is failed.
  2319. """
  2320. if not isinstance(value, bool):
  2321. raise TypeError("Value must be a boolean.")
  2322. try:
  2323. headers = {"Content-Type": "application/json", "Accept": "application/json"}
  2324. response = self.http_client.post(
  2325. "/rest/security", headers=headers, json=value
  2326. )
  2327. response.raise_for_status()
  2328. except httpx.HTTPStatusError as err:
  2329. if err.response.status_code == 401:
  2330. raise UnauthorisedError("Request is unauthorised.") from err
  2331. elif err.response.status_code == 403:
  2332. raise ForbiddenError("Request is forbidden.") from err
  2333. elif err.response.status_code == 412:
  2334. raise PreconditionFailedError("Precondition failed.") from err
  2335. raise
  2336. def get_free_access_details(self):
  2337. """
  2338. Check if free access is enabled.
  2339. Returns:
  2340. FreeAccessSettings: The free access settings.
  2341. Raises:
  2342. ResponseFormatError: If the response format is invalid.
  2343. InternalServerError: If the server returns an internal error.
  2344. """
  2345. try:
  2346. headers = {"Accept": "application/json"}
  2347. response = self.http_client.get(
  2348. "/rest/security/free-access", headers=headers
  2349. )
  2350. response.raise_for_status()
  2351. try:
  2352. return FreeAccessSettings(**response.json())
  2353. except (ValueError, TypeError) as err:
  2354. raise ResponseFormatError(
  2355. f"Failed to parse GraphDB response: {err}"
  2356. ) from err
  2357. except httpx.HTTPStatusError as err:
  2358. if err.response.status_code == 500:
  2359. raise InternalServerError(
  2360. f"Internal server error: {err.response.text}"
  2361. ) from err
  2362. raise
  2363. def set_free_access_details(self, free_access_settings: FreeAccessSettings):
  2364. """
  2365. Enable or disable free access.
  2366. Parameters:
  2367. free_access_settings: The free access settings.
  2368. Raises:
  2369. UnauthorisedError: If the request is unauthorised.
  2370. ForbiddenError: If the request is forbidden.
  2371. PreconditionFailedError: If the precondition is failed.
  2372. """
  2373. try:
  2374. headers = {"Content-Type": "application/json", "Accept": "application/json"}
  2375. response = self.http_client.post(
  2376. "/rest/security/free-access",
  2377. headers=headers,
  2378. json=free_access_settings.as_dict(),
  2379. )
  2380. response.raise_for_status()
  2381. except httpx.HTTPStatusError as err:
  2382. if err.response.status_code == 401:
  2383. raise UnauthorisedError("Request is unauthorised.") from err
  2384. elif err.response.status_code == 403:
  2385. raise ForbiddenError("Request is forbidden.") from err
  2386. elif err.response.status_code == 412:
  2387. raise PreconditionFailedError("Precondition failed.") from err
  2388. raise
  2389. class UserManagement:
  2390. """GraphDB User Management client."""
  2391. def __init__(self, http_client: httpx.Client):
  2392. self._http_client = http_client
  2393. @property
  2394. def http_client(self):
  2395. return self._http_client
  2396. def list(self) -> list[User]:
  2397. """
  2398. Get all users.
  2399. Returns:
  2400. A list of users.
  2401. Raises:
  2402. ResponseFormatError: If the response format is invalid.
  2403. UnauthorisedError: If the request is unauthorised.
  2404. ForbiddenError: If the request is forbidden.
  2405. PreconditionFailedError: If the precondition is failed.
  2406. InternalServerError: If the server returns an internal error.
  2407. """
  2408. headers = {"Accept": "application/json"}
  2409. try:
  2410. response = self.http_client.get("/rest/security/users", headers=headers)
  2411. response.raise_for_status()
  2412. try:
  2413. return [User(**user) for user in response.json()]
  2414. except (ValueError, TypeError) as err:
  2415. raise ResponseFormatError(
  2416. f"Failed to parse GraphDB response: {err}"
  2417. ) from err
  2418. except httpx.HTTPStatusError as err:
  2419. if err.response.status_code == 401:
  2420. raise UnauthorisedError("Request is unauthorised.") from err
  2421. elif err.response.status_code == 403:
  2422. raise ForbiddenError("Request is forbidden.") from err
  2423. elif err.response.status_code == 412:
  2424. raise PreconditionFailedError("Precondition failed.") from err
  2425. elif err.response.status_code == 500:
  2426. raise InternalServerError(
  2427. f"Internal server error: {err.response.text}"
  2428. ) from err
  2429. raise
  2430. def get(self, username: str) -> User:
  2431. """
  2432. Get a user.
  2433. Parameters:
  2434. username: The username of the user.
  2435. Returns:
  2436. User: The user.
  2437. Raises:
  2438. ResponseFormatError: If the response format is invalid.
  2439. ForbiddenError: If the request is forbidden.
  2440. NotFoundError: If the user is not found.
  2441. InternalServerError: If the server returns an internal error.
  2442. """
  2443. try:
  2444. headers = {"Accept": "application/json"}
  2445. response = self.http_client.get(
  2446. f"/rest/security/users/{username}", headers=headers
  2447. )
  2448. response.raise_for_status()
  2449. try:
  2450. return User(**response.json())
  2451. except (ValueError, TypeError) as err:
  2452. raise ResponseFormatError(
  2453. f"Failed to parse GraphDB response: {err}"
  2454. ) from err
  2455. except httpx.HTTPStatusError as err:
  2456. if err.response.status_code == 403:
  2457. raise ForbiddenError("Request is forbidden.") from err
  2458. elif err.response.status_code == 404:
  2459. raise NotFoundError("User not found.") from err
  2460. elif err.response.status_code == 500:
  2461. raise InternalServerError(
  2462. f"Internal server error: {err.response.text}"
  2463. ) from err
  2464. raise
  2465. def overwrite(self, username: str, user: User) -> None:
  2466. """
  2467. Overwrite a user.
  2468. Parameters:
  2469. username: The username of the user.
  2470. user: The user to overwrite.
  2471. Raises:
  2472. TypeError: if username is not a string or user is not an instance of User.
  2473. UnauthorisedError: If the request is unauthorised.
  2474. ForbiddenError: If the request is forbidden.
  2475. NotFoundError: If the user is not found.
  2476. """
  2477. if not isinstance(username, str):
  2478. raise TypeError("Username must be a string.")
  2479. if not isinstance(user, User):
  2480. raise TypeError("User must be an instance of User.")
  2481. try:
  2482. headers = {"Content-Type": "application/json"}
  2483. response = self.http_client.put(
  2484. f"/rest/security/users/{username}", headers=headers, json=user.as_dict()
  2485. )
  2486. response.raise_for_status()
  2487. except httpx.HTTPStatusError as err:
  2488. if err.response.status_code == 401:
  2489. raise UnauthorisedError("Request is unauthorised.") from err
  2490. elif err.response.status_code == 403:
  2491. raise ForbiddenError("Request is forbidden.") from err
  2492. elif err.response.status_code == 404:
  2493. raise NotFoundError("User not found.") from err
  2494. raise
  2495. def create(self, username: str, user: UserCreate) -> None:
  2496. """
  2497. Create a user.
  2498. Parameters:
  2499. username: The username of the user.
  2500. user: The user to create.
  2501. Raises:
  2502. TypeError: if username is not a string or user is not an instance of UserCreate.
  2503. BadRequestError: If the request is bad.
  2504. UnauthorisedError: If the request is unauthorised.
  2505. ForbiddenError: If the request is forbidden.
  2506. """
  2507. if not isinstance(username, str):
  2508. raise TypeError("Username must be a string.")
  2509. if not isinstance(user, UserCreate):
  2510. raise TypeError("User must be an instance of UserCreate.")
  2511. try:
  2512. headers = {"Content-Type": "application/json"}
  2513. response = self.http_client.post(
  2514. f"/rest/security/users/{username}", headers=headers, json=user.as_dict()
  2515. )
  2516. response.raise_for_status()
  2517. except httpx.HTTPStatusError as err:
  2518. if err.response.status_code == 400:
  2519. raise BadRequestError(f"Bad request. {err.response.text}") from err
  2520. elif err.response.status_code == 401:
  2521. raise UnauthorisedError("Request is unauthorised.") from err
  2522. elif err.response.status_code == 403:
  2523. raise ForbiddenError("Request is forbidden.") from err
  2524. raise
  2525. def delete(self, username: str) -> None:
  2526. """
  2527. Delete a user.
  2528. Parameters:
  2529. username: The username of the user.
  2530. Raises:
  2531. TypeError: if username is not a string.
  2532. BadRequestError: If the request is bad.
  2533. UnauthorisedError: If the request is unauthorised.
  2534. ForbiddenError: If the request is forbidden.
  2535. NotFoundError: If the user is not found.
  2536. """
  2537. if not isinstance(username, str):
  2538. raise TypeError("Username must be a string.")
  2539. try:
  2540. response = self.http_client.delete(f"/rest/security/users/{username}")
  2541. response.raise_for_status()
  2542. except httpx.HTTPStatusError as err:
  2543. if err.response.status_code == 400:
  2544. raise BadRequestError(f"Bad request. {err.response.text}") from err
  2545. elif err.response.status_code == 401:
  2546. raise UnauthorisedError("Request is unauthorised.") from err
  2547. elif err.response.status_code == 403:
  2548. raise ForbiddenError("Request is forbidden.") from err
  2549. elif err.response.status_code == 404:
  2550. raise NotFoundError("User not found.") from err
  2551. raise
  2552. def update(self, username: str, user: UserUpdate | dict[str, t.Any]) -> None:
  2553. """
  2554. Update a user.
  2555. !!! Note
  2556. Certain user fields are not updatable using PATCH. For example, at the
  2557. time of writing, `grantedAuthorities` are not updatable, but `appSettings` are.
  2558. Parameters:
  2559. username: The username of the user.
  2560. user: The user data to update, either as a UserUpdate instance or a dict.
  2561. Raises:
  2562. TypeError: if username is not a string or user is not an instance of UserUpdate or dict.
  2563. ForbiddenError: If the request is forbidden.
  2564. PreconditionFailedError: If the precondition is failed.
  2565. """
  2566. if not isinstance(username, str):
  2567. raise TypeError("Username must be a string.")
  2568. if not isinstance(user, (UserUpdate, dict)):
  2569. raise TypeError("User must be an instance of UserUpdate or dict.")
  2570. if isinstance(user, UserUpdate):
  2571. user_data = user.as_dict()
  2572. else:
  2573. user_data = user
  2574. try:
  2575. headers = {"Content-Type": "application/json"}
  2576. response = self.http_client.patch(
  2577. f"/rest/security/users/{username}", headers=headers, json=user_data
  2578. )
  2579. response.raise_for_status()
  2580. except httpx.HTTPStatusError as err:
  2581. if err.response.status_code == 403:
  2582. raise ForbiddenError("Request is forbidden.") from err
  2583. elif err.response.status_code == 412:
  2584. raise PreconditionFailedError("Precondition failed.") from err
  2585. raise
  2586. def custom_roles(self, username: str) -> t.List[str]:
  2587. """
  2588. Retrieve custom roles associated with the user.
  2589. Parameters:
  2590. username: The username of the user.
  2591. Returns:
  2592. list[str]: The custom roles for the user.
  2593. Raises:
  2594. TypeError: if username is not a string.
  2595. ForbiddenError: If the request is forbidden.
  2596. NotFoundError: If the user is not found.
  2597. InternalServerError: If the request fails.
  2598. """
  2599. if not isinstance(username, str):
  2600. raise TypeError("Username must be a string.")
  2601. try:
  2602. response = self.http_client.get(
  2603. f"/rest/security/users/{username}/custom-roles"
  2604. )
  2605. response.raise_for_status()
  2606. return response.json()
  2607. except httpx.HTTPStatusError as err:
  2608. if err.response.status_code == 403:
  2609. raise ForbiddenError("Request is forbidden.") from err
  2610. elif err.response.status_code == 404:
  2611. raise NotFoundError("User not found.") from err
  2612. elif err.response.status_code == 500:
  2613. raise InternalServerError("Internal server error.") from err
  2614. raise
  2615. class GraphDBClient(RDF4JClient):
  2616. """GraphDB Client
  2617. This client and its inner management objects perform HTTP requests via
  2618. httpx and may raise httpx-specific exceptions. Errors documented by GraphDB
  2619. in its OpenAPI specification are mapped to specific exceptions in this
  2620. library where applicable. Error mappings are documented on each management
  2621. method. The underlying httpx client is reused across
  2622. requests, and connection pooling is handled automatically by httpx.
  2623. Parameters:
  2624. base_url: The base URL of the GraphDB server.
  2625. auth: Authentication credentials. Can be a tuple (username, password) for
  2626. basic auth, or a string for token-based auth (e.g., "GDB <token>")
  2627. which is added as the Authorization header.
  2628. timeout: Request timeout in seconds or an httpx.Timeout for fine-grained control (default: 30.0).
  2629. kwargs: Additional keyword arguments to pass to the httpx.Client.
  2630. """
  2631. def __init__(
  2632. self,
  2633. base_url: str,
  2634. auth: tuple[str, str] | str | None = None,
  2635. timeout: float | httpx.Timeout = 30.0,
  2636. **kwargs: t.Any,
  2637. ):
  2638. super().__init__(base_url, auth, timeout, **kwargs)
  2639. self._cluster_group = ClusterGroupManagement(self.http_client)
  2640. self._monitoring = MonitoringManagement(self.http_client)
  2641. self._recovery = RecoveryManagement(self.http_client)
  2642. self._graphdb_repository_manager = RepositoryManager(self.http_client)
  2643. self._graphdb_repositories = RepositoryManagement(self.http_client)
  2644. self._security = SecurityManagement(self.http_client)
  2645. self._users = UserManagement(self.http_client)
  2646. @property
  2647. def cluster(self) -> ClusterGroupManagement:
  2648. return self._cluster_group
  2649. @property
  2650. def monitoring(self) -> MonitoringManagement:
  2651. return self._monitoring
  2652. @property
  2653. def recovery(self) -> RecoveryManagement:
  2654. return self._recovery
  2655. @property
  2656. def repositories(self) -> RepositoryManager:
  2657. """Server-level repository management operations (GraphDB-specific)."""
  2658. return self._graphdb_repository_manager
  2659. @property
  2660. def graphdb_repositories(self) -> RepositoryManagement:
  2661. return self._graphdb_repositories
  2662. @property
  2663. def security(self) -> SecurityManagement:
  2664. return self._security
  2665. @property
  2666. def users(self) -> UserManagement:
  2667. return self._users
  2668. def login(self, username: str, password: str) -> AuthenticatedUser:
  2669. """Authenticate with GraphDB and obtain a GDB token.
  2670. Parameters:
  2671. username: The username to authenticate with.
  2672. password: The password to authenticate with.
  2673. Returns:
  2674. An AuthenticatedUser instance containing user details and the GDB token.
  2675. Raises:
  2676. UnauthorisedError: If the credentials are invalid.
  2677. BadRequestError: If the request body is invalid.
  2678. ResponseFormatError: If the response cannot be parsed.
  2679. """
  2680. try:
  2681. response = self.http_client.post(
  2682. "/rest/login",
  2683. json={"username": username, "password": password},
  2684. )
  2685. response.raise_for_status()
  2686. auth_header = response.headers.get("Authorization", "")
  2687. if not auth_header.startswith("GDB "):
  2688. raise ResponseFormatError(
  2689. "Authorization header missing or invalid format"
  2690. )
  2691. try:
  2692. data = response.json()
  2693. except (ValueError, TypeError) as err:
  2694. raise ResponseFormatError(
  2695. f"Failed to parse login response: {err}"
  2696. ) from err
  2697. try:
  2698. return AuthenticatedUser.from_response(data, auth_header)
  2699. except (ValueError, TypeError) as err:
  2700. raise ResponseFormatError(
  2701. f"Failed to parse authenticated user: {err}"
  2702. ) from err
  2703. except httpx.HTTPStatusError as err:
  2704. status = err.response.status_code
  2705. if status == 400:
  2706. raise BadRequestError(f"Invalid request: {err.response.text}") from err
  2707. elif status == 401:
  2708. raise UnauthorisedError(
  2709. f"Invalid credentials: {err.response.text}"
  2710. ) from err
  2711. raise