66`register_schema_models` emit resolvable OpenAPI 3 references.
77"""
88
9+ import re
910from collections .abc import Iterable , Mapping
1011from enum import StrEnum
11- from typing import Any , Literal , NotRequired , Protocol , TypedDict
12+ from typing import Any , Literal , NotRequired , Protocol , TypedDict , override
13+ from weakref import WeakKeyDictionary
1214
1315from flask import request
1416from flask_restx import Namespace
1517from pydantic import BaseModel , TypeAdapter
18+ from pydantic .json_schema import CoreModeRef , DefsRef , GenerateJsonSchema
1619
1720from libs .flask_restx_compat import TYPED_EVENT_STREAM_RESPONSE_VENDOR_KEY
1821
@@ -53,39 +56,146 @@ def to_dict(self, flat: bool = True) -> dict[str, str]: ...
5356 def getlist (self , key : str ) -> list [str ]: ...
5457
5558
56- def _register_json_schema (namespace : Namespace , name : str , schema : dict ) -> None :
57- """Register a JSON schema and promote any nested Pydantic `$defs`."""
59+ class SchemaNameCollisionError (ValueError ):
60+ """Raised when two different schemas would be published under the same OpenAPI component name."""
61+
62+
63+ # Pydantic names `$defs` after the bare class name and only qualifies them when two
64+ # same-named types meet inside one schema, so the same type can surface under different
65+ # names depending on which model pulled it in. Types that share a class name with
66+ # another documented type get a fixed, unique component name here instead.
67+ SCHEMA_NAME_OVERRIDES : Mapping [str , str ] = {
68+ "graphon.model_runtime.entities.common_entities.I18nObject" : "ModelRuntimeI18nObject" ,
69+ "services.knowledge.entities.knowledge_entities.FileInfo" : "KnowledgeFileInfo" ,
70+ "core.plugin.entities.plugin.PluginDependency.Github" : "PluginDependencyGithub" ,
71+ "core.plugin.entities.plugin.PluginDependency.Marketplace" : "PluginDependencyMarketplace" ,
72+ "core.plugin.entities.plugin.PluginDependency.Package" : "PluginDependencyPackage" ,
73+ "core.plugin.entities.bundle.PluginBundleDependency.Github" : "PluginBundleDependencyGithub" ,
74+ "core.plugin.entities.bundle.PluginBundleDependency.Marketplace" : "PluginBundleDependencyMarketplace" ,
75+ "core.plugin.entities.bundle.PluginBundleDependency.Package" : "PluginBundleDependencyPackage" ,
76+ "core.app.entities.task_entities.AgentLogStreamResponse.Data" : "AgentLogStreamResponseData" ,
77+ "core.app.entities.task_entities.HumanInputFormFilledResponse.Data" : "HumanInputFormFilledResponseData" ,
78+ "core.app.entities.task_entities.HumanInputFormTimeoutResponse.Data" : "HumanInputFormTimeoutResponseData" ,
79+ "core.app.entities.task_entities.HumanInputRequiredResponse.Data" : "HumanInputRequiredResponseData" ,
80+ "core.app.entities.task_entities.IterationNodeCompletedStreamResponse.Data" : (
81+ "IterationNodeCompletedStreamResponseData"
82+ ),
83+ "core.app.entities.task_entities.IterationNodeNextStreamResponse.Data" : "IterationNodeNextStreamResponseData" ,
84+ "core.app.entities.task_entities.IterationNodeStartStreamResponse.Data" : "IterationNodeStartStreamResponseData" ,
85+ "core.app.entities.task_entities.LoopNodeCompletedStreamResponse.Data" : "LoopNodeCompletedStreamResponseData" ,
86+ "core.app.entities.task_entities.LoopNodeNextStreamResponse.Data" : "LoopNodeNextStreamResponseData" ,
87+ "core.app.entities.task_entities.LoopNodeStartStreamResponse.Data" : "LoopNodeStartStreamResponseData" ,
88+ "core.app.entities.task_entities.NodeFinishStreamResponse.Data" : "NodeFinishStreamResponseData" ,
89+ "core.app.entities.task_entities.NodeRetryStreamResponse.Data" : "NodeRetryStreamResponseData" ,
90+ "core.app.entities.task_entities.NodeStartStreamResponse.Data" : "NodeStartStreamResponseData" ,
91+ "core.app.entities.task_entities.ReasoningChunkStreamResponse.Data" : "ReasoningChunkStreamResponseData" ,
92+ "core.app.entities.task_entities.TextChunkStreamResponse.Data" : "TextChunkStreamResponseData" ,
93+ "core.app.entities.task_entities.TextReplaceStreamResponse.Data" : "TextReplaceStreamResponseData" ,
94+ "core.app.entities.task_entities.WorkflowAppBlockingResponse.Data" : "WorkflowAppBlockingResponseData" ,
95+ "core.app.entities.task_entities.WorkflowFinishStreamResponse.Data" : "WorkflowFinishStreamResponseData" ,
96+ "core.app.entities.task_entities.WorkflowPauseStreamResponse.Data" : "WorkflowPauseStreamResponseData" ,
97+ "core.app.entities.task_entities.WorkflowStartStreamResponse.Data" : "WorkflowStartStreamResponseData" ,
98+ }
99+
100+ _REF_ID_SUFFIX = re .compile (r":\d+(?=$|[\[\],])" )
101+ _MODULE_PREFIX = re .compile (r"(?:[^.[\],]+\.)+([^.[\],]+)" )
102+
103+ # Normalized component name -> (component name, fully qualified source type, schema), per namespace.
104+ _registered_schemas : WeakKeyDictionary [Namespace , dict [str , tuple [str , str , dict ]]] = WeakKeyDictionary ()
105+ _NON_ALPHANUMERIC = re .compile (r"[^a-z0-9]" )
106+
107+
108+ def _normalized_schema_name (name : str ) -> str :
109+ """Fold names that client code generators turn into the same identifier (`JSONValue` / `JsonValue`)."""
110+
111+ return _NON_ALPHANUMERIC .sub ("" , name .lower ())
112+
113+
114+ class _OpenApiJsonSchemaGenerator (GenerateJsonSchema ):
115+ """Emit stable component names and remember which type produced each of them."""
116+
117+ def __init__ (self , * args : Any , ** kwargs : Any ) -> None :
118+ super ().__init__ (* args , ** kwargs )
119+ self .schema_sources : dict [str , str ] = {}
120+
121+ @override
122+ def get_defs_ref (self , core_mode_ref : CoreModeRef ) -> DefsRef :
123+ core_ref , _mode = core_mode_ref
124+ source = _REF_ID_SUFFIX .sub ("" , core_ref )
125+ name = SCHEMA_NAME_OVERRIDES .get (source ) or self .normalize_name (_MODULE_PREFIX .sub (r"\1" , source ))
126+ existing_source = self .schema_sources .setdefault (name , source )
127+ if existing_source != source :
128+ raise SchemaNameCollisionError (
129+ f"OpenAPI schema name { name !r} is produced by both { existing_source } and { source } ; "
130+ "rename one of them or add it to SCHEMA_NAME_OVERRIDES."
131+ )
132+ defs_ref = DefsRef (name )
133+ self ._prioritized_defsref_choices [defs_ref ] = [defs_ref ]
134+ return defs_ref
135+
136+
137+ def _register_json_schema (namespace : Namespace , name : str , schema : dict , sources : Mapping [str , str ]) -> None :
138+ """Register a JSON schema and promote any nested Pydantic `$defs`.
139+
140+ `sources` maps each component name to the type that produced it. Flask-RESTX keeps
141+ one flat model namespace, so a second registration under an existing name would
142+ silently replace the first. Re-registering the same type with the same schema is a
143+ no-op; a different type, or the same type with a different shape (for example a
144+ response model also registered in validation mode), raises instead.
145+ """
58146
59147 nested_definitions = schema .get ("$defs" )
60148 schema_to_register = dict (schema )
61149 if isinstance (nested_definitions , dict ):
62150 schema_to_register .pop ("$defs" )
63151
152+ source = sources .get (name , name )
153+ registered = _registered_schemas .setdefault (namespace , {})
154+ normalized_name = _normalized_schema_name (name )
155+ existing = registered .get (normalized_name )
156+ if existing is not None and existing != (name , source , schema_to_register ):
157+ existing_name , existing_source , _ = existing
158+ if existing_name != name :
159+ reason = f"collides with { existing_name !r} from { existing_source } once normalized by client generators"
160+ elif existing_source != source :
161+ reason = f"is produced by both { existing_source } and { source } "
162+ else :
163+ reason = f"is registered by { source } with two different shapes (validation vs serialization mode?)"
164+ raise SchemaNameCollisionError (
165+ f"OpenAPI schema name { name !r} in namespace { namespace .name !r} { reason } ; give each shape its own "
166+ "class name, add it to SCHEMA_NAME_OVERRIDES, or register response models only with "
167+ "register_response_schema_models."
168+ )
169+ registered [normalized_name ] = (name , source , schema_to_register )
170+
64171 namespace .schema_model (name , schema_to_register )
65172
66173 if not isinstance (nested_definitions , dict ):
67174 return
68175
69176 for nested_name , nested_schema in nested_definitions .items ():
70177 if isinstance (nested_schema , dict ):
71- _register_json_schema (namespace , nested_name , nested_schema )
178+ _register_json_schema (namespace , nested_name , nested_schema , sources )
72179
73180
74181JsonSchemaMode = Literal ["validation" , "serialization" ]
75182
76183
77- def _register_schema_model (namespace : Namespace , model : type [BaseModel ], * , mode : JsonSchemaMode ) -> None :
184+ def _register_type_schema (namespace : Namespace , source_type : type , * , mode : JsonSchemaMode ) -> None :
185+ generator = _OpenApiJsonSchemaGenerator (ref_template = DEFAULT_REF_TEMPLATE_OPENAPI_3_0 )
186+ schema = generator .generate (TypeAdapter (source_type ).core_schema , mode = mode )
78187 _register_json_schema (
79188 namespace ,
80- model .__name__ ,
81- model .model_json_schema (ref_template = DEFAULT_REF_TEMPLATE_OPENAPI_3_0 , mode = mode ),
189+ source_type .__name__ ,
190+ schema ,
191+ {** generator .schema_sources , source_type .__name__ : f"{ source_type .__module__ } .{ source_type .__qualname__ } " },
82192 )
83193
84194
85195def register_schema_model (namespace : Namespace , model : type [BaseModel ]) -> None :
86196 """Register a BaseModel and its nested component schemas for OpenAPI documentation."""
87197
88- _register_schema_model (namespace , model , mode = "validation" )
198+ _register_type_schema (namespace , model , mode = "validation" )
89199
90200
91201def register_schema_models (namespace : Namespace , * models : type [BaseModel ]) -> None :
@@ -98,7 +208,7 @@ def register_schema_models(namespace: Namespace, *models: type[BaseModel]) -> No
98208def register_response_schema_model (namespace : Namespace , model : type [BaseModel ]) -> None :
99209 """Register a BaseModel using its serialized response shape."""
100210
101- _register_schema_model (namespace , model , mode = "serialization" )
211+ _register_type_schema (namespace , model , mode = "serialization" )
102212
103213
104214def register_response_schema_models (namespace : Namespace , * models : type [BaseModel ]) -> None :
@@ -134,11 +244,7 @@ def get_or_create_model(model_name: str, field_def):
134244def register_enum_models (namespace : Namespace , * models : type [StrEnum ]) -> None :
135245 """Register multiple StrEnum with a namespace."""
136246 for model in models :
137- _register_json_schema (
138- namespace ,
139- model .__name__ ,
140- TypeAdapter (model ).json_schema (ref_template = DEFAULT_REF_TEMPLATE_OPENAPI_3_0 ),
141- )
247+ _register_type_schema (namespace , model , mode = "validation" )
142248
143249
144250def query_params_from_model (model : type [BaseModel ]) -> dict [str , QueryParamDoc ]:
@@ -349,6 +455,8 @@ def _nullable_property_schema(property_schema: Mapping[str, Any]) -> Mapping[str
349455
350456__all__ = [
351457 "DEFAULT_REF_TEMPLATE_OPENAPI_3_0" ,
458+ "SCHEMA_NAME_OVERRIDES" ,
459+ "SchemaNameCollisionError" ,
352460 "get_or_create_model" ,
353461 "query_params_from_model" ,
354462 "query_params_from_request" ,
0 commit comments