├── .gitignore ├── .travis.yml ├── CONTRIBUTING.rst ├── LICENSE.txt ├── MANIFEST.in ├── README.rst ├── endpoints ├── __init__.py ├── _endpointscfg_impl.py ├── _endpointscfg_setup.py ├── api_config.py ├── api_config_manager.py ├── api_exceptions.py ├── api_request.py ├── apiserving.py ├── constants.py ├── directory_list_generator.py ├── discovery_generator.py ├── discovery_service.py ├── endpoints_dispatcher.py ├── endpointscfg.py ├── errors.py ├── generated_error_info.py ├── message_parser.py ├── openapi_generator.py ├── parameter_converter.py ├── protojson.py ├── proxy.html ├── resource_container.py ├── types.py ├── users_id_token.py └── util.py ├── requirements.txt ├── setup.cfg ├── setup.py ├── test-requirements.txt ├── test ├── __init__.py ├── api_config_manager_test.py ├── api_config_test.py ├── apiserving_test.py ├── conftest.py ├── directory_list_generator_test.py ├── discovery_document_test.py ├── discovery_generator_test.py ├── discovery_service_test.py ├── endpoints_dispatcher_test.py ├── integration_test.py ├── message_parser_test.py ├── openapi_generator_test.py ├── protojson_test.py ├── resource_container_test.py ├── test_live_auth.py ├── test_util.py ├── testdata │ ├── directory_list │ │ ├── basic.json │ │ └── localhost.json │ ├── discovery │ │ ├── allfields.json │ │ ├── bar_endpoint.json │ │ ├── foo_endpoint.json │ │ ├── multiple_parameter_endpoint.json │ │ └── namespace.json │ └── sample_app │ │ ├── app.yaml │ │ ├── appengine_config.py │ │ ├── data.py │ │ └── main.py ├── types_test.py ├── users_id_token_test.py └── util_test.py └── tox.ini /.gitignore: -------------------------------------------------------------------------------- 1 | *~ 2 | .DS_Store 3 | 4 | # https://github.com/github/gitignore/blob/master/Python.gitignore 5 | 6 | # Byte-compiled / optimized / DLL files 7 | __pycache__/ 8 | *.py[cod] 9 | *$py.class 10 | 11 | # C extensions 12 | *.so 13 | 14 | # Distribution / packaging 15 | .Python 16 | env/ 17 | build/ 18 | develop-eggs/ 19 | docs/generated 20 | dist/ 21 | downloads/ 22 | eggs/ 23 | .eggs/ 24 | lib/ 25 | lib64/ 26 | parts/ 27 | sdist/ 28 | var/ 29 | *.egg-info/ 30 | .installed.cfg 31 | *.egg 32 | 33 | # PyInstaller 34 | # Usually these files are written by a python script from a template 35 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 36 | *.manifest 37 | *.spec 38 | 39 | # Installer logs 40 | pip-log.txt 41 | pip-delete-this-directory.txt 42 | 43 | # Unit test / coverage reports 44 | htmlcov/ 45 | .tox/ 46 | .coverage 47 | .coverage.* 48 | .cache 49 | .pytest_cache 50 | nosetests.xml 51 | coverage.xml 52 | *,cover 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | 61 | # Sphinx documentation 62 | docs/_build/ 63 | _build 64 | 65 | # PyBuilder 66 | target/ 67 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | 3 | python: 4 | - "2.7" 5 | 6 | install: pip install tox-travis 7 | 8 | script: tox 9 | -------------------------------------------------------------------------------- /CONTRIBUTING.rst: -------------------------------------------------------------------------------- 1 | Contributing 2 | ============ 3 | 4 | 1. **Please sign one of the contributor license agreements [below][6].** 5 | 1. [File an issue][9] to notify the maintainers about what you're working on. 6 | 1. [Fork the repo][10], develop and [test][11] your code changes, add docs. 7 | 1. Make sure that your commit messages clearly describe the changes. 8 | 1. [Send][12] a pull request. 9 | 10 | Here are some guidelines for hacking on `google-endpoints`. 11 | 12 | Before writing code, file an issue 13 | ---------------------------------- 14 | 15 | Use the issue tracker to start the discussion. It is possible that someone else 16 | is already working on your idea, your approach is not quite right, or that the 17 | functionality exists already. The ticket you file in the issue tracker will be 18 | used to hash that all out. 19 | 20 | Fork `google-endpoints` 21 | ----------------------- 22 | 23 | We will use GitHub's mechanism for [forking][8] repositories and making pull 24 | requests. Fork the repository, and make your changes in the forked repository. 25 | 26 | Include tests 27 | ------------- 28 | 29 | Be sure to add the relevant tests before making the pull request. Docs will be 30 | updated automatically when we merge to `master`, but you should also build 31 | the docs yourself via `tox -e docs` and make sure they're readable. 32 | 33 | Make the pull request 34 | --------------------- 35 | 36 | Once you have made all your changes, tests, and updated the documentation, 37 | make a pull request to move everything back into the main `google-endpoints` 38 | repository. Be sure to reference the original issue in the pull request. 39 | Expect some back-and-forth with regards to style and compliance of these 40 | rules. 41 | 42 | Using a Development Checkout 43 | ---------------------------- 44 | 45 | You’ll have to create a development environment to hack on 46 | `google-endpoints`, using a Git checkout: 47 | 48 | - While logged into your GitHub account, navigate to the `google-endpoints` 49 | [repo][1] on GitHub. 50 | - Fork and clone the `google-endpoints` repository to your GitHub account 51 | by clicking the "Fork" button. 52 | - Clone your fork of `google-endpoints` from your GitHub account to your 53 | local computer, substituting your account username and specifying 54 | the destination as `hack-on-google-endpoints`. For example: 55 | 56 | ```bash 57 | $ cd ${HOME} 58 | $ git clone git@github.com:USERNAME/google-endpoints.git hack-on-google-endpoints 59 | $ cd hack-on-google-endpoints 60 | $ # Configure remotes such that you can pull changes from the google-endpoints 61 | $ # repository into your local repository. 62 | $ git remote add upstream https://github.com:google/google-endpoints 63 | $ # fetch and merge changes from upstream into master 64 | $ git fetch upstream 65 | $ git merge upstream/master 66 | ``` 67 | 68 | Now your local repo is set up such that you will push changes to your 69 | GitHub repo, from which you can submit a pull request. 70 | 71 | - Create a virtualenv in which to install `google-endpoints`: 72 | 73 | ```bash 74 | $ cd ~/hack-on-google-endpoints 75 | $ virtualenv -ppython2.7 env 76 | ``` 77 | 78 | Note that very old versions of virtualenv (virtualenv versions 79 | below, say, 1.10 or thereabouts) require you to pass a 80 | `--no-site-packages` flag to get a completely isolated environment. 81 | 82 | You can choose which Python version you want to use by passing a 83 | `-p` flag to `virtualenv`. For example, `virtualenv -ppython2.7` 84 | chooses the Python 2.7 interpreter to be installed. 85 | 86 | From here on in within these instructions, the 87 | `~/hack-on-google-endpoints/env` virtual environment you created above will be 88 | referred to as `$VENV`. To use the instructions in the steps that 89 | follow literally, use the `export VENV=~/hack-on-google-endpoints/env` 90 | command. 91 | 92 | - Install `google-endpoints` from the checkout into the virtualenv using 93 | `setup.py develop`. Running `setup.py develop` **must** be done while 94 | the current working directory is the `google-endpoints` checkout 95 | directory: 96 | 97 | ```bash 98 | $ cd ~/hack-on-google-endpoints 99 | $ $VENV/bin/python setup.py develop 100 | ``` 101 | 102 | Running Tests 103 | -------------- 104 | 105 | - To run all tests for `google-endpoints` on a single Python version, run 106 | `tox` from your development virtualenv (See 107 | **Using a Development Checkout** above). 108 | 109 | - To run the full set of `google-endpoints` tests on all platforms, install 110 | [`tox`][2] into a system Python. The `tox` console script will be 111 | installed into the scripts location for that Python. While in the 112 | `google-endpoints` checkout root directory (it contains `tox.ini`), 113 | invoke the `tox` console script. This will read the `tox.ini` file and 114 | execute the tests on multiple Python versions and platforms; while it runs, 115 | it creates a virtualenv for each version/platform combination. For 116 | example: 117 | 118 | ```bash 119 | $ sudo pip install tox 120 | $ cd ~/hack-on-google-endpoints 121 | $ tox 122 | 123 | 124 | Contributor License Agreements 125 | ------------------------------ 126 | 127 | Before we can accept your pull requests you'll need to sign a Contributor 128 | License Agreement (CLA): 129 | 130 | - **If you are an individual writing original source code** and **you own 131 | the intellectual property**, then you'll need to sign an 132 | [individual CLA][4]. 133 | - **If you work for a company that wants to allow you to contribute your 134 | work**, then you'll need to sign a [corporate CLA][5]. 135 | 136 | You can sign these electronically (just scroll to the bottom). After that, 137 | we'll be able to accept your pull requests. 138 | 139 | [1]: https://github.com/google/oauth2client 140 | [2]: https://tox.readthedocs.org/en/latest/ 141 | [4]: https://developers.google.com/open-source/cla/individual 142 | [5]: https://developers.google.com/open-source/cla/corporate 143 | [6]: #contributor-license-agreements 144 | [8]: https://help.github.com/articles/fork-a-repo/ 145 | [9]: #before-writing-code-file-an-issue 146 | [10]: #fork-google-endpoints 147 | [11]: #include-tests 148 | [12]: #make-the-pull-request 149 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | graft endpoints 2 | graft test 3 | global-exclude *.py[cod] __pycache__ *.so 4 | include LICENSE* CONTRIBUTING* 5 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Google Cloud Endpoints 2 | ====================== 3 | 4 | Google Cloud Endpoints is a solution for creating RESTful web APIs. 5 | 6 | Installation 7 | ------------- 8 | 9 | Install using pip, as recommended per `App Engine vendoring`_ 10 | 11 | .. code:: bash 12 | 13 | [sudo] pip install -t lib --ignore-installed google-endpoints 14 | 15 | 16 | Python Versions 17 | --------------- 18 | 19 | google-endpoints is currently tested with Python 2.7. 20 | 21 | 22 | Contributing 23 | ------------ 24 | 25 | Contributions to this library are always welcome and highly encouraged. 26 | 27 | See the `CONTRIBUTING`_ documentation for more information on how to get started. 28 | 29 | 30 | Versioning 31 | ---------- 32 | 33 | This library follows `Semantic Versioning`_ 34 | 35 | 36 | Details 37 | ------- 38 | 39 | For detailed documentation of google-endpoints, please see the `Cloud Endpoints Documentation`_. 40 | 41 | 42 | License 43 | ------- 44 | 45 | Apache - See `LICENSE`_ for more information. 46 | 47 | .. _`CONTRIBUTING`: https://github.com/cloudendpoints/endpoints-python/blob/master/CONTRIBUTING.rst 48 | .. _`LICENSE`: https://github.com/cloudendpoints/endpoints-python/blob/master/LICENSE.txt 49 | .. _`Install virtualenv`: http://docs.python-guide.org/en/latest/dev/virtualenvs/ 50 | .. _`pip`: https://pip.pypa.io 51 | .. _`edit RST online`: http://rst.ninjs.org 52 | .. _`RST cheatsheet`: http://docutils.sourceforge.net/docs/user/rst/cheatsheet.txt 53 | .. _`py.test`: http://pytest.org 54 | .. _`Tox-driven python development`: http://www.boronine.com/2012/11/15/Tox-Driven-Python-Development/ 55 | .. _`Sphinx documentation example`: http://sphinx-doc.org/latest/ext/example_google.html 56 | .. _`hyper`: https://github.com/lukasa/hyper 57 | .. _`Google APIs`: https://github.com/google/googleapis/ 58 | .. _`Semantic Versioning`: http://semver.org/ 59 | .. _`Cloud Endpoints Documentation`: https://cloud.google.com/endpoints/docs/frameworks/ 60 | .. _`App Engine vendoring`: https://cloud.google.com/appengine/docs/python/tools/using-libraries-python-27#vendoring 61 | -------------------------------------------------------------------------------- /endpoints/__init__.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/python 2 | # 3 | # Copyright 2016 Google Inc. All Rights Reserved. 4 | # 5 | # Licensed under the Apache License, Version 2.0 (the "License"); 6 | # you may not use this file except in compliance with the License. 7 | # You may obtain a copy of the License at 8 | # 9 | # http://www.apache.org/licenses/LICENSE-2.0 10 | # 11 | # Unless required by applicable law or agreed to in writing, software 12 | # distributed under the License is distributed on an "AS IS" BASIS, 13 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 14 | # See the License for the specific language governing permissions and 15 | # limitations under the License. 16 | 17 | 18 | """Google Cloud Endpoints module.""" 19 | 20 | # pylint: disable=wildcard-import 21 | from __future__ import absolute_import 22 | 23 | import logging 24 | 25 | from protorpc import message_types 26 | from protorpc import messages 27 | from protorpc import remote 28 | 29 | from .api_config import api, method 30 | from .api_config import AUTH_LEVEL, EMAIL_SCOPE 31 | from .api_config import Issuer, LimitDefinition, Namespace 32 | from .api_exceptions import * 33 | from .apiserving import * 34 | from .constants import API_EXPLORER_CLIENT_ID 35 | from .endpoints_dispatcher import * 36 | from . import message_parser 37 | from .resource_container import ResourceContainer 38 | from .users_id_token import get_current_user, get_verified_jwt, convert_jwks_uri 39 | from .users_id_token import InvalidGetUserCall 40 | from .users_id_token import SKIP_CLIENT_ID_CHECK 41 | 42 | __version__ = '4.8.0' 43 | 44 | _logger = logging.getLogger(__name__) 45 | _logger.setLevel(logging.INFO) 46 | -------------------------------------------------------------------------------- /endpoints/_endpointscfg_setup.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | """Module for setting up App Engine library paths. 15 | 16 | This module searches for the root of the App Engine Python SDK or Google Cloud 17 | SDK and computes a list of library paths and adds them to sys.path. This is 18 | necessary for two reasons: 19 | 20 | 1. The endpointscfg tool imports user code and therefore must be able to 21 | import modules used in the app. 22 | 2. As a consequence of the first item, we must call an App Engine method to 23 | set up service stubs in case an app's initialization code utilizes an App 24 | Engine service. For example, there exists an App Engine version of pytz 25 | which uses memcache and users may use it at the global level because it 26 | seems to be declarative. 27 | """ 28 | import logging 29 | import os 30 | import sys 31 | 32 | _PYTHON_EXTENSIONS_WARNING = """ 33 | Found Cloud SDK, but App Engine Python Extensions are not 34 | installed. If you encounter errors, please run: 35 | $ gcloud components install app-engine-python 36 | """.strip() 37 | 38 | 39 | _IMPORT_ERROR_WARNING = """ 40 | Could not import App Engine Python libraries. If you encounter 41 | errors, please make sure that the SDK binary path is in your PATH environment 42 | variable or that the ENDPOINTS_GAE_SDK variable points to a valid SDK root. 43 | """.strip() 44 | 45 | 46 | _NOT_FOUND_WARNING = """ 47 | Could not find either the Cloud SDK or the App Engine Python SDK. 48 | If you encounter errors, please make sure that the SDK binary path is in your 49 | PATH environment variable or that the ENDPOINTS_GAE_SDK variable points to a 50 | valid SDK root.""".strip() 51 | 52 | 53 | _NO_FIX_SYS_PATH_WARNING = """ 54 | Could not find the fix_sys_path() function in dev_appserver. 55 | If you encounter errors, please make sure that your Google App Engine SDK is 56 | up-to-date.""".strip() 57 | 58 | 59 | def _FindSdkPath(): 60 | environ_sdk = os.environ.get('ENDPOINTS_GAE_SDK') 61 | if environ_sdk: 62 | maybe_cloud_sdk = os.path.join(environ_sdk, 'platform', 'google_appengine') 63 | if os.path.exists(maybe_cloud_sdk): 64 | return maybe_cloud_sdk 65 | return environ_sdk 66 | 67 | for path in os.environ['PATH'].split(os.pathsep): 68 | if os.path.exists(os.path.join(path, 'dev_appserver.py')): 69 | if (path.endswith('bin') and 70 | os.path.exists(os.path.join(path, 'gcloud'))): 71 | # Cloud SDK ships with dev_appserver.py in a bin directory. In the 72 | # root directory, we can find the Python SDK in 73 | # platform/google_appengine provided that it's installed. 74 | sdk_path = os.path.join(os.path.dirname(path), 75 | 'platform', 76 | 'google_appengine') 77 | if not os.path.exists(sdk_path): 78 | logging.warning(_PYTHON_EXTENSIONS_WARNING) 79 | return sdk_path 80 | # App Engine SDK ships withd dev_appserver.py in the root directory. 81 | return path 82 | 83 | 84 | def _SetupPaths(): 85 | """Sets up the sys.path with special directories for endpointscfg.py.""" 86 | sdk_path = _FindSdkPath() 87 | if sdk_path: 88 | sys.path.append(sdk_path) 89 | try: 90 | import dev_appserver # pylint: disable=g-import-not-at-top 91 | if hasattr(dev_appserver, 'fix_sys_path'): 92 | dev_appserver.fix_sys_path() 93 | else: 94 | logging.warning(_NO_FIX_SYS_PATH_WARNING) 95 | except ImportError: 96 | logging.warning(_IMPORT_ERROR_WARNING) 97 | else: 98 | logging.warning(_NOT_FOUND_WARNING) 99 | 100 | # Add the path above this directory, so we can import the endpoints package 101 | # from the user's app code (rather than from another, possibly outdated SDK). 102 | # pylint: disable=g-import-not-at-top 103 | from google.appengine.ext import vendor 104 | vendor.add(os.path.dirname(os.path.dirname(__file__))) 105 | 106 | 107 | _SetupPaths() 108 | -------------------------------------------------------------------------------- /endpoints/api_exceptions.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """A library containing exception types used by Endpoints.""" 16 | 17 | from __future__ import absolute_import 18 | 19 | import httplib 20 | 21 | from . import remote 22 | 23 | 24 | class ServiceException(remote.ApplicationError): 25 | """Base class for request/service exceptions in Endpoints.""" 26 | 27 | def __init__(self, message=None): 28 | super(ServiceException, self).__init__(message, 29 | httplib.responses[self.http_status]) 30 | 31 | 32 | class BadRequestException(ServiceException): 33 | """Bad request exception that is mapped to a 400 response.""" 34 | http_status = httplib.BAD_REQUEST 35 | 36 | 37 | class UnauthorizedException(ServiceException): 38 | """Unauthorized exception that is mapped to a 401 response.""" 39 | http_status = httplib.UNAUTHORIZED 40 | 41 | 42 | class ForbiddenException(ServiceException): 43 | """Forbidden exception that is mapped to a 403 response.""" 44 | http_status = httplib.FORBIDDEN 45 | 46 | 47 | class NotFoundException(ServiceException): 48 | """Not found exception that is mapped to a 404 response.""" 49 | http_status = httplib.NOT_FOUND 50 | 51 | 52 | class ConflictException(ServiceException): 53 | """Conflict exception that is mapped to a 409 response.""" 54 | http_status = httplib.CONFLICT 55 | 56 | 57 | class GoneException(ServiceException): 58 | """Resource Gone exception that is mapped to a 410 response.""" 59 | http_status = httplib.GONE 60 | 61 | 62 | class PreconditionFailedException(ServiceException): 63 | """Precondition Failed exception that is mapped to a 412 response.""" 64 | http_status = httplib.PRECONDITION_FAILED 65 | 66 | 67 | class RequestEntityTooLargeException(ServiceException): 68 | """Request entity too large exception that is mapped to a 413 response.""" 69 | http_status = httplib.REQUEST_ENTITY_TOO_LARGE 70 | 71 | 72 | class InternalServerErrorException(ServiceException): 73 | """Internal server exception that is mapped to a 500 response.""" 74 | http_status = httplib.INTERNAL_SERVER_ERROR 75 | 76 | 77 | class ApiConfigurationError(Exception): 78 | """Exception thrown if there's an error in the configuration/annotations.""" 79 | 80 | 81 | class InvalidNamespaceException(Exception): 82 | """Exception thrown if there's an invalid namespace declaration.""" 83 | 84 | 85 | class InvalidLimitDefinitionException(Exception): 86 | """Exception thrown if there's an invalid rate limit definition.""" 87 | 88 | 89 | class InvalidApiNameException(Exception): 90 | """Exception thrown if the api name does not match the required character set.""" 91 | 92 | 93 | class ToolError(Exception): 94 | """Exception thrown if there's a general error in the endpointscfg.py tool.""" 95 | -------------------------------------------------------------------------------- /endpoints/api_request.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Cloud Endpoints API request-related data and functions.""" 16 | 17 | from __future__ import absolute_import 18 | 19 | # pylint: disable=g-bad-name 20 | import copy 21 | import json 22 | import logging 23 | import urllib 24 | import urlparse 25 | import zlib 26 | 27 | from . import util 28 | 29 | _logger = logging.getLogger(__name__) 30 | 31 | _METHOD_OVERRIDE = 'X-HTTP-METHOD-OVERRIDE' 32 | 33 | 34 | class ApiRequest(object): 35 | """Simple data object representing an API request. 36 | 37 | Parses the request from environment variables into convenient pieces 38 | and stores them as members. 39 | """ 40 | def __init__(self, environ, base_paths=None): 41 | """Constructor. 42 | 43 | Args: 44 | environ: An environ dict for the request as defined in PEP-333. 45 | 46 | Raises: 47 | ValueError: If the path for the request is invalid. 48 | """ 49 | self.headers = util.get_headers_from_environ(environ) 50 | self.http_method = environ['REQUEST_METHOD'] 51 | self.url_scheme = environ['wsgi.url_scheme'] 52 | self.server = environ['SERVER_NAME'] 53 | self.port = environ['SERVER_PORT'] 54 | self.path = environ['PATH_INFO'] 55 | self.request_uri = environ.get('REQUEST_URI') 56 | if self.request_uri is not None and len(self.request_uri) < len(self.path): 57 | self.request_uri = None 58 | self.query = environ.get('QUERY_STRING') 59 | self.body = environ['wsgi.input'].read() 60 | if self.body and self.headers.get('CONTENT-ENCODING') == 'gzip': 61 | # Increasing wbits to 16 + MAX_WBITS is necessary to be able to decode 62 | # gzipped content (as opposed to zlib-encoded content). 63 | # If there's an error in the decompression, it could be due to another 64 | # part of the serving chain that already decompressed it without clearing 65 | # the header. If so, just ignore it and continue. 66 | try: 67 | self.body = zlib.decompress(self.body, 16 + zlib.MAX_WBITS) 68 | except zlib.error: 69 | pass 70 | if _METHOD_OVERRIDE in self.headers: 71 | # the query arguments in the body will be handled by ._process_req_body() 72 | self.http_method = self.headers[_METHOD_OVERRIDE] 73 | del self.headers[_METHOD_OVERRIDE] # wsgiref.headers.Headers doesn't implement .pop() 74 | self.source_ip = environ.get('REMOTE_ADDR') 75 | self.relative_url = self._reconstruct_relative_url(environ) 76 | 77 | if not base_paths: 78 | base_paths = set() 79 | elif isinstance(base_paths, list): 80 | base_paths = set(base_paths) 81 | 82 | # Find a base_path in the path 83 | for base_path in base_paths: 84 | if self.path.startswith(base_path): 85 | self.path = self.path[len(base_path):] 86 | if self.request_uri is not None: 87 | self.request_uri = self.request_uri[len(base_path):] 88 | self.base_path = base_path 89 | break 90 | else: 91 | raise ValueError('Invalid request path: %s' % self.path) 92 | 93 | if self.query: 94 | self.parameters = urlparse.parse_qs(self.query, keep_blank_values=True) 95 | else: 96 | self.parameters = {} 97 | self.body_json = self._process_req_body(self.body) if self.body else {} 98 | self.request_id = None 99 | 100 | # Check if it's a batch request. We'll only handle single-element batch 101 | # requests on the dev server (and we need to handle them because that's 102 | # what RPC and JS calls typically show up as). Pull the request out of the 103 | # list and record the fact that we're processing a batch. 104 | if isinstance(self.body_json, list): 105 | if len(self.body_json) != 1: 106 | _logger.warning('Batch requests with more than 1 element aren\'t ' 107 | 'supported in devappserver2. Only the first element ' 108 | 'will be handled. Found %d elements.', 109 | len(self.body_json)) 110 | else: 111 | _logger.info('Converting batch request to single request.') 112 | self.body_json = self.body_json[0] 113 | self.body = json.dumps(self.body_json) 114 | self._is_batch = True 115 | else: 116 | self._is_batch = False 117 | 118 | def _process_req_body(self, body): 119 | """Process the body of the HTTP request. 120 | 121 | If the body is valid JSON, return the JSON as a dict. 122 | Else, convert the key=value format to a dict and return that. 123 | 124 | Args: 125 | body: The body of the HTTP request. 126 | """ 127 | try: 128 | return json.loads(body) 129 | except ValueError: 130 | return urlparse.parse_qs(body, keep_blank_values=True) 131 | 132 | def _reconstruct_relative_url(self, environ): 133 | """Reconstruct the relative URL of this request. 134 | 135 | This is based on the URL reconstruction code in Python PEP 333: 136 | http://www.python.org/dev/peps/pep-0333/#url-reconstruction. Rebuild the 137 | URL from the pieces available in the environment. 138 | 139 | Args: 140 | environ: An environ dict for the request as defined in PEP-333 141 | 142 | Returns: 143 | The portion of the URL from the request after the server and port. 144 | """ 145 | url = urllib.quote(environ.get('SCRIPT_NAME', '')) 146 | url += urllib.quote(environ.get('PATH_INFO', '')) 147 | if environ.get('QUERY_STRING'): 148 | url += '?' + environ['QUERY_STRING'] 149 | return url 150 | 151 | def reconstruct_hostname(self, port_override=None): 152 | """Reconstruct the hostname of a request. 153 | 154 | This is based on the URL reconstruction code in Python PEP 333: 155 | http://www.python.org/dev/peps/pep-0333/#url-reconstruction. Rebuild the 156 | hostname from the pieces available in the environment. 157 | 158 | Args: 159 | port_override: str, An override for the port on the returned hostname. 160 | 161 | Returns: 162 | The hostname portion of the URL from the request, not including the 163 | URL scheme. 164 | """ 165 | url = self.server 166 | port = port_override or self.port 167 | if port and ((self.url_scheme == 'https' and str(port) != '443') or 168 | (self.url_scheme != 'https' and str(port) != '80')): 169 | url += ':{0}'.format(port) 170 | 171 | return url 172 | 173 | def reconstruct_full_url(self, port_override=None): 174 | """Reconstruct the full URL of a request. 175 | 176 | This is based on the URL reconstruction code in Python PEP 333: 177 | http://www.python.org/dev/peps/pep-0333/#url-reconstruction. Rebuild the 178 | hostname from the pieces available in the environment. 179 | 180 | Args: 181 | port_override: str, An override for the port on the returned full URL. 182 | 183 | Returns: 184 | The full URL from the request, including the URL scheme. 185 | """ 186 | return '{0}://{1}{2}'.format(self.url_scheme, 187 | self.reconstruct_hostname(port_override), 188 | self.relative_url) 189 | 190 | def copy(self): 191 | return copy.deepcopy(self) 192 | 193 | def is_batch(self): 194 | return self._is_batch 195 | -------------------------------------------------------------------------------- /endpoints/constants.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Provide various constants needed by Endpoints Framework. 16 | 17 | Putting them in this file makes it easier to avoid circular imports, 18 | as well as keep from complicating tests due to importing code that 19 | uses App Engine apis. 20 | """ 21 | 22 | from __future__ import absolute_import 23 | 24 | __all__ = [ 25 | 'API_EXPLORER_CLIENT_ID', 26 | ] 27 | 28 | 29 | API_EXPLORER_CLIENT_ID = '292824132082.apps.googleusercontent.com' 30 | -------------------------------------------------------------------------------- /endpoints/directory_list_generator.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """A library for converting service configs to discovery directory lists.""" 16 | 17 | from __future__ import absolute_import 18 | 19 | import collections 20 | import json 21 | import re 22 | import urlparse 23 | 24 | from . import util 25 | 26 | 27 | class DirectoryListGenerator(object): 28 | """Generates a discovery directory list from a ProtoRPC service. 29 | 30 | Example: 31 | 32 | class HelloRequest(messages.Message): 33 | my_name = messages.StringField(1, required=True) 34 | 35 | class HelloResponse(messages.Message): 36 | hello = messages.StringField(1, required=True) 37 | 38 | class HelloService(remote.Service): 39 | 40 | @remote.method(HelloRequest, HelloResponse) 41 | def hello(self, request): 42 | return HelloResponse(hello='Hello there, %s!' % 43 | request.my_name) 44 | 45 | api_config = DirectoryListGenerator().pretty_print_config_to_json( 46 | HelloService) 47 | 48 | The resulting document will be a JSON directory list describing the APIs 49 | implemented by HelloService. 50 | """ 51 | 52 | def __init__(self, request=None): 53 | # The ApiRequest that called this generator 54 | self.__request = request 55 | 56 | def __item_descriptor(self, config): 57 | """Builds an item descriptor for a service configuration. 58 | 59 | Args: 60 | config: A dictionary containing the service configuration to describe. 61 | 62 | Returns: 63 | A dictionary that describes the service configuration. 64 | """ 65 | descriptor = { 66 | 'kind': 'discovery#directoryItem', 67 | 'icons': { 68 | 'x16': 'https://www.gstatic.com/images/branding/product/1x/' 69 | 'googleg_16dp.png', 70 | 'x32': 'https://www.gstatic.com/images/branding/product/1x/' 71 | 'googleg_32dp.png', 72 | }, 73 | 'preferred': True, 74 | } 75 | 76 | description = config.get('description') 77 | root_url = config.get('root') 78 | name = config.get('name') 79 | version = config.get('api_version') 80 | relative_path = '/apis/{0}/{1}/rest'.format(name, version) 81 | 82 | if description: 83 | descriptor['description'] = description 84 | 85 | descriptor['name'] = name 86 | descriptor['version'] = version 87 | descriptor['discoveryLink'] = '.{0}'.format(relative_path) 88 | 89 | root_url_port = urlparse.urlparse(root_url).port 90 | 91 | original_path = self.__request.reconstruct_full_url( 92 | port_override=root_url_port) 93 | descriptor['discoveryRestUrl'] = '{0}/{1}/{2}/rest'.format( 94 | original_path, name, version) 95 | 96 | if name and version: 97 | descriptor['id'] = '{0}:{1}'.format(name, version) 98 | 99 | return descriptor 100 | 101 | def __directory_list_descriptor(self, configs): 102 | """Builds a directory list for an API. 103 | 104 | Args: 105 | configs: List of dicts containing the service configurations to list. 106 | 107 | Returns: 108 | A dictionary that can be deserialized into JSON in discovery list format. 109 | 110 | Raises: 111 | ApiConfigurationError: If there's something wrong with the API 112 | configuration, such as a multiclass API decorated with different API 113 | descriptors (see the docstring for api()), or a repeated method 114 | signature. 115 | """ 116 | descriptor = { 117 | 'kind': 'discovery#directoryList', 118 | 'discoveryVersion': 'v1', 119 | } 120 | 121 | items = [] 122 | for config in configs: 123 | item_descriptor = self.__item_descriptor(config) 124 | if item_descriptor: 125 | items.append(item_descriptor) 126 | 127 | if items: 128 | descriptor['items'] = items 129 | 130 | return descriptor 131 | 132 | def get_directory_list_doc(self, configs): 133 | """JSON dict description of a protorpc.remote.Service in list format. 134 | 135 | Args: 136 | configs: Either a single dict or a list of dicts containing the service 137 | configurations to list. 138 | 139 | Returns: 140 | dict, The directory list document as a JSON dict. 141 | """ 142 | 143 | if not isinstance(configs, (tuple, list)): 144 | configs = [configs] 145 | 146 | util.check_list_type(configs, dict, 'configs', allow_none=False) 147 | 148 | return self.__directory_list_descriptor(configs) 149 | 150 | def pretty_print_config_to_json(self, configs): 151 | """JSON string description of a protorpc.remote.Service in a discovery doc. 152 | 153 | Args: 154 | configs: Either a single dict or a list of dicts containing the service 155 | configurations to list. 156 | 157 | Returns: 158 | string, The directory list document as a JSON string. 159 | """ 160 | descriptor = self.get_directory_list_doc(configs) 161 | return json.dumps(descriptor, sort_keys=True, indent=2, 162 | separators=(',', ': ')) 163 | -------------------------------------------------------------------------------- /endpoints/discovery_service.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Hook into the live Discovery service and get API configuration info.""" 16 | 17 | # pylint: disable=g-bad-name 18 | from __future__ import absolute_import 19 | 20 | import json 21 | import logging 22 | 23 | from . import api_config 24 | from . import directory_list_generator 25 | from . import discovery_generator 26 | from . import util 27 | 28 | _logger = logging.getLogger(__name__) 29 | 30 | 31 | class DiscoveryService(object): 32 | """Implements the local discovery service. 33 | 34 | This has a static minimal version of the discoverable part of the 35 | discovery .api file. 36 | 37 | It only handles returning the discovery doc and directory, and ignores 38 | directory parameters to filter the results. 39 | 40 | The discovery docs/directory are created by calling a Cloud Endpoints 41 | discovery service to generate the discovery docs/directory from an .api 42 | file/set of .api files. 43 | """ 44 | 45 | _GET_REST_API = 'apisdev.getRest' 46 | _GET_RPC_API = 'apisdev.getRpc' 47 | _LIST_API = 'apisdev.list' 48 | API_CONFIG = { 49 | 'name': 'discovery', 50 | 'version': 'v1', 51 | 'api_version': 'v1', 52 | 'path_version': 'v1', 53 | 'methods': { 54 | 'discovery.apis.getRest': { 55 | 'path': 'apis/{api}/{version}/rest', 56 | 'httpMethod': 'GET', 57 | 'rosyMethod': _GET_REST_API, 58 | }, 59 | 'discovery.apis.getRpc': { 60 | 'path': 'apis/{api}/{version}/rpc', 61 | 'httpMethod': 'GET', 62 | 'rosyMethod': _GET_RPC_API, 63 | }, 64 | 'discovery.apis.list': { 65 | 'path': 'apis', 66 | 'httpMethod': 'GET', 67 | 'rosyMethod': _LIST_API, 68 | }, 69 | } 70 | } 71 | 72 | def __init__(self, config_manager, backend): 73 | """Initializes an instance of the DiscoveryService. 74 | 75 | Args: 76 | config_manager: An instance of ApiConfigManager. 77 | backend: An _ApiServer instance for API config generation. 78 | """ 79 | self._config_manager = config_manager 80 | self._backend = backend 81 | 82 | def _send_success_response(self, response, start_response): 83 | """Sends an HTTP 200 json success response. 84 | 85 | This calls start_response and returns the response body. 86 | 87 | Args: 88 | response: A string containing the response body to return. 89 | start_response: A function with semantics defined in PEP-333. 90 | 91 | Returns: 92 | A string, the response body. 93 | """ 94 | headers = [('Content-Type', 'application/json; charset=UTF-8')] 95 | return util.send_wsgi_response('200 OK', headers, response, start_response) 96 | 97 | def _get_rest_doc(self, request, start_response): 98 | """Sends back HTTP response with API directory. 99 | 100 | This calls start_response and returns the response body. It will return 101 | the discovery doc for the requested api/version. 102 | 103 | Args: 104 | request: An ApiRequest, the transformed request sent to the Discovery API. 105 | start_response: A function with semantics defined in PEP-333. 106 | 107 | Returns: 108 | A string, the response body. 109 | """ 110 | api = request.body_json['api'] 111 | version = request.body_json['version'] 112 | 113 | generator = discovery_generator.DiscoveryGenerator(request=request) 114 | services = [s for s in self._backend.api_services if 115 | s.api_info.name == api and s.api_info.api_version == version] 116 | doc = generator.pretty_print_config_to_json(services) 117 | if not doc: 118 | error_msg = ('Failed to convert .api to discovery doc for ' 119 | 'version %s of api %s') % (version, api) 120 | _logger.error('%s', error_msg) 121 | return util.send_wsgi_error_response(error_msg, start_response) 122 | return self._send_success_response(doc, start_response) 123 | 124 | def _generate_api_config_with_root(self, request): 125 | """Generate an API config with a specific root hostname. 126 | 127 | This uses the backend object and the ApiConfigGenerator to create an API 128 | config specific to the hostname of the incoming request. This allows for 129 | flexible API configs for non-standard environments, such as localhost. 130 | 131 | Args: 132 | request: An ApiRequest, the transformed request sent to the Discovery API. 133 | 134 | Returns: 135 | A string representation of the generated API config. 136 | """ 137 | actual_root = self._get_actual_root(request) 138 | generator = api_config.ApiConfigGenerator() 139 | api = request.body_json['api'] 140 | version = request.body_json['version'] 141 | lookup_key = (api, version) 142 | 143 | service_factories = self._backend.api_name_version_map.get(lookup_key) 144 | if not service_factories: 145 | return None 146 | 147 | service_classes = [service_factory.service_class 148 | for service_factory in service_factories] 149 | config_dict = generator.get_config_dict( 150 | service_classes, hostname=actual_root) 151 | 152 | # Save to cache 153 | for config in config_dict.get('items', []): 154 | lookup_key_with_root = ( 155 | config.get('name', ''), config.get('version', ''), actual_root) 156 | self._config_manager.save_config(lookup_key_with_root, config) 157 | 158 | return config_dict 159 | 160 | def _get_actual_root(self, request): 161 | url = request.server 162 | 163 | # Append the port if not the default 164 | if ((request.url_scheme == 'https' and request.port != '443') or 165 | (request.url_scheme != 'https' and request.port != '80')): 166 | url += ':%s' % request.port 167 | 168 | return url 169 | 170 | def _list(self, request, start_response): 171 | """Sends HTTP response containing the API directory. 172 | 173 | This calls start_response and returns the response body. 174 | 175 | Args: 176 | request: An ApiRequest, the transformed request sent to the Discovery API. 177 | start_response: A function with semantics defined in PEP-333. 178 | 179 | Returns: 180 | A string containing the response body. 181 | """ 182 | configs = [] 183 | generator = directory_list_generator.DirectoryListGenerator(request) 184 | for config in self._config_manager.configs.itervalues(): 185 | if config != self.API_CONFIG: 186 | configs.append(config) 187 | directory = generator.pretty_print_config_to_json(configs) 188 | if not directory: 189 | _logger.error('Failed to get API directory') 190 | # By returning a 404, code explorer still works if you select the 191 | # API in the URL 192 | return util.send_wsgi_not_found_response(start_response) 193 | return self._send_success_response(directory, start_response) 194 | 195 | def handle_discovery_request(self, path, request, start_response): 196 | """Returns the result of a discovery service request. 197 | 198 | This calls start_response and returns the response body. 199 | 200 | Args: 201 | path: A string containing the API path (the portion of the path 202 | after /_ah/api/). 203 | request: An ApiRequest, the transformed request sent to the Discovery API. 204 | start_response: A function with semantics defined in PEP-333. 205 | 206 | Returns: 207 | The response body. Or returns False if the request wasn't handled by 208 | DiscoveryService. 209 | """ 210 | if path == self._GET_REST_API: 211 | return self._get_rest_doc(request, start_response) 212 | elif path == self._GET_RPC_API: 213 | error_msg = ('RPC format documents are no longer supported with the ' 214 | 'Endpoints Framework for Python. Please use the REST ' 215 | 'format.') 216 | _logger.error('%s', error_msg) 217 | return util.send_wsgi_error_response(error_msg, start_response) 218 | elif path == self._LIST_API: 219 | return self._list(request, start_response) 220 | return False 221 | -------------------------------------------------------------------------------- /endpoints/endpointscfg.py: -------------------------------------------------------------------------------- 1 | #!/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. 15 | 16 | r"""Wrapper script to set up import paths for endpointscfg. 17 | 18 | The actual implementation is in _endpointscfg_impl, but we have to set 19 | up import paths properly before we can import that module. 20 | 21 | See the docstring for endpoints._endpointscfg_impl for more 22 | information about this script's capabilities. 23 | """ 24 | 25 | import sys 26 | 27 | import _endpointscfg_setup # pylint: disable=unused-import 28 | from endpoints._endpointscfg_impl import main 29 | 30 | if __name__ == '__main__': 31 | main(sys.argv) 32 | -------------------------------------------------------------------------------- /endpoints/errors.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Error handling and exceptions used in the local Cloud Endpoints server.""" 16 | 17 | # pylint: disable=g-bad-name 18 | from __future__ import absolute_import 19 | 20 | import json 21 | import logging 22 | 23 | from . import generated_error_info 24 | 25 | __all__ = ['BackendError', 26 | 'BasicTypeParameterError', 27 | 'EnumRejectionError', 28 | 'InvalidParameterError', 29 | 'RequestError', 30 | 'RequestRejectionError'] 31 | 32 | _logger = logging.getLogger(__name__) 33 | 34 | _INVALID_ENUM_TEMPLATE = 'Invalid string value: %r. Allowed values: %r' 35 | _INVALID_BASIC_PARAM_TEMPLATE = 'Invalid %s value: %r.' 36 | 37 | 38 | class RequestError(Exception): 39 | """Base class for errors that happen while processing a request.""" 40 | 41 | def status_code(self): 42 | """HTTP status code number associated with this error. 43 | 44 | Subclasses must implement this, returning an integer with the status 45 | code number for the error. 46 | 47 | Example: 400 48 | 49 | Raises: 50 | NotImplementedError: Subclasses must override this function. 51 | """ 52 | raise NotImplementedError 53 | 54 | def message(self): 55 | """Text message explaining the error. 56 | 57 | Subclasses must implement this, returning a string that explains the 58 | error. 59 | 60 | Raises: 61 | NotImplementedError: Subclasses must override this function. 62 | """ 63 | raise NotImplementedError 64 | 65 | def reason(self): 66 | """Get the reason for the error. 67 | 68 | Error reason is a custom string in the Cloud Endpoints server. When 69 | possible, this should match the reason that the live server will generate, 70 | based on the error's status code. If this returns None, the error formatter 71 | will attempt to generate a reason from the status code. 72 | 73 | Returns: 74 | None, by default. Subclasses can override this if they have a specific 75 | error reason. 76 | """ 77 | raise NotImplementedError 78 | 79 | def domain(self): 80 | """Get the domain for this error. 81 | 82 | Returns: 83 | The string 'global' by default. Subclasses can override this if they have 84 | a different domain. 85 | """ 86 | return 'global' 87 | 88 | def extra_fields(self): 89 | """Return a dict of extra fields to add to the error response. 90 | 91 | Some errors have additional information. This provides a way for subclasses 92 | to provide that information. 93 | 94 | Returns: 95 | None, by default. Subclasses can return a dict with values to add 96 | to the error response. 97 | """ 98 | return None 99 | 100 | def __format_error(self, error_list_tag): 101 | """Format this error into a JSON response. 102 | 103 | Args: 104 | error_list_tag: A string specifying the name of the tag to use for the 105 | error list. 106 | 107 | Returns: 108 | A dict containing the reformatted JSON error response. 109 | """ 110 | error = {'domain': self.domain(), 111 | 'reason': self.reason(), 112 | 'message': self.message()} 113 | error.update(self.extra_fields() or {}) 114 | return {'error': {error_list_tag: [error], 115 | 'code': self.status_code(), 116 | 'message': self.message()}} 117 | 118 | def rest_error(self): 119 | """Format this error into a response to a REST request. 120 | 121 | Returns: 122 | A string containing the reformatted error response. 123 | """ 124 | error_json = self.__format_error('errors') 125 | return json.dumps(error_json, indent=1, sort_keys=True) 126 | 127 | def rpc_error(self): 128 | """Format this error into a response to a JSON RPC request. 129 | 130 | 131 | Returns: 132 | A dict containing the reformatted JSON error response. 133 | """ 134 | return self.__format_error('data') 135 | 136 | 137 | class RequestRejectionError(RequestError): 138 | """Base class for invalid/rejected requests. 139 | 140 | To be raised when parsing the request values and comparing them against the 141 | generated discovery document. 142 | """ 143 | 144 | def status_code(self): 145 | return 400 146 | 147 | 148 | class InvalidParameterError(RequestRejectionError): 149 | """Base class for invalid parameter errors. 150 | 151 | Child classes only need to implement the message() function. 152 | """ 153 | 154 | def __init__(self, parameter_name, value): 155 | """Constructor for InvalidParameterError. 156 | 157 | Args: 158 | parameter_name: String; the name of the parameter which had a value 159 | rejected. 160 | value: The actual value passed in for the parameter. Usually string. 161 | """ 162 | super(InvalidParameterError, self).__init__() 163 | self.parameter_name = parameter_name 164 | self.value = value 165 | 166 | def reason(self): 167 | """Returns the server's reason for this error. 168 | 169 | Returns: 170 | A string containing a short error reason. 171 | """ 172 | return 'invalidParameter' 173 | 174 | def extra_fields(self): 175 | """Returns extra fields to add to the error response. 176 | 177 | Returns: 178 | A dict containing extra fields to add to the error response. 179 | """ 180 | return {'locationType': 'parameter', 181 | 'location': self.parameter_name} 182 | 183 | 184 | class BasicTypeParameterError(InvalidParameterError): 185 | """Request rejection exception for basic types (int, float).""" 186 | 187 | def __init__(self, parameter_name, value, type_name): 188 | """Constructor for BasicTypeParameterError. 189 | 190 | Args: 191 | parameter_name: String; the name of the parameter which had a value 192 | rejected. 193 | value: The actual value passed in for the enum. Usually string. 194 | type_name: Descriptive name of the data type expected. 195 | """ 196 | super(BasicTypeParameterError, self).__init__(parameter_name, value) 197 | self.type_name = type_name 198 | 199 | def message(self): 200 | """A descriptive message describing the error.""" 201 | return _INVALID_BASIC_PARAM_TEMPLATE % (self.type_name, self.value) 202 | 203 | 204 | class EnumRejectionError(InvalidParameterError): 205 | """Custom request rejection exception for enum values.""" 206 | 207 | def __init__(self, parameter_name, value, allowed_values): 208 | """Constructor for EnumRejectionError. 209 | 210 | Args: 211 | parameter_name: String; the name of the enum parameter which had a value 212 | rejected. 213 | value: The actual value passed in for the enum. Usually string. 214 | allowed_values: List of strings allowed for the enum. 215 | """ 216 | super(EnumRejectionError, self).__init__(parameter_name, value) 217 | self.allowed_values = allowed_values 218 | 219 | def message(self): 220 | """A descriptive message describing the error.""" 221 | return _INVALID_ENUM_TEMPLATE % (self.value, self.allowed_values) 222 | 223 | 224 | class BackendError(RequestError): 225 | """Exception raised when the backend returns an error code.""" 226 | 227 | def __init__(self, body, status): 228 | super(BackendError, self).__init__() 229 | # Convert backend error status to whatever the live server would return. 230 | status_code = self._get_status_code(status) 231 | self._error_info = generated_error_info.get_error_info(status_code) 232 | 233 | try: 234 | error_json = json.loads(body) 235 | self._message = error_json.get('error_message') 236 | except TypeError: 237 | self._message = body 238 | 239 | def _get_status_code(self, http_status): 240 | """Get the HTTP status code from an HTTP status string. 241 | 242 | Args: 243 | http_status: A string containing a HTTP status code and reason. 244 | 245 | Returns: 246 | An integer with the status code number from http_status. 247 | """ 248 | try: 249 | return int(http_status.split(' ', 1)[0]) 250 | except TypeError: 251 | _logger.warning('Unable to find status code in HTTP status %r.', 252 | http_status) 253 | return 500 254 | 255 | def status_code(self): 256 | """Return the HTTP status code number for this error. 257 | 258 | Returns: 259 | An integer containing the status code for this error. 260 | """ 261 | return self._error_info.http_status 262 | 263 | def message(self): 264 | """Return a descriptive message for this error. 265 | 266 | Returns: 267 | A string containing a descriptive message for this error. 268 | """ 269 | return self._message 270 | 271 | def reason(self): 272 | """Return the short reason for this error. 273 | 274 | Returns: 275 | A string with the reason for this error. 276 | """ 277 | return self._error_info.reason 278 | 279 | def domain(self): 280 | """Return the remapped domain for this error. 281 | 282 | Returns: 283 | A string containing the remapped domain for this error. 284 | """ 285 | return self._error_info.domain 286 | -------------------------------------------------------------------------------- /endpoints/generated_error_info.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Automatically generated mapping of error codes.""" 16 | 17 | # pylint: disable=g-bad-name 18 | 19 | from __future__ import absolute_import 20 | 21 | import collections 22 | 23 | _ErrorInfo = collections.namedtuple( 24 | '_ErrorInfo', ['http_status', 'rpc_status', 'reason', 'domain']) 25 | 26 | _UNSUPPORTED_ERROR = _ErrorInfo(404, 27 | 404, 28 | 'unsupportedProtocol', 29 | 'global') 30 | _BACKEND_ERROR = _ErrorInfo(503, 31 | -32099, 32 | 'backendError', 33 | 'global') 34 | _ERROR_MAP = { 35 | 400: _ErrorInfo(400, 400, 'badRequest', 'global'), 36 | 401: _ErrorInfo(401, 401, 'required', 'global'), 37 | 402: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 38 | 403: _ErrorInfo(403, 403, 'forbidden', 'global'), 39 | 404: _ErrorInfo(404, 404, 'notFound', 'global'), 40 | 405: _ErrorInfo(501, 501, 'unsupportedMethod', 'global'), 41 | 406: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 42 | 407: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 43 | 408: _ErrorInfo(503, -32099, 'backendError', 'global'), 44 | 409: _ErrorInfo(409, 409, 'conflict', 'global'), 45 | 410: _ErrorInfo(410, 410, 'deleted', 'global'), 46 | 411: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 47 | 412: _ErrorInfo(412, 412, 'conditionNotMet', 'global'), 48 | 413: _ErrorInfo(413, 413, 'uploadTooLarge', 'global'), 49 | 414: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 50 | 415: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 51 | 416: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 52 | 417: _ErrorInfo(404, 404, 'unsupportedProtocol', 'global'), 53 | } 54 | 55 | 56 | def get_error_info(lily_status): 57 | """Get info that would be returned by the server for this HTTP status. 58 | 59 | Args: 60 | lily_status: An integer containing the HTTP status returned by the SPI. 61 | 62 | Returns: 63 | An _ErrorInfo object containing information that would be returned by the 64 | live server for the provided lily_status. 65 | """ 66 | if lily_status >= 500: 67 | return _BACKEND_ERROR 68 | 69 | return _ERROR_MAP.get(lily_status, _UNSUPPORTED_ERROR) 70 | -------------------------------------------------------------------------------- /endpoints/message_parser.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Describe ProtoRPC Messages in JSON Schema. 16 | 17 | Add protorpc.message subclasses to MessageTypeToJsonSchema and get a JSON 18 | Schema description of all the messages. 19 | """ 20 | 21 | # pylint: disable=g-bad-name 22 | from __future__ import absolute_import 23 | 24 | import re 25 | 26 | from . import message_types 27 | from . import messages 28 | 29 | __all__ = ['MessageTypeToJsonSchema'] 30 | 31 | 32 | class MessageTypeToJsonSchema(object): 33 | """Describe ProtoRPC messages in JSON Schema. 34 | 35 | Add protorpc.message subclasses to MessageTypeToJsonSchema and get a JSON 36 | Schema description of all the messages. MessageTypeToJsonSchema handles 37 | all the types of fields that can appear in a message. 38 | """ 39 | 40 | # Field to schema type and format. If the field maps to tuple, the 41 | # first entry is set as the type, the second the format (or left alone if 42 | # None). If the field maps to a dictionary, we'll grab the value from the 43 | # field's Variant in that dictionary. 44 | # The variant dictionary should include an element that None maps to, 45 | # to fall back on as a default. 46 | __FIELD_TO_SCHEMA_TYPE_MAP = { 47 | messages.IntegerField: {messages.Variant.INT32: ('integer', 'int32'), 48 | messages.Variant.INT64: ('string', 'int64'), 49 | messages.Variant.UINT32: ('integer', 'uint32'), 50 | messages.Variant.UINT64: ('string', 'uint64'), 51 | messages.Variant.SINT32: ('integer', 'int32'), 52 | messages.Variant.SINT64: ('string', 'int64'), 53 | None: ('integer', 'int64')}, 54 | messages.FloatField: {messages.Variant.FLOAT: ('number', 'float'), 55 | messages.Variant.DOUBLE: ('number', 'double'), 56 | None: ('number', 'float')}, 57 | messages.BooleanField: ('boolean', None), 58 | messages.BytesField: ('string', 'byte'), 59 | message_types.DateTimeField: ('string', 'date-time'), 60 | messages.StringField: ('string', None), 61 | messages.MessageField: ('object', None), 62 | messages.EnumField: ('string', None), 63 | } 64 | 65 | __DEFAULT_SCHEMA_TYPE = ('string', None) 66 | 67 | def __init__(self): 68 | # A map of schema ids to schemas. 69 | self.__schemas = {} 70 | 71 | # A map from schema id to non-normalized definition name. 72 | self.__normalized_names = {} 73 | 74 | def add_message(self, message_type): 75 | """Add a new message. 76 | 77 | Args: 78 | message_type: protorpc.message.Message class to be parsed. 79 | 80 | Returns: 81 | string, The JSON Schema id. 82 | 83 | Raises: 84 | KeyError if the Schema id for this message_type would collide with the 85 | Schema id of a different message_type that was already added. 86 | """ 87 | name = self.__normalized_name(message_type) 88 | if name not in self.__schemas: 89 | # Set a placeholder to prevent infinite recursion. 90 | self.__schemas[name] = None 91 | schema = self.__message_to_schema(message_type) 92 | self.__schemas[name] = schema 93 | return name 94 | 95 | def ref_for_message_type(self, message_type): 96 | """Returns the JSON Schema id for the given message. 97 | 98 | Args: 99 | message_type: protorpc.message.Message class to be parsed. 100 | 101 | Returns: 102 | string, The JSON Schema id. 103 | 104 | Raises: 105 | KeyError: if the message hasn't been parsed via add_message(). 106 | """ 107 | name = self.__normalized_name(message_type) 108 | if name not in self.__schemas: 109 | raise KeyError('Message has not been parsed: %s', name) 110 | return name 111 | 112 | def schemas(self): 113 | """Returns the JSON Schema of all the messages. 114 | 115 | Returns: 116 | object: JSON Schema description of all messages. 117 | """ 118 | return self.__schemas.copy() 119 | 120 | def __normalized_name(self, message_type): 121 | """Normalized schema name. 122 | 123 | Generate a normalized schema name, taking the class name and stripping out 124 | everything but alphanumerics, and camel casing the remaining words. 125 | A normalized schema name is a name that matches [a-zA-Z][a-zA-Z0-9]* 126 | 127 | Args: 128 | message_type: protorpc.message.Message class being parsed. 129 | 130 | Returns: 131 | A string, the normalized schema name. 132 | 133 | Raises: 134 | KeyError: A collision was found between normalized names. 135 | """ 136 | # Normalization is applied to match the constraints that Discovery applies 137 | # to Schema names. 138 | name = message_type.definition_name() 139 | 140 | split_name = re.split(r'[^0-9a-zA-Z]', name) 141 | normalized = ''.join( 142 | part[0].upper() + part[1:] for part in split_name if part) 143 | 144 | previous = self.__normalized_names.get(normalized) 145 | if previous: 146 | if previous != name: 147 | raise KeyError('Both %s and %s normalize to the same schema name: %s' % 148 | (name, previous, normalized)) 149 | else: 150 | self.__normalized_names[normalized] = name 151 | 152 | return normalized 153 | 154 | def __message_to_schema(self, message_type): 155 | """Parse a single message into JSON Schema. 156 | 157 | Will recursively descend the message structure 158 | and also parse other messages references via MessageFields. 159 | 160 | Args: 161 | message_type: protorpc.messages.Message class to parse. 162 | 163 | Returns: 164 | An object representation of the schema. 165 | """ 166 | name = self.__normalized_name(message_type) 167 | schema = { 168 | 'id': name, 169 | 'type': 'object', 170 | } 171 | if message_type.__doc__: 172 | schema['description'] = message_type.__doc__ 173 | properties = {} 174 | for field in message_type.all_fields(): 175 | descriptor = {} 176 | # Info about the type of this field. This is either merged with 177 | # the descriptor or it's placed within the descriptor's 'items' 178 | # property, depending on whether this is a repeated field or not. 179 | type_info = {} 180 | 181 | if type(field) == messages.MessageField: 182 | field_type = field.type().__class__ 183 | type_info['$ref'] = self.add_message(field_type) 184 | if field_type.__doc__: 185 | descriptor['description'] = field_type.__doc__ 186 | else: 187 | schema_type = self.__FIELD_TO_SCHEMA_TYPE_MAP.get( 188 | type(field), self.__DEFAULT_SCHEMA_TYPE) 189 | # If the map pointed to a dictionary, check if the field's variant 190 | # is in that dictionary and use the type specified there. 191 | if isinstance(schema_type, dict): 192 | variant_map = schema_type 193 | variant = getattr(field, 'variant', None) 194 | if variant in variant_map: 195 | schema_type = variant_map[variant] 196 | else: 197 | # The variant map needs to specify a default value, mapped by None. 198 | schema_type = variant_map[None] 199 | type_info['type'] = schema_type[0] 200 | if schema_type[1]: 201 | type_info['format'] = schema_type[1] 202 | 203 | if type(field) == messages.EnumField: 204 | sorted_enums = sorted([enum_info for enum_info in field.type], 205 | key=lambda enum_info: enum_info.number) 206 | type_info['enum'] = [enum_info.name for enum_info in sorted_enums] 207 | 208 | if field.required: 209 | descriptor['required'] = True 210 | 211 | if field.default: 212 | if type(field) == messages.EnumField: 213 | descriptor['default'] = str(field.default) 214 | else: 215 | descriptor['default'] = field.default 216 | 217 | if field.repeated: 218 | descriptor['items'] = type_info 219 | descriptor['type'] = 'array' 220 | else: 221 | descriptor.update(type_info) 222 | 223 | properties[field.name] = descriptor 224 | 225 | schema['properties'] = properties 226 | 227 | return schema 228 | -------------------------------------------------------------------------------- /endpoints/parameter_converter.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Helper that converts parameter values to the type expected by the API. 16 | 17 | Parameter values that appear in the URL and the query string are usually 18 | converted to native types before being passed to the backend. This code handles 19 | that conversion and some validation. 20 | """ 21 | 22 | # pylint: disable=g-bad-name 23 | from __future__ import absolute_import 24 | 25 | from . import errors 26 | 27 | __all__ = ['transform_parameter_value'] 28 | 29 | 30 | def _check_enum(parameter_name, value, parameter_config): 31 | """Checks if an enum value is valid. 32 | 33 | This is called by the transform_parameter_value function and shouldn't be 34 | called directly. 35 | 36 | This verifies that the value of an enum parameter is valid. 37 | 38 | Args: 39 | parameter_name: A string containing the name of the parameter, which is 40 | either just a variable name or the name with the index appended. For 41 | example 'var' or 'var[2]'. 42 | value: A string containing the value passed in for the parameter. 43 | parameter_config: The dictionary containing information specific to the 44 | parameter in question. This is retrieved from request.parameters in 45 | the method config. 46 | 47 | Raises: 48 | EnumRejectionError: If the given value is not among the accepted 49 | enum values in the field parameter. 50 | """ 51 | enum_values = [enum['backendValue'] 52 | for enum in parameter_config['enum'].values() 53 | if 'backendValue' in enum] 54 | if value not in enum_values: 55 | raise errors.EnumRejectionError(parameter_name, value, enum_values) 56 | 57 | 58 | def _check_boolean(parameter_name, value, parameter_config): 59 | """Checks if a boolean value is valid. 60 | 61 | This is called by the transform_parameter_value function and shouldn't be 62 | called directly. 63 | 64 | This checks that the string value passed in can be converted to a valid 65 | boolean value. 66 | 67 | Args: 68 | parameter_name: A string containing the name of the parameter, which is 69 | either just a variable name or the name with the index appended. For 70 | example 'var' or 'var[2]'. 71 | value: A string containing the value passed in for the parameter. 72 | parameter_config: The dictionary containing information specific to the 73 | parameter in question. This is retrieved from request.parameters in 74 | the method config. 75 | 76 | Raises: 77 | BasicTypeParameterError: If the given value is not a valid boolean 78 | value. 79 | """ 80 | if parameter_config.get('type') != 'boolean': 81 | return 82 | 83 | if value.lower() not in ('1', 'true', '0', 'false'): 84 | raise errors.BasicTypeParameterError(parameter_name, value, 'boolean') 85 | 86 | 87 | def _convert_boolean(value): 88 | """Convert a string to a boolean value the same way the server does. 89 | 90 | This is called by the transform_parameter_value function and shouldn't be 91 | called directly. 92 | 93 | Args: 94 | value: A string value to be converted to a boolean. 95 | 96 | Returns: 97 | True or False, based on whether the value in the string would be interpreted 98 | as true or false by the server. In the case of an invalid entry, this 99 | returns False. 100 | """ 101 | if value.lower() in ('1', 'true'): 102 | return True 103 | return False 104 | 105 | 106 | # Map to convert parameters from strings to their desired back-end format. 107 | # Anything not listed here will remain a string. Note that the server 108 | # keeps int64 and uint64 as strings when passed to the backend. 109 | # This maps a type name from the .api method configuration to a (validation 110 | # function, conversion function, descriptive type name) tuple. The 111 | # descriptive type name is only used in conversion error messages, and the 112 | # names here are chosen to match the error messages from the server. 113 | # Note that the 'enum' entry is special cased. Enums have 'type': 'string', 114 | # so we have special case code to recognize them and use the 'enum' map 115 | # entry. 116 | _PARAM_CONVERSION_MAP = {'boolean': (_check_boolean, 117 | _convert_boolean, 118 | 'boolean'), 119 | 'int32': (None, int, 'integer'), 120 | 'uint32': (None, int, 'integer'), 121 | 'float': (None, float, 'float'), 122 | 'double': (None, float, 'double'), 123 | 'enum': (_check_enum, None, None)} 124 | 125 | 126 | def _get_parameter_conversion_entry(parameter_config): 127 | """Get information needed to convert the given parameter to its API type. 128 | 129 | Args: 130 | parameter_config: The dictionary containing information specific to the 131 | parameter in question. This is retrieved from request.parameters in the 132 | method config. 133 | 134 | Returns: 135 | The entry from _PARAM_CONVERSION_MAP with functions/information needed to 136 | validate and convert the given parameter from a string to the type expected 137 | by the API. 138 | """ 139 | entry = _PARAM_CONVERSION_MAP.get(parameter_config.get('type')) 140 | 141 | # Special handling for enum parameters. An enum's type is 'string', so we 142 | # need to detect them by the presence of an 'enum' property in their 143 | # configuration. 144 | if entry is None and 'enum' in parameter_config: 145 | entry = _PARAM_CONVERSION_MAP['enum'] 146 | 147 | return entry 148 | 149 | 150 | def transform_parameter_value(parameter_name, value, parameter_config): 151 | """Validates and transforms parameters to the type expected by the API. 152 | 153 | If the value is a list this will recursively call _transform_parameter_value 154 | on the values in the list. Otherwise, it checks all parameter rules for the 155 | the current value and converts its type from a string to whatever format 156 | the API expects. 157 | 158 | In the list case, '[index-of-value]' is appended to the parameter name for 159 | error reporting purposes. 160 | 161 | Args: 162 | parameter_name: A string containing the name of the parameter, which is 163 | either just a variable name or the name with the index appended, in the 164 | recursive case. For example 'var' or 'var[2]'. 165 | value: A string or list of strings containing the value(s) passed in for 166 | the parameter. These are the values from the request, to be validated, 167 | transformed, and passed along to the backend. 168 | parameter_config: The dictionary containing information specific to the 169 | parameter in question. This is retrieved from request.parameters in the 170 | method config. 171 | 172 | Returns: 173 | The converted parameter value(s). Not all types are converted, so this 174 | may be the same string that's passed in. 175 | """ 176 | if isinstance(value, list): 177 | # We're only expecting to handle path and query string parameters here. 178 | # The way path and query string parameters are passed in, they'll likely 179 | # only be single values or singly-nested lists (no lists nested within 180 | # lists). But even if there are nested lists, we'd want to preserve that 181 | # structure. These recursive calls should preserve it and convert all 182 | # parameter values. See the docstring for information about the parameter 183 | # renaming done here. 184 | return [transform_parameter_value('%s[%d]' % (parameter_name, index), 185 | element, parameter_config) 186 | for index, element in enumerate(value)] 187 | 188 | # Validate and convert the parameter value. 189 | entry = _get_parameter_conversion_entry(parameter_config) 190 | if entry: 191 | validation_func, conversion_func, type_name = entry 192 | if validation_func: 193 | validation_func(parameter_name, value, parameter_config) 194 | if conversion_func: 195 | try: 196 | return conversion_func(value) 197 | except ValueError: 198 | raise errors.BasicTypeParameterError(parameter_name, value, type_name) 199 | 200 | return value 201 | -------------------------------------------------------------------------------- /endpoints/protojson.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Endpoints-specific implementation of ProtoRPC's ProtoJson class.""" 16 | from __future__ import absolute_import 17 | 18 | import base64 19 | 20 | from protorpc import protojson 21 | 22 | from . import messages 23 | 24 | # pylint: disable=g-bad-name 25 | 26 | 27 | __all__ = ['EndpointsProtoJson'] 28 | 29 | 30 | class EndpointsProtoJson(protojson.ProtoJson): 31 | """Endpoints-specific implementation of ProtoRPC's ProtoJson class. 32 | 33 | We need to adjust the way some types of data are encoded to ensure they're 34 | consistent with the existing API pipeline. This class adjusts the JSON 35 | encoding as needed. 36 | 37 | This may be used in a multithreaded environment, so take care to ensure 38 | that this class (and its parent, protojson.ProtoJson) remain thread-safe. 39 | """ 40 | 41 | def encode_field(self, field, value): 42 | """Encode a python field value to a JSON value. 43 | 44 | Args: 45 | field: A ProtoRPC field instance. 46 | value: A python value supported by field. 47 | 48 | Returns: 49 | A JSON serializable value appropriate for field. 50 | """ 51 | # Override the handling of 64-bit integers, so they're always encoded 52 | # as strings. 53 | if (isinstance(field, messages.IntegerField) and 54 | field.variant in (messages.Variant.INT64, 55 | messages.Variant.UINT64, 56 | messages.Variant.SINT64)): 57 | if value not in (None, [], ()): 58 | # Convert and replace the value. 59 | if isinstance(value, list): 60 | value = [str(subvalue) for subvalue in value] 61 | else: 62 | value = str(value) 63 | return value 64 | 65 | return super(EndpointsProtoJson, self).encode_field(field, value) 66 | 67 | @staticmethod 68 | def __pad_value(value, pad_len_multiple, pad_char): 69 | """Add padding characters to the value if needed. 70 | 71 | Args: 72 | value: The string value to be padded. 73 | pad_len_multiple: Pad the result so its length is a multiple 74 | of pad_len_multiple. 75 | pad_char: The character to use for padding. 76 | 77 | Returns: 78 | The string value with padding characters added. 79 | """ 80 | assert pad_len_multiple > 0 81 | assert len(pad_char) == 1 82 | padding_length = (pad_len_multiple - 83 | (len(value) % pad_len_multiple)) % pad_len_multiple 84 | return value + pad_char * padding_length 85 | 86 | def decode_field(self, field, value): 87 | """Decode a JSON value to a python value. 88 | 89 | Args: 90 | field: A ProtoRPC field instance. 91 | value: A serialized JSON value. 92 | 93 | Returns: 94 | A Python value compatible with field. 95 | """ 96 | # Override BytesField handling. Client libraries typically use a url-safe 97 | # encoding. b64decode doesn't handle these gracefully. urlsafe_b64decode 98 | # handles both cases safely. Also add padding if the padding is incorrect. 99 | if isinstance(field, messages.BytesField): 100 | try: 101 | # Need to call str(value) because ProtoRPC likes to pass values 102 | # as unicode, and urlsafe_b64decode can only handle bytes. 103 | padded_value = self.__pad_value(str(value), 4, '=') 104 | return base64.urlsafe_b64decode(padded_value) 105 | except (TypeError, UnicodeEncodeError), err: 106 | raise messages.DecodeError('Base64 decoding error: %s' % err) 107 | 108 | return super(EndpointsProtoJson, self).decode_field(field, value) 109 | -------------------------------------------------------------------------------- /endpoints/proxy.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 21 | 26 | 28 | 29 | 30 | 31 | 32 | -------------------------------------------------------------------------------- /endpoints/resource_container.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Module for a class that contains a request body resource and parameters.""" 16 | from __future__ import absolute_import 17 | 18 | from . import message_types 19 | from . import messages 20 | 21 | 22 | class ResourceContainer(object): 23 | """Container for a request body resource combined with parameters. 24 | 25 | Used for API methods which may also have path or query parameters in addition 26 | to a request body. 27 | 28 | Attributes: 29 | body_message_class: A message class to represent a request body. 30 | parameters_message_class: A placeholder message class for request 31 | parameters. 32 | """ 33 | 34 | __remote_info_cache = {} # pylint: disable=g-bad-name 35 | 36 | __combined_message_class = None # pylint: disable=invalid-name 37 | 38 | def __init__(self, _body_message_class=message_types.VoidMessage, **kwargs): 39 | """Constructor for ResourceContainer. 40 | 41 | Stores a request body message class and attempts to create one from the 42 | keyword arguments passed in. 43 | 44 | Args: 45 | _body_message_class: A keyword argument to be treated like a positional 46 | argument. This will not conflict with the potential names of fields 47 | since they can't begin with underscore. We make this a keyword 48 | argument since the default VoidMessage is a very common choice given 49 | the prevalence of GET methods. 50 | **kwargs: Keyword arguments specifying field names (the named arguments) 51 | and instances of ProtoRPC fields as the values. 52 | """ 53 | self.body_message_class = _body_message_class 54 | self.parameters_message_class = type('ParameterContainer', 55 | (messages.Message,), kwargs) 56 | 57 | @property 58 | def combined_message_class(self): 59 | """A ProtoRPC message class with both request and parameters fields. 60 | 61 | Caches the result in a local private variable. Uses _CopyField to create 62 | copies of the fields from the existing request and parameters classes since 63 | those fields are "owned" by the message classes. 64 | 65 | Raises: 66 | TypeError: If a field name is used in both the request message and the 67 | parameters but the two fields do not represent the same type. 68 | 69 | Returns: 70 | Value of combined message class for this property. 71 | """ 72 | if self.__combined_message_class is not None: 73 | return self.__combined_message_class 74 | 75 | fields = {} 76 | # We don't need to preserve field.number since this combined class is only 77 | # used for the protorpc remote.method and is not needed for the API config. 78 | # The only place field.number matters is in parameterOrder, but this is set 79 | # based on container.parameters_message_class which will use the field 80 | # numbers originally passed in. 81 | 82 | # Counter for fields. 83 | field_number = 1 84 | for field in self.body_message_class.all_fields(): 85 | fields[field.name] = _CopyField(field, number=field_number) 86 | field_number += 1 87 | for field in self.parameters_message_class.all_fields(): 88 | if field.name in fields: 89 | if not _CompareFields(field, fields[field.name]): 90 | raise TypeError('Field %r contained in both parameters and request ' 91 | 'body, but the fields differ.' % (field.name,)) 92 | else: 93 | # Skip a field that's already there. 94 | continue 95 | fields[field.name] = _CopyField(field, number=field_number) 96 | field_number += 1 97 | 98 | self.__combined_message_class = type('CombinedContainer', 99 | (messages.Message,), fields) 100 | return self.__combined_message_class 101 | 102 | @classmethod 103 | def add_to_cache(cls, remote_info, container): # pylint: disable=g-bad-name 104 | """Adds a ResourceContainer to a cache tying it to a protorpc method. 105 | 106 | Args: 107 | remote_info: Instance of protorpc.remote._RemoteMethodInfo corresponding 108 | to a method. 109 | container: An instance of ResourceContainer. 110 | 111 | Raises: 112 | TypeError: if the container is not an instance of cls. 113 | KeyError: if the remote method has been reference by a container before. 114 | This created remote method should never occur because a remote method 115 | is created once. 116 | """ 117 | if not isinstance(container, cls): 118 | raise TypeError('%r not an instance of %r, could not be added to cache.' % 119 | (container, cls)) 120 | if remote_info in cls.__remote_info_cache: 121 | raise KeyError('Cache has collision but should not.') 122 | cls.__remote_info_cache[remote_info] = container 123 | 124 | @classmethod 125 | def get_request_message(cls, remote_info): # pylint: disable=g-bad-name 126 | """Gets request message or container from remote info. 127 | 128 | Args: 129 | remote_info: Instance of protorpc.remote._RemoteMethodInfo corresponding 130 | to a method. 131 | 132 | Returns: 133 | Either an instance of the request type from the remote or the 134 | ResourceContainer that was cached with the remote method. 135 | """ 136 | if remote_info in cls.__remote_info_cache: 137 | return cls.__remote_info_cache[remote_info] 138 | else: 139 | return remote_info.request_type() 140 | 141 | 142 | def _GetFieldAttributes(field): 143 | """Decomposes field into the needed arguments to pass to the constructor. 144 | 145 | This can be used to create copies of the field or to compare if two fields 146 | are "equal" (since __eq__ is not implemented on messages.Field). 147 | 148 | Args: 149 | field: A ProtoRPC message field (potentially to be copied). 150 | 151 | Raises: 152 | TypeError: If the field is not an instance of messages.Field. 153 | 154 | Returns: 155 | A pair of relevant arguments to be passed to the constructor for the field 156 | type. The first element is a list of positional arguments for the 157 | constructor and the second is a dictionary of keyword arguments. 158 | """ 159 | if not isinstance(field, messages.Field): 160 | raise TypeError('Field %r to be copied not a ProtoRPC field.' % (field,)) 161 | 162 | positional_args = [] 163 | kwargs = { 164 | 'required': field.required, 165 | 'repeated': field.repeated, 166 | 'variant': field.variant, 167 | 'default': field._Field__default, # pylint: disable=protected-access 168 | } 169 | 170 | if isinstance(field, messages.MessageField): 171 | # Message fields can't have a default 172 | kwargs.pop('default') 173 | if not isinstance(field, message_types.DateTimeField): 174 | positional_args.insert(0, field.message_type) 175 | elif isinstance(field, messages.EnumField): 176 | positional_args.insert(0, field.type) 177 | 178 | return positional_args, kwargs 179 | 180 | 181 | def _CompareFields(field, other_field): 182 | """Checks if two ProtoRPC fields are "equal". 183 | 184 | Compares the arguments, rather than the id of the elements (which is 185 | the default __eq__ behavior) as well as the class of the fields. 186 | 187 | Args: 188 | field: A ProtoRPC message field to be compared. 189 | other_field: A ProtoRPC message field to be compared. 190 | 191 | Returns: 192 | Boolean indicating whether the fields are equal. 193 | """ 194 | field_attrs = _GetFieldAttributes(field) 195 | other_field_attrs = _GetFieldAttributes(other_field) 196 | if field_attrs != other_field_attrs: 197 | return False 198 | return field.__class__ == other_field.__class__ 199 | 200 | 201 | def _CopyField(field, number=None): 202 | """Copies a (potentially) owned ProtoRPC field instance into a new copy. 203 | 204 | Args: 205 | field: A ProtoRPC message field to be copied. 206 | number: An integer for the field to override the number of the field. 207 | Defaults to None. 208 | 209 | Raises: 210 | TypeError: If the field is not an instance of messages.Field. 211 | 212 | Returns: 213 | A copy of the ProtoRPC message field. 214 | """ 215 | positional_args, kwargs = _GetFieldAttributes(field) 216 | number = number or field.number 217 | positional_args.append(number) 218 | return field.__class__(*positional_args, **kwargs) 219 | -------------------------------------------------------------------------------- /endpoints/types.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Provide various utility/container types needed by Endpoints Framework. 16 | 17 | Putting them in this file makes it easier to avoid circular imports, 18 | as well as keep from complicating tests due to importing code that 19 | uses App Engine apis. 20 | """ 21 | 22 | from __future__ import absolute_import 23 | 24 | import attr 25 | 26 | __all__ = [ 27 | 'OAuth2Scope', 'Issuer', 'LimitDefinition', 'Namespace', 28 | ] 29 | 30 | 31 | @attr.s(frozen=True, slots=True) 32 | class OAuth2Scope(object): 33 | scope = attr.ib(validator=attr.validators.instance_of(basestring)) 34 | description = attr.ib(validator=attr.validators.instance_of(basestring)) 35 | 36 | @classmethod 37 | def convert_scope(cls, scope): 38 | "Convert string scopes into OAuth2Scope objects." 39 | if isinstance(scope, cls): 40 | return scope 41 | return cls(scope=scope, description=scope) 42 | 43 | @classmethod 44 | def convert_list(cls, values): 45 | "Convert a list of scopes into a list of OAuth2Scope objects." 46 | if values is not None: 47 | return [cls.convert_scope(value) for value in values] 48 | 49 | Issuer = attr.make_class('Issuer', ['issuer', 'jwks_uri']) 50 | LimitDefinition = attr.make_class('LimitDefinition', ['metric_name', 51 | 'display_name', 52 | 'default_limit']) 53 | Namespace = attr.make_class('Namespace', ['owner_domain', 54 | 'owner_name', 55 | 'package_path']) 56 | -------------------------------------------------------------------------------- /endpoints/util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Helper utilities for the endpoints package.""" 16 | 17 | # pylint: disable=g-bad-name 18 | from __future__ import absolute_import 19 | 20 | import cStringIO 21 | import json 22 | import os 23 | import wsgiref.headers 24 | 25 | from google.appengine.api import app_identity 26 | from google.appengine.api.modules import modules 27 | 28 | 29 | class StartResponseProxy(object): 30 | """Proxy for the typical WSGI start_response object.""" 31 | 32 | def __init__(self): 33 | self.call_context = {} 34 | self.body_buffer = cStringIO.StringIO() 35 | 36 | def __enter__(self): 37 | return self 38 | 39 | def __exit__(self, exc_type, exc_value, traceback): 40 | # Close out the cStringIO.StringIO buffer to prevent memory leakage. 41 | if self.body_buffer: 42 | self.body_buffer.close() 43 | 44 | def Proxy(self, status, headers, exc_info=None): 45 | """Save args, defer start_response until response body is parsed. 46 | 47 | Create output buffer for body to be written into. 48 | Note: this is not quite WSGI compliant: The body should come back as an 49 | iterator returned from calling service_app() but instead, StartResponse 50 | returns a writer that will be later called to output the body. 51 | See google/appengine/ext/webapp/__init__.py::Response.wsgi_write() 52 | write = start_response('%d %s' % self.__status, self.__wsgi_headers) 53 | write(body) 54 | 55 | Args: 56 | status: Http status to be sent with this response 57 | headers: Http headers to be sent with this response 58 | exc_info: Exception info to be displayed for this response 59 | Returns: 60 | callable that takes as an argument the body content 61 | """ 62 | self.call_context['status'] = status 63 | self.call_context['headers'] = headers 64 | self.call_context['exc_info'] = exc_info 65 | 66 | return self.body_buffer.write 67 | 68 | @property 69 | def response_body(self): 70 | return self.body_buffer.getvalue() 71 | 72 | @property 73 | def response_headers(self): 74 | return self.call_context.get('headers') 75 | 76 | @property 77 | def response_status(self): 78 | return self.call_context.get('status') 79 | 80 | @property 81 | def response_exc_info(self): 82 | return self.call_context.get('exc_info') 83 | 84 | 85 | def send_wsgi_not_found_response(start_response, cors_handler=None): 86 | return send_wsgi_response('404 Not Found', [('Content-Type', 'text/plain')], 87 | 'Not Found', start_response, 88 | cors_handler=cors_handler) 89 | 90 | 91 | def send_wsgi_error_response(message, start_response, cors_handler=None): 92 | body = json.dumps({'error': {'message': message}}) 93 | return send_wsgi_response('500', [('Content-Type', 'application/json')], body, 94 | start_response, cors_handler=cors_handler) 95 | 96 | 97 | def send_wsgi_rejected_response(rejection_error, start_response, 98 | cors_handler=None): 99 | body = rejection_error.to_json() 100 | return send_wsgi_response('400', [('Content-Type', 'application/json')], body, 101 | start_response, cors_handler=cors_handler) 102 | 103 | 104 | def send_wsgi_redirect_response(redirect_location, start_response, 105 | cors_handler=None): 106 | return send_wsgi_response('302', [('Location', redirect_location)], '', 107 | start_response, cors_handler=cors_handler) 108 | 109 | 110 | def send_wsgi_no_content_response(start_response, cors_handler=None): 111 | return send_wsgi_response('204 No Content', [], '', start_response, 112 | cors_handler) 113 | 114 | 115 | def send_wsgi_response(status, headers, content, start_response, 116 | cors_handler=None): 117 | """Dump reformatted response to CGI start_response. 118 | 119 | This calls start_response and returns the response body. 120 | 121 | Args: 122 | status: A string containing the HTTP status code to send. 123 | headers: A list of (header, value) tuples, the headers to send in the 124 | response. 125 | content: A string containing the body content to write. 126 | start_response: A function with semantics defined in PEP-333. 127 | cors_handler: A handler to process CORS request headers and update the 128 | headers in the response. Or this can be None, to bypass CORS checks. 129 | 130 | Returns: 131 | A string containing the response body. 132 | """ 133 | if cors_handler: 134 | cors_handler.update_headers(headers) 135 | 136 | # Update content length. 137 | content_len = len(content) if content else 0 138 | headers = [(header, value) for header, value in headers 139 | if header.lower() != 'content-length'] 140 | headers.append(('Content-Length', '%s' % content_len)) 141 | 142 | start_response(status, headers) 143 | return content 144 | 145 | 146 | def get_headers_from_environ(environ): 147 | """Get a wsgiref.headers.Headers object with headers from the environment. 148 | 149 | Headers in environ are prefixed with 'HTTP_', are all uppercase, and have 150 | had dashes replaced with underscores. This strips the HTTP_ prefix and 151 | changes underscores back to dashes before adding them to the returned set 152 | of headers. 153 | 154 | Args: 155 | environ: An environ dict for the request as defined in PEP-333. 156 | 157 | Returns: 158 | A wsgiref.headers.Headers object that's been filled in with any HTTP 159 | headers found in environ. 160 | """ 161 | headers = wsgiref.headers.Headers([]) 162 | for header, value in environ.iteritems(): 163 | if header.startswith('HTTP_'): 164 | headers[header[5:].replace('_', '-')] = value 165 | # Content-Type is special; it does not start with 'HTTP_'. 166 | if 'CONTENT_TYPE' in environ: 167 | headers['CONTENT-TYPE'] = environ['CONTENT_TYPE'] 168 | return headers 169 | 170 | 171 | def put_headers_in_environ(headers, environ): 172 | """Given a list of headers, put them into environ based on PEP-333. 173 | 174 | This converts headers to uppercase, prefixes them with 'HTTP_', and 175 | converts dashes to underscores before adding them to the environ dict. 176 | 177 | Args: 178 | headers: A list of (header, value) tuples. The HTTP headers to add to the 179 | environment. 180 | environ: An environ dict for the request as defined in PEP-333. 181 | """ 182 | for key, value in headers: 183 | environ['HTTP_%s' % key.upper().replace('-', '_')] = value 184 | 185 | 186 | def is_running_on_app_engine(): 187 | return os.environ.get('GAE_MODULE_NAME') is not None 188 | 189 | 190 | def is_running_on_devserver(): 191 | server_software = os.environ.get('SERVER_SOFTWARE', '') 192 | return (server_software.startswith('Development/') and 193 | server_software != 'Development/1.0 (testbed)') 194 | 195 | 196 | def is_running_on_localhost(): 197 | return os.environ.get('SERVER_NAME') == 'localhost' 198 | 199 | 200 | def get_hostname_prefix(): 201 | """Returns the hostname prefix of a running Endpoints service. 202 | 203 | The prefix is the portion of the hostname that comes before the API name. 204 | For example, if a non-default version and a non-default service are in use, 205 | the returned result would be '{VERSION}-dot-{SERVICE}-'. 206 | 207 | Returns: 208 | str, the hostname prefix. 209 | """ 210 | parts = [] 211 | 212 | # Check if this is the default version 213 | version = modules.get_current_version_name() 214 | default_version = modules.get_default_version() 215 | if version != default_version: 216 | parts.append(version) 217 | 218 | # Check if this is the default module 219 | module = modules.get_current_module_name() 220 | if module != 'default': 221 | parts.append(module) 222 | 223 | # If there is anything to prepend, add an extra blank entry for the trailing 224 | # -dot- 225 | if parts: 226 | parts.append('') 227 | 228 | return '-dot-'.join(parts) 229 | 230 | 231 | def get_app_hostname(): 232 | """Return hostname of a running Endpoints service. 233 | 234 | Returns hostname of an running Endpoints API. It can be 1) "localhost:PORT" 235 | if running on development server, or 2) "app_id.appspot.com" if running on 236 | external app engine prod, or "app_id.googleplex.com" if running as Google 237 | first-party Endpoints API, or 4) None if not running on App Engine 238 | (e.g. Tornado Endpoints API). 239 | 240 | Returns: 241 | A string representing the hostname of the service. 242 | """ 243 | if not is_running_on_app_engine() or is_running_on_localhost(): 244 | return None 245 | 246 | app_id = app_identity.get_application_id() 247 | 248 | prefix = get_hostname_prefix() 249 | suffix = 'appspot.com' 250 | 251 | if ':' in app_id: 252 | tokens = app_id.split(':') 253 | api_name = tokens[1] 254 | if tokens[0] == 'google.com': 255 | suffix = 'googleplex.com' 256 | else: 257 | api_name = app_id 258 | 259 | return '{0}{1}.{2}'.format(prefix, api_name, suffix) 260 | 261 | 262 | def check_list_type(objects, allowed_type, name, allow_none=True): 263 | """Verify that objects in list are of the allowed type or raise TypeError. 264 | 265 | Args: 266 | objects: The list of objects to check. 267 | allowed_type: The allowed type of items in 'settings'. 268 | name: Name of the list of objects, added to the exception. 269 | allow_none: If set, None is also allowed. 270 | 271 | Raises: 272 | TypeError: if object is not of the allowed type. 273 | 274 | Returns: 275 | The list of objects, for convenient use in assignment. 276 | """ 277 | if objects is None: 278 | if not allow_none: 279 | raise TypeError('%s is None, which is not allowed.' % name) 280 | return objects 281 | if not isinstance(objects, (tuple, list)): 282 | raise TypeError('%s is not a list.' % name) 283 | if not all(isinstance(i, allowed_type) for i in objects): 284 | type_list = sorted(list(set(type(obj) for obj in objects))) 285 | raise TypeError('%s contains types that don\'t match %s: %s' % 286 | (name, allowed_type.__name__, type_list)) 287 | return objects 288 | 289 | 290 | def snake_case_to_headless_camel_case(snake_string): 291 | """Convert snake_case to headlessCamelCase. 292 | 293 | Args: 294 | snake_string: The string to be converted. 295 | Returns: 296 | The input string converted to headlessCamelCase. 297 | """ 298 | return ''.join([snake_string.split('_')[0]] + 299 | list(sub_string.capitalize() 300 | for sub_string in snake_string.split('_')[1:])) 301 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | attrs==17.4.0 2 | google-endpoints-api-management>=1.10.0 3 | semver==2.7.7 4 | setuptools>=36.2.5 5 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bumpversion] 2 | commit = True 3 | tag = False 4 | current_version = 4.8.0 5 | 6 | [tool:pytest] 7 | usefixtures = appengine_environ 8 | 9 | [bumpversion:file:setup.py] 10 | 11 | [bumpversion:file:endpoints/__init__.py] 12 | 13 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | # -*- coding: utf-8 -*- 3 | 4 | # Copyright 2016 Google Inc. All Rights Reserved. 5 | # 6 | # Licensed under the Apache License, Version 2.0 (the "License"); 7 | # you may not use this file except in compliance with the License. 8 | # You may obtain a copy of the License at 9 | # 10 | # http://www.apache.org/licenses/LICENSE-2.0 11 | # 12 | # Unless required by applicable law or agreed to in writing, software 13 | # distributed under the License is distributed on an "AS IS" BASIS, 14 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 15 | # See the License for the specific language governing permissions and 16 | # limitations under the License. 17 | 18 | from setuptools import setup, find_packages 19 | 20 | install_requires = [ 21 | 'attrs==17.4.0', 22 | 'google-endpoints-api-management>=1.10.0', 23 | 'semver==2.7.7', 24 | 'setuptools>=36.2.5', 25 | ] 26 | 27 | setup( 28 | name='google-endpoints', 29 | version='4.8.0', 30 | description='Google Cloud Endpoints', 31 | long_description=open('README.rst').read(), 32 | author='Google Endpoints Authors', 33 | author_email='googleapis-packages@google.com', 34 | url='https://github.com/cloudendpoints/endpoints-python', 35 | packages=find_packages(exclude=['test', 'test.*']), 36 | package_dir={'google-endpoints': 'endpoints'}, 37 | include_package_data=True, 38 | license='Apache', 39 | classifiers=[ 40 | 'Development Status :: 4 - Beta', 41 | 'Intended Audience :: Developers', 42 | 'License :: OSI Approved :: Apache Software License', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 2', 45 | 'Programming Language :: Python :: 2.7', 46 | 'Programming Language :: Python :: Implementation :: CPython', 47 | ], 48 | scripts=['endpoints/endpointscfg.py'], 49 | tests_require=['mock', 'protobuf', 'protorpc', 'pytest', 'webtest'], 50 | install_requires=install_requires, 51 | ) 52 | -------------------------------------------------------------------------------- /test-requirements.txt: -------------------------------------------------------------------------------- 1 | appengine-sdk>=1.9.36,<2.0 2 | mock>=1.3.0 3 | pytest>=2.8.3 4 | pytest-cov>=1.8.1 5 | pytest-timeout>=1.0.0 6 | webtest>=2.0.23,<3.0 7 | git+git://github.com/inklesspen/protorpc.git@endpoints-dependency#egg=protorpc-0.12.0a0 8 | protobuf>=3.0.0b3 9 | PyYAML==3.12 10 | -------------------------------------------------------------------------------- /test/__init__.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Google Cloud Endpoints test module.""" 16 | -------------------------------------------------------------------------------- /test/conftest.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import os 16 | 17 | import pytest 18 | from mock import patch 19 | 20 | # The environment settings in this section were extracted from the 21 | # google.appengine.ext.testbed library, as extracted from version 22 | # 1.9.61 of the SDK. 23 | 24 | # from google.appengine.ext.testbed 25 | DEFAULT_ENVIRONMENT = { 26 | 'APPENGINE_RUNTIME': 'python27', 27 | 'APPLICATION_ID': 'testbed-test', 28 | 'AUTH_DOMAIN': 'gmail.com', 29 | 'HTTP_HOST': 'testbed.example.com', 30 | 'CURRENT_MODULE_ID': 'default', 31 | 'CURRENT_VERSION_ID': 'testbed-version', 32 | 'REQUEST_ID_HASH': 'testbed-request-id-hash', 33 | 'REQUEST_LOG_ID': '7357B3D7091D', 34 | 'SERVER_NAME': 'testbed.example.com', 35 | 'SERVER_SOFTWARE': 'Development/1.0 (testbed)', 36 | 'SERVER_PORT': '80', 37 | 'USER_EMAIL': '', 38 | 'USER_ID': '', 39 | } 40 | 41 | # endpoints updated value 42 | DEFAULT_ENVIRONMENT['CURRENT_VERSION_ID'] = '1.0' 43 | 44 | 45 | def environ_patcher(**kwargs): 46 | replaces = dict(DEFAULT_ENVIRONMENT, **kwargs) 47 | return patch.dict(os.environ, replaces) 48 | 49 | 50 | @pytest.fixture() 51 | def appengine_environ(): 52 | """Patch os.environ with appengine values.""" 53 | patcher = environ_patcher() 54 | with patcher: 55 | yield 56 | -------------------------------------------------------------------------------- /test/directory_list_generator_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for endpoints.directory_list_generator.""" 16 | 17 | import json 18 | import os 19 | import unittest 20 | 21 | import test_util 22 | from endpoints import api_config 23 | from endpoints import api_request 24 | from endpoints import apiserving 25 | from endpoints import directory_list_generator 26 | from endpoints import message_types 27 | from endpoints import messages 28 | from endpoints import remote 29 | 30 | _GET_REST_API = 'apisdev.getRest' 31 | _GET_RPC_API = 'apisdev.getRpc' 32 | _LIST_API = 'apisdev.list' 33 | API_CONFIG = { 34 | 'name': 'discovery', 35 | 'version': 'v1', 36 | 'api_version': 'v1', 37 | 'path_version': 'v1', 38 | 'methods': { 39 | 'discovery.apis.getRest': { 40 | 'path': 'apis/{api}/{version}/rest', 41 | 'httpMethod': 'GET', 42 | 'rosyMethod': _GET_REST_API, 43 | }, 44 | 'discovery.apis.getRpc': { 45 | 'path': 'apis/{api}/{version}/rpc', 46 | 'httpMethod': 'GET', 47 | 'rosyMethod': _GET_RPC_API, 48 | }, 49 | 'discovery.apis.list': { 50 | 'path': 'apis', 51 | 'httpMethod': 'GET', 52 | 'rosyMethod': _LIST_API, 53 | }, 54 | } 55 | } 56 | 57 | 58 | class BaseDirectoryListGeneratorTest(unittest.TestCase): 59 | 60 | @classmethod 61 | def setUpClass(cls): 62 | cls.maxDiff = None 63 | 64 | def _def_path(self, path): 65 | return '#/definitions/' + path 66 | 67 | 68 | class DirectoryListGeneratorTest(BaseDirectoryListGeneratorTest): 69 | 70 | def testBasic(self): 71 | 72 | @api_config.api(name='root', hostname='example.appspot.com', version='v1', 73 | description='This is an API') 74 | class RootService(remote.Service): 75 | """Describes RootService.""" 76 | 77 | @api_config.method(message_types.VoidMessage, message_types.VoidMessage, 78 | path='foo', http_method='GET', name='foo') 79 | def foo(self, unused_request): 80 | """Blank endpoint.""" 81 | return message_types.VoidMessage() 82 | 83 | @api_config.api(name='myapi', hostname='example.appspot.com', version='v1', 84 | description='This is my API') 85 | class MyService(remote.Service): 86 | """Describes MyService.""" 87 | 88 | @api_config.method(message_types.VoidMessage, message_types.VoidMessage, 89 | path='foo', http_method='GET', name='foo') 90 | def foo(self, unused_request): 91 | """Blank endpoint.""" 92 | return message_types.VoidMessage() 93 | 94 | api_server = apiserving.api_server([RootService, MyService]) 95 | api_config_response = api_server.get_api_configs() 96 | if api_config_response: 97 | api_server.config_manager.process_api_config_response(api_config_response) 98 | else: 99 | raise Exception('Could not process API config response') 100 | 101 | configs = [] 102 | for config in api_server.config_manager.configs.itervalues(): 103 | if config != API_CONFIG: 104 | configs.append(config) 105 | 106 | environ = test_util.create_fake_environ( 107 | 'https', 'example.appspot.com', path='/_ah/api/discovery/v1/apis') 108 | request = api_request.ApiRequest(environ, base_paths=['/_ah/api']) 109 | generator = directory_list_generator.DirectoryListGenerator(request) 110 | 111 | directory = json.loads(generator.pretty_print_config_to_json(configs)) 112 | 113 | try: 114 | pwd = os.path.dirname(os.path.realpath(__file__)) 115 | test_file = os.path.join(pwd, 'testdata', 'directory_list', 'basic.json') 116 | with open(test_file) as f: 117 | expected_directory = json.loads(f.read()) 118 | except IOError as e: 119 | print 'Could not find expected output file ' + test_file 120 | raise e 121 | 122 | test_util.AssertDictEqual(expected_directory, directory, self) 123 | 124 | 125 | class DevServerDirectoryListGeneratorTest(BaseDirectoryListGeneratorTest, 126 | test_util.DevServerTest): 127 | 128 | def setUp(self): 129 | super(DevServerDirectoryListGeneratorTest, self).setUp() 130 | self.env_key, self.orig_env_value = (test_util.DevServerTest. 131 | setUpDevServerEnv()) 132 | self.addCleanup(test_util.DevServerTest.restoreEnv, 133 | self.env_key, self.orig_env_value) 134 | 135 | def testLocalhost(self): 136 | 137 | @api_config.api(name='root', hostname='localhost:8080', version='v1', 138 | description='This is an API') 139 | class RootService(remote.Service): 140 | """Describes RootService.""" 141 | 142 | @api_config.method(message_types.VoidMessage, message_types.VoidMessage, 143 | path='foo', http_method='GET', name='foo') 144 | def foo(self, unused_request): 145 | """Blank endpoint.""" 146 | return message_types.VoidMessage() 147 | 148 | @api_config.api(name='myapi', hostname='localhost:8081', version='v1', 149 | description='This is my API') 150 | class MyService(remote.Service): 151 | """Describes MyService.""" 152 | 153 | @api_config.method(message_types.VoidMessage, message_types.VoidMessage, 154 | path='foo', http_method='GET', name='foo') 155 | def foo(self, unused_request): 156 | """Blank endpoint.""" 157 | return message_types.VoidMessage() 158 | 159 | api_server = apiserving.api_server([RootService, MyService]) 160 | api_config_response = api_server.get_api_configs() 161 | if api_config_response: 162 | api_server.config_manager.process_api_config_response(api_config_response) 163 | else: 164 | raise Exception('Could not process API config response') 165 | 166 | configs = [] 167 | for config in api_server.config_manager.configs.itervalues(): 168 | if config != API_CONFIG: 169 | configs.append(config) 170 | 171 | environ = test_util.create_fake_environ( 172 | 'http', 'localhost', path='/_ah/api/discovery/v1/apis') 173 | request = api_request.ApiRequest(environ, base_paths=['/_ah/api']) 174 | generator = directory_list_generator.DirectoryListGenerator(request) 175 | 176 | directory = json.loads(generator.pretty_print_config_to_json(configs)) 177 | 178 | try: 179 | pwd = os.path.dirname(os.path.realpath(__file__)) 180 | test_file = os.path.join(pwd, 'testdata', 'directory_list', 181 | 'localhost.json') 182 | with open(test_file) as f: 183 | expected_directory = json.loads(f.read()) 184 | except IOError as e: 185 | print 'Could not find expected output file ' + test_file 186 | raise e 187 | 188 | test_util.AssertDictEqual(expected_directory, directory, self) 189 | 190 | 191 | if __name__ == '__main__': 192 | unittest.main() 193 | -------------------------------------------------------------------------------- /test/discovery_document_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Test various discovery docs""" 16 | 17 | import json 18 | import os.path 19 | import urllib 20 | 21 | import endpoints 22 | import pytest 23 | import webtest 24 | from endpoints import discovery_generator 25 | from endpoints import message_types 26 | from endpoints import messages 27 | from endpoints import remote 28 | 29 | package = 'DiscoveryDocumentTest' 30 | 31 | 32 | def make_collection(cls): 33 | return type( 34 | 'Collection_{}'.format(cls.__name__), 35 | (messages.Message,), 36 | { 37 | 'items': messages.MessageField(cls, 1, repeated=True), 38 | 'nextPageToken': messages.StringField(2) 39 | }) 40 | 41 | def load_expected_document(filename): 42 | try: 43 | pwd = os.path.dirname(os.path.realpath(__file__)) 44 | test_file = os.path.join(pwd, 'testdata', 'discovery', filename) 45 | with open(test_file) as f: 46 | return json.loads(f.read()) 47 | except IOError as e: 48 | print 'Could not find expected output file ' + test_file 49 | raise e 50 | 51 | 52 | class Foo(messages.Message): 53 | name = messages.StringField(1) 54 | value = messages.IntegerField(2, variant=messages.Variant.INT32) 55 | 56 | FooCollection = make_collection(Foo) 57 | FooResource = endpoints.ResourceContainer( 58 | Foo, 59 | id=messages.StringField(1, required=True), 60 | ) 61 | FooIdResource = endpoints.ResourceContainer( 62 | message_types.VoidMessage, 63 | id=messages.StringField(1, required=True), 64 | ) 65 | FooNResource = endpoints.ResourceContainer( 66 | message_types.VoidMessage, 67 | n = messages.IntegerField(1, required=True, variant=messages.Variant.INT32), 68 | ) 69 | 70 | @endpoints.api( 71 | name='foo', version='v1', audiences=['audiences'], 72 | title='The Foo API', description='Just Foo Things', 73 | documentation='https://example.com', canonical_name='CanonicalName') 74 | class FooEndpoint(remote.Service): 75 | @endpoints.method(FooResource, Foo, name='foo.create', path='foos/{id}', http_method='PUT') 76 | def createFoo(self, request): 77 | pass 78 | @endpoints.method(FooIdResource, Foo, name='foo.get', path='foos/{id}', http_method='GET') 79 | def getFoo(self, request): 80 | pass 81 | @endpoints.method(FooResource, Foo, name='foo.update', path='foos/{id}', http_method='POST') 82 | def updateFoo(self, request): 83 | pass 84 | @endpoints.method(FooIdResource, Foo, name='foo.delete', path='foos/{id}', http_method='DELETE') 85 | def deleteFoo(self, request): 86 | pass 87 | @endpoints.method(FooNResource, FooCollection, name='foo.list', path='foos', http_method='GET') 88 | def listFoos(self, request): 89 | pass 90 | @endpoints.method(message_types.VoidMessage, FooCollection, name='toplevel', path='foos', http_method='POST') 91 | def toplevel(self, request): 92 | pass 93 | 94 | 95 | class Bar(messages.Message): 96 | name = messages.StringField(1, default='Jimothy') 97 | value = messages.IntegerField(2, default=42, variant=messages.Variant.INT32) 98 | active = messages.BooleanField(3, default=True) 99 | 100 | BarCollection = make_collection(Bar) 101 | BarResource = endpoints.ResourceContainer( 102 | Bar, 103 | id=messages.StringField(1, required=True), 104 | ) 105 | BarIdResource = endpoints.ResourceContainer( 106 | message_types.VoidMessage, 107 | id=messages.StringField(1, required=True), 108 | ) 109 | BarNResource = endpoints.ResourceContainer( 110 | message_types.VoidMessage, 111 | n = messages.IntegerField(1, required=True, variant=messages.Variant.INT32), 112 | ) 113 | 114 | @endpoints.api(name='bar', version='v1') 115 | class BarEndpoint(remote.Service): 116 | @endpoints.method(BarResource, Bar, name='bar.create', path='bars/{id}', http_method='PUT') 117 | def createBar(self, request): 118 | pass 119 | @endpoints.method(BarIdResource, Bar, name='bar.get', path='bars/{id}', http_method='GET') 120 | def getBar(self, request): 121 | pass 122 | @endpoints.method(BarResource, Bar, name='bar.update', path='bars/{id}', http_method='POST') 123 | def updateBar(self, request): 124 | pass 125 | @endpoints.method(BarIdResource, Bar, name='bar.delete', path='bars/{id}', http_method='DELETE') 126 | def deleteBar(self, request): 127 | pass 128 | @endpoints.method(BarNResource, BarCollection, name='bar.list', path='bars', http_method='GET') 129 | def listBars(self, request): 130 | pass 131 | 132 | 133 | @endpoints.api(name='multipleparam', version='v1') 134 | class MultipleParameterEndpoint(remote.Service): 135 | @endpoints.method(endpoints.ResourceContainer( 136 | message_types.VoidMessage, 137 | parent = messages.StringField(1, required=True), 138 | query = messages.StringField(2, required=False), 139 | child = messages.StringField(3, required=True), 140 | queryb = messages.StringField(4, required=True), 141 | querya = messages.StringField(5, required=True), 142 | allow = messages.BooleanField(6, default=True), 143 | ), message_types.VoidMessage, name='param', path='param/{parent}/{child}') 144 | def param(self, request): 145 | pass 146 | 147 | @pytest.mark.parametrize('endpoint, json_filename', [ 148 | (FooEndpoint, 'foo_endpoint.json'), 149 | (BarEndpoint, 'bar_endpoint.json'), 150 | (MultipleParameterEndpoint, 'multiple_parameter_endpoint.json'), 151 | ]) 152 | def test_discovery(endpoint, json_filename): 153 | generator = discovery_generator.DiscoveryGenerator() 154 | # JSON roundtrip so we get consistent string types 155 | actual = json.loads(generator.pretty_print_config_to_json( 156 | [endpoint], hostname='discovery-test.appspot.com')) 157 | expected = load_expected_document(json_filename) 158 | assert actual == expected 159 | -------------------------------------------------------------------------------- /test/discovery_service_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for discovery_service.""" 16 | 17 | import os 18 | import unittest 19 | 20 | import test_util 21 | import webtest 22 | from endpoints import api_config 23 | from endpoints import api_config_manager 24 | from endpoints import apiserving 25 | from endpoints import discovery_service 26 | from endpoints import message_types 27 | from endpoints import messages 28 | from endpoints import remote 29 | 30 | 31 | @api_config.api('aservice', 'v3', hostname='aservice.appspot.com', 32 | description='A Service API') 33 | class AService(remote.Service): 34 | 35 | @api_config.method(path='noop') 36 | def Noop(self, unused_request): 37 | return message_types.VoidMessage() 38 | 39 | 40 | class DiscoveryServiceTest(unittest.TestCase): 41 | 42 | class FakeRequest(object): 43 | 44 | def __init__(self, server=None, port=None, url_scheme=None, api=None, 45 | version=None): 46 | self.server = server 47 | self.port = port 48 | self.url_scheme = url_scheme 49 | self.body_json = {'api': api, 'version': version} 50 | 51 | def setUp(self): 52 | """Make ApiConfigManager with a few helpful fakes.""" 53 | self.backend = self._create_wsgi_application() 54 | self.config_manager = api_config_manager.ApiConfigManager() 55 | self.discovery = discovery_service.DiscoveryService( 56 | self.config_manager, self.backend) 57 | 58 | def _create_wsgi_application(self): 59 | return apiserving._ApiServer([AService], registry_path='/my_registry') 60 | 61 | def _check_api_config(self, expected_base_url, server, port, url_scheme, api, 62 | version): 63 | request = DiscoveryServiceTest.FakeRequest( 64 | server=server, port=port, url_scheme=url_scheme, api=api, 65 | version=version) 66 | config_dict = self.discovery._generate_api_config_with_root(request) 67 | 68 | # Check bns entry 69 | adapter = config_dict.get('adapter') 70 | self.assertIsNotNone(adapter) 71 | self.assertEqual(expected_base_url, adapter.get('bns')) 72 | 73 | # Check root 74 | self.assertEqual(expected_base_url, config_dict.get('root')) 75 | 76 | 77 | class ProdDiscoveryServiceTest(DiscoveryServiceTest): 78 | 79 | def testGenerateApiConfigWithRoot(self): 80 | server = 'test.appspot.com' 81 | port = '12345' 82 | url_scheme = 'https' 83 | api = 'aservice' 84 | version = 'v3' 85 | expected_base_url = '{0}://{1}:{2}/_ah/api'.format(url_scheme, server, port) 86 | 87 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 88 | version) 89 | 90 | def testGenerateApiConfigWithRootLocalhost(self): 91 | server = 'localhost' 92 | port = '12345' 93 | url_scheme = 'http' 94 | api = 'aservice' 95 | version = 'v3' 96 | expected_base_url = '{0}://{1}:{2}/_ah/api'.format(url_scheme, server, port) 97 | 98 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 99 | version) 100 | 101 | def testGenerateApiConfigLocalhostDefaultHttpPort(self): 102 | server = 'localhost' 103 | port = '80' 104 | url_scheme = 'http' 105 | api = 'aservice' 106 | version = 'v3' 107 | expected_base_url = '{0}://{1}/_ah/api'.format(url_scheme, server) 108 | 109 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 110 | version) 111 | 112 | def testGenerateApiConfigWithRootDefaultHttpsPort(self): 113 | server = 'test.appspot.com' 114 | port = '443' 115 | url_scheme = 'https' 116 | api = 'aservice' 117 | version = 'v3' 118 | expected_base_url = '{0}://{1}/_ah/api'.format(url_scheme, server) 119 | 120 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 121 | version) 122 | 123 | 124 | class DevServerDiscoveryServiceTest(DiscoveryServiceTest, 125 | test_util.DevServerTest): 126 | 127 | def setUp(self): 128 | super(DevServerDiscoveryServiceTest, self).setUp() 129 | self.env_key, self.orig_env_value = (test_util.DevServerTest. 130 | setUpDevServerEnv()) 131 | self.addCleanup(test_util.DevServerTest.restoreEnv, 132 | self.env_key, self.orig_env_value) 133 | 134 | def testGenerateApiConfigWithRootDefaultHttpPort(self): 135 | server = 'test.appspot.com' 136 | port = '80' 137 | url_scheme = 'http' 138 | api = 'aservice' 139 | version = 'v3' 140 | expected_base_url = '{0}://{1}/_ah/api'.format(url_scheme, server) 141 | 142 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 143 | version) 144 | 145 | def testGenerateApiConfigLocalhostDefaultHttpPort(self): 146 | server = 'localhost' 147 | port = '80' 148 | url_scheme = 'http' 149 | api = 'aservice' 150 | version = 'v3' 151 | expected_base_url = '{0}://{1}/_ah/api'.format(url_scheme, server) 152 | 153 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 154 | version) 155 | 156 | def testGenerateApiConfigHTTPS(self): 157 | server = 'test.appspot.com' 158 | port = '443' 159 | url_scheme = 'http' # Should still be 'http' because we're using devserver 160 | api = 'aservice' 161 | version = 'v3' 162 | expected_base_url = '{0}://{1}:{2}/_ah/api'.format(url_scheme, server, port) 163 | 164 | self._check_api_config(expected_base_url, server, port, url_scheme, api, 165 | version) 166 | 167 | 168 | class Airport(messages.Message): 169 | iata = messages.StringField(1, required=True) 170 | name = messages.StringField(2, required=True) 171 | 172 | class AirportList(messages.Message): 173 | airports = messages.MessageField(Airport, 1, repeated=True) 174 | 175 | @api_config.api(name='iata', version='v1') 176 | class V1Service(remote.Service): 177 | @api_config.method( 178 | message_types.VoidMessage, 179 | AirportList, 180 | path='airports', 181 | http_method='GET', 182 | name='list_airports') 183 | def list_airports(self, request): 184 | return AirportList(airports=[ 185 | Airport(iata=u'DEN', name=u'Denver International Airport'), 186 | Airport(iata=u'SEA', name=u'Seattle Tacoma International Airport'), 187 | ]) 188 | 189 | @api_config.api(name='iata', version='v2') 190 | class V2Service(remote.Service): 191 | @api_config.method( 192 | message_types.VoidMessage, 193 | AirportList, 194 | path='airports', 195 | http_method='GET', 196 | name='list_airports') 197 | def list_airports(self, request): 198 | return AirportList(airports=[ 199 | Airport(iata=u'DEN', name=u'Denver International Airport'), 200 | Airport(iata=u'JFK', name=u'John F Kennedy International Airport'), 201 | Airport(iata=u'SEA', name=u'Seattle Tacoma International Airport'), 202 | ]) 203 | 204 | class DiscoveryServiceVersionTest(unittest.TestCase): 205 | def setUp(self): 206 | api = apiserving.api_server([V1Service, V2Service]) 207 | self.app = webtest.TestApp(api) 208 | 209 | def testListApis(self): 210 | resp = self.app.get('http://localhost/_ah/api/discovery/v1/apis') 211 | items = resp.json['items'] 212 | self.assertItemsEqual( 213 | (i['id'] for i in items), [u'iata:v1', u'iata:v2']) 214 | self.assertItemsEqual( 215 | (i['discoveryLink'] for i in items), 216 | [u'./apis/iata/v1/rest', u'./apis/iata/v2/rest']) 217 | 218 | def testGetApis(self): 219 | for version in ['v1', 'v2']: 220 | resp = self.app.get( 221 | 'http://localhost/_ah/api/discovery/v1/apis/iata/{}/rest'.format(version)) 222 | self.assertEqual(resp.json['version'], version) 223 | self.assertItemsEqual(resp.json['methods'].keys(), [u'list_airports']) 224 | 225 | if __name__ == '__main__': 226 | unittest.main() 227 | -------------------------------------------------------------------------------- /test/endpoints_dispatcher_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for endpoints.endpoints_dispatcher.""" 16 | 17 | import unittest 18 | 19 | from endpoints import api_config 20 | from endpoints import apiserving 21 | from endpoints import endpoints_dispatcher 22 | from endpoints import remote 23 | from webtest import TestApp 24 | 25 | 26 | @api_config.api('aservice', 'v1', hostname='aservice.appspot.com', 27 | description='A Service API', base_path='/anapi/') 28 | class AService(remote.Service): 29 | 30 | @api_config.method(path='noop') 31 | def Noop(self, unused_request): 32 | return message_types.VoidMessage() 33 | 34 | 35 | class EndpointsDispatcherBaseTest(unittest.TestCase): 36 | 37 | def setUp(self): 38 | self.dispatcher = endpoints_dispatcher.EndpointsDispatcherMiddleware( 39 | apiserving._ApiServer([AService])) 40 | 41 | 42 | class EndpointsDispatcherGetExplorerUrlTest(EndpointsDispatcherBaseTest): 43 | 44 | def _check_explorer_url(self, server, port, base_url, expected): 45 | actual = self.dispatcher._get_explorer_redirect_url( 46 | server, port, base_url) 47 | self.assertEqual(actual, expected) 48 | 49 | def testGetExplorerUrl(self): 50 | self._check_explorer_url( 51 | 'localhost', 8080, '_ah/api', 52 | 'https://apis-explorer.appspot.com/apis-explorer/' 53 | '?base=http://localhost:8080/_ah/api') 54 | 55 | def testGetExplorerUrlExplicitHttpPort(self): 56 | self._check_explorer_url( 57 | 'localhost', 80, '_ah/api', 58 | 'https://apis-explorer.appspot.com/apis-explorer/' 59 | '?base=http://localhost/_ah/api') 60 | 61 | def testGetExplorerUrlExplicitHttpsPort(self): 62 | self._check_explorer_url( 63 | 'testapp.appspot.com', 443, '_ah/api', 64 | 'https://apis-explorer.appspot.com/apis-explorer/' 65 | '?base=https://testapp.appspot.com/_ah/api') 66 | 67 | class EndpointsDispatcherGetProxyHtmlTest(EndpointsDispatcherBaseTest): 68 | def testGetProxyHtml(self): 69 | app = TestApp(self.dispatcher) 70 | resp = app.get('/anapi/static/proxy.html') 71 | assert '/_ah/api' not in resp.body 72 | assert '.init()' in resp.body 73 | 74 | def testGetProxyHtmlBadUrl(self): 75 | app = TestApp(self.dispatcher) 76 | resp = app.get('/anapi/static/missing.html', status=404) 77 | -------------------------------------------------------------------------------- /test/integration_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests against fully-constructed apps""" 16 | 17 | import urllib 18 | 19 | import endpoints 20 | import pytest 21 | import webtest 22 | from endpoints import message_types 23 | from endpoints import messages 24 | from endpoints import remote 25 | 26 | 27 | class FileResponse(messages.Message): 28 | path = messages.StringField(1) 29 | payload = messages.StringField(2) 30 | 31 | FILE_RESOURCE = endpoints.ResourceContainer( 32 | message_types.VoidMessage, 33 | path=messages.StringField(1) 34 | ) 35 | 36 | FILES = { 37 | u'/': u'4aa7fda4-8853-4946-946e-aab5dada1152', 38 | u'foo': u'719eeb0a-de5e-4f17-8ab0-1567974d75c1', 39 | u'foo/bar': u'da6e0dfe-8c74-4a46-9696-b08b58e03e79', 40 | u'foo/bar/baz/': u'2543e3cf-0ae1-4278-9a76-8d1e2ee90de7', 41 | u'/hello': u'80e3d7ee-d289-4aa7-b0ef-eb3a8232f33f', 42 | } 43 | 44 | def _quote_slash(part): 45 | return urllib.quote(part, safe='') 46 | 47 | def _make_app(api_use, method_use): 48 | @endpoints.api(name='filefetcher', version='1.0.0', use_request_uri=api_use) 49 | class FileFetcherApi(remote.Service): 50 | @endpoints.method(FILE_RESOURCE, FileResponse, path='get_file/{path}', 51 | http_method='GET', name='get_file', use_request_uri=method_use) 52 | def get_file(self, request): 53 | if request.path not in FILES: 54 | raise endpoints.NotFoundException() 55 | val = FileResponse(path=request.path, payload=FILES[request.path]) 56 | return val 57 | return webtest.TestApp(endpoints.api_server([FileFetcherApi]), lint=False) 58 | 59 | def _make_request(app, url, expect_status): 60 | kwargs = {} 61 | if expect_status: 62 | kwargs['status'] = expect_status 63 | return app.get(url, extra_environ={'REQUEST_URI': url}, **kwargs) 64 | 65 | 66 | @pytest.mark.parametrize('api_use,method_use,expect_404', [ 67 | (True, True, False), 68 | (True, False, True), 69 | (False, True, False), 70 | (False, False, True), 71 | ]) 72 | class TestSlashVariable(object): 73 | def test_missing_file(self, api_use, method_use, expect_404): 74 | app = _make_app(api_use, method_use) 75 | url = '/_ah/api/filefetcher/v1/get_file/missing' 76 | # This _should_ return 404, but https://github.com/cloudendpoints/endpoints-python/issues/138 77 | # the other methods actually return 404 because protorpc doesn't rewrite it there 78 | _make_request(app, url, expect_status=400) 79 | 80 | def test_no_slash(self, api_use, method_use, expect_404): 81 | app = _make_app(api_use, method_use) 82 | url = '/_ah/api/filefetcher/v1/get_file/foo' 83 | _make_request(app, url, expect_status=None) 84 | 85 | def test_mid_slash(self, api_use, method_use, expect_404): 86 | app = _make_app(api_use, method_use) 87 | url = '/_ah/api/filefetcher/v1/get_file/{}'.format(_quote_slash('foo/bar')) 88 | actual = _make_request(app, url, expect_status=404 if expect_404 else 200) 89 | if not expect_404: 90 | expected = {'path': 'foo/bar', 'payload': 'da6e0dfe-8c74-4a46-9696-b08b58e03e79'} 91 | assert actual.json == expected 92 | 93 | def test_ending_slash(self, api_use, method_use, expect_404): 94 | app = _make_app(api_use, method_use) 95 | url = '/_ah/api/filefetcher/v1/get_file/{}'.format(_quote_slash('foo/bar/baz/')) 96 | actual = _make_request(app, url, expect_status=404 if expect_404 else 200) 97 | if not expect_404: 98 | expected = {'path': 'foo/bar/baz/', 'payload': '2543e3cf-0ae1-4278-9a76-8d1e2ee90de7'} 99 | assert actual.json == expected 100 | 101 | def test_beginning_slash(self, api_use, method_use, expect_404): 102 | app = _make_app(api_use, method_use) 103 | url = '/_ah/api/filefetcher/v1/get_file/{}'.format(_quote_slash('/hello')) 104 | actual = _make_request(app, url, expect_status=404 if expect_404 else 200) 105 | if not expect_404: 106 | expected = {'path': '/hello', 'payload': '80e3d7ee-d289-4aa7-b0ef-eb3a8232f33f'} 107 | assert actual.json == expected 108 | 109 | MP_INPUT = endpoints.ResourceContainer( 110 | message_types.VoidMessage, 111 | query_foo = messages.StringField(2, required=False), 112 | query_bar = messages.StringField(4, required=True), 113 | query_baz = messages.StringField(5, required=True), 114 | ) 115 | 116 | class MPResponse(messages.Message): 117 | value_foo = messages.StringField(1) 118 | value_bar = messages.StringField(2) 119 | value_baz = messages.StringField(3) 120 | 121 | @endpoints.api(name='multiparam', version='v1') 122 | class MultiParamApi(remote.Service): 123 | @endpoints.method(MP_INPUT, MPResponse, http_method='GET', name='param', path='param') 124 | def param(self, request): 125 | return MPResponse(value_foo=request.query_foo, value_bar=request.query_bar, value_baz=request.query_baz) 126 | 127 | MULTI_PARAM_APP = webtest.TestApp(endpoints.api_server([MultiParamApi]), lint=False) 128 | 129 | def test_normal_get(): 130 | url = '/_ah/api/multiparam/v1/param?query_foo=alice&query_bar=bob&query_baz=carol' 131 | actual = MULTI_PARAM_APP.get(url) 132 | assert actual.json == {'value_foo': 'alice', 'value_bar': 'bob', 'value_baz': 'carol'} 133 | 134 | def test_post_method_override(): 135 | url = '/_ah/api/multiparam/v1/param' 136 | body = 'query_foo=alice&query_bar=bob&query_baz=carol' 137 | actual = MULTI_PARAM_APP.post( 138 | url, params=body, content_type='application/x-www-form-urlencoded', headers={ 139 | 'x-http-method-override': 'GET', 140 | }) 141 | assert actual.json == {'value_foo': 'alice', 'value_bar': 'bob', 'value_baz': 'carol'} 142 | -------------------------------------------------------------------------------- /test/protojson_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for Endpoints-specific ProtoJson class.""" 16 | 17 | import json 18 | import unittest 19 | 20 | import test_util 21 | from endpoints import messages 22 | from endpoints import protojson 23 | 24 | 25 | class MyMessage(messages.Message): 26 | """Test message containing various types.""" 27 | var_int32 = messages.IntegerField(1, variant=messages.Variant.INT32) 28 | var_int64 = messages.IntegerField(2, variant=messages.Variant.INT64) 29 | var_repeated_int64 = messages.IntegerField(3, variant=messages.Variant.INT64, 30 | repeated=True) 31 | var_sint64 = messages.IntegerField(4, variant=messages.Variant.SINT64) 32 | var_uint64 = messages.IntegerField(5, variant=messages.Variant.UINT64) 33 | var_bytes = messages.BytesField(6) 34 | 35 | 36 | class ModuleInterfaceTest(test_util.ModuleInterfaceTest, 37 | unittest.TestCase): 38 | 39 | MODULE = protojson 40 | 41 | 42 | class EndpointsProtoJsonTest(unittest.TestCase): 43 | 44 | def setUp(self): 45 | self.__protojson = protojson.EndpointsProtoJson() 46 | super(EndpointsProtoJsonTest, self).setUp() 47 | 48 | def CompareEncoded(self, expected_encoded, actual_encoded): 49 | """JSON encoding will be laundered to remove string differences.""" 50 | self.assertEquals(json.loads(expected_encoded), json.loads(actual_encoded)) 51 | 52 | def testEncodeInt32(self): 53 | """Make sure int32 values are encoded as integers.""" 54 | encoded_message = self.__protojson.encode_message(MyMessage(var_int32=123)) 55 | expected_encoding = '{"var_int32": 123}' 56 | self.CompareEncoded(expected_encoding, encoded_message) 57 | 58 | def testEncodeInt64(self): 59 | """Make sure int64 values are encoded as strings.""" 60 | encoded_message = self.__protojson.encode_message(MyMessage(var_int64=123)) 61 | expected_encoding = '{"var_int64": "123"}' 62 | self.CompareEncoded(expected_encoding, encoded_message) 63 | 64 | def testEncodeRepeatedInt64(self): 65 | """Make sure int64 in repeated fields are encoded as strings.""" 66 | encoded_message = self.__protojson.encode_message( 67 | MyMessage(var_repeated_int64=[1, 2, 3])) 68 | expected_encoding = '{"var_repeated_int64": ["1", "2", "3"]}' 69 | self.CompareEncoded(expected_encoding, encoded_message) 70 | 71 | def testEncodeSint64(self): 72 | """Make sure sint64 values are encoded as strings.""" 73 | encoded_message = self.__protojson.encode_message(MyMessage(var_sint64=-12)) 74 | expected_encoding = '{"var_sint64": "-12"}' 75 | self.CompareEncoded(expected_encoding, encoded_message) 76 | 77 | def testEncodeUint64(self): 78 | """Make sure uint64 values are encoded as strings.""" 79 | encoded_message = self.__protojson.encode_message(MyMessage(var_uint64=900)) 80 | expected_encoding = '{"var_uint64": "900"}' 81 | self.CompareEncoded(expected_encoding, encoded_message) 82 | 83 | def testBytesNormal(self): 84 | """Verify that bytes encoded with standard b64 encoding are accepted.""" 85 | for encoded, decoded in (('/+==', '\xff'), 86 | ('/+/+', '\xff\xef\xfe'), 87 | ('YWI+Y2Q/', 'ab>cd?')): 88 | self.assertEqual(decoded, self.__protojson.decode_field( 89 | MyMessage.var_bytes, encoded)) 90 | 91 | def testBytesUrlSafe(self): 92 | """Verify that bytes encoded with urlsafe b64 encoding are accepted.""" 93 | for encoded, decoded in (('_-==', '\xff'), 94 | ('_-_-', '\xff\xef\xfe'), 95 | ('YWI-Y2Q_', 'ab>cd?')): 96 | self.assertEqual(decoded, self.__protojson.decode_field( 97 | MyMessage.var_bytes, encoded)) 98 | 99 | def testBytesMisalignedEncoding(self): 100 | """Verify that misaligned BytesField data raises an error.""" 101 | for encoded in ('garbagebb', 'a===', 'a', 'abcde'): 102 | self.assertRaises(messages.DecodeError, 103 | self.__protojson.decode_field, 104 | MyMessage.var_bytes, encoded) 105 | 106 | def testBytesUnpaddedEncoding(self): 107 | """Verify that unpadded BytesField data is accepted.""" 108 | for encoded, decoded in (('YQ', 'a'), 109 | ('YWI', 'ab'), 110 | ('_-', '\xff'), 111 | ('VGVzdGluZyB1bnBhZGRlZCBtZXNzYWdlcw', 112 | 'Testing unpadded messages')): 113 | self.assertEqual(decoded, self.__protojson.decode_field( 114 | MyMessage.var_bytes, encoded)) 115 | 116 | def testBytesInvalidChars(self): 117 | """Verify that invalid characters are ignored in BytesField encodings.""" 118 | for encoded in ('\x00\x01\x02\x03', '\xff==='): 119 | self.assertEqual('', self.__protojson.decode_field(MyMessage.var_bytes, 120 | encoded)) 121 | 122 | if __name__ == '__main__': 123 | unittest.main() 124 | -------------------------------------------------------------------------------- /test/resource_container_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for endpoints.resource_container.""" 16 | 17 | import json 18 | import unittest 19 | 20 | import test_util 21 | from endpoints import api_config 22 | from endpoints import message_types 23 | from endpoints import messages 24 | from endpoints import remote 25 | from endpoints import resource_container 26 | 27 | package = 'ResourceContainerTest' 28 | 29 | 30 | class AllBasicFields(messages.Message): 31 | """Contains all field types.""" 32 | 33 | bool_value = messages.BooleanField(1, variant=messages.Variant.BOOL) 34 | bytes_value = messages.BytesField(2, variant=messages.Variant.BYTES) 35 | double_value = messages.FloatField(3, variant=messages.Variant.DOUBLE) 36 | float_value = messages.FloatField(5, variant=messages.Variant.FLOAT) 37 | int32_value = messages.IntegerField(6, variant=messages.Variant.INT32) 38 | int64_value = messages.IntegerField(7, variant=messages.Variant.INT64) 39 | string_value = messages.StringField(8, variant=messages.Variant.STRING) 40 | uint32_value = messages.IntegerField(9, variant=messages.Variant.UINT32) 41 | uint64_value = messages.IntegerField(10, variant=messages.Variant.UINT64) 42 | sint32_value = messages.IntegerField(11, variant=messages.Variant.SINT32) 43 | sint64_value = messages.IntegerField(12, variant=messages.Variant.SINT64) 44 | datetime_value = message_types.DateTimeField(14) 45 | 46 | 47 | class ResourceContainerTest(unittest.TestCase): 48 | 49 | def testResourceContainer(self): 50 | rc = resource_container.ResourceContainer( 51 | **{field.name: field for field in AllBasicFields.all_fields()}) 52 | self.assertEqual(rc.body_message_class, message_types.VoidMessage) 53 | -------------------------------------------------------------------------------- /test/test_live_auth.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All rights reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | import base64 16 | import cStringIO 17 | import importlib 18 | import os 19 | import shutil 20 | import subprocess 21 | import sys 22 | import tempfile 23 | import zipfile 24 | 25 | import requests # provided by endpoints-management-python 26 | 27 | import pytest 28 | import yaml 29 | 30 | JSON_HEADERS = {'content-type': 'application/json'} 31 | TESTDIR = os.path.dirname(os.path.realpath(__file__)) 32 | 33 | def _find_setup_py(some_path): 34 | while not os.path.isfile(os.path.join(some_path, 'setup.py')): 35 | some_path = os.path.dirname(some_path) 36 | return some_path 37 | 38 | PKGDIR = _find_setup_py(TESTDIR) 39 | 40 | @pytest.fixture(scope='session') 41 | def integration_project_id(): 42 | if 'INTEGRATION_PROJECT_ID' not in os.environ: 43 | raise KeyError('INTEGRATION_PROJECT_ID required in environment. Set it to the appropriate project id.') 44 | return os.environ['INTEGRATION_PROJECT_ID'] 45 | 46 | @pytest.fixture(scope='session') 47 | def service_account_keyfile(): 48 | if 'SERVICE_ACCOUNT_KEYFILE' not in os.environ: 49 | raise KeyError('SERVICE_ACCOUNT_KEYFILE required in environment. Set it to the path to the service account key.') 50 | value = os.environ['SERVICE_ACCOUNT_KEYFILE'] 51 | if not os.path.isfile(value): 52 | raise ValueError('SERVICE_ACCOUNT_KEYFILE must point to a file containing the service account key.') 53 | return value 54 | 55 | @pytest.fixture(scope='session') 56 | def api_key(): 57 | if 'PROJECT_API_KEY' not in os.environ: 58 | raise KeyError('PROJECT_API_KEY required in environment. Set it to a valid api key for the specified project.') 59 | return os.environ['PROJECT_API_KEY'] 60 | 61 | @pytest.fixture(scope='session') 62 | def gcloud_driver_module(request): 63 | """This fixture provides the gcloud test driver. It is not normally installable, since it lacks a setup.py""" 64 | cache_key = 'live_auth/driver_zip' 65 | driver_zip_data = request.config.cache.get(cache_key, None) 66 | if driver_zip_data is None: 67 | url = "https://github.com/GoogleCloudPlatform/cloudsdk-test-driver/archive/master.zip" 68 | driver_zip_data = requests.get(url).content 69 | request.config.cache.set(cache_key, base64.b64encode(driver_zip_data)) 70 | else: 71 | driver_zip_data = base64.b64decode(driver_zip_data) 72 | extract_path = tempfile.mkdtemp() 73 | with zipfile.ZipFile(cStringIO.StringIO(driver_zip_data)) as driver_zip: 74 | driver_zip.extractall(path=extract_path) 75 | # have to rename the subfolder 76 | os.rename(os.path.join(extract_path, 'cloudsdk-test-driver-master'), os.path.join(extract_path, 'cloudsdk_test_driver')) 77 | sys.path.append(extract_path) 78 | driver_module = importlib.import_module('cloudsdk_test_driver.driver') 79 | yield driver_module 80 | sys.path.pop() 81 | shutil.rmtree(extract_path) 82 | 83 | @pytest.fixture(scope='session') 84 | def gcloud_driver(gcloud_driver_module): 85 | with gcloud_driver_module.Manager(additional_components=['app-engine-python']): 86 | yield gcloud_driver_module 87 | 88 | @pytest.fixture(scope='session') 89 | def gcloud_sdk(gcloud_driver, integration_project_id, service_account_keyfile): 90 | return gcloud_driver.SDKFromArgs(project=integration_project_id, service_account_keyfile=service_account_keyfile) 91 | 92 | class TestAppManager(object): 93 | # This object will manage the test app. It needs to be told what 94 | # kind of app to make; such methods are named `become_*_app`, 95 | # because they mutate the manager object rather than returning 96 | # some new object. 97 | 98 | def __init__(self): 99 | self.cleanup_path = tempfile.mkdtemp() 100 | self.app_path = os.path.join(self.cleanup_path, 'app') 101 | 102 | def cleanup(self): 103 | shutil.rmtree(self.cleanup_path) 104 | 105 | def become_apikey_app(self, project_id): 106 | source_path = os.path.join(TESTDIR, 'testdata', 'sample_app') 107 | shutil.copytree(source_path, self.app_path) 108 | self.update_app_yaml(project_id) 109 | 110 | def update_app_yaml(self, project_id, version=None): 111 | yaml_path = os.path.join(self.app_path, 'app.yaml') 112 | app_yaml = yaml.load(open(yaml_path)) 113 | env = app_yaml['env_variables'] 114 | env['ENDPOINTS_SERVICE_NAME'] = '{}.appspot.com'.format(project_id) 115 | if version is not None: 116 | env['ENDPOINTS_SERVICE_VERSION'] = version 117 | with open(yaml_path, 'w') as outfile: 118 | yaml.dump(app_yaml, outfile, default_flow_style=False) 119 | 120 | 121 | @pytest.fixture(scope='class') 122 | def apikey_app(gcloud_sdk, integration_project_id): 123 | app = TestAppManager() 124 | app.become_apikey_app(integration_project_id) 125 | path = app.app_path 126 | os.mkdir(os.path.join(path, 'lib')) 127 | # Install the checked-out endpoints repo 128 | subprocess.check_call(['python', '-m', 'pip', 'install', '-t', 'lib', PKGDIR, '--ignore-installed'], cwd=path) 129 | print path 130 | subprocess.check_call(['python', 'lib/endpoints/endpointscfg.py', 'get_openapi_spec', 'main.IataApi', '--hostname', '{}.appspot.com'.format(integration_project_id)], cwd=path) 131 | out, err, code = gcloud_sdk.RunGcloud(['endpoints', 'services', 'deploy', os.path.join(path, 'iatav1openapi.json')]) 132 | assert code == 0 133 | version = out['serviceConfig']['id'].encode('ascii') 134 | app.update_app_yaml(integration_project_id, version) 135 | 136 | out, err, code = gcloud_sdk.RunGcloud(['app', 'deploy', os.path.join(path, 'app.yaml')]) 137 | assert code == 0 138 | 139 | base_url = 'https://{}.appspot.com/_ah/api/iata/v1'.format(integration_project_id) 140 | yield base_url 141 | app.cleanup() 142 | 143 | 144 | @pytest.fixture() 145 | def clean_apikey_app(apikey_app, api_key): 146 | url = '/'.join([apikey_app, 'reset']) 147 | r = requests.post(url, params={'key': api_key}) 148 | assert r.status_code == 204 149 | return apikey_app 150 | 151 | @pytest.mark.livetest 152 | class TestApikeyRequirement(object): 153 | def test_get_airport(self, clean_apikey_app): 154 | url = '/'.join([clean_apikey_app, 'airport', 'YYZ']) 155 | r = requests.get(url, headers=JSON_HEADERS) 156 | actual = r.json() 157 | expected = {u'iata': u'YYZ', u'name': u'Lester B. Pearson International Airport'} 158 | assert actual == expected 159 | 160 | def test_list_airports(self, clean_apikey_app): 161 | url = '/'.join([clean_apikey_app, 'airports']) 162 | r = requests.get(url, headers=JSON_HEADERS) 163 | raw = r.json() 164 | assert 'airports' in raw 165 | actual = {a['iata']: a['name'] for a in raw['airports']} 166 | assert actual[u'YYZ'] == u'Lester B. Pearson International Airport' 167 | assert u'ZZT' not in actual 168 | 169 | def test_create_airport(self, clean_apikey_app, api_key): 170 | url = '/'.join([clean_apikey_app, 'airport']) 171 | r = requests.get('/'.join([url, 'ZZT']), headers=JSON_HEADERS) 172 | assert r.status_code == 404 173 | data = {u'iata': u'ZZT', u'name': u'Town Airport'} 174 | r = requests.post(url, json=data, params={'key': api_key}) 175 | assert data == r.json() 176 | r = requests.get('/'.join([url, 'ZZT']), headers=JSON_HEADERS) 177 | assert r.status_code == 200 178 | assert data == r.json() 179 | 180 | def test_create_airport_key_required(self, clean_apikey_app): 181 | url = '/'.join([clean_apikey_app, 'airport']) 182 | data = {u'iata': u'ZZT', u'name': u'Town Airport'} 183 | r = requests.post(url, json=data) 184 | assert r.status_code == 401 185 | r = requests.get('/'.join([url, 'ZZT']), headers=JSON_HEADERS) 186 | assert r.status_code == 404 187 | 188 | def test_modify_airport(self, clean_apikey_app, api_key): 189 | url = '/'.join([clean_apikey_app, 'airport', 'YYZ']) 190 | r = requests.get(url, headers=JSON_HEADERS) 191 | actual = r.json() 192 | expected = {u'iata': u'YYZ', u'name': u'Lester B. Pearson International Airport'} 193 | assert actual == expected 194 | 195 | data = {u'iata': u'YYZ', u'name': u'Torontoland'} 196 | r = requests.post(url, json=data, params={'key': api_key}) 197 | assert data == r.json() 198 | 199 | r = requests.get(url, headers=JSON_HEADERS) 200 | assert data == r.json() 201 | 202 | def test_modify_airport_key_required(self, clean_apikey_app): 203 | url = '/'.join([clean_apikey_app, 'airport', 'YYZ']) 204 | data = {u'iata': u'YYZ', u'name': u'Torontoland'} 205 | r = requests.post(url, json=data) 206 | assert r.status_code == 401 207 | 208 | r = requests.get(url, headers=JSON_HEADERS) 209 | actual = r.json() 210 | expected = {u'iata': u'YYZ', u'name': u'Lester B. Pearson International Airport'} 211 | assert actual == expected 212 | 213 | def test_delete_airport(self, clean_apikey_app, api_key): 214 | url = '/'.join([clean_apikey_app, 'airport', 'YYZ']) 215 | r = requests.delete(url, headers=JSON_HEADERS, params={'key': api_key}) 216 | assert r.status_code == 204 217 | 218 | r = requests.get(url, headers=JSON_HEADERS) 219 | assert r.status_code == 404 220 | 221 | def test_delete_airport_key_required(self, clean_apikey_app): 222 | url = '/'.join([clean_apikey_app, 'airport', 'YYZ']) 223 | r = requests.delete(url, headers=JSON_HEADERS) 224 | assert r.status_code == 401 225 | 226 | r = requests.get(url, headers=JSON_HEADERS) 227 | actual = r.json() 228 | expected = {u'iata': u'YYZ', u'name': u'Lester B. Pearson International Airport'} 229 | assert actual == expected 230 | -------------------------------------------------------------------------------- /test/test_util.py: -------------------------------------------------------------------------------- 1 | # Copyright 2016 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Test utilities for API modules. 16 | 17 | Classes: 18 | ModuleInterfaceTest: Test framework for developing public modules. 19 | """ 20 | 21 | # pylint: disable=g-bad-name 22 | 23 | import __future__ 24 | 25 | import json 26 | import os 27 | import StringIO 28 | import types 29 | 30 | 31 | def SortListEntries(d): 32 | for k, v in d.iteritems(): 33 | if isinstance(v, dict): 34 | SortListEntries(v) 35 | elif isinstance(v, list): 36 | d[k] = sorted(v) 37 | 38 | 39 | def AssertDictEqual(expected, actual, testcase): 40 | """Utility method to dump diffs if the dictionaries aren't equal. 41 | 42 | Args: 43 | expected: dict, the expected results. 44 | actual: dict, the actual results. 45 | testcase: unittest.TestCase, the test case this assertion is used within. 46 | """ 47 | if expected != actual: 48 | SortListEntries(expected) 49 | SortListEntries(actual) 50 | testcase.assertMultiLineEqual( 51 | json.dumps(expected, indent=2, sort_keys=True), 52 | json.dumps(actual, indent=2, sort_keys=True)) 53 | 54 | 55 | class ModuleInterfaceTest(object): 56 | r"""Test to ensure module interface is carefully constructed. 57 | 58 | A module interface is the set of public objects listed in the module __all__ 59 | attribute. Modules that will be used by the public should have this interface 60 | carefully declared. At all times, the __all__ attribute should have objects 61 | intended to be used by the public and other objects in the module should be 62 | considered unused. 63 | 64 | Protected attributes (those beginning with '_') and other imported modules 65 | should not be part of this set of variables. An exception is for variables 66 | that begin and end with '__' which are implicitly part of the interface 67 | (eg. __name__, __file__, __all__ itself, etc.). 68 | 69 | Modules that are imported in to the tested modules are an exception and may 70 | be left out of the __all__ definition. The test is done by checking the value 71 | of what would otherwise be a public name and not allowing it to be exported 72 | if it is an instance of a module. Modules that are explicitly exported are 73 | for the time being not permitted. 74 | 75 | To use this test class a module should define a new class that inherits first 76 | from ModuleInterfaceTest and then from unittest.TestCase. No other tests 77 | should be added to this test case, making the order of inheritance less 78 | important, but if setUp for some reason is overidden, it is important that 79 | ModuleInterfaceTest is first in the list so that its setUp method is 80 | invoked. 81 | 82 | Multiple inheretance is required so that ModuleInterfaceTest is not itself 83 | a test, and is not itself executed as one. 84 | 85 | The test class is expected to have the following class attributes defined: 86 | 87 | MODULE: A reference to the module that is being validated for interface 88 | correctness. 89 | 90 | Example: 91 | Module definition (hello.py): 92 | 93 | import sys 94 | 95 | __all__ = ['hello'] 96 | 97 | def _get_outputter(): 98 | return sys.stdout 99 | 100 | def hello(): 101 | _get_outputter().write('Hello\n') 102 | 103 | Test definition: 104 | 105 | import test_util 106 | import unittest 107 | 108 | import hello 109 | 110 | class ModuleInterfaceTest(module_testutil.ModuleInterfaceTest, 111 | unittest.TestCase): 112 | 113 | MODULE = hello 114 | 115 | 116 | class HelloTest(unittest.TestCase): 117 | ... Test 'hello' module ... 118 | 119 | 120 | def main(unused_argv): 121 | unittest.main() 122 | 123 | 124 | if __name__ == '__main__': 125 | app.run() 126 | """ 127 | 128 | def setUp(self): 129 | """Set up makes sure that MODULE and IMPORTED_MODULES is defined. 130 | 131 | This is a basic configuration test for the test itself so does not 132 | get it's own test case. 133 | """ 134 | if not hasattr(self, 'MODULE'): 135 | self.fail( 136 | "You must define 'MODULE' on ModuleInterfaceTest sub-class %s." % 137 | type(self).__name__) 138 | 139 | def testAllExist(self): 140 | """Test that all attributes defined in __all__ exist.""" 141 | missing_attributes = [] 142 | for attribute in self.MODULE.__all__: 143 | if not hasattr(self.MODULE, attribute): 144 | missing_attributes.append(attribute) 145 | if missing_attributes: 146 | self.fail('%s of __all__ are not defined in module.' % 147 | missing_attributes) 148 | 149 | def testAllExported(self): 150 | """Test that all public attributes not imported are in __all__.""" 151 | missing_attributes = [] 152 | for attribute in dir(self.MODULE): 153 | if not attribute.startswith('_'): 154 | if attribute not in self.MODULE.__all__: 155 | attribute_value = getattr(self.MODULE, attribute) 156 | if isinstance(attribute_value, types.ModuleType): 157 | continue 158 | # pylint: disable=protected-access 159 | if isinstance(attribute_value, __future__._Feature): 160 | continue 161 | missing_attributes.append(attribute) 162 | if missing_attributes: 163 | self.fail('%s are not modules and not defined in __all__.' % 164 | missing_attributes) 165 | 166 | def testNoExportedProtectedVariables(self): 167 | """Test that there are no protected variables listed in __all__.""" 168 | protected_variables = [] 169 | for attribute in self.MODULE.__all__: 170 | if attribute.startswith('_'): 171 | protected_variables.append(attribute) 172 | if protected_variables: 173 | self.fail('%s are protected variables and may not be exported.' % 174 | protected_variables) 175 | 176 | def testNoExportedModules(self): 177 | """Test that no modules exist in __all__.""" 178 | exported_modules = [] 179 | for attribute in self.MODULE.__all__: 180 | try: 181 | value = getattr(self.MODULE, attribute) 182 | except AttributeError: 183 | # This is a different error case tested for in testAllExist. 184 | pass 185 | else: 186 | if isinstance(value, types.ModuleType): 187 | exported_modules.append(attribute) 188 | if exported_modules: 189 | self.fail('%s are modules and may not be exported.' % exported_modules) 190 | 191 | 192 | class DevServerTest(object): 193 | 194 | @staticmethod 195 | def setUpDevServerEnv(server_software_key='SERVER_SOFTWARE', 196 | server_software_value='Development/2.0.0'): 197 | original_env_value = os.environ.get(server_software_key) 198 | os.environ[server_software_key] = server_software_value 199 | return server_software_key, original_env_value 200 | 201 | @staticmethod 202 | def restoreEnv(server_software_key, server_software_value): 203 | if server_software_value is None: 204 | os.environ.pop(server_software_key, None) 205 | else: 206 | os.environ[server_software_key] = server_software_value 207 | 208 | 209 | def create_fake_environ(protocol, server, port=None, path=None, 210 | query_string=None, body=None, http_method='GET'): 211 | if port is None: 212 | port = 80 if protocol.lower() == 'http' else 443 213 | 214 | return { 215 | 'wsgi.url_scheme': protocol, 216 | 'REQUEST_METHOD': http_method, 217 | 'SERVER_NAME': server, 218 | 'SERVER_PORT': str(port), 219 | 'PATH_INFO': path, 220 | 'wsgi.input': StringIO.StringIO(body) if body else StringIO.StringIO(), 221 | 'QUERY_STRING': query_string, 222 | } 223 | -------------------------------------------------------------------------------- /test/testdata/directory_list/basic.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "discovery#directoryList", 3 | "discoveryVersion": "v1", 4 | "items": [ 5 | { 6 | "kind": "discovery#directoryItem", 7 | "id": "root:v1", 8 | "name": "root", 9 | "version": "v1", 10 | "description": "This is an API", 11 | "discoveryRestUrl": "https://example.appspot.com/_ah/api/discovery/v1/apis/root/v1/rest", 12 | "discoveryLink": "./apis/root/v1/rest", 13 | "icons": { 14 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 15 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 16 | }, 17 | "preferred": true 18 | }, 19 | { 20 | "kind": "discovery#directoryItem", 21 | "id": "myapi:v1", 22 | "name": "myapi", 23 | "version": "v1", 24 | "description": "This is my API", 25 | "discoveryRestUrl": "https://example.appspot.com/_ah/api/discovery/v1/apis/myapi/v1/rest", 26 | "discoveryLink": "./apis/myapi/v1/rest", 27 | "icons": { 28 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 29 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 30 | }, 31 | "preferred": true 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /test/testdata/directory_list/localhost.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind": "discovery#directoryList", 3 | "discoveryVersion": "v1", 4 | "items": [ 5 | { 6 | "kind": "discovery#directoryItem", 7 | "id": "root:v1", 8 | "name": "root", 9 | "version": "v1", 10 | "description": "This is an API", 11 | "discoveryRestUrl": "http://localhost:8080/_ah/api/discovery/v1/apis/root/v1/rest", 12 | "discoveryLink": "./apis/root/v1/rest", 13 | "icons": { 14 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 15 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 16 | }, 17 | "preferred": true 18 | }, 19 | { 20 | "kind": "discovery#directoryItem", 21 | "id": "myapi:v1", 22 | "name": "myapi", 23 | "version": "v1", 24 | "description": "This is my API", 25 | "discoveryRestUrl": "http://localhost:8081/_ah/api/discovery/v1/apis/myapi/v1/rest", 26 | "discoveryLink": "./apis/myapi/v1/rest", 27 | "icons": { 28 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 29 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 30 | }, 31 | "preferred": true 32 | } 33 | ] 34 | } 35 | -------------------------------------------------------------------------------- /test/testdata/discovery/bar_endpoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "oauth2": { 4 | "scopes": { 5 | "https://www.googleapis.com/auth/userinfo.email": { 6 | "description": "View your email address" 7 | } 8 | } 9 | } 10 | }, 11 | "basePath": "/_ah/api/bar/v1/", 12 | "baseUrl": "https://discovery-test.appspot.com/_ah/api/bar/v1/", 13 | "batchPath": "batch", 14 | "description": "This is an API", 15 | "discoveryVersion": "v1", 16 | "icons": { 17 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 18 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 19 | }, 20 | "id": "bar:v1", 21 | "kind": "discovery#restDescription", 22 | "name": "bar", 23 | "parameters": { 24 | "alt": { 25 | "default": "json", 26 | "description": "Data format for the response.", 27 | "enum": [ 28 | "json" 29 | ], 30 | "enumDescriptions": [ 31 | "Responses with Content-Type of application/json" 32 | ], 33 | "location": "query", 34 | "type": "string" 35 | }, 36 | "fields": { 37 | "description": "Selector specifying which fields to include in a partial response.", 38 | "location": "query", 39 | "type": "string" 40 | }, 41 | "key": { 42 | "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", 43 | "location": "query", 44 | "type": "string" 45 | }, 46 | "oauth_token": { 47 | "description": "OAuth 2.0 token for the current user.", 48 | "location": "query", 49 | "type": "string" 50 | }, 51 | "prettyPrint": { 52 | "default": "true", 53 | "description": "Returns response with indentations and line breaks.", 54 | "location": "query", 55 | "type": "boolean" 56 | }, 57 | "quotaUser": { 58 | "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. Overrides userIp if both are provided.", 59 | "location": "query", 60 | "type": "string" 61 | }, 62 | "userIp": { 63 | "description": "IP address of the site where the request originates. Use this if you want to enforce per-user limits.", 64 | "location": "query", 65 | "type": "string" 66 | } 67 | }, 68 | "protocol": "rest", 69 | "resources": { 70 | "bar": { 71 | "methods": { 72 | "create": { 73 | "httpMethod": "PUT", 74 | "id": "bar.bar.create", 75 | "parameterOrder": [ 76 | "id" 77 | ], 78 | "parameters": { 79 | "id": { 80 | "location": "path", 81 | "required": true, 82 | "type": "string" 83 | } 84 | }, 85 | "path": "bars/{id}", 86 | "request": { 87 | "$ref": "DiscoveryDocumentTestBar", 88 | "parameterName": "resource" 89 | }, 90 | "response": { 91 | "$ref": "DiscoveryDocumentTestBar" 92 | }, 93 | "scopes": [ 94 | "https://www.googleapis.com/auth/userinfo.email" 95 | ] 96 | }, 97 | "delete": { 98 | "httpMethod": "DELETE", 99 | "id": "bar.bar.delete", 100 | "parameterOrder": [ 101 | "id" 102 | ], 103 | "parameters": { 104 | "id": { 105 | "location": "path", 106 | "required": true, 107 | "type": "string" 108 | } 109 | }, 110 | "path": "bars/{id}", 111 | "response": { 112 | "$ref": "DiscoveryDocumentTestBar" 113 | }, 114 | "scopes": [ 115 | "https://www.googleapis.com/auth/userinfo.email" 116 | ] 117 | }, 118 | "get": { 119 | "httpMethod": "GET", 120 | "id": "bar.bar.get", 121 | "parameterOrder": [ 122 | "id" 123 | ], 124 | "parameters": { 125 | "id": { 126 | "location": "path", 127 | "required": true, 128 | "type": "string" 129 | } 130 | }, 131 | "path": "bars/{id}", 132 | "response": { 133 | "$ref": "DiscoveryDocumentTestBar" 134 | }, 135 | "scopes": [ 136 | "https://www.googleapis.com/auth/userinfo.email" 137 | ] 138 | }, 139 | "list": { 140 | "httpMethod": "GET", 141 | "id": "bar.bar.list", 142 | "parameterOrder": [ 143 | "n" 144 | ], 145 | "parameters": { 146 | "n": { 147 | "format": "int32", 148 | "location": "query", 149 | "required": true, 150 | "type": "integer" 151 | } 152 | }, 153 | "path": "bars", 154 | "response": { 155 | "$ref": "ProtorpcMessagesCollectionBar" 156 | }, 157 | "scopes": [ 158 | "https://www.googleapis.com/auth/userinfo.email" 159 | ] 160 | }, 161 | "update": { 162 | "httpMethod": "POST", 163 | "id": "bar.bar.update", 164 | "parameterOrder": [ 165 | "id" 166 | ], 167 | "parameters": { 168 | "id": { 169 | "location": "path", 170 | "required": true, 171 | "type": "string" 172 | } 173 | }, 174 | "path": "bars/{id}", 175 | "request": { 176 | "$ref": "DiscoveryDocumentTestBar", 177 | "parameterName": "resource" 178 | }, 179 | "response": { 180 | "$ref": "DiscoveryDocumentTestBar" 181 | }, 182 | "scopes": [ 183 | "https://www.googleapis.com/auth/userinfo.email" 184 | ] 185 | } 186 | } 187 | } 188 | }, 189 | "rootUrl": "https://discovery-test.appspot.com/_ah/api/", 190 | "schemas": { 191 | "ProtorpcMessagesCollectionBar": { 192 | "id": "ProtorpcMessagesCollectionBar", 193 | "properties": { 194 | "items": { 195 | "items": { 196 | "$ref": "DiscoveryDocumentTestBar" 197 | }, 198 | "type": "array" 199 | }, 200 | "nextPageToken": { 201 | "type": "string" 202 | } 203 | }, 204 | "type": "object" 205 | }, 206 | "DiscoveryDocumentTestBar": { 207 | "id": "DiscoveryDocumentTestBar", 208 | "properties": { 209 | "name": { 210 | "type": "string", 211 | "default": "Jimothy" 212 | }, 213 | "value": { 214 | "format": "int32", 215 | "type": "integer", 216 | "default": "42" 217 | }, 218 | "active": { 219 | "type": "boolean", 220 | "default": "true" 221 | } 222 | }, 223 | "type": "object" 224 | } 225 | }, 226 | "servicePath": "bar/v1/", 227 | "version": "v1" 228 | } 229 | -------------------------------------------------------------------------------- /test/testdata/discovery/foo_endpoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "oauth2": { 4 | "scopes": { 5 | "https://www.googleapis.com/auth/userinfo.email": { 6 | "description": "View your email address" 7 | } 8 | } 9 | } 10 | }, 11 | "basePath": "/_ah/api/foo/v1/", 12 | "baseUrl": "https://discovery-test.appspot.com/_ah/api/foo/v1/", 13 | "batchPath": "batch", 14 | "canonicalName": "CanonicalName", 15 | "description": "Just Foo Things", 16 | "discoveryVersion": "v1", 17 | "documentationLink": "https://example.com", 18 | "icons": { 19 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 20 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 21 | }, 22 | "id": "foo:v1", 23 | "kind": "discovery#restDescription", 24 | "methods": { 25 | "toplevel": { 26 | "httpMethod": "POST", 27 | "id": "foo.toplevel", 28 | "path": "foos", 29 | "response": { 30 | "$ref": "ProtorpcMessagesCollectionFoo" 31 | }, 32 | "scopes": [ 33 | "https://www.googleapis.com/auth/userinfo.email" 34 | ] 35 | } 36 | }, 37 | "name": "foo", 38 | "parameters": { 39 | "alt": { 40 | "default": "json", 41 | "description": "Data format for the response.", 42 | "enum": [ 43 | "json" 44 | ], 45 | "enumDescriptions": [ 46 | "Responses with Content-Type of application/json" 47 | ], 48 | "location": "query", 49 | "type": "string" 50 | }, 51 | "fields": { 52 | "description": "Selector specifying which fields to include in a partial response.", 53 | "location": "query", 54 | "type": "string" 55 | }, 56 | "key": { 57 | "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", 58 | "location": "query", 59 | "type": "string" 60 | }, 61 | "oauth_token": { 62 | "description": "OAuth 2.0 token for the current user.", 63 | "location": "query", 64 | "type": "string" 65 | }, 66 | "prettyPrint": { 67 | "default": "true", 68 | "description": "Returns response with indentations and line breaks.", 69 | "location": "query", 70 | "type": "boolean" 71 | }, 72 | "quotaUser": { 73 | "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. Overrides userIp if both are provided.", 74 | "location": "query", 75 | "type": "string" 76 | }, 77 | "userIp": { 78 | "description": "IP address of the site where the request originates. Use this if you want to enforce per-user limits.", 79 | "location": "query", 80 | "type": "string" 81 | } 82 | }, 83 | "protocol": "rest", 84 | "resources": { 85 | "foo": { 86 | "methods": { 87 | "create": { 88 | "httpMethod": "PUT", 89 | "id": "foo.foo.create", 90 | "parameterOrder": [ 91 | "id" 92 | ], 93 | "parameters": { 94 | "id": { 95 | "location": "path", 96 | "required": true, 97 | "type": "string" 98 | } 99 | }, 100 | "path": "foos/{id}", 101 | "request": { 102 | "$ref": "DiscoveryDocumentTestFoo", 103 | "parameterName": "resource" 104 | }, 105 | "response": { 106 | "$ref": "DiscoveryDocumentTestFoo" 107 | }, 108 | "scopes": [ 109 | "https://www.googleapis.com/auth/userinfo.email" 110 | ] 111 | }, 112 | "delete": { 113 | "httpMethod": "DELETE", 114 | "id": "foo.foo.delete", 115 | "parameterOrder": [ 116 | "id" 117 | ], 118 | "parameters": { 119 | "id": { 120 | "location": "path", 121 | "required": true, 122 | "type": "string" 123 | } 124 | }, 125 | "path": "foos/{id}", 126 | "response": { 127 | "$ref": "DiscoveryDocumentTestFoo" 128 | }, 129 | "scopes": [ 130 | "https://www.googleapis.com/auth/userinfo.email" 131 | ] 132 | }, 133 | "get": { 134 | "httpMethod": "GET", 135 | "id": "foo.foo.get", 136 | "parameterOrder": [ 137 | "id" 138 | ], 139 | "parameters": { 140 | "id": { 141 | "location": "path", 142 | "required": true, 143 | "type": "string" 144 | } 145 | }, 146 | "path": "foos/{id}", 147 | "response": { 148 | "$ref": "DiscoveryDocumentTestFoo" 149 | }, 150 | "scopes": [ 151 | "https://www.googleapis.com/auth/userinfo.email" 152 | ] 153 | }, 154 | "list": { 155 | "httpMethod": "GET", 156 | "id": "foo.foo.list", 157 | "parameterOrder": [ 158 | "n" 159 | ], 160 | "parameters": { 161 | "n": { 162 | "format": "int32", 163 | "location": "query", 164 | "required": true, 165 | "type": "integer" 166 | } 167 | }, 168 | "path": "foos", 169 | "response": { 170 | "$ref": "ProtorpcMessagesCollectionFoo" 171 | }, 172 | "scopes": [ 173 | "https://www.googleapis.com/auth/userinfo.email" 174 | ] 175 | }, 176 | "update": { 177 | "httpMethod": "POST", 178 | "id": "foo.foo.update", 179 | "parameterOrder": [ 180 | "id" 181 | ], 182 | "parameters": { 183 | "id": { 184 | "location": "path", 185 | "required": true, 186 | "type": "string" 187 | } 188 | }, 189 | "path": "foos/{id}", 190 | "request": { 191 | "$ref": "DiscoveryDocumentTestFoo", 192 | "parameterName": "resource" 193 | }, 194 | "response": { 195 | "$ref": "DiscoveryDocumentTestFoo" 196 | }, 197 | "scopes": [ 198 | "https://www.googleapis.com/auth/userinfo.email" 199 | ] 200 | } 201 | } 202 | } 203 | }, 204 | "rootUrl": "https://discovery-test.appspot.com/_ah/api/", 205 | "schemas": { 206 | "ProtorpcMessagesCollectionFoo": { 207 | "id": "ProtorpcMessagesCollectionFoo", 208 | "properties": { 209 | "items": { 210 | "items": { 211 | "$ref": "DiscoveryDocumentTestFoo" 212 | }, 213 | "type": "array" 214 | }, 215 | "nextPageToken": { 216 | "type": "string" 217 | } 218 | }, 219 | "type": "object" 220 | }, 221 | "DiscoveryDocumentTestFoo": { 222 | "id": "DiscoveryDocumentTestFoo", 223 | "properties": { 224 | "name": { 225 | "type": "string" 226 | }, 227 | "value": { 228 | "format": "int32", 229 | "type": "integer" 230 | } 231 | }, 232 | "type": "object" 233 | } 234 | }, 235 | "servicePath": "foo/v1/", 236 | "title": "The Foo API", 237 | "version": "v1" 238 | } 239 | -------------------------------------------------------------------------------- /test/testdata/discovery/multiple_parameter_endpoint.json: -------------------------------------------------------------------------------- 1 | { 2 | "auth": { 3 | "oauth2": { 4 | "scopes": { 5 | "https://www.googleapis.com/auth/userinfo.email": { 6 | "description": "View your email address" 7 | } 8 | } 9 | } 10 | }, 11 | "basePath": "/_ah/api/multipleparam/v1/", 12 | "baseUrl": "https://discovery-test.appspot.com/_ah/api/multipleparam/v1/", 13 | "batchPath": "batch", 14 | "description": "This is an API", 15 | "discoveryVersion": "v1", 16 | "icons": { 17 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 18 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 19 | }, 20 | "id": "multipleparam:v1", 21 | "kind": "discovery#restDescription", 22 | "methods": { 23 | "param": { 24 | "httpMethod": "POST", 25 | "id": "multipleparam.param", 26 | "parameterOrder": [ 27 | "parent", 28 | "child", 29 | "querya", 30 | "queryb" 31 | ], 32 | "parameters": { 33 | "parent": { 34 | "location": "path", 35 | "required": true, 36 | "type": "string" 37 | }, 38 | "query": { 39 | "location": "query", 40 | "type": "string" 41 | }, 42 | "child": { 43 | "location": "path", 44 | "required": true, 45 | "type": "string" 46 | }, 47 | "queryb": { 48 | "location": "query", 49 | "required": true, 50 | "type": "string" 51 | }, 52 | "querya": { 53 | "location": "query", 54 | "required": true, 55 | "type": "string" 56 | }, 57 | "allow": { 58 | "default": "true", 59 | "location": "query", 60 | "type": "boolean" 61 | } 62 | }, 63 | "path": "param/{parent}/{child}", 64 | "scopes": [ 65 | "https://www.googleapis.com/auth/userinfo.email" 66 | ] 67 | } 68 | }, 69 | "name": "multipleparam", 70 | "parameters": { 71 | "alt": { 72 | "default": "json", 73 | "description": "Data format for the response.", 74 | "enum": [ 75 | "json" 76 | ], 77 | "enumDescriptions": [ 78 | "Responses with Content-Type of application/json" 79 | ], 80 | "location": "query", 81 | "type": "string" 82 | }, 83 | "fields": { 84 | "description": "Selector specifying which fields to include in a partial response.", 85 | "location": "query", 86 | "type": "string" 87 | }, 88 | "key": { 89 | "description": "API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", 90 | "location": "query", 91 | "type": "string" 92 | }, 93 | "oauth_token": { 94 | "description": "OAuth 2.0 token for the current user.", 95 | "location": "query", 96 | "type": "string" 97 | }, 98 | "prettyPrint": { 99 | "default": "true", 100 | "description": "Returns response with indentations and line breaks.", 101 | "location": "query", 102 | "type": "boolean" 103 | }, 104 | "quotaUser": { 105 | "description": "Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. Overrides userIp if both are provided.", 106 | "location": "query", 107 | "type": "string" 108 | }, 109 | "userIp": { 110 | "description": "IP address of the site where the request originates. Use this if you want to enforce per-user limits.", 111 | "location": "query", 112 | "type": "string" 113 | } 114 | }, 115 | "protocol": "rest", 116 | "rootUrl": "https://discovery-test.appspot.com/_ah/api/", 117 | "servicePath": "multipleparam/v1/", 118 | "version": "v1" 119 | } 120 | -------------------------------------------------------------------------------- /test/testdata/discovery/namespace.json: -------------------------------------------------------------------------------- 1 | { 2 | "kind":"discovery#restDescription", 3 | "discoveryVersion":"v1", 4 | "id":"root:v1", 5 | "name":"root", 6 | "ownerDomain": "domain", 7 | "ownerName": "name", 8 | "packagePath": "path", 9 | "version":"v1", 10 | "description":"This is an API", 11 | "icons":{ 12 | "x16": "https://www.gstatic.com/images/branding/product/1x/googleg_16dp.png", 13 | "x32": "https://www.gstatic.com/images/branding/product/1x/googleg_32dp.png" 14 | }, 15 | "protocol":"rest", 16 | "baseUrl":"https://example.appspot.com/_ah/api/root/v1/", 17 | "basePath":"/_ah/api/root/v1/", 18 | "batchPath": "batch", 19 | "rootUrl":"https://example.appspot.com/_ah/api/", 20 | "servicePath":"root/v1/", 21 | "parameters":{ 22 | "alt":{ 23 | "type":"string", 24 | "description":"Data format for the response.", 25 | "default":"json", 26 | "enum":[ 27 | "json" 28 | ], 29 | "enumDescriptions":[ 30 | "Responses with Content-Type of application/json" 31 | ], 32 | "location":"query" 33 | }, 34 | "fields":{ 35 | "type":"string", 36 | "description":"Selector specifying which fields to include in a partial response.", 37 | "location":"query" 38 | }, 39 | "key":{ 40 | "type":"string", 41 | "description":"API key. Your API key identifies your project and provides you with API access, quota, and reports. Required unless you provide an OAuth 2.0 token.", 42 | "location":"query" 43 | }, 44 | "oauth_token":{ 45 | "type":"string", 46 | "description":"OAuth 2.0 token for the current user.", 47 | "location":"query" 48 | }, 49 | "prettyPrint":{ 50 | "type":"boolean", 51 | "description":"Returns response with indentations and line breaks.", 52 | "default":"true", 53 | "location":"query" 54 | }, 55 | "quotaUser":{ 56 | "type":"string", 57 | "description":"Available to use for quota purposes for server-side applications. Can be any arbitrary string assigned to a user, but should not exceed 40 characters. Overrides userIp if both are provided.", 58 | "location":"query" 59 | }, 60 | "userIp":{ 61 | "type":"string", 62 | "description":"IP address of the site where the request originates. Use this if you want to enforce per-user limits.", 63 | "location":"query" 64 | } 65 | }, 66 | "auth": { 67 | "oauth2": { 68 | "scopes": { 69 | "https://www.googleapis.com/auth/userinfo.email": { 70 | "description": "View your email address" 71 | } 72 | } 73 | } 74 | }, 75 | "methods": { 76 | "get_entry": { 77 | "description": "Id (integer) field type in the query parameters.", 78 | "httpMethod": "GET", 79 | "id": "root.get_entry", 80 | "parameters": { 81 | "id_value": { 82 | "format": "int32", 83 | "location": "query", 84 | "type": "integer" 85 | } 86 | }, 87 | "path": "entries", 88 | "scopes": [ 89 | "https://www.googleapis.com/auth/userinfo.email" 90 | ] 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /test/testdata/sample_app/app.yaml: -------------------------------------------------------------------------------- 1 | runtime: python27 2 | threadsafe: false 3 | api_version: 1 4 | basic_scaling: 5 | max_instances: 2 6 | 7 | #[START_EXCLUDE] 8 | skip_files: 9 | - ^(.*/)?#.*#$ 10 | - ^(.*/)?.*~$ 11 | - ^(.*/)?.*\.py[co]$ 12 | - ^(.*/)?.*/RCS/.*$ 13 | - ^(.*/)?\..*$ 14 | - ^(.*/)?setuptools/script \(dev\).tmpl$ 15 | #[END_EXCLUDE] 16 | 17 | handlers: 18 | # The endpoints handler must be mapped to /_ah/api. 19 | - url: /_ah/api/.* 20 | script: main.api 21 | 22 | libraries: 23 | - name: pycrypto 24 | version: 2.6 25 | - name: ssl 26 | version: 2.7.11 27 | 28 | env_variables: 29 | # The following values are to be replaced by information from the output of 30 | # 'gcloud service-management deploy swagger.json' command. 31 | ENDPOINTS_SERVICE_NAME: project_id.appspot.com 32 | ENDPOINTS_SERVICE_VERSION: version 33 | -------------------------------------------------------------------------------- /test/testdata/sample_app/appengine_config.py: -------------------------------------------------------------------------------- 1 | from google.appengine.ext import vendor 2 | 3 | # Add any libraries installed in the `lib` folder. 4 | vendor.add('lib') 5 | -------------------------------------------------------------------------------- /test/testdata/sample_app/data.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All rights reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | AIRPORTS = { 16 | u'ABQ': u'Albuquerque International Sunport Airport', 17 | u'ACA': u'General Juan N Alvarez International Airport', 18 | u'ADW': u'Andrews Air Force Base', 19 | u'AFW': u'Fort Worth Alliance Airport', 20 | u'AGS': u'Augusta Regional At Bush Field', 21 | u'AMA': u'Rick Husband Amarillo International Airport', 22 | u'ANC': u'Ted Stevens Anchorage International Airport', 23 | u'ATL': u'Hartsfield Jackson Atlanta International Airport', 24 | u'AUS': u'Austin Bergstrom International Airport', 25 | u'AVL': u'Asheville Regional Airport', 26 | u'BAB': u'Beale Air Force Base', 27 | u'BAD': u'Barksdale Air Force Base', 28 | u'BDL': u'Bradley International Airport', 29 | u'BFI': u'Boeing Field King County International Airport', 30 | u'BGR': u'Bangor International Airport', 31 | u'BHM': u'Birmingham-Shuttlesworth International Airport', 32 | u'BIL': u'Billings Logan International Airport', 33 | u'BLV': u'Scott AFB/Midamerica Airport', 34 | u'BMI': u'Central Illinois Regional Airport at Bloomington-Normal', 35 | u'BNA': u'Nashville International Airport', 36 | u'BOI': u'Boise Air Terminal/Gowen field', 37 | u'BOS': u'General Edward Lawrence Logan International Airport', 38 | u'BTR': u'Baton Rouge Metropolitan, Ryan Field', 39 | u'BUF': u'Buffalo Niagara International Airport', 40 | u'BWI': u'Baltimore/Washington International Thurgood Marshall Airport', 41 | u'CAE': u'Columbia Metropolitan Airport', 42 | u'CBM': u'Columbus Air Force Base', 43 | u'CHA': u'Lovell Field', 44 | u'CHS': u'Charleston Air Force Base-International Airport', 45 | u'CID': u'The Eastern Iowa Airport', 46 | u'CLE': u'Cleveland Hopkins International Airport', 47 | u'CLT': u'Charlotte Douglas International Airport', 48 | u'CMH': u'Port Columbus International Airport', 49 | u'COS': u'City of Colorado Springs Municipal Airport', 50 | u'CPR': u'Casper-Natrona County International Airport', 51 | u'CRP': u'Corpus Christi International Airport', 52 | u'CRW': u'Yeager Airport', 53 | u'CUN': u'Canc\xfan International Airport', 54 | u'CVG': u'Cincinnati Northern Kentucky International Airport', 55 | u'CVS': u'Cannon Air Force Base', 56 | u'DAB': u'Daytona Beach International Airport', 57 | u'DAL': u'Dallas Love Field', 58 | u'DAY': u'James M Cox Dayton International Airport', 59 | u'DBQ': u'Dubuque Regional Airport', 60 | u'DCA': u'Ronald Reagan Washington National Airport', 61 | u'DEN': u'Denver International Airport', 62 | u'DFW': u'Dallas Fort Worth International Airport', 63 | u'DLF': u'Laughlin Air Force Base', 64 | u'DLH': u'Duluth International Airport', 65 | u'DOV': u'Dover Air Force Base', 66 | u'DSM': u'Des Moines International Airport', 67 | u'DTW': u'Detroit Metropolitan Wayne County Airport', 68 | u'DYS': u'Dyess Air Force Base', 69 | u'EDW': u'Edwards Air Force Base', 70 | u'END': u'Vance Air Force Base', 71 | u'ERI': u'Erie International Tom Ridge Field', 72 | u'EWR': u'Newark Liberty International Airport', 73 | u'FAI': u'Fairbanks International Airport', 74 | u'FFO': u'Wright-Patterson Air Force Base', 75 | u'FLL': u'Fort Lauderdale Hollywood International Airport', 76 | u'FSM': u'Fort Smith Regional Airport', 77 | u'FTW': u'Fort Worth Meacham International Airport', 78 | u'FWA': u'Fort Wayne International Airport', 79 | u'GDL': u'Don Miguel Hidalgo Y Costilla International Airport', 80 | u'GEG': u'Spokane International Airport', 81 | u'GPT': u'Gulfport Biloxi International Airport', 82 | u'GRB': u'Austin Straubel International Airport', 83 | u'GSB': u'Seymour Johnson Air Force Base', 84 | u'GSO': u'Piedmont Triad International Airport', 85 | u'GSP': u'Greenville Spartanburg International Airport', 86 | u'GUS': u'Grissom Air Reserve Base', 87 | u'HIB': u'Range Regional Airport', 88 | u'HMN': u'Holloman Air Force Base', 89 | u'HMO': u'General Ignacio P. Garcia International Airport', 90 | u'HNL': u'Honolulu International Airport', 91 | u'HOU': u'William P Hobby Airport', 92 | u'HSV': u'Huntsville International Carl T Jones Field', 93 | u'HTS': u'Tri-State/Milton J. Ferguson Field', 94 | u'IAD': u'Washington Dulles International Airport', 95 | u'IAH': u'George Bush Intercontinental Houston Airport', 96 | u'ICT': u'Wichita Mid Continent Airport', 97 | u'IND': u'Indianapolis International Airport', 98 | u'JAN': u'Jackson-Medgar Wiley Evers International Airport', 99 | u'JAX': u'Jacksonville International Airport', 100 | u'JFK': u'John F Kennedy International Airport', 101 | u'JLN': u'Joplin Regional Airport', 102 | u'LAS': u'McCarran International Airport', 103 | u'LAX': u'Los Angeles International Airport', 104 | u'LBB': u'Lubbock Preston Smith International Airport', 105 | u'LCK': u'Rickenbacker International Airport', 106 | u'LEX': u'Blue Grass Airport', 107 | u'LFI': u'Langley Air Force Base', 108 | u'LFT': u'Lafayette Regional Airport', 109 | u'LGA': u'La Guardia Airport', 110 | u'LIT': u'Bill & Hillary Clinton National Airport/Adams Field', 111 | u'LTS': u'Altus Air Force Base', 112 | u'LUF': u'Luke Air Force Base', 113 | u'MBS': u'MBS International Airport', 114 | u'MCF': u'Mac Dill Air Force Base', 115 | u'MCI': u'Kansas City International Airport', 116 | u'MCO': u'Orlando International Airport', 117 | u'MDW': u'Chicago Midway International Airport', 118 | u'MEM': u'Memphis International Airport', 119 | u'MEX': u'Licenciado Benito Juarez International Airport', 120 | u'MGE': u'Dobbins Air Reserve Base', 121 | u'MGM': u'Montgomery Regional (Dannelly Field) Airport', 122 | u'MHT': u'Manchester Airport', 123 | u'MIA': u'Miami International Airport', 124 | u'MKE': u'General Mitchell International Airport', 125 | u'MLI': u'Quad City International Airport', 126 | u'MLU': u'Monroe Regional Airport', 127 | u'MOB': u'Mobile Regional Airport', 128 | u'MSN': u'Dane County Regional Truax Field', 129 | u'MSP': u'Minneapolis-St Paul International/Wold-Chamberlain Airport', 130 | u'MSY': u'Louis Armstrong New Orleans International Airport', 131 | u'MTY': u'General Mariano Escobedo International Airport', 132 | u'MUO': u'Mountain Home Air Force Base', 133 | u'OAK': u'Metropolitan Oakland International Airport', 134 | u'OKC': u'Will Rogers World Airport', 135 | u'ONT': u'Ontario International Airport', 136 | u'ORD': u"Chicago O'Hare International Airport", 137 | u'ORF': u'Norfolk International Airport', 138 | u'PAM': u'Tyndall Air Force Base', 139 | u'PBI': u'Palm Beach International Airport', 140 | u'PDX': u'Portland International Airport', 141 | u'PHF': u'Newport News Williamsburg International Airport', 142 | u'PHL': u'Philadelphia International Airport', 143 | u'PHX': u'Phoenix Sky Harbor International Airport', 144 | u'PIA': u'General Wayne A. Downing Peoria International Airport', 145 | u'PIT': u'Pittsburgh International Airport', 146 | u'PPE': u'Mar de Cort\xe9s International Airport', 147 | u'PVR': u'Licenciado Gustavo D\xedaz Ordaz International Airport', 148 | u'PWM': u'Portland International Jetport Airport', 149 | u'RDU': u'Raleigh Durham International Airport', 150 | u'RFD': u'Chicago Rockford International Airport', 151 | u'RIC': u'Richmond International Airport', 152 | u'RND': u'Randolph Air Force Base', 153 | u'RNO': u'Reno Tahoe International Airport', 154 | u'ROA': u'Roanoke\u2013Blacksburg Regional Airport', 155 | u'ROC': u'Greater Rochester International Airport', 156 | u'RST': u'Rochester International Airport', 157 | u'RSW': u'Southwest Florida International Airport', 158 | u'SAN': u'San Diego International Airport', 159 | u'SAT': u'San Antonio International Airport', 160 | u'SAV': u'Savannah Hilton Head International Airport', 161 | u'SBN': u'South Bend Regional Airport', 162 | u'SDF': u'Louisville International Standiford Field', 163 | u'SEA': u'Seattle Tacoma International Airport', 164 | u'SFB': u'Orlando Sanford International Airport', 165 | u'SFO': u'San Francisco International Airport', 166 | u'SGF': u'Springfield Branson National Airport', 167 | u'SHV': u'Shreveport Regional Airport', 168 | u'SJC': u'Norman Y. Mineta San Jose International Airport', 169 | u'SJD': u'Los Cabos International Airport', 170 | u'SKA': u'Fairchild Air Force Base', 171 | u'SLC': u'Salt Lake City International Airport', 172 | u'SMF': u'Sacramento International Airport', 173 | u'SNA': u'John Wayne Airport-Orange County Airport', 174 | u'SPI': u'Abraham Lincoln Capital Airport', 175 | u'SPS': u'Sheppard Air Force Base-Wichita Falls Municipal Airport', 176 | u'SRQ': u'Sarasota Bradenton International Airport', 177 | u'SSC': u'Shaw Air Force Base', 178 | u'STL': u'Lambert St Louis International Airport', 179 | u'SUS': u'Spirit of St Louis Airport', 180 | u'SUU': u'Travis Air Force Base', 181 | u'SUX': u'Sioux Gateway Col. Bud Day Field', 182 | u'SYR': u'Syracuse Hancock International Airport', 183 | u'SZL': u'Whiteman Air Force Base', 184 | u'TCM': u'McChord Air Force Base', 185 | u'TIJ': u'General Abelardo L. Rodr\xedguez International Airport', 186 | u'TIK': u'Tinker Air Force Base', 187 | u'TLH': u'Tallahassee Regional Airport', 188 | u'TOL': u'Toledo Express Airport', 189 | u'TPA': u'Tampa International Airport', 190 | u'TRI': u'Tri Cities Regional Tn Va Airport', 191 | u'TUL': u'Tulsa International Airport', 192 | u'TUS': u'Tucson International Airport', 193 | u'TYS': u'McGhee Tyson Airport', 194 | u'VBG': u'Vandenberg Air Force Base', 195 | u'VPS': u'Destin-Ft Walton Beach Airport', 196 | u'WRB': u'Robins Air Force Base', 197 | u'YEG': u'Edmonton International Airport', 198 | u'YHZ': u'Halifax / Stanfield International Airport', 199 | u'YOW': u'Ottawa Macdonald-Cartier International Airport', 200 | u'YUL': u'Montreal / Pierre Elliott Trudeau International Airport', 201 | u'YVR': u'Vancouver International Airport', 202 | u'YWG': u'Winnipeg / James Armstrong Richardson International Airport', 203 | u'YYC': u'Calgary International Airport', 204 | u'YYJ': u'Victoria International Airport', 205 | u'YYT': u"St. John's International Airport", 206 | u'YYZ': u'Lester B. Pearson International Airport' 207 | } 208 | -------------------------------------------------------------------------------- /test/testdata/sample_app/main.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All rights reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """This is a sample Hello World API implemented using Google Cloud 16 | Endpoints.""" 17 | 18 | import copy 19 | 20 | # [START imports] 21 | import endpoints 22 | from data import AIRPORTS as SOURCE_AIRPORTS 23 | from endpoints import message_types 24 | from endpoints import messages 25 | from endpoints import remote 26 | 27 | # [END imports] 28 | 29 | AIRPORTS = copy.deepcopy(SOURCE_AIRPORTS) 30 | 31 | # [START messages] 32 | IATA_RESOURCE = endpoints.ResourceContainer( 33 | iata=messages.StringField(1, required=True) 34 | ) 35 | 36 | class Airport(messages.Message): 37 | iata = messages.StringField(1, required=True) 38 | name = messages.StringField(2, required=True) 39 | 40 | IATA_AIRPORT_RESOURCE = endpoints.ResourceContainer( 41 | Airport, 42 | iata=messages.StringField(1, required=True) 43 | ) 44 | 45 | class AirportList(messages.Message): 46 | airports = messages.MessageField(Airport, 1, repeated=True) 47 | # [END messages] 48 | 49 | 50 | # [START echo_api] 51 | @endpoints.api(name='iata', version='v1') 52 | class IataApi(remote.Service): 53 | @endpoints.method( 54 | IATA_RESOURCE, 55 | Airport, 56 | path='airport/{iata}', 57 | http_method='GET', 58 | name='get_airport') 59 | def get_airport(self, request): 60 | if request.iata not in AIRPORTS: 61 | raise endpoints.NotFoundException() 62 | return Airport(iata=request.iata, name=AIRPORTS[request.iata]) 63 | 64 | @endpoints.method( 65 | message_types.VoidMessage, 66 | AirportList, 67 | path='airports', 68 | http_method='GET', 69 | name='list_airports') 70 | def list_airports(self, request): 71 | codes = AIRPORTS.keys() 72 | codes.sort() 73 | return AirportList(airports=[ 74 | Airport(iata=iata, name=AIRPORTS[iata]) for iata in codes 75 | ]) 76 | 77 | @endpoints.method( 78 | IATA_RESOURCE, 79 | message_types.VoidMessage, 80 | path='airport/{iata}', 81 | http_method='DELETE', 82 | name='delete_airport', 83 | api_key_required=True) 84 | def delete_airport(self, request): 85 | if request.iata not in AIRPORTS: 86 | raise endpoints.NotFoundException() 87 | del AIRPORTS[request.iata] 88 | return message_types.VoidMessage() 89 | 90 | @endpoints.method( 91 | Airport, 92 | Airport, 93 | path='airport', 94 | http_method='POST', 95 | name='create_airport', 96 | api_key_required=True) 97 | def create_airport(self, request): 98 | if request.iata in AIRPORTS: 99 | raise endpoints.BadRequestException() 100 | AIRPORTS[request.iata] = request.name 101 | return Airport(iata=request.iata, name=AIRPORTS[request.iata]) 102 | 103 | @endpoints.method( 104 | IATA_AIRPORT_RESOURCE, 105 | Airport, 106 | path='airport/{iata}', 107 | http_method='POST', 108 | name='update_airport', 109 | api_key_required=True) 110 | def update_airport(self, request): 111 | if request.iata not in AIRPORTS: 112 | raise endpoints.BadRequestException() 113 | AIRPORTS[request.iata] = request.name 114 | return Airport(iata=request.iata, name=AIRPORTS[request.iata]) 115 | 116 | @endpoints.method( 117 | message_types.VoidMessage, 118 | message_types.VoidMessage, 119 | path='reset', 120 | http_method='POST', 121 | name='reset_data', 122 | api_key_required=True) 123 | def reset_data(self, request): 124 | global AIRPORTS 125 | AIRPORTS = copy.deepcopy(SOURCE_AIRPORTS) 126 | return message_types.VoidMessage() 127 | 128 | # [END echo_api] 129 | 130 | 131 | # [START api_server] 132 | api = endpoints.api_server([IataApi]) 133 | # [END api_server] 134 | -------------------------------------------------------------------------------- /test/types_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for types.""" 16 | 17 | import base64 18 | import json 19 | import os 20 | import string 21 | import time 22 | import unittest 23 | 24 | import test_util 25 | from endpoints import api_config 26 | from endpoints import message_types 27 | from endpoints import messages 28 | from endpoints import remote 29 | from endpoints import types as endpoints_types 30 | 31 | 32 | class ModuleInterfaceTest(test_util.ModuleInterfaceTest, 33 | unittest.TestCase): 34 | 35 | MODULE = endpoints_types 36 | 37 | class TestOAuth2Scope(unittest.TestCase): 38 | def testScope(self): 39 | sample = endpoints_types.OAuth2Scope(scope='foo', description='bar') 40 | converted = endpoints_types.OAuth2Scope(scope='foo', description='foo') 41 | self.assertEqual(sample.scope, 'foo') 42 | self.assertEqual(sample.description, 'bar') 43 | 44 | self.assertEqual(endpoints_types.OAuth2Scope.convert_scope(sample), sample) 45 | self.assertEqual(endpoints_types.OAuth2Scope.convert_scope('foo'), converted) 46 | 47 | self.assertIsNone(endpoints_types.OAuth2Scope.convert_list(None)) 48 | self.assertEqual(endpoints_types.OAuth2Scope.convert_list([sample, 'foo']), [sample, converted]) 49 | -------------------------------------------------------------------------------- /test/util_test.py: -------------------------------------------------------------------------------- 1 | # Copyright 2017 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | """Tests for endpoints.util.""" 16 | 17 | import os 18 | import sys 19 | import unittest 20 | 21 | import endpoints._endpointscfg_setup # pylint: disable=unused-import 22 | import mock 23 | from endpoints import util 24 | 25 | MODULES_MODULE = 'google.appengine.api.modules.modules' 26 | 27 | 28 | class GetProtocolForEnvTest(unittest.TestCase): 29 | 30 | def testGetHostnamePrefixAllDefault(self): 31 | with mock.patch('{0}.get_current_version_name'.format(MODULES_MODULE), 32 | return_value='v1'): 33 | with mock.patch('{0}.get_default_version'.format(MODULES_MODULE), 34 | return_value='v1'): 35 | with mock.patch('{0}.get_current_module_name'.format(MODULES_MODULE), 36 | return_value='default'): 37 | result = util.get_hostname_prefix() 38 | self.assertEqual('', result) 39 | 40 | def testGetHostnamePrefixSpecificVersion(self): 41 | with mock.patch('{0}.get_current_version_name'.format(MODULES_MODULE), 42 | return_value='dev'): 43 | with mock.patch('{0}.get_default_version'.format(MODULES_MODULE), 44 | return_value='v1'): 45 | with mock.patch('{0}.get_current_module_name'.format(MODULES_MODULE), 46 | return_value='default'): 47 | result = util.get_hostname_prefix() 48 | self.assertEqual('dev-dot-', result) 49 | 50 | def testGetHostnamePrefixSpecificModule(self): 51 | with mock.patch('{0}.get_current_version_name'.format(MODULES_MODULE), 52 | return_value='v1'): 53 | with mock.patch('{0}.get_default_version'.format(MODULES_MODULE), 54 | return_value='v1'): 55 | with mock.patch('{0}.get_current_module_name'.format(MODULES_MODULE), 56 | return_value='devmodule'): 57 | result = util.get_hostname_prefix() 58 | self.assertEqual('devmodule-dot-', result) 59 | 60 | def testGetHostnamePrefixSpecificVersionAndModule(self): 61 | with mock.patch('{0}.get_current_version_name'.format(MODULES_MODULE), 62 | return_value='devversion'): 63 | with mock.patch('{0}.get_default_version'.format(MODULES_MODULE), 64 | return_value='v1'): 65 | with mock.patch('{0}.get_current_module_name'.format(MODULES_MODULE), 66 | return_value='devmodule'): 67 | result = util.get_hostname_prefix() 68 | self.assertEqual('devversion-dot-devmodule-dot-', result) 69 | 70 | 71 | if __name__ == '__main__': 72 | unittest.main() 73 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | # Copyright 2018 Google Inc. All Rights Reserved. 2 | # 3 | # Licensed under the Apache License, Version 2.0 (the "License"); 4 | # you may not use this file except in compliance with the License. 5 | # You may obtain a copy of the License at 6 | # 7 | # http://www.apache.org/licenses/LICENSE-2.0 8 | # 9 | # Unless required by applicable law or agreed to in writing, software 10 | # distributed under the License is distributed on an "AS IS" BASIS, 11 | # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 12 | # See the License for the specific language governing permissions and 13 | # limitations under the License. 14 | 15 | [tox] 16 | envlist = py27 17 | #envlist = py27,pep8,pylint-errors,pylint-full 18 | 19 | [tox:travis] 20 | 2.7 = py27 21 | 22 | [testenv] 23 | setenv = 24 | PYTHONPATH = {toxinidir} 25 | 26 | deps = -r{toxinidir}/test-requirements.txt 27 | -r{toxinidir}/requirements.txt 28 | commands = py.test -m "not livetest" --timeout=30 --cov-report html --cov-report=term --cov {toxinidir}/test 29 | 30 | [testenv:livetest] 31 | deps = -r{toxinidir}/test-requirements.txt 32 | -r{toxinidir}/requirements.txt 33 | commands = py.test -m "livetest" {posargs} {toxinidir}/test 34 | passenv = INTEGRATION_PROJECT_ID SERVICE_ACCOUNT_KEYFILE PROJECT_API_KEY 35 | 36 | [testenv:pep8] 37 | deps = flake8 38 | commands = flake8 --max-complexity=10 endpoints --ignore=E501 39 | 40 | [testenv:pylint-errors] 41 | deps = pylint 42 | -r{toxinidir}/test-requirements.txt 43 | -r{toxinidir}/requirements.txt 44 | commands = pylint -f colorized -E endpoints 45 | 46 | [testenv:pylint-warnings] 47 | deps = pylint 48 | commands = pylint -f colorized -d all -e W -r n endpoints 49 | 50 | [testenv:pylint-full] 51 | deps = pylint 52 | -r{toxinidir}/test-requirements.txt 53 | -r{toxinidir}/requirements.txt 54 | commands = pylint -f colorized -e E,W,R -d fixme,locally-disabled endpoints 55 | 56 | [testenv:devenv] 57 | commands = 58 | envdir = {toxworkdir}/develop 59 | basepython = python2.7 60 | usedevelop = True 61 | deps= -r{toxinidir}/test-requirements.txt 62 | -r{toxinidir}/requirements.txt 63 | --------------------------------------------------------------------------------