Skip to content

Commit e6a8f18

Browse files
committed
@GitHK review: split tests and adds xfail
1 parent 0bc3eb0 commit e6a8f18

File tree

2 files changed

+116
-27
lines changed

2 files changed

+116
-27
lines changed

services/api-server/src/simcore_service_api_server/models/_utils_pydantic.py

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,22 +12,27 @@ class BaseConfig:
1212

1313

1414
class UriSchema:
15-
"""
16-
Use with HttpUrl to produce schema with uri format such as
17-
{
18-
"format": "uri",
19-
"maxLength": 2083,
20-
"minLength": 1,
21-
"type": "string",
22-
}
23-
24-
SEE # https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-7.3.5
15+
"""Metadata class to modify openapi schemas of Url fields
16+
17+
Usage:
18+
class TestModel(BaseModel):
19+
url: Annotated[HttpUrl, UriSchema()]
20+
21+
22+
will produce a schema for url field property as a string with a format
23+
{
24+
"format": "uri",
25+
"type": "string",
26+
}
27+
28+
SEE https://datatracker.ietf.org/doc/html/draft-bhutton-json-schema-validation-00#section-7.3.5
2529
"""
2630

2731
@classmethod
2832
def __get_pydantic_json_schema__(
2933
cls, core_schema: CoreSchema, handler: GetJsonSchemaHandler
3034
) -> JsonSchemaValue:
35+
# SEE https://docs.pydantic.dev/2.10/concepts/json_schema/#implementing-__get_pydantic_json_schema__
3136
json_schema = deepcopy(handler(core_schema))
3237

3338
if (schema := core_schema.get("schema", {})) and schema.get("type") == "url":
@@ -36,6 +41,8 @@ def __get_pydantic_json_schema__(
3641
format="uri",
3742
)
3843
if max_length := schema.get("max_length"):
44+
# SEE https://docs.pydantic.dev/2.10/api/networks/#pydantic.networks.UrlConstraints
45+
# adds limits if schema UrlConstraints includes it (e.g HttUrl includes )
3946
json_schema.update(maxLength=max_length, minLength=1)
4047

4148
return json_schema

services/api-server/tests/test_utils_pydantic.py

Lines changed: 99 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -2,31 +2,46 @@
22
# pylint: disable=unused-argument
33
# pylint: disable=unused-variable
44
# pylint: disable=too-many-arguments
5-
from typing import Annotated
65

6+
from typing import Annotated, Any
7+
8+
import fastapi
9+
import pydantic
10+
import pytest
711
from fastapi import FastAPI
8-
from pydantic import BaseModel, HttpUrl
12+
from pydantic import (
13+
AnyHttpUrl,
14+
AnyUrl,
15+
BaseModel,
16+
HttpUrl,
17+
TypeAdapter,
18+
ValidationError,
19+
)
920
from simcore_service_api_server.models._utils_pydantic import UriSchema
1021

1122

12-
def test_annotated_url_in_pydantic_changes_in_fastapi():
13-
class TestModel(BaseModel):
14-
urls0: list[HttpUrl]
15-
urls1: list[Annotated[HttpUrl, UriSchema()]]
23+
class _FakeModel(BaseModel):
24+
urls0: list[HttpUrl]
25+
urls1: list[Annotated[HttpUrl, UriSchema()]]
26+
27+
# with and w/o
28+
url0: HttpUrl
29+
url1: Annotated[HttpUrl, UriSchema()]
30+
31+
# # including None inside/outside annotated
32+
url2: Annotated[HttpUrl, UriSchema()] | None
33+
url3: Annotated[HttpUrl | None, UriSchema()]
1634

17-
# with and w/o
18-
url0: HttpUrl
19-
url1: Annotated[HttpUrl, UriSchema()]
35+
# # mistake
36+
int0: Annotated[int, UriSchema()]
2037

21-
# # including None inside/outside annotated
22-
url2: Annotated[HttpUrl, UriSchema()] | None
23-
url3: Annotated[HttpUrl | None, UriSchema()]
2438

25-
# # mistake
26-
int0: Annotated[int, UriSchema()]
39+
@pytest.fixture
40+
def pydantic_schema() -> dict[str, Any]:
41+
return _FakeModel.model_json_schema()
2742

28-
pydantic_schema = TestModel.model_json_schema()
2943

44+
def test_pydantic_json_schema(pydantic_schema: dict[str, Any]):
3045
assert pydantic_schema["properties"] == {
3146
"int0": {"title": "Int0", "type": "integer"},
3247
"url0": {
@@ -79,14 +94,20 @@ class TestModel(BaseModel):
7994
},
8095
}
8196

97+
98+
@pytest.fixture
99+
def fastapi_schema() -> dict[str, Any]:
82100
app = FastAPI()
83101

84-
@app.get("/", response_model=TestModel)
102+
@app.get("/", response_model=_FakeModel)
85103
def _h():
86104
...
87105

88106
openapi = app.openapi()
89-
fastapi_schema = openapi["components"]["schemas"]["TestModel"]
107+
return openapi["components"]["schemas"][_FakeModel.__name__]
108+
109+
110+
def test_fastapi_openapi_component_schemas(fastapi_schema: dict[str, Any]):
90111

91112
assert fastapi_schema["properties"] == {
92113
"int0": {"title": "Int0", "type": "integer"},
@@ -119,5 +140,66 @@ def _h():
119140
},
120141
}
121142

143+
144+
@pytest.mark.xfail(
145+
reason=f"{pydantic.__version__=} and {fastapi.__version__=} produce different json-schemas for the same model"
146+
)
147+
def test_compare_pydantic_vs_fastapi_schemas(
148+
fastapi_schema: dict[str, Any], pydantic_schema: dict[str, Any]
149+
):
150+
122151
# NOTE @all: I cannot understand this?!
123152
assert fastapi_schema["properties"] != pydantic_schema["properties"]
153+
154+
155+
def test_differences_between_new_pydantic_url_types():
156+
# SEE https://docs.pydantic.dev/2.10/api/networks/
157+
158+
# | **URL** | **AnyUrl** | **HttpUrl** | **AnyHttpUrl** |
159+
# |-------------------------------|------------|-------------|----------------|
160+
# | `http://example.com` | ✅ | ✅ | ✅ |
161+
# | `https://example.com/resource`| ✅ | ✅ | ✅ |
162+
# | `ftp://example.com` | ✅ | ❌ | ❌ |
163+
# | `http://localhost` | ✅ | ✅ | ✅ |
164+
# | `http://127.0.0.1` | ✅ | ✅ | ✅ |
165+
# | `http://127.0.0.1:8080` | ✅ | ✅ | ✅ |
166+
# | `customscheme://example.com` | ✅ | ❌ | ❌ |
167+
168+
url = "http://example.com"
169+
TypeAdapter(AnyUrl).validate_python(url)
170+
TypeAdapter(HttpUrl).validate_python(url)
171+
TypeAdapter(AnyHttpUrl).validate_python(url)
172+
173+
url = "https://example.com/resource"
174+
TypeAdapter(AnyUrl).validate_python(url)
175+
TypeAdapter(HttpUrl).validate_python(url)
176+
TypeAdapter(AnyHttpUrl).validate_python(url)
177+
178+
url = "ftp://example.com"
179+
TypeAdapter(AnyUrl).validate_python(url)
180+
with pytest.raises(ValidationError):
181+
TypeAdapter(HttpUrl).validate_python(url)
182+
with pytest.raises(ValidationError):
183+
TypeAdapter(AnyHttpUrl).validate_python(url)
184+
185+
url = "http://localhost"
186+
TypeAdapter(AnyUrl).validate_python(url)
187+
TypeAdapter(HttpUrl).validate_python(url)
188+
TypeAdapter(AnyHttpUrl).validate_python(url)
189+
190+
url = "http://127.0.0.1"
191+
TypeAdapter(AnyUrl).validate_python(url)
192+
TypeAdapter(HttpUrl).validate_python(url)
193+
TypeAdapter(AnyHttpUrl).validate_python(url)
194+
195+
url = "http://127.0.0.1:8080"
196+
TypeAdapter(AnyUrl).validate_python(url)
197+
TypeAdapter(HttpUrl).validate_python(url)
198+
TypeAdapter(AnyHttpUrl).validate_python(url)
199+
200+
url = "customscheme://example.com"
201+
TypeAdapter(AnyUrl).validate_python(url)
202+
with pytest.raises(ValidationError):
203+
TypeAdapter(HttpUrl).validate_python(url)
204+
with pytest.raises(ValidationError):
205+
TypeAdapter(AnyHttpUrl).validate_python(url)

0 commit comments

Comments
 (0)