Skip to content

Commit 703af19

Browse files
committed
refactor(gitlab): move all GitLab requests to dedicated client (#685)
Closes #676
1 parent 9a7bd8c commit 703af19

File tree

8 files changed

+634
-154
lines changed

8 files changed

+634
-154
lines changed

reana_server/config.py

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -328,8 +328,6 @@ def _get_rate_limit(env_variable: str, default: str) -> str:
328328
REANA_GITLAB_OAUTH_APP_SECRET = os.getenv("REANA_GITLAB_OAUTH_APP_SECRET", "CHANGE_ME")
329329
REANA_GITLAB_HOST = os.getenv("REANA_GITLAB_HOST", None)
330330
REANA_GITLAB_URL = "https://{}".format((REANA_GITLAB_HOST or "CHANGE ME"))
331-
REANA_GITLAB_MAX_PER_PAGE = 100
332-
"""Maximum number of items that can be listed in a single GitLab's paginated response."""
333331

334332
# Workflow scheduler
335333
# ==================

reana_server/gitlab_client.py

Lines changed: 266 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,266 @@
1+
# This file is part of REANA.
2+
# Copyright (C) 2024 CERN.
3+
#
4+
# REANA is free software; you can redistribute it and/or modify it
5+
# under the terms of the MIT License; see LICENSE file for more details.
6+
"""REANA-Server GitLab client."""
7+
8+
from typing import Dict, Optional, Union
9+
from urllib.parse import quote_plus
10+
import requests
11+
import yaml
12+
13+
from reana_commons.k8s.secrets import REANAUserSecretsStore
14+
15+
from reana_server.config import REANA_GITLAB_HOST
16+
17+
18+
class GitLabClientException(Exception):
19+
"""Base class for GitLab exceptions."""
20+
21+
def __init__(self, message):
22+
"""Initialise the GitLabClientException exception."""
23+
self.message = message
24+
25+
def __str__(self):
26+
"""Return the exception message."""
27+
return self.message
28+
29+
30+
class GitLabClientRequestError(GitLabClientException):
31+
"""Raised when a GitLab API request fails."""
32+
33+
def __init__(self, response, message=None):
34+
"""Initialise the GitLabClientRequestError exception."""
35+
message = message or f"GitLab API request failed: {response.status_code}"
36+
super().__init__(message)
37+
self.response = response
38+
39+
40+
class GitLabClientInvalidToken(GitLabClientException):
41+
"""Raised when GitLab token is invalid or missing."""
42+
43+
def __init__(self, message=None):
44+
"""Initialise the GitLabClientInvalidToken exception."""
45+
message = message or (
46+
"GitLab token invalid or missing, "
47+
"please go to your profile page on REANA "
48+
"and reconnect to GitLab."
49+
)
50+
super().__init__(message)
51+
52+
53+
class GitLabClient:
54+
"""Client for interacting with the GitLab API."""
55+
56+
MAX_PER_PAGE = 100
57+
"""Maximum number of items per page in paginated responses."""
58+
59+
@classmethod
60+
def from_k8s_secret(cls, user_id, **kwargs):
61+
"""
62+
Create a client instance taking the GitLab token from the user's k8s secret.
63+
64+
:param user_id: User UUID.
65+
"""
66+
secrets_store = REANAUserSecretsStore(str(user_id))
67+
gitlab_token = secrets_store.get_secret_value("gitlab_access_token")
68+
if not gitlab_token:
69+
raise GitLabClientInvalidToken
70+
return cls(access_token=gitlab_token, **kwargs)
71+
72+
def __init__(
73+
self,
74+
host: str = REANA_GITLAB_HOST,
75+
access_token: Optional[str] = None,
76+
http_request=None,
77+
):
78+
"""Initialise the GitLab client.
79+
80+
:param host: GitLab host (default: REANA_GITLAB_HOST)
81+
:param access_token: GitLab access token (default: unauthenticated)
82+
:param http_request: Function to make HTTP requests (default: requests.request).
83+
"""
84+
self.access_token = access_token
85+
self.host = host
86+
self._http_request = (
87+
http_request if http_request is not None else requests.request
88+
)
89+
90+
def _make_url(self, path: str, **kwargs: Dict[str, str]):
91+
quoted = {k: quote_plus(v) for k, v in kwargs.items()}
92+
return f"https://{self.host}/api/v4/{path.lstrip('/').format(**quoted)}"
93+
94+
def _request(self, verb: str, url: str, params=None, data=None):
95+
res = self._http_request(verb, url, params=params, data=data)
96+
if res.status_code == 401:
97+
raise GitLabClientInvalidToken
98+
elif res.status_code >= 400:
99+
message = f"GitLab API request failed: {res.status_code}, {res.content}"
100+
try:
101+
response = res.json()
102+
if "message" in response:
103+
message = f"GitLab API request failed: {res.status_code}, {response['message']}"
104+
elif "error_description" in response:
105+
message = f"GitLab API request failed: {res.status_code}, {response['error_description']}"
106+
except Exception:
107+
pass
108+
raise GitLabClientRequestError(res, message)
109+
return res
110+
111+
def _get(self, url, params=None):
112+
return self._request("GET", url, params)
113+
114+
def _post(self, url, params=None, data=None):
115+
return self._request("POST", url, params, data)
116+
117+
def _unroll_pagination(self, url, params):
118+
# use maximum allowed value to avoid too many network requests
119+
params["per_page"] = self.MAX_PER_PAGE
120+
res = self._get(url, params)
121+
while res:
122+
yield from res.json()
123+
next_url = res.links.get("next", {}).get("url")
124+
res = self._get(next_url) if next_url else None
125+
126+
def oauth_token(self, data):
127+
"""Request an OAuth token from GitLab.
128+
129+
:param data: Dictionary with the following keys:
130+
- client_id: The client ID of the application.
131+
- client_secret: The client secret of the application.
132+
- code: The authorization code.
133+
- redirect_uri: The redirect URI of the application.
134+
- grant_type: The grant type of the request.
135+
"""
136+
# _make_url is not used here as the URL does not contain `api/v4`
137+
url = f"https://{self.host}/oauth/token"
138+
return self._post(url, data=data)
139+
140+
def get_file(
141+
self, project: Union[int, str], file_path: str, ref: Optional[str] = None
142+
):
143+
"""Get the content of a file in a GitLab repository.
144+
145+
:param project: Project ID or name.
146+
:param file_path: Path to the file.
147+
:param ref: The name of a repository branch, tag or commit.
148+
"""
149+
url = self._make_url(
150+
"projects/{project}/repository/files/{file_path}/raw",
151+
project=str(project),
152+
file_path=file_path,
153+
)
154+
params = {
155+
"access_token": self.access_token,
156+
"ref": ref,
157+
}
158+
return self._get(url, params)
159+
160+
def get_projects(self, page: int = 1, per_page: Optional[int] = None, **kwargs):
161+
"""Get a list of projects the user has access to.
162+
163+
:param page: Page number.
164+
:param per_page: Number of projects per page.
165+
:param kwargs: Additional query parameters to customise and filter the results.
166+
"""
167+
url = self._make_url("projects")
168+
params = {
169+
"access_token": self.access_token,
170+
"page": page,
171+
"per_page": per_page,
172+
**kwargs,
173+
}
174+
return self._get(url, params)
175+
176+
def get_webhooks(
177+
self, project: Union[int, str], page: int = 1, per_page: Optional[int] = None
178+
):
179+
"""Get a list of webhooks for a project.
180+
181+
:param project: Project ID or name.
182+
:param page: Page number.
183+
:param per_page: Number of webhooks per page.
184+
"""
185+
url = self._make_url("projects/{project}/hooks", project=str(project))
186+
params = {
187+
"access_token": self.access_token,
188+
"page": page,
189+
"per_page": per_page,
190+
}
191+
return self._get(url, params)
192+
193+
def get_all_webhooks(self, project: Union[int, str]):
194+
"""Get all webhooks for a project.
195+
196+
Compared to `get_webhooks`, this method returns a generator that yields
197+
all webhooks in the project, making multiple requests if necessary.
198+
199+
:param project: Project ID or name.
200+
"""
201+
url = self._make_url("projects/{project}/hooks", project=str(project))
202+
params = {"access_token": self.access_token}
203+
yield from self._unroll_pagination(url, params)
204+
205+
def create_webhook(self, project: Union[int, str], config: Dict):
206+
"""Create a webhook for a project.
207+
208+
:param project: Project ID or name.
209+
:param config: Dictionary withe the webhook configuration.
210+
See https://docs.gitlab.com/ee/api/projects.html#add-project-hook
211+
"""
212+
url = self._make_url("projects/{project}/hooks", project=str(project))
213+
params = {"access_token": self.access_token}
214+
return self._post(url, params, data=config)
215+
216+
def delete_webhook(self, project: Union[int, str], hook_id: int):
217+
"""Delete a webhook from a project.
218+
219+
:param project: Project ID or name.
220+
:param hook_id: Webhook ID.
221+
"""
222+
url = self._make_url(
223+
"projects/{project}/hooks/{hook_id}",
224+
project=str(project),
225+
hook_id=str(hook_id),
226+
)
227+
params = {
228+
"access_token": self.access_token,
229+
}
230+
return self._request("DELETE", url, params)
231+
232+
def set_commit_build_status(
233+
self,
234+
project: Union[int, str],
235+
commit_sha: str,
236+
state: str,
237+
description: Optional[str] = None,
238+
name: str = "reana",
239+
):
240+
"""Set the status of a commit in a GitLab repository.
241+
242+
:param project: Project ID or name.
243+
:param commit_sha: The commit SHA.
244+
:param state: The state of the status.
245+
Can be one of 'pending', 'running', 'success', 'failed', 'canceled'.
246+
:param description: A short description of the status.
247+
:param name: The name of the context (default: 'reana').
248+
"""
249+
url = self._make_url(
250+
"projects/{project}/statuses/{commit_sha}",
251+
project=str(project),
252+
commit_sha=commit_sha,
253+
)
254+
params = {
255+
"access_token": self.access_token,
256+
"state": state,
257+
"description": description,
258+
"name": name,
259+
}
260+
return self._post(url, params)
261+
262+
def get_user(self):
263+
"""Get the user's profile."""
264+
url = self._make_url("user")
265+
params = {"access_token": self.access_token}
266+
return self._get(url, params)

0 commit comments

Comments
 (0)