Skip to content

Commit f8ad462

Browse files
authored
client: abstract generation (#70)
Dropping reliance on global variables allows creating clients on the fly and working with multiple API specs in a single codebase.
1 parent eab8214 commit f8ad462

3 files changed

Lines changed: 375 additions & 333 deletions

File tree

exoscale/api/exceptions.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,9 @@ class ExoscaleAPIException(Exception):
44
Base for all other exception classes, it allows to catch all exoscale exceptions with a single except block.
55
"""
66

7-
pass
7+
def __init__(self, message, response=None):
8+
super().__init__(message)
9+
self.response = response
810

911

1012
class ExoscaleAPIClientException(ExoscaleAPIException):

exoscale/api/generator.py

Lines changed: 353 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,353 @@
1+
import copy
2+
from itertools import chain
3+
4+
import requests
5+
6+
from .exceptions import (
7+
ExoscaleAPIAuthException,
8+
ExoscaleAPIClientException,
9+
ExoscaleAPIServerException,
10+
)
11+
12+
13+
def _get_in(payload, keys):
14+
"""
15+
Returns the value in a nested dict, where items is a sequence of keys.
16+
"""
17+
k, *ks = keys
18+
ret = payload[k]
19+
if ks:
20+
return _get_in(ret, ks)
21+
else:
22+
return ret
23+
24+
25+
def _get_ref(api_spec, path):
26+
root, *parts = path.split("/")
27+
if root != "#":
28+
raise AssertionError("Non-root path start", root, path)
29+
30+
# We're going to mutate payload later on, so make a full copy to avoid
31+
# altering api_spec.
32+
payload = copy.deepcopy(_get_in(api_spec, parts))
33+
34+
for name, desc in payload.get("properties", {}).items():
35+
if "$ref" in desc:
36+
resolved_schema = _get_ref(api_spec, desc["$ref"])
37+
for k, v in desc.items():
38+
if k != "$ref":
39+
resolved_schema[k] = v
40+
payload["properties"][name] = resolved_schema
41+
payload["$schema"] = "http://json-schema.org/draft-04/schema"
42+
return payload
43+
44+
45+
_type_translations = {
46+
"string": "str",
47+
"integer": "int",
48+
"object": "dict",
49+
"array": "list",
50+
"boolean": "bool",
51+
"number": "float",
52+
}
53+
54+
55+
def _return_docstring(api_spec, operation):
56+
[status_code] = operation["responses"].keys()
57+
[ctype] = operation["responses"][status_code]["content"].keys()
58+
return_schema = operation["responses"][status_code]["content"][ctype][
59+
"schema"
60+
]
61+
if "$ref" in return_schema:
62+
ref = _get_ref(api_spec, return_schema["$ref"])
63+
if (
64+
"properties" in ref
65+
and ref["type"] == "object"
66+
and "description" in ref
67+
):
68+
body = {}
69+
for name, prop in ref["properties"].items():
70+
if "$ref" in prop:
71+
_ref = _get_ref(api_spec, prop["$ref"])
72+
item = _ref
73+
else:
74+
item = prop
75+
typ = _type_translations[item["type"]]
76+
desc = prop.get("description")
77+
if "enum" in item:
78+
choices = "``, ``".join(map(repr, item["enum"]))
79+
desc += f". Values are ``{choices}``"
80+
suffix = f": {desc}" if desc else ""
81+
normalized_name = name.replace("-", "_")
82+
body[normalized_name] = (
83+
f"**{normalized_name}** ({typ}){suffix}."
84+
)
85+
86+
doc = (
87+
f"dict: {ref['description']}. A dictionnary with the following keys:"
88+
+ "\n\n * ".join([""] + list(body.values()))
89+
)
90+
elif "description" in ref:
91+
doc = f"{_type_translations[ref['type']]}: {ref['description']}."
92+
else:
93+
doc = _type_translations[ref["type"]]
94+
else:
95+
doc = _type_translations[return_schema["type"]]
96+
return doc
97+
98+
99+
class BaseClient:
100+
_api_spec = None
101+
_by_operation = None
102+
103+
def __init__(self, url=None, **kwargs):
104+
if url is None:
105+
server = self._api_spec["servers"][0]
106+
variables = {
107+
var_name: var["default"]
108+
for var_name, var in server["variables"].items()
109+
}
110+
for k, v in kwargs.items():
111+
if k not in server["variables"]:
112+
raise TypeError(f"Unhandled keyword argument {k!r}.")
113+
if choices := server["variables"][k].get("enum"):
114+
if v not in choices:
115+
choices_repr = "', '".join(choices)
116+
raise TypeError(
117+
f"Invalid {k}: must be one of '{choices_repr}'."
118+
)
119+
variables[k] = v
120+
121+
self.endpoint = server["url"].format(**variables)
122+
else:
123+
self.endpoint = url
124+
125+
self.http_client = requests.Session()
126+
127+
def __repr__(self):
128+
return f"<Client endpoint={self.endpoint}>"
129+
130+
def _call_operation(self, operation_id, parameters=None, body=None):
131+
op = self._by_operation[operation_id]
132+
133+
path = op["path"]
134+
query_params = {}
135+
path_params = {}
136+
if parameters is None:
137+
parameters = {}
138+
for param in op["operation"].get("parameters", []):
139+
name = param["name"]
140+
if param["required"] and name not in parameters:
141+
raise ValueError(f"Missing mandatory param {name!r}")
142+
if name in parameters:
143+
value = parameters[name]
144+
if param["in"] == "path":
145+
path_params[name] = value
146+
elif param["in"] == "query":
147+
query_params[name] = value
148+
149+
path = path.format(**path_params)
150+
151+
url = f"{self.endpoint}{path}"
152+
153+
json = {}
154+
if body is not None:
155+
# TODO validate
156+
json["json"] = body
157+
158+
response = self.http_client.request(
159+
method=op["verb"].upper(), url=url, params=query_params, **json
160+
)
161+
162+
# Error handling
163+
if response.status_code == 403:
164+
raise ExoscaleAPIAuthException(
165+
f"Authentication error {response.status_code}: {response.text}",
166+
response,
167+
)
168+
if 400 <= response.status_code < 500:
169+
raise ExoscaleAPIClientException(
170+
f"Client error {response.status_code}: {response.text}",
171+
response,
172+
)
173+
elif response.status_code >= 500:
174+
raise ExoscaleAPIServerException(
175+
f"Server error {response.status_code}: {response.text}",
176+
response,
177+
)
178+
179+
return response.json()
180+
181+
182+
def _args_docstring(parameters, body):
183+
return "\n\n ".join(chain(parameters.values(), body.values()))
184+
185+
186+
def _create_operation_call(
187+
py_operation_name, operation_name, operation, api_spec
188+
):
189+
docstring = """{summary}
190+
191+
Args:
192+
{args}
193+
194+
Returns:
195+
{ret}
196+
"""
197+
198+
parameters = {}
199+
body = {}
200+
normalized_names = {}
201+
for param in operation.get("parameters", []):
202+
name = param["name"]
203+
if "$ref" in param["schema"]:
204+
ref = _get_ref(api_spec, param["schema"]["$ref"])
205+
typ = _type_translations[ref["type"]]
206+
else:
207+
typ = _type_translations[param["schema"]["type"]]
208+
normalized_name = name.replace("-", "_")
209+
normalized_names[normalized_name] = name
210+
parameters[normalized_name] = f"{normalized_name} ({typ})."
211+
212+
if "requestBody" in operation:
213+
schema = operation["requestBody"]["content"]["application/json"][
214+
"schema"
215+
]
216+
if "$ref" in schema:
217+
ref = _get_ref(api_spec, schema["$ref"])
218+
properties = ref["properties"]
219+
else:
220+
properties = schema["properties"]
221+
222+
for name, prop in properties.items():
223+
if "$ref" in prop:
224+
ref = _get_ref(api_spec, prop["$ref"])
225+
item = ref
226+
else:
227+
item = prop
228+
typ = _type_translations[item["type"]]
229+
desc = prop.get("description")
230+
if "enum" in item:
231+
choices = "``, ``".join(map(repr, item["enum"]))
232+
desc += f". Must be one of ``{choices}``"
233+
suffix = f": {desc}" if desc else ""
234+
normalized_name = name.replace("-", "_")
235+
normalized_names[normalized_name] = name
236+
body[normalized_name] = f"{normalized_name} ({typ}){suffix}."
237+
238+
def _api_call(self, *args, **kwargs):
239+
if args:
240+
raise TypeError(
241+
f"{py_operation_name}() only accepts keyword arguments."
242+
)
243+
_params = {}
244+
_body = {}
245+
for k, v in kwargs.items():
246+
if k not in normalized_names:
247+
raise TypeError(f"Unhandled keyword argument {k!r}.")
248+
api_name = normalized_names[k]
249+
if k in parameters:
250+
_params[api_name] = v
251+
elif k in body:
252+
_body[api_name] = v
253+
else:
254+
raise TypeError(f"Unhandled keyword argument {k!r}.")
255+
256+
if not _body:
257+
_body = None
258+
259+
return self._call_operation(
260+
operation_name, parameters=_params, body=_body
261+
)
262+
263+
_api_call.__name__ = py_operation_name
264+
_api_call.__qualname__ = f"Client.{py_operation_name}"
265+
_api_call.__doc__ = docstring.format(
266+
summary=operation["summary"],
267+
args=_args_docstring(parameters, body),
268+
ret=_return_docstring(api_spec, operation),
269+
)
270+
return _api_call
271+
272+
273+
def _client_docstring(api_spec):
274+
template = """Create an API client.
275+
276+
Args:
277+
key (str): Exoscale API key.
278+
279+
secret (str): Exoscale API secret.
280+
281+
url (str): Endpoint URL to use. Defaults to ``{default_server!r}``.
282+
283+
{dynamic_args}
284+
285+
Returns:
286+
Client: A configured API client.
287+
"""
288+
servers = []
289+
args = {}
290+
for server in api_spec["servers"]:
291+
servers.append(server["url"])
292+
for name, variable in server["variables"].items():
293+
if name in args:
294+
continue
295+
choices = "``, ``".join(map(repr, variable["enum"]))
296+
typ = type(variable["default"]).__name__
297+
args[name] = (
298+
f"{name} ({typ}): one of ``{choices}``."
299+
f" Defaults to ``{variable['default']!r}``."
300+
)
301+
dynamic_args = "\n\n ".join(args.values())
302+
return template.format(
303+
servers="``, ``".join(map(repr, servers)),
304+
default_server=servers[0],
305+
dynamic_args=dynamic_args,
306+
)
307+
308+
309+
def create_client_class(api_spec):
310+
by_operation = {}
311+
for path, item in api_spec["paths"].items():
312+
for verb, operation in item.items():
313+
if verb not in {
314+
"get",
315+
"put",
316+
"post",
317+
"delete",
318+
"head",
319+
"options",
320+
"patch",
321+
"trace",
322+
}:
323+
raise AssertionError(
324+
"Unhandled path item object (https://swagger.io/specification/#pathItemObject) field", # noqa
325+
verb,
326+
)
327+
by_operation[operation["operationId"]] = {
328+
"verb": verb,
329+
"path": path,
330+
"operation": operation,
331+
}
332+
333+
class_name = "Client"
334+
bases = [BaseClient]
335+
class_attributes = {
336+
"_api_spec": api_spec,
337+
"_by_operation": by_operation,
338+
"__doc__": _client_docstring(api_spec),
339+
}
340+
341+
for operation_name, operation in by_operation.items():
342+
py_operation_name = operation_name.replace("-", "_")
343+
op_fn = _create_operation_call(
344+
py_operation_name,
345+
operation_name,
346+
operation["operation"],
347+
api_spec,
348+
)
349+
350+
class_attributes[py_operation_name] = op_fn
351+
352+
cls = type(class_name, tuple(bases), class_attributes)
353+
return cls

0 commit comments

Comments
 (0)