Skip to content

Commit 5c3b648

Browse files
Merge branch 'master' into prevent-schema-creation-race
2 parents 564f1f6 + 5e1177b commit 5c3b648

6 files changed

Lines changed: 116 additions & 7 deletions

File tree

CHANGELOG.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ Bug Fixes
3636

3737
* Add python version requirement on setup.py (#586) [jason-the-j]
3838
* Add a thread lock to avoid concurrent schema construction. (#545) [peter-doggart]
39+
* Fix Nested field schema generation for nullable fields. (#638) [peter-doggart]
40+
* Fix reference resolution for definitions in schema. (#553) [peter-doggart]
3941

4042
.. _section-1.3.0:
4143
1.3.0

flask_restx/api.py

Lines changed: 41 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@
1717

1818
from flask.signals import got_request_exception
1919

20-
from jsonschema import RefResolver
20+
from referencing import Registry
2121

2222
from werkzeug.utils import cached_property
2323
from werkzeug.datastructures import Headers
@@ -134,7 +134,7 @@ def __init__(
134134
format_checker=None,
135135
url_scheme=None,
136136
default_swagger_filename="swagger.json",
137-
**kwargs
137+
**kwargs,
138138
):
139139
self.version = version
140140
self.title = title or "API"
@@ -830,7 +830,44 @@ def payload(self):
830830
@property
831831
def refresolver(self):
832832
if not self._refresolver:
833-
self._refresolver = RefResolver.from_schema(self.__schema__)
833+
# Create a registry that can resolve references within our schema
834+
registry = Registry()
835+
schema = self.__schema__
836+
837+
# If schema has definitions, register it
838+
if "definitions" in schema:
839+
schema_id = schema.get("$id", "http://localhost/schema.json")
840+
registry = registry.with_resource(schema_id, schema)
841+
else:
842+
# If no definitions in schema, register all models individually
843+
for name, model in self.models.items():
844+
model_schema = model.__schema__
845+
# Add $id to the model schema so it can be referenced
846+
if "$id" not in model_schema:
847+
model_schema = model_schema.copy()
848+
model_schema["$id"] = (
849+
f"http://localhost/schema.json#/definitions/{name}"
850+
)
851+
registry = registry.with_resource(
852+
f"http://localhost/schema.json#/definitions/{name}",
853+
model_schema,
854+
)
855+
856+
# Also register the root schema with definitions
857+
if self.models:
858+
definitions = {}
859+
for name, model in self.models.items():
860+
definitions[name] = model.__schema__
861+
862+
schema_with_definitions = {
863+
"$id": "http://localhost/schema.json",
864+
"definitions": definitions,
865+
}
866+
registry = registry.with_resource(
867+
"http://localhost/schema.json", schema_with_definitions
868+
)
869+
870+
self._refresolver = registry
834871
return self._refresolver
835872

836873
@staticmethod
@@ -866,7 +903,7 @@ def _blueprint_setup_add_url_rule_patch(
866903
"%s.%s" % (blueprint_setup.blueprint.name, endpoint),
867904
view_func,
868905
defaults=defaults,
869-
**options
906+
**options,
870907
)
871908

872909
def _deferred_blueprint_init(self, setup_state):

flask_restx/fields.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,9 @@ class Raw(object):
134134
:param bool readonly: Is the field read only ? (for documentation purpose)
135135
:param example: An optional data example (for documentation purpose)
136136
:param callable mask: An optional mask function to be applied to output
137+
:param bool nullable: Whether the field accepts null values in input
138+
validation. When True, the generated JSON Schema will allow null
139+
values for this field during request payload validation.
137140
"""
138141

139142
#: The JSON/Swagger schema type
@@ -153,6 +156,7 @@ def __init__(
153156
readonly=None,
154157
example=None,
155158
mask=None,
159+
nullable=None,
156160
**kwargs
157161
):
158162
self.attribute = attribute
@@ -163,6 +167,7 @@ def __init__(
163167
self.readonly = readonly
164168
self.example = example if example is not None else self.__schema_example__
165169
self.mask = mask
170+
self.nullable = nullable
166171

167172
def format(self, value):
168173
"""
@@ -284,6 +289,19 @@ def schema(self):
284289
schema["allOf"] = allOf
285290
else:
286291
schema["$ref"] = ref
292+
293+
# If nullable is True, wrap using anyOf to permit nulls for input validation
294+
if self.nullable:
295+
# Remove structural keys that conflict with anyOf composition
296+
for key in ("$ref", "allOf", "type", "items"):
297+
schema.pop(key, None)
298+
# Create anyOf with the original schema and null type
299+
anyOf = [{"$ref": ref}]
300+
if self.as_list:
301+
anyOf = [{"type": "array", "items": {"$ref": ref}}]
302+
anyOf.append({"type": "null"})
303+
schema["anyOf"] = anyOf
304+
287305
return schema
288306

289307
def clone(self, mask=None):

flask_restx/model.py

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from .errors import abort
1212

1313
from jsonschema import Draft4Validator
14+
from jsonschema.validators import validator_for
1415
from jsonschema.exceptions import ValidationError
1516

1617
from .utils import not_none
@@ -89,9 +90,48 @@ def inherit(cls, name, *parents):
8990
return model
9091

9192
def validate(self, data, resolver=None, format_checker=None):
92-
validator = Draft4Validator(
93-
self.__schema__, resolver=resolver, format_checker=format_checker
94-
)
93+
# For backward compatibility, resolver can be either a RefResolver or a Registry
94+
if resolver is not None and hasattr(resolver, "resolve"):
95+
# Old RefResolver - convert to registry
96+
registry = None
97+
validator = Draft4Validator(
98+
self.__schema__, resolver=resolver, format_checker=format_checker
99+
)
100+
else:
101+
# New Registry or None
102+
# If we have a registry, we need to create a schema that includes definitions
103+
schema_to_validate = self.__schema__
104+
if resolver is not None:
105+
# Check if the schema has $ref that need to be resolved
106+
import json
107+
108+
schema_str = json.dumps(self.__schema__)
109+
if '"$ref"' in schema_str:
110+
# Create a schema with inline definitions from the registry
111+
definitions = {}
112+
for uri in resolver:
113+
resource = resolver[uri]
114+
if isinstance(resource, dict) and "definitions" in resource:
115+
definitions.update(resource["definitions"])
116+
117+
if definitions:
118+
# Create a new schema that includes the definitions
119+
schema_to_validate = {
120+
"$id": "http://localhost/schema.json",
121+
"definitions": definitions,
122+
**self.__schema__,
123+
}
124+
125+
ValidatorClass = validator_for(schema_to_validate)
126+
if resolver is not None:
127+
validator = ValidatorClass(
128+
schema_to_validate, registry=resolver, format_checker=format_checker
129+
)
130+
else:
131+
validator = ValidatorClass(
132+
schema_to_validate, format_checker=format_checker
133+
)
134+
95135
try:
96136
validator.validate(data)
97137
except ValidationError:

requirements/install.pip

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
aniso8601>=0.82
22
jsonschema
3+
referencing
34
Flask>=0.8, !=2.0.0
45
werkzeug!=2.0.0
56
importlib_resources

tests/test_fields.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -881,6 +881,17 @@ def test_with_allow_null(self, api):
881881
assert field.allow_null
882882
assert field.__schema__ == {"$ref": "#/definitions/NestedModel"}
883883

884+
def test_with_nullable_schema(self, api):
885+
nested_fields = api.model("NestedModel", {"name": fields.String})
886+
field = fields.Nested(nested_fields, nullable=True)
887+
# Should allow null in schema via anyOf
888+
assert field.__schema__ == {
889+
"anyOf": [
890+
{"$ref": "#/definitions/NestedModel"},
891+
{"type": "null"},
892+
]
893+
}
894+
884895
def test_with_skip_none(self, api):
885896
nested_fields = api.model("NestedModel", {"name": fields.String})
886897
field = fields.Nested(nested_fields, skip_none=True)

0 commit comments

Comments
 (0)