1111from collections import defaultdict
1212from getpass import getpass
1313from 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
1617import openapi3 .paths
17- from openapi3 .paths import Operation
18+ from openapi3 .paths import Operation , Parameter
1819
1920from linodecli .baked .request import OpenAPIFilteringRequest , OpenAPIRequest
2021from 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