├── .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 |
--------------------------------------------------------------------------------