Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Default error schema 422 #1396

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 34 additions & 17 deletions ninja/openapi/schema.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
import itertools
import re
from http.client import responses
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Any, Dict, Generator, List, Optional, Set, Tuple, Union

from django.utils.termcolors import make_style

from ninja.constants import NOT_SET
from ninja.operation import Operation
from ninja.params.models import TModel, TModels
from ninja.schema import NinjaGenerateJsonSchema
from ninja.schema import Schema, NinjaGenerateJsonSchema
from ninja.types import DictStrAny
from ninja.utils import normalize_path

Expand All @@ -23,6 +23,17 @@
"file": "multipart/form-data",
}

http422 = 422


class DefaultValidationError(Schema):
class ValidationError(Schema):
loc: List[Union[str, int]]
msg: str
type: str

detail: List[ValidationError]


def get_schema(api: "NinjaAPI", path_prefix: str = "") -> "OpenAPISchema":
openapi = OpenAPISchema(api, path_prefix)
Expand Down Expand Up @@ -88,13 +99,13 @@ def methods(self, operations: list) -> DictStrAny:
return result

def deep_dict_update(
self, main_dict: Dict[Any, Any], update_dict: Dict[Any, Any]
self, main_dict: Dict[Any, Any], update_dict: Dict[Any, Any]
) -> None:
for key in update_dict:
if (
key in main_dict
and isinstance(main_dict[key], dict)
and isinstance(update_dict[key], dict)
key in main_dict
and isinstance(main_dict[key], dict)
and isinstance(update_dict[key], dict)
):
self.deep_dict_update(
main_dict[key], update_dict[key]
Expand Down Expand Up @@ -167,7 +178,7 @@ def _extract_parameters(self, model: TModel) -> List[DictStrAny]:
p_schema: DictStrAny
p_required: bool
for p_name, p_schema, p_required in flatten_properties(
name, details, is_required, schema.get("$defs", {})
name, details, is_required, schema.get("$defs", {})
):
if not p_schema.get("include_in_schema", True):
continue
Expand Down Expand Up @@ -206,10 +217,10 @@ def _flatten_schema(self, model: TModel) -> DictStrAny:
return flattened

def _create_schema_from_model(
self,
model: TModel,
by_alias: bool = True,
remove_level: bool = True,
self,
model: TModel,
by_alias: bool = True,
remove_level: bool = True,
) -> Tuple[DictStrAny, bool]:
if hasattr(model, "__ninja_flatten_map__"):
schema = self._flatten_schema(model)
Expand All @@ -234,7 +245,7 @@ def _create_schema_from_model(
return schema, True

def _create_multipart_schema_from_models(
self, models: TModels
self, models: TModels
) -> Tuple[DictStrAny, str]:
# We have File and Form or Body, so we need to use multipart (File)
content_type = BODY_CONTENT_TYPES["file"]
Expand Down Expand Up @@ -291,7 +302,13 @@ def responses(self, operation: Operation) -> Dict[int, DictStrAny]:
self.api.renderer.media_type: {"schema": schema}
}
result.update(details)

if http422 not in result:
model = operation._create_response_model_multiple({http422: DefaultValidationError})[http422]
schema = self._create_schema_from_model(model)[0]
result[http422] = {
"description": "Validation error",
"content": {self.api.renderer.media_type: {"schema": schema}},
}
return result

def operation_security(self, operation: Operation) -> Optional[List[DictStrAny]]:
Expand Down Expand Up @@ -322,10 +339,10 @@ def add_schema_definitions(self, definitions: dict) -> None:


def flatten_properties(
prop_name: str,
prop_details: DictStrAny,
prop_required: bool,
definitions: DictStrAny,
prop_name: str,
prop_details: DictStrAny,
prop_required: bool,
definitions: DictStrAny,
) -> Generator[Tuple[str, DictStrAny, bool], None, None]:
"""
extracts all nested model's properties into flat properties
Expand Down
39 changes: 28 additions & 11 deletions tests/test_alias.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,19 +15,36 @@ def alias_operation(request):

def test_alias():
schema = api.get_openapi_schema()["components"]
print(schema)
assert schema == {
"schemas": {
"SchemaWithAlias": {
"type": "object",
"properties": {
"foo": {"type": "string", "default": "", "title": "Foo"}
},
"title": "SchemaWithAlias",
}
}
assert schema['schemas']['SchemaWithAlias'] == {
"type": "object",
"properties": {
"foo": {"type": "string", "default": "", "title": "Foo"}
},
"title": "SchemaWithAlias",
}

assert schema['schemas']['DefaultValidationError'] == {
'properties': {
'detail': {
'items': {'$ref': '#/components/schemas/ValidationError'},
'title': 'Detail', 'type': 'array'
}
},
'required': ['detail'],
'title': 'DefaultValidationError', 'type': 'object'
}
assert schema['schemas']['ValidationError'] == {
'properties': {
'loc': {
'items': {
'anyOf': [{'type': 'string'}, {'type': 'integer'}]
}, 'title': 'Loc', 'type': 'array'
},
'msg': {'title': 'Msg', 'type': 'string'},
'type': {'title': 'Type', 'type': 'string'}
},
'required': ['loc', 'msg', 'type'], 'title': 'ValidationError', 'type': 'object'
}

# TODO: check the conflicting approach
# when alias is used both for response and request schema
Expand Down
81 changes: 68 additions & 13 deletions tests/test_annotated.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,19 +21,19 @@ class Payload(Schema):

@api.post("/multi/{p}")
def multi_op(
request,
q: Annotated[str, Query(description="Query param")],
p: Annotated[int, Path(description="Path param")],
f: Annotated[FormData, Form(description="Form params")],
c: Annotated[str, Cookie(description="Cookie params")],
request,
q: Annotated[str, Query(description="Query param")],
p: Annotated[int, Path(description="Path param")],
f: Annotated[FormData, Form(description="Form params")],
c: Annotated[str, Cookie(description="Cookie params")],
):
return {"q": q, "p": p, "f": f.dict(), "c": c}


@api.post("/query_list")
def query_list(
request,
q: Annotated[List[str], Query(description="User ID")],
request,
q: Annotated[List[str], Query(description="User ID")],
):
return {"q": q}

Expand All @@ -45,7 +45,7 @@ def headers(request, h: Annotated[str, Header()] = "some-default"):

@api.post("/body")
def body_op(
request, payload: Annotated[Payload, Body(examples=[{"t": 42, "p": "test"}])]
request, payload: Annotated[Payload, Body(examples=[{"t": 42, "p": "test"}])]
):
return {"payload": payload}

Expand Down Expand Up @@ -84,7 +84,6 @@ def test_headers():

def test_openapi_schema():
schema = api.get_openapi_schema()["paths"]
print(schema)
assert schema == {
"/api/multi/{p}": {
"post": {
Expand Down Expand Up @@ -125,7 +124,21 @@ def test_openapi_schema():
"description": "Cookie params",
},
],
"responses": {200: {"description": "OK"}},
"responses": {
200: {
"description": "OK"
},
422: {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultValidationError"
}
}
},
"description": "Validation error"
}
},
"requestBody": {
"content": {
"application/x-www-form-urlencoded": {
Expand Down Expand Up @@ -162,7 +175,21 @@ def test_openapi_schema():
"description": "User ID",
}
],
"responses": {200: {"description": "OK"}},
"responses": {
200: {
"description": "OK"
},
422: {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultValidationError"
}
}
},
"description": "Validation error"
}
},
}
},
"/api/headers": {
Expand All @@ -181,15 +208,43 @@ def test_openapi_schema():
"required": False,
}
],
"responses": {200: {"description": "OK"}},
"responses": {
200: {
"description": "OK"
},
422: {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultValidationError"
}
}
},
"description": "Validation error"
}
},
}
},
"/api/body": {
"post": {
"operationId": "test_annotated_body_op",
"summary": "Body Op",
"parameters": [],
"responses": {200: {"description": "OK"}},
"responses": {
200: {
"description": "OK"
},
422: {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/DefaultValidationError"
}
}
},
"description": "Validation error"
}
},
"requestBody": {
"content": {
"application/json": {
Expand Down
Loading