Add the ability to programmatically generate named URLs on client-side.

This commit is contained in:
Aaron Tan 2017-06-13 15:00:23 -04:00
parent 098c27e5cd
commit 2ed3be0aab
4 changed files with 179 additions and 6 deletions

View File

@ -119,10 +119,20 @@ class URLModificationMiddleware(object):
field_class=fields.DictField,
read_only=True,
label=_('Formats of all available named urls'),
help_text=_('Read-only list of key-value pairs that shows the format of all available named'
' URLs. Use this list as a guide when composing named URLs for resources'),
category=_('System'),
category_slug='system',
help_text=_('Read-only list of key-value pairs that shows the standard format of all '
'available named URLs.'),
category=_('Named URL'),
category_slug='named-url',
)
register(
'NAMED_URL_GRAPH_NODES',
field_class=fields.DictField,
read_only=True,
label=_('List of all named url graph nodes.'),
help_text=_('Read-only list of key-value pairs that exposes named URL graph topology.'
' Use this list to programmatically generate named URLs for resources'),
category=_('Named URL'),
category_slug='named-url',
)
def _named_url_to_pk(self, node, named_url):

View File

@ -50,11 +50,18 @@ class GraphNode(object):
stack.pop()
else:
to_append = stack[-1].adj_list[stack[-1].counter][NEXT_NODE]
current_fk_name = stack[-1].adj_list[stack[-1].counter][FK_NAME] + '.'
current_fk_name = "%s." % (stack[-1].adj_list[stack[-1].counter][FK_NAME],)
stack[-1].counter += 1
stack.append(to_append)
return NAMED_URL_RES_DILIMITER.join(named_url_components)
@property
def named_url_repr(self):
ret = {}
ret['fields'] = self.fields
ret['adj_list'] = [[x[FK_NAME], x[NEXT_NODE].model_url_name] for x in self.adj_list]
return ret
def _encode_uri(self, text):
'''
Performance assured: http://stackoverflow.com/a/27086669
@ -144,11 +151,13 @@ class GraphNode(object):
def add_bindings(self):
if self.model_url_name not in settings.NAMED_URL_FORMATS:
settings.NAMED_URL_FORMATS[self.model_url_name] = self.named_url_format
settings.NAMED_URL_GRAPH_NODES[self.model_url_name] = self.named_url_repr
settings.NAMED_URL_MAPPINGS[self.model_url_name] = self.model
def remove_bindings(self):
if self.model_url_name in settings.NAMED_URL_FORMATS:
settings.NAMED_URL_FORMATS.pop(self.model_url_name)
settings.NAMED_URL_GRAPH_NODES.pop(self.model_url_name)
settings.NAMED_URL_MAPPINGS.pop(self.model_url_name)
@ -270,6 +279,7 @@ def _generate_single_graph(configuration, dead_ends):
def generate_graph(models):
settings.NAMED_URL_FORMATS = {}
settings.NAMED_URL_GRAPH_NODES = {}
settings.NAMED_URL_MAPPINGS = {}
candidate_nodes = {}
dead_ends = set()

View File

@ -2,7 +2,7 @@ Starting from API V2, the named URL feature lets user access Tower resources via
## Usage
There is one named-URL-related Tower configuration setting available under `/api/v2/settings/system/`: `NAMED_URL_FORMATS`, which is a *read only* key-value pair list of all available named URL identifier formats. A typical `NAMED_URL_FORMATS` looks like this:
There are two named-URL-related Tower configuration setting available under `/api/v2/settings/named-url/`: `NAMED_URL_FORMATS` and `NAMED_URL_GRAPH_NODES`. `NAMED_URL_FORMATS` is a *read only* key-value pair list of all available named URL identifier formats. A typical `NAMED_URL_FORMATS` looks like this:
```
"NAMED_URL_FORMATS": {
"job_templates": "<name>",
@ -35,6 +35,7 @@ An important aspect of generating unique identifier for named URL is dealing wit
Although `NAMED_URL_FORMATS` is immutable on user side, it will be automatically modified and expanded over time, reflecting underlying resource modification and expansion. Please consult `NAMED_URL_FORMATS` on the same Tower cluster where you want to use named url feature against.
`NAMED_URL_GRAPH_NODES` is another *read-only* list of key-value pairs that exposes the internal graph data structure Tower used to manage named URLs. This is not supposed to be human-readable but should be used for programmatically generating named URLs. An example script of generating named URL given the primary key of arbitrary resource objects that can have named URL, using info provided by `NAMED_URL_GRAPH_NODES`, can be found as `/tools/scripts/pk_to_named_url.py`.
## Identifier Format Protocol
@ -71,3 +72,4 @@ In general, acceptance should follow what's in 'Usage' section. The contents in
* A user following the rules specified in `NAMED_URL_FORMATS` should be able to generate named URL exactly the same as the `named_url` field.
* A user should be able to access specified resource objects via accurately generated named URL. This includes not only the object itself but also its related URLs, like if `/api/v2/res_name/obj_slug/` is valid, `/api/v2/res_name/obj_slug/related_res_name/` should also be valid.
* A user should not be able to access specified resource objects if the given named URL is inaccurate. For example, reserved characters not correctly escaped, or components whose corresponding foreign key field pointing nowhere is not replaced by empty string.
* A user should be able to dynamically generate named URLs by utilizing `NAMED_URL_GRAPH_NODES`.

151
tools/scripts/pk_to_named_url.py Executable file
View File

@ -0,0 +1,151 @@
#!/usr/bin/env python
import argparse
import six
import requests
NAMED_URL_RES_DILIMITER = "--"
NAMED_URL_RES_INNER_DILIMITER = "-"
NAMED_URL_RES_DILIMITER_ENCODE = "%2D"
URL_PATH_RESERVED_CHARSET = {}
for c in ';/?:@=&[]':
URL_PATH_RESERVED_CHARSET[c] = six.moves.urllib.parse.quote(c, safe='')
def _get_named_url_graph(url, auth):
"""Get the graph data structure Tower used to manage all named URLs.
Args:
url: String representing the URL of tower configuration endpoint where
to fetch graph information.
auth: Tuple of username + password to authenticate connection to Tower.
Return:
A dict of graph nodes that in ensembly represent the graph structure. Each
node is represented as a dict of 'fields' and 'adj_list'.
Raises:
N/A
"""
r = requests.get(url, auth=auth, verify=False)
ret = r.json()['NAMED_URL_GRAPH_NODES']
return ret
def _encode_uri(text):
"""Properly encode input text to make it satisfy named URL convention.
Args:
text: the original string to be encoded.
Return:
The encoded string
Raises:
N/A
"""
for c in URL_PATH_RESERVED_CHARSET:
if c in text:
text = text.replace(c, URL_PATH_RESERVED_CHARSET[c])
text = text.replace(NAMED_URL_RES_INNER_DILIMITER,
'[%s]' % NAMED_URL_RES_INNER_DILIMITER)
return text
def _generate_identifier_component(response, fields):
"""Generate an individual component of named URL identifier.
Args:
response: JSON containing the details of a particular resource object.
fields: name of resource object fields needed to generate a named URL
identifier component.
Return:
A string representing generated identifier component.
Raises:
N/A
"""
ret = []
for field_name in fields:
ret.append(_encode_uri(response[field_name]))
return NAMED_URL_RES_INNER_DILIMITER.join(ret)
def _get_named_url_identifier(url, named_url_graph, resource, tower_host, auth, ret):
"""DFS the named URL graph structure to generate identifier for a resource object.
Args:
url: A string used to access a particular resource object to generate identifier
component from.
named_url_graph: The graph structure used to DFS against.
resource: Key name of the current graph node.
tower_host: String representing the host name of Tower backend.
auth: Tuple of username + password to authenticate connection to Tower.
ret: list of strings storing components that would later be joined into
the final named URL identifier.
Return:
None. Note the actual outcome is stored in argument ret due to the recursive
nature of this function.
Raises:
"""
r = requests.get(url, auth=auth, verify=False).json()
ret.append(_generate_identifier_component(r, named_url_graph[resource]['fields']))
for next_ in named_url_graph[resource]['adj_list']:
next_fk, next_res = tuple(next_)
if next_fk in r['related']:
_get_named_url_identifier(tower_host.strip('/') + r['related'][next_fk],
named_url_graph, next_res, tower_host, auth, ret)
else:
ret.append('')
def main(username=None, password=None, tower_host=None, resource=None, pk=None):
"""Main function for generating and printing named URL of a resource object given its pk.
Args:
username: String representing the username needed to authenticating Tower.
password: String representing the password needed to authenticating Tower.
tower_host: String representing the host name of Tower backend.
resource: REST API name of a specific resource, e.g. name for resource inventory
is 'inventories'.
pk: Primary key of the resource object whose named URL will be derived.
Returns:
None
Raises:
N/A
"""
start_url = '%s/api/v2/%s/%s/' % (tower_host.strip('/'), resource.strip('/'), pk)
conf_url = '%s/api/v2/settings/named-url/' % tower_host.strip('/')
auth = (username, password)
named_url_graph = _get_named_url_graph(conf_url, auth)
named_url_identifier = []
_get_named_url_identifier(start_url, named_url_graph, resource,
tower_host, auth, named_url_identifier)
print('%s/api/v2/%s/%s/' % (tower_host.strip('/'), resource.strip('/'),
NAMED_URL_RES_DILIMITER.join(named_url_identifier)))
if __name__ == '__main__':
parser = argparse.ArgumentParser()
parser.add_argument('--username', type=str, required=True,
help='Name of the Tower user for making requests',
dest='username', metavar='STR')
parser.add_argument('--password', type=str, required=True,
help='Password of the Tower user for making requests',
dest='password', metavar='STR')
parser.add_argument('--tower-host', type=str, required=True,
help='Tower host name, like "http://127.0.0.1"',
dest='tower_host', metavar='STR')
parser.add_argument('--resource', type=str, required=True,
help='Name of the resource in REST endpoints',
dest='resource', metavar='STR')
parser.add_argument('--pk', type=int, required=True,
help='Primary key of resource object whose named URL will be derived',
dest='pk', metavar='INT')
main(**vars(parser.parse_args()))