Skip to content

Commit bcde8a8

Browse files
author
Glenn Snyder
authored
Merge pull request #127 from AR-Calder/managable-monolithic
Split HubRestApi mega-class into multiple files
2 parents 37a44e8 + 59ffd84 commit bcde8a8

22 files changed

+1838
-1647
lines changed

blackduck/Authentication.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import logging
2+
import requests
3+
import json
4+
from operator import itemgetter
5+
import urllib.parse
6+
7+
logger = logging.getLogger(__name__)

blackduck/Components.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import logging
2+
import requests
3+
import json
4+
from operator import itemgetter
5+
import urllib.parse
6+
7+
logger = logging.getLogger(__name__)
8+
9+
def find_component_info_for_protex_component(self, protex_component_id, protex_component_release_id):
10+
'''Will return the Hub component corresponding to the protex_component_id, and if a release (version) id
11+
is given, the response will also include the component-version. Returns an empty list if there were
12+
no components found.
13+
'''
14+
url = self.config['baseurl'] + "/api/components"
15+
if protex_component_release_id:
16+
query = "?q=bdsuite:{}%23{}&limit=9999".format(protex_component_id, protex_component_release_id)
17+
else:
18+
query = "?q=bdsuite:{}&limit=9999".format(protex_component_id)
19+
with_query = url + query
20+
logger.debug("Finding the Hub componet for Protex component id {}, release id {} using query/url {}".format(
21+
protex_component_id, protex_component_release_id, with_query))
22+
response = self.execute_get(with_query)
23+
logger.debug("query results in status code {}, json data: {}".format(response.status_code, response.json()))
24+
# TODO: Error checking and retry? For now, as POC just assuming it worked
25+
component_list_d = response.json()
26+
return response.json()
27+
28+
def _get_components_url(self):
29+
return self.get_urlbase() + "/api/components"
30+
31+
def get_components(self, limit=100, parameters={}):
32+
if limit:
33+
parameters.update({'limit':limit})
34+
#
35+
# I was only able to GET components when using this internal media type which is how the GUI works
36+
# July 19, 2019 Glenn Snyder
37+
#
38+
custom_headers = {'Accept':'application/vnd.blackducksoftware.internal-1+json'}
39+
url = self._get_components_url() + self._get_parameter_string(parameters)
40+
response = self.execute_get(url, custom_headers=custom_headers)
41+
return response.json()
42+
43+
def search_components(self, search_str_or_query, limit=100, parameters={}):
44+
if limit:
45+
parameters.update({'limit':limit})
46+
if search_str_or_query.startswith("q="):
47+
# allow caller to override original behavior with their own query
48+
query = search_str_or_query
49+
else:
50+
# maintain original, somewhat flawed behavior
51+
query = "q=name:{}".format(search_str_or_query)
52+
parm_str = self._get_parameter_string(parameters)
53+
url = self.get_apibase() + "/search/components{}&{}".format(parm_str, query)
54+
response = self.execute_get(url)
55+
return response.json()
56+
57+
def get_component_by_id(self, component_id):
58+
url = self.config['baseurl'] + "/api/components/{}".format(component_id)
59+
return self.get_component_by_url(url)
60+
61+
def get_component_by_url(self, component_url):
62+
headers = self.get_headers()
63+
response = self.execute_get(component_url)
64+
jsondata = response.json()
65+
return jsondata
66+
67+
def update_component_by_id(self, component_id, update_json):
68+
url = self.config["baseurl"] + "/api/components/{}".format(component_id)
69+
return self.update_component_by_url(url, update_json)
70+
71+
def update_component_by_url(self, component_url, update_json):
72+
return self.execute_put(component_url, update_json)

blackduck/Core.py

Lines changed: 183 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
import logging
2+
import requests
3+
import json
4+
from operator import itemgetter
5+
import urllib.parse
6+
7+
logger = logging.getLogger(__name__)
8+
9+
def read_config(self):
10+
try:
11+
with open('.restconfig.json','r') as f:
12+
self.config = json.load(f)
13+
except:
14+
logging.error(f"Unable to load configuration from '.restconfig.json'. Make sure you create one with proper connection and authentication values for your Black Duck server")
15+
raise
16+
17+
def write_config(self):
18+
with open(self.configfile,'w') as f:
19+
json.dump(self.config, f, indent=3)
20+
21+
def get_auth_token(self):
22+
api_token = self.config.get('api_token', False)
23+
if api_token:
24+
authendpoint = "/api/tokens/authenticate"
25+
url = self.config['baseurl'] + authendpoint
26+
session = requests.session()
27+
response = session.post(
28+
url,
29+
data={},
30+
headers={'Authorization': 'token {}'.format(api_token)},
31+
verify=not self.config['insecure']
32+
)
33+
csrf_token = response.headers['X-CSRF-TOKEN']
34+
try:
35+
bearer_token = json.loads(response.content.decode('utf-8'))['bearerToken']
36+
except json.decoder.JSONDecodeError as e:
37+
logger.exception("Authentication failure, could not obtain bearer token")
38+
raise Exception("Failed to obtain bearer token, check for valid authentication token")
39+
return (bearer_token, csrf_token, None)
40+
else:
41+
authendpoint="/j_spring_security_check"
42+
url = self.config['baseurl'] + authendpoint
43+
session=requests.session()
44+
credentials = dict()
45+
credentials['j_username'] = self.config['username']
46+
credentials['j_password'] = self.config['password']
47+
response = session.post(url, credentials, verify= not self.config['insecure'])
48+
cookie = response.headers['Set-Cookie']
49+
token = cookie[cookie.index('=')+1:cookie.index(';')]
50+
return (token, None, cookie)
51+
52+
def _get_hub_rest_api_version_info(self):
53+
'''Get the version info from the server, if available
54+
'''
55+
session = requests.session()
56+
url = self.config['baseurl'] + "/api/current-version"
57+
response = session.get(url, verify = not self.config['insecure'])
58+
59+
if response.status_code == 200:
60+
version_info = response.json()
61+
if 'version' in version_info:
62+
return version_info
63+
else:
64+
raise UnknownVersion("Did not find the 'version' key in the response to a successful GET on /api/current-version")
65+
else:
66+
raise UnknownVersion("Failed to retrieve the version info from {}, status code {}".format(url, response.status_code))
67+
68+
def _get_major_version(self):
69+
return self.version_info['version'].split(".")[0]
70+
71+
def get_urlbase(self):
72+
return self.config['baseurl']
73+
74+
def get_headers(self):
75+
if self.config.get('api_token', False):
76+
return {
77+
'X-CSRF-TOKEN': self.csrf_token,
78+
'Authorization': 'Bearer {}'.format(self.token),
79+
'Accept': 'application/json',
80+
'Content-Type': 'application/json'}
81+
else:
82+
if self.bd_major_version == "3":
83+
return {"Cookie": self.cookie}
84+
else:
85+
return {"Authorization":"Bearer " + self.token}
86+
87+
def get_api_version(self):
88+
url = self.get_urlbase() + '/api/current-version'
89+
response = self.execute_get(url)
90+
version = response.json().get('version', 'unknown')
91+
return version
92+
93+
def _get_parameter_string(self, parameters={}):
94+
parameter_string = "&".join(["{}={}".format(k,urllib.parse.quote(str(v))) for k,v in sorted(parameters.items(), key=itemgetter(0))])
95+
return "?" + parameter_string
96+
97+
def get_tags_url(self, component_or_project):
98+
# Utility method to return the tags URL from either a component or project object
99+
url = None
100+
for link_d in component_or_project['_meta']['links']:
101+
if link_d['rel'] == 'tags':
102+
return link_d['href']
103+
return url
104+
105+
def get_link(self, bd_rest_obj, link_name):
106+
# returns the URL for the link_name OR None
107+
if bd_rest_obj and '_meta' in bd_rest_obj and 'links' in bd_rest_obj['_meta']:
108+
for link_obj in bd_rest_obj['_meta']['links']:
109+
if 'rel' in link_obj and link_obj['rel'] == link_name:
110+
return link_obj.get('href', None)
111+
else:
112+
logger.warning("This does not appear to be a BD REST object. It should have ['_meta']['links']")
113+
114+
def get_limit_paramstring(self, limit):
115+
return "?limit={}".format(limit)
116+
117+
def get_apibase(self):
118+
return self.config['baseurl'] + "/api"
119+
120+
def execute_delete(self, url):
121+
headers = self.get_headers()
122+
response = requests.delete(url, headers=headers, verify = not self.config['insecure'])
123+
return response
124+
125+
def _validated_json_data(self, data_to_validate):
126+
if isinstance(data_to_validate, dict) or isinstance(data_to_validate, list):
127+
json_data = json.dumps(data_to_validate)
128+
else:
129+
json_data = data_to_validate
130+
json.loads(json_data) # will fail with JSONDecodeError if invalid
131+
return json_data
132+
133+
def execute_get(self, url, custom_headers={}):
134+
headers = self.get_headers()
135+
headers.update(custom_headers)
136+
response = requests.get(url, headers=headers, verify = not self.config['insecure'])
137+
return response
138+
139+
def execute_put(self, url, data, custom_headers={}):
140+
json_data = self._validated_json_data(data)
141+
headers = self.get_headers()
142+
headers["Content-Type"] = "application/json"
143+
headers.update(custom_headers)
144+
response = requests.put(url, headers=headers, data=json_data, verify = not self.config['insecure'])
145+
return response
146+
147+
def _create(self, url, json_body):
148+
response = self.execute_post(url, json_body)
149+
# v4+ returns the newly created location in the response headers
150+
# and there is nothing in the response json
151+
# whereas v3 returns the newly created object in the response json
152+
if response.status_code == 201:
153+
if "location" in response.headers:
154+
return response.headers["location"]
155+
else:
156+
try:
157+
response_json = response.json()
158+
except json.decoder.JSONDecodeError:
159+
logger.warning('did not receive any json data back')
160+
else:
161+
if '_meta' in response_json and 'href' in response_json['_meta']:
162+
return response_json['_meta']['href']
163+
else:
164+
return response_json
165+
elif response.status_code == 412:
166+
raise CreateFailedAlreadyExists("Failed to create the object because it already exists - url {}, body {}, response {}".format(url, json_body, response))
167+
else:
168+
raise CreateFailedUnknown("Failed to create the object for an unknown reason - url {}, body {}, response {}".format(url, json_body, response))
169+
170+
def execute_post(self, url, data, custom_headers={}):
171+
json_data = self._validated_json_data(data)
172+
headers = self.get_headers()
173+
headers["Content-Type"] = "application/json"
174+
headers.update(custom_headers)
175+
response = requests.post(url, headers=headers, data=json_data, verify = not self.config['insecure'])
176+
return response
177+
178+
def get_matched_components(self, version_obj, limit=9999):
179+
url = "{}/matched-files".format(version_obj['_meta']['href'])
180+
param_string = self._get_parameter_string({'limit': limit})
181+
url = "{}{}".format(url, param_string)
182+
response = self.execute_get(url)
183+
return response.json()

blackduck/CustomFields.py

Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import logging
2+
import requests
3+
import json
4+
from operator import itemgetter
5+
import urllib.parse
6+
7+
logger = logging.getLogger(__name__)
8+
9+
def _get_cf_url(self):
10+
return self.get_apibase() + "/custom-fields/objects"
11+
12+
def supported_cf_object_types(self):
13+
'''Get the types and cache them since they are static (on a per-release basis)'''
14+
if not hasattr(self, "_cf_object_types"):
15+
logger.debug("retrieving object types")
16+
self._cf_object_types = [cfo['name'] for cfo in self.get_cf_objects().get('items', [])]
17+
return self._cf_object_types
18+
19+
def get_cf_objects(self):
20+
'''Get CF objects and cache them since these are static (on a per-release basis)'''
21+
url = self._get_cf_url()
22+
if not hasattr(self, "_cf_objects"):
23+
logger.debug("retrieving objects")
24+
response = self.execute_get(url)
25+
self._cf_objects = response.json()
26+
return self._cf_objects
27+
28+
def _get_cf_object_url(self, object_name):
29+
for cf_object in self.get_cf_objects().get('items', []):
30+
if cf_object['name'].lower() == object_name.lower():
31+
return cf_object['_meta']['href']
32+
33+
def get_cf_object(self, object_name):
34+
assert object_name in self.supported_cf_object_types(), "Object name {} not one of the supported types ({})".format(object_name, self.supported_cf_object_types())
35+
36+
object_url = self._get_cf_object_url(object_name)
37+
response = self.execute_get(object_url)
38+
return response.json()
39+
40+
def _get_cf_obj_rel_path(self, object_name):
41+
return object_name.lower().replace(" ", "-")
42+
43+
def create_cf(self, object_name, field_type, description, label, position, active=True, initial_options=[]):
44+
'''
45+
Create a custom field for the given object type (e.g. "Project", "Project Version") using the field_type and other parameters.
46+
47+
Initial options are needed for field types like multi-select where the multiple values to choose from must also be provided.
48+
49+
initial_options = [{"label":"val1", "position":0}, {"label":"val2", "position":1}]
50+
'''
51+
assert isinstance(position, int) and position >= 0, "position must be an integer that is greater than or equal to 0"
52+
assert field_type in ["BOOLEAN", "DATE", "DROPDOWN", "MULTISELECT", "RADIO", "TEXT", "TEXTAREA"]
53+
54+
types_using_initial_options = ["DROPDOWN", "MULTISELECT", "RADIO"]
55+
56+
post_url = self._get_cf_object_url(object_name) + "/fields"
57+
cf_object = self._get_cf_obj_rel_path(object_name)
58+
cf_request = {
59+
"active": active,
60+
"description": description,
61+
"label": label,
62+
"position": position,
63+
"type": field_type,
64+
}
65+
if field_type in types_using_initial_options and initial_options:
66+
cf_request.update({"initialOptions": initial_options})
67+
response = self.execute_post(post_url, data=cf_request)
68+
return response
69+
70+
def delete_cf(self, object_name, field_id):
71+
'''Delete a custom field from a given object type, e.g. Project, Project Version, Component, etc
72+
73+
WARNING: Deleting a custom field is irreversiable. Any data in the custom fields could be lost so use with caution.
74+
'''
75+
assert object_name in self.supported_cf_object_types(), "You must supply a supported object name that is in {}".format(self.supported_cf_object_types())
76+
77+
delete_url = self._get_cf_object_url(object_name) + "/fields/{}".format(field_id)
78+
return self.execute_delete(delete_url)
79+
80+
def get_custom_fields(self, object_name):
81+
'''Get the custom field (definition) for a given object type, e.g. Project, Project Version, Component, etc
82+
'''
83+
assert object_name in self.supported_cf_object_types(), "You must supply a supported object name that is in {}".format(self.supported_cf_object_types())
84+
85+
url = self._get_cf_object_url(object_name) + "/fields"
86+
87+
response = self.execute_get(url)
88+
return response.json()
89+
90+
def get_cf_values(self, obj):
91+
'''Get all of the custom fields from an object such as a Project, Project Version, Component, etc
92+
93+
The obj is expected to be the JSON document for a project, project-version, component, etc
94+
'''
95+
url = self.get_link(obj, "custom-fields")
96+
response = self.execute_get(url)
97+
return response.json()
98+
99+
def get_cf_value(self, obj, field_id):
100+
'''Get a custom field value from an object such as a Project, Project Version, Component, etc
101+
102+
The obj is expected to be the JSON document for a project, project-version, component, etc
103+
'''
104+
url = self.get_link(obj, "custom-fields") + "/{}".format(field_id)
105+
response = self.execute_get(url)
106+
return response.json()
107+
108+
def put_cf_value(self, cf_url, new_cf_obj):
109+
'''new_cf_obj is expected to be a modified custom field value object with the values updated accordingly, e.g.
110+
call get_cf_value, modify the object, and then call put_cf_value
111+
'''
112+
return self.execute_put(cf_url, new_cf_obj)

0 commit comments

Comments
 (0)