Merge pull request #5 from Maillol/fix_bug_oas_generation

Fix bug query string appear as required in generated Open API specifi…
This commit is contained in:
MAILLOL Vincent 2020-11-21 17:57:36 +01:00 committed by GitHub
commit 8aee135f95
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 324 additions and 33 deletions

View File

@ -1,3 +1,7 @@
"""
Utility to write Open Api Specifications using the Python language.
"""
from typing import Union from typing import Union
@ -7,7 +11,7 @@ class Info:
@property @property
def title(self): def title(self):
return self._spec["title"] return self._spec.get("title")
@title.setter @title.setter
def title(self, title): def title(self, title):
@ -15,7 +19,7 @@ class Info:
@property @property
def description(self): def description(self):
return self._spec["description"] return self._spec.get("description")
@description.setter @description.setter
def description(self, description): def description(self, description):
@ -23,12 +27,20 @@ class Info:
@property @property
def version(self): def version(self):
return self._spec["version"] return self._spec.get("version")
@version.setter @version.setter
def version(self, version): def version(self, version):
self._spec["version"] = version self._spec["version"] = version
@property
def terms_of_service(self):
return self._spec.get("termsOfService")
@terms_of_service.setter
def terms_of_service(self, terms_of_service):
self._spec["termsOfService"] = terms_of_service
class RequestBody: class RequestBody:
def __init__(self, spec: dict): def __init__(self, spec: dict):
@ -43,8 +55,8 @@ class RequestBody:
self._spec["description"] = description self._spec["description"] = description
@property @property
def required(self): def required(self) -> bool:
return self._spec["required"] return self._spec.get("required", False)
@required.setter @required.setter
def required(self, required: bool): def required(self, required: bool):
@ -220,6 +232,22 @@ class PathItem:
def trace(self) -> OperationObject: def trace(self) -> OperationObject:
return OperationObject(self._spec.setdefault("trace", {})) return OperationObject(self._spec.setdefault("trace", {}))
@property
def description(self) -> str:
return self._spec["description"]
@description.setter
def description(self, description: str):
self._spec["description"] = description
@property
def summary(self) -> str:
return self._spec["summary"]
@summary.setter
def summary(self, summary: str):
self._spec["summary"] = summary
class Paths: class Paths:
def __init__(self, spec: dict): def __init__(self, spec: dict):
@ -244,7 +272,7 @@ class Server:
@property @property
def description(self) -> str: def description(self) -> str:
return self._spec["url"] return self._spec["description"]
@description.setter @description.setter
def description(self, description: str): def description(self, description: str):

View File

@ -13,7 +13,7 @@ Example:
from functools import lru_cache from functools import lru_cache
from types import new_class from types import new_class
from typing import Protocol, TypeVar from typing import Protocol, TypeVar, Optional, Type
RespContents = TypeVar("RespContents", covariant=True) RespContents = TypeVar("RespContents", covariant=True)
@ -24,9 +24,10 @@ _status_code = frozenset(f"r{code}" for code in range(100, 600))
def _make_status_code_type(status_code): def _make_status_code_type(status_code):
if status_code in _status_code: if status_code in _status_code:
return new_class(status_code, (Protocol[RespContents],)) return new_class(status_code, (Protocol[RespContents],))
return None
def is_status_code_type(obj): def is_status_code_type(obj) -> bool:
""" """
Return True if obj is a status code type such as _200 or _404. Return True if obj is a status code type such as _200 or _404.
""" """

View File

@ -1,6 +1,9 @@
from inspect import getdoc
import typing import typing
from datetime import date, datetime
from inspect import getdoc
from itertools import count
from typing import List, Type from typing import List, Type
from uuid import UUID
from aiohttp.web import Response, json_response from aiohttp.web import Response, json_response
from aiohttp.web_app import Application from aiohttp.web_app import Application
@ -12,7 +15,30 @@ from ..utils import is_pydantic_base_model
from ..view import PydanticView, is_pydantic_view from ..view import PydanticView, is_pydantic_view
from .typing import is_status_code_type from .typing import is_status_code_type
JSON_SCHEMA_TYPES = {float: "number", str: "string", int: "integer"} JSON_SCHEMA_TYPES = {
float: {"type": "number"},
str: {"type": "string"},
int: {"type": "integer"},
UUID: {"type": "string", "format": "uuid"},
bool: {"type": "boolean"},
datetime: {"type": "string", "format": "date-time"},
date: {"type": "string", "format": "date"},
}
def _handle_optional(type_):
"""
Returns the type wrapped in Optional or None.
>>> _handle_optional(int)
>>> _handle_optional(Optional[str])
<class 'str'>
"""
if typing.get_origin(type_) is typing.Union:
args = typing.get_args(type_)
if len(args) == 2 and type(None) in args:
return next(iter(set(args) - {type(None)}))
return None
class _OASResponseBuilder: class _OASResponseBuilder:
@ -77,24 +103,23 @@ def _add_http_method_to_oas(
"application/json": {"schema": next(iter(body_args.values())).schema()} "application/json": {"schema": next(iter(body_args.values())).schema()}
} }
i = 0 indexes = count()
for i, (name, type_) in enumerate(path_args.items()): for args_location, args in (
oas_operation.parameters[i].required = True ("path", path_args.items()),
oas_operation.parameters[i].in_ = "path" ("query", qs_args.items()),
oas_operation.parameters[i].name = name ("header", header_args.items()),
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} ):
for name, type_ in args:
for i, (name, type_) in enumerate(qs_args.items(), i + 1): i = next(indexes)
oas_operation.parameters[i].required = False oas_operation.parameters[i].in_ = args_location
oas_operation.parameters[i].in_ = "query" oas_operation.parameters[i].name = name
oas_operation.parameters[i].name = name optional_type = _handle_optional(type_)
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]} if optional_type is None:
oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[type_]
for i, (name, type_) in enumerate(header_args.items(), i + 1): oas_operation.parameters[i].required = True
oas_operation.parameters[i].required = False else:
oas_operation.parameters[i].in_ = "header" oas_operation.parameters[i].schema = JSON_SCHEMA_TYPES[optional_type]
oas_operation.parameters[i].name = name oas_operation.parameters[i].required = False
oas_operation.parameters[i].schema = {"type": JSON_SCHEMA_TYPES[type_]}
return_type = handler.__annotations__.get("return") return_type = handler.__annotations__.get("return")
if return_type is not None: if return_type is not None:

View File

View File

@ -0,0 +1,54 @@
import pytest
from aiohttp_pydantic.oas.struct import OpenApiSpec3
def test_info_title():
oas = OpenApiSpec3()
assert oas.info.title is None
oas.info.title = "Info Title"
assert oas.info.title == "Info Title"
assert oas.spec == {"info": {"title": "Info Title"}, "openapi": "3.0.0"}
def test_info_description():
oas = OpenApiSpec3()
assert oas.info.description is None
oas.info.description = "info description"
assert oas.info.description == "info description"
assert oas.spec == {"info": {"description": "info description"}, "openapi": "3.0.0"}
def test_info_version():
oas = OpenApiSpec3()
assert oas.info.version is None
oas.info.version = "3.14"
assert oas.info.version == "3.14"
assert oas.spec == {"info": {"version": "3.14"}, "openapi": "3.0.0"}
def test_info_terms_of_service():
oas = OpenApiSpec3()
assert oas.info.terms_of_service is None
oas.info.terms_of_service = "http://example.com/terms/"
assert oas.info.terms_of_service == "http://example.com/terms/"
assert oas.spec == {
"info": {"termsOfService": "http://example.com/terms/"},
"openapi": "3.0.0",
}
@pytest.mark.skip("Not yet implemented")
def test_info_license():
oas = OpenApiSpec3()
oas.info.license.name = "Apache 2.0"
oas.info.license.url = "https://www.apache.org/licenses/LICENSE-2.0.html"
assert oas.spec == {
"info": {
"license": {
"name": "Apache 2.0",
"url": "https://www.apache.org/licenses/LICENSE-2.0.html",
}
},
"openapi": "3.0.0",
}

View File

@ -0,0 +1,124 @@
from aiohttp_pydantic.oas.struct import OpenApiSpec3
def test_paths_description():
oas = OpenApiSpec3()
oas.paths["/users/{id}"].description = "This route ..."
assert oas.spec == {
"openapi": "3.0.0",
"paths": {"/users/{id}": {"description": "This route ..."}},
}
def test_paths_get():
oas = OpenApiSpec3()
oas.paths["/users/{id}"].get
assert oas.spec == {"openapi": "3.0.0", "paths": {"/users/{id}": {"get": {}}}}
def test_paths_operation_description():
oas = OpenApiSpec3()
operation = oas.paths["/users/{id}"].get
operation.description = "Long descriptions ..."
assert oas.spec == {
"openapi": "3.0.0",
"paths": {"/users/{id}": {"get": {"description": "Long descriptions ..."}}},
}
def test_paths_operation_summary():
oas = OpenApiSpec3()
operation = oas.paths["/users/{id}"].get
operation.summary = "Updates a pet in the store with form data"
assert oas.spec == {
"openapi": "3.0.0",
"paths": {
"/users/{id}": {
"get": {"summary": "Updates a pet in the store with form data"}
}
},
}
def test_paths_operation_parameters():
oas = OpenApiSpec3()
operation = oas.paths["/users/{petId}"].get
parameter = operation.parameters[0]
parameter.name = "petId"
parameter.description = "ID of pet that needs to be updated"
parameter.in_ = "path"
parameter.required = True
assert oas.spec == {
"openapi": "3.0.0",
"paths": {
"/users/{petId}": {
"get": {
"parameters": [
{
"description": "ID of pet that needs to be updated",
"in": "path",
"name": "petId",
"required": True,
}
]
}
}
},
}
def test_paths_operation_requestBody():
oas = OpenApiSpec3()
request_body = oas.paths["/users/{petId}"].get.request_body
request_body.description = "user to add to the system"
request_body.content = {
"application/json": {
"schema": {"$ref": "#/components/schemas/User"},
"examples": {
"user": {
"summary": "User Example",
"externalValue": "http://foo.bar/examples/user-example.json",
}
},
}
}
request_body.required = True
assert oas.spec == {
"openapi": "3.0.0",
"paths": {
"/users/{petId}": {
"get": {
"requestBody": {
"content": {
"application/json": {
"examples": {
"user": {
"externalValue": "http://foo.bar/examples/user-example.json",
"summary": "User Example",
}
},
"schema": {"$ref": "#/components/schemas/User"},
}
},
"description": "user to add to the system",
"required": True,
}
}
}
},
}
def test_paths_operation_responses():
oas = OpenApiSpec3()
response = oas.paths["/users/{petId}"].get.responses[200]
response.description = "A complex object array response"
response.content = {
"application/json": {
"schema": {
"type": "array",
"items": {"$ref": "#/components/schemas/VeryComplexType"},
}
}
}

View File

@ -0,0 +1,36 @@
import pytest
from aiohttp_pydantic.oas.struct import OpenApiSpec3
def test_sever_url():
oas = OpenApiSpec3()
oas.servers[0].url = "https://development.gigantic-server.com/v1"
oas.servers[1].url = "https://development.gigantic-server.com/v2"
assert oas.spec == {
"openapi": "3.0.0",
"servers": [
{"url": "https://development.gigantic-server.com/v1"},
{"url": "https://development.gigantic-server.com/v2"},
],
}
def test_sever_description():
oas = OpenApiSpec3()
oas.servers[0].url = "https://development.gigantic-server.com/v1"
oas.servers[0].description = "Development server"
assert oas.spec == {
"openapi": "3.0.0",
"servers": [
{
"url": "https://development.gigantic-server.com/v1",
"description": "Development server",
}
],
}
@pytest.mark.skip("Not yet implemented")
def test_sever_variables():
oas = OpenApiSpec3()

View File

@ -1,4 +1,5 @@
from typing import List, Union from typing import List, Optional, Union
from uuid import UUID
import pytest import pytest
from aiohttp import web from aiohttp import web
@ -14,7 +15,9 @@ class Pet(BaseModel):
class PetCollectionView(PydanticView): class PetCollectionView(PydanticView):
async def get(self) -> r200[List[Pet]]: async def get(
self, format: str, name: Optional[str] = None, *, promo: Optional[UUID] = None
) -> r200[List[Pet]]:
""" """
Get a list of pets Get a list of pets
""" """
@ -57,21 +60,41 @@ async def test_generated_oas_should_have_pets_paths(generated_oas):
async def test_pets_route_should_have_get_method(generated_oas): async def test_pets_route_should_have_get_method(generated_oas):
assert generated_oas["paths"]["/pets"]["get"] == { assert generated_oas["paths"]["/pets"]["get"] == {
"description": "Get a list of pets", "description": "Get a list of pets",
"parameters": [
{
"in": "query",
"name": "format",
"required": True,
"schema": {"type": "string"},
},
{
"in": "query",
"name": "name",
"required": False,
"schema": {"type": "string"},
},
{
"in": "header",
"name": "promo",
"required": False,
"schema": {"format": "uuid", "type": "string"},
},
],
"responses": { "responses": {
"200": { "200": {
"content": { "content": {
"application/json": { "application/json": {
"schema": { "schema": {
"type": "array",
"items": { "items": {
"title": "Pet",
"type": "object",
"properties": { "properties": {
"id": {"title": "Id", "type": "integer"}, "id": {"title": "Id", "type": "integer"},
"name": {"title": "Name", "type": "string"}, "name": {"title": "Name", "type": "string"},
}, },
"required": ["id", "name"], "required": ["id", "name"],
"title": "Pet",
"type": "object",
}, },
"type": "array",
} }
} }
} }