mirror of
https://github.com/YunoHost/moulinette.git
synced 2024-09-03 20:06:31 +02:00
Initial refactoring
This commit is contained in:
parent
aa1c87c4d0
commit
a802bcfd6f
31 changed files with 941 additions and 527 deletions
42
bin/yunohost
Executable file
42
bin/yunohost
Executable file
|
@ -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)
|
43
bin/yunohost-api
Executable file
43
bin/yunohost-api
Executable file
|
@ -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)
|
|
@ -95,7 +95,7 @@ user:
|
|||
### user_delete()
|
||||
delete:
|
||||
action_help: Delete user
|
||||
api: 'DELETE /users/{users}'
|
||||
api: 'DELETE /users/<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/<username>'
|
||||
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/<username>'
|
||||
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/<domain>'
|
||||
arguments:
|
||||
domain:
|
||||
help: ""
|
||||
|
@ -266,7 +266,7 @@ app:
|
|||
### app_info()
|
||||
info:
|
||||
action_help: Get app info
|
||||
api: GET /app/{app}
|
||||
api: GET /app/<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/<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/<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/<service>
|
||||
arguments:
|
||||
service:
|
||||
help: Service to add/remove
|
|
@ -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:
|
|
@ -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
|
||||
|
89
src/moulinette/__init__.py
Executable file
89
src/moulinette/__init__.py
Executable file
|
@ -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)
|
16
src/moulinette/config.py
Normal file
16
src/moulinette/config.py
Normal file
|
@ -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'
|
0
src/moulinette/core/__init__.py
Executable file
0
src/moulinette/core/__init__.py
Executable file
490
src/moulinette/core/actionsmap.py
Normal file
490
src/moulinette/core/actionsmap.py
Normal file
|
@ -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
|
211
src/moulinette/core/api.py
Normal file
211
src/moulinette/core/api.py
Normal file
|
@ -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/<category:re:[a-z]+>'], 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))
|
|
@ -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 <kload@kload.fr>'
|
||||
__version__ = '695'
|
||||
|
||||
import os
|
||||
import sys
|
||||
try:
|
0
src/moulinette/yunohost/__init__.py
Executable file
0
src/moulinette/yunohost/__init__.py
Executable file
|
@ -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'
|
|
@ -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):
|
||||
"""
|
|
@ -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):
|
|
@ -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):
|
||||
"""
|
|
@ -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):
|
|
@ -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/'
|
||||
|
|
@ -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' + \
|
|
@ -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 []
|
|
@ -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):
|
|
@ -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):
|
3
txrestapi/.gitignore
vendored
3
txrestapi/.gitignore
vendored
|
@ -1,3 +0,0 @@
|
|||
_trial_temp
|
||||
txrestapi.egg-info
|
||||
txrestapi/_trial_temp
|
|
@ -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<id>[^/]+)/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<id>[^/]+)/info')
|
||||
... def get_info(self, request, id):
|
||||
... return 'Info for id %s' % id
|
||||
...
|
||||
... @PUT('^/(?P<id>[^/]+)/update')
|
||||
... @POST('^/(?P<id>[^/]+)/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.
|
|
@ -1 +0,0 @@
|
|||
#
|
|
@ -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')
|
|
@ -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
|
|
@ -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()
|
|
@ -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>[^/]*)/a/(?P<b>[^/]*)/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>[^/]*)/a/(?P<b>[^/]*)/c', cb1)
|
||||
r.register('GET', '/(?P<a>[^/]*)/a/(?P<b>[^/]*)', 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>[^/]*)/a/(?P<b>[^/]*)$', 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('^/(?P<a>test[^/]*)/?')
|
||||
def _on_test_get(self, request, a):
|
||||
return 'GET %s' % a
|
||||
|
||||
@PUT('^/(?P<a>test[^/]*)/?')
|
||||
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
|
||||
|
Loading…
Add table
Reference in a new issue