Skip to content

Commit 14e3e8a

Browse files
committed
Initial commit with OpenAPI parsing
0 parents  commit 14e3e8a

15 files changed

+1007
-0
lines changed

.circleci/config.yml

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
jobs:
2+
build:
3+
docker:
4+
- image: circleci/python:3.8.1
5+
steps:
6+
- checkout
7+
- restore_cache:
8+
keys:
9+
- v1-dependencies-{{ checksum "poetry.lock" }}
10+
- run:
11+
command: 'pip install poetry --user --upgrade
12+
13+
poetry config virtualenvs.in-project true
14+
15+
poetry config repositories.triaxtec https://pypi.fury.io/triaxtec/
16+
17+
poetry config http-basic.triaxtec $GEMFURY_PULL_TOKEN $GEMFURY_PULL_TOKEN
18+
19+
poetry install
20+
21+
'
22+
name: install dependencies
23+
- run:
24+
command: 'mkdir -p test-reports/safety test-reports/mypy test-reports/pytest
25+
26+
poetry run black . --check
27+
28+
poetry run safety check --json > test-reports/safety/results.json
29+
30+
poetry run mypy openapi_python_client --junit-xml=test-reports/mypy/results.xml
31+
32+
poetry run pytest --junitxml=test-reports/pytest/results.xml
33+
34+
'
35+
name: run tests
36+
- store_test_results:
37+
path: test-reports
38+
- run:
39+
command: poetry run pip uninstall openapi-python-client -y
40+
name: Uninstall Package
41+
- save_cache:
42+
key: v1-dependencies-{{ checksum "poetry.lock" }}
43+
paths:
44+
- ./.venv
45+
working_directory: ~/repo
46+
version: 2

.gitignore

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
__pycache__/
2+
build/
3+
dist/
4+
*.egg-info/
5+
.pytest_cache/
6+
7+
# Sphinx documentation
8+
docs/_build/
9+
10+
# pyenv
11+
.python-version
12+
13+
# Environments
14+
.env
15+
.venv
16+
17+
# mypy
18+
.mypy_cache/
19+
.dmypy.json
20+
dmypy.json
21+
22+
# JetBrains
23+
.idea/
24+
25+
# Terraform
26+
.terraform

CHANGELOG.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
# Changelog
2+
All notable changes to this project will be documented in this file.
3+
4+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
5+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
## 0.1.0 - YYYY-MM-DD
8+
- Initial Release

README.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# openapi-python-client
2+
3+
## Purpose
4+
Generate modern Python clients from OpenAPI
5+
6+
## Contribution Guidelines
7+
- The project is written to support Python 3.8 and should conform to the [Triax Python Standards](https://triaxtec.atlassian.net/wiki/spaces/EN/pages/499482627/Python+Guidelines).
8+
- Any changes should be covered with a unit test and documented in [CHANGELOG.md]
9+
10+
## Release Process
11+
1. Start a release with Git Flow
12+
1. Update the version number in `pyproject.toml` with `poetry version <rule>`
13+
1. Ensure all requirements are pointing to released versions
14+
1. Add the release date to the new version in [CHANGELOG.md]
15+
1. Commit and push any changes
16+
1. Create a pull request from the release branch to master
17+
1. Get approval from all stakeholders
18+
1. Ensure all checks pass (e.g. CircleCI)
19+
1. Open and merge the pull request
20+
1. Create a tag on the merge commit with the release number
21+
22+
## Contributors
23+
- Dylan Anthony <[email protected]>
24+
25+
26+
[CHANGELOG.md]: CHANGELOG.md

mypy.ini

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[mypy]
2+
disallow_any_explicit = True
3+
disallow_any_generics = True
4+
disallow_untyped_defs = True
5+
warn_redundant_casts = True
6+
warn_unused_ignores = True
7+
strict_equality = True

openapi_python_client/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
""" Generate modern Python clients from OpenAPI """
2+
import sys
3+
from typing import Dict
4+
5+
import orjson
6+
import requests
7+
8+
from .models import OpenAPI
9+
10+
11+
def main():
12+
""" Generate the client library """
13+
url = sys.argv[1]
14+
json = _get_json(url)
15+
data_dict = _parse_json(json)
16+
openapi = OpenAPI.from_dict(data_dict)
17+
print(openapi)
18+
19+
20+
def _get_json(url) -> bytes:
21+
response = requests.get(url)
22+
return response.content
23+
24+
25+
def _parse_json(json: bytes) -> Dict:
26+
return orjson.loads(json)

openapi_python_client/__main__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import main
2+
3+
main()
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
""" Classes representing the data in the OpenAPI schema """
2+
3+
from .api_info import APIInfo
4+
from .openapi import OpenAPI
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
from dataclasses import dataclass
2+
3+
4+
@dataclass
5+
class APIInfo:
6+
""" Info about the API """
7+
8+
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
from enum import Enum
5+
from typing import Dict, List, Optional
6+
7+
from .properties import Property, property_from_dict
8+
9+
10+
class Method(Enum):
11+
""" HTTP Methods """
12+
GET = "get"
13+
POST = "post"
14+
PATCH = "patch"
15+
16+
17+
class ParameterLocation(Enum):
18+
""" The places Parameters can be put when calling an Endpoint """
19+
QUERY = "query"
20+
PATH = "path"
21+
22+
23+
@dataclass
24+
class Parameter:
25+
""" A parameter in an Endpoint """
26+
location: ParameterLocation
27+
property: Property
28+
29+
@staticmethod
30+
def from_dict(d: Dict, /) -> Parameter:
31+
""" Construct a parameter from it's OpenAPI dict form """
32+
return Parameter(
33+
location=ParameterLocation(d["in"]),
34+
property=property_from_dict(
35+
name=d["name"],
36+
required=d["required"],
37+
data=d["schema"],
38+
),
39+
)
40+
41+
42+
@dataclass
43+
class Endpoint:
44+
"""
45+
Describes a single endpoint on the server
46+
"""
47+
path: str
48+
method: Method
49+
description: Optional[str]
50+
name: str
51+
parameters: List[Parameter]
52+
tag: Optional[str] = None
53+
54+
@staticmethod
55+
def get_list_from_dict(d: Dict[str, Dict[str, Dict]], /) -> List[Endpoint]:
56+
""" Parse the openapi paths data to get a list of endpoints """
57+
endpoints = []
58+
for path, path_data in d.items():
59+
for method, method_data in path_data.items():
60+
parameters: List[Parameter] = []
61+
for param_dict in method_data.get("parameters", []):
62+
parameters.append(Parameter.from_dict(param_dict))
63+
endpoint = Endpoint(
64+
path=path,
65+
method=Method(method),
66+
description=method_data.get("description"),
67+
name=method_data["operationId"],
68+
parameters=parameters,
69+
tag=method_data.get("tags", [None])[0],
70+
)
71+
endpoints.append(endpoint)
72+
return endpoints
73+
74+
75+
@dataclass
76+
class Schema:
77+
"""
78+
Describes a schema, AKA data model used in requests.
79+
80+
These will all be converted to dataclasses in the client
81+
"""
82+
83+
title: str
84+
properties: List[Property]
85+
description: str
86+
87+
@staticmethod
88+
def from_dict(d: Dict, /) -> Schema:
89+
""" A single Schema from its dict representation """
90+
required = set(d.get("required", []))
91+
properties: List[Property] = []
92+
for key, value in d["properties"].items():
93+
properties.append(property_from_dict(name=key, required=key in required, data=value,))
94+
return Schema(title=d["title"], properties=properties, description=d.get("description", ""),)
95+
96+
@staticmethod
97+
def dict(d: Dict, /) -> Dict[str, Schema]:
98+
""" Get a list of Schemas from an OpenAPI dict """
99+
result = {}
100+
for data in d.values():
101+
s = Schema.from_dict(data)
102+
result[s.title] = s
103+
return result
104+
105+
106+
@dataclass
107+
class OpenAPI:
108+
""" Top level OpenAPI spec """
109+
110+
title: str
111+
description: str
112+
version: str
113+
security_schemes: Dict
114+
schemas: Dict[str, Schema]
115+
endpoints: List[Endpoint]
116+
117+
@staticmethod
118+
def from_dict(d: Dict, /) -> OpenAPI:
119+
""" Create an OpenAPI from dict """
120+
return OpenAPI(
121+
title=d["info"]["title"],
122+
description=d["info"]["description"],
123+
version=d["info"]["version"],
124+
endpoints=Endpoint.get_list_from_dict(d["paths"]),
125+
schemas=Schema.dict(d["components"]["schemas"]),
126+
security_schemes=d["components"]["securitySchemes"],
127+
)
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
from dataclasses import dataclass
2+
from typing import Optional, List, Dict, Union
3+
4+
5+
@dataclass
6+
class Property:
7+
""" Describes a single property for a schema """
8+
9+
name: str
10+
required: bool
11+
12+
13+
@dataclass
14+
class StringProperty(Property):
15+
""" A property of type str """
16+
17+
max_length: Optional[int] = None
18+
default: Optional[str] = None
19+
pattern: Optional[str] = None
20+
21+
22+
@dataclass
23+
class DateTimeProperty(Property):
24+
""" A property of type datetime.datetime """
25+
26+
pass
27+
28+
29+
@dataclass
30+
class FloatProperty(Property):
31+
""" A property of type float """
32+
33+
default: Optional[float] = None
34+
35+
36+
@dataclass
37+
class IntProperty(Property):
38+
""" A property of type int """
39+
40+
default: Optional[int] = None
41+
42+
43+
@dataclass
44+
class BooleanProperty(Property):
45+
""" Property for bool """
46+
47+
pass
48+
49+
50+
@dataclass
51+
class ListProperty(Property):
52+
""" Property for list """
53+
54+
type: Optional[str] = None
55+
ref: Optional[str] = None
56+
57+
58+
@dataclass
59+
class EnumProperty(Property):
60+
""" A property that should use an enum """
61+
62+
values: List[str]
63+
64+
65+
@dataclass
66+
class RefProperty(Property):
67+
""" A property which refers to another Schema """
68+
69+
ref: str
70+
71+
72+
@dataclass
73+
class DictProperty(Property):
74+
""" Property that is a general Dict """
75+
76+
77+
def property_from_dict(
78+
name: str, required: bool, data: Dict[str, Union[float, int, str, List[str], Dict[str, str]]]
79+
) -> Property:
80+
if "enum" in data:
81+
return EnumProperty(name=name, required=required, values=data["enum"],)
82+
if "$ref" in data:
83+
ref = data["$ref"].split("/")[-1]
84+
return RefProperty(name=name, required=required, ref=ref,)
85+
if data["type"] == "string":
86+
if "format" not in data:
87+
return StringProperty(
88+
name=name, default=data.get("default"), required=required, pattern=data.get("pattern"),
89+
)
90+
elif data["format"] == "date-time":
91+
return DateTimeProperty(name=name, required=required,)
92+
elif data["type"] == "number":
93+
return FloatProperty(name=name, default=data.get("default"), required=required,)
94+
elif data["type"] == "integer":
95+
return IntProperty(name=name, default=data.get("default"), required=required,)
96+
elif data["type"] == "boolean":
97+
return BooleanProperty(name=name, required=required,)
98+
elif data["type"] == "array":
99+
ref = None
100+
if "$ref" in data["items"]:
101+
ref = data["items"]["$ref"].split("/")[-1]
102+
return ListProperty(name=name, required=required, type=data["items"].get("type"), ref=ref,)
103+
elif data["type"] == "object":
104+
return DictProperty(name=name, required=required,)
105+
raise ValueError(f"Did not recognize type of {data}")

openapi_python_client/py.typed

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
# Marker file for PEP 561

0 commit comments

Comments
 (0)