├── .gitignore
├── .landscape.yml
├── .travis.yml
├── LICENSE
├── MANIFEST.in
├── README.md
├── calm
├── __init__.py
├── codec.py
├── core.py
├── decorator.py
├── ex.py
├── handler.py
├── param.py
├── resource.py
├── service.py
└── testing.py
├── docs
├── CNAME
├── extra.css
├── index.md
├── intro.md
└── logo
│ ├── calm-logo.png
│ ├── calm-logo.svg
│ ├── soossad2016-small.png
│ ├── soossad2016.png
│ └── soossad2016.svg
├── mkdocs.yml
├── requirements.txt
├── scripts
├── publish-docs.sh
└── run-tests.sh
├── setup.py
└── tests
├── .requirements.txt
├── __init__.py
├── __main__.py
├── test_codec.py
├── test_core.py
├── test_decorator.py
├── test_param.py
├── test_service.py
├── test_swagger.py
└── test_websocket.py
/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__
3 | *.py[cod]
4 | *$py.class
5 |
6 | # C extensions
7 | *.so
8 |
9 | # Distribution / packaging
10 | .Python
11 | env/
12 | build/
13 | develop-eggs/
14 | dist
15 | downloads/
16 | eggs/
17 | .eggs/
18 | lib/
19 | lib64/
20 | parts/
21 | sdist/
22 | var/
23 | *.egg-info
24 | .installed.cfg
25 | *.egg
26 |
27 | # PyInstaller
28 | # Usually these files are written by a python script from a template
29 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
30 | *.manifest
31 | *.spec
32 |
33 | # Installer logs
34 | pip-log.txt
35 | pip-delete-this-directory.txt
36 |
37 | # Unit test / coverage reports
38 | htmlcov/
39 | .tox/
40 | .coverage
41 | .coverage.*
42 | .cache
43 | nosetests.xml
44 | coverage.xml
45 | *,cover
46 | .hypothesis/
47 |
48 | # Translations
49 | *.mo
50 | *.pot
51 |
52 | # Django stuff:
53 | *.log
54 |
55 | # Sphinx documentation
56 | docs/_build/
57 |
58 | # PyBuilder
59 | target/
60 |
61 | #Ipython Notebook
62 | .ipynb_checkpoints
63 |
64 |
65 | docs/.site
66 |
--------------------------------------------------------------------------------
/.landscape.yml:
--------------------------------------------------------------------------------
1 | doc-warnings: no
2 | test-warnings: no
3 | strictness: veryhigh
4 | max-line-length: 80
5 | autodetect: yes
6 | python-targets:
7 | - 3
8 | requirements:
9 | - requirements.txt
10 | ignore-paths:
11 | - docs
12 | - setup.py
13 |
--------------------------------------------------------------------------------
/.travis.yml:
--------------------------------------------------------------------------------
1 | language: python
2 | python:
3 | - "3.5"
4 | install:
5 | - "pip install -r requirements.txt"
6 | - "pip install -r tests/.requirements.txt"
7 | script:
8 | - "scripts/run-tests.sh"
9 | after_success:
10 | - "coveralls"
11 | branches:
12 | only:
13 | - master
14 | notifications:
15 | webhooks:
16 | urls:
17 | - https://webhooks.gitter.im/e/988d1033da1413747904
18 | on_success: change
19 | on_failure: always
20 | on_start: never
21 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2016 Bagrat Aznauryan
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/MANIFEST.in:
--------------------------------------------------------------------------------
1 | include *.txt *.md
2 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Calm
2 |
3 |
4 |
9 |
10 |
11 | [](https://pypi.python.org/pypi/calm)
12 | [](https://travis-ci.org/bagrat/calm)
13 | [](https://coveralls.io/github/bagrat/calm?branch=master)
14 | [](https://landscape.io/github/bagrat/calm/master)
15 | [](https://gitter.im/bagrat/calm?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
16 | [](https://raw.githubusercontent.com/bagrat/calm/master/LICENSE)
17 |
18 | ## Introduction
19 |
20 | Calm is an extension to Tornado Framework that provides decorators and other
21 | tools to easily implement RESTful APIs. The purpose of Calm is to ease the
22 | process of defining your API, parsing argument values in the request handlers,
23 | etc.
24 |
25 | ### Installation
26 |
27 | Calm installation process is dead simple with `pip`:
28 |
29 | ```
30 | $ pip install calm
31 | ```
32 |
33 | *Note: Calm works only with Python 3.5*
34 |
35 | ## Let's code!
36 |
37 | Here is a basic usage example of Calm:
38 |
39 | ```python
40 | import tornado.ioloop
41 | from calm import Application
42 |
43 |
44 | app = Application()
45 |
46 |
47 | @app.get('/hello/:your_name')
48 | async def hello_world(request, your_name):
49 | return {'hello': your_name}
50 |
51 |
52 | tornado_app = app.make_app()
53 | tornado_app.listen(8888)
54 | tornado.ioloop.IOLoop.current().start()
55 | ```
56 |
57 | Now go ahead and try your new application! Navigate to
58 | `http://localhost:8888/hello/YOUR_NAME_HERE` and see what you get.
59 |
60 | Now that you built your first Calm RESTful API, let us dive deeper and see more
61 | features of Calm. Go ahead and add the following code to your first application.
62 |
63 | ```python
64 | # Calm has the notion of a Service. A Service is nothing more than a URL prefix for
65 | # a group of endpoints.
66 | my_service = app.service('/my_service')
67 |
68 |
69 | # So when usually you would define your handler with `@app.get`
70 | # (or `post`, `put`, `delete`), with Service you use the same named methods of
71 | # the Service instance
72 | @my_service.post('/body_demo')
73 | async def body_demo(request):
74 | """
75 | The request body is automatically parsed to a dict.
76 |
77 | If the request body is not a valid JSON, `400` HTTP error is returned.
78 |
79 | When the handler returns not a `dict` object, the return value is nested
80 | into a JSON, e.g.:
81 |
82 | {"result": YOUR_RETURN_VALUE}
83 |
84 | """
85 | return request.body['key']
86 |
87 |
88 | @my_service.get('/args_demo/:number')
89 | async def args_demo(request, number: int, arg1: int, arg2='arg2_default'):
90 | """
91 | You can specify types for your request arguments.
92 |
93 | When specified, Calm will parse the arguments to the appropriate type. When
94 | there is an error parsing the value, `400` HTTP error is returned.
95 |
96 | Any function parameters that do not appear as path arguments, are
97 | considered query arguments. If a default value is assigned for a query
98 | argument it is considered optional. And finally if not all required query
99 | arguments are passed, `400` HTTP error is returned.
100 | """
101 | return {
102 | 'type(number)': str(type(number)),
103 | 'type(arg1)': str(type(arg1)),
104 | 'arg2': arg2
105 | }
106 | ```
107 |
108 | If you followed the comments in the example, then we are ready to play with it!
109 |
110 | First let us see how Calm treats request and response bodies:
111 |
112 | ```
113 | $ curl -X POST --data '{"key": "value"}' 'localhost:8888/my_service/body_demo'
114 | {"result": "value"}
115 |
116 | $ curl -X POST --data '{"another_key": "value"}' 'localhost:8888/my_service/body_demo'
117 | {"error": "Oops our bad. We are working to fix this!"}
118 |
119 | $ curl -X POST --data 'This is not JSON' 'localhost:8888/my_service/body_demo'
120 | {"error": "Malformed request body. JSON is expected."}
121 | ```
122 |
123 | Now it's time to observe some request argument magic!
124 |
125 | ```
126 | $ curl 'localhost:8888/my_service/args_demo/0'
127 | {"error": "Missing required query param 'arg1'"}
128 |
129 | $ curl 'localhost:8888/my_service/args_demo/0?arg1=12'
130 | {"type(arg1)": "", "type(number)": "", "arg2": "arg2_default"}
131 |
132 | $ curl 'localhost:8888/my_service/args_demo/0?arg1=not_a_number'
133 | {"error": "Bad value for integer: not_a_number"}
134 |
135 | $ curl 'localhost:8888/my_service/args_demo/0?arg1=12&arg2=hello'
136 | {"type(arg1)": "", "type(number)": "", "arg2": "hello"}
137 | ```
138 |
139 | ### Adding custom `RequestHandler` implementations
140 |
141 | If you have a custom Tornado `RequestHandler` implementation, you can easily add
142 | them to your Calm application in one of the two ways:
143 |
144 | * using the `Application.add_handler` method
145 | * using the `Application.custom_handler` decorator
146 |
147 | For the first option, you can just define the custom handler and manually add it
148 | to the Calm application, just like you would define a Tornado application:
149 |
150 | ```python
151 | class MyHandler(RequestHandler):
152 | def get(self):
153 | self.write('Hello Custom Handler!')
154 |
155 | app.add_handler('/custom_handler', MyHandler)
156 | ```
157 |
158 | The second option might look more consistent with other Calm-style definitions:
159 |
160 | ```python
161 | @app.custom_handler('/custom_handler')
162 | class MyHandler(RequestHandler):
163 | def get(self):
164 | self.write('Hello Custom Handler!')
165 | ```
166 |
167 | You can also use the `custom_handler` decorator of services, e.g.:
168 |
169 |
170 | ```python
171 | custom_service = app.service('/custom')
172 |
173 | @custom_service.custom_handler('/custom_handler')
174 | class MyHandler(RequestHandler):
175 | def get(self):
176 | self.write('Hello Custom Handler!')
177 | ```
178 |
179 | ## Contributions
180 |
181 | Calm loves Pull Requests and welcomes any contribution be it an issue,
182 | documentation or code. A good start for a contribution can be reviewing existing
183 | open issues and trying to fix one of them.
184 |
185 | If you find nothing to work on but cannot kill the urge, jump into the [gitter
186 | channel](https://gitter.im/n9code/calm) and ask "what can I do?".
187 |
--------------------------------------------------------------------------------
/calm/__init__.py:
--------------------------------------------------------------------------------
1 | """
2 | It is always Calm before a Tornado!
3 |
4 | Calm is a Tornado extension providing tools and decorators to easily develop
5 | RESTful APIs based on Tornado.
6 |
7 | The core of Calm is the Calm Application. The users should initialize an
8 | (or more) instance of `calm.Application` and all the rest is done using that
9 | instance.
10 |
11 | The whole idea of Calm is to follow the common pattern of frameworks, where the
12 | user defines handlers and decorates them with appropriate HTTP methods and
13 | assigns URIs to them. Here is a basic example:
14 |
15 | from calm import Application
16 |
17 | app = Application()
18 |
19 |
20 | @app.get('/hello/:your_name')
21 | def hello_world(request, your_name):
22 | return "Hello %s".format(your_name)
23 |
24 | For more information see `README.md`.
25 | """
26 | import logging
27 |
28 | from calm.core import CalmApp as Application # noqa
29 | from calm.codec import ArgumentParser # noqa
30 |
31 | logging.getLogger('calm').addHandler(logging.NullHandler())
32 |
33 | __version__ = '0.1.4'
34 |
--------------------------------------------------------------------------------
/calm/codec.py:
--------------------------------------------------------------------------------
1 | """
2 | This module defines a parser for Calm and the users.
3 |
4 | Classes:
5 | ArgumentParser - defines a parser base class that enables the users to
6 | provide custom parsers to convert request ArgumentParser
7 | (path, query) to custom types
8 | """
9 |
10 | from calm.ex import DefinitionError, ArgumentParseError
11 |
12 |
13 | class ArgumentParser(object):
14 | """
15 | Extensible default parser for request arguments (path, query).
16 |
17 | When specifying request arguments (path, query) types via annotations,
18 | an instance of this class (or subclass) is used to parse the argument
19 | values. This class defines the default type parsers, but can be further
20 | extended by the user, to add more built-in or custom types.
21 |
22 | The default types supported are:
23 | `int` - parses base 10 number string into `int` object
24 |
25 | To extend this class in a subclass, the user must define a class/instances
26 | attribute named `parser` which should be of type `dict`, mapping types to
27 | parser functions. The parser function should accept one argument, which is
28 | the raw request argument value, and should return the parsed value.
29 |
30 | For convenience it is recommended to define the parser functions as
31 | instance methods, and the provide the `parsers` as a `@property`.
32 |
33 | Example:
34 | class MyArgumentParser(ArgumentParser):
35 | def parse_list(self, value):
36 | # your implementation here
37 |
38 | return parsed_value
39 |
40 | @property
41 | def parsers(self):
42 | return {
43 | list: self.parse_list
44 | }
45 |
46 | If defined a custom argument parser by extending this class, the user must
47 | supply the subclass to the `calm.Application.configure` method, using the
48 | `argument_parser` key.
49 |
50 | Side Effects:
51 | `DefinitionError` - raises when a parser is not implemented for a
52 | requested type
53 | `ArgumentParseError` - raises when the parsing fails for some reason
54 | """
55 | def __init__(self):
56 | super(ArgumentParser, self).__init__()
57 |
58 | self._parsers = {
59 | int: self.parse_int,
60 | bool: self.parse_bool
61 | }
62 |
63 | def parse(self, arg_type, value):
64 | """Parses the `value` to `arg_type` using the appropriate parser."""
65 | if arg_type not in self._parsers:
66 | if hasattr(arg_type, 'parse'):
67 | return arg_type.parse(value)
68 |
69 | raise DefinitionError(
70 | "Argument parser for '{}' is not defined".format(
71 | arg_type
72 | )
73 | )
74 |
75 | return self._parsers[arg_type](value)
76 |
77 | @classmethod
78 | def parse_int(cls, value):
79 | """Parses a base 10 string to `int` object."""
80 | try:
81 | return int(value)
82 | except ValueError:
83 | raise ArgumentParseError(
84 | "Bad value for integer: {}".format(value)
85 | )
86 |
87 | @classmethod
88 | def parse_bool(cls, value):
89 | """Parses a base 10 string to `int` object."""
90 | try:
91 | return {
92 | 'true': True,
93 | 'false': False,
94 | '1': True,
95 | '0': False,
96 | 'yes': True,
97 | 'no': False
98 | }[value.lower()]
99 | except KeyError:
100 | raise ArgumentParseError(
101 | "Bad value for boolean: {}".format(value)
102 | )
103 |
--------------------------------------------------------------------------------
/calm/core.py:
--------------------------------------------------------------------------------
1 | """
2 | Here lies the core of Calm.
3 | """
4 | import re
5 | from collections import defaultdict
6 | from inspect import cleandoc
7 |
8 | from tornado.web import Application
9 | from tornado.websocket import WebSocketHandler
10 |
11 | from calm.ex import DefinitionError, ClientError
12 | from calm.codec import ArgumentParser
13 | from calm.service import CalmService
14 | from calm.handler import (MainHandler, DefaultHandler, SwaggerHandler,
15 | HandlerDef)
16 | from calm.resource import Resource
17 |
18 |
19 | __all__ = ['CalmApp']
20 |
21 |
22 | class CalmApp(object):
23 | """
24 | This class defines the Calm Application.
25 |
26 | Starts using calm by initializing an instance of this class. Afterwards,
27 | the application is being defined by calling its instance methods,
28 | decorators on your user-defined handlers.
29 |
30 | Public Methods:
31 | * configure - use this method to tweak some configuration parameter
32 | of a Calm Application
33 | * get, post, put, delete - appropriate HTTP method decorators.
34 | The user defined handlers should be
35 | decorated by these decorators specifying
36 | the URL
37 | * service - creates a new Service using provided URL prefix
38 | * make_app - compiles the Calm application and returns a Tornado
39 | Application instance
40 | """
41 | URI_REGEX = re.compile(r'\{([^\/\?\}]*)\}')
42 | config = { # The default configuration
43 | 'argument_parser': ArgumentParser,
44 | 'error_key': 'error',
45 | 'swagger_url': '/swagger.json'
46 | }
47 |
48 | def __init__(self, name, version, *,
49 | host=None, base_path=None,
50 | description='', tos=''):
51 | super(CalmApp, self).__init__()
52 |
53 | self._app = None
54 | self._route_map = defaultdict(dict)
55 | self._custom_handlers = []
56 | self._ws_map = {}
57 |
58 | self.name = name
59 | self.version = version
60 | self.description = description
61 | self.tos = tos
62 | self.license = None
63 | self.contact = None
64 | self.host = host
65 | self.base_path = base_path
66 |
67 | self.swagger_json = None
68 |
69 | def set_licence(self, name, url):
70 | """
71 | Set a License information for the API.
72 |
73 | Arguments:
74 | * name - The license name used for the API.
75 | * url - A URL to the license used for the API.
76 | """
77 | self.license = {
78 | 'name': name,
79 | 'url': url
80 | }
81 |
82 | def set_contact(self, name, url, email):
83 | """
84 | Set a contact information for the API.
85 |
86 | Arguments:
87 | * name - The identifying name of the contact person/organization.
88 | * url - The URL pointing to the contact information.
89 | * email - The email address of the contact person/organization.
90 | """
91 | self.contact = {
92 | 'name': name,
93 | 'url': url,
94 | 'email': email
95 | }
96 |
97 | def configure(self, **kwargs):
98 | """
99 | Configures the Calm Application.
100 |
101 | Use this method to customize the Calm Application to your needs.
102 | """
103 | self.config.update(kwargs)
104 |
105 | def make_app(self):
106 | """Compiles and returns a Tornado Application instance."""
107 | route_defs = []
108 |
109 | default_handler_args = {
110 | 'argument_parser': self.config.get('argument_parser',
111 | ArgumentParser),
112 | 'app': self
113 | }
114 |
115 | for uri, methods in self._route_map.items():
116 | init_params = {
117 | **methods, # noqa
118 | **default_handler_args # noqa
119 | }
120 |
121 | route_defs.append(
122 | (self._regexify_uri(uri), MainHandler, init_params)
123 | )
124 |
125 | for url_spec in self._custom_handlers:
126 | route_defs.append(url_spec)
127 |
128 | for uri, handler in self._ws_map.items():
129 | route_defs.append(
130 | (self._regexify_uri(uri), handler)
131 | )
132 |
133 | route_defs.append(
134 | (self.config['swagger_url'],
135 | SwaggerHandler,
136 | default_handler_args)
137 | )
138 |
139 | self._app = Application(route_defs,
140 | default_handler_class=DefaultHandler,
141 | default_handler_args=default_handler_args)
142 |
143 | self.swagger_json = self.generate_swagger_json()
144 |
145 | return self._app
146 |
147 | def add_handler(self, *url_spec):
148 | """Add a custom `RequestHandler` implementation to the app."""
149 | self._custom_handlers.append(url_spec)
150 |
151 | def custom_handler(self, *uri_fragments, init_args=None):
152 | """
153 | Decorator for custom handlers.
154 |
155 | A custom `RequestHandler` implementation decorated with this decorator
156 | will be added to the application with the specified `uri` and
157 | `init_args`.
158 | """
159 | def wrapper(klass):
160 | """Adds the `klass` as a custom handler and returns it back."""
161 | self.add_handler(self._normalize_uri(*uri_fragments),
162 | klass,
163 | init_args)
164 |
165 | return klass
166 |
167 | return wrapper
168 |
169 | @classmethod
170 | def _normalize_uri(cls, *uri_fragments):
171 | """Join the URI fragments and strip."""
172 | uri = '/'.join(
173 | u.strip('/') for u in uri_fragments
174 | )
175 | uri = '/' + uri
176 |
177 | return uri
178 |
179 | def _regexify_uri(self, uri):
180 | """Convert a URL pattern into a regex."""
181 | uri += '/?'
182 | path_params = self.URI_REGEX.findall(uri)
183 | for path_param in path_params:
184 | uri = uri.replace(
185 | '{{{}}}'.format(path_param),
186 | r'(?P<{}>[^\/\?]*)'.format(path_param)
187 | )
188 |
189 | return uri
190 |
191 | def _add_route(self, http_method, function, *uri_fragments,
192 | consumes=None, produces=None):
193 | """
194 | Maps a function to a specific URL and HTTP method.
195 |
196 | Arguments:
197 | * http_method - the HTTP method to map to
198 | * function - the handler function to be mapped to URL and method
199 | * uri - a list of URL fragments. This is used as a tuple for easy
200 | implementation of the Service notion.
201 | * consumes - a Resource type of what the operation consumes
202 | * produces - a Resource type of what the operation produces
203 | """
204 | uri = self._normalize_uri(*uri_fragments)
205 | uri_regex = self._regexify_uri(uri)
206 | handler_def = HandlerDef(uri, uri_regex, function)
207 |
208 | consumes = getattr(function, 'consumes', consumes)
209 | produces = getattr(function, 'produces', produces)
210 | handler_def.consumes = consumes
211 | handler_def.produces = produces
212 |
213 | function.handler_def = handler_def
214 | self._route_map[uri][http_method.lower()] = handler_def
215 |
216 | def _decorator(self, http_method, *uri,
217 | consumes=None, produces=None):
218 | """
219 | A generic HTTP method decorator.
220 |
221 | This method simply stores all the mapping information needed, and
222 | returns the original function.
223 | """
224 | def wrapper(function):
225 | """Takes a record of the function and returns it."""
226 | self._add_route(http_method, function, *uri,
227 | consumes=consumes, produces=produces)
228 | return function
229 |
230 | return wrapper
231 |
232 | def get(self, *uri, consumes=None, produces=None):
233 | """Define GET handler for `uri`"""
234 | return self._decorator("GET", *uri,
235 | consumes=consumes, produces=produces)
236 |
237 | def post(self, *uri, consumes=None, produces=None):
238 | """Define POST handler for `uri`"""
239 | return self._decorator("POST", *uri,
240 | consumes=consumes, produces=produces)
241 |
242 | def delete(self, *uri, consumes=None, produces=None):
243 | """Define DELETE handler for `uri`"""
244 | return self._decorator("DELETE", *uri,
245 | consumes=consumes, produces=produces)
246 |
247 | def put(self, *uri, consumes=None, produces=None):
248 | """Define PUT handler for `uri`"""
249 | return self._decorator("PUT", *uri,
250 | consumes=consumes, produces=produces)
251 |
252 | def websocket(self, *uri_fragments):
253 | """Define a WebSocket handler for `uri`"""
254 | def decor(klass):
255 | """Takes a record of the WebSocket class and returns it."""
256 | if not isinstance(klass, type):
257 | raise DefinitionError("A WebSocket handler should be a class")
258 | elif not issubclass(klass, WebSocketHandler):
259 | name = getattr(klass, '__name__', 'WebSocket handler')
260 | raise DefinitionError(
261 | "{} should subclass '{}'".format(name,
262 | WebSocketHandler.__name__)
263 | )
264 |
265 | uri = self._normalize_uri(*uri_fragments)
266 | self._ws_map[uri] = klass
267 |
268 | return klass
269 |
270 | return decor
271 |
272 | def service(self, url):
273 | """Returns a Service defined by the `url` prefix"""
274 | return CalmService(self, url)
275 |
276 | def _generate_swagger_info(self):
277 | """Generate `info` key of swagger.json."""
278 | info = {
279 | 'title': self.name,
280 | 'version': self.version,
281 | }
282 |
283 | if self.description:
284 | info['description'] = self.description
285 | if self.tos:
286 | info['termsOfService'] = self.tos
287 | if self.contact:
288 | info['contact'] = self.contact
289 | if self.license:
290 | info['license'] = self.license
291 |
292 | return info
293 |
294 | def _generate_swagger_paths(self):
295 | """Generate `paths` definitions of swagger.json."""
296 | paths = defaultdict(dict)
297 | for uri, methods in self._route_map.items():
298 | for method, hdef in methods.items():
299 | paths[uri][method] = hdef.operation_definition
300 |
301 | return dict(paths)
302 |
303 | def _generate_swagger_responses(self):
304 | """Generate `responses` definitions of swagger.json."""
305 | defined_errors = ClientError.get_defined_errors()
306 | return {
307 | e.__name__: {
308 | 'description': cleandoc(e.__doc__),
309 | 'schema': {
310 | '$ref': '#/definitions/Error'
311 | }
312 | }
313 | for e in defined_errors
314 | }
315 |
316 | def _generate_swagger_definitions(self):
317 | """Generate `definitions` definitions of swagger.json."""
318 | resource_defs = Resource.schema_definitions
319 | resource_defs.update(self._generate_error_schema())
320 |
321 | return resource_defs
322 |
323 | def generate_swagger_json(self):
324 | """Generates the swagger.json contents for the Calm Application."""
325 | swagger_json = {
326 | 'swagger': '2.0',
327 | 'info': self._generate_swagger_info(),
328 | 'consumes': ['application/json'],
329 | 'produces': ['application/json'],
330 | 'definitions': self._generate_swagger_definitions(),
331 | 'responses': self._generate_swagger_responses(),
332 | 'paths': self._generate_swagger_paths()
333 | }
334 |
335 | if self.host:
336 | swagger_json['host'] = self.host
337 | if self.base_path:
338 | swagger_json['basePath'] = self.base_path
339 | # TODO: add schemes
340 |
341 | return swagger_json
342 |
343 | def _generate_error_schema(self):
344 | return {
345 | 'Error': {
346 | 'properties': {
347 | self.config['error_key']: {'type': 'string'}
348 | },
349 | 'required': [self.config['error_key']]
350 | }
351 | }
352 |
--------------------------------------------------------------------------------
/calm/decorator.py:
--------------------------------------------------------------------------------
1 | """
2 | This module defines general decorators to define the Calm Application.
3 | """
4 | from calm.resource import Resource
5 | from calm.ex import DefinitionError, ClientError
6 |
7 |
8 | def _set_handler_attribute(func, attr, value):
9 | """
10 | This checks whether the function is already defined as a Calm handler or
11 | not and sets the appropriate attribute based on that. This is done in
12 | order to not enforce a particular order for the decorators.
13 | """
14 | if getattr(func, 'handler_def', None):
15 | setattr(func.handler_def, attr, value)
16 | else:
17 | setattr(func, attr, value)
18 |
19 |
20 | def produces(resource_type):
21 | """Decorator to specify what kind of Resource the handler produces."""
22 | if not issubclass(resource_type, Resource):
23 | raise DefinitionError('@produces value should be of type Resource.')
24 |
25 | def decor(func):
26 | """The function wrapper."""
27 | _set_handler_attribute(func, 'produces', resource_type)
28 |
29 | return func
30 |
31 | return decor
32 |
33 |
34 | def consumes(resource_type):
35 | """Decorator to specify what kind of Resource the handler consumes."""
36 | if not issubclass(resource_type, Resource):
37 | raise DefinitionError('@consumes value should be of type Resource.')
38 |
39 | def decor(func):
40 | """The function wrapper."""
41 | _set_handler_attribute(func, 'consumes', resource_type)
42 |
43 | return func
44 |
45 | return decor
46 |
47 |
48 | def fails(*errors):
49 | """Decorator to specify the list of errors returned by the handler."""
50 | for error in errors:
51 | if not issubclass(error, ClientError):
52 | raise DefinitionError('@fails accepts only subclasses of '
53 | 'calm.ex.ClientError')
54 |
55 | def decor(func):
56 | """The function wrapper."""
57 | _set_handler_attribute(func, 'errors', errors)
58 |
59 | return func
60 |
61 | return decor
62 |
63 |
64 | def deprecated(func):
65 | """Decorator to specify that the handler is deprecated."""
66 | _set_handler_attribute(func, 'deprecated', True)
67 |
68 | return func
69 |
--------------------------------------------------------------------------------
/calm/ex.py:
--------------------------------------------------------------------------------
1 | """
2 | This module defines the custom exception hierarchy of Calm.
3 |
4 | The base exception is `CalmError` from which all the other exceptions
5 | inherit. On the next level, there come two exceptions:
6 |
7 | ClientError - this is the root exception for all the errors that are
8 | caused by the client action. This exception (or a child)
9 | should be risen when there is an error in the client's
10 | request and cannot be processed further.
11 | ServerError - this one is the root exception for all the errors that are
12 | caused by the application, specifically an incorrect usage
13 | of Calm by the application.
14 |
15 | """
16 |
17 |
18 | class CalmError(Exception):
19 | """The omnipresent exception of Calm."""
20 | pass
21 |
22 |
23 | class ClientError(CalmError):
24 | """
25 | The root class for client errors.
26 |
27 | This class has attributes `code` and `message`. The `code` value defines
28 | what HTTP status code will be returned to the client when this exceptions
29 | (or a child) is risen. The message attribute defines the actual message
30 | that will be returned to the client as a response body. If there is no
31 | message defined for the exception, the string using which the exceptions
32 | is initialized while rising will be exposed to the client.
33 | """
34 | code = None
35 | message = None
36 |
37 | @classmethod
38 | def get_defined_errors(cls):
39 | """Get all the errors that have a defined message."""
40 | subclasses = cls.__subclasses__() # pylint: disable=no-member
41 | result = [sc for sc in subclasses if sc.__doc__]
42 | for subclass in subclasses:
43 | result += subclass.get_defined_errors()
44 |
45 | return result
46 |
47 |
48 | class BadRequestError(ClientError):
49 | """
50 | The error when client made the request incorrectly.
51 |
52 | Examples are malformed request body, missing arguments, etc.
53 | """
54 | code = 400
55 |
56 |
57 | class ArgumentParseError(BadRequestError):
58 | """Error when Calm has trouble parsing the request arguments."""
59 | pass
60 |
61 |
62 | class MethodNotAllowedError(ClientError):
63 | """Error when the request method is not implemented for the URL."""
64 | code = 405
65 | message = "Method not allowed"
66 |
67 |
68 | class NotFoundError(ClientError):
69 | """Error when the requested URL is not found."""
70 | code = 404
71 | message = "Resource not found"
72 |
73 |
74 | class ServerError(CalmError):
75 | """The root class for server errors."""
76 | pass
77 |
78 |
79 | class DefinitionError(ServerError):
80 | """Error when the application programmer uses Calm incorrectly."""
81 | pass
82 |
--------------------------------------------------------------------------------
/calm/handler.py:
--------------------------------------------------------------------------------
1 | """
2 | This module extends and defines Torndo RequestHandlers.
3 |
4 | Classes:
5 | * MainHandler - this is the main RequestHandler subclass, that is mapped to
6 | all URIs and serves as a dispatcher handler.
7 | * DefaultHandler - this class serves all the URIs that are not defined
8 | within the application, returning `404` error.
9 | """
10 | import re
11 | import json
12 | import inspect
13 | from inspect import Parameter
14 | import logging
15 | import datetime
16 |
17 | from tornado.web import RequestHandler
18 | from tornado.web import MissingArgumentError
19 |
20 | from untt.util import parse_docstring
21 | from untt.ex import ValidationError
22 |
23 | from calm.ex import (ServerError, ClientError, BadRequestError,
24 | MethodNotAllowedError, NotFoundError, DefinitionError)
25 | from calm.param import QueryParam, PathParam
26 |
27 | __all__ = ['MainHandler', 'DefaultHandler']
28 |
29 |
30 | class MainHandler(RequestHandler):
31 | """
32 | The main dispatcher request handler.
33 |
34 | This class extends the Tornado `RequestHandler` class, and it is mapped to
35 | all the defined applications handlers handlers. This class implements all
36 | HTTP method handlers, which dispatch the control to the appropriate user
37 | handlers based on their definitions and request itself.
38 | """
39 | BUILTIN_TYPES = (str, list, tuple, set, int, float, datetime.datetime)
40 |
41 | def __init__(self, *args, **kwargs):
42 | """
43 | Initializes the dispatcher request handler.
44 |
45 | Arguments:
46 | * get, post, put, delete - appropriate HTTP method handler for
47 | a specific URI
48 | * argument_parser - a `calm.ArgumentParser` subclass
49 | * app - the Calm application
50 | """
51 | self._get_handler = kwargs.pop('get', None)
52 | self._post_handler = kwargs.pop('post', None)
53 | self._put_handler = kwargs.pop('put', None)
54 | self._delete_handler = kwargs.pop('delete', None)
55 |
56 | self._argument_parser = kwargs.pop('argument_parser')()
57 | self._app = kwargs.pop('app')
58 |
59 | self.log = logging.getLogger('calm')
60 |
61 | super(MainHandler, self).__init__(*args, **kwargs)
62 |
63 | def _get_query_args(self, handler_def):
64 | """Retreives the values for query arguments."""
65 | query_args = {}
66 | for qarg in handler_def.query_args:
67 | try:
68 | query_args[qarg.name] = self.get_query_argument(qarg.name)
69 | except MissingArgumentError:
70 | if not qarg.required:
71 | continue
72 |
73 | raise BadRequestError(
74 | "Missing required query argument '{}'".format(qarg.name)
75 | )
76 |
77 | return query_args
78 |
79 | def _cast_args(self, handler, args):
80 | """Converts the request arguments to appropriate types."""
81 | arg_types = handler.__annotations__
82 | for arg in args:
83 | arg_type = arg_types.get(arg)
84 |
85 | if not arg_type:
86 | continue
87 |
88 | args[arg] = self._argument_parser.parse(arg_type, args[arg])
89 |
90 | def _parse_and_update_body(self, handler_def):
91 | """Parses the request body to JSON."""
92 | if self.request.body:
93 | try:
94 | json_body = json.loads(self.request.body.decode('utf-8'))
95 | except json.JSONDecodeError:
96 | raise BadRequestError(
97 | "Malformed request body. JSON is expected."
98 | )
99 |
100 | new_body = json_body
101 | if handler_def.consumes:
102 | try:
103 | new_body = handler_def.consumes.from_json(json_body)
104 | except ValidationError:
105 | # TODO: log warning or error
106 | raise BadRequestError("Bad data structure.")
107 |
108 | self.request.body = new_body
109 |
110 | async def _handle_request(self, handler_def, **kwargs):
111 | """A generic HTTP method handler."""
112 | if not handler_def:
113 | raise MethodNotAllowedError()
114 |
115 | handler = handler_def.handler
116 | kwargs.update(self._get_query_args(handler_def))
117 | self._cast_args(handler, kwargs)
118 | self._parse_and_update_body(handler_def)
119 | if inspect.iscoroutinefunction(handler):
120 | resp = await handler(self.request, **kwargs)
121 | else:
122 | self.log.warning("'%s' is not a coroutine!", handler_def.handler)
123 | resp = handler(self.request, **kwargs)
124 |
125 | if resp:
126 | self._write_response(resp, handler_def)
127 |
128 | async def get(self, **kwargs):
129 | """The HTTP GET handler."""
130 | await self._handle_request(self._get_handler, **kwargs)
131 |
132 | async def post(self, **kwargs):
133 | """The HTTP POST handler."""
134 | await self._handle_request(self._post_handler, **kwargs)
135 |
136 | async def put(self, **kwargs):
137 | """The HTTP PUT handler."""
138 | await self._handle_request(self._put_handler, **kwargs)
139 |
140 | async def delete(self, **kwargs):
141 | """The HTTP DELETE handler."""
142 | await self._handle_request(self._delete_handler, **kwargs)
143 |
144 | def _write_response(self, response, handler_def=None):
145 | """Converts various types to JSON and returns to the client"""
146 | result = response
147 | if hasattr(response, '__json__'):
148 | result = response.__json__()
149 |
150 | if handler_def:
151 | if handler_def.produces:
152 | try:
153 | handler_def.produces.validate(result)
154 | except ValidationError:
155 | self.log.warning("Bad output data structure in '%s'",
156 | handler_def.uri)
157 | else:
158 | self.log.warning("'%s' has no return type but returns data.",
159 | handler_def.uri)
160 |
161 | try:
162 | json_str = json.dumps(result)
163 | except TypeError:
164 | raise ServerError(
165 | "Could not serialize '{}' to JSON".format(
166 | type(response).__name__
167 | )
168 | )
169 |
170 | self.set_header('Content-Type', 'application/json')
171 | self.write(json_str)
172 | self.finish()
173 |
174 | def write_error(self, status_code, exc_info=None, **kwargs):
175 | """The top function for writing errors"""
176 | if exc_info:
177 | exc_type, exc_inst, _ = exc_info
178 | if issubclass(exc_type, ClientError):
179 | self._write_client_error(exc_inst)
180 | return
181 |
182 | self._write_server_error()
183 |
184 | def _write_client_error(self, exc):
185 | """Formats and returns a client error to the client"""
186 | result = {
187 | self._app.config['error_key']: exc.message or str(exc)
188 | }
189 |
190 | self.set_status(exc.code)
191 | self.write(json.dumps(result))
192 |
193 | def _write_server_error(self):
194 | """Formats and returns a server error to the client"""
195 | result = {
196 | self._app.config['error_key']: 'Oops our bad. '
197 | 'We are working to fix this!'
198 | }
199 |
200 | self.set_status(500)
201 | self.write(json.dumps(result))
202 |
203 | def data_received(self, data): # pragma: no cover
204 | """This is to ommit quality check errors."""
205 | pass
206 |
207 |
208 | class DefaultHandler(MainHandler):
209 | """
210 | This class extends the main dispatcher class for request handlers
211 | `MainHandler`.
212 |
213 | It implements the `_handle_request` method and raises `NotFoundError` which
214 | will be returned to the user as an appropriate JSON message.
215 | """
216 | async def _handle_request(self, *_, **dummy):
217 | raise NotFoundError()
218 |
219 |
220 | class HandlerDef(object):
221 | """
222 | Defines a request handler.
223 |
224 | During initialization, the instance will process and store all argument
225 | information.
226 | """
227 | URI_REGEX = re.compile(r'\{([^\/\?\}]*)\}')
228 |
229 | def __init__(self, uri, uri_regex, handler):
230 | super(HandlerDef, self).__init__()
231 |
232 | self.uri = uri
233 | self.uri_regex = uri_regex
234 | self.handler = handler
235 | self._signature = inspect.signature(handler)
236 | self._params = {
237 | k: v for k, v in list(
238 | self._signature.parameters.items()
239 | )[1:]
240 | }
241 |
242 | self.path_args = []
243 | self.query_args = []
244 |
245 | self.consumes = getattr(handler, 'consumes', None)
246 | self.produces = getattr(handler, 'produces', None)
247 | self.errors = getattr(handler, 'errors', [])
248 | self.deprecated = getattr(handler, 'deprecated', False)
249 |
250 | self._extract_arguments()
251 | self.operation_definition = self._generate_operation_definition()
252 |
253 | def _extract_path_args(self):
254 | """Extracts path arguments from the URI."""
255 | regex = re.compile(self.uri_regex)
256 | path_arg_names = list(regex.groupindex.keys())
257 |
258 | for arg_name in path_arg_names:
259 | if arg_name in self._params:
260 | if self._params[arg_name].default is not Parameter.empty:
261 | raise DefinitionError(
262 | "Path argument '{}' must not be optional in '{}'"
263 | .format(
264 | arg_name,
265 | self.handler.__name__
266 | )
267 | )
268 |
269 | self.path_args.append(
270 | PathParam(arg_name,
271 | self._params[arg_name].annotation)
272 | )
273 | else:
274 | raise DefinitionError(
275 | "Path argument '{}' must be expected by '{}'".format(
276 | arg_name,
277 | self.handler.__name__
278 | )
279 | )
280 |
281 | def _extract_query_arguments(self):
282 | """
283 | Extracts query arguments from handler signature
284 |
285 | Should be called after path arguments are extracted.
286 | """
287 | for _, param in self._params.items():
288 | if param.name not in [a.name for a in self.path_args]:
289 | self.query_args.append(
290 | QueryParam(param.name,
291 | param.annotation,
292 | param.default)
293 | )
294 |
295 | def _extract_arguments(self):
296 | """Extracts path and query arguments."""
297 | self._extract_path_args()
298 | self._extract_query_arguments()
299 |
300 | def _generate_operation_definition(self):
301 | summary, description = parse_docstring(self.handler.__doc__ or '')
302 |
303 | operation_id = '.'.join(
304 | [self.handler.__module__, self.handler.__name__]
305 | ).replace('.', '_')
306 |
307 | parameters = [q.generate_swagger() for q in self.path_args]
308 | parameters += [q.generate_swagger() for q in self.query_args]
309 |
310 | if self.consumes:
311 | parameters.append({
312 | 'in': 'body',
313 | 'name': 'body',
314 | 'schema': self.consumes.json_schema
315 | })
316 |
317 | responses = {}
318 | if self.produces:
319 | responses['200'] = {
320 | 'description': '', # TODO: decide what to put down here
321 | 'schema': self.produces.json_schema
322 | }
323 | else:
324 | responses['204'] = {
325 | 'description': 'This endpoint does not return data.'
326 | }
327 |
328 | for error in self.errors:
329 | responses[str(error.code)] = {
330 | '$ref': '#/responses/{}'.format(error.__name__)
331 | }
332 |
333 | opdef = {
334 | 'summary': summary,
335 | 'description': description,
336 | 'operationId': operation_id,
337 | 'parameters': parameters,
338 | 'responses': responses
339 | }
340 |
341 | if self.deprecated:
342 | opdef['deprecated'] = True
343 |
344 | return opdef
345 |
346 |
347 | class SwaggerHandler(DefaultHandler):
348 | """
349 | The handler for Swagger.io (OpenAPI).
350 |
351 | This handler defined the GET method to output the Swagger.io (OpenAPI)
352 | definition for the Calm Application.
353 | """
354 | async def get(self):
355 | self._write_response(self._app.swagger_json)
356 |
--------------------------------------------------------------------------------
/calm/param.py:
--------------------------------------------------------------------------------
1 | from inspect import Parameter as P
2 |
3 | from calm.ex import DefinitionError
4 |
5 |
6 | class Parameter(object):
7 | def __init__(self, name, param_type, param_in, default=P.empty):
8 | super().__init__()
9 |
10 | # TODO: add param description
11 | self.name = name
12 | self.param_type = param_type if param_type is not P.empty else str
13 | try:
14 | self.json_type = ParameterJsonType.from_python_type(self.param_type)
15 | except TypeError as ex:
16 | raise DefinitionError(
17 | "Wrong argument type for '{}'".format(name)
18 | ) from ex
19 | self.param_in = param_in
20 | self.required = default is P.empty
21 | self.default = default if default is not P.empty else None
22 |
23 | def generate_swagger(self):
24 | swagger = {
25 | 'name': self.name,
26 | 'in': self.param_in,
27 | 'type': self.json_type,
28 | 'required': self.required
29 | }
30 |
31 | if self.json_type == 'array':
32 | swagger['items'] = self.json_type.params['items']
33 |
34 | if not self.required:
35 | swagger['default'] = self.default
36 |
37 | return swagger
38 |
39 |
40 | class PathParam(Parameter):
41 | def __init__(self, name, param_type):
42 | super().__init__(name, param_type, 'path')
43 |
44 |
45 | class QueryParam(Parameter):
46 | def __init__(self, name, param_type, default=P.empty):
47 | super().__init__(name, param_type, 'query', default)
48 |
49 |
50 | class ParameterJsonType(str):
51 | """An extended representation of a JSON type."""
52 | basic_type_map = {
53 | int: 'integer',
54 | float: 'number',
55 | str: 'string',
56 | bool: 'boolean'
57 | }
58 |
59 | def __init__(self, name):
60 | super().__init__()
61 |
62 | self.name = name
63 | self.params = {}
64 |
65 | @classmethod
66 | def from_python_type(cls, python_type):
67 | """Converts a Python type to a JsonType object."""
68 | if isinstance(python_type, list):
69 | if len(python_type) != 1:
70 | raise TypeError(
71 | "Array type can contain only one element, i.e item type."
72 | )
73 |
74 | if isinstance(python_type[0], list):
75 | raise TypeError("Wrong parameter type: list of lists.")
76 |
77 | json_type = ParameterJsonType('array')
78 | json_type.params['items'] = cls.from_python_type(python_type[0])
79 |
80 | return json_type
81 |
82 | if isinstance(python_type, type):
83 | if python_type in cls.basic_type_map:
84 | # This is done to check direct equality to avoid subclass
85 | # anomalies, e.g. `bool` is a subclass of `int`.
86 | return ParameterJsonType(cls.basic_type_map[python_type])
87 |
88 | for supported_type in cls.basic_type_map:
89 | if issubclass(python_type, supported_type):
90 | return ParameterJsonType(
91 | cls.basic_type_map[supported_type]
92 | )
93 |
94 | raise TypeError(
95 | "No associated JSON type for '{}'".format(python_type)
96 | )
97 |
--------------------------------------------------------------------------------
/calm/resource.py:
--------------------------------------------------------------------------------
1 | from untt import Entity
2 | from untt.util import entity_base
3 | from untt.types import (Integer, Number, String, # noqa
4 | Boolean, Array, Datetime)
5 |
6 |
7 | @entity_base
8 | class Resource(Entity):
9 | schema_root = True
10 |
11 | def __json__(self):
12 | """Proxies `Entity.to_json()`."""
13 | return self.to_json() # pragma: no cover
14 |
--------------------------------------------------------------------------------
/calm/service.py:
--------------------------------------------------------------------------------
1 | """
2 | This module defines a class that implements the notion of a Service.
3 |
4 | When the user defines several handlers that share a common URL prefix, Calm
5 | enables them to define a Service using that common prefix. Afterwards, all
6 | that handlers can be defines using that prefix, without specifying the
7 | repeating common prefix, eliminating duplication and irritating bugs.
8 | """
9 |
10 |
11 | class CalmService(object):
12 | """
13 | This class implements the Service notion.
14 |
15 | An instance of this class is initialized when `calm.Application.service()`
16 | is called, with the service prefix. This class simply redefines the HTTP
17 | method decorators by prepending the Service prefix to the handler URL.
18 | """
19 | def __init__(self, app, url):
20 | """
21 | Initializes a Calm Service.
22 |
23 | Arguments:
24 | app - the calm application the service belongs to
25 | url - the service prefix
26 | """
27 | super(CalmService, self).__init__()
28 |
29 | self._app = app
30 | self._url = url
31 |
32 | def get(self, *url):
33 | """Extends the GET HTTP method decorator."""
34 | return self._app.get(self._url, *url)
35 |
36 | def post(self, *url):
37 | """Extends the POST HTTP method decorator."""
38 | return self._app.post(self._url, *url)
39 |
40 | def put(self, *url):
41 | """Extends the PUT HTTP method decorator."""
42 | return self._app.put(self._url, *url)
43 |
44 | def delete(self, *url):
45 | """Extends the DELETE HTTP method decorator."""
46 | return self._app.delete(self._url, *url)
47 |
48 | def custom_handler(self, *url, init_args=None):
49 | """Extends the custom handler addition to support services."""
50 | return self._app.custom_handler(self._url, *url, init_args=init_args)
51 |
--------------------------------------------------------------------------------
/calm/testing.py:
--------------------------------------------------------------------------------
1 | """
2 | This is the testing module for Calm applications.
3 |
4 | This defines a handy subclass with its utilities, so that you can use them to
5 | test your Calm applications more conveniently and with less code.
6 | """
7 | import json
8 |
9 | from tornado.testing import AsyncHTTPTestCase
10 | from tornado.websocket import websocket_connect
11 |
12 | from calm.core import CalmApp
13 |
14 |
15 | class CalmHTTPTestCase(AsyncHTTPTestCase):
16 | """
17 | This is the base class to inherit in order to test your Calm app.
18 |
19 | You may use this to test only the HTTP part of your application.
20 | """
21 | def get_calm_app(self):
22 | """
23 | This method needs to be implemented by the user.
24 |
25 | Simply return an instance of your Calm application so that Calm will
26 | know what are you testing.
27 | """
28 | pass # pragma: no cover
29 |
30 | def get_app(self):
31 | """This one is for Tornado, returns the app under test."""
32 | calm_app = self.get_calm_app()
33 |
34 | if calm_app is None or not isinstance(calm_app, CalmApp):
35 | raise NotImplementedError( # pragma: no cover
36 | "Please implement CalmTestCase.get_calm_app()"
37 | )
38 |
39 | return calm_app.make_app()
40 |
41 | def _request(self, url, *args,
42 | expected_code=200,
43 | expected_body=None,
44 | expected_json_body=None,
45 | query_args=None,
46 | json_body=None,
47 | **kwargs):
48 | """
49 | Makes a request to the `url` of the app and makes assertions.
50 | """
51 | # generate the query fragment of the URL
52 | if query_args is None:
53 | query_string = ''
54 | else:
55 | query_args_kv = ['='.join(
56 | [k, str(v)]
57 | ) for k, v in query_args.items()]
58 | query_string = '&'.join(query_args_kv)
59 | if query_string:
60 | url = url + '?' + query_string
61 |
62 | if ((kwargs.get('body') or json_body) and
63 | kwargs['method'] not in ('POST', 'PUT')):
64 | raise Exception( # pragma: no cover
65 | "Cannot send body with methods other than POST and PUT"
66 | )
67 |
68 | if not kwargs.get('body'):
69 | if kwargs['method'] in ('POST', 'PUT'):
70 | if json_body:
71 | kwargs['body'] = json.dumps(json_body)
72 | else:
73 | kwargs['body'] = '{}'
74 |
75 | resp = self.fetch(url, *args, **kwargs)
76 |
77 | actual_code = resp.code
78 | self.assertEqual(actual_code, expected_code)
79 |
80 | if expected_body:
81 | self.assertEqual(resp.body.decode('utf-8'),
82 | expected_body) # pragma: no cover
83 |
84 | if expected_json_body:
85 | actual_json_body = json.loads(resp.body.decode('utf-8'))
86 | self.assertEqual(expected_json_body, actual_json_body)
87 |
88 | return resp
89 |
90 | def get(self, url, *args, **kwargs):
91 | """Makes a `GET` request to the `url` of your app."""
92 | kwargs.update(method='GET')
93 | return self._request(url, *args, **kwargs)
94 |
95 | def post(self, url, *args, **kwargs):
96 | """Makes a `POST` request to the `url` of your app."""
97 | kwargs.update(method='POST')
98 | return self._request(url, *args, **kwargs)
99 |
100 | def put(self, url, *args, **kwargs):
101 | """Makes a `PUT` request to the `url` of your app."""
102 | kwargs.update(method='PUT')
103 | return self._request(url, *args, **kwargs)
104 |
105 | def delete(self, url, *args, **kwargs):
106 | """Makes a `DELETE` request to the `url` of your app."""
107 | kwargs.update(method='DELETE')
108 | return self._request(url, *args, **kwargs)
109 |
110 |
111 | class CalmWebSocketTestCase(AsyncHTTPTestCase):
112 | """
113 | This is the base class to inherit in order to test your WS handlers.
114 | """
115 | def __init__(self, *args, **kwargs):
116 | super(CalmWebSocketTestCase, self).__init__(*args, **kwargs)
117 |
118 | self._websocket = None
119 |
120 | def get_calm_app(self):
121 | """
122 | This method needs to be implemented by the user.
123 |
124 | Simply return an instance of your Calm application so that Calm will
125 | know what are you testing.
126 | """
127 | pass # pragma: no cover
128 |
129 | def get_app(self):
130 | """This one is for Tornado, returns the app under test."""
131 | calm_app = self.get_calm_app()
132 |
133 | if calm_app is None or not isinstance(calm_app, CalmApp):
134 | raise NotImplementedError( # pragma: no cover
135 | "Please implement CalmTestCase.get_calm_app()"
136 | )
137 |
138 | return calm_app.make_app()
139 |
140 | async def init_websocket(self, url):
141 | """Initiate a new WebSocket connection."""
142 | self._websocket = await websocket_connect(self.get_url(url))
143 | return self._websocket
144 |
145 | def get_protocol(self):
146 | """Override for `get_url` to return with schema `ws://`"""
147 | return 'ws'
148 |
--------------------------------------------------------------------------------
/docs/CNAME:
--------------------------------------------------------------------------------
1 | calm.n9co.de
2 |
--------------------------------------------------------------------------------
/docs/extra.css:
--------------------------------------------------------------------------------
1 | .navbar-right {
2 | visibility: hidden
3 | }
4 |
5 | .col-md-12 {
6 | visibility: hidden
7 | }
8 |
--------------------------------------------------------------------------------
/docs/index.md:
--------------------------------------------------------------------------------
1 |
2 |
7 |
8 |
9 | # Calm
10 |
11 |
12 | *It is always Calm before a Tornado!*
13 | ****
14 |
15 |
16 |
--------------------------------------------------------------------------------
/docs/intro.md:
--------------------------------------------------------------------------------
1 | # Introduction
2 |
--------------------------------------------------------------------------------
/docs/logo/calm-logo.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagrat/calm/5f85bfcdc063b4cf898b64bb622bed879d1a5b04/docs/logo/calm-logo.png
--------------------------------------------------------------------------------
/docs/logo/calm-logo.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
184 |
--------------------------------------------------------------------------------
/docs/logo/soossad2016-small.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagrat/calm/5f85bfcdc063b4cf898b64bb622bed879d1a5b04/docs/logo/soossad2016-small.png
--------------------------------------------------------------------------------
/docs/logo/soossad2016.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagrat/calm/5f85bfcdc063b4cf898b64bb622bed879d1a5b04/docs/logo/soossad2016.png
--------------------------------------------------------------------------------
/docs/logo/soossad2016.svg:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
253 |
--------------------------------------------------------------------------------
/mkdocs.yml:
--------------------------------------------------------------------------------
1 | site_name: Calm
2 | site_favicon: ./logo/calm-logo.png
3 | pages:
4 | - Home: index.md
5 | - Introduction: intro.md
6 | theme: cosmo
7 | site_dir: docs/.site
8 | include_next_prev: False
9 |
10 | extra:
11 | extra.include_toc: True
12 |
--------------------------------------------------------------------------------
/requirements.txt:
--------------------------------------------------------------------------------
1 | tornado==4.3
2 | python-dateutil==2.5.3
3 | iso8601==0.1.11
4 | pytz
5 | untt==0.1
6 |
--------------------------------------------------------------------------------
/scripts/publish-docs.sh:
--------------------------------------------------------------------------------
1 | # This calls `mkdocs` to push docs to `gh-pages` branch
2 | mkdocs gh-deploy --clean
3 |
--------------------------------------------------------------------------------
/scripts/run-tests.sh:
--------------------------------------------------------------------------------
1 | # Clean old coverage report
2 | rm .coverage
3 |
4 | # Remove old pyc files
5 | find . -name "*.pyc" -delete
6 |
7 | # Run tests with coverage
8 | python -m tests
9 |
--------------------------------------------------------------------------------
/setup.py:
--------------------------------------------------------------------------------
1 | from os import path
2 | import codecs
3 |
4 | from setuptools import setup, find_packages
5 |
6 | here = path.abspath(path.dirname(__file__))
7 |
8 | with codecs.open(path.join(here, 'requirements.txt'),
9 | encoding='utf-8') as reqs:
10 | requirements = reqs.read()
11 |
12 | setup(
13 | name='calm',
14 | version='0.1.4',
15 |
16 | description='It is always Calm before a Tornado!',
17 | long_description="""
18 | Calm is an extension to Tornado Framework for building RESTful APIs.
19 |
20 | Navigate to http://calm.n9co.de for more information.
21 | """,
22 |
23 | url='http://calm.n9co.de',
24 |
25 | author='Bagrat Aznauryan',
26 | author_email='bagrat@aznauryan.org',
27 |
28 | license='MIT',
29 |
30 | classifiers=[
31 | 'Development Status :: 3 - Alpha',
32 |
33 | 'Intended Audience :: Developers',
34 | 'Intended Audience :: Information Technology',
35 |
36 | 'License :: OSI Approved :: MIT License',
37 |
38 | 'Programming Language :: Python :: 3.5',
39 | 'Programming Language :: Python :: 3',
40 | 'Programming Language :: Python :: 3 :: Only',
41 |
42 | 'Topic :: Internet :: WWW/HTTP',
43 | 'Topic :: Internet :: WWW/HTTP :: HTTP Servers',
44 | 'Topic :: Software Development',
45 | 'Topic :: Software Development :: Libraries',
46 | 'Topic :: Software Development :: Libraries :: Application Frameworks',
47 | ],
48 |
49 | keywords='tornado rest restful api framework',
50 |
51 | packages=find_packages(exclude=['docs', 'tests']),
52 |
53 | install_requires=requirements,
54 | )
55 |
--------------------------------------------------------------------------------
/tests/.requirements.txt:
--------------------------------------------------------------------------------
1 | nose
2 | coverage
3 | coveralls
4 |
--------------------------------------------------------------------------------
/tests/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/bagrat/calm/5f85bfcdc063b4cf898b64bb622bed879d1a5b04/tests/__init__.py
--------------------------------------------------------------------------------
/tests/__main__.py:
--------------------------------------------------------------------------------
1 | import sys
2 | import nose
3 |
4 | sys.argv = sys.argv + [
5 | '-s',
6 | '-v',
7 | '--with-coverage',
8 | '--cover-package=calm',
9 | '--cover-erase',
10 | ]
11 |
12 | nose.main()
13 |
--------------------------------------------------------------------------------
/tests/test_codec.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 | from unittest.mock import MagicMock
3 |
4 | from calm.codec import ArgumentParser
5 | from calm.ex import DefinitionError, ArgumentParseError
6 |
7 |
8 | class CodecTests(TestCase):
9 | def test_argument_parser(self):
10 | parser = ArgumentParser()
11 |
12 | expected = 567
13 | actual = parser.parse(int, expected)
14 |
15 | self.assertEqual(expected, actual)
16 | self.assertRaises(DefinitionError, parser.parse, tuple, "nvm")
17 |
18 | def test_custom_type(self):
19 | custom_type = MagicMock()
20 |
21 | parser = ArgumentParser()
22 |
23 | parser.parse(custom_type, 1234)
24 |
25 | custom_type.parse.assert_called_once_with(1234)
26 |
27 | def test_bool_type(self):
28 | parser = ArgumentParser()
29 |
30 | self.assertTrue(parser.parse(bool, 'yes'))
31 | self.assertRaises(ArgumentParseError,
32 | parser.parse, bool, 'womp')
33 |
--------------------------------------------------------------------------------
/tests/test_core.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from tornado.web import RequestHandler
4 |
5 | from calm.testing import CalmHTTPTestCase
6 | from calm import Application
7 | from calm.ex import DefinitionError, MethodNotAllowedError, NotFoundError
8 | from calm.resource import Resource, Integer, String
9 | from calm.decorator import produces, consumes
10 |
11 |
12 | app = Application('testapp', '1')
13 |
14 |
15 | @app.get('/async/{param}')
16 | async def async_mock(request, param):
17 | return param
18 |
19 |
20 | @app.post('/sync/{param}')
21 | def sync_mock(request, param):
22 | return param
23 |
24 |
25 | @app.get('/part1/', '/part2')
26 | async def fragments_handler(request, retparam):
27 | return retparam
28 |
29 |
30 | @app.put('/default')
31 | async def default_handler(request, p1, p2, p3, p4,
32 | p5, p6, p7, p8='p8', p9='p9'):
33 | return p1 + p2 + p3 + p4 + p5 + p6 + p7 + p8 + p9
34 |
35 |
36 | @app.get('/required_query_param')
37 | def required_query_param(request, query_param):
38 | pass
39 |
40 |
41 | @app.get('/blowup')
42 | def blow_things_up(request):
43 | raise TypeError()
44 |
45 |
46 | @app.delete('/response/{rtype}')
47 | def response_manipulations(request, rtype):
48 | if rtype == 'str':
49 | return 'test_result'
50 | elif rtype == 'list':
51 | return ['test', 'result']
52 | elif rtype == 'dict':
53 | return {'test': 'result'}
54 | elif rtype == 'object':
55 | class jsonable():
56 | def __json__(self):
57 | return {'test': 'result'}
58 |
59 | return jsonable()
60 | else:
61 | return object()
62 |
63 |
64 | @app.get('/argtypes')
65 | def argument_types(request, arg1, arg2: int):
66 | return arg1, arg2
67 |
68 |
69 | @app.post('/json/body')
70 | def json_body(request):
71 | return request.body
72 |
73 |
74 | custom_service = app.service('/custom')
75 |
76 |
77 | @custom_service.custom_handler('/handler')
78 | class MyHandler(RequestHandler):
79 | def get(self):
80 | self.write("custom result")
81 |
82 |
83 | class SomeResource(Resource):
84 | someint = Integer()
85 | somestr = String()
86 |
87 |
88 | @app.post('/input/output/validation')
89 | @produces(SomeResource)
90 | @consumes(SomeResource)
91 | def input_output_validation(self, bad: bool = False):
92 | return {
93 | 'someint': 'str' if bad else 123,
94 | 'somestr': 456 if bad else 'correct'
95 | }
96 |
97 |
98 | class CoreTests(CalmHTTPTestCase):
99 | def get_calm_app(self):
100 | global app
101 | return app
102 |
103 | def test_sync_async(self):
104 | async_expected = 'async_result'
105 | sync_expected = 'sync_result'
106 |
107 | self.get('/async/{}'.format(async_expected),
108 | expected_json_body=async_expected)
109 |
110 | self.post('/sync/{}'.format(sync_expected),
111 | expected_json_body=sync_expected)
112 |
113 | def test_uri_fragments(self):
114 | expected = 'expected_json_body'
115 |
116 | self.get('/part1/part2/?retparam={}'.format(expected),
117 | expected_json_body=expected)
118 |
119 | def test_default(self):
120 | p_values = {
121 | 'p' + str(i): 'p' + str(i)
122 | for i in range(1, 8)
123 | }
124 |
125 | self.put('/default?{}'.format(
126 | '&'.join(['='.join(kv) for kv in p_values.items()])
127 | ),
128 | expected_json_body=''.join(
129 | sorted(
130 | [v for v in p_values.values()] + ['p8', 'p9']
131 | )
132 | )
133 | )
134 |
135 | p_values = {
136 | 'p' + str(i): 'p' + str(i)
137 | for i in range(1, 9)
138 | }
139 |
140 | self.put('/default?{}'.format(
141 | '&'.join(['='.join(kv) for kv in p_values.items()])
142 | ),
143 | expected_json_body=''.join(
144 | sorted(
145 | [v for v in p_values.values()] + ['p9']
146 | )
147 | )
148 | )
149 |
150 | def test_definition_errors(self):
151 | async def missing_path_param(request):
152 | pass
153 |
154 | self.assertRaises(DefinitionError,
155 | app.delete('/missing_path_param/{param}'),
156 | missing_path_param)
157 |
158 | async def default_path_param(request, param='default'):
159 | pass
160 |
161 | self.assertRaises(DefinitionError,
162 | app.delete('/default_path_param/{param}'),
163 | default_path_param)
164 |
165 | def test_required_query_param(self):
166 | self.get('/required_query_param',
167 | expected_code=400)
168 |
169 | def test_method_not_allowed(self):
170 | self.post('/async/something',
171 | expected_code=405,
172 | expected_json_body={
173 | self.get_calm_app().config[
174 | 'error_key'
175 | ]: MethodNotAllowedError.message
176 | })
177 |
178 | def test_url_not_found(self):
179 | self.post('/not_found',
180 | expected_code=404,
181 | expected_json_body={
182 | self.get_calm_app().config[
183 | 'error_key'
184 | ]: NotFoundError.message
185 | })
186 |
187 | def test_server_error(self):
188 | self.get('/blowup',
189 | expected_code=500)
190 |
191 | def test_response_manipulations(self):
192 | self.delete('/response/str',
193 | expected_json_body='test_result')
194 |
195 | self.delete('/response/list',
196 | expected_json_body=["test", "result"])
197 |
198 | self.delete('/response/dict',
199 | expected_json_body={"test": "result"})
200 |
201 | self.delete('/response/object',
202 | expected_json_body={"test": "result"})
203 |
204 | self.delete('/response/error',
205 | expected_code=500)
206 |
207 | def test_argument_types(self):
208 | args = {'arg1': 'something', 'arg2': 1234}
209 | expected = [args['arg1'], args['arg2']]
210 |
211 | self.get('/argtypes',
212 | query_args=args,
213 | expected_code=200,
214 | expected_json_body=expected)
215 |
216 | args['arg2'] = "NotANumber"
217 | self.get('/argtypes',
218 | query_args=args,
219 | expected_code=400)
220 |
221 | def test_json_body(self):
222 | expected = {
223 | 'list': [
224 | 'hello',
225 | 'world'
226 | ],
227 | 'dict': {
228 | 'subdoc': 5,
229 | },
230 | 'something': 'else'
231 | }
232 | self.post('/json/body',
233 | json_body=expected,
234 | expected_code=200,
235 | expected_json_body=expected)
236 |
237 | self.post('/json/body',
238 | body='definitely not json',
239 | expected_code=400)
240 |
241 | def test_configure(self):
242 | app = self.get_calm_app()
243 | old_config = app.config
244 | app.configure(
245 | error_key='pardon'
246 | )
247 |
248 | self.post('/async/something',
249 | expected_code=405,
250 | expected_json_body={
251 | 'pardon': str(MethodNotAllowedError.message)
252 | })
253 |
254 | app.configure(**old_config)
255 |
256 | def test_custom_handler(self):
257 | self.get('/custom/handler',
258 | expected_code=200,
259 | expected_body='custom result')
260 |
261 | def test_wrong_param_type(self):
262 | def some_handler(request, badparam: set):
263 | pass
264 |
265 | self.assertRaises(DefinitionError, app.get('/something'), some_handler)
266 |
267 | def test_io_validation(self):
268 | bad_data = {
269 | 'someint': 'notint',
270 | 'somestr': 123
271 | }
272 | self.post('/input/output/validation',
273 | json_body=bad_data,
274 | expected_code=400)
275 |
276 | good_data = {
277 | 'someint': 456,
278 | 'somestr': 'abc'
279 | }
280 |
281 | self.post('/input/output/validation',
282 | query_args={'bad': 'true'},
283 | json_body=good_data,
284 | expected_code=200)
285 |
--------------------------------------------------------------------------------
/tests/test_decorator.py:
--------------------------------------------------------------------------------
1 | from calm.testing import CalmHTTPTestCase
2 |
3 | from calm import Application
4 | from calm.decorator import produces, consumes, fails
5 | from calm.resource import Resource, Integer
6 | from calm.ex import DefinitionError, BadRequestError
7 |
8 |
9 | app = Application('testapp', '1')
10 |
11 |
12 | class ProdResource(Resource):
13 | someint = Integer()
14 |
15 |
16 | class ConsResource(Resource):
17 | someint = Integer()
18 |
19 |
20 | @app.get('/regular_order')
21 | @produces(ProdResource)
22 | @consumes(ConsResource)
23 | def regular_order_handler(request):
24 | pass
25 |
26 |
27 | @consumes(ConsResource)
28 | @produces(ProdResource)
29 | @app.get('/regular_order')
30 | def reverse_order_handler(request):
31 | pass
32 |
33 |
34 | class DecoratorTests(CalmHTTPTestCase):
35 | def get_calm_app(self):
36 | global app
37 | return app
38 |
39 | def test_produces_consumes(self):
40 | handler = regular_order_handler
41 | self.assertIsNotNone(handler.handler_def)
42 | self.assertEqual(handler.handler_def.consumes, ConsResource)
43 | self.assertEqual(handler.handler_def.produces, ProdResource)
44 |
45 | handler = reverse_order_handler
46 | self.assertIsNotNone(handler.handler_def)
47 | self.assertEqual(handler.handler_def.consumes, ConsResource)
48 | self.assertEqual(handler.handler_def.produces, ProdResource)
49 |
50 | self.assertRaises(DefinitionError, produces, int)
51 | self.assertRaises(DefinitionError, consumes, str)
52 |
53 | def test_fails(self):
54 | def func():
55 | pass
56 |
57 | fails(BadRequestError)(func)
58 |
59 | self.assertEqual(len(func.errors), 1)
60 | self.assertIn(BadRequestError, func.errors)
61 |
62 | class BadError():
63 | pass
64 |
65 | self.assertRaises(DefinitionError, fails, BadError)
66 |
--------------------------------------------------------------------------------
/tests/test_param.py:
--------------------------------------------------------------------------------
1 | from unittest import TestCase
2 |
3 | from calm.param import ParameterJsonType
4 |
5 |
6 | class CodecTests(TestCase):
7 | def test_param_json_type(self):
8 | pjt = ParameterJsonType.from_python_type(int)
9 | self.assertEqual(pjt, 'integer')
10 |
11 | pjt = ParameterJsonType.from_python_type(float)
12 | self.assertEqual(pjt, 'number')
13 |
14 | pjt = ParameterJsonType.from_python_type(str)
15 | self.assertEqual(pjt, 'string')
16 |
17 | pjt = ParameterJsonType.from_python_type(bool)
18 | self.assertEqual(pjt, 'boolean')
19 |
20 | pjt = ParameterJsonType.from_python_type([bool])
21 | self.assertEqual(pjt, 'array')
22 | self.assertEqual(pjt.params['items'], 'boolean')
23 |
24 | class CustomType(str):
25 | pass
26 |
27 | pjt = ParameterJsonType.from_python_type(CustomType)
28 | self.assertEqual(pjt, 'string')
29 |
30 | def test_param_json_type_errors(self):
31 | self.assertRaises(TypeError,
32 | ParameterJsonType.from_python_type,
33 | [int, str])
34 |
35 | self.assertRaises(TypeError,
36 | ParameterJsonType.from_python_type,
37 | [[int]])
38 |
39 | self.assertRaises(TypeError,
40 | ParameterJsonType.from_python_type,
41 | tuple)
42 |
--------------------------------------------------------------------------------
/tests/test_service.py:
--------------------------------------------------------------------------------
1 | from calm.testing import CalmHTTPTestCase
2 | from calm import Application
3 |
4 |
5 | app = Application('testapp', '1')
6 | service1 = app.service('/service1')
7 | service2 = app.service('/service2')
8 |
9 |
10 | @service1.get('/url1/{retparam}')
11 | def service1_url1(request, retparam):
12 | return retparam
13 |
14 |
15 | @service1.put('/url2/{retparam}')
16 | def service1_url2(request, retparam):
17 | return retparam
18 |
19 |
20 | @service2.post('/url1/{retparam}')
21 | def service2_url2(request, retparam):
22 | return retparam
23 |
24 |
25 | @service2.delete('/url2/{retparam}')
26 | def service2_url3(request, retparam):
27 | return retparam
28 |
29 |
30 | @app.delete('/applevel/{retparam}')
31 | def applevel(request, retparam):
32 | return retparam
33 |
34 |
35 | class CalmServiceTests(CalmHTTPTestCase):
36 | def get_calm_app(self):
37 | global app
38 | return app
39 |
40 | def test_service(self):
41 | self.get('/service1/url1/something',
42 | expected_code=200,
43 | expected_json_body='something')
44 |
45 | self.put('/service1/url2/something',
46 | expected_code=200,
47 | expected_json_body='something')
48 |
49 | self.post('/service2/url1/something',
50 | expected_code=200,
51 | expected_json_body='something')
52 |
53 | self.delete('/service2/url2/something',
54 | expected_code=200,
55 | expected_json_body='something')
56 |
57 | self.delete('/applevel/something',
58 | expected_code=200,
59 | expected_json_body='something')
60 |
--------------------------------------------------------------------------------
/tests/test_swagger.py:
--------------------------------------------------------------------------------
1 | from unittest.mock import patch
2 |
3 | from calm import Application
4 | from calm.testing import CalmHTTPTestCase
5 | from calm.decorator import produces, consumes, fails, deprecated
6 | from calm.resource import Resource, Integer
7 | from calm.ex import ClientError
8 |
9 |
10 | app = Application(name='testapp', version='1')
11 |
12 |
13 | class SomeProdResource(Resource):
14 | someint = Integer()
15 |
16 |
17 | class SomeConsResource(Resource):
18 | someint = Integer()
19 |
20 |
21 | class SomeError(ClientError):
22 | """some error"""
23 | code = 456
24 |
25 |
26 | @app.post('/somepost/{somepatharg}')
27 | @produces(SomeProdResource)
28 | @consumes(SomeConsResource)
29 | @fails(SomeError)
30 | @deprecated
31 | def somepost(self, somepatharg,
32 | somequeryarg: int,
33 | somelistarg: [bool],
34 | somedefaultarg: str = "default"):
35 | """
36 | Some summary.
37 |
38 | Some description.
39 | """
40 | pass
41 |
42 |
43 | class Swaggertests(CalmHTTPTestCase):
44 | def get_calm_app(self):
45 | global app
46 | self.maxDiff = None
47 | return app
48 |
49 | def test_basic_info(self):
50 | test_app = Application(name='testapp', version='1', host='http://a.b',
51 | base_path='/test', description='swagger test',
52 | tos='terms of service')
53 | test_app.set_contact('tester', 'http://testurl.com', 'test@email.com')
54 | test_app.set_licence('name', 'http://license.url')
55 |
56 | expected_swagger = {
57 | 'swagger': '2.0',
58 | 'info': {
59 | 'title': 'testapp',
60 | 'version': '1',
61 | 'description': 'swagger test',
62 | 'termsOfService': 'terms of service',
63 | 'contact': {
64 | 'name': 'tester',
65 | 'url': 'http://testurl.com',
66 | 'email': 'test@email.com'
67 | },
68 | 'license': {
69 | 'name': 'name',
70 | 'url': 'http://license.url'
71 | }
72 | },
73 | 'host': 'http://a.b',
74 | 'basePath': '/test',
75 | 'consumes': ['application/json'],
76 | 'produces': ['application/json'],
77 | }
78 |
79 | actual_swagger = test_app.generate_swagger_json()
80 | actual_swagger.pop('responses')
81 | actual_swagger.pop('definitions')
82 | actual_swagger.pop('paths')
83 | self.assertEqual(expected_swagger, actual_swagger)
84 |
85 | def test_error_responses(self):
86 | class SomeError():
87 | """some description"""
88 |
89 | someapp = Application(name='testapp', version='1')
90 | someapp.configure(error_key='wompwomp')
91 |
92 | self.assertEqual(someapp._generate_error_schema(), {
93 | 'Error': {
94 | 'properties': {
95 | 'wompwomp': {'type': 'string'}
96 | },
97 | 'required': ['wompwomp']
98 | }
99 | })
100 |
101 | with patch('calm.ex.ClientError.get_defined_errors') as gde:
102 | gde.return_value = [SomeError]
103 | responses = someapp.generate_swagger_json().pop('responses')
104 |
105 | self.assertEqual(responses, {
106 | 'SomeError': {
107 | 'description': 'some description',
108 | 'schema': {
109 | '$ref': '#/definitions/Error'
110 | }
111 | }
112 | })
113 |
114 | def test_swagger_json(self):
115 | self.get('/swagger.json', expected_json_body={
116 | 'swagger': '2.0',
117 | 'info': {
118 | 'title': 'testapp',
119 | 'version': '1'
120 | },
121 | 'produces': ['application/json'],
122 | 'consumes': ['application/json'],
123 | 'paths': {
124 | '/somepost/{somepatharg}': {
125 | 'post': somepost.handler_def.operation_definition
126 | }
127 | },
128 | 'definitions': app._generate_swagger_definitions(),
129 | 'responses': app._generate_swagger_responses()
130 | })
131 |
132 | def test_operation_definition(self):
133 | handler_def = somepost.handler_def
134 |
135 | expected_opdef = {
136 | 'summary': 'Some summary.',
137 | 'description': 'Some description.',
138 | 'operationId': 'tests_test_swagger_somepost',
139 | 'deprecated': True,
140 | 'responses': {
141 | '200': {
142 | 'description': '',
143 | 'schema': {
144 | '$ref': '#/definitions/SomeProdResource'
145 | }
146 | },
147 | '456': {
148 | '$ref': '#/responses/SomeError'
149 | }
150 | },
151 | }
152 | expected_parameters = [
153 | {
154 | 'name': 'somepatharg',
155 | 'in': 'path',
156 | 'required': True,
157 | 'type': 'string'
158 | },
159 | {
160 | 'name': 'somequeryarg',
161 | 'in': 'query',
162 | 'type': 'integer',
163 | 'required': True
164 | },
165 | {
166 | 'name': 'somedefaultarg',
167 | 'in': 'query',
168 | 'type': 'string',
169 | 'required': False,
170 | 'default': 'default'
171 | },
172 | {
173 | 'name': 'somelistarg',
174 | 'in': 'query',
175 | 'required': True,
176 | 'type': 'array',
177 | 'items': 'boolean'
178 | },
179 | {
180 | 'in': 'body',
181 | 'name': 'body',
182 | 'schema': {
183 | '$ref': '#/definitions/SomeConsResource'
184 | }
185 | }
186 | ]
187 | actual_opdef = handler_def.operation_definition
188 | actual_parameters = actual_opdef.pop('parameters')
189 |
190 | self.assertEqual(expected_opdef, actual_opdef)
191 | self.assertCountEqual(expected_parameters, actual_parameters)
192 |
--------------------------------------------------------------------------------
/tests/test_websocket.py:
--------------------------------------------------------------------------------
1 | from tornado.testing import gen_test
2 | from tornado.websocket import WebSocketHandler
3 |
4 | from calm.testing import CalmWebSocketTestCase
5 | from calm import Application
6 | from calm.ex import DefinitionError
7 |
8 |
9 | app = Application('testws', '1')
10 |
11 |
12 | @app.websocket('/ws')
13 | class SomeWebSocket(WebSocketHandler):
14 | OPEN_MESSAGE = "some open message"
15 |
16 | def open(self):
17 | self.write_message(self.OPEN_MESSAGE)
18 |
19 | def on_message(self, message):
20 | self.write_message(message)
21 |
22 |
23 | class WebSocketTests(CalmWebSocketTestCase):
24 | def get_calm_app(self):
25 | global app
26 | return app
27 |
28 | def test_wrong_class(self):
29 | class WrongClass(object):
30 | pass
31 |
32 | self.assertRaises(DefinitionError, app.websocket('/wrong_class'),
33 | WrongClass)
34 | self.assertRaises(DefinitionError, app.websocket('/wrong_class'),
35 | 1)
36 |
37 | @gen_test
38 | async def test_base_case(self):
39 | websocket = await self.init_websocket('/ws')
40 |
41 | msg = await websocket.read_message()
42 | self.assertEqual(msg, SomeWebSocket.OPEN_MESSAGE)
43 |
44 | some_msg = "some echo message"
45 | websocket.write_message(some_msg)
46 | msg = await websocket.read_message()
47 | self.assertEqual(msg, some_msg)
48 |
49 | websocket.close()
50 | msg = await websocket.read_message()
51 | self.assertEqual(msg, None)
52 |
--------------------------------------------------------------------------------