Skip to content

Commit 2c070ba

Browse files
implement REST client backend
1 parent c24f92b commit 2c070ba

File tree

3 files changed

+170
-0
lines changed

3 files changed

+170
-0
lines changed

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ requires-python = ">=3.9"
2424
dependencies = [
2525
"cryptography < 43.0.0", # using a more recent version triggers annoying warnings with paramiko
2626
"paramiko",
27+
"requests",
2728
]
2829

2930
[project.urls]

src/borgstore/backends/rest.py

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
"""
2+
REST http client based backend implementation.
3+
4+
Usage:
5+
6+
b = get_rest_backend("https://username:password@username.repo.borgbase.com/restictest")
7+
b.open()
8+
b.create()
9+
b.store("config", b"foo")
10+
b.load("config")
11+
b.delete("config")
12+
b.store("config", b"bar")
13+
b.store("data/<sha256(value)>", value)
14+
b.list("data")
15+
b.load("data/<sha256>")
16+
b.close()
17+
"""
18+
import os
19+
import re
20+
import requests
21+
from typing import Iterator, Dict, Optional
22+
from urllib.parse import unquote
23+
24+
from requests.auth import HTTPBasicAuth
25+
26+
from ._base import BackendBase, ItemInfo, validate_name
27+
from .errors import ObjectNotFound
28+
29+
30+
def get_rest_backend(base_url: str):
31+
# http(s)://username:password@hostname:port/path or http(s)://hostname:port/path + auth from env
32+
http_regex = r"""
33+
(?P<scheme>http|https)://
34+
((?P<username>[^:]+):(?P<password>[^@]+)@)?
35+
(?P<host>[^:/]+)(:?(?P<port>\d+))?
36+
(?P<path>(/.*))
37+
"""
38+
m = re.match(http_regex, base_url, re.VERBOSE)
39+
if m:
40+
scheme = m.group("scheme")
41+
host = m.group("host")
42+
port = m.group("port")
43+
path = m.group("path")
44+
45+
base_url = f"{scheme}://{host}{f':{port}' if port else ''}{path}"
46+
47+
username, password = m.group("username"), m.group("password")
48+
if username and password:
49+
username, password = unquote(username), unquote(password)
50+
else:
51+
username, password = os.environ.get("REST_BACKEND_USERNAME"), os.environ.get("REST_BACKEND_PASSWORD")
52+
53+
return RestClientBackend(base_url, username=username, password=password)
54+
55+
56+
class RestClientBackend(BackendBase):
57+
def __init__(
58+
self,
59+
base_url: str,
60+
username: Optional[str] = None,
61+
password: Optional[str] = None,
62+
headers: Optional[Dict[str, str]] = None,
63+
timeout: Optional[int] = 30,
64+
):
65+
self.base_url = base_url.rstrip("/") # _url method adds slash
66+
self.headers = headers or {}
67+
self.headers["Accept"] = "application/vnd.x.restic.rest.v2"
68+
self.timeout = timeout
69+
self.auth = HTTPBasicAuth(username, password) if username and password else None
70+
self.session = None
71+
72+
def _url(self, path: str) -> str:
73+
return f"{self.base_url}/{path.lstrip('/')}"
74+
75+
def _request(self, method, url, *, headers=None, data=None, params=None):
76+
if self.session is not None: # between .open() and .close()
77+
return self.session.request(method, url, params=params, data=data, headers=headers, timeout=self.timeout)
78+
else: # .create() and .destroy() are called when backend is not opened
79+
assert headers is None
80+
return requests.request(
81+
method, url, auth=self.auth, params=params, data=data, headers=self.headers, timeout=self.timeout
82+
)
83+
84+
def create(self) -> None:
85+
# restic-server: repo creation creates all needed directories
86+
response = self._request("post", self._url(""), params={"create": "true"})
87+
if response.status_code != 200:
88+
response.raise_for_status()
89+
90+
def destroy(self) -> None:
91+
# XXX restic-server: repo deletion doesn't work on borgbase.com, 405 "Method not allowed"
92+
response = self._request("delete", self._url(""))
93+
if response.status_code != 200:
94+
response.raise_for_status()
95+
96+
def open(self):
97+
self.session = requests.Session()
98+
self.session.auth = self.auth
99+
self.session.headers.update(self.headers)
100+
101+
def close(self):
102+
if self.session is not None:
103+
self.session.close()
104+
self.session = None
105+
106+
def mkdir(self, name: str) -> None:
107+
pass
108+
109+
def rmdir(self, name: str) -> None:
110+
pass
111+
112+
def info(self, name: str) -> ItemInfo:
113+
# restic-server: only works on objects, not on directories
114+
validate_name(name)
115+
response = self._request("head", self._url(name))
116+
if response.status_code != 200:
117+
if response.status_code == 404:
118+
raise ObjectNotFound(name)
119+
else:
120+
response.raise_for_status()
121+
return ItemInfo(name=name, exists=True, size=int(response.headers["Content-Length"]), directory=False)
122+
123+
def load(self, name: str, *, size=None, offset=0) -> bytes:
124+
validate_name(name)
125+
126+
r_hdr = (None if not offset else f"bytes={offset}-") if size is None else f"bytes={offset}-{offset + size - 1}"
127+
headers = self.headers.copy()
128+
if r_hdr:
129+
headers["Range"] = r_hdr
130+
131+
response = self._request("get", self._url(name), headers=headers)
132+
if response.status_code != 200:
133+
if response.status_code == 404:
134+
raise ObjectNotFound(name)
135+
else:
136+
response.raise_for_status()
137+
return response.content
138+
139+
def store(self, name: str, value: bytes) -> None:
140+
validate_name(name)
141+
# restic-server only works with key == sha256(value) (verifies the hash while writing to disk)
142+
# and it rejects overwriting existing objects.
143+
response = self._request("post", self._url(name), data=value)
144+
if response.status_code != 200:
145+
response.raise_for_status()
146+
147+
def delete(self, name: str) -> None:
148+
validate_name(name)
149+
response = self._request("delete", self._url(name))
150+
if response.status_code != 200:
151+
if response.status_code == 404:
152+
raise ObjectNotFound(name)
153+
else:
154+
response.raise_for_status()
155+
156+
def move(self, curr_name: str, new_name: str) -> None:
157+
raise NotImplementedError
158+
159+
def list(self, name: str) -> Iterator[ItemInfo]:
160+
validate_name(name)
161+
response = self._request("get", self._url(name) + "/") # trailing "/" needed to get list
162+
if response.status_code != 200:
163+
if response.status_code == 404:
164+
raise ObjectNotFound(name)
165+
else:
166+
response.raise_for_status()
167+
for entry in response.json():
168+
yield ItemInfo(name=entry["name"], exists=True, size=entry["size"], directory=False)

tox.ini

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,4 +19,5 @@ changedir =
1919
deps =
2020
mypy
2121
types-paramiko
22+
types-requests
2223
commands = mypy

0 commit comments

Comments
 (0)