blob: 42d18c01ef520295eda4051fec04ee7a036e2bed [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2017 the original author or authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
import sys
from google.protobuf.compiler import plugin_pb2 as plugin
from google.protobuf.descriptor_pb2 import ServiceDescriptorProto, \
MethodOptions
from jinja2 import Template
from simplejson import dumps
from chameleon.protos.third_party.google.api import annotations_pb2, http_pb2
_ = annotations_pb2, http_pb2 # to keep import line from being optimized out
template = Template("""
# Generated file; please do not edit
from simplejson import dumps, load
from structlog import get_logger
from google.protobuf.json_format import MessageToDict, ParseDict
from twisted.internet.defer import inlineCallbacks, returnValue
{% set package = file_name.replace('.proto', '') %}
{% for pypackage, module in includes %}
{% if pypackage %}
from {{ pypackage }} import {{ module }}
{% else %}
import {{ module }}
{% endif %}
{% endfor %}
log = get_logger()
def add_routes(app, grpc_client):
pass # so that if no endpoints are defined, Python is still happy
{% for method in methods %}
{% set method_name = method['service'].rpartition('.')[2] + '_' + method['method'] %}
{% set path = method['path'].replace('{', '<string:').replace('}', '>') %}
@app.route('{{ path }}', methods=['{{ method['verb'].upper() }}'])
@inlineCallbacks
def {{ method_name }}(server, request, **kw):
log.debug('{{ method_name }}', request=request, server=server, **kw)
{% if method['body'] == '*' %}
data = load(request.content)
data.update(kw)
{% elif method['body'] == '' %}
data = kw
{% else %}
raise NotImplementedError('cannot handle specific body field list')
{% endif %}
try:
req = ParseDict(data, {{ type_map[method['input_type']] }}())
except Exception, e:
log.error('cannot-convert-to-protobuf', e=e, data=data)
raise
res, metadata = yield grpc_client.invoke(
{{ type_map[method['service']] }}Stub,
'{{ method['method'] }}', req, request.getAllHeaders().items())
try:
out_data = MessageToDict(res, True, True)
except AttributeError, e:
filename = '/tmp/chameleon_failed_to_convert_data.pbd'
with file(filename, 'w') as f:
f.write(res.SerializeToString())
log.error('cannot-convert-from-protobuf', outdata_saved=filename)
raise
for key, value in metadata:
request.setHeader(key, value)
request.setHeader('Content-Type', 'application/json')
log.debug('{{ method_name }}', **out_data)
returnValue(dumps(out_data))
{% endfor %}
""", trim_blocks=True, lstrip_blocks=True)
def traverse_methods(proto_file):
package = proto_file.name
for service in proto_file.service:
assert isinstance(service, ServiceDescriptorProto)
for method in service.method:
options = method.options
assert isinstance(options, MethodOptions)
for fd, http in options.ListFields():
if fd.full_name == 'google.api.http':
assert fd.name == 'http'
assert isinstance(http, http_pb2.HttpRule)
input_type = method.input_type
if input_type.startswith('.'):
input_type = input_type[1:]
output_type = method.output_type
if output_type.startswith('.'):
output_type = output_type[1:]
if http.delete:
verb = 'delete'
path = http.delete
elif http.get:
verb = 'get'
path = http.get
elif http.patch:
verb = 'patch'
path = http.patch
elif http.post:
verb = 'post'
path = http.post
elif http.put:
verb = 'put'
path = http.put
else:
raise AttributeError('No valid verb in method %s' %
method.name)
body = http.body
data = {
'package': package,
'filename': proto_file.name,
'service': proto_file.package + '.' + service.name,
'method': method.name,
'input_type': input_type,
'output_type': output_type,
'path': path,
'verb': verb,
'body': body
}
yield data
def generate_gw_code(file_name, methods, type_map, includes):
return template.render(file_name=file_name, methods=methods,
type_map=type_map, includes=includes)
class IncludeManager(object):
# need to keep track of what files define what message types and
# under what package name. Later, when we analyze the methods, we
# need to be able to derive the list of files we need to load and we
# also need to replce the <proto-package-name>.<artifact-name> in the
# templates with <python-package-name>.<artifact-name> so Python can
# resolve these.
def __init__(self):
self.package_to_localname = {}
self.fullname_to_filename = {}
self.prefix_table = [] # sorted table of top-level symbols in protos
self.type_map = {} # full name as used in .proto -> python name
self.includes_needed = set() # names of files needed to be included
self.filename_to_module = {} # filename -> (package, module)
def extend_symbol_tables(self, proto_file):
# keep track of what file adds what top-level symbol to what abstract
# package name
package_name = proto_file.package
file_name = proto_file.name
self._add_filename(file_name)
all_defs = list(proto_file.message_type)
all_defs.extend(list(proto_file.enum_type))
all_defs.extend(list(proto_file.service))
for typedef in all_defs:
name = typedef.name
fullname = package_name + '.' + name
self.fullname_to_filename[fullname] = file_name
self.package_to_localname.setdefault(package_name, []).append(name)
self._update_prefix_table()
def _add_filename(self, filename):
if filename not in self.filename_to_module:
python_path = filename.replace('.proto', '_pb2').replace('/', '.')
package_name, _, module_name = python_path.rpartition('.')
self.filename_to_module[filename] = (package_name, module_name)
def _update_prefix_table(self):
# make a sorted list symbol prefixes needed to resolv for potential use
# of nested symbols
self.prefix_table = sorted(self.fullname_to_filename.iterkeys(),
reverse=True)
def _find_matching_prefix(self, fullname):
for prefix in self.prefix_table:
if fullname.startswith(prefix):
return prefix
# This should never happen
raise Exception('No match for type name "{}"'.format(fullname))
def add_needed_symbol(self, fullname):
if fullname in self.type_map:
return
top_level_symbol = self._find_matching_prefix(fullname)
name = top_level_symbol.rpartition('.')[2]
nested_name = fullname[len(top_level_symbol):] # may be empty
file_name = self.fullname_to_filename[top_level_symbol]
self.includes_needed.add(file_name)
module_name = self.filename_to_module[file_name][1]
python_name = module_name + '.' + name + nested_name
self.type_map[fullname] = python_name
def get_type_map(self):
return self.type_map
def get_includes(self):
return sorted(
self.filename_to_module[fn] for fn in self.includes_needed)
def generate_code(request, response):
assert isinstance(request, plugin.CodeGeneratorRequest)
include_manager = IncludeManager()
for proto_file in request.proto_file:
include_manager.extend_symbol_tables(proto_file)
methods = []
for data in traverse_methods(proto_file):
methods.append(data)
include_manager.add_needed_symbol(data['input_type'])
include_manager.add_needed_symbol(data['output_type'])
include_manager.add_needed_symbol(data['service'])
type_map = include_manager.get_type_map()
includes = include_manager.get_includes()
# as a nice side-effect, generate a json file capturing the essence
# of the RPC method entries
f = response.file.add()
f.name = proto_file.name + '.json'
f.content = dumps(dict(
type_rename_map=type_map,
includes=includes,
methods=methods), indent=4)
# generate the real Python code file
f = response.file.add()
assert proto_file.name.endswith('.proto')
f.name = proto_file.name.replace('.proto', '_gw.py')
f.content = generate_gw_code(proto_file.name,
methods, type_map, includes)
if __name__ == '__main__':
if len(sys.argv) >= 2:
# read input from file, to allow troubleshooting
with open(sys.argv[1], 'r') as f:
data = f.read()
else:
# read input from stdin
data = sys.stdin.read()
# with file('/tmp/buf', 'wb') as f:
# f.write(data)
# parse request
request = plugin.CodeGeneratorRequest()
request.ParseFromString(data)
# create response object
response = plugin.CodeGeneratorResponse()
# generate the output and the response
generate_code(request, response)
# serialize the response
output = response.SerializeToString()
# write response to stdout
sys.stdout.write(output)