Skip to content

Commit eaa91d1

Browse files
committed
Add initial code for json_schema support
1 parent 920e38c commit eaa91d1

1 file changed

Lines changed: 117 additions & 0 deletions

File tree

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
"""JSON schema support for Odin."""
2+
import json
3+
from typing import Any, Dict, Final, Sequence, TextIO, Type
4+
5+
import odin
6+
from odin.registration import get_child_resources
7+
from odin.resources import ResourceBase, ResourceOptions
8+
from odin.utils import getmeta
9+
10+
SCHEMA_DIALECT: Final[str] = "https://json-schema.org/draft/2020-12/schema"
11+
12+
13+
class JSONSchema:
14+
"""JSON Schema representation of an Odin resource."""
15+
16+
def __init__(self, resource: Type[ResourceBase]):
17+
self.resource = resource
18+
19+
self.defs = {}
20+
21+
def to_dict(self) -> Dict[str, Any]:
22+
"""Convert the schema to a dictionary."""
23+
meta = getmeta(self.resource)
24+
25+
schema = {
26+
"$schema": SCHEMA_DIALECT,
27+
"$id": f"urn:jsonschema:{meta.resource_name}",
28+
}
29+
schema.update(self._resource_to_schema(meta))
30+
schema["$defs"] = self.defs
31+
32+
return schema
33+
34+
def _resource_to_schema(self, meta: ResourceOptions) -> Dict[str, Any]:
35+
"""Convert a resource to a JSON schema."""
36+
schema = {
37+
"type": "object",
38+
"properties": self._fields_to_properties(meta),
39+
"required": self._required_fields(meta),
40+
"additionalProperties": False,
41+
}
42+
return schema
43+
44+
def _required_fields(self, meta: ResourceOptions) -> Sequence[str]:
45+
"""Get a list of required fields."""
46+
required = [field.name for field in meta.fields if not field.null]
47+
required.append(meta.type_field)
48+
return required
49+
50+
def _fields_to_properties(self, meta: ResourceOptions) -> Dict[str, Any]:
51+
"""Convert a set of fields to JSON schema properties."""
52+
properties = {meta.type_field: {"const": meta.resource_name}}
53+
for field in meta.fields:
54+
properties[field.name] = self._field_to_schema(field)
55+
return properties
56+
57+
def _field_to_schema(self, field: odin.Field) -> Dict[str, Any]:
58+
"""Convert a field to a JSON schema."""
59+
60+
if isinstance(field, odin.CompositeField):
61+
return self._composite_field_to_schema(field)
62+
63+
schema = {
64+
"type": "string",
65+
"description": field.doc_text or "",
66+
}
67+
if field.choices:
68+
schema["enum"] = tuple(str(value) for value in field.choice_values)
69+
70+
return schema
71+
72+
def _composite_field_to_schema(self, field: odin.CompositeField) -> Dict[str, Any]:
73+
"""Convert a composite field to a JSON schema."""
74+
75+
# Handle abstract resources
76+
child_resources = get_child_resources(field.of)
77+
if child_resources:
78+
schema = {
79+
"oneOf": [
80+
self._schema_def(child_resource)
81+
for child_resource in child_resources
82+
]
83+
}
84+
else:
85+
schema = self._schema_def(field.of)
86+
87+
if isinstance(field, odin.DictAs):
88+
pass
89+
90+
elif isinstance(field, odin.ListOf):
91+
schema = {"type": "array", "items": schema}
92+
93+
elif isinstance(field, odin.DictOf):
94+
schema = {"type": "object", "additionalProperties": schema}
95+
96+
return schema
97+
98+
def _schema_def(self, resource: Type[ResourceBase]) -> Dict[str, str]:
99+
"""Convert a resource to a JSON schema definition."""
100+
meta = getmeta(resource)
101+
ref = meta.resource_name
102+
if ref not in self.defs:
103+
self.defs[ref] = None # Placeholder to prevent recursion
104+
self.defs[ref] = self._resource_to_schema(meta)
105+
return {"$ref": f"#/$defs/{ref}"}
106+
107+
108+
def dumps(resource: Type[ResourceBase]) -> str:
109+
"""Dump a JSON schema for the given resource."""
110+
schema = JSONSchema(resource).to_dict()
111+
return json.dumps(schema, indent=2)
112+
113+
114+
def dump(resource: Type[ResourceBase], fp: TextIO):
115+
"""Dump a JSON schema for the given resource."""
116+
schema = JSONSchema(resource).to_dict()
117+
json.dump(schema, fp, indent=2)

0 commit comments

Comments
 (0)