blob: 28d6f472440ef447fb9b26d9f6ae291109ec8039 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001# Copyright 2016 Google Inc. All Rights Reserved.
2#
3# Licensed under the Apache License, Version 2.0 (the "License");
4# you may not use this file except in compliance with the License.
5# You may obtain a copy of the License at
6#
7# http://www.apache.org/licenses/LICENSE-2.0
8#
9# Unless required by applicable law or agreed to in writing, software
10# distributed under the License is distributed on an "AS IS" BASIS,
11# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12# See the License for the specific language governing permissions and
13# limitations under the License.
14
15"""Describe ProtoRPC Messages in JSON Schema.
16
17Add protorpc.message subclasses to MessageTypeToJsonSchema and get a JSON
18Schema description of all the messages.
19"""
20
21# pylint: disable=g-bad-name
22from __future__ import absolute_import
23
24import re
25
26from . import message_types
27from . import messages
28
29__all__ = ['MessageTypeToJsonSchema']
30
31
32class MessageTypeToJsonSchema(object):
33 """Describe ProtoRPC messages in JSON Schema.
34
35 Add protorpc.message subclasses to MessageTypeToJsonSchema and get a JSON
36 Schema description of all the messages. MessageTypeToJsonSchema handles
37 all the types of fields that can appear in a message.
38 """
39
40 # Field to schema type and format. If the field maps to tuple, the
41 # first entry is set as the type, the second the format (or left alone if
42 # None). If the field maps to a dictionary, we'll grab the value from the
43 # field's Variant in that dictionary.
44 # The variant dictionary should include an element that None maps to,
45 # to fall back on as a default.
46 __FIELD_TO_SCHEMA_TYPE_MAP = {
47 messages.IntegerField: {messages.Variant.INT32: ('integer', 'int32'),
48 messages.Variant.INT64: ('string', 'int64'),
49 messages.Variant.UINT32: ('integer', 'uint32'),
50 messages.Variant.UINT64: ('string', 'uint64'),
51 messages.Variant.SINT32: ('integer', 'int32'),
52 messages.Variant.SINT64: ('string', 'int64'),
53 None: ('integer', 'int64')},
54 messages.FloatField: {messages.Variant.FLOAT: ('number', 'float'),
55 messages.Variant.DOUBLE: ('number', 'double'),
56 None: ('number', 'float')},
57 messages.BooleanField: ('boolean', None),
58 messages.BytesField: ('string', 'byte'),
59 message_types.DateTimeField: ('string', 'date-time'),
60 messages.StringField: ('string', None),
61 messages.MessageField: ('object', None),
62 messages.EnumField: ('string', None),
63 }
64
65 __DEFAULT_SCHEMA_TYPE = ('string', None)
66
67 def __init__(self):
68 # A map of schema ids to schemas.
69 self.__schemas = {}
70
71 # A map from schema id to non-normalized definition name.
72 self.__normalized_names = {}
73
74 def add_message(self, message_type):
75 """Add a new message.
76
77 Args:
78 message_type: protorpc.message.Message class to be parsed.
79
80 Returns:
81 string, The JSON Schema id.
82
83 Raises:
84 KeyError if the Schema id for this message_type would collide with the
85 Schema id of a different message_type that was already added.
86 """
87 name = self.__normalized_name(message_type)
88 if name not in self.__schemas:
89 # Set a placeholder to prevent infinite recursion.
90 self.__schemas[name] = None
91 schema = self.__message_to_schema(message_type)
92 self.__schemas[name] = schema
93 return name
94
95 def ref_for_message_type(self, message_type):
96 """Returns the JSON Schema id for the given message.
97
98 Args:
99 message_type: protorpc.message.Message class to be parsed.
100
101 Returns:
102 string, The JSON Schema id.
103
104 Raises:
105 KeyError: if the message hasn't been parsed via add_message().
106 """
107 name = self.__normalized_name(message_type)
108 if name not in self.__schemas:
109 raise KeyError('Message has not been parsed: %s', name)
110 return name
111
112 def schemas(self):
113 """Returns the JSON Schema of all the messages.
114
115 Returns:
116 object: JSON Schema description of all messages.
117 """
118 return self.__schemas.copy()
119
120 def __normalized_name(self, message_type):
121 """Normalized schema name.
122
123 Generate a normalized schema name, taking the class name and stripping out
124 everything but alphanumerics, and camel casing the remaining words.
125 A normalized schema name is a name that matches [a-zA-Z][a-zA-Z0-9]*
126
127 Args:
128 message_type: protorpc.message.Message class being parsed.
129
130 Returns:
131 A string, the normalized schema name.
132
133 Raises:
134 KeyError: A collision was found between normalized names.
135 """
136 # Normalization is applied to match the constraints that Discovery applies
137 # to Schema names.
138 name = message_type.definition_name()
139
140 split_name = re.split(r'[^0-9a-zA-Z]', name)
141 normalized = ''.join(
142 part[0].upper() + part[1:] for part in split_name if part)
143
144 previous = self.__normalized_names.get(normalized)
145 if previous:
146 if previous != name:
147 raise KeyError('Both %s and %s normalize to the same schema name: %s' %
148 (name, previous, normalized))
149 else:
150 self.__normalized_names[normalized] = name
151
152 return normalized
153
154 def __message_to_schema(self, message_type):
155 """Parse a single message into JSON Schema.
156
157 Will recursively descend the message structure
158 and also parse other messages references via MessageFields.
159
160 Args:
161 message_type: protorpc.messages.Message class to parse.
162
163 Returns:
164 An object representation of the schema.
165 """
166 name = self.__normalized_name(message_type)
167 schema = {
168 'id': name,
169 'type': 'object',
170 }
171 if message_type.__doc__:
172 schema['description'] = message_type.__doc__
173 properties = {}
174 for field in message_type.all_fields():
175 descriptor = {}
176 # Info about the type of this field. This is either merged with
177 # the descriptor or it's placed within the descriptor's 'items'
178 # property, depending on whether this is a repeated field or not.
179 type_info = {}
180
181 if type(field) == messages.MessageField:
182 field_type = field.type().__class__
183 type_info['$ref'] = self.add_message(field_type)
184 if field_type.__doc__:
185 descriptor['description'] = field_type.__doc__
186 else:
187 schema_type = self.__FIELD_TO_SCHEMA_TYPE_MAP.get(
188 type(field), self.__DEFAULT_SCHEMA_TYPE)
189 # If the map pointed to a dictionary, check if the field's variant
190 # is in that dictionary and use the type specified there.
191 if isinstance(schema_type, dict):
192 variant_map = schema_type
193 variant = getattr(field, 'variant', None)
194 if variant in variant_map:
195 schema_type = variant_map[variant]
196 else:
197 # The variant map needs to specify a default value, mapped by None.
198 schema_type = variant_map[None]
199 type_info['type'] = schema_type[0]
200 if schema_type[1]:
201 type_info['format'] = schema_type[1]
202
203 if type(field) == messages.EnumField:
204 sorted_enums = sorted([enum_info for enum_info in field.type],
205 key=lambda enum_info: enum_info.number)
206 type_info['enum'] = [enum_info.name for enum_info in sorted_enums]
207
208 if field.required:
209 descriptor['required'] = True
210
211 if field.default:
212 if type(field) == messages.EnumField:
213 descriptor['default'] = str(field.default)
214 else:
215 descriptor['default'] = field.default
216
217 if field.repeated:
218 descriptor['items'] = type_info
219 descriptor['type'] = 'array'
220 else:
221 descriptor.update(type_info)
222
223 properties[field.name] = descriptor
224
225 schema['properties'] = properties
226
227 return schema