Skip to content

Commit 3be330c

Browse files
new: Changes for CLI/TechDocs spec compatibility (#624)
Co-authored-by: Zhiwei Liang <[email protected]>
1 parent e88be48 commit 3be330c

31 files changed

+748
-143
lines changed

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,4 @@ test/.env
1313
.tmp*
1414
MANIFEST
1515
venv
16+
openapi*.yaml

linodecli/__init__.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,11 @@
5151
or TEST_MODE
5252
)
5353

54-
cli = CLI(VERSION, handle_url_overrides(BASE_URL), skip_config=skip_config)
54+
cli = CLI(
55+
VERSION,
56+
handle_url_overrides(BASE_URL, override_path=True),
57+
skip_config=skip_config,
58+
)
5559

5660

5761
def main(): # pylint: disable=too-many-branches,too-many-statements

linodecli/api_request.py

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
from requests import Response
1414

1515
from linodecli.exit_codes import ExitCodes
16-
from linodecli.helpers import API_CA_PATH
16+
from linodecli.helpers import API_CA_PATH, API_VERSION_OVERRIDE
1717

1818
from .baked.operation import (
1919
ExplicitEmptyListValue,
@@ -185,14 +185,22 @@ def _build_filter_header(
185185

186186

187187
def _build_request_url(ctx, operation, parsed_args) -> str:
188-
target_server = handle_url_overrides(
188+
url_base = handle_url_overrides(
189189
operation.url_base,
190190
host=ctx.config.get_value("api_host"),
191-
version=ctx.config.get_value("api_version"),
192191
scheme=ctx.config.get_value("api_scheme"),
193192
)
194193

195-
result = f"{target_server}{operation.url_path}".format(**vars(parsed_args))
194+
result = f"{url_base}{operation.url_path}".format(
195+
# {apiVersion} is defined in the endpoint paths for
196+
# the TechDocs API specs
197+
apiVersion=(
198+
API_VERSION_OVERRIDE
199+
or ctx.config.get_value("api_version")
200+
or operation.default_api_version
201+
),
202+
**vars(parsed_args),
203+
)
196204

197205
if operation.method == "get":
198206
result += f"?page={ctx.page}&page_size={ctx.page_size}"

linodecli/baked/operation.py

Lines changed: 156 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,11 @@
1111
from collections import defaultdict
1212
from getpass import getpass
1313
from os import environ, path
14-
from typing import Any, Dict, List, Tuple
14+
from typing import Any, Dict, List, Optional, Tuple
15+
from urllib.parse import urlparse
1516

1617
import openapi3.paths
17-
from openapi3.paths import Operation
18+
from openapi3.paths import Operation, Parameter
1819

1920
from linodecli.baked.request import OpenAPIFilteringRequest, OpenAPIRequest
2021
from linodecli.baked.response import OpenAPIResponse
@@ -295,7 +296,9 @@ class OpenAPIOperation:
295296
This is the class that should be pickled when building the CLI.
296297
"""
297298

298-
def __init__(self, command, operation: Operation, method, params):
299+
def __init__(
300+
self, command, operation: Operation, method, params
301+
): # pylint: disable=too-many-locals,too-many-branches,too-many-statements
299302
"""
300303
Wraps an openapi3.Operation object and handles pulling out values relevant
301304
to the Linode CLI.
@@ -309,16 +312,25 @@ def __init__(self, command, operation: Operation, method, params):
309312
self.response_model = None
310313
self.allowed_defaults = None
311314

315+
# The legacy spec uses "200" (str) in response keys
316+
# while the new spec uses 200 (int).
317+
response_key = "200" if "200" in operation.responses else 200
318+
312319
if (
313-
"200" in operation.responses
314-
and "application/json" in operation.responses["200"].content
320+
response_key in operation.responses
321+
and "application/json" in operation.responses[response_key].content
315322
):
316323
self.response_model = OpenAPIResponse(
317-
operation.responses["200"].content["application/json"]
324+
operation.responses[response_key].content["application/json"]
318325
)
319326

320327
if method in ("post", "put") and operation.requestBody:
321-
if "application/json" in operation.requestBody.content:
328+
content = operation.requestBody.content
329+
330+
if (
331+
"application/json" in content
332+
and content["application/json"].schema is not None
333+
):
322334
self.request = OpenAPIRequest(
323335
operation.requestBody.content["application/json"]
324336
)
@@ -346,30 +358,27 @@ def __init__(self, command, operation: Operation, method, params):
346358

347359
self.summary = operation.summary
348360
self.description = operation.description.split(".")[0]
349-
self.params = [OpenAPIOperationParameter(c) for c in params]
350361

351-
# These fields must be stored separately
352-
# to allow them to be easily modified
353-
# at runtime.
354-
self.url_base = (
355-
operation.servers[0].url
356-
if operation.servers
357-
else operation._root.servers[0].url
358-
)
362+
# The apiVersion attribute should not be specified as a positional argument
363+
self.params = [
364+
OpenAPIOperationParameter(param)
365+
for param in params
366+
if param.name not in {"apiVersion"}
367+
]
359368

360-
self.url_path = operation.path[-2]
369+
self.url_base, self.url_path, self.default_api_version = (
370+
self._get_api_url_components(operation, params)
371+
)
361372

362373
self.url = self.url_base + self.url_path
363374

364-
docs_url = None
365-
tags = operation.tags
366-
if tags is not None and len(tags) > 0 and len(operation.summary) > 0:
367-
tag_path = self._flatten_url_path(tags[0])
368-
summary_path = self._flatten_url_path(operation.summary)
369-
docs_url = (
370-
f"https://www.linode.com/docs/api/{tag_path}/#{summary_path}"
375+
self.docs_url = self._resolve_operation_docs_url(operation)
376+
377+
if self.docs_url is None:
378+
print(
379+
f"INFO: Could not resolve docs URL for {operation}",
380+
file=sys.stderr,
371381
)
372-
self.docs_url = docs_url
373382

374383
code_samples_ext = operation.extensions.get("code-samples")
375384
self.samples = (
@@ -401,6 +410,85 @@ def _flatten_url_path(tag: str) -> str:
401410
new_tag = re.sub(r"[^a-z ]", "", new_tag).replace(" ", "-")
402411
return new_tag
403412

413+
@staticmethod
414+
def _resolve_api_version(
415+
params: List[Parameter], server_url: str
416+
) -> Optional[str]:
417+
"""
418+
Returns the API version for a given list of params and target URL.
419+
420+
:param params: The params for this operation's endpoint path.
421+
:type params: List[Parameter]
422+
:param server_url: The URL of server for this operation.
423+
:type server_url: str
424+
425+
:returns: The default API version if the URL has a version, else None.
426+
:rtype: Optional[str]
427+
"""
428+
429+
# Remove empty segments from the URL path, stripping the first,
430+
# last and any duplicate slashes if necessary.
431+
# There shouldn't be a case where this is needed, but it's
432+
# always good to make things more resilient :)
433+
url_path_segments = [
434+
seg for seg in urlparse(server_url).path.split("/") if len(seg) > 0
435+
]
436+
if len(url_path_segments) > 0:
437+
return "/".join(url_path_segments)
438+
439+
version_param = next(
440+
(
441+
param
442+
for param in params
443+
if param.name == "apiVersion" and param.in_ == "path"
444+
),
445+
None,
446+
)
447+
if version_param is not None:
448+
return version_param.schema.default
449+
450+
return None
451+
452+
@staticmethod
453+
def _get_api_url_components(
454+
operation: Operation, params: List[Parameter]
455+
) -> Tuple[str, str, str]:
456+
"""
457+
Returns the URL components for a given operation.
458+
459+
:param operation: The operation to get the URL components for.
460+
:type operation: Operation
461+
:param params: The parameters for this operation's route.
462+
:type params: List[Parameter]
463+
464+
:returns: The base URL, path, and default API version of the operation.
465+
:rtype: Tuple[str, str, str]
466+
"""
467+
468+
url_server = (
469+
operation.servers[0].url
470+
if operation.servers
471+
# pylint: disable-next=protected-access
472+
else operation._root.servers[0].url
473+
)
474+
475+
url_base = urlparse(url_server)._replace(path="").geturl()
476+
url_path = operation.path[-2]
477+
478+
api_version = OpenAPIOperation._resolve_api_version(params, url_server)
479+
if api_version is None:
480+
raise ValueError(
481+
f"Failed to resolve API version for operation {operation}"
482+
)
483+
484+
# The apiVersion is only specified in the new-style OpenAPI spec,
485+
# so we need to manually insert it into the path to maintain
486+
# backwards compatibility
487+
if "{apiVersion}" not in url_path:
488+
url_path = "/{apiVersion}" + url_path
489+
490+
return url_base, url_path, api_version
491+
404492
def process_response_json(
405493
self, json: Dict[str, Any], handler: OutputHandler
406494
): # pylint: disable=redefined-outer-name
@@ -437,6 +525,7 @@ def _add_args_filter(self, parser: argparse.ArgumentParser):
437525

438526
# build args for filtering
439527
filterable_args = []
528+
440529
for attr in self.response_model.attrs:
441530
if not attr.filterable:
442531
continue
@@ -715,3 +804,45 @@ def parse_args(self, args: Any) -> argparse.Namespace:
715804
self._validate_parent_child_conflicts(parsed)
716805

717806
return self._handle_list_items(list_items, parsed)
807+
808+
@staticmethod
809+
def _resolve_operation_docs_url_legacy(
810+
operation: Operation,
811+
) -> Optional[str]:
812+
"""
813+
Gets the docs URL for a given operation in the legacy OpenAPI spec.
814+
815+
:param operation: The target openapi3.Operation to get the docs URL for.
816+
:type operation: str
817+
818+
:returns: The docs URL if it can be resolved, else None
819+
:rtype: Optional[str]
820+
"""
821+
tags = operation.tags
822+
if tags is None or len(tags) < 1 or len(operation.summary) < 1:
823+
return None
824+
825+
tag_path = OpenAPIOperation._flatten_url_path(tags[0])
826+
summary_path = OpenAPIOperation._flatten_url_path(operation.summary)
827+
return f"https://www.linode.com/docs/api/{tag_path}/#{summary_path}"
828+
829+
@staticmethod
830+
def _resolve_operation_docs_url(operation: Operation) -> Optional[str]:
831+
"""
832+
Gets the docs URL for a given OpenAPI operation.
833+
834+
:param operation: The target openapi3.Operation to get the docs URL for.
835+
:type operation: str
836+
837+
:returns: The docs URL if it can be resolved, else None
838+
:rtype: Optional[str]
839+
"""
840+
# Case for TechDocs
841+
if (
842+
operation.externalDocs is not None
843+
and operation.externalDocs.url is not None
844+
):
845+
return operation.externalDocs.url
846+
847+
# Case for legacy docs
848+
return OpenAPIOperation._resolve_operation_docs_url_legacy(operation)

0 commit comments

Comments
 (0)