openrelik-api-client 0.2.4__tar.gz → 0.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.3
2
2
  Name: openrelik-api-client
3
- Version: 0.2.4
3
+ Version: 0.4.0
4
4
  Summary: API client that automatically handles token refresh
5
5
  Author: Johan Berggren
6
6
  Author-email: jberggren@gmail.com
@@ -0,0 +1,174 @@
1
+ # Copyright 2024 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ import warnings
16
+ from typing import Any
17
+
18
+ import requests
19
+ from requests.exceptions import RequestException
20
+
21
+ from .configs import ConfigsAPI
22
+ from .files import FilesAPI
23
+
24
+
25
+ class APIClient:
26
+ """
27
+ A reusable API client that automatically handles token refresh on 401 responses.
28
+
29
+ Attributes:
30
+ api_server_url (str): The URL of the API server.
31
+ api_key (str): The API key.
32
+ api_version (str): The API version.
33
+ session (requests.Session): The session object.
34
+
35
+ Example usage:
36
+ client = APIClient(api_server_url, refresh_token)
37
+ response = client.get("/users/me/")
38
+ print(response.json())
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ api_server_url,
44
+ api_key=None,
45
+ api_version="v1",
46
+ ):
47
+ self.base_url = f"{api_server_url}/api/{api_version}"
48
+ self.session = TokenRefreshSession(api_server_url, api_key)
49
+
50
+ def get(self, endpoint, **kwargs):
51
+ """Sends a GET request to the specified API endpoint."""
52
+ url = f"{self.base_url}{endpoint}"
53
+ return self.session.get(url, **kwargs)
54
+
55
+ def post(self, endpoint, data=None, json=None, **kwargs):
56
+ """Sends a POST request to the specified API endpoint."""
57
+ url = f"{self.base_url}{endpoint}"
58
+ return self.session.post(url, data=data, json=json, **kwargs)
59
+
60
+ def put(self, endpoint, data=None, **kwargs):
61
+ """Sends a PUT request to the specified API endpoint."""
62
+ url = f"{self.base_url}{endpoint}"
63
+ return self.session.put(url, data=data, **kwargs)
64
+
65
+ def patch(self, endpoint, data=None, json=None, **kwargs):
66
+ """Sends a PATCH request to the specified API endpoint."""
67
+ url = f"{self.base_url}{endpoint}"
68
+ return self.session.patch(url, data=data, json=json, **kwargs)
69
+
70
+ def delete(self, endpoint, **kwargs):
71
+ """Sends a DELETE request to the specified API endpoint."""
72
+ url = f"{self.base_url}{endpoint}"
73
+ return self.session.delete(url, **kwargs)
74
+
75
+ def get_config(self) -> dict[str, Any]:
76
+ """
77
+ DEPRECATED: Use configAPI.get_system_config() instead.
78
+ This method will be removed in a future version.
79
+ """
80
+ warnings.warn(
81
+ "The 'APIClient.get_config' method is deprecated. Please use 'configAPI.get_system_config()' instead.",
82
+ DeprecationWarning,
83
+ stacklevel=2,
84
+ )
85
+ configs_api = ConfigsAPI(self)
86
+ return configs_api.get_system_config()
87
+
88
+ def download_file(self, file_id: int, filename: str) -> str | None:
89
+ """
90
+ DEPRECATED: Use filesAPI.download_file() instead.
91
+ This method will be removed in a future version.
92
+ """
93
+ warnings.warn(
94
+ "The 'APIClient.download_file' method is deprecated. Please use 'filesAPI.download_file()' instead.",
95
+ DeprecationWarning,
96
+ stacklevel=2,
97
+ )
98
+ files_api = FilesAPI(self)
99
+ return files_api.download_file(file_id, filename)
100
+
101
+ def upload_file(self, file_path: str, folder_id: int) -> int | None:
102
+ """
103
+ DEPRECATED: Use filesAPI.download_file() instead.
104
+ This method will be removed in a future version.
105
+ """
106
+ warnings.warn(
107
+ "The 'APIClient.upload_file' method is deprecated. Please use 'filesAPI.upload_file()' instead.",
108
+ DeprecationWarning,
109
+ stacklevel=2,
110
+ )
111
+ files_api = FilesAPI(self)
112
+ return files_api.upload_file(file_path, folder_id)
113
+
114
+
115
+ class TokenRefreshSession(requests.Session):
116
+ """Custom session class that handles automatic token refresh on 401 responses."""
117
+
118
+ def __init__(self, api_server_url, api_key):
119
+ """
120
+ Initializes the TokenRefreshSession with the API server URL and refresh token.
121
+
122
+ Args:
123
+ api_server_url (str): The URL of the API server.
124
+ refresh_token (str): The refresh token.
125
+ """
126
+ super().__init__()
127
+ self.api_server_url = api_server_url
128
+ if api_key:
129
+ self.headers["x-openrelik-refresh-token"] = api_key
130
+
131
+ def request(self, method: str, url: str, **kwargs: dict[str, Any]) -> requests.Response:
132
+ """Intercepts the request to handle token expiration.
133
+
134
+ Args:
135
+ method (str): The HTTP method.
136
+ url (str): The URL of the request.
137
+ **kwargs: Additional keyword arguments for the request.
138
+
139
+ Returns:
140
+ Response: The response object.
141
+
142
+ Raises:
143
+ Exception: If the token refresh fails.
144
+ """
145
+ response = super().request(method, url, **kwargs)
146
+
147
+ if response.status_code == 401:
148
+ if self._refresh_token(url):
149
+ # Retry the original request with the new token
150
+ response = super().request(method, url, **kwargs)
151
+ else:
152
+ raise RuntimeError("API key has expired")
153
+
154
+ return response
155
+
156
+ def _refresh_token(self, requested_url: str) -> bool:
157
+ """Refreshes the access token using the refresh token."""
158
+ refresh_url = f"{self.api_server_url}/auth/refresh"
159
+
160
+ # If the original URL is the same as the refresh URL, do not attempt to refresh as this
161
+ # indicates a faulty or expired api key. This prevents an infinite loop of refresh attempts.
162
+ if requested_url == refresh_url:
163
+ return False
164
+
165
+ try:
166
+ response = self.get(refresh_url)
167
+ response.raise_for_status()
168
+ # Update session headers with the new access token
169
+ new_access_token = response.json().get("new_access_token")
170
+ self.headers["x-openrelik-access-token"] = new_access_token
171
+ return True
172
+ except RequestException as e:
173
+ print(f"Failed to refresh token: {e}")
174
+ return False
@@ -0,0 +1,31 @@
1
+ # Copyright 2025 Google LLC
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # https://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+ from typing import TYPE_CHECKING, Any
16
+
17
+ if TYPE_CHECKING:
18
+ from openrelik_api_client.api_client import APIClient
19
+
20
+
21
+ class ConfigsAPI:
22
+ def __init__(self, api_client: "APIClient"):
23
+ super().__init__()
24
+ self.api_client = api_client
25
+
26
+ def get_system_config(self) -> dict[str, Any]:
27
+ """Gets the current OpenRelik system configuration."""
28
+ endpoint = f"{self.api_client.base_url}/configs/system/"
29
+ response = self.api_client.session.get(endpoint)
30
+ response.raise_for_status()
31
+ return response.json()
@@ -1,4 +1,4 @@
1
- # Copyright 2024 Google LLC
1
+ # Copyright 2025 Google LLC
2
2
  #
3
3
  # Licensed under the Apache License, Version 2.0 (the "License");
4
4
  # you may not use this file except in compliance with the License.
@@ -17,71 +17,73 @@ import os
17
17
  import tempfile
18
18
  import time
19
19
  from pathlib import Path
20
- from typing import Any
20
+ from typing import TYPE_CHECKING, Any
21
21
  from uuid import uuid4
22
22
 
23
- import requests
24
- from requests.exceptions import RequestException
25
23
  from requests_toolbelt import MultipartEncoder
26
24
 
25
+ if TYPE_CHECKING:
26
+ from openrelik_api_client.api_client import APIClient
27
27
 
28
- class APIClient:
29
- """
30
- A reusable API client that automatically handles token refresh on 401 responses.
31
-
32
- Attributes:
33
- api_server_url (str): The URL of the API server.
34
- api_key (str): The API key.
35
- api_version (str): The API version.
36
- session (requests.Session): The session object.
37
-
38
- Example usage:
39
- client = APIClient(api_server_url, refresh_token)
40
- response = client.get("/users/me/")
41
- print(response.json())
42
- """
43
-
44
- def __init__(
45
- self,
46
- api_server_url,
47
- api_key=None,
48
- api_version="v1",
49
- ):
50
- self.base_url = f"{api_server_url}/api/{api_version}"
51
- self.session = TokenRefreshSession(api_server_url, api_key)
52
-
53
- def get(self, endpoint, **kwargs):
54
- """Sends a GET request to the specified API endpoint."""
55
- url = f"{self.base_url}{endpoint}"
56
- return self.session.get(url, **kwargs)
57
-
58
- def post(self, endpoint, data=None, json=None, **kwargs):
59
- """Sends a POST request to the specified API endpoint."""
60
- url = f"{self.base_url}{endpoint}"
61
- return self.session.post(url, data=data, json=json, **kwargs)
62
-
63
- def put(self, endpoint, data=None, **kwargs):
64
- """Sends a PUT request to the specified API endpoint."""
65
- url = f"{self.base_url}{endpoint}"
66
- return self.session.put(url, data=data, **kwargs)
67
-
68
- def patch(self, endpoint, data=None, json=None, **kwargs):
69
- """Sends a PATCH request to the specified API endpoint."""
70
- url = f"{self.base_url}{endpoint}"
71
- return self.session.patch(url, data=data, json=json, **kwargs)
72
-
73
- def delete(self, endpoint, **kwargs):
74
- """Sends a DELETE request to the specified API endpoint."""
75
- url = f"{self.base_url}{endpoint}"
76
- return self.session.delete(url, **kwargs)
77
-
78
- def get_config(self) -> dict[str, Any]:
79
- """Gets the current OpenRelik configuration."""
80
- endpoint = f"{self.base_url}/config/system/"
81
- response = self.session.get(endpoint)
28
+
29
+ class FilesAPI:
30
+ def __init__(self, api_client: "APIClient"):
31
+ super().__init__()
32
+ self.api_client = api_client
33
+
34
+ def get_file_metadata(self, file_id: int) -> dict[str, Any]:
35
+ """Reads a file metadata.
36
+
37
+ Args:
38
+ file_id: The ID of the file to get the metadata from.
39
+
40
+ Returns:
41
+ A dictionary containing file metadata.
42
+
43
+ Raises:
44
+ HTTPError: If the API request failed.
45
+ """
46
+ endpoint = f"{self.api_client.base_url}/files/{file_id}/"
47
+ response = self.api_client.session.get(endpoint)
82
48
  response.raise_for_status()
83
49
  return response.json()
84
50
 
51
+ def get_file_content(
52
+ self, file_id: int, max_file_size_bytes: int, return_type: str = "bytes"
53
+ ) -> str | bytes | None:
54
+ """Download the content of a file.
55
+
56
+ Args:
57
+ file_id: The ID of the file to download.
58
+ max_file_size_bytes: Maximum file size to download in bytes.
59
+ return_type: The type of content to return. Can be "bytes" or "text".
60
+
61
+ Returns:
62
+ The content of the file as bytes if return_type is "bytes", or as a string if
63
+ return_type is "text". Returns None if the file does not exist.
64
+
65
+ Raises:
66
+ RuntimeError: If the file is too large to download.
67
+ ValueError: If the return_type is not "bytes" or "text".
68
+ """
69
+ # Guard against large files as this will read the entire file into memory.
70
+ # If downloading larger files than 100MB is needed, use the download_file method instead.
71
+ MAX_FILE_SIZE_GUARD = 100 * 1024 * 1024 # 100 MB
72
+
73
+ filesize = self.get_file_metadata(file_id).get("filesize")
74
+ if filesize > max_file_size_bytes or filesize > MAX_FILE_SIZE_GUARD:
75
+ raise RuntimeError("File too large to download")
76
+
77
+ endpoint = f"{self.api_client.base_url}/files/{file_id}/download_stream"
78
+ response = self.api_client.session.get(endpoint)
79
+ response.raise_for_status()
80
+ if return_type == "text":
81
+ return response.text
82
+ elif return_type == "bytes":
83
+ return response.content
84
+ else:
85
+ raise ValueError("Invalid return_type. Must be 'bytes' or 'text'.")
86
+
85
87
  def download_file(self, file_id: int, filename: str) -> str | None:
86
88
  """Downloads a file from OpenRelik.
87
89
 
@@ -92,8 +94,9 @@ class APIClient:
92
94
  Returns:
93
95
  str: The path to the downloaded file.
94
96
  """
95
- endpoint = f"{self.base_url}/files/{file_id}/download"
96
- response = self.session.get(endpoint)
97
+ endpoint = f"{self.api_client.base_url}/files/{file_id}/download"
98
+ response = self.api_client.session.get(endpoint)
99
+ response.raise_for_status()
97
100
  filename_prefix, extension = os.path.splitext(filename)
98
101
  file = tempfile.NamedTemporaryFile(
99
102
  mode="wb", prefix=f"{filename_prefix}", suffix=extension, delete=False
@@ -131,7 +134,9 @@ class APIClient:
131
134
  raise FileNotFoundError(f"File {file_path} not found.")
132
135
 
133
136
  if folder_id:
134
- response = self.session.get(f"{self.base_url}/folders/{folder_id}")
137
+ response = self.api_client.session.get(
138
+ f"{self.api_client.base_url}/folders/{folder_id}"
139
+ )
135
140
  if response.status_code == 404:
136
141
  return file_id
137
142
 
@@ -157,8 +162,8 @@ class APIClient:
157
162
  {"file": (file_path.name, chunk, "application/octet-stream")}
158
163
  )
159
164
  headers = {"Content-Type": encoder.content_type}
160
- response = self.session.post(
161
- f"{self.base_url}{endpoint}",
165
+ response = self.api_client.session.post(
166
+ f"{self.api_client.base_url}{endpoint}",
162
167
  headers=headers,
163
168
  data=encoder.to_string(),
164
169
  params=params,
@@ -182,58 +187,30 @@ class APIClient:
182
187
 
183
188
  return file_id
184
189
 
185
-
186
- class TokenRefreshSession(requests.Session):
187
- """Custom session class that handles automatic token refresh on 401 responses."""
188
-
189
- def __init__(self, api_server_url, api_key):
190
- """
191
- Initializes the TokenRefreshSession with the API server URL and refresh token.
190
+ def get_sql_schemas(self, file_id: int) -> dict[str, Any]:
191
+ """Retrieve tables and schemas for a supported SQL file.
192
192
 
193
193
  Args:
194
- api_server_url (str): The URL of the API server.
195
- refresh_token (str): The refresh token.
194
+ file_id: The ID of the file to run the query against.
195
+
196
+ Returns:
197
+ A dictionary containing the results.
196
198
  """
197
- super().__init__()
198
- self.api_server_url = api_server_url
199
- if api_key:
200
- self.headers["x-openrelik-refresh-token"] = api_key
199
+ endpoint = f"{self.api_client.base_url}/files/{file_id}/sql/schemas/"
200
+ response = self.api_client.session.get(endpoint)
201
+ return response.json()
201
202
 
202
- def request(self, method: str, url: str, **kwargs: dict[str, Any]) -> requests.Response:
203
- """Intercepts the request to handle token expiration.
203
+ def run_sql_query(self, file_id: int, query: str) -> dict[str, Any]:
204
+ """Runs a SQL query against a supported SQL file.
204
205
 
205
206
  Args:
206
- method (str): The HTTP method.
207
- url (str): The URL of the request.
208
- **kwargs: Additional keyword arguments for the request.
207
+ file_id: The ID of the file to run the query against.
208
+ query: The SQL query to run.
209
209
 
210
210
  Returns:
211
- Response: The response object.
212
-
213
- Raises:
214
- Exception: If the token refresh fails.
211
+ A dictionary containing the query results.
215
212
  """
216
- response = super().request(method, url, **kwargs)
217
-
218
- if response.status_code == 401:
219
- if self._refresh_token():
220
- # Retry the original request with the new token
221
- response = super().request(method, url, **kwargs)
222
- else:
223
- raise Exception("Token refresh failed")
224
-
225
- return response
226
-
227
- def _refresh_token(self) -> bool:
228
- """Refreshes the access token using the refresh token."""
229
- refresh_url = f"{self.api_server_url}/auth/refresh"
230
- try:
231
- response = self.get(refresh_url)
232
- response.raise_for_status()
233
- # Update session headers with the new access token
234
- new_access_token = response.json().get("new_access_token")
235
- self.headers["x-openrelik-access-token"] = new_access_token
236
- return True
237
- except RequestException as e:
238
- print(f"Failed to refresh token: {e}")
239
- return False
213
+ endpoint = f"{self.api_client.base_url}/files/{file_id}/sql/query/"
214
+ request_body = {"query": query}
215
+ response = self.api_client.session.post(endpoint, json=request_body)
216
+ return response.json()
@@ -22,6 +22,47 @@ class FoldersAPI:
22
22
  super().__init__()
23
23
  self.api_client = api_client
24
24
 
25
+ def list_root_folders(
26
+ self, limit: int, pagination_metadata: bool = False
27
+ ) -> list[dict[str, Any]]:
28
+ """List root folders.
29
+
30
+ Args:
31
+ limit: Maximum number of folders to return.
32
+ pagination_metadata: If True, include pagination metadata in the response.
33
+
34
+ Returns:
35
+ A list of dictionaries containing folder metadata.
36
+
37
+ Raises:
38
+ HTTPError: If the API request failed.
39
+ """
40
+ endpoint = f"{self.api_client.base_url}/folders/all/?page_size={limit}"
41
+ response = self.api_client.session.get(endpoint)
42
+ response.raise_for_status()
43
+ if pagination_metadata:
44
+ folders = response.json()
45
+ else:
46
+ folders = response.json().get("folders", [])
47
+ return folders
48
+
49
+ def list_folder(self, folder_id: int) -> list[dict[str, Any]]:
50
+ """List files in a folder.
51
+
52
+ Args:
53
+ folder_id: The ID of the folder to check.
54
+
55
+ Returns:
56
+ A list of dictionaries containing file metadata
57
+
58
+ Raises:
59
+ HTTPError: If the API request failed.
60
+ """
61
+ endpoint = f"{self.api_client.base_url}/folders/{folder_id}/files/"
62
+ response = self.api_client.session.get(endpoint)
63
+ response.raise_for_status()
64
+ return response.json()
65
+
25
66
  def create_root_folder(self, display_name: str) -> int | None:
26
67
  """Create a root folder.
27
68
 
@@ -56,12 +97,11 @@ class FoldersAPI:
56
97
  Raises:
57
98
  HTTPError: If the API request failed.
58
99
  """
59
- # Corrected: Use the passed folder_id in the endpoint URL
60
100
  endpoint = f"{self.api_client.base_url}/folders/{folder_id}/folders/"
61
101
  data = {"display_name": display_name}
62
102
  response = self.api_client.session.post(endpoint, json=data)
63
103
  response.raise_for_status()
64
- # Corrected: assign to a different variable or use the return directly
104
+
65
105
  new_folder_id = None
66
106
  if response.status_code == 201:
67
107
  new_folder_id = response.json().get("id")
@@ -84,9 +124,7 @@ class FoldersAPI:
84
124
  response.raise_for_status()
85
125
  return response.status_code == 200
86
126
 
87
- def update_folder(
88
- self, folder_id: int, folder_data: dict[str, Any]
89
- ) -> dict[str, Any] | None:
127
+ def update_folder(self, folder_id: int, folder_data: dict[str, Any]) -> dict[str, Any] | None:
90
128
  """Updates an existing folder.
91
129
 
92
130
  Args:
@@ -72,6 +72,25 @@ class WorkflowsAPI:
72
72
  if response.status_code == 200:
73
73
  return response.json()
74
74
 
75
+ def get_workflow_status(self, folder_id: int, workflow_id: int) -> dict[str, Any]:
76
+ """Retrieves a workflow status by ID.
77
+
78
+ Args:
79
+ folder_id: The ID of the folder where the workflow exists.
80
+ workflow_id: The ID of the workflow to retrieve.
81
+
82
+ Returns:
83
+ The workflow status data.
84
+
85
+ Raises:
86
+ HTTPError: If the API request failed.
87
+ """
88
+ endpoint = f"{self.folders_url}/{folder_id}/workflows/{workflow_id}/status"
89
+ response = self.api_client.session.get(endpoint)
90
+ response.raise_for_status()
91
+ if response.status_code == 200:
92
+ return response.json()
93
+
75
94
  def update_workflow(
76
95
  self, folder_id: int, workflow_id: int, workflow_data: dict
77
96
  ) -> dict[str, Any] | None:
@@ -1,6 +1,6 @@
1
1
  [tool.poetry]
2
2
  name = "openrelik-api-client"
3
- version = "0.2.4"
3
+ version = "0.4.0"
4
4
  description = "API client that automatically handles token refresh"
5
5
  authors = ["Johan Berggren <jberggren@gmail.com>"]
6
6
  readme = "README.md"
@@ -10,6 +10,17 @@ python = "^3.10"
10
10
  requests = "^2.32.3"
11
11
  requests_toolbelt = "^1.0.0"
12
12
 
13
+ [tool.poetry.group.dev.dependencies]
14
+ pylint = "^3.1.0"
15
+ pytest = "^8.0.2"
16
+ pytest-mock = "^3.14.0"
17
+ pytest-cov = "^6.0.0"
18
+
19
+ [tool.pytest.ini_options]
20
+ python_files = "*_test.py"
21
+ python_classes = "Test*"
22
+ python_functions = "test_*"
23
+
13
24
  [build-system]
14
25
  requires = ["poetry-core"]
15
26
  build-backend = "poetry.core.masonry.api"