Skip to content

Commit a596566

Browse files
committed
Add: Support for the openvasd HTTP API
A new subpackage for sending requests to HTTP APIs has been added which also includes the first version of the openvasd API.
1 parent ddd71ec commit a596566

18 files changed

+1501
-8
lines changed

gvm/http/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later

gvm/http/core/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later

gvm/http/core/api.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
from typing import Optional
6+
7+
from gvm.http.core.connector import HttpApiConnector
8+
9+
10+
class GvmHttpApi:
11+
"""
12+
Base class for HTTP-based GVM APIs.
13+
"""
14+
15+
def __init__(self, connector: HttpApiConnector, *, api_key: Optional[str] = None):
16+
"""
17+
Create a new generic GVM HTTP API instance.
18+
19+
Args:
20+
connector: The connector handling the HTTP(S) connection
21+
api_key: Optional API key for authentication
22+
"""
23+
24+
"The connector handling the HTTP(S) connection"
25+
self._connector: HttpApiConnector = connector
26+
27+
"Optional API key for authentication"
28+
self._api_key: Optional[str] = api_key

gvm/http/core/connector.py

Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
5+
import urllib.parse
6+
from typing import Optional, Tuple, Dict, Any
7+
8+
from requests import Session
9+
10+
from gvm.http.core.response import HttpResponse
11+
12+
13+
def url_join(base: str, rel_path: str) -> str:
14+
"""
15+
Combines a base URL and a relative path into one URL.
16+
17+
Unlike `urrlib.parse.urljoin` the base path will always be the parent of the relative path as if it
18+
ends with "/".
19+
"""
20+
if base.endswith("/"):
21+
return urllib.parse.urljoin(base, rel_path)
22+
else:
23+
return urllib.parse.urljoin(base + "/", rel_path)
24+
25+
26+
class HttpApiConnector:
27+
"""
28+
Class for connecting to HTTP based API servers, sending requests and receiving the responses.
29+
"""
30+
31+
@classmethod
32+
def _new_session(cls):
33+
"""
34+
Creates a new session
35+
"""
36+
return Session()
37+
38+
def __init__(
39+
self,
40+
base_url: str,
41+
*,
42+
server_ca_path: Optional[str] = None,
43+
client_cert_paths: Optional[str | Tuple[str]] = None,
44+
):
45+
"""
46+
Create a new HTTP API Connector.
47+
48+
Args:
49+
base_url: The base server URL to which request-specific paths will be appended for the requests
50+
server_ca_path: Optional path to a CA certificate for verifying the server.
51+
If none is given, server verification is disabled.
52+
client_cert_paths: Optional path to a client private key and certificate for authentication.
53+
Can be a combined key and certificate file or a tuple containing separate files.
54+
The key must not be encrypted.
55+
"""
56+
57+
self.base_url = base_url
58+
"The base server URL to which request-specific paths will be appended for the requests"
59+
60+
self._session = self._new_session()
61+
"Internal session handling the HTTP requests"
62+
if server_ca_path:
63+
self._session.verify = server_ca_path
64+
if client_cert_paths:
65+
self._session.cert = client_cert_paths
66+
67+
def update_headers(self, new_headers: Dict[str, str]) -> None:
68+
"""
69+
Updates the headers sent with each request, e.g. for passing an API key
70+
71+
Args:
72+
new_headers: Dict containing the new headers
73+
"""
74+
self._session.headers.update(new_headers)
75+
76+
def delete(
77+
self,
78+
rel_path: str,
79+
*,
80+
raise_for_status: bool = True,
81+
params: Optional[Dict[str,str]] = None,
82+
headers: Optional[Dict[str,str]] = None,
83+
) -> HttpResponse:
84+
"""
85+
Sends a ``DELETE`` request and returns the response.
86+
87+
Args:
88+
rel_path: The relative path for the request
89+
raise_for_status: Whether to raise an error if response has a non-success HTTP status code
90+
params: Optional dict of URL-encoded parameters
91+
headers: Optional additional headers added to the request
92+
93+
Return:
94+
The HTTP response.
95+
"""
96+
url = url_join(self.base_url, rel_path)
97+
r = self._session.delete(url, params=params, headers=headers)
98+
if raise_for_status:
99+
r.raise_for_status()
100+
return HttpResponse.from_requests_lib(r)
101+
102+
def get(
103+
self,
104+
rel_path: str,
105+
*,
106+
raise_for_status: bool = True,
107+
params: Optional[Dict[str,str]] = None,
108+
headers: Optional[Dict[str,str]] = None,
109+
) -> HttpResponse:
110+
"""
111+
Sends a ``GET`` request and returns the response.
112+
113+
Args:
114+
rel_path: The relative path for the request
115+
raise_for_status: Whether to raise an error if response has a non-success HTTP status code
116+
params: Optional dict of URL-encoded parameters
117+
headers: Optional additional headers added to the request
118+
119+
Return:
120+
The HTTP response.
121+
"""
122+
url = url_join(self.base_url, rel_path)
123+
r = self._session.get(url, params=params, headers=headers)
124+
if raise_for_status:
125+
r.raise_for_status()
126+
return HttpResponse.from_requests_lib(r)
127+
128+
def post_json(
129+
self,
130+
rel_path: str,
131+
json: Any,
132+
*,
133+
raise_for_status: bool = True,
134+
params: Optional[Dict[str, str]] = None,
135+
headers: Optional[Dict[str, str]] = None,
136+
) -> HttpResponse:
137+
"""
138+
Sends a ``POST`` request, using the given JSON-compatible object as the request body, and returns the response.
139+
140+
Args:
141+
rel_path: The relative path for the request
142+
json: The object to use as the request body.
143+
raise_for_status: Whether to raise an error if response has a non-success HTTP status code
144+
params: Optional dict of URL-encoded parameters
145+
headers: Optional additional headers added to the request
146+
147+
Return:
148+
The HTTP response.
149+
"""
150+
url = url_join(self.base_url, rel_path)
151+
r = self._session.post(url, json=json, params=params, headers=headers)
152+
if raise_for_status:
153+
r.raise_for_status()
154+
return HttpResponse.from_requests_lib(r)

gvm/http/core/headers.py

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
from dataclasses import dataclass
5+
from typing import Self, Dict, Optional
6+
7+
8+
@dataclass
9+
class ContentType:
10+
"""
11+
Class representing the content type of a HTTP response.
12+
"""
13+
14+
media_type: str
15+
"The MIME media type, e.g. \"application/json\""
16+
17+
params: Dict[str,str]
18+
"Dictionary of parameters in the content type header"
19+
20+
charset: Optional[str]
21+
"The charset parameter in the content type header if it is set"
22+
23+
@classmethod
24+
def from_string(
25+
cls,
26+
header_string: str,
27+
fallback_media_type: Optional[str] = "application/octet-stream"
28+
) -> Self:
29+
"""
30+
Parse the content of content type header into a ContentType object.
31+
32+
Args:
33+
header_string: The string to parse
34+
fallback_media_type: The media type to use if the `header_string` is `None` or empty.
35+
"""
36+
media_type = fallback_media_type
37+
params = {}
38+
charset = None
39+
40+
if header_string:
41+
parts = header_string.split(";")
42+
media_type = parts[0].strip()
43+
for param in parts[1:]:
44+
param = param.strip()
45+
if "=" in param:
46+
key, value = map(lambda x: x.strip(), param.split("=", 1))
47+
params[key] = value
48+
if key == 'charset':
49+
charset = value
50+
else:
51+
params[param] = True
52+
53+
return ContentType(media_type=media_type, params=params, charset=charset)

gvm/http/core/response.py

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later
4+
from dataclasses import dataclass
5+
from typing import Any, Dict, Self, Optional
6+
from requests import Request, Response
7+
8+
from gvm.http.core.headers import ContentType
9+
10+
11+
@dataclass
12+
class HttpResponse:
13+
"""
14+
Class representing an HTTP response.
15+
"""
16+
body: Any
17+
status: int
18+
headers: Dict[str, str]
19+
content_type: Optional[ContentType]
20+
21+
@classmethod
22+
def from_requests_lib(cls, r: Response) -> Self:
23+
"""
24+
Creates a new HTTP response object from a Request object created by the "Requests" library.
25+
26+
Args:
27+
r: The request object to convert.
28+
29+
Return:
30+
A HttpResponse object representing the response.
31+
32+
An empty body is represented by None.
33+
If the content-type header in the response is set to 'application/json'.
34+
A non-empty body will be parsed accordingly.
35+
"""
36+
ct = ContentType.from_string(r.headers.get('content-type'))
37+
body = r.content
38+
39+
if r.content == b'':
40+
body = None
41+
elif ct is not None:
42+
if ct.media_type.lower() == 'application/json':
43+
body = r.json()
44+
45+
return HttpResponse(body, r.status_code, r.headers, ct)

gvm/http/openvasd/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# SPDX-FileCopyrightText: 2025 Greenbone AG
2+
#
3+
# SPDX-License-Identifier: GPL-3.0-or-later

0 commit comments

Comments
 (0)