blob: 2d3f740470639cb63957c9dee0278fd6d7f4da23 [file] [log] [blame]
Adrià Vilanova Martínezf19ea432024-01-23 20:20:52 +01001#!/usr/bin/python
2# Copyright 2017 Google Inc. All Rights Reserved.
3#
4# Licensed under the Apache License, Version 2.0 (the "License");
5# you may not use this file except in compliance with the License.
6# You may obtain a copy of the License at
7#
8# http://www.apache.org/licenses/LICENSE-2.0
9#
10# Unless required by applicable law or agreed to in writing, software
11# distributed under the License is distributed on an "AS IS" BASIS,
12# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13# See the License for the specific language governing permissions and
14# limitations under the License.
15r"""External script for generating Cloud Endpoints related files.
16
17The gen_discovery_doc subcommand takes a list of fully qualified ProtoRPC
18service names and calls a cloud service which generates a discovery document in
19REST or RPC style.
20
21Example:
22 endpointscfg.py gen_discovery_doc -o . -f rest postservice.GreetingsV1
23
24The gen_client_lib subcommand takes a discovery document and calls a cloud
25service to generate a client library for a target language (currently just Java)
26
27Example:
28 endpointscfg.py gen_client_lib java -o . greetings-v0.1.discovery
29
30The get_client_lib subcommand does both of the above commands at once.
31
32Example:
33 endpointscfg.py get_client_lib java -o . postservice.GreetingsV1
34
35The gen_api_config command outputs an .api configuration file for a service.
36
37Example:
38 endpointscfg.py gen_api_config -o . -a /path/to/app \
39 --hostname myhost.appspot.com postservice.GreetingsV1
40"""
41
42from __future__ import absolute_import
43
44import argparse
45import collections
46import contextlib
47import logging
48import os
49import re
50import sys
51from six.moves import urllib
52import urllib2
53
54import yaml
55from google.appengine.ext import testbed
56
57from . import api_config
58from . import discovery_generator
59from . import openapi_generator
60from . import remote
61
62# Conditional import, pylint: disable=g-import-not-at-top
63try:
64 import json
65except ImportError:
66 # If we can't find json packaged with Python import simplejson, which is
67 # packaged with the SDK.
68 import simplejson as json
69
70
71CLIENT_LIBRARY_BASE = 'https://google-api-client-libraries.appspot.com/generate'
72_VISIBLE_COMMANDS = ('get_client_lib', 'get_discovery_doc', 'get_openapi_spec')
73
74
75class ServerRequestException(Exception):
76 """Exception for problems with the request to a server."""
77
78 def __init__(self, http_error):
79 """Create a ServerRequestException from a given urllib2.HTTPError.
80
81 Args:
82 http_error: The HTTPError that the ServerRequestException will be
83 based on.
84 """
85 error_details = None
86 error_response = None
87 if http_error.fp:
88 try:
89 error_response = http_error.fp.read()
90 error_body = json.loads(error_response)
91 error_details = ['%s: %s' % (detail['message'], detail['debug_info'])
92 for detail in error_body['error']['errors']]
93 except (ValueError, TypeError, KeyError):
94 pass
95 if error_details:
96 error_details_str = ', '.join(error_details)
97 error_message = ('HTTP %s (%s) error when communicating with URL: %s. '
98 'Details: %s' % (http_error.code, http_error.reason,
99 http_error.filename, error_details_str))
100 else:
101 error_message = ('HTTP %s (%s) error when communicating with URL: %s. '
102 'Response: %s' % (http_error.code, http_error.reason,
103 http_error.filename,
104 error_response))
105 super(ServerRequestException, self).__init__(error_message)
106
107
108class _EndpointsParser(argparse.ArgumentParser):
109 """Create a subclass of argparse.ArgumentParser for Endpoints."""
110
111 def error(self, message):
112 """Override superclass to support customized error message.
113
114 Error message needs to be rewritten in order to display visible commands
115 only, when invalid command is called by user. Otherwise, hidden commands
116 will be displayed in stderr, which is not expected.
117
118 Refer the following argparse python documentation for detailed method
119 information:
120 http://docs.python.org/2/library/argparse.html#exiting-methods
121
122 Args:
123 message: original error message that will be printed to stderr
124 """
125 # subcommands_quoted is the same as subcommands, except each value is
126 # surrounded with double quotes. This is done to match the standard
127 # output of the ArgumentParser, while hiding commands we don't want users
128 # to use, as they are no longer documented and only here for legacy use.
129 subcommands_quoted = ', '.join(
130 [repr(command) for command in _VISIBLE_COMMANDS])
131 subcommands = ', '.join(_VISIBLE_COMMANDS)
132 message = re.sub(
133 r'(argument {%s}: invalid choice: .*) \(choose from (.*)\)$'
134 % subcommands, r'\1 (choose from %s)' % subcommands_quoted, message)
135 super(_EndpointsParser, self).error(message)
136
137
138def _WriteFile(output_path, name, content):
139 """Write given content to a file in a given directory.
140
141 Args:
142 output_path: The directory to store the file in.
143 name: The name of the file to store the content in.
144 content: The content to write to the file.close
145
146 Returns:
147 The full path to the written file.
148 """
149 path = os.path.join(output_path, name)
150 with open(path, 'wb') as f:
151 f.write(content)
152 return path
153
154
155def GenApiConfig(service_class_names, config_string_generator=None,
156 hostname=None, application_path=None, **additional_kwargs):
157 """Write an API configuration for endpoints annotated ProtoRPC services.
158
159 Args:
160 service_class_names: A list of fully qualified ProtoRPC service classes.
161 config_string_generator: A generator object that produces API config strings
162 using its pretty_print_config_to_json method.
163 hostname: A string hostname which will be used as the default version
164 hostname. If no hostname is specificied in the @endpoints.api decorator,
165 this value is the fallback.
166 application_path: A string with the path to the AppEngine application.
167
168 Raises:
169 TypeError: If any service classes don't inherit from remote.Service.
170 messages.DefinitionNotFoundError: If a service can't be found.
171
172 Returns:
173 A map from service names to a string containing the API configuration of the
174 service in JSON format.
175 """
176 # First, gather together all the different APIs implemented by these
177 # classes. There may be fewer APIs than service classes. Each API is
178 # uniquely identified by (name, version). Order needs to be preserved here,
179 # so APIs that were listed first are returned first.
180 api_service_map = collections.OrderedDict()
181 resolved_services = []
182
183 for service_class_name in service_class_names:
184 module_name, base_service_class_name = service_class_name.rsplit('.', 1)
185 module = __import__(module_name, fromlist=base_service_class_name)
186 service = getattr(module, base_service_class_name)
187 if hasattr(service, 'get_api_classes'):
188 resolved_services.extend(service.get_api_classes())
189 elif (not isinstance(service, type) or
190 not issubclass(service, remote.Service)):
191 raise TypeError('%s is not a ProtoRPC service' % service_class_name)
192 else:
193 resolved_services.append(service)
194
195 for resolved_service in resolved_services:
196 services = api_service_map.setdefault(
197 (resolved_service.api_info.name, resolved_service.api_info.api_version), [])
198 services.append(resolved_service)
199
200 # If hostname isn't specified in the API or on the command line, we'll
201 # try to build it from information in app.yaml.
202 app_yaml_hostname = _GetAppYamlHostname(application_path)
203
204 service_map = collections.OrderedDict()
205 config_string_generator = (
206 config_string_generator or api_config.ApiConfigGenerator())
207 for api_info, services in api_service_map.items():
208 assert services, 'An API must have at least one ProtoRPC service'
209 # Only override hostname if None. Hostname will be the same for all
210 # services within an API, since it's stored in common info.
211 hostname = services[0].api_info.hostname or hostname or app_yaml_hostname
212
213 # Map each API by name-version.
214 service_map['%s-%s' % api_info] = (
215 config_string_generator.pretty_print_config_to_json(
216 services, hostname=hostname, **additional_kwargs))
217
218 return service_map
219
220
221def _GetAppYamlHostname(application_path, open_func=open):
222 """Build the hostname for this app based on the name in app.yaml.
223
224 Args:
225 application_path: A string with the path to the AppEngine application. This
226 should be the directory containing the app.yaml file.
227 open_func: Function to call to open a file. Used to override the default
228 open function in unit tests.
229
230 Returns:
231 A hostname, usually in the form of "myapp.appspot.com", based on the
232 application name in the app.yaml file. If the file can't be found or
233 there's a problem building the name, this will return None.
234 """
235 try:
236 app_yaml_file = open_func(os.path.join(application_path or '.', 'app.yaml'))
237 config = yaml.safe_load(app_yaml_file.read())
238 except IOError:
239 # Couldn't open/read app.yaml.
240 return None
241
242 application = config.get('application')
243 if not application:
244 return None
245
246 if ':' in application:
247 # Don't try to deal with alternate domains.
248 return None
249
250 # If there's a prefix ending in a '~', strip it.
251 tilde_index = application.rfind('~')
252 if tilde_index >= 0:
253 application = application[tilde_index + 1:]
254 if not application:
255 return None
256
257 return '%s.appspot.com' % application
258
259
260def _GenDiscoveryDoc(service_class_names,
261 output_path, hostname=None,
262 application_path=None):
263 """Write discovery documents generated from the service classes to file.
264
265 Args:
266 service_class_names: A list of fully qualified ProtoRPC service names.
267 output_path: The directory to output the discovery docs to.
268 hostname: A string hostname which will be used as the default version
269 hostname. If no hostname is specificied in the @endpoints.api decorator,
270 this value is the fallback. Defaults to None.
271 application_path: A string containing the path to the AppEngine app.
272
273 Returns:
274 A list of discovery doc filenames.
275 """
276 output_files = []
277 service_configs = GenApiConfig(
278 service_class_names, hostname=hostname,
279 config_string_generator=discovery_generator.DiscoveryGenerator(),
280 application_path=application_path)
281 for api_name_version, config in service_configs.items():
282 discovery_name = api_name_version + '.discovery'
283 output_files.append(_WriteFile(output_path, discovery_name, config))
284
285 return output_files
286
287
288def _GenOpenApiSpec(service_class_names, output_path, hostname=None,
289 application_path=None, x_google_api_name=False):
290 """Write openapi documents generated from the service classes to file.
291
292 Args:
293 service_class_names: A list of fully qualified ProtoRPC service names.
294 output_path: The directory to which to output the OpenAPI specs.
295 hostname: A string hostname which will be used as the default version
296 hostname. If no hostname is specified in the @endpoints.api decorator,
297 this value is the fallback. Defaults to None.
298 application_path: A string containing the path to the AppEngine app.
299
300 Returns:
301 A list of OpenAPI spec filenames.
302 """
303 output_files = []
304 service_configs = GenApiConfig(
305 service_class_names, hostname=hostname,
306 config_string_generator=openapi_generator.OpenApiGenerator(),
307 application_path=application_path,
308 x_google_api_name=x_google_api_name)
309 for api_name_version, config in service_configs.items():
310 openapi_name = api_name_version.replace('-', '') + 'openapi.json'
311 output_files.append(_WriteFile(output_path, openapi_name, config))
312
313 return output_files
314
315
316def _GenClientLib(discovery_path, language, output_path, build_system):
317 """Write a client library from a discovery doc.
318
319 Args:
320 discovery_path: Path to the discovery doc used to generate the client
321 library.
322 language: The client library language to generate. (java)
323 output_path: The directory to output the client library zip to.
324 build_system: The target build system for the client library language.
325
326 Raises:
327 IOError: If reading the discovery doc fails.
328 ServerRequestException: If fetching the generated client library fails.
329
330 Returns:
331 The path to the zipped client library.
332 """
333 with open(discovery_path) as f:
334 discovery_doc = f.read()
335
336 client_name = re.sub(r'\.discovery$', '.zip',
337 os.path.basename(discovery_path))
338
339 return _GenClientLibFromContents(discovery_doc, language, output_path,
340 build_system, client_name)
341
342
343def _GenClientLibFromContents(discovery_doc, language, output_path,
344 build_system, client_name):
345 """Write a client library from a discovery doc.
346
347 Args:
348 discovery_doc: A string, the contents of the discovery doc used to
349 generate the client library.
350 language: A string, the client library language to generate. (java)
351 output_path: A string, the directory to output the client library zip to.
352 build_system: A string, the target build system for the client language.
353 client_name: A string, the filename used to save the client lib.
354
355 Raises:
356 IOError: If reading the discovery doc fails.
357 ServerRequestException: If fetching the generated client library fails.
358
359 Returns:
360 The path to the zipped client library.
361 """
362
363 body = urllib.parse.urlencode({'lang': language, 'content': discovery_doc,
364 'layout': build_system})
365 request = urllib2.Request(CLIENT_LIBRARY_BASE, body)
366 try:
367 with contextlib.closing(urllib2.urlopen(request)) as response:
368 content = response.read()
369 return _WriteFile(output_path, client_name, content)
370 except urllib2.HTTPError as error:
371 raise ServerRequestException(error)
372
373
374def _GetClientLib(service_class_names, language, output_path, build_system,
375 hostname=None, application_path=None):
376 """Fetch client libraries from a cloud service.
377
378 Args:
379 service_class_names: A list of fully qualified ProtoRPC service names.
380 language: The client library language to generate. (java)
381 output_path: The directory to output the discovery docs to.
382 build_system: The target build system for the client library language.
383 hostname: A string hostname which will be used as the default version
384 hostname. If no hostname is specificied in the @endpoints.api decorator,
385 this value is the fallback. Defaults to None.
386 application_path: A string containing the path to the AppEngine app.
387
388 Returns:
389 A list of paths to client libraries.
390 """
391 client_libs = []
392 service_configs = GenApiConfig(
393 service_class_names, hostname=hostname,
394 config_string_generator=discovery_generator.DiscoveryGenerator(),
395 application_path=application_path)
396 for api_name_version, config in service_configs.items():
397 client_name = api_name_version + '.zip'
398 client_libs.append(
399 _GenClientLibFromContents(config, language, output_path,
400 build_system, client_name))
401 return client_libs
402
403
404def _GenApiConfigCallback(args, api_func=GenApiConfig):
405 """Generate an api file.
406
407 Args:
408 args: An argparse.Namespace object to extract parameters from.
409 api_func: A function that generates and returns an API configuration
410 for a list of services.
411 """
412 service_configs = api_func(args.service,
413 hostname=args.hostname,
414 application_path=args.application)
415
416 for api_name_version, config in service_configs.items():
417 _WriteFile(args.output, api_name_version + '.api', config)
418
419
420def _GetClientLibCallback(args, client_func=_GetClientLib):
421 """Generate discovery docs and client libraries to files.
422
423 Args:
424 args: An argparse.Namespace object to extract parameters from.
425 client_func: A function that generates client libraries and stores them to
426 files, accepting a list of service names, a client library language,
427 an output directory, a build system for the client library language, and
428 a hostname.
429 """
430 client_paths = client_func(
431 args.service, args.language, args.output, args.build_system,
432 hostname=args.hostname, application_path=args.application)
433
434 for client_path in client_paths:
435 print 'API client library written to %s' % client_path
436
437
438def _GenDiscoveryDocCallback(args, discovery_func=_GenDiscoveryDoc):
439 """Generate discovery docs to files.
440
441 Args:
442 args: An argparse.Namespace object to extract parameters from
443 discovery_func: A function that generates discovery docs and stores them to
444 files, accepting a list of service names, a discovery doc format, and an
445 output directory.
446 """
447 discovery_paths = discovery_func(args.service, args.output,
448 hostname=args.hostname,
449 application_path=args.application)
450 for discovery_path in discovery_paths:
451 print 'API discovery document written to %s' % discovery_path
452
453
454def _GenOpenApiSpecCallback(args, openapi_func=_GenOpenApiSpec):
455 """Generate OpenAPI (Swagger) specs to files.
456
457 Args:
458 args: An argparse.Namespace object to extract parameters from
459 openapi_func: A function that generates OpenAPI specs and stores them to
460 files, accepting a list of service names and an output directory.
461 """
462 openapi_paths = openapi_func(args.service, args.output,
463 hostname=args.hostname,
464 application_path=args.application,
465 x_google_api_name=args.x_google_api_name)
466 for openapi_path in openapi_paths:
467 print 'OpenAPI spec written to %s' % openapi_path
468
469
470def _GenClientLibCallback(args, client_func=_GenClientLib):
471 """Generate a client library to file.
472
473 Args:
474 args: An argparse.Namespace object to extract parameters from
475 client_func: A function that generates client libraries and stores them to
476 files, accepting a path to a discovery doc, a client library language, an
477 output directory, and a build system for the client library language.
478 """
479 client_path = client_func(args.discovery_doc[0], args.language, args.output,
480 args.build_system)
481 print 'API client library written to %s' % client_path
482
483
484def MakeParser(prog):
485 """Create an argument parser.
486
487 Args:
488 prog: The name of the program to use when outputting help text.
489
490 Returns:
491 An argparse.ArgumentParser built to specification.
492 """
493
494 def AddStandardOptions(parser, *args):
495 """Add common endpoints options to a parser.
496
497 Args:
498 parser: The parser to add options to.
499 *args: A list of option names to add. Possible names are: application,
500 format, output, language, service, and discovery_doc.
501 """
502 if 'application' in args:
503 parser.add_argument('-a', '--application', default='.',
504 help='The path to the Python App Engine App')
505 if 'format' in args:
506 # This used to be a valid option, allowing the user to select 'rest' or 'rpc',
507 # but now 'rest' is the only valid type. The argument remains so scripts using it
508 # won't break.
509 parser.add_argument('-f', '--format', default='rest',
510 choices=['rest'],
511 help='The requested API protocol type (ignored)')
512 if 'hostname' in args:
513 help_text = ('Default application hostname, if none is specified '
514 'for API service.')
515 parser.add_argument('--hostname', help=help_text)
516 if 'output' in args:
517 parser.add_argument('-o', '--output', default='.',
518 help='The directory to store output files')
519 if 'language' in args:
520 parser.add_argument('language',
521 help='The target output programming language')
522 if 'service' in args:
523 parser.add_argument('service', nargs='+',
524 help='Fully qualified service class name')
525 if 'discovery_doc' in args:
526 parser.add_argument('discovery_doc', nargs=1,
527 help='Path to the discovery document')
528 if 'build_system' in args:
529 parser.add_argument('-bs', '--build_system', default='default',
530 help='The target build system')
531
532 parser = _EndpointsParser(prog=prog)
533 subparsers = parser.add_subparsers(
534 title='subcommands', metavar='{%s}' % ', '.join(_VISIBLE_COMMANDS))
535
536 get_client_lib = subparsers.add_parser(
537 'get_client_lib', help=('Generates discovery documents and client '
538 'libraries from service classes'))
539 get_client_lib.set_defaults(callback=_GetClientLibCallback)
540 AddStandardOptions(get_client_lib, 'application', 'hostname', 'output',
541 'language', 'service', 'build_system')
542
543 get_discovery_doc = subparsers.add_parser(
544 'get_discovery_doc',
545 help='Generates discovery documents from service classes')
546 get_discovery_doc.set_defaults(callback=_GenDiscoveryDocCallback)
547 AddStandardOptions(get_discovery_doc, 'application', 'format', 'hostname',
548 'output', 'service')
549
550 get_openapi_spec = subparsers.add_parser(
551 'get_openapi_spec',
552 help='Generates OpenAPI (Swagger) specs from service classes')
553 get_openapi_spec.set_defaults(callback=_GenOpenApiSpecCallback)
554 AddStandardOptions(get_openapi_spec, 'application', 'hostname', 'output',
555 'service')
556 get_openapi_spec.add_argument('--x-google-api-name', action='store_true',
557 help="Add the 'x-google-api-name' field to the generated spec")
558
559 # Create an alias for get_openapi_spec called get_swagger_spec to support
560 # the old-style naming. This won't be a visible command, but it will still
561 # function to support legacy scripts.
562 get_swagger_spec = subparsers.add_parser(
563 'get_swagger_spec',
564 help='Generates OpenAPI (Swagger) specs from service classes')
565 get_swagger_spec.set_defaults(callback=_GenOpenApiSpecCallback)
566 AddStandardOptions(get_swagger_spec, 'application', 'hostname', 'output',
567 'service')
568
569 # By removing the help attribute, the following three actions won't be
570 # displayed in usage message
571 gen_api_config = subparsers.add_parser('gen_api_config')
572 gen_api_config.set_defaults(callback=_GenApiConfigCallback)
573 AddStandardOptions(gen_api_config, 'application', 'hostname', 'output',
574 'service')
575
576 gen_discovery_doc = subparsers.add_parser('gen_discovery_doc')
577 gen_discovery_doc.set_defaults(callback=_GenDiscoveryDocCallback)
578 AddStandardOptions(gen_discovery_doc, 'application', 'format', 'hostname',
579 'output', 'service')
580
581 gen_client_lib = subparsers.add_parser('gen_client_lib')
582 gen_client_lib.set_defaults(callback=_GenClientLibCallback)
583 AddStandardOptions(gen_client_lib, 'output', 'language', 'discovery_doc',
584 'build_system')
585
586 return parser
587
588
589def _SetupStubs():
590 tb = testbed.Testbed()
591 tb.setup_env(CURRENT_VERSION_ID='1.0')
592 tb.activate()
593 for k, v in testbed.INIT_STUB_METHOD_NAMES.items():
594 # The old stub initialization code didn't support the image service at all
595 # so we just ignore it here.
596 if k != 'images':
597 getattr(tb, v)()
598
599
600def main(argv):
601 logging.basicConfig()
602 # silence warnings from endpoints.apiserving; they're not relevant
603 # to command-line operation.
604 logging.getLogger('endpoints.apiserving').setLevel(logging.ERROR)
605
606 _SetupStubs()
607
608 parser = MakeParser(argv[0])
609 args = parser.parse_args(argv[1:])
610
611 # Handle the common "application" argument here, since most of the handlers
612 # use this.
613 application_path = getattr(args, 'application', None)
614 if application_path is not None:
615 sys.path.insert(0, os.path.abspath(application_path))
616
617 args.callback(args)