diff --git a/bin/yunohost b/bin/yunohost new file mode 100755 index 00000000..d4483df5 --- /dev/null +++ b/bin/yunohost @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import sys +import os.path +import gettext + +# Debug option +if '--debug' in sys.argv: + sys.path.append(os.path.abspath(os.path.dirname(__file__) +'/../src')) +from moulinette import cli +from moulinette.core.helpers import YunoHostError, colorize + +gettext.install('YunoHost') + + +## Main action + +if __name__ == '__main__': + # Additional arguments + use_cache = True + if '--no-cache' in sys.argv: + use_cache = False + sys.argv.remove('--no-cache') + if '--debug' in sys.argv: + sys.argv.remove('--debug') + + try: + args = list(sys.argv) + args.pop(0) + + # Check that YunoHost is installed + if not os.path.isfile('/etc/yunohost/installed') \ + and (len(args) < 2 or args[1] != 'tools' or args[2] != 'postinstall'): + raise YunoHostError(17, _("YunoHost is not correctly installed, please execute 'yunohost tools postinstall'")) + + # Execute the action + cli(args, use_cache) + except YunoHostError as e: + print(colorize(_("Error: "), 'red') + e.message) + sys.exit(e.code) + sys.exit(0) diff --git a/bin/yunohost-api b/bin/yunohost-api new file mode 100755 index 00000000..625d2b7e --- /dev/null +++ b/bin/yunohost-api @@ -0,0 +1,43 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- + +import sys +import os.path +import gettext + +# Debug option +if '--debug' in sys.argv: + sys.path.append(os.path.abspath(os.path.dirname(__file__) +'/../src')) +from moulinette import api + +gettext.install('YunoHost') + + +## Callbacks for additional routes + +def is_installed(): + """ + Check whether YunoHost is installed or not + + """ + installed = False + if os.path.isfile('/etc/yunohost/installed'): + installed = True + return { 'installed': installed } + + +## Main action + +if __name__ == '__main__': + # Additional arguments + use_cache = True + if '--no-cache' in sys.argv: + use_cache = False + sys.argv.remove('--no-cache') + if '--debug' in sys.argv: + sys.argv.remove('--debug') + # TODO: Add log argument + + # Rune the server + api(6787, {('GET', '/installed'): is_installed}, use_cache) + sys.exit(0) diff --git a/action_map.yml b/etc/actionsmap/yunohost.yml similarity index 98% rename from action_map.yml rename to etc/actionsmap/yunohost.yml index 15b41c16..cf2df613 100644 --- a/action_map.yml +++ b/etc/actionsmap/yunohost.yml @@ -95,7 +95,7 @@ user: ### user_delete() delete: action_help: Delete user - api: 'DELETE /users/{users}' + api: 'DELETE /users/' arguments: -u: full: --users @@ -109,7 +109,7 @@ user: ### user_update() update: action_help: Update user informations - api: 'PUT /users/{username}' + api: 'PUT /users/' arguments: username: help: Username of user to update @@ -143,7 +143,7 @@ user: ### user_info() info: action_help: Get user informations - api: 'GET /users/{username}' + api: 'GET /users/' arguments: username: help: Username or mail to get informations @@ -202,7 +202,7 @@ domain: ### domain_info() info: action_help: Get domain informations - api: 'GET /domains/{domain}' + api: 'GET /domains/' arguments: domain: help: "" @@ -266,7 +266,7 @@ app: ### app_info() info: action_help: Get app info - api: GET /app/{app} + api: GET /app/ arguments: app: help: Specific app ID @@ -310,7 +310,7 @@ app: ### app_remove() TODO: Write help remove: action_help: Remove app - api: DELETE /app/{app} + api: DELETE /app/ arguments: app: help: App(s) to delete @@ -333,7 +333,7 @@ app: ### app_setting() setting: action_help: Set ou get an app setting value - api: GET /app/{app}/setting + api: GET /app//setting arguments: app: help: App ID @@ -350,7 +350,7 @@ app: ### app_service() service: action_help: Add or remove a YunoHost monitored service - api: POST /app/service/{service} + api: POST /app/service/ arguments: service: help: Service to add/remove diff --git a/firewall.yml b/etc/firewall.yml similarity index 93% rename from firewall.yml rename to etc/firewall.yml index 075116cf..5a7383d1 100644 --- a/firewall.yml +++ b/etc/firewall.yml @@ -1,6 +1,6 @@ -UPNP: +UPNP: cron: false - ports: + ports: TCP: [22, 25, 53, 80, 443, 465, 993, 5222, 5269, 5280, 6767, 7676] UDP: [53, 137, 138] ipv4: diff --git a/ldap_scheme.yml b/etc/ldap_scheme.yml similarity index 100% rename from ldap_scheme.yml rename to etc/ldap_scheme.yml diff --git a/bash/yunohost_cli b/etc/moulinette_cli similarity index 87% rename from bash/yunohost_cli rename to etc/moulinette_cli index 20ed5855..15ac21f2 100644 --- a/bash/yunohost_cli +++ b/etc/moulinette_cli @@ -7,14 +7,14 @@ COMPREPLY=() argc=${COMP_CWORD} cur="${COMP_WORDS[argc]}" -prev="${COMP_WORDS[argc-1]}" +prev="${COMP_WORDS[argc-1]}" opts=$(yunohost -h | sed -n "/usage/,/}/p" | awk -F"{" '{print $2}' | awk -F"}" '{print $1}' | tr ',' ' ') if [[ $argc = 1 ]]; then COMPREPLY=( $(compgen -W "$opts --help" -- $cur ) ) fi - + if [[ "$prev" != "--help" ]]; then if [[ $argc = 2 ]]; @@ -23,9 +23,9 @@ then COMPREPLY=( $(compgen -W "$opts2 --help" -- $cur ) ) elif [[ $argc = 3 ]]; then - COMPREPLY=( $(compgen -W "--help" $cur ) ) + COMPREPLY=( $(compgen -W "--help" $cur ) ) fi -else +else COMPREPLY=() fi diff --git a/services.yml b/etc/services.yml similarity index 100% rename from services.yml rename to etc/services.yml diff --git a/src/moulinette/__init__.py b/src/moulinette/__init__.py new file mode 100755 index 00000000..784ed407 --- /dev/null +++ b/src/moulinette/__init__.py @@ -0,0 +1,89 @@ +# -*- coding: utf-8 -*- + +__title__ = 'moulinette' +__version__ = '695' +__author__ = ['Kload', + 'jlebleu', + 'titoko', + 'beudbeud', + 'npze'] +__license__ = 'AGPL 3.0' +__credits__ = """ + Copyright (C) 2014 YUNOHOST.ORG + + This program is free software; you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program; if not, see http://www.gnu.org/licenses + """ + + +## Fast access functions + +def api(port, routes={}, use_cache=True): + """ + Run a HTTP server with the moulinette for an API usage. + + Keyword arguments: + + - port -- Port to run on + + - routes -- A dict of additional routes to add in the form of + {(method, uri): callback} + + - use_cache -- False if it should parse the actions map file + instead of using the cached one + + """ + from bottle import run + from core.actionsmap import ActionsMap + from core.api import MoulinetteAPI + + amap = ActionsMap(ActionsMap.IFACE_API, use_cache=use_cache) + moulinette = MoulinetteAPI(amap, routes) + + run(moulinette.app, port=port) + +def cli(args, use_cache=True): + """ + Execute an action with the moulinette from the CLI and print its + result in a readable format. + + Keyword arguments: + + - args -- A list of argument strings + + - use_cache -- False if it should parse the actions map file + instead of using the cached one + + """ + import os + from core.actionsmap import ActionsMap + from core.helpers import YunoHostError, pretty_print_dict + + lock_file = '/var/run/moulinette.lock' + + # Check the lock + if os.path.isfile(lock_file): + raise YunoHostError(1, _("The moulinette is already running")) + + # Create a lock + with open(lock_file, 'w') as f: pass + os.system('chmod 400 '+ lock_file) + + try: + amap = ActionsMap(ActionsMap.IFACE_CLI, use_cache=use_cache) + pretty_print_dict(amap.process(args)) + except KeyboardInterrupt, EOFError: + raise YunoHostError(125, _("Interrupted")) + finally: + # Remove the lock + os.remove(lock_file) diff --git a/src/moulinette/config.py b/src/moulinette/config.py new file mode 100644 index 00000000..b1bcdfb9 --- /dev/null +++ b/src/moulinette/config.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- + +# TODO: Remove permanent debug values +import os + +# Path for the the web sessions +session_path = '/var/cache/yunohost/session' + +# Path of the actions map definition(s) +actionsmap_path = os.path.dirname(__file__) +'/../../etc/actionsmap' + +# Path for the actions map cache +actionsmap_cache_path = '/var/cache/yunohost/actionsmap' + +# Path of the doc in json format +doc_json_path = os.path.dirname(__file__) +'/../../doc' diff --git a/src/moulinette/core/__init__.py b/src/moulinette/core/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/src/moulinette/core/actionsmap.py b/src/moulinette/core/actionsmap.py new file mode 100644 index 00000000..022dc06c --- /dev/null +++ b/src/moulinette/core/actionsmap.py @@ -0,0 +1,490 @@ +# -*- coding: utf-8 -*- + +import argparse +import getpass +import marshal +import pickle +import yaml +import re +import os + +from .. import __version__ +from ..config import actionsmap_path, actionsmap_cache_path +from helpers import YunoHostError, colorize + +class _HTTPArgumentParser(object): + + def __init__(self, method, uri): + # Initialize the ArgumentParser object + self._parser = argparse.ArgumentParser(usage='', + prefix_chars='@', + add_help=False) + self._parser.error = self._error + + self.method = method + self.uri = uri + + self._positional = [] # list(arg_name) + self._optional = {} # dict({arg_name: option_strings}) + + def set_defaults(self, **kwargs): + return self._parser.set_defaults(**kwargs) + + def get_default(self, dest): + return self._parser.get_default(dest) + + def add_argument(self, *args, **kwargs): + action = self._parser.add_argument(*args, **kwargs) + + # Append newly created action + if len(action.option_strings) == 0: + self._positional.append(action.dest) + else: + self._optional[action.dest] = action.option_strings + + return action + + def parse_args(self, args): + arg_strings = [] + + ## Append an argument to the current one + def append(arg_strings, value, option_string=None): + # TODO: Process list arguments + if isinstance(value, bool): + # Append the option string only + if option_string is not None: + arg_strings.append(option_string) + elif isinstance(value, str): + if option_string is not None: + arg_strings.append(option_string) + arg_strings.append(value) + else: + arg_strings.append(value) + + return arg_strings + + # Iterate over positional arguments + for dest in self._positional: + if dest in args: + arg_strings = append(arg_strings, args[dest]) + + # Iterate over optional arguments + for dest, opt in self._optional.items(): + if dest in args: + arg_strings = append(arg_strings, args[dest], opt[0]) + return self._parser.parse_args(arg_strings) + + def _error(self, message): + # TODO: Raise a proper exception + raise Exception(message) + +class HTTPParser(object): + """ + Object for parsing HTTP requests into Python objects. + + """ + + def __init__(self): + self._parsers = {} # dict({(method, uri): _HTTPArgumentParser}) + + @property + def routes(self): + """Get current routes""" + return self._parsers.keys() + + def add_parser(self, method, uri): + """ + Add a parser for a given route + + Keyword arguments: + - method -- The route's HTTP method (GET, POST, PUT, DELETE) + - uri -- The route's URI + + Returns: + A new _HTTPArgumentParser object for the route + + """ + # Check if a parser already exists for the route + key = (method, uri) + if key in self.routes: + raise ValueError("A parser for '%s' already exists" % key) + + # Create and append parser + parser = _HTTPArgumentParser(method, uri) + self._parsers[key] = parser + + # Return the created parser + return parser + + def parse_args(self, method, uri, args={}): + """ + Convert argument variables to objects and assign them as + attributes of the namespace for a given route + + Keyword arguments: + - method -- The route's HTTP method (GET, POST, PUT, DELETE) + - uri -- The route's URI + - args -- Argument variables for the route + + Returns: + The populated namespace + + """ + # Retrieve the parser for the route + key = (method, uri) + if key not in self.routes: + raise ValueError("No parser for '%s %s' found" % key) + + return self._parsers[key].parse_args(args) + + +class _ExtraParameters(object): + + CLI_PARAMETERS = ['ask', 'password', 'pattern'] + API_PARAMETERS = ['pattern'] + AVAILABLE_PARAMETERS = CLI_PARAMETERS + + def __init__(self, **kwargs): + self._params = {} + + for k, v in kwargs.items(): + if k in self.AVAILABLE_PARAMETERS: + self._params[k] = v + + def validate(self, p_name, p_value): + ret = type(p_value)() if p_value is not None else None + + for p, v in self._params.items(): + func = getattr(self, 'process_' + p) + + if isinstance(ret, list): + for p_v in p_value: + r = func(v, p_name, p_v) + if r is not None: + ret.append(r) + else: + r = func(v, p_name, p_value) + if r is not None: + ret = r + + return ret + + + ## Parameters validating's method + # TODO: Add doc + + def process_ask(self, message, p_name, p_value): + # TODO: Fix asked arguments ordering + if not self._can_prompt(p_value): + return p_value + + # Skip password asking + if 'password' in self._params.keys(): + return None + + ret = raw_input(colorize(message + ': ', 'cyan')) + return ret + + def process_password(self, is_password, p_name, p_value): + if not self._can_prompt(p_value): + return p_value + + message = self._params['ask'] + pwd1 = getpass.getpass(colorize(message + ': ', 'cyan')) + pwd2 = getpass.getpass(colorize('Retype ' + message + ': ', 'cyan')) + if pwd1 != pwd2: + raise YunoHostError(22, _("Passwords don't match")) + return pwd1 + + def process_pattern(self, pattern, p_name, p_value): + # TODO: Add a pattern_help parameter + # TODO: Fix missing pattern matching on asking + if p_value is not None and not re.match(pattern, p_value): + raise YunoHostError(22, _("'%s' argument not match pattern" % p_name)) + return p_value + + + ## Private method + + def _can_prompt(self, p_value): + if os.isatty(1) and (p_value is None or p_value == ''): + return True + return False + + +class ActionsMap(object): + """ + Validate and process action defined into the actions map. + + The actions map defines features and their usage of the main + application. It is composed by categories which contain one or more + action(s). Moreover, the action can have specific argument(s). + + Keyword arguments: + + - interface -- Interface type that requires the actions map. + Possible value is one of: + - 'cli' for the command line interface + - 'api' for an API usage (HTTP requests) + + - use_cache -- False if it should parse the actions map file + instead of using the cached one. + + """ + IFACE_CLI = 'cli' + IFACE_API = 'api' + + def __init__(self, interface, use_cache=True): + if interface not in [self.IFACE_CLI,self.IFACE_API]: + raise ValueError(_("Invalid interface '%s'" % interface)) + self.interface = interface + + # Iterate over actions map namespaces + actionsmap = {} + for n in self.get_actionsmap_namespaces(): + if use_cache: + cache_file = '%s/%s.pkl' % (actionsmap_cache_path, n) + if os.path.isfile(cache_file): + with open(cache_file, 'r') as f: + actionsmap[n] = pickle.load(f) + else: + actionsmap = self.generate_cache() + else: + am_file = '%s/%s.yml' % (actionsmap_path, n) + with open(am_file, 'r') as f: + actionsmap[n] = yaml.load(f) + + self.parser = self._construct_parser(actionsmap) + + def process(self, args, route=None): + """ + Parse arguments and process the proper action + + Keyword arguments: + - args -- The arguments to parse + - route -- A tupple (method, uri) of the requested route (api only) + + """ + arguments = None + + # Parse arguments + if self.interface ==self.IFACE_CLI: + arguments = self.parser.parse_args(args) + elif self.interface ==self.IFACE_API: + if route is None: + # TODO: Raise a proper exception + raise Exception(_("Missing route argument")) + method, uri = route + arguments = self.parser.parse_args(method, uri, args) + arguments = vars(arguments) + + # Parse extra parameters + arguments = self._parse_extra_parameters(arguments) + + # Retrieve action information + namespace = arguments['_info']['namespace'] + category = arguments['_info']['category'] + action = arguments['_info']['action'] + del arguments['_info'] + + module = '%s.%s' % (namespace, category) + function = '%s_%s' % (category, action) + + try: + mod = __import__(module, globals=globals(), fromlist=[function], level=2) + func = getattr(mod, function) + except (AttributeError, ImportError): + raise YunoHostError(168, _('Function is not defined')) + else: + # Process the action + return func(**arguments) + + @staticmethod + def get_actionsmap_namespaces(path=actionsmap_path): + """ + Retrieve actions map namespaces in a given path + + """ + namespaces = [] + + for f in os.listdir(path): + if f.endswith('.yml'): + namespaces.append(f[:-4]) + return namespaces + + @classmethod + def generate_cache(cls): + """ + Generate cache for the actions map's file(s) + + """ + actionsmap = {} + + if not os.path.isdir(actionsmap_cache_path): + os.makedirs(actionsmap_cache_path) + + for n in cls.get_actionsmap_namespaces(): + am_file = '%s/%s.yml' % (actionsmap_path, n) + with open(am_file, 'r') as f: + actionsmap[n] = yaml.load(f) + cache_file = '%s/%s.pkl' % (actionsmap_cache_path, n) + with open(cache_file, 'w') as f: + pickle.dump(actionsmap[n], f) + + return actionsmap + + + ## Private class and methods + + def _store_extra_parameters(self, parser, arg_name, arg_params): + """ + Store extra parameters for a given parser's argument name + + Keyword arguments: + - parser -- Parser object of the argument + - arg_name -- Argument name + - arg_params -- Argument parameters + + """ + params = {} + keys = [] + + # Get available parameters for the current interface + if self.interface ==self.IFACE_CLI: + keys = _ExtraParameters.CLI_PARAMETERS + elif self.interface ==self.IFACE_API: + keys = _ExtraParameters.API_PARAMETERS + + for k in keys: + if k in arg_params: + params[k] = arg_params[k] + + if len(params) > 0: + # Retrieve all extra parameters from the parser + extra = parser.get_default('_extra') + if not extra or not isinstance(extra, dict): + extra = {} + + # Add completed extra parameters to the parser + extra[arg_name] = _ExtraParameters(**params) + parser.set_defaults(_extra=extra) + + return parser + + def _parse_extra_parameters(self, args): + # Retrieve extra parameters from the arguments + if '_extra' not in args: + return args + extra = args['_extra'] + del args['_extra'] + + # Validate extra parameters for each arguments + for n, e in extra.items(): + args[n] = e.validate(n, args[n]) + + return args + + def _construct_parser(self, actionsmap): + """ + Construct the parser with the actions map + + Keyword arguments: + - actionsmap -- Multi-level dictionnary of + categories/actions/arguments list + + Returns: + Interface relevant's parser object + + """ + top_parser = None + iface = self.interface + + # Create parser object + if iface ==self.IFACE_CLI: + # TODO: Add descritpion (from __description__) + top_parser = argparse.ArgumentParser() + top_subparsers = top_parser.add_subparsers() + elif iface ==self.IFACE_API: + top_parser = HTTPParser() + + ## Extract option strings from parameters + def _option_strings(arg_name, arg_params): + if iface ==self.IFACE_CLI: + if arg_name[0] == '-' and 'full' in arg_params: + return [arg_name, arg_params['full']] + return [arg_name] + elif iface ==self.IFACE_API: + if arg_name[0] != '-': + return [arg_name] + if 'full' in arg_params: + return [arg_params['full'].replace('--', '@', 1)] + if arg_name.startswith('--'): + return [arg_name.replace('--', '@', 1)] + return [arg_name.replace('-', '@', 1)] + + ## Extract a key from parameters + def _key(arg_params, key, default=str()): + if key in arg_params: + return arg_params[key] + return default + + ## Remove extra parameters + def _clean_params(arg_params): + keys = list(_ExtraParameters.AVAILABLE_PARAMETERS) + keys.append('full') + + for k in keys: + if k in arg_params: + del arg_params[k] + return arg_params + + # Iterate over actions map namespaces + for n in self.get_actionsmap_namespaces(): + # Parse general arguments for the cli only + if iface ==self.IFACE_CLI: + for an, ap in actionsmap[n]['general_arguments'].items(): + if 'version' in ap: + ap['version'] = ap['version'].replace('%version%', __version__) + top_parser.add_argument(*_option_strings(an, ap), **_clean_params(ap)) + del actionsmap[n]['general_arguments'] + + # Parse categories + for cn, cp in actionsmap[n].items(): + if 'actions' not in cp: + continue + + # Add category subparsers for the cli only + if iface ==self.IFACE_CLI: + c_help = _key(cp, 'category_help') + subparsers = top_subparsers.add_parser(cn, help=c_help).add_subparsers() + + # Parse actions + for an, ap in cp['actions'].items(): + parser = None + + # Add parser for the current action + if iface ==self.IFACE_CLI: + a_help = _key(ap, 'action_help') + parser = subparsers.add_parser(an, help=a_help) + elif iface ==self.IFACE_API and 'api' in ap: + # Extract method and uri + m = re.match('(GET|POST|PUT|DELETE) (/\S+)', ap['api']) + if m: + parser = top_parser.add_parser(m.group(1), m.group(2)) + if not parser: + continue + + # Store action information + parser.set_defaults(_info={'namespace': n, + 'category': cn, + 'action': an}) + + # Add arguments + if not 'arguments' in ap: + continue + for argn, argp in ap['arguments'].items(): + arg = parser.add_argument(*_option_strings(argn, argp), + **_clean_params(argp.copy())) + parser = self._store_extra_parameters(parser, arg.dest, argp) + + return top_parser diff --git a/src/moulinette/core/api.py b/src/moulinette/core/api.py new file mode 100644 index 00000000..ea00cc9e --- /dev/null +++ b/src/moulinette/core/api.py @@ -0,0 +1,211 @@ +# -*- coding: utf-8 -*- + +import os.path +from bottle import Bottle, request, response, HTTPResponse +from beaker.middleware import SessionMiddleware + +from ..config import session_path, doc_json_path +from helpers import YunoHostError, YunoHostLDAP + +class APIAuthPlugin(object): + """ + This Bottle plugin manages the authentication for the API access. + + """ + name = 'apiauth' + api = 2 + + def __init__(self): + # TODO: Add options (e.g. session type, content type, ...) + if not os.path.isdir(session_path): + os.makedirs(session_path) + + @property + def app(self): + """Get Bottle application with session integration""" + if hasattr(self, '_app'): + return self._app + raise Exception(_("The APIAuth Plugin is not installed yet.")) + + def setup(self, app): + """ + Setup the plugin and install the session into the app + + Keyword argument: + app -- The associated application object + + """ + app.route('/login', name='login', method='POST', callback=self.login) + app.route('/logout', name='logout', method='GET', callback=self.logout) + + session_opts = { + 'session.type': 'file', + 'session.cookie_expires': True, + 'session.data_dir': session_path, + 'session.secure': True + } + self._app = SessionMiddleware(app, session_opts) + + def apply(self, callback, context): + """ + Check authentication before executing the route callback + + Keyword argument: + callback -- The route callback + context -- An instance of Route + + """ + # Check the authentication + if self._is_authenticated: + if context.name == 'login': + self.logout() + else: + # TODO: Fix this tweak to retrieve uri from the callback + def wrapper(*args, **kwargs): + if hasattr(context.config, '_uri'): + kwargs['_uri'] = context.config._uri + return callback(*args, **kwargs) + return wrapper + + # Process login route + if context.name == 'login': + password = request.POST.get('password', None) + if password is not None and self.login(password): + raise HTTPResponse(status=200) + else: + raise HTTPResponse(_("Wrong password"), 401) + + # Deny access to the requested route + raise HTTPResponse(_("Unauthorized"), 401) + + def login(self, password): + """ + Attempt to log in with the given password + + Keyword argument: + password -- Cleartext password + + """ + try: YunoHostLDAP(password=password) + except YunoHostError: + return False + else: + session = self._beaker_session + session['authenticated'] = True + session.save() + return True + return False + + def logout(self): + """ + Log out and delete the session + + """ + # TODO: Delete the cached session file + session = self._beaker_session + session.delete() + + + ## Private methods + + @property + def _beaker_session(self): + """Get Beaker session""" + return request.environ.get('beaker.session') + + @property + def _is_authenticated(self): + """Check authentication""" + # TODO: Clear the session path on password changing to avoid invalid access + if 'authenticated' in self._beaker_session: + return True + return False + + +class MoulinetteAPI(object): + """ + Initialize a HTTP server which serves the API to access to the + moulinette actions. + + Keyword arguments: + + - actionsmap -- The relevant ActionsMap instance + + - routes -- A dict of additional routes to add in the form of + {(method, uri): callback} + + """ + + def __init__(self, actionsmap, routes={}): + self.actionsmap = actionsmap + + # Initialize app and default routes + # TODO: Return OK to 'OPTIONS' xhr requests (l173) + app = Bottle() + app.route(['/api', '/api/'], method='GET', callback=self.doc, skip=['apiauth']) + + # Append routes from the actions map + for (m, u) in actionsmap.parser.routes: + app.route(u, method=m, callback=self._route_wrapper, _uri=u) + + # Append additional routes + for (m, u), c in routes.items(): + app.route(u, method=m, callback=c) + + # Define and install a plugin which sets proper header + def apiheader(callback): + def wrapper(*args, **kwargs): + response.content_type = 'application/json' + response.set_header('Access-Control-Allow-Origin', '*') + return callback(*args, **kwargs) + return wrapper + app.install(apiheader) + + # Install authentication plugin + apiauth = APIAuthPlugin() + app.install(apiauth) + + self._app = apiauth.app + + @property + def app(self): + """Get Bottle application""" + return self._app + + def doc(self, category=None): + """ + Get API documentation for a category (all by default) + + Keyword argument: + category -- Name of the category + + """ + if category is None: + with open(doc_json_path +'/resources.json') as f: + return f.read() + + try: + with open(doc_json_path +'/'+ category +'.json') as f: + return f.read() + except IOError: + return 'unknown' + + def _route_wrapper(self, *args, **kwargs): + """Process the relevant action for the request""" + # Retrieve uri + if '_uri' in kwargs: + uri = kwargs['_uri'] + del kwargs['_uri'] + else: + uri = request.path + + # Bring arguments together + params = kwargs + for a in args: + params[a] = True + for k, v in request.params.items(): + params[k] = v + + # Process the action + # TODO: Catch errors + return self.actionsmap.process(params, (request.method, uri)) diff --git a/yunohost.py b/src/moulinette/core/helpers.py similarity index 91% rename from yunohost.py rename to src/moulinette/core/helpers.py index f75ee7ac..cf4e383c 100644 --- a/yunohost.py +++ b/src/moulinette/core/helpers.py @@ -1,47 +1,5 @@ # -*- coding: utf-8 -*- -""" License - - Copyright (C) 2013 YunoHost - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program; if not, see http://www.gnu.org/licenses - -""" - -""" - YunoHost core classes & functions -""" - -__credits__ = """ - Copyright (C) 2012 YunoHost - - This program is free software; you can redistribute it and/or modify - it under the terms of the GNU Affero General Public License as published - by the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - This program is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU Affero General Public License for more details. - - You should have received a copy of the GNU Affero General Public License - along with this program; if not, see http://www.gnu.org/licenses - """ -__author__ = 'Kload ' -__version__ = '695' - import os import sys try: diff --git a/src/moulinette/yunohost/__init__.py b/src/moulinette/yunohost/__init__.py new file mode 100755 index 00000000..e69de29b diff --git a/yunohost_app.py b/src/moulinette/yunohost/app.py similarity index 99% rename from yunohost_app.py rename to src/moulinette/yunohost/app.py index 24a7299d..70d8f978 100644 --- a/yunohost_app.py +++ b/src/moulinette/yunohost/app.py @@ -33,10 +33,11 @@ import time import re import socket import urlparse -from yunohost import YunoHostError, YunoHostLDAP, win_msg, random_password, is_true, validate -from yunohost_domain import domain_list, domain_add -from yunohost_user import user_info, user_list -from yunohost_hook import hook_exec, hook_add, hook_remove +from domain import domain_list, domain_add +from user import user_info, user_list +from hook import hook_exec, hook_add, hook_remove + +from ..core.helpers import YunoHostError, YunoHostLDAP, win_msg, random_password, is_true, validate repo_path = '/var/cache/yunohost/repo' apps_path = '/usr/share/yunohost/apps' diff --git a/yunohost_backup.py b/src/moulinette/yunohost/backup.py similarity index 94% rename from yunohost_backup.py rename to src/moulinette/yunohost/backup.py index 2edea6bb..18363bbb 100644 --- a/yunohost_backup.py +++ b/src/moulinette/yunohost/backup.py @@ -28,7 +28,8 @@ import sys import json import yaml import glob -from yunohost import YunoHostError, YunoHostLDAP, validate, colorize, win_msg + +from ..core.helpers import YunoHostError, YunoHostLDAP, validate, colorize, win_msg def backup_init(helper=False): """ diff --git a/yunohost_domain.py b/src/moulinette/yunohost/domain.py similarity index 98% rename from yunohost_domain.py rename to src/moulinette/yunohost/domain.py index 0bf39c2f..6c6f1127 100644 --- a/yunohost_domain.py +++ b/src/moulinette/yunohost/domain.py @@ -32,8 +32,9 @@ import json import yaml import requests from urllib import urlopen -from yunohost import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args -from yunohost_dyndns import dyndns_subscribe +from dyndns import dyndns_subscribe + +from ..core.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args def domain_list(filter=None, limit=None, offset=None): diff --git a/yunohost_dyndns.py b/src/moulinette/yunohost/dyndns.py similarity index 98% rename from yunohost_dyndns.py rename to src/moulinette/yunohost/dyndns.py index 2d4355ce..13d5259c 100644 --- a/yunohost_dyndns.py +++ b/src/moulinette/yunohost/dyndns.py @@ -29,7 +29,8 @@ import requests import json import glob import base64 -from yunohost import YunoHostError, YunoHostLDAP, validate, colorize, win_msg + +from ..core.helpers import YunoHostError, YunoHostLDAP, validate, colorize, win_msg def dyndns_subscribe(subscribe_host="dyndns.yunohost.org", domain=None, key=None): """ diff --git a/yunohost_firewall.py b/src/moulinette/yunohost/firewall.py similarity index 99% rename from yunohost_firewall.py rename to src/moulinette/yunohost/firewall.py index 1e9844e0..ae83a7cc 100644 --- a/yunohost_firewall.py +++ b/src/moulinette/yunohost/firewall.py @@ -36,7 +36,8 @@ except ImportError: sys.stderr.write('Error: Yunohost CLI Require yaml lib\n') sys.stderr.write('apt-get install python-yaml\n') sys.exit(1) -from yunohost import YunoHostError, win_msg + +from ..core.helpers import YunoHostError, win_msg def firewall_allow(protocol=None, port=None, ipv6=None, upnp=False): diff --git a/yunohost_hook.py b/src/moulinette/yunohost/hook.py similarity index 98% rename from yunohost_hook.py rename to src/moulinette/yunohost/hook.py index 14b719ee..2aea8706 100644 --- a/yunohost_hook.py +++ b/src/moulinette/yunohost/hook.py @@ -27,7 +27,8 @@ import os import sys import re import json -from yunohost import YunoHostError, YunoHostLDAP, win_msg, colorize + +from ..core.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize hook_folder = '/usr/share/yunohost/hooks/' diff --git a/yunohost_monitor.py b/src/moulinette/yunohost/monitor.py similarity index 99% rename from yunohost_monitor.py rename to src/moulinette/yunohost/monitor.py index ed4f7db1..069fa39f 100644 --- a/yunohost_monitor.py +++ b/src/moulinette/yunohost/monitor.py @@ -34,10 +34,11 @@ import os.path import cPickle as pickle from urllib import urlopen from datetime import datetime, timedelta -from yunohost import YunoHostError, win_msg -from yunohost_service import (service_enable, service_disable, +from service import (service_enable, service_disable, service_start, service_stop, service_status) +from ..core.helpers import YunoHostError, win_msg + glances_uri = 'http://127.0.0.1:61209' stats_path = '/var/lib/yunohost/stats' crontab_path = '/etc/cron.d/yunohost-monitor' @@ -359,7 +360,7 @@ def monitor_enable(no_stats=False): # Install crontab if not no_stats: - cmd = 'yunohost monitor update-stats' + cmd = 'cd /home/admin/dev/moulinette && ./yunohost monitor update-stats' # day: every 5 min # week: every 1 h # month: every 4 h # rules = ('*/5 * * * * root %(cmd)s day --no-ldap >> /dev/null\n' + \ '3 * * * * root %(cmd)s week --no-ldap >> /dev/null\n' + \ diff --git a/yunohost_service.py b/src/moulinette/yunohost/service.py similarity index 99% rename from yunohost_service.py rename to src/moulinette/yunohost/service.py index cc9467bf..cfbfa875 100644 --- a/yunohost_service.py +++ b/src/moulinette/yunohost/service.py @@ -27,7 +27,8 @@ import yaml import glob import subprocess import os.path -from yunohost import YunoHostError, win_msg + +from ..core.helpers import YunoHostError, win_msg def service_start(names): @@ -169,7 +170,7 @@ def service_log(name, number=50): if name not in services.keys(): raise YunoHostError(1, _("Unknown service '%s'") % service) - + if 'log' in services[name]: log_list = services[name]['log'] result = {} @@ -253,7 +254,7 @@ def _tail(file, n, offset=None): pos = f.tell() lines = f.read().splitlines() if len(lines) >= to_read or pos == 0: - return lines[-to_read:offset and -offset or None] + return lines[-to_read:offset and -offset or None] avg_line_length *= 1.3 except IOError: return [] diff --git a/yunohost_tools.py b/src/moulinette/yunohost/tools.py similarity index 96% rename from yunohost_tools.py rename to src/moulinette/yunohost/tools.py index 3cac46a6..1300063c 100644 --- a/yunohost_tools.py +++ b/src/moulinette/yunohost/tools.py @@ -31,11 +31,12 @@ import getpass import subprocess import requests import json -from yunohost import YunoHostError, YunoHostLDAP, validate, colorize, get_required_args, win_msg -from yunohost_domain import domain_add, domain_list -from yunohost_dyndns import dyndns_subscribe -from yunohost_backup import backup_init -from yunohost_app import app_ssowatconf +from domain import domain_add, domain_list +from dyndns import dyndns_subscribe +from backup import backup_init +from app import app_ssowatconf + +from ..core.helpers import YunoHostError, YunoHostLDAP, validate, colorize, get_required_args, win_msg def tools_ldapinit(password=None): diff --git a/yunohost_user.py b/src/moulinette/yunohost/user.py similarity index 98% rename from yunohost_user.py rename to src/moulinette/yunohost/user.py index 5d74f323..16ac8cd4 100644 --- a/yunohost_user.py +++ b/src/moulinette/yunohost/user.py @@ -30,9 +30,10 @@ import crypt import random import string import getpass -from yunohost import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args -from yunohost_domain import domain_list -from yunohost_hook import hook_callback +from domain import domain_list +from hook import hook_callback + +from ..core.helpers import YunoHostError, YunoHostLDAP, win_msg, colorize, validate, get_required_args def user_list(fields=None, filter=None, limit=None, offset=None): """ @@ -117,7 +118,7 @@ def user_create(username, firstname, lastname, mail, password): uid = str(random.randint(200, 99999)) uid_check = os.system("getent passwd " + uid) gid_check = os.system("getent group " + uid) - + # Adapt values for LDAP fullname = firstname + ' ' + lastname rdn = 'uid=' + username + ',ou=users' @@ -139,7 +140,7 @@ def user_create(username, firstname, lastname, mail, password): 'uidNumber' : uid, 'homeDirectory' : '/home/' + username, 'loginShell' : '/bin/false' - + } if yldap.add(rdn, attr_dict): diff --git a/txrestapi/.gitignore b/txrestapi/.gitignore deleted file mode 100644 index 2193e643..00000000 --- a/txrestapi/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -_trial_temp -txrestapi.egg-info -txrestapi/_trial_temp diff --git a/txrestapi/README.rst b/txrestapi/README.rst deleted file mode 100644 index caf2cf32..00000000 --- a/txrestapi/README.rst +++ /dev/null @@ -1,146 +0,0 @@ -============ -Introduction -============ - -``txrestapi`` makes it easier to create Twisted REST API services. Normally, one -would create ``Resource`` subclasses defining each segment of a path; this is -cubersome to implement and results in output that isn't very readable. -``txrestapi`` provides an ``APIResource`` class allowing complex mapping of path to -callback (a la Django) with a readable decorator. - -=============================== -Basic URL callback registration -=============================== - -First, let's create a bare API service:: - - >>> from txrestapi.resource import APIResource - >>> api = APIResource() - -and a web server to serve it:: - - >>> from twisted.web.server import Site - >>> from twisted.internet import reactor - >>> site = Site(api, timeout=None) - -and a function to make it easy for us to make requests (only for doctest -purposes; normally you would of course use ``reactor.listenTCP(8080, site)``):: - - >>> from twisted.web.server import Request - >>> class FakeChannel(object): - ... transport = None - >>> def makeRequest(method, path): - ... req = Request(FakeChannel(), None) - ... req.prepath = req.postpath = None - ... req.method = method; req.path = path - ... resource = site.getChildWithDefault(path, req) - ... return resource.render(req) - -We can now register callbacks for paths we care about. We can provide different -callbacks for different methods; they must accept ``request`` as the first -argument:: - - >>> def get_callback(request): return 'GET callback' - >>> api.register('GET', '^/path/to/method', get_callback) - >>> def post_callback(request): return 'POST callback' - >>> api.register('POST', '^/path/to/method', post_callback) - -Then, when we make a call, the request is routed to the proper callback:: - - >>> print makeRequest('GET', '/path/to/method') - GET callback - >>> print makeRequest('POST', '/path/to/method') - POST callback - -We can register multiple callbacks for different requests; the first one that -matches wins:: - - >>> def default_callback(request): - ... return 'Default callback' - >>> api.register('GET', '^/.*$', default_callback) # Matches everything - >>> print makeRequest('GET', '/path/to/method') - GET callback - >>> print makeRequest('GET', '/path/to/different/method') - Default callback - -Our default callback, however, will only match GET requests. For a true default -callback, we can either register callbacks for each method individually, or we -can use ALL:: - - >>> api.register('ALL', '^/.*$', default_callback) - >>> print makeRequest('PUT', '/path/to/method') - Default callback - >>> print makeRequest('DELETE', '/path/to/method') - Default callback - >>> print makeRequest('GET', '/path/to/method') - GET callback - -Let's unregister all references to the default callback so it doesn't interfere -with later tests (default callbacks should, of course, always be registered -last, so they don't get called before other callbacks):: - - >>> api.unregister(callback=default_callback) - -============= -URL Arguments -============= - -Since callbacks accept ``request``, they have access to POST data or query -arguments, but we can also pull arguments out of the URL by using named groups -in the regular expression (similar to Django). These will be passed into the -callback as keyword arguments:: - - >>> def get_info(request, id): - ... return 'Information for id %s' % id - >>> api.register('GET', '/(?P[^/]+)/info$', get_info) - >>> print makeRequest('GET', '/someid/info') - Information for id someid - -Bear in mind all arguments will come in as strings, so code should be -accordingly defensive. - -================ -Decorator syntax -================ - -Registration via the ``register()`` method is somewhat awkward, so decorators -are provided making it much more straightforward. :: - - >>> from txrestapi.methods import GET, POST, PUT, ALL - >>> class MyResource(APIResource): - ... - ... @GET('^/(?P[^/]+)/info') - ... def get_info(self, request, id): - ... return 'Info for id %s' % id - ... - ... @PUT('^/(?P[^/]+)/update') - ... @POST('^/(?P[^/]+)/update') - ... def set_info(self, request, id): - ... return "Setting info for id %s" % id - ... - ... @ALL('^/') - ... def default_view(self, request): - ... return "I match any URL" - -Again, registrations occur top to bottom, so methods should be written from -most specific to least. Also notice that one can use the decorator syntax as -one would expect to register a method as the target for two URLs :: - - >>> site = Site(MyResource(), timeout=None) - >>> print makeRequest('GET', '/anid/info') - Info for id anid - >>> print makeRequest('PUT', '/anid/update') - Setting info for id anid - >>> print makeRequest('POST', '/anid/update') - Setting info for id anid - >>> print makeRequest('DELETE', '/anid/delete') - I match any URL - -====================== -Callback return values -====================== - -You can return Resource objects from a callback if you wish, allowing you to -have APIs that send you to other kinds of resources, or even other APIs. -Normally, however, you'll most likely want to return strings, which will be -wrapped in a Resource object for convenience. diff --git a/txrestapi/__init__.py b/txrestapi/__init__.py deleted file mode 100644 index 792d6005..00000000 --- a/txrestapi/__init__.py +++ /dev/null @@ -1 +0,0 @@ -# diff --git a/txrestapi/methods.py b/txrestapi/methods.py deleted file mode 100644 index 8d5a89d9..00000000 --- a/txrestapi/methods.py +++ /dev/null @@ -1,29 +0,0 @@ -from zope.interface.advice import addClassAdvisor - -def method_factory_factory(method): - def factory(regex): - _f = {} - def decorator(f): - _f[f.__name__] = f - return f - def advisor(cls): - def wrapped(f): - def __init__(self, *args, **kwargs): - f(self, *args, **kwargs) - for func_name in _f: - orig = _f[func_name] - func = getattr(self, func_name) - if func.im_func==orig: - self.register(method, regex, func) - return __init__ - cls.__init__ = wrapped(cls.__init__) - return cls - addClassAdvisor(advisor) - return decorator - return factory - -ALL = method_factory_factory('ALL') -GET = method_factory_factory('GET') -POST = method_factory_factory('POST') -PUT = method_factory_factory('PUT') -DELETE = method_factory_factory('DELETE') diff --git a/txrestapi/resource.py b/txrestapi/resource.py deleted file mode 100644 index 322acd5f..00000000 --- a/txrestapi/resource.py +++ /dev/null @@ -1,65 +0,0 @@ -import re -from itertools import ifilter -from functools import wraps -from twisted.web.resource import Resource, NoResource - -class _FakeResource(Resource): - _result = '' - isLeaf = True - def __init__(self, result): - Resource.__init__(self) - self._result = result - def render(self, request): - return self._result - - -def maybeResource(f): - @wraps(f) - def inner(*args, **kwargs): - result = f(*args, **kwargs) - if not isinstance(result, Resource): - result = _FakeResource(result) - return result - return inner - - -class APIResource(Resource): - - _registry = None - - def __init__(self, *args, **kwargs): - Resource.__init__(self, *args, **kwargs) - self._registry = [] - - def _get_callback(self, request): - filterf = lambda t:t[0] in (request.method, 'ALL') - path_to_check = getattr(request, '_remaining_path', request.path) - for m, r, cb in ifilter(filterf, self._registry): - result = r.search(path_to_check) - if result: - request._remaining_path = path_to_check[result.span()[1]:] - return cb, result.groupdict() - return None, None - - def register(self, method, regex, callback): - self._registry.append((method, re.compile(regex), callback)) - - def unregister(self, method=None, regex=None, callback=None): - if regex is not None: regex = re.compile(regex) - for m, r, cb in self._registry[:]: - if not method or (method and m==method): - if not regex or (regex and r==regex): - if not callback or (callback and cb==callback): - self._registry.remove((m, r, cb)) - - def getChild(self, name, request): - r = self.children.get(name, None) - if r is None: - # Go into the thing - callback, args = self._get_callback(request) - if callback is None: - return NoResource() - else: - return maybeResource(callback)(request, **args) - else: - return r diff --git a/txrestapi/service.py b/txrestapi/service.py deleted file mode 100644 index 78d031a8..00000000 --- a/txrestapi/service.py +++ /dev/null @@ -1,7 +0,0 @@ -from twisted.web.server import Site -from .resource import APIResource - - -class RESTfulService(Site): - def __init__(self, port=8080): - self.root = APIResource() diff --git a/txrestapi/tests.py b/txrestapi/tests.py deleted file mode 100644 index 3ee1b53c..00000000 --- a/txrestapi/tests.py +++ /dev/null @@ -1,194 +0,0 @@ -import txrestapi -__package__="txrestapi" -import re -import os.path -from twisted.internet import reactor -from twisted.internet.defer import inlineCallbacks -from twisted.web.resource import Resource, NoResource -from twisted.web.server import Request, Site -from twisted.web.client import getPage -from twisted.trial import unittest -from .resource import APIResource -from .methods import GET, PUT - -class FakeChannel(object): - transport = None - -def getRequest(method, url): - req = Request(FakeChannel(), None) - req.method = method - req.path = url - return req - -class APIResourceTest(unittest.TestCase): - - def test_returns_normal_resources(self): - r = APIResource() - a = Resource() - r.putChild('a', a) - req = Request(FakeChannel(), None) - a_ = r.getChild('a', req) - self.assertEqual(a, a_) - - def test_registry(self): - compiled = re.compile('regex') - r = APIResource() - r.register('GET', 'regex', None) - self.assertEqual([x[0] for x in r._registry], ['GET']) - self.assertEqual(r._registry[0], ('GET', compiled, None)) - - def test_method_matching(self): - r = APIResource() - r.register('GET', 'regex', 1) - r.register('PUT', 'regex', 2) - r.register('GET', 'another', 3) - - req = getRequest('GET', 'regex') - result = r._get_callback(req) - self.assert_(result) - self.assertEqual(result[0], 1) - - req = getRequest('PUT', 'regex') - result = r._get_callback(req) - self.assert_(result) - self.assertEqual(result[0], 2) - - req = getRequest('GET', 'another') - result = r._get_callback(req) - self.assert_(result) - self.assertEqual(result[0], 3) - - req = getRequest('PUT', 'another') - result = r._get_callback(req) - self.assertEqual(result, (None, None)) - - def test_callback(self): - marker = object() - def cb(request): - return marker - r = APIResource() - r.register('GET', 'regex', cb) - req = getRequest('GET', 'regex') - result = r.getChild('regex', req) - self.assertEqual(result.render(req), marker) - - def test_longerpath(self): - marker = object() - r = APIResource() - def cb(request): - return marker - r.register('GET', '/regex/a/b/c', cb) - req = getRequest('GET', '/regex/a/b/c') - result = r.getChild('regex', req) - self.assertEqual(result.render(req), marker) - - def test_args(self): - r = APIResource() - def cb(request, **kwargs): - return kwargs - r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)/c', cb) - req = getRequest('GET', '/regex/a/b/c') - result = r.getChild('regex', req) - self.assertEqual(sorted(result.render(req).keys()), ['a', 'b']) - - def test_order(self): - r = APIResource() - def cb1(request, **kwargs): - kwargs.update({'cb1':True}) - return kwargs - def cb(request, **kwargs): - return kwargs - # Register two regexes that will match - r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)/c', cb1) - r.register('GET', '/(?P[^/]*)/a/(?P[^/]*)', cb) - req = getRequest('GET', '/regex/a/b/c') - result = r.getChild('regex', req) - # Make sure the first one got it - self.assert_('cb1' in result.render(req)) - - def test_no_resource(self): - r = APIResource() - r.register('GET', '^/(?P[^/]*)/a/(?P[^/]*)$', None) - req = getRequest('GET', '/definitely/not/a/match') - result = r.getChild('regex', req) - self.assert_(isinstance(result, NoResource)) - - def test_all(self): - r = APIResource() - def get_cb(r): return 'GET' - def put_cb(r): return 'PUT' - def all_cb(r): return 'ALL' - r.register('GET', '^path', get_cb) - r.register('ALL', '^path', all_cb) - r.register('PUT', '^path', put_cb) - # Test that the ALL registration picks it up before the PUT one - for method in ('GET', 'PUT', 'ALL'): - req = getRequest(method, 'path') - result = r.getChild('path', req) - self.assertEqual(result.render(req), 'ALL' if method=='PUT' else method) - - -class TestResource(Resource): - isLeaf = True - def render(self, request): - return 'aresource' - - -class TestAPI(APIResource): - - @GET('^/(?Ptest[^/]*)/?') - def _on_test_get(self, request, a): - return 'GET %s' % a - - @PUT('^/(?Ptest[^/]*)/?') - def _on_test_put(self, request, a): - return 'PUT %s' % a - - @GET('^/gettest') - def _on_gettest(self, request): - return TestResource() - - -class DecoratorsTest(unittest.TestCase): - def _listen(self, site): - return reactor.listenTCP(0, site, interface="127.0.0.1") - - def setUp(self): - r = TestAPI() - site = Site(r, timeout=None) - self.port = self._listen(site) - self.portno = self.port.getHost().port - - def tearDown(self): - return self.port.stopListening() - - def getURL(self, path): - return "http://127.0.0.1:%d/%s" % (self.portno, path) - - @inlineCallbacks - def test_get(self): - url = self.getURL('test_thing/') - result = yield getPage(url, method='GET') - self.assertEqual(result, 'GET test_thing') - - @inlineCallbacks - def test_put(self): - url = self.getURL('test_thing/') - result = yield getPage(url, method='PUT') - self.assertEqual(result, 'PUT test_thing') - - @inlineCallbacks - def test_resource_wrapper(self): - url = self.getURL('gettest') - result = yield getPage(url, method='GET') - self.assertEqual(result, 'aresource') - - -def test_suite(): - import unittest as ut - suite = unittest.TestSuite() - suite.addTest(ut.makeSuite(DecoratorsTest)) - suite.addTest(ut.makeSuite(APIResourceTest)) - suite.addTest(unittest.doctest.DocFileSuite(os.path.join('..', 'README.rst'))) - return suite -