diff --git a/doc/builtin_generators.rst b/doc/builtin_generators.rst index 0319618e..dfc3ca01 100644 --- a/doc/builtin_generators.rst +++ b/doc/builtin_generators.rst @@ -37,7 +37,8 @@ command-line interface (CLI):: a generator module. Paths to generator modules must end with a .stoneg.py extension. The following generators are built-in: js_client, js_types, - python_types, python_client, swift_client + tsd_client, tsd_types, python_types, python_client, + swift_client output The folder to save generated files to. spec Path to API specifications. Each must have a .stone extension. If omitted or set to "-", the spec is read diff --git a/stone/cli.py b/stone/cli.py index 39cf757f..cb541cd2 100644 --- a/stone/cli.py +++ b/stone/cli.py @@ -23,6 +23,8 @@ _builtin_generators = ( 'js_client', 'js_types', + 'tsd_client', + 'tsd_types', 'python_types', 'python_client', 'swift_types', diff --git a/stone/target/tsd_client.py b/stone/target/tsd_client.py new file mode 100644 index 00000000..a6217fa7 --- /dev/null +++ b/stone/target/tsd_client.py @@ -0,0 +1,123 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import argparse +import json +import os +import re +import six +import sys + +from stone.generator import CodeGenerator +from stone.target.tsd_helpers import ( + fmt_error_type, + fmt_func, + fmt_tag, + fmt_type, + fmt_union, +) + + +_cmdline_parser = argparse.ArgumentParser(prog='tsd-client-generator') +_cmdline_parser.add_argument( + 'template', + help=('A template to use when generating the TypeScript definition file.') +) +_cmdline_parser.add_argument( + 'filename', + help=('The name to give the single TypeScript definition file to contain ' + 'all of the emitted types.'), +) +_cmdline_parser.add_argument( + '-t', + '--template-string', + type=str, + default='ROUTES', + help=('The name of the template string to replace with route definitions. ' + 'Defaults to ROUTES, which replaces the string /*ROUTES*/ with route ' + 'definitions.') +) +_cmdline_parser.add_argument( + '-i', + '--indent-level', + type=int, + default=1, + help=('Indentation level to emit types at. Routes are automatically ' + 'indented one level further than this.') +) +_cmdline_parser.add_argument( + '-s', + '--spaces-per-indent', + type=int, + default=2, + help=('Number of spaces to use per indentation level.') +) + +_header = """\ +// Auto-generated by Stone, do not modify. +""" + +class TSDClientGenerator(CodeGenerator): + """Generates a TypeScript definition file with routes defined.""" + + cmdline_parser = _cmdline_parser + + preserve_aliases = True + + def generate(self, api): + spaces_per_indent = self.args.spaces_per_indent + indent_level = self.args.indent_level + template_path = os.path.join(self.target_folder_path, self.args.template) + template_string = self.args.template_string + + with self.output_to_relative_path(self.args.filename): + if os.path.isfile(template_path): + with open(template_path, 'r') as template_file: + template = template_file.read() + else: + exit('TypeScript template file does not exist.') + + # /*ROUTES*/ + r_match = re.search("/\*%s\*/" % (template_string), template) + if not r_match: + exit('Missing /*%s*/ in TypeScript template file.' % template_string) + + r_start = r_match.start() + r_end = r_match.end() + r_ends_with_newline = template[r_end - 1] == '\n' + t_end = len(template) + t_ends_with_newline = template[t_end - 1] == '\n' + + self.emit_raw(template[0:r_start] + ('\n' if not r_ends_with_newline else '')) + self._generate_routes(api, spaces_per_indent, indent_level) + self.emit_raw(template[r_end + 1 : t_end] + ('\n' if not t_ends_with_newline else '')) + + def _generate_routes(self, api, spaces_per_indent, indent_level): + with self.indent(dent=spaces_per_indent * (indent_level + 1)): + for namespace in api.namespaces.values(): + for route in namespace.routes: + self._generate_route( + api.route_schema, namespace, route) + + def _generate_route(self, route_schema, namespace, route): + function_name = fmt_func(namespace.name + '_' + route.name) + self.emit() + self.emit('/**') + if route.doc: + self.emit_wrapped_text(self.process_doc(route.doc, self._docf), prefix=' * ') + self.emit(' * ') + self.emit_wrapped_text('When an error occurs, the route rejects the promise with type %s.' % fmt_error_type(route.error_data_type), prefix = ' * ') + if route.deprecated: + self.emit(' * @deprecated') + + self.emit(' * @param arg The request parameters.') + self.emit(' */') + + self.emit('public %s(arg: %s): Promise<%s>;' % + (function_name, fmt_type(route.arg_data_type), + fmt_type(route.result_data_type))) + + def _docf(self, tag, val): + """ + Callback to process documentation references. + """ + return fmt_tag(None, tag, val) diff --git a/stone/target/tsd_helpers.py b/stone/target/tsd_helpers.py new file mode 100644 index 00000000..3f9ae6cf --- /dev/null +++ b/stone/target/tsd_helpers.py @@ -0,0 +1,134 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import json +import six + +from stone.data_type import ( + Boolean, + Bytes, + Float32, + Float64, + Int32, + Int64, + List, + String, + Timestamp, + UInt32, + UInt64, + Void, + is_alias, + is_list_type, + is_struct_type, + is_user_defined_type, +) +from stone.target.helpers import ( + fmt_camel, +) + +_base_type_table = { + Boolean: 'boolean', + Bytes: 'string', + Float32: 'number', + Float64: 'number', + Int32: 'number', + Int64: 'number', + List: 'Array', + String: 'string', + UInt32: 'number', + UInt64: 'number', + Timestamp: 'Timestamp', + Void: 'void', +} + + + +def fmt_error_type(data_type, inside_namespace = None): + """ + Converts the error type into a TypeScript type. + inside_namespace should be set to the namespace that the reference + occurs in, or None if this parameter is not relevant. + """ + return 'Error<%s>' % fmt_type(data_type, inside_namespace) + +def fmt_type_name(data_type, inside_namespace = None): + """ + Produces a TypeScript type name for the given data type. + inside_namespace should be set to the namespace that the reference + occurs in, or None if this parameter is not relevant. + """ + if is_user_defined_type(data_type) or is_alias(data_type): + if data_type.namespace == inside_namespace: + return data_type.name + else: + return '%s.%s' % (data_type.namespace.name, data_type.name) + else: + fmted_type = _base_type_table.get(data_type.__class__, 'Object') + if is_list_type(data_type): + fmted_type += '<' + fmt_type(data_type.data_type, inside_namespace) + '>' + return fmted_type + +def fmt_polymorphic_type_reference(data_type, inside_namespace = None): + """ + Produces a TypeScript type name for the meta-type that refers to the given + struct, which belongs to an enumerated subtypes tree. This meta-type contains the + .tag field that lets developers discriminate between subtypes. + """ + # NOTE: These types are not properly namespaced, so there could be a conflict + # with other user-defined types. If this ever surfaces as a problem, we + # can defer emitting these types until the end, and emit them in a + # nested namespace (e.g., files.references.MetadataReference). + return fmt_type_name(data_type, inside_namespace) + "Reference" + +def fmt_type(data_type, inside_namespace = None): + """ + Returns a TypeScript type annotation for a data type. + May contain a union of enumerated subtypes. + inside_namespace should be set to the namespace that the type reference + occurs in, or None if this parameter is not relevant. + """ + if is_struct_type(data_type) and data_type.has_enumerated_subtypes(): + possible_types = [] + possible_subtypes = data_type.get_all_subtypes_with_tags() + for _, subtype in possible_subtypes: + possible_types.append(fmt_polymorphic_type_reference(subtype, inside_namespace)) + if data_type.is_catch_all(): + possible_types.append(fmt_polymorphic_type_reference(data_type, inside_namespace)) + return fmt_union(possible_types) + else: + return fmt_type_name(data_type, inside_namespace) + +def fmt_union(type_strings): + """ + Returns a union type of the given types. + """ + return '|'.join(type_strings) if len(type_strings) > 1 else type_strings[0]; + +def fmt_func(name): + return fmt_camel(name) + +def fmt_var(name): + return fmt_camel(name) + +def fmt_tag(cur_namespace, tag, val): + """ + Processes a documentation reference. + """ + if tag == 'type': + fq_val = val + if '.' not in val and cur_namespace != None: + fq_val = cur_namespace.name + '.' + fq_val + return fq_val + elif tag == 'route': + return fmt_func(val) + "()" + elif tag == 'link': + anchor, link = val.rsplit(' ', 1) + # There's no way to have links in TSDoc, so simply use JSDoc's formatting. + # It's entirely possible some editors support this. + return '[%s]{@link %s}' % (anchor, link) + elif tag == 'val': + # Value types seem to match JavaScript (true, false, null) + return val + elif tag == 'field': + return val + else: + raise RuntimeError('Unknown doc ref tag %r' % tag) diff --git a/stone/target/tsd_types.py b/stone/target/tsd_types.py new file mode 100644 index 00000000..2bbba9dc --- /dev/null +++ b/stone/target/tsd_types.py @@ -0,0 +1,377 @@ +from __future__ import absolute_import, division, print_function, unicode_literals + +import argparse +import json +import os +import re +import six +import sys + +from stone.data_type import ( + is_alias, + is_struct_type, + is_union_type, + is_user_defined_type, + is_void_type, + unwrap_nullable, +) +from stone.generator import CodeGenerator +from stone.target.helpers import ( + fmt_pascal, +) +from stone.target.tsd_helpers import ( + fmt_func, + fmt_polymorphic_type_reference, + fmt_tag, + fmt_type, + fmt_type_name, + fmt_union, +) + + +_cmdline_parser = argparse.ArgumentParser(prog='tsd-types-generator') +_cmdline_parser.add_argument( + 'template', + help=('A template to use when generating the TypeScript definition file. ' + 'Replaces the string /*TYPES*/ with stone type definitions.') +) +_cmdline_parser.add_argument( + 'filename', + help=('The name to give the single TypeScript definition file that contains ' + 'all of the emitted types.'), +) +_cmdline_parser.add_argument( + '-e', + '--extra-arg', + action='append', + type=str, + default=[], + help=("Additional argument to add to a route's argument based " + "on if the route has a certain attribute set. Format (JSON): " + '{"match": ["ROUTE_ATTR", ROUTE_VALUE_TO_MATCH], ' + '"arg_name": "ARG_NAME", "arg_type": "ARG_TYPE", ' + '"arg_docstring": "ARG_DOCSTRING"}'), +) +_cmdline_parser.add_argument( + '-i', + '--indent-level', + type=int, + default=1, + help=('Indentation level to emit types at. Routes are automatically ' + 'indented one level further than this.') +) +_cmdline_parser.add_argument( + '-s', + '--spaces-per-indent', + type=int, + default=2, + help=('Number of spaces to use per indentation level.') +) + +_header = """\ +// Auto-generated by Stone, do not modify. +""" + +_types_header = """\ +/** + * An Error object returned from a route. + */ +interface Error { +\t// Text summary of the error. +\terror_summary: string; +\t// The error object. +\terror: T; +\t// User-friendly error message. +\tuser_message: UserMessage; +} + +/** + * User-friendly error message. + */ +interface UserMessage { +\t// The message. +\ttext: string; +\t// The locale of the message. +\tlocale: string; +} + +type Timestamp = string; +""" + +class TSDTypesGenerator(CodeGenerator): + """Generates a single TypeScript definition file with all of the types defined.""" + + cmdline_parser = _cmdline_parser + + preserve_aliases = True + + cur_namespace = None + + def generate(self, api): + spaces_per_indent = self.args.spaces_per_indent + indent_level = self.args.indent_level + template_path = os.path.join(self.target_folder_path, self.args.template) + + with self.output_to_relative_path(self.args.filename): + extra_args = self._parse_extra_args(api, self.args.extra_arg) + if os.path.isfile(template_path): + with open(template_path, 'r') as template_file: + template = template_file.read() + else: + exit('TypeScript template file does not exist.') + + # /*TYPES*/ + t_match = re.search("/\*TYPES\*/", template) + if not t_match: + exit('Missing /*TYPES*/ in TypeScript template file.') + + t_start = t_match.start() + t_end = t_match.end() + t_ends_with_newline = template[t_end - 1] == '\n' + temp_end = len(template) + temp_ends_with_newline = template[temp_end - 1] == '\n' + + self.emit_raw(template[0:t_start] + ("\n" if not t_ends_with_newline else '')) + self._generate_types(api, spaces_per_indent, indent_level, extra_args) + self.emit_raw(template[t_end + 1 : temp_end] + ("\n" if not temp_ends_with_newline else '')) + + def _generate_types(self, api, spaces_per_indent, indent_level, extra_args): + indent = spaces_per_indent * indent_level + indent_spaces = (' '*indent) + with self.indent(dent = indent): + indented_types_header = indent_spaces + ('\n' + indent_spaces).join(_types_header.split('\n')).replace('\t', ' ' * spaces_per_indent) + self.emit_raw(indented_types_header + '\n') + self.emit() + + for namespace in api.namespaces.values(): + self.cur_namespace = namespace + # Count aliases as data types too! + data_types = namespace.data_types + namespace.aliases + # Skip namespaces that do not contain types. + if len(data_types) == 0: + continue; + + if namespace.doc: + self._emit_tsdoc_header(namespace.doc) + self.emit_wrapped_text('namespace %s {' % namespace.name) + + with self.indent(dent = spaces_per_indent): + for data_type in data_types: + self._generate_type(data_type, spaces_per_indent, extra_args.get(data_type, [])) + + self.emit('}') + self.emit() + + def _parse_extra_args(self, api, extra_args_raw): + """ + Parses extra arguments into a map keyed on particular data types. + """ + extra_args = {} + + for extra_arg_raw in extra_args_raw: + def exit(m): + print('Invalid --extra-arg:%s: %s' % (m, extra_arg_raw), + file=sys.stderr) + sys.exit(1) + + try: + extra_arg = json.loads(extra_arg_raw) + except ValueError as e: + exit(str(e)) + + # Validate extra_arg JSON blob + if 'match' not in extra_arg: + exit('No match key') + elif (not isinstance(extra_arg['match'], list) or + len(extra_arg['match']) != 2): + exit('match key is not a list of two strings') + elif (not isinstance(extra_arg['match'][0], six.text_type) or + not isinstance(extra_arg['match'][1], six.text_type)): + print(type(extra_arg['match'][0])) + exit('match values are not strings') + elif 'arg_name' not in extra_arg: + exit('No arg_name key') + elif not isinstance(extra_arg['arg_name'], six.text_type): + exit('arg_name is not a string') + elif 'arg_type' not in extra_arg: + exit('No arg_type key') + elif not isinstance(extra_arg['arg_type'], six.text_type): + exit('arg_type is not a string') + elif ('arg_docstring' in extra_arg and + not isinstance(extra_arg['arg_docstring'], six.text_type)): + exit('arg_docstring is not a string') + + attr_key, attr_val = extra_arg['match'][0], extra_arg['match'][1] + extra_args.setdefault(attr_key, {})[attr_val] = \ + (extra_arg['arg_name'], extra_arg['arg_type'], + extra_arg.get('arg_docstring')) + + # Extra arguments, keyed on data type objects. + extra_args_for_types = {} + # Locate data types that contain extra arguments + for namespace in api.namespaces.values(): + for route in namespace.routes: + extra_parameters = [] + if is_user_defined_type(route.arg_data_type): + for attr_key in route.attrs: + if attr_key not in extra_args: + continue + attr_val = route.attrs[attr_key] + if attr_val in extra_args[attr_key]: + extra_parameters.append(extra_args[attr_key][attr_val]) + if len(extra_parameters) > 0: + extra_args_for_types[route.arg_data_type] = extra_parameters + + return extra_args_for_types + + def _emit_tsdoc_header(self, docstring): + self.emit('/**') + self.emit_wrapped_text(self.process_doc(docstring, self._docf), prefix = ' * ') + self.emit(' */') + + def _generate_type(self, data_type, indent_spaces, extra_args): + """ + Generates a TypeScript type for the given type. + """ + if is_alias(data_type): + self._generate_alias_type(data_type) + elif is_struct_type(data_type): + self._generate_struct_type(data_type, indent_spaces, extra_args) + elif is_union_type(data_type): + self._generate_union_type(data_type, indent_spaces) + + def _generate_alias_type(self, alias_type): + """ + Generates a TypeScript type for a stone alias. + """ + namespace = alias_type.namespace + self.emit('type %s = %s;' % (fmt_type_name(alias_type, namespace), + fmt_type_name(alias_type.data_type, namespace))); + self.emit() + + def _generate_struct_type(self, struct_type, indent_spaces, extra_parameters): + """ + Generates a TypeScript interface for a stone struct. + """ + namespace = struct_type.namespace + if struct_type.doc: + self._emit_tsdoc_header(struct_type.doc) + parent_type = struct_type.parent_type + extends_line = ' extends %s' % fmt_type_name(parent_type, namespace) if parent_type else '' + self.emit('interface %s%s {' % (fmt_type_name(struct_type, namespace), extends_line)) + with self.indent(dent = indent_spaces): + + for param_name, param_type, param_docstring in extra_parameters: + if param_docstring: + self._emit_tsdoc_header(param_docstring) + self.emit('%s: %s;' % (param_name, param_type)) + + for field in struct_type.fields: + doc = field.doc + field_type, nullable = unwrap_nullable(field.data_type) + field_ts_type = fmt_type(field_type, namespace) + optional = nullable or field.has_default + if field.has_default: + # doc may be None. If it is not empty, add newlines + # before appending to it. + doc = doc + '\n\n' if doc else '' + doc = "Defaults to %s." % field.default + + if doc: + self._emit_tsdoc_header(doc) + # Translate nullable types into optional properties. + field_name = '%s?' % field.name if optional else field.name + self.emit('%s: %s;' % (field_name, field_ts_type)) + + self.emit('}') + self.emit() + + # Some structs can explicitly list their subtypes. These structs have a .tag field that indicate + # which subtype they are, which is only present when a type reference is ambiguous. + # Emit a special interface that contains this extra field, and refer to it whenever we encounter + # a reference to a type with enumerated subtypes. + if struct_type.is_member_of_enumerated_subtypes_tree(): + if struct_type.has_enumerated_subtypes(): + # This struct is the parent to multiple subtypes. Determine all of the possible + # values of the .tag property. + tag_values = [] + for tags, _ in struct_type.get_all_subtypes_with_tags(): + for tag in tags: + tag_values.append('"%s"' % tag) + + tag_union = fmt_union(tag_values) + self._emit_tsdoc_header('Reference to the %s polymorphic type. Contains a .tag property to ' + 'let you discriminate between possible subtypes.' % fmt_type_name(struct_type, namespace)); + self.emit('interface %s extends %s {' % (fmt_polymorphic_type_reference(struct_type, namespace), + fmt_type_name(struct_type, namespace))) + + with self.indent(dent = indent_spaces): + self._emit_tsdoc_header('Tag identifying the subtype variant.') + self.emit('\'.tag\': %s;' % tag_union) + + self.emit('}') + self.emit() + else: + # This struct is a particular subtype. Find the applicable .tag value from the parent + # type, which may be an arbitrary number of steps up the inheritance hierarchy. + previous = struct_type + parent = struct_type.parent_type + while not parent.has_enumerated_subtypes(): + previous = parent + parent = parent.parent_type + # parent now contains the closest parent type in the inheritance hierarchy that has + # enumerated subtypes. Determine which subtype this is. + for subtype in parent.get_enumerated_subtypes(): + if subtype.data_type == struct_type: + self._emit_tsdoc_header('Reference to the %s type, identified by the value of the .tag property.' + % fmt_type_name(struct_type, namespace)) + self.emit('interface %s extends %s {' % (fmt_polymorphic_type_reference(struct_type, namespace), + fmt_type_name(struct_type, namespace))) + + with self.indent(dent = indent_spaces): + self._emit_tsdoc_header('Tag identifying this subtype variant. This field ' + 'is only present when needed to discriminate between multiple possible ' + 'subtypes.') + self.emit_wrapped_text('\'.tag\': \'%s\';' % subtype.name) + + self.emit('}') + self.emit() + break + + def _generate_union_type(self, union_type, indent_spaces): + """ + Generates a TypeScript interface for a stone union. + """ + # Emit an interface for each variant. TypeScript 2.0 supports these tagged unions. + # https://github.com/Microsoft/TypeScript/wiki/What%27s-new-in-TypeScript#tagged-union-types + parent_type = union_type.parent_type + namespace = union_type.namespace + union_type_name = fmt_type_name(union_type, namespace) + variant_type_names = [] + if parent_type: + variant_type_names.append(fmt_type_name(parent_type, namespace)) + for variant in union_type.fields: + if variant.doc: + self._emit_tsdoc_header(variant.doc) + variant_name = '%s%s' % (union_type_name, fmt_pascal(variant.name)) + variant_type_names.append(variant_name) + self.emit('interface %s {' % variant_name) + with self.indent(dent = indent_spaces): + # Since field contains non-alphanumeric character, we need to enclose + # it in quotation marks. + self.emit("'.tag': '%s';" % variant.name) + if is_void_type(variant.data_type) == False: + self.emit("%s: %s;" % (variant.name, fmt_type(variant.data_type, namespace))) + self.emit('}') + self.emit() + + if union_type.doc: + self._emit_tsdoc_header(union_type.doc) + self.emit('type %s = %s;' % (union_type_name, ' | '.join(variant_type_names))) + self.emit() + + def _docf(self, tag, val): + """ + Callback to process documentation references. + """ + return fmt_tag(self.cur_namespace, tag, val)