Skip to content

Commit 19ee0b7

Browse files
new: Add support for loading JSON OpenAPI spec files (#629)
1 parent 6d49b6e commit 19ee0b7

File tree

12 files changed

+333
-50
lines changed

12 files changed

+333
-50
lines changed

.github/workflows/docker-build.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,4 +17,4 @@ jobs:
1717
- name: Build the Docker image
1818
run: docker build . --file Dockerfile --tag linode/cli:$(date +%s) --build-arg="github_token=$GITHUB_TOKEN"
1919
env:
20-
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
20+
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

Dockerfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
FROM python:3.11-slim AS builder
22

33
ARG linode_cli_version
4+
45
ARG github_token
56

67
WORKDIR /src

Makefile

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
#
22
# Makefile for more convenient building of the Linode CLI and its baked content
33
#
4+
5+
# Test-related arguments
46
MODULE :=
57
TEST_CASE_COMMAND :=
68
TEST_ARGS :=
@@ -9,7 +11,6 @@ ifdef TEST_CASE
911
TEST_CASE_COMMAND = -k $(TEST_CASE)
1012
endif
1113

12-
1314
SPEC_VERSION ?= latest
1415
ifndef SPEC
1516
override SPEC = $(shell ./resolve_spec_url ${SPEC_VERSION})

linodecli/__init__.py

Lines changed: 2 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,7 @@
1515
from linodecli import plugins
1616
from linodecli.exit_codes import ExitCodes
1717

18-
from .arg_helpers import (
19-
bake_command,
20-
register_args,
21-
register_plugin,
22-
remove_plugin,
23-
)
18+
from .arg_helpers import register_args, register_plugin, remove_plugin
2419
from .cli import CLI
2520
from .completion import get_completions
2621
from .configuration import ENV_TOKEN_NAME
@@ -103,7 +98,7 @@ def main(): # pylint: disable=too-many-branches,too-many-statements
10398
if parsed.action is None:
10499
print("No spec provided, cannot bake", file=sys.stderr)
105100
sys.exit(ExitCodes.ARGUMENT_ERROR)
106-
bake_command(cli, parsed.action)
101+
cli.bake(parsed.action)
107102
sys.exit(ExitCodes.SUCCESS)
108103
elif cli.ops is None:
109104
# if not spec was found and we weren't baking, we're doomed

linodecli/arg_helpers.py

Lines changed: 0 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,10 @@
22
"""
33
Argument parser for the linode CLI
44
"""
5-
6-
import os
75
import sys
86
from importlib import import_module
97

10-
import requests
11-
import yaml
12-
138
from linodecli import plugins
14-
from linodecli.exit_codes import ExitCodes
159
from linodecli.helpers import (
1610
register_args_shared,
1711
register_debug_arg,
@@ -169,24 +163,3 @@ def remove_plugin(plugin_name, config):
169163

170164
config.write_config()
171165
return f"Plugin {plugin_name} removed", 0
172-
173-
174-
def bake_command(cli, spec_loc):
175-
"""
176-
Handle a bake command from args
177-
"""
178-
try:
179-
if os.path.exists(os.path.expanduser(spec_loc)):
180-
with open(os.path.expanduser(spec_loc), encoding="utf-8") as f:
181-
spec = yaml.safe_load(f.read())
182-
else: # try to GET it
183-
resp = requests.get(spec_loc, timeout=120)
184-
if resp.status_code == 200:
185-
spec = yaml.safe_load(resp.content)
186-
else:
187-
raise RuntimeError(f"Request failed to {spec_loc}")
188-
except Exception as e:
189-
print(f"Could not load spec: {e}", file=sys.stderr)
190-
sys.exit(ExitCodes.REQUEST_FAILED)
191-
192-
cli.bake(spec)

linodecli/baked/parsing.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99

1010
# Sentence delimiter, split on a period followed by any type of
1111
# whitespace (space, new line, tab, etc.)
12-
REGEX_SENTENCE_DELIMITER = re.compile(r"\.(?:\s|$)")
12+
REGEX_SENTENCE_DELIMITER = re.compile(r"\W(?:\s|$)")
1313

1414
# Matches on pattern __prefix__ at the beginning of a description
1515
# or after a comma

linodecli/cli.py

Lines changed: 99 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,17 @@
22
Responsible for managing spec and routing commands to operations.
33
"""
44

5+
import contextlib
6+
import json
57
import os
68
import pickle
79
import sys
10+
from json import JSONDecodeError
811
from sys import version_info
12+
from typing import IO, Any, ContextManager, Dict
913

14+
import requests
15+
import yaml
1016
from openapi3 import OpenAPI
1117

1218
from linodecli.api_request import do_request, get_all_pages
@@ -40,11 +46,19 @@ def __init__(self, version, base_url, skip_config=False):
4046
self.config = CLIConfig(self.base_url, skip_config=skip_config)
4147
self.load_baked()
4248

43-
def bake(self, spec):
49+
def bake(self, spec_location: str):
4450
"""
45-
Generates ops and bakes them to a pickle
51+
Generates ops and bakes them to a pickle.
52+
53+
:param spec_location: The URL or file path of the OpenAPI spec to parse.
4654
"""
47-
spec = OpenAPI(spec)
55+
56+
try:
57+
spec = self._load_openapi_spec(spec_location)
58+
except Exception as e:
59+
print(f"Failed to load spec: {e}")
60+
sys.exit(ExitCodes.REQUEST_FAILED)
61+
4862
self.spec = spec
4963
self.ops = {}
5064
ext = {
@@ -206,3 +220,85 @@ def user_agent(self) -> str:
206220
f"linode-api-docs/{self.spec_version} "
207221
f"python/{version_info[0]}.{version_info[1]}.{version_info[2]}"
208222
)
223+
224+
@staticmethod
225+
def _load_openapi_spec(spec_location: str) -> OpenAPI:
226+
"""
227+
Attempts to load the raw OpenAPI spec (YAML or JSON) at the given location.
228+
229+
:param spec_location: The location of the OpenAPI spec.
230+
This can be a local path or a URL.
231+
232+
:returns: A tuple containing the loaded OpenAPI object and the parsed spec in
233+
dict format.
234+
"""
235+
236+
with CLI._get_spec_file_reader(spec_location) as f:
237+
parsed = CLI._parse_spec_file(f)
238+
239+
return OpenAPI(parsed)
240+
241+
@staticmethod
242+
@contextlib.contextmanager
243+
def _get_spec_file_reader(
244+
spec_location: str,
245+
) -> ContextManager[IO]:
246+
"""
247+
Returns a reader for an OpenAPI spec file from the given location.
248+
249+
:param spec_location: The location of the OpenAPI spec.
250+
This can be a local path or a URL.
251+
252+
:returns: A context manager yielding the spec file's reader.
253+
"""
254+
255+
# Case for local file
256+
local_path = os.path.expanduser(spec_location)
257+
if os.path.exists(local_path):
258+
f = open(local_path, "r", encoding="utf-8")
259+
260+
try:
261+
yield f
262+
finally:
263+
f.close()
264+
265+
return
266+
267+
# Case for remote file
268+
resp = requests.get(spec_location, stream=True, timeout=120)
269+
if resp.status_code != 200:
270+
raise RuntimeError(f"Failed to GET {spec_location}")
271+
272+
# We need to access the underlying urllib
273+
# response here so we can return a reader
274+
# usable in yaml.safe_load(...) and json.load(...)
275+
resp.raw.decode_content = True
276+
277+
try:
278+
yield resp.raw
279+
finally:
280+
resp.close()
281+
282+
@staticmethod
283+
def _parse_spec_file(reader: IO) -> Dict[str, Any]:
284+
"""
285+
Parses the given file reader into a dict and returns a dict.
286+
287+
:param reader: A reader for a YAML or JSON file.
288+
289+
:returns: The parsed file.
290+
"""
291+
292+
errors = []
293+
294+
try:
295+
return yaml.safe_load(reader)
296+
except yaml.YAMLError as err:
297+
errors.append(str(err))
298+
299+
try:
300+
return json.load(reader)
301+
except JSONDecodeError as err:
302+
errors.append(str(err))
303+
304+
raise ValueError(f"Failed to parse spec file: {'; '.join(errors)}")

tests/fixtures/cli_test_load.json

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
{
2+
"openapi": "3.0.1",
3+
"info": {
4+
"title": "API Specification",
5+
"version": "1.0.0"
6+
},
7+
"servers": [
8+
{
9+
"url": "http://localhost/v4"
10+
}
11+
],
12+
"paths": {
13+
"/foo/bar": {
14+
"get": {
15+
"summary": "get info",
16+
"operationId": "fooBarGet",
17+
"description": "This is description",
18+
"responses": {
19+
"200": {
20+
"description": "Successful response",
21+
"content": {
22+
"application/json": {
23+
"schema": {
24+
"type": "object",
25+
"properties": {
26+
"data": {
27+
"type": "array",
28+
"items": {
29+
"$ref": "#/components/schemas/OpenAPIResponseAttr"
30+
}
31+
},
32+
"page": {
33+
"$ref": "#/components/schemas/PaginationEnvelope/properties/page"
34+
},
35+
"pages": {
36+
"$ref": "#/components/schemas/PaginationEnvelope/properties/pages"
37+
},
38+
"results": {
39+
"$ref": "#/components/schemas/PaginationEnvelope/properties/results"
40+
}
41+
}
42+
}
43+
}
44+
}
45+
}
46+
}
47+
}
48+
}
49+
},
50+
"components": {
51+
"schemas": {
52+
"OpenAPIResponseAttr": {
53+
"type": "object",
54+
"properties": {
55+
"filterable_result": {
56+
"x-linode-filterable": true,
57+
"type": "string",
58+
"description": "Filterable result value"
59+
},
60+
"filterable_list_result": {
61+
"x-linode-filterable": true,
62+
"type": "array",
63+
"items": {
64+
"type": "string"
65+
},
66+
"description": "Filterable result value"
67+
}
68+
}
69+
},
70+
"PaginationEnvelope": {
71+
"type": "object",
72+
"properties": {
73+
"pages": {
74+
"type": "integer",
75+
"readOnly": true,
76+
"description": "The total number of pages.",
77+
"example": 1
78+
},
79+
"page": {
80+
"type": "integer",
81+
"readOnly": true,
82+
"description": "The current page.",
83+
"example": 1
84+
},
85+
"results": {
86+
"type": "integer",
87+
"readOnly": true,
88+
"description": "The total number of results.",
89+
"example": 1
90+
}
91+
}
92+
}
93+
}
94+
}
95+
}

tests/fixtures/cli_test_load.yaml

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
openapi: 3.0.1
2+
info:
3+
title: API Specification
4+
version: 1.0.0
5+
servers:
6+
- url: http://localhost/v4
7+
paths:
8+
/foo/bar:
9+
get:
10+
summary: get info
11+
operationId: fooBarGet
12+
description: This is description
13+
responses:
14+
'200':
15+
description: Successful response
16+
content:
17+
application/json:
18+
schema:
19+
type: object
20+
properties:
21+
data:
22+
type: array
23+
items:
24+
$ref: '#/components/schemas/OpenAPIResponseAttr'
25+
page:
26+
$ref: '#/components/schemas/PaginationEnvelope/properties/page'
27+
pages:
28+
$ref: '#/components/schemas/PaginationEnvelope/properties/pages'
29+
results:
30+
$ref: '#/components/schemas/PaginationEnvelope/properties/results'
31+
32+
components:
33+
schemas:
34+
OpenAPIResponseAttr:
35+
type: object
36+
properties:
37+
filterable_result:
38+
x-linode-filterable: true
39+
type: string
40+
description: Filterable result value
41+
filterable_list_result:
42+
x-linode-filterable: true
43+
type: array
44+
items:
45+
type: string
46+
description: Filterable result value
47+
PaginationEnvelope:
48+
type: object
49+
properties:
50+
pages:
51+
type: integer
52+
readOnly: true
53+
description: The total number of pages.
54+
example: 1
55+
page:
56+
type: integer
57+
readOnly: true
58+
description: The current page.
59+
example: 1
60+
results:
61+
type: integer
62+
readOnly: true
63+
description: The total number of results.
64+
example: 1

0 commit comments

Comments
 (0)