├── .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 | Calm Logo 9 | 10 | 11 | [![PyPI](https://img.shields.io/pypi/v/calm.svg)](https://pypi.python.org/pypi/calm) 12 | [![Build Status](https://travis-ci.org/bagrat/calm.svg?branch=master)](https://travis-ci.org/bagrat/calm) 13 | [![Coverage Status](https://coveralls.io/repos/github/bagrat/calm/badge.svg?branch=master)](https://coveralls.io/github/bagrat/calm?branch=master) 14 | [![Code Health](https://landscape.io/github/bagrat/calm/master/landscape.svg?style=flat)](https://landscape.io/github/bagrat/calm/master) 15 | [![Gitter](https://badges.gitter.im/bagrat/calm.svg)](https://gitter.im/bagrat/calm?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) 16 | [![License](https://img.shields.io/badge/license-MIT-blue.svg)](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 | Calm Logo 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 | 20 | 22 | 24 | 28 | 32 | 33 | 35 | 39 | 43 | 44 | 46 | 50 | 54 | 55 | 57 | 61 | 65 | 66 | 68 | 72 | 76 | 77 | 79 | 83 | 87 | 88 | 97 | 106 | 116 | 126 | 137 | 138 | 156 | 158 | 159 | 161 | image/svg+xml 162 | 164 | 165 | 166 | 167 | 168 | 172 | 177 | 182 | 183 | 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 | 23 | 25 | 27 | 31 | 35 | 36 | 38 | 42 | 46 | 47 | 49 | 53 | 57 | 58 | 60 | 64 | 68 | 69 | 71 | 75 | 79 | 80 | 82 | 86 | 90 | 91 | 100 | 109 | 119 | 129 | 140 | 141 | 160 | 162 | 163 | 165 | image/svg+xml 166 | 168 | 169 | 170 | 171 | 172 | 177 | 184 | 191 | 196 | 201 | Calm 212 | REST APIExtensionfor Tornado 234 | CONTRIBUTEto code and docs 251 | 252 | 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 | --------------------------------------------------------------------------------