1*14675a02SAndroid Build Coastguard Worker# Copyright 2022 Google LLC 2*14675a02SAndroid Build Coastguard Worker# 3*14675a02SAndroid Build Coastguard Worker# Licensed under the Apache License, Version 2.0 (the "License"); 4*14675a02SAndroid Build Coastguard Worker# you may not use this file except in compliance with the License. 5*14675a02SAndroid Build Coastguard Worker# You may obtain a copy of the License at 6*14675a02SAndroid Build Coastguard Worker# 7*14675a02SAndroid Build Coastguard Worker# http://www.apache.org/licenses/LICENSE-2.0 8*14675a02SAndroid Build Coastguard Worker# 9*14675a02SAndroid Build Coastguard Worker# Unless required by applicable law or agreed to in writing, software 10*14675a02SAndroid Build Coastguard Worker# distributed under the License is distributed on an "AS IS" BASIS, 11*14675a02SAndroid Build Coastguard Worker# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12*14675a02SAndroid Build Coastguard Worker# See the License for the specific language governing permissions and 13*14675a02SAndroid Build Coastguard Worker# limitations under the License. 14*14675a02SAndroid Build Coastguard Worker"""Utilities for creating proto service and HTTP action handlers. 15*14675a02SAndroid Build Coastguard Worker 16*14675a02SAndroid Build Coastguard WorkerThe `@proto_action` function annotates a method as implementing a proto service 17*14675a02SAndroid Build Coastguard Workermethod. The annotated method should have the type 18*14675a02SAndroid Build Coastguard Worker`Callable[[RequestMessage], ResponseMessage]`. The decorator will take care of 19*14675a02SAndroid Build Coastguard Workertranscoding to/from a HTTP request, similar to 20*14675a02SAndroid Build Coastguard Workerhttps://cloud.google.com/endpoints/docs/grpc/transcoding. The transcoding only 21*14675a02SAndroid Build Coastguard Workersupports proto-over-http ('?alt=proto'). 22*14675a02SAndroid Build Coastguard Worker 23*14675a02SAndroid Build Coastguard WorkerThe `@http_action` function annotates a method as implementing a HTTP action at 24*14675a02SAndroid Build Coastguard Workersome request path. The annotated method will receive the request body, and 25*14675a02SAndroid Build Coastguard Workershould return a `HttpResponse`. 26*14675a02SAndroid Build Coastguard Worker 27*14675a02SAndroid Build Coastguard WorkerThe `create_handler` function merges one or more objects with decorated methods 28*14675a02SAndroid Build Coastguard Workerinto a single request handler that's compatible with `http.server`. 29*14675a02SAndroid Build Coastguard Worker""" 30*14675a02SAndroid Build Coastguard Worker 31*14675a02SAndroid Build Coastguard Workerimport collections 32*14675a02SAndroid Build Coastguard Workerimport dataclasses 33*14675a02SAndroid Build Coastguard Workerimport enum 34*14675a02SAndroid Build Coastguard Workerimport gzip 35*14675a02SAndroid Build Coastguard Workerimport http 36*14675a02SAndroid Build Coastguard Workerimport http.server 37*14675a02SAndroid Build Coastguard Workerimport re 38*14675a02SAndroid Build Coastguard Workerfrom typing import Any, Callable, Mapping, Match, Pattern, Type, TypeVar 39*14675a02SAndroid Build Coastguard Workerimport urllib.parse 40*14675a02SAndroid Build Coastguard Workerimport zlib 41*14675a02SAndroid Build Coastguard Worker 42*14675a02SAndroid Build Coastguard Workerfrom absl import logging 43*14675a02SAndroid Build Coastguard Worker 44*14675a02SAndroid Build Coastguard Workerfrom google.api import annotations_pb2 45*14675a02SAndroid Build Coastguard Workerfrom google.protobuf import descriptor_pool 46*14675a02SAndroid Build Coastguard Workerfrom google.protobuf import message 47*14675a02SAndroid Build Coastguard Workerfrom google.protobuf import message_factory 48*14675a02SAndroid Build Coastguard Worker 49*14675a02SAndroid Build Coastguard Worker_CallableT = TypeVar('_CallableT', bound=Callable) 50*14675a02SAndroid Build Coastguard Worker 51*14675a02SAndroid Build Coastguard Worker_HTTP_ACTION_ATTR = '_http_action_data' 52*14675a02SAndroid Build Coastguard Worker_FACTORY = message_factory.MessageFactory(descriptor_pool.Default()) 53*14675a02SAndroid Build Coastguard Worker 54*14675a02SAndroid Build Coastguard Worker 55*14675a02SAndroid Build Coastguard Worker@dataclasses.dataclass(frozen=True) 56*14675a02SAndroid Build Coastguard Workerclass HttpError(Exception): 57*14675a02SAndroid Build Coastguard Worker """An Exception specifying the HTTP error to return.""" 58*14675a02SAndroid Build Coastguard Worker code: http.HTTPStatus 59*14675a02SAndroid Build Coastguard Worker 60*14675a02SAndroid Build Coastguard Worker 61*14675a02SAndroid Build Coastguard Worker@dataclasses.dataclass(frozen=True) 62*14675a02SAndroid Build Coastguard Workerclass HttpResponse: 63*14675a02SAndroid Build Coastguard Worker """Information for a successful HTTP response.""" 64*14675a02SAndroid Build Coastguard Worker body: bytes 65*14675a02SAndroid Build Coastguard Worker headers: Mapping[str, str] = dataclasses.field(default_factory=lambda: {}) 66*14675a02SAndroid Build Coastguard Worker 67*14675a02SAndroid Build Coastguard Worker 68*14675a02SAndroid Build Coastguard Workerdef proto_action(*, 69*14675a02SAndroid Build Coastguard Worker service=str, 70*14675a02SAndroid Build Coastguard Worker method=str) -> Callable[[_CallableT], _CallableT]: 71*14675a02SAndroid Build Coastguard Worker """Decorator annotating a method as handling a proto service method. 72*14675a02SAndroid Build Coastguard Worker 73*14675a02SAndroid Build Coastguard Worker The `google.api.http` annotation on the method will determine what requests 74*14675a02SAndroid Build Coastguard Worker will be handled by the decorated function. Only a subset of methods and path 75*14675a02SAndroid Build Coastguard Worker patterns are currently supported. 76*14675a02SAndroid Build Coastguard Worker 77*14675a02SAndroid Build Coastguard Worker The decorated method will be called with the request message; it should return 78*14675a02SAndroid Build Coastguard Worker a response message or or throw an `HttpError`. 79*14675a02SAndroid Build Coastguard Worker 80*14675a02SAndroid Build Coastguard Worker Args: 81*14675a02SAndroid Build Coastguard Worker service: The full name of the proto service. 82*14675a02SAndroid Build Coastguard Worker method: The name of the method. 83*14675a02SAndroid Build Coastguard Worker 84*14675a02SAndroid Build Coastguard Worker Returns: 85*14675a02SAndroid Build Coastguard Worker An annotated function. 86*14675a02SAndroid Build Coastguard Worker """ 87*14675a02SAndroid Build Coastguard Worker try: 88*14675a02SAndroid Build Coastguard Worker desc = _FACTORY.pool.FindServiceByName(service).FindMethodByName(method) 89*14675a02SAndroid Build Coastguard Worker except KeyError as e: 90*14675a02SAndroid Build Coastguard Worker raise ValueError(f'Unable to find /{service}.{method}.') from e 91*14675a02SAndroid Build Coastguard Worker 92*14675a02SAndroid Build Coastguard Worker rule = desc.GetOptions().Extensions[annotations_pb2.http] 93*14675a02SAndroid Build Coastguard Worker pattern_kind = rule.WhichOneof('pattern') 94*14675a02SAndroid Build Coastguard Worker try: 95*14675a02SAndroid Build Coastguard Worker http_method = _HttpMethod[pattern_kind.upper()] 96*14675a02SAndroid Build Coastguard Worker except KeyError as e: 97*14675a02SAndroid Build Coastguard Worker raise ValueError( 98*14675a02SAndroid Build Coastguard Worker f'The google.api.http annotation on /{service}.{method} is invalid ' 99*14675a02SAndroid Build Coastguard Worker 'or unsupported.') from e 100*14675a02SAndroid Build Coastguard Worker path = _convert_pattern(getattr(rule, pattern_kind), alt_proto=True) 101*14675a02SAndroid Build Coastguard Worker 102*14675a02SAndroid Build Coastguard Worker def handler(match: Match[str], body: bytes, 103*14675a02SAndroid Build Coastguard Worker fn: Callable[[message.Message], message.Message]) -> HttpResponse: 104*14675a02SAndroid Build Coastguard Worker request = _FACTORY.GetPrototype(desc.input_type)() 105*14675a02SAndroid Build Coastguard Worker if rule.body == '*': 106*14675a02SAndroid Build Coastguard Worker try: 107*14675a02SAndroid Build Coastguard Worker request.ParseFromString(body) 108*14675a02SAndroid Build Coastguard Worker except message.DecodeError as e: 109*14675a02SAndroid Build Coastguard Worker raise HttpError(code=http.HTTPStatus.BAD_REQUEST) from e 110*14675a02SAndroid Build Coastguard Worker elif rule.body: 111*14675a02SAndroid Build Coastguard Worker setattr(request, rule.body, body) 112*14675a02SAndroid Build Coastguard Worker # Set any fields from the request path. 113*14675a02SAndroid Build Coastguard Worker for prop, value in match.groupdict().items(): 114*14675a02SAndroid Build Coastguard Worker try: 115*14675a02SAndroid Build Coastguard Worker unescaped = urllib.parse.unquote(value) 116*14675a02SAndroid Build Coastguard Worker except UnicodeError as e: 117*14675a02SAndroid Build Coastguard Worker raise HttpError(code=http.HTTPStatus.BAD_REQUEST) from e 118*14675a02SAndroid Build Coastguard Worker setattr(request, prop, unescaped) 119*14675a02SAndroid Build Coastguard Worker 120*14675a02SAndroid Build Coastguard Worker response_body = fn(request).SerializeToString() 121*14675a02SAndroid Build Coastguard Worker return HttpResponse( 122*14675a02SAndroid Build Coastguard Worker body=response_body, 123*14675a02SAndroid Build Coastguard Worker headers={ 124*14675a02SAndroid Build Coastguard Worker 'Content-Length': len(response_body), 125*14675a02SAndroid Build Coastguard Worker 'Content-Type': 'application/x-protobuf' 126*14675a02SAndroid Build Coastguard Worker }) 127*14675a02SAndroid Build Coastguard Worker 128*14675a02SAndroid Build Coastguard Worker def annotate_method(func: _CallableT) -> _CallableT: 129*14675a02SAndroid Build Coastguard Worker setattr(func, _HTTP_ACTION_ATTR, 130*14675a02SAndroid Build Coastguard Worker _HttpActionData(method=http_method, path=path, handler=handler)) 131*14675a02SAndroid Build Coastguard Worker return func 132*14675a02SAndroid Build Coastguard Worker 133*14675a02SAndroid Build Coastguard Worker return annotate_method 134*14675a02SAndroid Build Coastguard Worker 135*14675a02SAndroid Build Coastguard Worker 136*14675a02SAndroid Build Coastguard Workerdef http_action(*, method: str, 137*14675a02SAndroid Build Coastguard Worker pattern: str) -> Callable[[_CallableT], _CallableT]: 138*14675a02SAndroid Build Coastguard Worker """Decorator annotating a method as an HTTP action handler. 139*14675a02SAndroid Build Coastguard Worker 140*14675a02SAndroid Build Coastguard Worker Request matching the method and pattern will be handled by the decorated 141*14675a02SAndroid Build Coastguard Worker method. The pattern may contain bracket-enclosed keywords (e.g., 142*14675a02SAndroid Build Coastguard Worker '/data/{path}'), which will be matched against the request and passed 143*14675a02SAndroid Build Coastguard Worker to the decorated function as keyword arguments. 144*14675a02SAndroid Build Coastguard Worker 145*14675a02SAndroid Build Coastguard Worker The decorated method will be called with the request body (if any) and any 146*14675a02SAndroid Build Coastguard Worker keyword args from the path pattern; it should return a `HttpResponse` or throw 147*14675a02SAndroid Build Coastguard Worker an `HttpError`. 148*14675a02SAndroid Build Coastguard Worker 149*14675a02SAndroid Build Coastguard Worker Args: 150*14675a02SAndroid Build Coastguard Worker method: The type of HTTP method ('GET' or 'POST'). 151*14675a02SAndroid Build Coastguard Worker pattern: The url pattern to match. 152*14675a02SAndroid Build Coastguard Worker 153*14675a02SAndroid Build Coastguard Worker Returns: 154*14675a02SAndroid Build Coastguard Worker An annotated function. 155*14675a02SAndroid Build Coastguard Worker """ 156*14675a02SAndroid Build Coastguard Worker try: 157*14675a02SAndroid Build Coastguard Worker http_method = _HttpMethod[method.upper()] 158*14675a02SAndroid Build Coastguard Worker except KeyError as e: 159*14675a02SAndroid Build Coastguard Worker raise ValueError(f'unsupported HTTP method `{method}`') from e 160*14675a02SAndroid Build Coastguard Worker path = _convert_pattern(pattern) 161*14675a02SAndroid Build Coastguard Worker 162*14675a02SAndroid Build Coastguard Worker def handler(match: Match[str], body: bytes, 163*14675a02SAndroid Build Coastguard Worker fn: Callable[[bytes], HttpResponse]) -> HttpResponse: 164*14675a02SAndroid Build Coastguard Worker try: 165*14675a02SAndroid Build Coastguard Worker args = {k: urllib.parse.unquote(v) for k, v in match.groupdict().items()} 166*14675a02SAndroid Build Coastguard Worker except UnicodeError as e: 167*14675a02SAndroid Build Coastguard Worker raise HttpError(code=http.HTTPStatus.BAD_REQUEST) from e 168*14675a02SAndroid Build Coastguard Worker return fn(body, **args) 169*14675a02SAndroid Build Coastguard Worker 170*14675a02SAndroid Build Coastguard Worker def annotate_method(func: _CallableT) -> _CallableT: 171*14675a02SAndroid Build Coastguard Worker setattr(func, _HTTP_ACTION_ATTR, 172*14675a02SAndroid Build Coastguard Worker _HttpActionData(method=http_method, path=path, handler=handler)) 173*14675a02SAndroid Build Coastguard Worker return func 174*14675a02SAndroid Build Coastguard Worker 175*14675a02SAndroid Build Coastguard Worker return annotate_method 176*14675a02SAndroid Build Coastguard Worker 177*14675a02SAndroid Build Coastguard Worker 178*14675a02SAndroid Build Coastguard Workerdef create_handler(*services: Any) -> Type[http.server.BaseHTTPRequestHandler]: 179*14675a02SAndroid Build Coastguard Worker """Builds a BaseHTTPRequestHandler that delegates to decorated methods. 180*14675a02SAndroid Build Coastguard Worker 181*14675a02SAndroid Build Coastguard Worker The returned BaseHTTPRequestHandler class will route requests to decorated 182*14675a02SAndroid Build Coastguard Worker methods of the provided services, or return 404 if the request path does not 183*14675a02SAndroid Build Coastguard Worker match any action handlers. If the request path matches multiple registered 184*14675a02SAndroid Build Coastguard Worker action handlers, it's unspecified which will be invoked. 185*14675a02SAndroid Build Coastguard Worker 186*14675a02SAndroid Build Coastguard Worker Args: 187*14675a02SAndroid Build Coastguard Worker *services: A list of objects with methods decorated with `@proto_action` or 188*14675a02SAndroid Build Coastguard Worker `@http_action`. 189*14675a02SAndroid Build Coastguard Worker 190*14675a02SAndroid Build Coastguard Worker Returns: 191*14675a02SAndroid Build Coastguard Worker A BaseHTTPRequestHandler subclass. 192*14675a02SAndroid Build Coastguard Worker """ 193*14675a02SAndroid Build Coastguard Worker 194*14675a02SAndroid Build Coastguard Worker # Collect all handlers, keyed by HTTP method. 195*14675a02SAndroid Build Coastguard Worker handlers = collections.defaultdict(lambda: []) 196*14675a02SAndroid Build Coastguard Worker for service in services: 197*14675a02SAndroid Build Coastguard Worker for attr_name in dir(service): 198*14675a02SAndroid Build Coastguard Worker attr = getattr(service, attr_name) 199*14675a02SAndroid Build Coastguard Worker if not callable(attr): 200*14675a02SAndroid Build Coastguard Worker continue 201*14675a02SAndroid Build Coastguard Worker data = getattr(attr, _HTTP_ACTION_ATTR, None) 202*14675a02SAndroid Build Coastguard Worker if isinstance(data, _HttpActionData): 203*14675a02SAndroid Build Coastguard Worker handlers[data.method].append((data, attr)) 204*14675a02SAndroid Build Coastguard Worker 205*14675a02SAndroid Build Coastguard Worker format_handlers = lambda h: ''.join([f'\n * {e[0].path.pattern}' for e in h]) 206*14675a02SAndroid Build Coastguard Worker logging.debug( 207*14675a02SAndroid Build Coastguard Worker 'Creating HTTP request handler for path patterns:\nGET:%s\nPOST:%s', 208*14675a02SAndroid Build Coastguard Worker format_handlers(handlers[_HttpMethod.GET]), 209*14675a02SAndroid Build Coastguard Worker format_handlers(handlers[_HttpMethod.POST])) 210*14675a02SAndroid Build Coastguard Worker 211*14675a02SAndroid Build Coastguard Worker class RequestHandler(http.server.BaseHTTPRequestHandler): 212*14675a02SAndroid Build Coastguard Worker """Handler that delegates to `handlers`.""" 213*14675a02SAndroid Build Coastguard Worker 214*14675a02SAndroid Build Coastguard Worker def do_GET(self) -> None: # pylint:disable=invalid-name (override) 215*14675a02SAndroid Build Coastguard Worker self._handle_request(_HttpMethod.GET, read_body=False) 216*14675a02SAndroid Build Coastguard Worker 217*14675a02SAndroid Build Coastguard Worker def do_POST(self) -> None: # pylint:disable=invalid-name (override) 218*14675a02SAndroid Build Coastguard Worker self._handle_request(_HttpMethod.POST) 219*14675a02SAndroid Build Coastguard Worker 220*14675a02SAndroid Build Coastguard Worker def _handle_request(self, 221*14675a02SAndroid Build Coastguard Worker method: _HttpMethod, 222*14675a02SAndroid Build Coastguard Worker read_body: bool = True) -> None: 223*14675a02SAndroid Build Coastguard Worker """Reads and delegates an incoming request to a registered handler.""" 224*14675a02SAndroid Build Coastguard Worker for data, fn in handlers[method]: 225*14675a02SAndroid Build Coastguard Worker match = data.path.fullmatch(self.path) 226*14675a02SAndroid Build Coastguard Worker if match is None: 227*14675a02SAndroid Build Coastguard Worker continue 228*14675a02SAndroid Build Coastguard Worker 229*14675a02SAndroid Build Coastguard Worker try: 230*14675a02SAndroid Build Coastguard Worker body = self._read_body() if read_body else b'' 231*14675a02SAndroid Build Coastguard Worker response = data.handler(match, body, fn) 232*14675a02SAndroid Build Coastguard Worker except HttpError as e: 233*14675a02SAndroid Build Coastguard Worker logging.debug('%s error: %s', self.path, e) 234*14675a02SAndroid Build Coastguard Worker return self.send_error(e.code) 235*14675a02SAndroid Build Coastguard Worker return self._send_response(response) 236*14675a02SAndroid Build Coastguard Worker 237*14675a02SAndroid Build Coastguard Worker # If no handler matched the path, return an error. 238*14675a02SAndroid Build Coastguard Worker self.send_error(http.HTTPStatus.NOT_FOUND) 239*14675a02SAndroid Build Coastguard Worker 240*14675a02SAndroid Build Coastguard Worker def _read_body(self) -> bytes: 241*14675a02SAndroid Build Coastguard Worker """Reads the body of the request.""" 242*14675a02SAndroid Build Coastguard Worker body = self.rfile.read(int(self.headers['Content-Length'])) 243*14675a02SAndroid Build Coastguard Worker if self.headers['Content-Encoding'] == 'gzip': 244*14675a02SAndroid Build Coastguard Worker try: 245*14675a02SAndroid Build Coastguard Worker body = gzip.decompress(body) 246*14675a02SAndroid Build Coastguard Worker except (gzip.BadGzipFile, zlib.error) as e: 247*14675a02SAndroid Build Coastguard Worker raise HttpError(http.HTTPStatus.BAD_REQUEST) from e 248*14675a02SAndroid Build Coastguard Worker elif self.headers['Content-Encoding']: 249*14675a02SAndroid Build Coastguard Worker logging.warning('Unsupported content encoding %s', 250*14675a02SAndroid Build Coastguard Worker self.headers['Content-Encoding']) 251*14675a02SAndroid Build Coastguard Worker raise HttpError(http.HTTPStatus.BAD_REQUEST) 252*14675a02SAndroid Build Coastguard Worker return body 253*14675a02SAndroid Build Coastguard Worker 254*14675a02SAndroid Build Coastguard Worker def _send_response(self, response: HttpResponse) -> None: 255*14675a02SAndroid Build Coastguard Worker """Sends a successful response message.""" 256*14675a02SAndroid Build Coastguard Worker self.send_response(http.HTTPStatus.OK) 257*14675a02SAndroid Build Coastguard Worker for keyword, value in response.headers.items(): 258*14675a02SAndroid Build Coastguard Worker self.send_header(keyword, value) 259*14675a02SAndroid Build Coastguard Worker self.end_headers() 260*14675a02SAndroid Build Coastguard Worker self.wfile.write(response.body) 261*14675a02SAndroid Build Coastguard Worker 262*14675a02SAndroid Build Coastguard Worker return RequestHandler 263*14675a02SAndroid Build Coastguard Worker 264*14675a02SAndroid Build Coastguard Worker 265*14675a02SAndroid Build Coastguard Workerclass _HttpMethod(enum.Enum): 266*14675a02SAndroid Build Coastguard Worker GET = 1 267*14675a02SAndroid Build Coastguard Worker POST = 2 268*14675a02SAndroid Build Coastguard Worker 269*14675a02SAndroid Build Coastguard Worker 270*14675a02SAndroid Build Coastguard Worker@dataclasses.dataclass(frozen=True) 271*14675a02SAndroid Build Coastguard Workerclass _HttpActionData: 272*14675a02SAndroid Build Coastguard Worker """Data tracked for HTTP actions. 273*14675a02SAndroid Build Coastguard Worker 274*14675a02SAndroid Build Coastguard Worker Attributes: 275*14675a02SAndroid Build Coastguard Worker method: The name of the HTTP method to handle. 276*14675a02SAndroid Build Coastguard Worker path: Requests matching this pattern will be handled. 277*14675a02SAndroid Build Coastguard Worker handler: The handler function, which receives the path match, request body, 278*14675a02SAndroid Build Coastguard Worker and decorated function. 279*14675a02SAndroid Build Coastguard Worker """ 280*14675a02SAndroid Build Coastguard Worker method: _HttpMethod 281*14675a02SAndroid Build Coastguard Worker path: Pattern[str] 282*14675a02SAndroid Build Coastguard Worker handler: Callable[[Match[str], bytes, Callable[..., Any]], HttpResponse] 283*14675a02SAndroid Build Coastguard Worker 284*14675a02SAndroid Build Coastguard Worker 285*14675a02SAndroid Build Coastguard Workerdef _convert_pattern(pattern: str, alt_proto=False) -> Pattern[str]: 286*14675a02SAndroid Build Coastguard Worker """Converts a Google API pattern to a regexp with named groups.""" 287*14675a02SAndroid Build Coastguard Worker # Subfields are not supported and will generate a regexp compilation error. 288*14675a02SAndroid Build Coastguard Worker pattern_regexp = re.sub(r'\\\{(.+?)\\\}', r'(?P<\1>[^/?]*)', 289*14675a02SAndroid Build Coastguard Worker re.escape(pattern)) 290*14675a02SAndroid Build Coastguard Worker if alt_proto: 291*14675a02SAndroid Build Coastguard Worker pattern_regexp += r'\?%24alt=proto' 292*14675a02SAndroid Build Coastguard Worker try: 293*14675a02SAndroid Build Coastguard Worker return re.compile(pattern_regexp) 294*14675a02SAndroid Build Coastguard Worker except re.error as e: 295*14675a02SAndroid Build Coastguard Worker raise ValueError(f'unable to convert `{pattern}` to a regexp') from e 296