Coverage for src/rsnapshot_docker_compose_backup/utils/docker_api_client.py: 76%

80 statements  

« prev     ^ index     » next       coverage.py v7.5.4, created at 2024-07-07 01:03 +0200

1from dataclasses import dataclass 

2import json 

3import socket 

4from typing import Any, Dict, List, Optional 

5import urllib.parse 

6 

7 

8@dataclass 

9class HttpResponse: 

10 protocol_version: str 

11 status_code: int 

12 status_text: str 

13 headers: Dict[str, str] 

14 json_body: Any 

15 

16 

17class Api: 

18 

19 _docker_socket: Optional[socket.socket] = None 

20 

21 def __init__( 

22 self, socket_connection: str = "unix:///run/docker.sock", version: str = "v1.46" 

23 ) -> None: 

24 self.docker_socket = self._open_socket(socket_connection) 

25 self.version = version 

26 

27 def _open_socket(self, socket_connection: str) -> socket.socket: 

28 if socket_connection.startswith("unix://"): 

29 sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) 

30 sock.connect(socket_connection[7:]) # Remove 'unix://' 

31 return sock 

32 if socket_connection.startswith("http://"): 

33 sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) 

34 parts = socket_connection[7:].split(":") # Remove 'unix://' 

35 host = parts[0] 

36 port = 80 

37 if len(parts) == 2: 

38 port = int(parts[1]) 

39 print(f"connect to {host}, {port}") 

40 sock.connect((host, port)) 

41 return sock 

42 raise ValueError("Only Unix and http Sockets are supported") 

43 

44 def get( 

45 self, 

46 endpoint: str, 

47 *, 

48 query_parameter: Optional[Dict[str, str]] = None, 

49 header: Optional[Dict[str, str]] = None, 

50 ) -> HttpResponse: 

51 path = f"/{self.version}{endpoint}" 

52 if query_parameter is not None: 

53 parameter: List[str] = [] 

54 for name, value in query_parameter.items(): 

55 parameter.append(f"{name}={urllib.parse.quote_plus(value)}") 

56 path = path + "?" + "&".join(parameter) 

57 request = [ 

58 f"GET {path} HTTP/1.1", 

59 "Host:docker.sock", 

60 ] 

61 if header is not None: 

62 for name, value in header.items(): 

63 request.append(f"{name}:{value}") 

64 request.append("\r\n") 

65 self.docker_socket.send("\r\n".join(request).encode("utf-8")) 

66 return self._parse_response(self.docker_socket) 

67 

68 @staticmethod 

69 def _parse_response(sock: socket.socket) -> HttpResponse: 

70 # Read statusline and headers. I read byte by byte to be sure that not too much is read wich could lead to blocking 

71 message_start = b"" 

72 while not message_start.endswith(b"\r\n\r\n"): 

73 message_start = message_start + sock.recv(1) 

74 lines: List[str] = message_start.decode("utf-8").splitlines() 

75 # Parse statusline 

76 status_line = lines[0] 

77 protocol_version = status_line.split(" ")[0] 

78 status_code = int(status_line.split(" ")[1]) 

79 status_text = " ".join(status_line.split(" ")[2:]) 

80 if status_code >= 400: 

81 raise ValueError(status_text) 

82 # Read headers 

83 headers: Dict[str, str] = {} 

84 for line in lines[1:]: 

85 split = line.split(":") 

86 if len(split) == 2: 

87 headers[split[0]] = split[1].strip() 

88 # read body 

89 if "Content-Length" in headers: 

90 body_length = int(headers["Content-Length"]) 

91 body = sock.recv(body_length).decode("utf-8") 

92 elif ( 

93 "Transfer-Encoding" in headers and headers["Transfer-Encoding"] == "chunked" 

94 ): 

95 body = "" 

96 while True: 

97 chunk = b"" 

98 while not chunk.endswith(b"\r\n"): 

99 chunk += sock.recv(1) 

100 length = int(chunk.decode("utf-8"), 16) 

101 if length == 0: 

102 break 

103 body = body + sock.recv(length).decode("utf-8") 

104 sock.recv(2) # Skip ending \r\n 

105 else: 

106 raise ValueError(headers) 

107 return HttpResponse( 

108 protocol_version=protocol_version, 

109 status_code=status_code, 

110 status_text=status_text, 

111 headers=headers, 

112 json_body=json.loads(body), 

113 )