fix(tool_call): reland schema type normalization (#26433)
This commit is contained in:
@@ -60,7 +60,10 @@ from sglang.srt.environ import envs
|
|||||||
from sglang.srt.function_call.core_types import ToolCallItem
|
from sglang.srt.function_call.core_types import ToolCallItem
|
||||||
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
from sglang.srt.function_call.function_call_parser import FunctionCallParser
|
||||||
from sglang.srt.function_call.json_array_parser import JsonArrayParser
|
from sglang.srt.function_call.json_array_parser import JsonArrayParser
|
||||||
from sglang.srt.function_call.utils import get_json_schema_constraint
|
from sglang.srt.function_call.utils import (
|
||||||
|
get_json_schema_constraint,
|
||||||
|
normalize_json_schema_types,
|
||||||
|
)
|
||||||
from sglang.srt.managers.io_struct import GenerateReqInput
|
from sglang.srt.managers.io_struct import GenerateReqInput
|
||||||
from sglang.srt.parser.conversation import generate_chat_conv
|
from sglang.srt.parser.conversation import generate_chat_conv
|
||||||
from sglang.srt.parser.jinja_template_utils import process_content_for_template_format
|
from sglang.srt.parser.jinja_template_utils import process_content_for_template_format
|
||||||
@@ -416,9 +419,19 @@ class OpenAIServingChat(OpenAIServingBase):
|
|||||||
if tool.function.parameters is None:
|
if tool.function.parameters is None:
|
||||||
continue
|
continue
|
||||||
try:
|
try:
|
||||||
|
# Rewrite DB/ORM-style aliases (e.g. "varchar", "enum", "int")
|
||||||
|
# to standard JSON Schema types before validation. RecursionError
|
||||||
|
# guards against hand-crafted cyclic schemas so the request gets
|
||||||
|
# a 400 instead of crashing into a 500.
|
||||||
|
normalize_json_schema_types(tool.function.parameters)
|
||||||
Draft202012Validator.check_schema(tool.function.parameters)
|
Draft202012Validator.check_schema(tool.function.parameters)
|
||||||
except SchemaError as e:
|
except SchemaError as e:
|
||||||
return f"Tool {i} function has invalid 'parameters' schema: {str(e)}"
|
return f"Tool {i} function has invalid 'parameters' schema: {str(e)}"
|
||||||
|
except RecursionError:
|
||||||
|
return (
|
||||||
|
f"Tool {i} function 'parameters' schema is too deeply nested "
|
||||||
|
"or contains a cycle."
|
||||||
|
)
|
||||||
|
|
||||||
max_output_tokens = request.max_completion_tokens or request.max_tokens
|
max_output_tokens = request.max_completion_tokens or request.max_tokens
|
||||||
server_context_length = self.tokenizer_manager.server_args.context_length
|
server_context_length = self.tokenizer_manager.server_args.context_length
|
||||||
|
|||||||
@@ -8,6 +8,168 @@ from partial_json_parser.core.options import Allow
|
|||||||
|
|
||||||
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
|
from sglang.srt.entrypoints.openai.protocol import Tool, ToolChoice
|
||||||
|
|
||||||
|
_STANDARD_JSON_SCHEMA_TYPES = {
|
||||||
|
"null",
|
||||||
|
"boolean",
|
||||||
|
"object",
|
||||||
|
"array",
|
||||||
|
"number",
|
||||||
|
"string",
|
||||||
|
"integer",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Non-standard ``type`` values commonly emitted by DB/ORM-driven tool-schema
|
||||||
|
# generators. Mapped to the closest JSON Schema 2020-12 primitive so that
|
||||||
|
# ``Draft202012Validator.check_schema`` does not reject an otherwise-usable
|
||||||
|
# tool definition.
|
||||||
|
_JSON_SCHEMA_TYPE_ALIASES: Dict[str, str] = {
|
||||||
|
"str": "string",
|
||||||
|
"text": "string",
|
||||||
|
"varchar": "string",
|
||||||
|
"char": "string",
|
||||||
|
"enum": "string",
|
||||||
|
"uuid": "string",
|
||||||
|
"date": "string",
|
||||||
|
"datetime": "string",
|
||||||
|
"time": "string",
|
||||||
|
"timestamp": "string",
|
||||||
|
"binary": "string",
|
||||||
|
"blob": "string",
|
||||||
|
"bytea": "string",
|
||||||
|
"bytes": "string",
|
||||||
|
"varbinary": "string",
|
||||||
|
"bool": "boolean",
|
||||||
|
"bigint": "integer",
|
||||||
|
"smallint": "integer",
|
||||||
|
"tinyint": "integer",
|
||||||
|
"double": "number",
|
||||||
|
"decimal": "number",
|
||||||
|
"real": "number",
|
||||||
|
"numeric": "number",
|
||||||
|
"arr": "array",
|
||||||
|
"tuple": "array",
|
||||||
|
"set": "array",
|
||||||
|
"map": "object",
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prefix-based matching so that parameterised names like ``int32`` /
|
||||||
|
# ``float64`` / ``list[str]`` / ``dict[str, int]`` resolve. A prefix only
|
||||||
|
# matches when it spans the entire token or is followed by a non-identifier
|
||||||
|
# char, so "int" does not swallow "internal" and "list" does not swallow
|
||||||
|
# "list_price".
|
||||||
|
_PREFIX_BOUNDARY_CHARS = frozenset("0123456789[<( \t")
|
||||||
|
_PREFIX_RULES: Tuple[Tuple[Tuple[str, ...], str], ...] = (
|
||||||
|
(("int", "uint", "long", "short", "unsigned"), "integer"),
|
||||||
|
(("num", "float"), "number"),
|
||||||
|
(("list",), "array"),
|
||||||
|
(("dict",), "object"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _matches_type_prefix(base: str, prefixes: Tuple[str, ...]) -> bool:
|
||||||
|
for p in prefixes:
|
||||||
|
if base == p:
|
||||||
|
return True
|
||||||
|
if (
|
||||||
|
len(base) > len(p)
|
||||||
|
and base.startswith(p)
|
||||||
|
and base[len(p)] in _PREFIX_BOUNDARY_CHARS
|
||||||
|
):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_single_type(raw: Any) -> Any:
|
||||||
|
if not isinstance(raw, str):
|
||||||
|
return raw
|
||||||
|
if raw in _STANDARD_JSON_SCHEMA_TYPES:
|
||||||
|
return raw
|
||||||
|
# ``split("(", 1)[0]`` strips parenthesized params like ``varchar(255)``
|
||||||
|
# or ``decimal(10,2)`` without the overhead of a regex per call.
|
||||||
|
base = raw.split("(", 1)[0].strip().lower()
|
||||||
|
if base in _STANDARD_JSON_SCHEMA_TYPES:
|
||||||
|
return base
|
||||||
|
mapped = _JSON_SCHEMA_TYPE_ALIASES.get(base)
|
||||||
|
if mapped is not None:
|
||||||
|
return mapped
|
||||||
|
for prefixes, target in _PREFIX_RULES:
|
||||||
|
if _matches_type_prefix(base, prefixes):
|
||||||
|
return target
|
||||||
|
return raw
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_type_list(raw_items: List[Any]) -> List[Any]:
|
||||||
|
normalized_items: List[Any] = []
|
||||||
|
for item in raw_items:
|
||||||
|
normalized_item = _normalize_single_type(item)
|
||||||
|
if normalized_item not in normalized_items:
|
||||||
|
normalized_items.append(normalized_item)
|
||||||
|
return normalized_items
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_json_schema_types(schema: Any) -> None:
|
||||||
|
"""
|
||||||
|
Walk a JSON Schema in place and rewrite non-standard ``"type"`` values
|
||||||
|
(e.g. ``"varchar"``, ``"enum"``, ``"int"``) to their standard JSON Schema
|
||||||
|
equivalents.
|
||||||
|
|
||||||
|
Acts as a compatibility layer for tool ``parameters`` schemas exported
|
||||||
|
from database / ORM tooling, which often uses DB type names rather than
|
||||||
|
JSON Schema types. Unknown types are left untouched so that downstream
|
||||||
|
validation can still surface genuine errors.
|
||||||
|
|
||||||
|
Mutates the input dict in place; the rewritten schema is also what gets
|
||||||
|
rendered into the model prompt, so e.g. a user-supplied ``"varchar"``
|
||||||
|
reaches the model as ``"string"``. ``$ref`` values are not resolved;
|
||||||
|
callers pass tree-shaped schemas (HTTP JSON input is always a tree).
|
||||||
|
"""
|
||||||
|
if isinstance(schema, list):
|
||||||
|
for item in schema:
|
||||||
|
normalize_json_schema_types(item)
|
||||||
|
return
|
||||||
|
if not isinstance(schema, dict):
|
||||||
|
return
|
||||||
|
|
||||||
|
if "type" in schema:
|
||||||
|
t = schema["type"]
|
||||||
|
if isinstance(t, str):
|
||||||
|
schema["type"] = _normalize_single_type(t)
|
||||||
|
elif isinstance(t, list):
|
||||||
|
schema["type"] = _normalize_type_list(t)
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"properties",
|
||||||
|
"patternProperties",
|
||||||
|
"$defs",
|
||||||
|
"definitions",
|
||||||
|
"dependentSchemas",
|
||||||
|
):
|
||||||
|
nested = schema.get(key)
|
||||||
|
if isinstance(nested, dict):
|
||||||
|
for v in nested.values():
|
||||||
|
normalize_json_schema_types(v)
|
||||||
|
|
||||||
|
for key in ("anyOf", "oneOf", "allOf", "prefixItems"):
|
||||||
|
nested = schema.get(key)
|
||||||
|
if isinstance(nested, list):
|
||||||
|
for v in nested:
|
||||||
|
normalize_json_schema_types(v)
|
||||||
|
|
||||||
|
for key in (
|
||||||
|
"items",
|
||||||
|
"additionalProperties",
|
||||||
|
"not",
|
||||||
|
"if",
|
||||||
|
"then",
|
||||||
|
"else",
|
||||||
|
"contains",
|
||||||
|
"propertyNames",
|
||||||
|
"unevaluatedItems",
|
||||||
|
"unevaluatedProperties",
|
||||||
|
):
|
||||||
|
if key in schema:
|
||||||
|
normalize_json_schema_types(schema[key])
|
||||||
|
|
||||||
|
|
||||||
def _find_common_prefix(s1: str, s2: str) -> str:
|
def _find_common_prefix(s1: str, s2: str) -> str:
|
||||||
prefix = ""
|
prefix = ""
|
||||||
|
|||||||
@@ -0,0 +1,409 @@
|
|||||||
|
"""Unit tests for tool-parameter schema alias normalization."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from jsonschema import Draft202012Validator, SchemaError
|
||||||
|
|
||||||
|
from sglang.srt.function_call.utils import normalize_json_schema_types
|
||||||
|
from sglang.test.ci.ci_register import register_cpu_ci
|
||||||
|
from sglang.test.test_utils import CustomTestCase
|
||||||
|
|
||||||
|
register_cpu_ci(1.0, "base-a-test-cpu")
|
||||||
|
|
||||||
|
|
||||||
|
class TestNormalizeJsonSchemaTypes(CustomTestCase):
|
||||||
|
def _assert_accepts(self, schema: dict) -> None:
|
||||||
|
Draft202012Validator.check_schema(schema)
|
||||||
|
|
||||||
|
def test_enum_alias_becomes_string(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {"color": {"type": "enum", "enum": ["red", "green", "blue"]}},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["color"]["type"], "string")
|
||||||
|
self.assertEqual(
|
||||||
|
schema["properties"]["color"]["enum"], ["red", "green", "blue"]
|
||||||
|
)
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_varchar_alias_becomes_string(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"name": {"type": "varchar"},
|
||||||
|
"short_name": {"type": "VARCHAR(255)"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["name"]["type"], "string")
|
||||||
|
self.assertEqual(schema["properties"]["short_name"]["type"], "string")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_numeric_aliases(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"age": {"type": "int"},
|
||||||
|
"big": {"type": "bigint"},
|
||||||
|
"price": {"type": "decimal(10,2)"},
|
||||||
|
"ratio": {"type": "float"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
props = schema["properties"]
|
||||||
|
self.assertEqual(props["age"]["type"], "integer")
|
||||||
|
self.assertEqual(props["big"]["type"], "integer")
|
||||||
|
self.assertEqual(props["price"]["type"], "number")
|
||||||
|
self.assertEqual(props["ratio"]["type"], "number")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_prefix_matched_numeric_types(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "int32"},
|
||||||
|
"b": {"type": "int64"},
|
||||||
|
"c": {"type": "uint"},
|
||||||
|
"d": {"type": "unsigned"},
|
||||||
|
"e": {"type": "long"},
|
||||||
|
"f": {"type": "short"},
|
||||||
|
"g": {"type": "float32"},
|
||||||
|
"h": {"type": "float64"},
|
||||||
|
"i": {"type": "num"},
|
||||||
|
"j": {"type": "numeric"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
p = schema["properties"]
|
||||||
|
for k in ("a", "b", "c", "d", "e", "f"):
|
||||||
|
self.assertEqual(p[k]["type"], "integer")
|
||||||
|
for k in ("g", "h", "i", "j"):
|
||||||
|
self.assertEqual(p[k]["type"], "number")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_prefix_matched_compound_types(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "list[str]"},
|
||||||
|
"b": {"type": "list<int>"},
|
||||||
|
"c": {"type": "dict"},
|
||||||
|
"d": {"type": "dict[str, int]"},
|
||||||
|
"e": {"type": "long long"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
p = schema["properties"]
|
||||||
|
self.assertEqual(p["a"]["type"], "array")
|
||||||
|
self.assertEqual(p["b"]["type"], "array")
|
||||||
|
self.assertEqual(p["c"]["type"], "object")
|
||||||
|
self.assertEqual(p["d"]["type"], "object")
|
||||||
|
self.assertEqual(p["e"]["type"], "integer")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_word_boundary_prevents_false_positives(self):
|
||||||
|
"""Prefixes must end at a non-identifier char, so custom type names
|
||||||
|
that merely start with a known prefix are left alone."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "internal"},
|
||||||
|
"b": {"type": "list_price"},
|
||||||
|
"c": {"type": "integer_enum"},
|
||||||
|
"d": {"type": "dictionary_entry"},
|
||||||
|
"e": {"type": "floating"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
p = schema["properties"]
|
||||||
|
for key, expected in [
|
||||||
|
("a", "internal"),
|
||||||
|
("b", "list_price"),
|
||||||
|
("c", "integer_enum"),
|
||||||
|
("d", "dictionary_entry"),
|
||||||
|
("e", "floating"),
|
||||||
|
]:
|
||||||
|
self.assertEqual(p[key]["type"], expected)
|
||||||
|
|
||||||
|
def test_recurses_into_draft_2020_12_keywords(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"row": {
|
||||||
|
"dependentSchemas": {
|
||||||
|
"kind": {
|
||||||
|
"properties": {"sku": {"type": "varchar"}},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"propertyNames": {"type": "str"},
|
||||||
|
"unevaluatedProperties": {"type": "bigint"},
|
||||||
|
},
|
||||||
|
"rows": {
|
||||||
|
"type": "arr",
|
||||||
|
"unevaluatedItems": {"type": "int32"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
row = schema["properties"]["row"]
|
||||||
|
rows = schema["properties"]["rows"]
|
||||||
|
self.assertEqual(
|
||||||
|
row["dependentSchemas"]["kind"]["properties"]["sku"]["type"], "string"
|
||||||
|
)
|
||||||
|
self.assertEqual(row["propertyNames"]["type"], "string")
|
||||||
|
self.assertEqual(row["unevaluatedProperties"]["type"], "integer")
|
||||||
|
self.assertEqual(rows["type"], "array")
|
||||||
|
self.assertEqual(rows["unevaluatedItems"]["type"], "integer")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_bytes_and_arr_aliases(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"payload": {"type": "binary"},
|
||||||
|
"raw": {"type": "bytea"},
|
||||||
|
"items": {"type": "arr"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["payload"]["type"], "string")
|
||||||
|
self.assertEqual(schema["properties"]["raw"]["type"], "string")
|
||||||
|
self.assertEqual(schema["properties"]["items"]["type"], "array")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_bool_alias_becomes_boolean(self):
|
||||||
|
schema = {"type": "object", "properties": {"flag": {"type": "bool"}}}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["flag"]["type"], "boolean")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_case_insensitive(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "VARCHAR"},
|
||||||
|
"b": {"type": "INT"},
|
||||||
|
"c": {"type": "String"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
p = schema["properties"]
|
||||||
|
self.assertEqual(p["a"]["type"], "string")
|
||||||
|
self.assertEqual(p["b"]["type"], "integer")
|
||||||
|
self.assertEqual(p["c"]["type"], "string")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_array_and_object_aliases(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"tags": {"type": "list", "items": {"type": "str"}},
|
||||||
|
"meta": {"type": "dict"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["tags"]["type"], "array")
|
||||||
|
self.assertEqual(schema["properties"]["tags"]["items"]["type"], "string")
|
||||||
|
self.assertEqual(schema["properties"]["meta"]["type"], "object")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_nested_anyof_and_defs(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"anyOf": [
|
||||||
|
{"type": "int"},
|
||||||
|
{"type": "varchar"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"Row": {"type": "object", "properties": {"id": {"type": "bigint"}}}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
any_of = schema["properties"]["value"]["anyOf"]
|
||||||
|
self.assertEqual(any_of[0]["type"], "integer")
|
||||||
|
self.assertEqual(any_of[1]["type"], "string")
|
||||||
|
self.assertEqual(schema["$defs"]["Row"]["properties"]["id"]["type"], "integer")
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_type_list_member_normalized(self):
|
||||||
|
schema = {"type": ["varchar", "null"]}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["type"], ["string", "null"])
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_type_list_deduplicates_after_normalization(self):
|
||||||
|
schema = {"type": ["varchar", "string", "null", "int", "integer"]}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["type"], ["string", "null", "integer"])
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_standard_types_untouched(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "string"},
|
||||||
|
"b": {"type": "integer"},
|
||||||
|
"c": {"type": "boolean"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["a"]["type"], "string")
|
||||||
|
self.assertEqual(schema["properties"]["b"]["type"], "integer")
|
||||||
|
self.assertEqual(schema["properties"]["c"]["type"], "boolean")
|
||||||
|
|
||||||
|
def test_unknown_type_left_alone(self):
|
||||||
|
schema = {"type": "object", "properties": {"x": {"type": "geometry"}}}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["x"]["type"], "geometry")
|
||||||
|
with self.assertRaises(SchemaError):
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_common_db_orm_type_names_accepted(self):
|
||||||
|
"""Common non-standard DB/ORM type names all survive validation."""
|
||||||
|
recognized = [
|
||||||
|
# string family
|
||||||
|
"string",
|
||||||
|
"str",
|
||||||
|
"text",
|
||||||
|
"varchar",
|
||||||
|
"char",
|
||||||
|
"enum",
|
||||||
|
# integer via prefix
|
||||||
|
"int",
|
||||||
|
"int32",
|
||||||
|
"int64",
|
||||||
|
"uint",
|
||||||
|
"uint8",
|
||||||
|
"long",
|
||||||
|
"long long",
|
||||||
|
"short",
|
||||||
|
"unsigned",
|
||||||
|
# number via prefix
|
||||||
|
"num",
|
||||||
|
"numeric",
|
||||||
|
"float",
|
||||||
|
"float32",
|
||||||
|
"float64",
|
||||||
|
# boolean
|
||||||
|
"boolean",
|
||||||
|
"bool",
|
||||||
|
"binary",
|
||||||
|
"bytea",
|
||||||
|
"bytes",
|
||||||
|
"blob",
|
||||||
|
"varbinary",
|
||||||
|
# compound
|
||||||
|
"object",
|
||||||
|
"array",
|
||||||
|
"arr",
|
||||||
|
"dict",
|
||||||
|
"dict[str, int]",
|
||||||
|
"list",
|
||||||
|
"list[str]",
|
||||||
|
]
|
||||||
|
for t in recognized:
|
||||||
|
schema = {"type": "object", "properties": {"x": {"type": t}}}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
try:
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
except SchemaError as e:
|
||||||
|
self.fail(f"type {t!r} -> {schema['properties']['x']['type']!r}: {e}")
|
||||||
|
|
||||||
|
def test_pre_existing_400_schema_now_accepted(self):
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"sql": {"type": "varchar"},
|
||||||
|
"mode": {"type": "enum", "enum": ["read", "write"]},
|
||||||
|
},
|
||||||
|
"required": ["sql", "mode"],
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
def test_idempotent(self):
|
||||||
|
"""Running normalize twice produces the same result as running it once."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "varchar"},
|
||||||
|
"b": {"type": "int32"},
|
||||||
|
"c": {"type": ["bigint", "null"]},
|
||||||
|
"d": {
|
||||||
|
"anyOf": [{"type": "enum"}, {"type": "decimal(10,2)"}],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
once = json.loads(json.dumps(schema))
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema, once)
|
||||||
|
|
||||||
|
def test_non_string_type_values_pass_through(self):
|
||||||
|
"""``type`` that isn't str/list is left for the real validator to reject."""
|
||||||
|
for bad in (None, 42, {"$ref": "#/$defs/Foo"}, ["string", 1, None]):
|
||||||
|
schema = {"properties": {"x": {"type": bad}}}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["x"]["type"], bad)
|
||||||
|
|
||||||
|
def test_recurses_into_all_walked_keywords(self):
|
||||||
|
"""Every keyword the walker recurses into must actually rewrite nested aliases."""
|
||||||
|
schema = {
|
||||||
|
"patternProperties": {"^x_": {"type": "varchar"}},
|
||||||
|
"definitions": {"Row": {"type": "bigint"}},
|
||||||
|
"prefixItems": [{"type": "int32"}, {"type": "float64"}],
|
||||||
|
"if": {"type": "bool"},
|
||||||
|
"then": {"type": "str"},
|
||||||
|
"else": {"type": "decimal"},
|
||||||
|
"not": {"type": "uuid"},
|
||||||
|
"contains": {"type": "enum"},
|
||||||
|
"additionalProperties": {"type": "bool"},
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["patternProperties"]["^x_"]["type"], "string")
|
||||||
|
self.assertEqual(schema["definitions"]["Row"]["type"], "integer")
|
||||||
|
self.assertEqual(schema["prefixItems"][0]["type"], "integer")
|
||||||
|
self.assertEqual(schema["prefixItems"][1]["type"], "number")
|
||||||
|
self.assertEqual(schema["if"]["type"], "boolean")
|
||||||
|
self.assertEqual(schema["then"]["type"], "string")
|
||||||
|
self.assertEqual(schema["else"]["type"], "number")
|
||||||
|
self.assertEqual(schema["not"]["type"], "string")
|
||||||
|
self.assertEqual(schema["contains"]["type"], "string")
|
||||||
|
self.assertEqual(schema["additionalProperties"]["type"], "boolean")
|
||||||
|
|
||||||
|
def test_cyclic_schema_raises_recursion_error(self):
|
||||||
|
"""A pathological cyclic schema surfaces as RecursionError; caller
|
||||||
|
(``_validate_request``) converts it to a 400, not a 500."""
|
||||||
|
schema = {"type": "object"}
|
||||||
|
schema["items"] = schema
|
||||||
|
with self.assertRaises(RecursionError):
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
|
||||||
|
def test_boolean_subschema_does_not_crash(self):
|
||||||
|
"""``additionalProperties: True`` and ``items: false`` are valid 2020-12
|
||||||
|
forms; the walker must pass through without raising."""
|
||||||
|
schema = {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"a": {"type": "varchar"},
|
||||||
|
"b": {"type": "array", "items": False},
|
||||||
|
},
|
||||||
|
"additionalProperties": True,
|
||||||
|
"unevaluatedProperties": False,
|
||||||
|
}
|
||||||
|
normalize_json_schema_types(schema)
|
||||||
|
self.assertEqual(schema["properties"]["a"]["type"], "string")
|
||||||
|
self.assertIs(schema["properties"]["b"]["items"], False)
|
||||||
|
self.assertIs(schema["additionalProperties"], True)
|
||||||
|
self.assertIs(schema["unevaluatedProperties"], False)
|
||||||
|
self._assert_accepts(schema)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user