blob: bc1377e93546f0f731f8d819d51f2444b489e9bf [file] [log] [blame]
#!/usr/bin/env python
#
# Copyright 2011 Google Inc.
#
# 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.
#
"""ProtoRPC WSGI service applications.
Use functions in this module to configure ProtoRPC services for use with
WSGI applications. For more information about WSGI, please see:
http://wsgi.org/wsgi
http://docs.python.org/library/wsgiref.html
"""
import six
__author__ = 'rafek@google.com (Rafe Kaplan)'
import cgi
import six.moves.http_client
import logging
import re
from .. import messages
from .. import registry
from .. import remote
from .. import util
from . import util as wsgi_util
__all__ = [
'DEFAULT_REGISTRY_PATH',
'service_app',
]
_METHOD_PATTERN = r'(?:\.([^?]+))'
_REQUEST_PATH_PATTERN = r'^(%%s)%s$' % _METHOD_PATTERN
_HTTP_BAD_REQUEST = wsgi_util.error(six.moves.http_client.BAD_REQUEST)
_HTTP_NOT_FOUND = wsgi_util.error(six.moves.http_client.NOT_FOUND)
_HTTP_UNSUPPORTED_MEDIA_TYPE = wsgi_util.error(six.moves.http_client.UNSUPPORTED_MEDIA_TYPE)
DEFAULT_REGISTRY_PATH = '/protorpc'
@util.positional(2)
def service_mapping(service_factory, service_path=r'.*', protocols=None):
"""WSGI application that handles a single ProtoRPC service mapping.
Args:
service_factory: Service factory for creating instances of service request
handlers. Either callable that takes no parameters and returns a service
instance or a service class whose constructor requires no parameters.
service_path: Regular expression for matching requests against. Requests
that do not have matching paths will cause a 404 (Not Found) response.
protocols: remote.Protocols instance that configures supported protocols
on server.
"""
service_class = getattr(service_factory, 'service_class', service_factory)
remote_methods = service_class.all_remote_methods()
path_matcher = re.compile(_REQUEST_PATH_PATTERN % service_path)
def protorpc_service_app(environ, start_response):
"""Actual WSGI application function."""
path_match = path_matcher.match(environ['PATH_INFO'])
if not path_match:
return _HTTP_NOT_FOUND(environ, start_response)
service_path = path_match.group(1)
method_name = path_match.group(2)
content_type = environ.get('CONTENT_TYPE')
if not content_type:
content_type = environ.get('HTTP_CONTENT_TYPE')
if not content_type:
return _HTTP_BAD_REQUEST(environ, start_response)
# TODO(rafek): Handle alternate encodings.
content_type = cgi.parse_header(content_type)[0]
request_method = environ['REQUEST_METHOD']
if request_method != 'POST':
content = ('%s.%s is a ProtoRPC method.\n\n'
'Service %s\n\n'
'More about ProtoRPC: '
'%s\n' %
(service_path,
method_name,
service_class.definition_name().encode('utf-8'),
util.PROTORPC_PROJECT_URL))
error_handler = wsgi_util.error(
six.moves.http_client.METHOD_NOT_ALLOWED,
six.moves.http_client.responses[six.moves.http_client.METHOD_NOT_ALLOWED],
content=content,
content_type='text/plain; charset=utf-8')
return error_handler(environ, start_response)
local_protocols = protocols or remote.Protocols.get_default()
try:
protocol = local_protocols.lookup_by_content_type(content_type)
except KeyError:
return _HTTP_UNSUPPORTED_MEDIA_TYPE(environ,start_response)
def send_rpc_error(status_code, state, message, error_name=None):
"""Helper function to send an RpcStatus message as response.
Will create static error handler and begin response.
Args:
status_code: HTTP integer status code.
state: remote.RpcState enum value to send as response.
message: Helpful message to send in response.
error_name: Error name if applicable.
Returns:
List containing encoded content response using the same content-type as
the request.
"""
status = remote.RpcStatus(state=state,
error_message=message,
error_name=error_name)
encoded_status = protocol.encode_message(status)
error_handler = wsgi_util.error(
status_code,
content_type=protocol.default_content_type,
content=encoded_status)
return error_handler(environ, start_response)
method = remote_methods.get(method_name)
if not method:
return send_rpc_error(six.moves.http_client.BAD_REQUEST,
remote.RpcState.METHOD_NOT_FOUND_ERROR,
'Unrecognized RPC method: %s' % method_name)
content_length = int(environ.get('CONTENT_LENGTH') or '0')
remote_info = method.remote
try:
request = protocol.decode_message(
remote_info.request_type, environ['wsgi.input'].read(content_length))
except (messages.ValidationError, messages.DecodeError) as err:
return send_rpc_error(six.moves.http_client.BAD_REQUEST,
remote.RpcState.REQUEST_ERROR,
'Error parsing ProtoRPC request '
'(Unable to parse request content: %s)' % err)
instance = service_factory()
initialize_request_state = getattr(
instance, 'initialize_request_state', None)
if initialize_request_state:
# TODO(rafek): This is not currently covered by tests.
server_port = environ.get('SERVER_PORT', None)
if server_port:
server_port = int(server_port)
headers = []
for name, value in six.iteritems(environ):
if name.startswith('HTTP_'):
headers.append((name[len('HTTP_'):].lower().replace('_', '-'), value))
request_state = remote.HttpRequestState(
remote_host=environ.get('REMOTE_HOST', None),
remote_address=environ.get('REMOTE_ADDR', None),
server_host=environ.get('SERVER_HOST', None),
server_port=server_port,
http_method=request_method,
service_path=service_path,
headers=headers)
initialize_request_state(request_state)
try:
response = method(instance, request)
encoded_response = protocol.encode_message(response)
except remote.ApplicationError as err:
return send_rpc_error(six.moves.http_client.BAD_REQUEST,
remote.RpcState.APPLICATION_ERROR,
err.message,
err.error_name)
except Exception as err:
logging.exception('Encountered unexpected error from ProtoRPC '
'method implementation: %s (%s)' %
(err.__class__.__name__, err))
return send_rpc_error(six.moves.http_client.INTERNAL_SERVER_ERROR,
remote.RpcState.SERVER_ERROR,
'Internal Server Error')
response_headers = [('content-type', content_type)]
start_response('%d %s' % (six.moves.http_client.OK, six.moves.http_client.responses[six.moves.http_client.OK],),
response_headers)
return [encoded_response]
# Return WSGI application.
return protorpc_service_app
@util.positional(1)
def service_mappings(services, registry_path=DEFAULT_REGISTRY_PATH):
"""Create multiple service mappings with optional RegistryService.
Use this function to create single WSGI application that maps to
multiple ProtoRPC services plus an optional RegistryService.
Example:
services = service.service_mappings(
[(r'/time', TimeService),
(r'/weather', WeatherService)
])
In this example, the services WSGI application will map to two services,
TimeService and WeatherService to the '/time' and '/weather' paths
respectively. In addition, it will also add a ProtoRPC RegistryService
configured to serve information about both services at the (default) path
'/protorpc'.
Args:
services: If a dictionary is provided instead of a list of tuples, the
dictionary item pairs are used as the mappings instead.
Otherwise, a list of tuples (service_path, service_factory):
service_path: The path to mount service on.
service_factory: A service class or service instance factory.
registry_path: A string to change where the registry is mapped (the default
location is '/protorpc'). When None, no registry is created or mounted.
Returns:
WSGI application that serves ProtoRPC services on their respective URLs
plus optional RegistryService.
"""
if isinstance(services, dict):
services = six.iteritems(services)
final_mapping = []
paths = set()
registry_map = {} if registry_path else None
for service_path, service_factory in services:
try:
service_class = service_factory.service_class
except AttributeError:
service_class = service_factory
if service_path not in paths:
paths.add(service_path)
else:
raise remote.ServiceConfigurationError(
'Path %r is already defined in service mapping' %
service_path.encode('utf-8'))
if registry_map is not None:
registry_map[service_path] = service_class
final_mapping.append(service_mapping(service_factory, service_path))
if registry_map is not None:
final_mapping.append(service_mapping(
registry.RegistryService.new_factory(registry_map), registry_path))
return wsgi_util.first_found(final_mapping)