├── .gitignore ├── .idea ├── .name ├── codeStyleSettings.xml ├── compiler.xml ├── dictionaries │ └── dok.xml ├── encodings.xml ├── inspectionProfiles │ ├── Project_Default.xml │ └── profiles_settings.xml ├── modules.xml ├── scopes │ └── scope_settings.xml └── vcs.xml ├── .project ├── .pydevproject ├── Corepost.iml ├── LICENSE ├── README.md ├── corepost ├── __init__.py ├── convert.py ├── enums.py ├── ext │ ├── __init__.py │ ├── multicore │ │ ├── __init__.py │ │ └── zmq.py │ ├── security.py │ ├── sql.py │ └── sql │ │ └── __init__.py ├── filters.py ├── routing.py ├── test │ ├── __init__.py │ ├── arguments.py │ ├── feature │ │ ├── arguments.feature │ │ ├── content_types.feature │ │ ├── filters.feature │ │ ├── issues.feature │ │ ├── rest_app.feature │ │ ├── url_routing.feature │ │ ├── validate.feature │ │ └── zeromq_resource.py │ ├── filter_resource.py │ ├── home_resource.py │ ├── multi_resource.py │ ├── rest_resource.py │ ├── sql_resource.py │ └── steps.py ├── utils.py └── web.py ├── setup.py └── test.sh /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | build 3 | dist 4 | *.egg-info 5 | .settings 6 | *plugin.xml 7 | .idea 8 | -------------------------------------------------------------------------------- /.idea/.name: -------------------------------------------------------------------------------- 1 | corepost -------------------------------------------------------------------------------- /.idea/codeStyleSettings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 68 | 69 | 70 | 71 | -------------------------------------------------------------------------------- /.idea/compiler.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 6 | 7 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /.idea/dictionaries/dok.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | -------------------------------------------------------------------------------- /.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/Project_Default.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 6 | -------------------------------------------------------------------------------- /.idea/inspectionProfiles/profiles_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 7 | -------------------------------------------------------------------------------- /.idea/modules.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | -------------------------------------------------------------------------------- /.idea/scopes/scope_settings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | -------------------------------------------------------------------------------- /.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.project: -------------------------------------------------------------------------------- 1 | 2 | 3 | corepost 4 | 5 | 6 | 7 | 8 | 9 | org.python.pydev.PyDevBuilder 10 | 11 | 12 | 13 | 14 | 15 | org.python.pydev.pythonNature 16 | 17 | 18 | -------------------------------------------------------------------------------- /.pydevproject: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | python 6 | python 2.7 7 | 8 | /corepost 9 | 10 | 11 | -------------------------------------------------------------------------------- /Corepost.iml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | BSD License 2 | -------------------------------------------------------------------- 3 | Copyright (c) 2011 Jacek Furmankiewicz 4 | All rights reserved. 5 | 6 | Redistribution and use in source and binary forms, with or without 7 | modification, are permitted provided that the following conditions 8 | are met: 9 | 1. Redistributions of source code must retain the above copyright 10 | notice, this list of conditions and the following disclaimer. 11 | 2. Redistributions in binary form must reproduce the above copyright 12 | notice, this list of conditions and the following disclaimer in the 13 | documentation and/or other materials provided with the distribution. 14 | 3. The name of the author may not be used to endorse or promote products 15 | derived from this software without specific prior written permission. 16 | 17 | THIS SOFTWARE IS PROVIDED BY THE AUTHOR ``AS IS'' AND ANY EXPRESS OR 18 | IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES 19 | OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. 20 | IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY DIRECT, INDIRECT, 21 | INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT 22 | NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF 26 | THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Twisted REST micro-framework 2 | ================================ 3 | 4 | Inspired by the *Flask* API. 5 | Provides a more Flask/Sinatra-style API on top of the core *twisted.web* APIs. 6 | 7 | See our HTML documentation: 8 | http://jacek99.github.com/corepost/ 9 | 10 | or the PDF version: 11 | https://github.com/jacek99/corepost/raw/gh-pages/doc/build/latex/CorePost.pdf 12 | 13 | END OF LIFE NOTICE 14 | ================== 15 | 16 | This project is not maintained any more, as I do not work with Python these days. 17 | 18 | If anyone is interested in forking it and taking it over, feel free to contact me. 19 | -------------------------------------------------------------------------------- /corepost/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Common classes 3 | ''' 4 | 5 | from zope.interface import Interface, Attribute 6 | 7 | ######################################################### 8 | # 9 | # INTERFACES 10 | # 11 | ######################################################### 12 | 13 | class IRESTResource(Interface): 14 | """An interface for all REST services that can be added within a root CorePost resource""" 15 | services = Attribute("All the REST services contained in this resource") 16 | 17 | 18 | ######################################################### 19 | # 20 | # CLASSES 21 | # 22 | ######################################################### 23 | 24 | class Response: 25 | """ 26 | Custom response object, can be returned instead of raw string response 27 | """ 28 | def __init__(self,code=200,entity=None,headers={}): 29 | self.code = code 30 | self.entity=entity if entity != None else "" 31 | self.headers=headers 32 | 33 | def __str__(self): 34 | return str(self.__dict__) 35 | 36 | class RESTException(Exception): 37 | """Standard REST exception that gets converted to the Response it passes in""" 38 | def __init__(self, response): 39 | self.response = response 40 | 41 | class NotFoundException(RESTException): 42 | """Standard 404 exception when REST resource is not found""" 43 | def __init__(self, resourceName, invalidValue): 44 | RESTException.__init__(self,Response(404,"Unable to find %s identified by '%s'" % (resourceName,invalidValue), {"x-corepost-resource":resourceName,"x-corepost-value":invalidValue})) 45 | 46 | class ConflictException(RESTException): 47 | """Standard 409 exception when REST resource is not found. Allows to pass in a custom message with more details""" 48 | def __init__(self, resourceName, invalidValue, message): 49 | RESTException.__init__(self,Response(409,"Conflict for %s identified by '%s': %s" % (resourceName,invalidValue, message), {"x-corepost-resource":resourceName,"x-corepost-value":invalidValue})) 50 | 51 | class AlreadyExistsException(ConflictException): 52 | """Standard 409 exception when REST resource already exists during a POST""" 53 | def __init__(self, resourceName, invalidValue, message = None): 54 | ConflictException.__init__(self, resourceName, invalidValue, "%s already exists" % resourceName) 55 | 56 | class InternalServerException(RESTException): 57 | """Standard 500 error""" 58 | def __init__(self, safeErrorMessage): 59 | RESTException.__init__(self,Response(500,safeErrorMessage)) 60 | -------------------------------------------------------------------------------- /corepost/convert.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 2011-10-11 3 | @author: jacekf 4 | 5 | Responsible for converting return values into cleanly serializable dict/tuples/lists 6 | for JSON/XML/YAML output 7 | ''' 8 | 9 | import collections 10 | import logging 11 | import json 12 | from UserDict import DictMixin 13 | from twisted.python import log 14 | 15 | primitives = (int, long, float, bool, str,unicode) 16 | 17 | def convertForSerialization(obj): 18 | """Converts anything (clas,tuples,list) to the safe serializable equivalent""" 19 | try: 20 | if type(obj) in primitives: 21 | # no conversion 22 | return obj 23 | elif isinstance(obj, dict) or isinstance(obj,DictMixin): 24 | return traverseDict(obj) 25 | elif isClassInstance(obj): 26 | return convertClassToDict(obj) 27 | elif isinstance(obj,collections.Iterable) and not isinstance(obj,str): 28 | # iterable 29 | values = [] 30 | for val in obj: 31 | values.append(convertForSerialization(val)) 32 | return values 33 | else: 34 | # return as-is 35 | return obj 36 | except AttributeError as ex: 37 | log.msg(ex,logLevel=logging.WARN) 38 | return obj 39 | 40 | def convertClassToDict(clazz): 41 | """Converts a class to a dictionary""" 42 | properties = {} 43 | for prop,val in clazz.__dict__.iteritems(): 44 | #omit private fields 45 | if not prop.startswith("_"): 46 | properties[prop] = val 47 | 48 | return traverseDict(properties) 49 | 50 | def traverseDict(dictObject): 51 | """Traverses a dict recursively to convertForSerialization any nested classes""" 52 | newDict = {} 53 | 54 | for prop,val in dictObject.iteritems(): 55 | newDict[prop] = convertForSerialization(val) 56 | 57 | return newDict 58 | 59 | 60 | def convertToJson(obj): 61 | """Converts to JSON, including Python classes that are not JSON serializable by default""" 62 | try: 63 | return json.dumps(obj) 64 | except Exception as ex: 65 | raise RuntimeError(str(ex)) 66 | 67 | def generateXml(obj): 68 | """Generates basic XML from an object that has already been converted for serialization""" 69 | if isinstance(obj, dict) or isinstance(obj,DictMixin): 70 | return getXML_dict(obj, "item") 71 | elif isinstance(obj,collections.Iterable): 72 | return "%s" % getXML(obj, "item") 73 | else: 74 | raise RuntimeError("Unable to convert to XML: %s" % obj) 75 | 76 | def isClassInstance(obj): 77 | """Checks if a given obj is a class instance""" 78 | return getattr(obj, "__class__",None) != None and not isinstance(obj,dict) and not isinstance(obj,tuple) and not isinstance(obj,list) and not isinstance(obj,str) 79 | 80 | ## {{{ http://code.activestate.com/recipes/440595/ (r2) 81 | def getXML(obj, objname=None): 82 | """getXML(obj, objname=None) 83 | returns an object as XML where Python object names are the tags. 84 | 85 | >>> u={'UserID':10,'Name':'Mark','Group':['Admin','Webmaster']} 86 | >>> getXML(u,'User') 87 | '10MarkAdminWebmaster' 88 | """ 89 | if obj == None: 90 | return "" 91 | if not objname: 92 | objname = "item" 93 | adapt={ 94 | dict: getXML_dict, 95 | list: getXML_list, 96 | tuple: getXML_list, 97 | } 98 | if adapt.has_key(obj.__class__): 99 | return adapt[obj.__class__](obj, objname) 100 | else: 101 | return "<%(n)s>%(o)s"%{'n':objname,'o':str(obj)} 102 | 103 | def getXML_dict(indict, objname=None): 104 | h = "<%s>"%objname 105 | for k, v in indict.items(): 106 | h += getXML(v, k) 107 | h += ""%objname 108 | return h 109 | 110 | def getXML_list(inlist, objname=None): 111 | h = "" 112 | for i in inlist: 113 | h += getXML(i, objname) 114 | return h 115 | ## end of http://code.activestate.com/recipes/440595/ }}} 116 | 117 | -------------------------------------------------------------------------------- /corepost/enums.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Common enums 3 | 4 | @author: jacekf 5 | ''' 6 | 7 | 8 | class Http: 9 | """Enumerates HTTP methods""" 10 | GET = "GET" 11 | POST = "POST" 12 | PUT = "PUT" 13 | DELETE = "DELETE" 14 | OPTIONS = "OPTIONS" 15 | HEAD = "HEAD" 16 | PATCH = "PATCH" 17 | 18 | 19 | class HttpHeader: 20 | """Enumerates common HTTP headers""" 21 | CONTENT_TYPE = "content-type" 22 | ACCEPT = "accept" 23 | 24 | 25 | class MediaType: 26 | """Enumerates media types""" 27 | WILDCARD = "*/*" 28 | APPLICATION_XML = "application/xml" 29 | APPLICATION_ATOM_XML = "application/atom+xml" 30 | APPLICATION_XHTML_XML = "application/xhtml+xml" 31 | APPLICATION_SVG_XML = "application/svg+xml" 32 | APPLICATION_JSON = "application/json" 33 | APPLICATION_FORM_URLENCODED = "application/x-www-form-urlencoded" 34 | MULTIPART_FORM_DATA = "multipart/form-data" 35 | APPLICATION_OCTET_STREAM = "application/octet-stream" 36 | TEXT_PLAIN = "text/plain" 37 | TEXT_XML = "text/xml" 38 | TEXT_HTML = "text/html" 39 | TEXT_YAML = "text/yaml" -------------------------------------------------------------------------------- /corepost/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacek99/corepost/4d4c0efa0af4a113dcd7f22c14c918dbce65c0c9/corepost/ext/__init__.py -------------------------------------------------------------------------------- /corepost/ext/multicore/__init__.py: -------------------------------------------------------------------------------- 1 | __author__ = 'jacekf' 2 | -------------------------------------------------------------------------------- /corepost/ext/multicore/zmq.py: -------------------------------------------------------------------------------- 1 | __author__ = 'jacekf' 2 | 3 | try: 4 | import txZMQ 5 | except ImportError as ex: 6 | print "You must have ZeroMQ and txZMQ installed" 7 | raise ex 8 | 9 | from corepost import Response, IRESTResource 10 | from corepost.enums import Http 11 | from corepost.routing import UrlRouter, RequestRouter 12 | from enums import MediaType 13 | from formencode import FancyValidator, Invalid 14 | from twisted.internet import reactor 15 | from twisted.internet.defer import Deferred 16 | from twisted.web.resource import Resource 17 | from twisted.web.server import Site, NOT_DONE_YET 18 | from zope.interface import implements 19 | 20 | class ZMQResource(Resource): 21 | """ 22 | Responsible for intercepting HTTP requests and marshalling them via ZeroMQ to responders in the process pool 23 | """ 24 | isLeaf = True 25 | implements(IRESTResource) 26 | 27 | def __init__(self): 28 | ''' 29 | Constructor 30 | ''' 31 | Resource.__init__(self) 32 | 33 | def render(self, request): 34 | """Posts request to ZeroMQ and waits for response""" 35 | pass 36 | 37 | 38 | class ZMQResponder: 39 | """ 40 | Responsible for processing an incoming request via ZeroMQ and responding via a REST API as if it were a direct HTTP request 41 | """ 42 | def __init__(self,services=(),schema=None,filters=()): 43 | ''' 44 | Constructor 45 | ''' 46 | self.services = services 47 | self.__router = RequestRouter(self,schema,filters) 48 | -------------------------------------------------------------------------------- /corepost/ext/security.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Enhancements to core Twisted security 3 | @author: jacekf 4 | ''' 5 | 6 | from twisted.cred.checkers import ICredentialsChecker 7 | from zope.interface import implements 8 | 9 | from beaker.cache import CacheManager 10 | from beaker.util import parse_cache_config_options 11 | 12 | class Principal: 13 | '''A security principal with privileges attached to it''' 14 | def __init__(self,userId,privileges=None): 15 | ''' 16 | @param userId -- mandatory user ID 17 | @param privileges -- list of privileges assigned to this user 18 | ''' 19 | self.__userId = userId 20 | self.__privileges = privileges 21 | 22 | @property 23 | def userId(self): 24 | return self.__userId 25 | 26 | @property 27 | def privileges(self): 28 | return self.__privileges 29 | 30 | class CachedCredentialsChecker: 31 | """A cached credentials checker wrapper. It will forward calls to the actual credentials checker only when the cache expires (or on first call)""" 32 | implements(ICredentialsChecker) 33 | 34 | def __init__(self,credentialInterfaces,credentialsChecker): 35 | self.credentialInterfaces = credentialInterfaces 36 | self.checker = credentialsChecker 37 | 38 | #initialize cache 39 | cacheOptions = { 40 | 'cache.type': 'memory', 41 | } 42 | self.cache = CacheManager(**parse_cache_config_options(cacheOptions)) 43 | 44 | def requestAvatarId(self,credentials): 45 | pass 46 | 47 | 48 | ################################################################################################## 49 | # 50 | # DECORATORS 51 | # 52 | ################################################################################################## 53 | 54 | def secured(privileges=None): 55 | ''' 56 | Main decorator for securing REST endpoints via roles 57 | ''' 58 | pass 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | -------------------------------------------------------------------------------- /corepost/ext/sql.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 2012-04-17 3 | 4 | @author: jacekf 5 | ''' 6 | 7 | class SqlEntityService: 8 | pass 9 | -------------------------------------------------------------------------------- /corepost/ext/sql/__init__.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Multicore module 3 | 4 | @author: jacekf 5 | ''' 6 | 7 | try: 8 | import txZMQ 9 | except ImportError as ex: 10 | print "You need to have txZMQ and ZeroMQ installed in order to use multicore support in Corepost" 11 | raise ex 12 | 13 | -------------------------------------------------------------------------------- /corepost/filters.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Various filters & interceptors 3 | @author: jacekf 4 | ''' 5 | from zope.interface import Interface 6 | 7 | class IRequestFilter(Interface): 8 | """Request filter interface""" 9 | def filterRequest(self,request): 10 | """Allows to intercept and change an incoming request""" 11 | pass 12 | 13 | class IResponseFilter(Interface): 14 | """Response filter interface""" 15 | def filterResponse(self,request,response): 16 | """Allows to intercept and change an outgoing response""" 17 | pass 18 | -------------------------------------------------------------------------------- /corepost/routing.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 2011-10-03 3 | @author: jacekf 4 | 5 | Common routing classes, regardless of whether used in HTTP or multiprocess context 6 | ''' 7 | from collections import defaultdict 8 | from corepost import Response, RESTException 9 | from corepost.enums import Http, HttpHeader 10 | from corepost.utils import getMandatoryArgumentNames, safeDictUpdate 11 | from corepost.convert import convertForSerialization, generateXml, convertToJson 12 | from corepost.filters import IRequestFilter, IResponseFilter 13 | 14 | from enums import MediaType 15 | from twisted.internet import defer 16 | from twisted.web.http import parse_qs 17 | from twisted.python import log 18 | import re, copy, exceptions, yaml,json, logging 19 | from xml.etree import ElementTree 20 | import uuid 21 | 22 | 23 | class UrlRouter: 24 | ''' Common class for containing info related to routing a request to a function ''' 25 | 26 | __urlMatcher = re.compile(r"<(int|float|uuid|):?([^/]+)>") 27 | __urlRegexReplace = {"":r"(?P([^/]+))","int":r"(?P\d+)","float":r"(?P\d+.?\d*)","uuid":r"(?P[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12})"} 28 | __typeConverters = {"int":int,"float":float,"uuid":uuid.UUID} 29 | 30 | def __init__(self,f,url,methods,accepts,produces,cache): 31 | self.__f = f 32 | self.__url = url 33 | self.__methods = methods if isinstance(methods,tuple) else (methods,) 34 | self.__accepts = accepts if isinstance(accepts,tuple) else (accepts,) 35 | self.__produces = produces 36 | self.__cache = cache 37 | self.__argConverters = {} # dict of arg names -> group index 38 | self.__validators = {} 39 | self.__mandatory = getMandatoryArgumentNames(f)[2:] 40 | 41 | def compileMatcherForFullUrl(self): 42 | """Compiles the regex matches once the URL has been updated to include the full path from the parent class""" 43 | #parse URL into regex used for matching 44 | m = UrlRouter.__urlMatcher.findall(self.url) 45 | self.__matchUrl = "^%s$" % self.url 46 | for match in m: 47 | if len(match[0]) == 0: 48 | # string 49 | self.__argConverters[match[1]] = None 50 | self.__matchUrl = self.__matchUrl.replace("<%s>" % match[1], 51 | UrlRouter.__urlRegexReplace[match[0]].replace("arg",match[1])) 52 | else: 53 | # non string 54 | self.__argConverters[match[1]] = UrlRouter.__typeConverters[match[0]] 55 | self.__matchUrl = self.__matchUrl.replace("<%s:%s>" % match, 56 | UrlRouter.__urlRegexReplace[match[0]].replace("arg",match[1])) 57 | 58 | self.__matcher = re.compile(self.__matchUrl) 59 | 60 | 61 | @property 62 | def cache(self): 63 | '''Indicates if this URL should be cached or not''' 64 | return self.__cache 65 | 66 | @property 67 | def methods(self): 68 | return self.__methods 69 | 70 | @property 71 | def url(self): 72 | return self.__url 73 | 74 | @property 75 | def accepts(self): 76 | return self.__accepts 77 | 78 | def addValidator(self,fieldName,validator): 79 | '''Adds additional field-specific formencode validators''' 80 | self.__validators[fieldName] = validator 81 | 82 | def getArguments(self,url): 83 | ''' 84 | Returns None if nothing matched (i.e. URL does not match), empty dict if no args found (i,e, static URL) 85 | or dict with arg/values for dynamic URLs 86 | ''' 87 | g = self.__matcher.search(url) 88 | if g != None: 89 | args = g.groupdict() 90 | # convert to expected datatypes 91 | if len(args) > 0: 92 | for name in args.keys(): 93 | converter = self.__argConverters[name] 94 | if converter != None: 95 | args[name] = converter(args[name]) 96 | return args 97 | else: 98 | return None 99 | 100 | def call(self,instance,request,**kwargs): 101 | '''Forwards call to underlying method''' 102 | for arg in self.__mandatory: 103 | if arg not in kwargs: 104 | raise TypeError("Missing mandatory argument '%s'" % arg) 105 | return self.__f(instance,request,**kwargs) 106 | 107 | def __str__(self): 108 | return "%s %s" % (self.url, self.methods) 109 | 110 | class UrlRouterInstance(): 111 | """Combines a UrlRouter with a class instance it should be executed against""" 112 | def __init__(self,clazz,urlRouter): 113 | self.clazz = clazz 114 | self.urlRouter = urlRouter 115 | 116 | def __str__(self): 117 | return self.urlRouter.url 118 | 119 | class CachedUrl: 120 | ''' 121 | Used for caching URLs that have been already routed once before. Avoids the overhead 122 | of regex processing on every incoming call for commonly accessed REST URLs 123 | ''' 124 | def __init__(self,urlRouterInstance,args): 125 | self.__urlRouterInstance = urlRouterInstance 126 | self.__args = args 127 | 128 | @property 129 | def urlRouterInstance(self): 130 | return self.__urlRouterInstance 131 | 132 | @property 133 | def args(self): 134 | return self.__args 135 | 136 | class RequestRouter: 137 | ''' 138 | Class that handles request->method routing functionality to any type of resource 139 | ''' 140 | 141 | def __init__(self,restServiceContainer,schema=None,filters=()): 142 | ''' 143 | Constructor 144 | ''' 145 | self.__urls = {Http.GET: defaultdict(dict),Http.POST: defaultdict(dict),Http.PUT: defaultdict(dict),Http.DELETE: defaultdict(dict),Http.OPTIONS: defaultdict(dict),Http.PATCH: defaultdict(dict),Http.HEAD: defaultdict(dict)} 146 | self.__cachedUrls = {Http.GET: defaultdict(dict),Http.POST: defaultdict(dict),Http.PUT: defaultdict(dict),Http.DELETE: defaultdict(dict),Http.OPTIONS: defaultdict(dict),Http.PATCH: defaultdict(dict),Http.HEAD: defaultdict(dict)} 147 | self.__urlRouterInstances = {} 148 | self.__schema = schema 149 | self.__urlsMehods = {} 150 | self.__registerRouters(restServiceContainer) 151 | self.__urlContainer = restServiceContainer 152 | self.__requestFilters = [] 153 | self.__responseFilters = [] 154 | 155 | if filters != None: 156 | for webFilter in filters: 157 | valid = False 158 | if IRequestFilter.providedBy(webFilter): 159 | self.__requestFilters.append(webFilter) 160 | valid = True 161 | if IResponseFilter.providedBy(webFilter): 162 | self.__responseFilters.append(webFilter) 163 | valid = True 164 | 165 | if not valid: 166 | raise RuntimeError("filter %s must implement IRequestFilter or IResponseFilter" % webFilter.__class__.__name__) 167 | 168 | @property 169 | def path(self): 170 | return self.__path 171 | 172 | def __registerRouters(self, restServiceContainer): 173 | """Main method responsible for registering routers""" 174 | from types import FunctionType 175 | 176 | for service in restServiceContainer.services: 177 | # check if the service has a root path defined, which is optional 178 | rootPath = service.__class__.path if "path" in service.__class__.__dict__ else "" 179 | 180 | for key in service.__class__.__dict__: 181 | func = service.__class__.__dict__[key] 182 | # handle REST resources directly on the CorePost resource 183 | if type(func) == FunctionType and hasattr(func,'corepostRequestRouter'): 184 | # if specified, add class path to each function's path 185 | rq = func.corepostRequestRouter 186 | #workaround for multiple passes of __registerRouters (for unit tests etc) 187 | if not hasattr(rq, 'urlAdapted'): 188 | rq.url = "%s%s" % (rootPath,rq.url) 189 | # remove first and trailing '/' to standardize URLs 190 | start = 1 if rq.url[0:1] == "/" else 0 191 | end = -1 if rq.url[len(rq.url) -1] == '/' else len(rq.url) 192 | rq.url = rq.url[start:end] 193 | setattr(rq,'urlAdapted',True) 194 | 195 | # now that the full URL is set, compile the matcher for it 196 | rq.compileMatcherForFullUrl() 197 | for method in rq.methods: 198 | for accepts in rq.accepts: 199 | urlRouterInstance = UrlRouterInstance(service,rq) 200 | self.__urls[method][rq.url][accepts] = urlRouterInstance 201 | self.__urlRouterInstances[func] = urlRouterInstance # needed so that we can lookup the urlRouterInstance for a specific function 202 | if self.__urlsMehods.get(rq.url, None) is None: 203 | self.__urlsMehods[rq.url] = [] 204 | self.__urlsMehods[rq.url].append(method) 205 | 206 | def getResponse(self,request): 207 | """Finds the appropriate instance and dispatches the request to the registered function. Returns the appropriate Response object""" 208 | # see if already cached 209 | response = None 210 | try: 211 | if len(self.__requestFilters) > 0: 212 | self.__filterRequests(request) 213 | 214 | # standardize URL and remove trailing "/" if necessary 215 | standardized_postpath = request.postpath if (request.postpath[-1] != '' or request.postpath == ['']) else request.postpath[:-1] 216 | path = '/'.join(standardized_postpath) 217 | 218 | contentType = MediaType.WILDCARD if HttpHeader.CONTENT_TYPE not in request.received_headers else request.received_headers[HttpHeader.CONTENT_TYPE] 219 | 220 | urlRouterInstance, pathargs = None, None 221 | # fetch URL arguments <-> function from cache if hit at least once before 222 | if contentType in self.__cachedUrls[request.method][path]: 223 | cachedUrl = self.__cachedUrls[request.method][path][contentType] 224 | urlRouterInstance,pathargs = cachedUrl.urlRouterInstance, cachedUrl.args 225 | else: 226 | # first time this URL is called 227 | instance = None 228 | 229 | # go through all the URLs, pick up the ones matching by content type 230 | # and then validate which ones match by path/argument to a particular UrlRouterInstance 231 | for contentTypeInstances in self.__urls[request.method].values(): 232 | 233 | if contentType in contentTypeInstances: 234 | # there is an exact function for this incoming content type 235 | instance = contentTypeInstances[contentType] 236 | elif MediaType.WILDCARD in contentTypeInstances: 237 | # fall back to any wildcard method 238 | instance = contentTypeInstances[MediaType.WILDCARD] 239 | 240 | if instance != None: 241 | # see if the path arguments match up against any function @route definition 242 | args = instance.urlRouter.getArguments(path) 243 | if args != None: 244 | 245 | if instance.urlRouter.cache: 246 | self.__cachedUrls[request.method][path][contentType] = CachedUrl(instance, args) 247 | urlRouterInstance,pathargs = instance,args 248 | break 249 | #actual call 250 | if urlRouterInstance != None and pathargs != None: 251 | allargs = copy.deepcopy(pathargs) 252 | 253 | try: 254 | # if POST/PUT, check if we need to automatically parse JSON, YAML, XML 255 | self.__parseRequestData(request) 256 | # parse request arguments from form or JSON docss 257 | self.__addRequestArguments(request, allargs) 258 | urlRouter = urlRouterInstance.urlRouter 259 | val = urlRouter.call(urlRouterInstance.clazz,request,**allargs) 260 | 261 | #handle Deferreds natively 262 | if isinstance(val,defer.Deferred): 263 | # add callback to finish the request 264 | val.addCallback(self.__finishDeferred,request) 265 | val.addErrback(self.__finishDeferredError,request) 266 | return val 267 | else: 268 | #special logic for POST to return 201 (created) 269 | if request.method == Http.POST: 270 | if hasattr(request, 'code'): 271 | if request.code == 200: 272 | request.setResponseCode(201) 273 | else: 274 | request.setResponseCode(201) 275 | 276 | response = self.__generateResponse(request, val, request.code) 277 | 278 | except exceptions.TypeError as ex: 279 | log.msg(ex,logLevel=logging.WARN) 280 | response = self.__createErrorResponse(request,400,"%s" % ex) 281 | 282 | except RESTException as ex: 283 | """Convert REST exceptions to their responses. Input errors log at a lower level to avoid overloading logs""" 284 | if (ex.response.code in (400,404)): 285 | log.msg(ex,logLevel=logging.WARN) 286 | else: 287 | log.err(ex) 288 | response = ex.response 289 | 290 | except Exception as ex: 291 | log.err(ex) 292 | response = self.__createErrorResponse(request,500,"Unexpected server error: %s\n%s" % (type(ex),ex)) 293 | 294 | #if a url is defined, but not the requested method 295 | elif not request.method in self.__urlsMehods.get(path, []) and self.__urlsMehods.get(path, []) != []: 296 | 297 | response = self.__createErrorResponse(request,501, "") 298 | else: 299 | log.msg("URL %s not found" % path,logLevel=logging.WARN) 300 | response = self.__createErrorResponse(request,404,"URL '%s' not found\n" % request.path) 301 | 302 | except Exception as ex: 303 | log.err(ex) 304 | response = self.__createErrorResponse(request,500,"Internal server error: %s" % ex) 305 | 306 | # response handling 307 | if response != None and len(self.__responseFilters) > 0: 308 | self.__filterResponses(request,response) 309 | 310 | return response 311 | 312 | def __generateResponse(self,request,response,code=200): 313 | """ 314 | Takes care of automatically rendering the response and converting it to appropriate format (text,XML,JSON,YAML) 315 | depending on what the caller can accept. Returns Response 316 | """ 317 | if isinstance(response, str): 318 | return Response(code,response,{HttpHeader.CONTENT_TYPE: MediaType.TEXT_PLAIN}) 319 | elif isinstance(response, Response): 320 | return response 321 | else: 322 | (content,contentType) = self.__convertObjectToContentType(request, response) 323 | return Response(code,content,{HttpHeader.CONTENT_TYPE:contentType}) 324 | 325 | def __convertObjectToContentType(self,request,obj): 326 | """ 327 | Takes care of converting an object (non-String) response to the appropriate format, based on the what the caller can accept. 328 | Returns a tuple of (content,contentType) 329 | """ 330 | obj = convertForSerialization(obj) 331 | 332 | if HttpHeader.ACCEPT in request.received_headers: 333 | accept = request.received_headers[HttpHeader.ACCEPT] 334 | if MediaType.APPLICATION_JSON in accept: 335 | return (convertToJson(obj),MediaType.APPLICATION_JSON) 336 | elif MediaType.TEXT_YAML in accept: 337 | return (yaml.dump(obj),MediaType.TEXT_YAML) 338 | elif MediaType.APPLICATION_XML in accept or MediaType.TEXT_XML in accept: 339 | return (generateXml(obj),MediaType.APPLICATION_XML) 340 | else: 341 | # no idea, let's do JSON 342 | return (convertToJson(obj),MediaType.APPLICATION_JSON) 343 | else: 344 | # called has no accept header, let's default to JSON 345 | return (convertToJson(obj),MediaType.APPLICATION_JSON) 346 | 347 | def __finishDeferred(self,val,request): 348 | """Finishes any Defered/inlineCallback methods. Returns Response""" 349 | if isinstance(val,Response): 350 | return val 351 | elif val != None: 352 | try: 353 | return self.__generateResponse(request,val) 354 | except Exception as ex: 355 | msg = "Unexpected server error: %s\n%s" % (type(ex),ex) 356 | return self.__createErrorResponse(request, 500, msg) 357 | else: 358 | return Response(209,None) 359 | 360 | def __finishDeferredError(self,error,request): 361 | """Finishes any Defered/inlineCallback methods that raised an error. Returns Response""" 362 | log.err(error, "Deferred failed") 363 | return self.__createErrorResponse(request, 500,"Internal server error") 364 | 365 | def __createErrorResponse(self,request,code,message): 366 | """Common method for rendering errors""" 367 | return Response(code=code, entity=message, headers={"content-type": MediaType.TEXT_PLAIN}) 368 | 369 | def __parseRequestData(self,request): 370 | '''Automatically parses JSON,XML,YAML if present''' 371 | if request.method in (Http.POST,Http.PUT) and HttpHeader.CONTENT_TYPE in request.received_headers.keys(): 372 | contentType = request.received_headers["content-type"] 373 | request.data = request.content.read() 374 | 375 | if contentType == MediaType.APPLICATION_JSON: 376 | try: 377 | request.json = json.loads(request.data) if request.data else {} 378 | except Exception as ex: 379 | raise TypeError("Unable to parse JSON body: %s" % ex) 380 | elif contentType in (MediaType.APPLICATION_XML,MediaType.TEXT_XML): 381 | try: 382 | request.xml = ElementTree.XML(request.data) 383 | except Exception as ex: 384 | raise TypeError("Unable to parse XML body: %s" % ex) 385 | elif contentType == MediaType.TEXT_YAML: 386 | try: 387 | request.yaml = yaml.safe_load(request.data) 388 | except Exception as ex: 389 | raise TypeError("Unable to parse YAML body: %s" % ex) 390 | 391 | def __addRequestArguments(self,request,allargs): 392 | """Parses the request form arguments OR JSON document root elements to build the list of arguments to a method""" 393 | # handler for weird Twisted logic where PUT does not get form params 394 | # see: http://twistedmatrix.com/pipermail/twisted-web/2007-March/003338.html 395 | requestargs = request.args 396 | 397 | if request.method == Http.PUT and HttpHeader.CONTENT_TYPE in request.received_headers.keys() \ 398 | and request.received_headers[HttpHeader.CONTENT_TYPE] == MediaType.APPLICATION_FORM_URLENCODED: 399 | # request.data is populated in __parseRequestData 400 | requestargs = parse_qs(request.data, 1) 401 | 402 | #merge form args 403 | if len(requestargs.keys()) > 0: 404 | for arg in requestargs.keys(): 405 | # maintain first instance of an argument always 406 | safeDictUpdate(allargs,arg,requestargs[arg][0]) 407 | elif hasattr(request,'json'): 408 | # if YAML parse root elements instead of form elements 409 | for key in request.json.keys(): 410 | safeDictUpdate(allargs, key, request.json[key]) 411 | elif hasattr(request,'yaml'): 412 | # if YAML parse root elements instead of form elements 413 | for key in request.yaml.keys(): 414 | safeDictUpdate(allargs, key, request.yaml[key]) 415 | elif hasattr(request,'xml'): 416 | # if XML, parse attributes first, then root nodes 417 | for key in request.xml.attrib: 418 | safeDictUpdate(allargs, key, request.xml.attrib[key]) 419 | for el in request.xml.findall("*"): 420 | safeDictUpdate(allargs, el.tag,el.text) 421 | 422 | 423 | def __filterRequests(self,request): 424 | """Filters incoming requests""" 425 | for webFilter in self.__requestFilters: 426 | webFilter.filterRequest(request) 427 | 428 | def __filterResponses(self,request,response): 429 | """Filters incoming requests""" 430 | for webFilter in self.__responseFilters: 431 | webFilter.filterResponse(request,response) -------------------------------------------------------------------------------- /corepost/test/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jacek99/corepost/4d4c0efa0af4a113dcd7f22c14c918dbce65c0c9/corepost/test/__init__.py -------------------------------------------------------------------------------- /corepost/test/arguments.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Argument extraction tests 3 | @author: jacekf 4 | ''' 5 | 6 | from corepost.web import RESTResource, validate, route 7 | from corepost.enums import Http 8 | from formencode import Schema, validators 9 | 10 | class TestSchema(Schema): 11 | allow_extra_fields = True 12 | childId = validators.Regex(regex="^jacekf|test$") 13 | 14 | class ArgumentApp(): 15 | 16 | @route("/int//float//string/",Http.GET) 17 | def test(self,request,intarg,floatarg,stringarg,**kwargs): 18 | args = (intarg,floatarg,stringarg) 19 | return "%s" % map(lambda x: (type(x),x),args) 20 | 21 | @route("/validate//schema",Http.POST) 22 | @validate(schema=TestSchema()) 23 | def postValidateSchema(self,request,rootId,childId,**kwargs): 24 | return "%s - %s - %s" % (rootId,childId,kwargs) 25 | 26 | @route("/validate//custom",Http.POST) 27 | @validate(childId=validators.Regex(regex="^jacekf|test$")) 28 | def postValidateCustom(self,request,rootId,childId,**kwargs): 29 | return "%s - %s - %s" % (rootId,childId,kwargs) 30 | 31 | @route("/formOrJson",Http.GET) 32 | def getArgumentsByContentType(self,request,first,last,**kwargs): 33 | return "%s %s" % (str(first),str(last)) 34 | 35 | @route("/formOrJson",(Http.POST,Http.PUT)) 36 | def postArgumentsByContentType(self,request,first,last,**kwargs): 37 | return "%s %s" % (str(first),str(last)) 38 | 39 | 40 | def run_app_arguments(): 41 | app = RESTResource((ArgumentApp(),)) 42 | app.run(8082) -------------------------------------------------------------------------------- /corepost/test/feature/arguments.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @arguments 4 | Feature: Arguments 5 | CorePost should be able to correctly extract arguments 6 | from paths, query arguments, form arguments and JSON documents 7 | 8 | @arguments_ok 9 | Scenario Outline: Path argument extraction 10 | Given 'arguments' is running 11 | When as user 'None:None' I GET 'http://127.0.0.1:8082' 12 | Then I expect HTTP code 13 | And I expect content contains '' 14 | 15 | Examples: 16 | | url | code | content | 17 | | /int/1/float/1.1/string/TEST | 200 | [(, 1), (, 1.1), (, 'TEST')] | 18 | | /int/1/float/1/string/TEST | 200 | [(, 1), (, 1.0), (, 'TEST')] | 19 | | /int/1/float/1/string/23 | 200 | [(, 1), (, 1.0), (, '23')] | 20 | 21 | @arguments_error 22 | Scenario Outline: Path argument extraction - error handling 23 | Given 'arguments' is running 24 | When as user 'None:None' I GET 'http://127.0.0.1:8082' 25 | Then I expect HTTP code 26 | And I expect content contains '' 27 | 28 | Examples: 29 | | url | code | content | 30 | | /int/WRONG/float/1.1/string/TEST | 404 | URL '/int/WRONG/float/1.1/string/TEST' not found | 31 | | /int/1/float/WRONG/string/TEST | 404 | URL '/int/1/float/WRONG/string/TEST' not found | 32 | 33 | @arguments_by_type 34 | Scenario Outline: Parse form arguments OR from JSON documents for POST / PUT 35 | Given 'arguments' is running 36 | 37 | # pass in as form arguments 38 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with 'first=John&last=Doe' 39 | Then I expect HTTP code 40 | And I expect content contains 'John Doe' 41 | 42 | # pass in as *** JSON *** document 43 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with JSON 44 | """ 45 | {"first":"Jane","last":"Doeovskaya"} 46 | """ 47 | Then I expect HTTP code 48 | And I expect content contains 'Jane Doeovskaya' 49 | # additional arguments should be OK 50 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with JSON 51 | """ 52 | {"first":"Jane","last":"Doeovskaya","middle":"Oksana"} 53 | """ 54 | Then I expect HTTP code 55 | And I expect content contains 'Jane Doeovskaya' 56 | 57 | # pass in as *** YAML *** document 58 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with YAML 59 | """ 60 | first: Oksana 61 | last: Dolovskaya 62 | """ 63 | Then I expect HTTP code 64 | And I expect content contains 'Oksana Dolovskaya' 65 | # additional arguments should be OK 66 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with YAML 67 | """ 68 | first: Svetlana 69 | middle: Jane 70 | last: Gingrychnoya 71 | """ 72 | Then I expect HTTP code 73 | And I expect content contains 'Svetlana Gingrychnoya' 74 | 75 | # pass in as *** XML *** document wit both attributes and child nodes 76 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with XML 77 | """ 78 | 79 | """ 80 | Then I expect HTTP code 81 | And I expect content contains 'John Doe' 82 | 83 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with XML 84 | """ 85 | 86 | Dolowski 87 | 88 | """ 89 | Then I expect HTTP code 90 | And I expect content contains 'Jan Dolowski' 91 | 92 | When as user 'None:None' I 'http://127.0.0.1:8082/formOrJson' with XML 93 | """ 94 | 95 | Grzegorz 96 | Jim 97 | Brzeczyszczykiewicz 98 | 99 | """ 100 | Then I expect HTTP code 101 | And I expect content contains 'Grzegorz Brzeczyszczykiewicz' 102 | 103 | Examples: 104 | | method | code | 105 | | POST | 201 | 106 | | PUT | 200 | 107 | 108 | @arguments_by_type 109 | Scenario: Parse query arguments for GET 110 | Given 'arguments' is running 111 | When as user 'None:None' I GET 'http://127.0.0.1:8082/formOrJson?first=John&last=Doe' 112 | Then I expect HTTP code 200 113 | And I expect content contains 'John Doe' 114 | -------------------------------------------------------------------------------- /corepost/test/feature/content_types.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @content_types 4 | Feature: Content types 5 | CorePost should be able to 6 | correctly parse/generate 7 | JSON/XML/YAML based on content types 8 | 9 | Background: 10 | Given 'home_resource' is running 11 | 12 | @json 13 | Scenario Outline: Parse incoming JSON data 14 | When as user 'None:None' I 'http://127.0.0.1:8080/post/json' with JSON 15 | """ 16 | {"test":"test2"} 17 | """ 18 | Then I expect HTTP code 19 | And I expect JSON content 20 | """ 21 | {"test":"test2"} 22 | """ 23 | 24 | Examples: 25 | | method | code | 26 | | POST | 201 | 27 | | PUT | 200 | 28 | 29 | @json 30 | Scenario Outline: Handle invalid incoming JSON data 31 | When as user 'None:None' I 'http://127.0.0.1:8080/post/json' with JSON 32 | """ 33 | wrong_json 34 | """ 35 | Then I expect HTTP code 400 36 | And I expect content contains 'Unable to parse JSON body: No JSON object could be decoded' 37 | 38 | Examples: 39 | | method | 40 | | POST | 41 | | PUT | 42 | 43 | @xml 44 | Scenario Outline: Parse incoming XML data 45 | When as user 'None:None' I 'http://127.0.0.1:8080/post/xml' with XML 46 | """ 47 | TESTYo 48 | """ 49 | Then I expect HTTP code 50 | # ElementTree object 51 | And I expect content contains 'TESTYo' 52 | 53 | Examples: 54 | | method | code | 55 | | POST | 201 | 56 | | PUT | 200 | 57 | 58 | @xml 59 | Scenario Outline: Handle invalid XML data 60 | When as user 'None:None' I 'http://127.0.0.1:8080/post/xml' with XML 61 | """ 62 | wrong xml 63 | """ 64 | Then I expect HTTP code 400 65 | And I expect content contains 'Unable to parse XML body: syntax error: line 1, column 0' 66 | 67 | Examples: 68 | | method | 69 | | POST | 70 | | PUT | 71 | 72 | 73 | @yaml 74 | Scenario Outline: Parse incoming YAML data 75 | When as user 'None:None' I 'http://127.0.0.1:8080/post/yaml' with YAML 76 | """ 77 | invoice: 34843 78 | date : 2001-01-23 79 | bill-to: &id001 80 | given : Chris 81 | family : Dumars 82 | address: 83 | lines: | 84 | 458 Walkman Dr. 85 | Suite #292 86 | city : Royal Oak 87 | state : MI 88 | postal : 48046 89 | ship-to: *id001 90 | product: 91 | - sku : BL394D 92 | quantity : 4 93 | description : Basketball 94 | price : 450.00 95 | - sku : BL4438H 96 | quantity : 1 97 | description : Super Hoop 98 | price : 2392.00 99 | tax : 251.42 100 | total: 4443.52 101 | comments: > 102 | Late afternoon is best. 103 | Backup contact is Nancy 104 | Billsmer @ 338-4338. 105 | """ 106 | Then I expect HTTP code 107 | And I expect content contains 108 | """ 109 | bill-to: &id001 110 | address: 111 | city: Royal Oak 112 | lines: '458 Walkman Dr. 113 | 114 | Suite #292 115 | 116 | ' 117 | postal: 48046 118 | state: MI 119 | family: Dumars 120 | given: Chris 121 | comments: Late afternoon is best. Backup contact is Nancy Billsmer @ 338-4338. 122 | date: 2001-01-23 123 | invoice: 34843 124 | product: 125 | - description: Basketball 126 | price: 450.0 127 | quantity: 4 128 | sku: BL394D 129 | - description: Super Hoop 130 | price: 2392.0 131 | quantity: 1 132 | sku: BL4438H 133 | ship-to: *id001 134 | tax: 251.42 135 | total: 4443.52 136 | """ 137 | 138 | Examples: 139 | | method | code | 140 | | POST | 201 | 141 | | PUT | 200 | 142 | 143 | @yaml 144 | Scenario Outline: Handle invalid YAML data 145 | When as user 'None:None' I 'http://127.0.0.1:8080/post/yaml' with YAML 146 | """ 147 | - test 148 | {test} 149 | """ 150 | Then I expect HTTP code 400 151 | And I expect content contains 'Unable to parse YAML body: while scanning a simple key' 152 | 153 | Examples: 154 | | method | 155 | | POST | 156 | | PUT | 157 | 158 | @json @yaml @xml @route_content_type 159 | Scenario Outline: Route by incoming content type 160 | When I prepare HTTP header 'content-type' = '' 161 | When as user 'None:None' I 'http://127.0.0.1:8080/post/by/content' with body '' 162 | Then I expect HTTP code 163 | And I expect content contains '' 164 | 165 | Examples: 166 | | method | type | body | content | code | 167 | | POST | JSON | {"test":2} | application/json | 201 | 168 | | POST | XML | 1 | application/xml | 201 | 169 | | POST | XML | 1 | text/xml | 201 | 170 | | POST | YAML | test: 2 | text/yaml | 201 | 171 | | PUT | JSON | {"test":2} | application/json | 200 | 172 | | PUT | XML | 1 | text/xml | 200 | 173 | | PUT | XML | 1 | application/xml | 200 | 174 | | PUT | YAML | test: 2 | text/yaml | 200 | 175 | 176 | @json @yaml @xml @return_accept 177 | Scenario Outline: Return content type based on caller's Accept 178 | When I prepare HTTP header 'Accept' = '' 179 | When as user 'None:None' I GET 'http://127.0.0.1:8080/return/by/accept' 180 | Then I expect HTTP code 181 | And I expect content contains '' 182 | 183 | Examples: 184 | | content | accept | code | 185 | | [{"test1": "Test1"}, {"test2": "Test2"}] | application/json | 200 | 186 | | Test1Test2 | application/xml | 200 | 187 | | - {test1: Test1}\n- {test2: Test2} | text/yaml | 200 | 188 | 189 | @json @yaml @xml @return_accept_deferred 190 | Scenario Outline: Return content type based on caller's Accept from Deferred methods 191 | When I prepare HTTP header 'Accept' = '' 192 | When as user 'None:None' I GET 'http://127.0.0.1:8080/return/by/accept/deferred' 193 | Then I expect HTTP code 194 | And I expect content contains '' 195 | 196 | Examples: 197 | | content | accept | code | 198 | | [{"test1": "Test1"}, {"test2": "Test2"}] | application/json | 200 | 199 | | Test1Test2 | application/xml | 200 | 200 | | - {test1: Test1}\n- {test2: Test2} | text/yaml | 200 | 201 | 202 | @json @yaml @xml @return_accept @tmp 203 | Scenario Outline: Return class content type based on caller's Accept 204 | When I prepare HTTP header 'Accept' = '' 205 | When as user 'None:None' I GET 'http://127.0.0.1:8080/return/by/accept/class' 206 | Then I expect HTTP code 207 | And I expect content contains '' 208 | 209 | Examples: 210 | | content | accept | code | 211 | | [{"test1": "Test1"}, {"test2": "Test2"}] | application/json | 200 | 212 | | Test1Test2 | application/xml | 200 | 213 | | - {test1: Test1}\n- {test2: Test2} | text/yaml | 200 | 214 | -------------------------------------------------------------------------------- /corepost/test/feature/filters.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @filters 4 | Feature: Filters 5 | CorePost should be able to 6 | filter incoming requests and outgoing responses 7 | 8 | Background: 9 | Given 'filter_resource' is running 10 | 11 | Scenario: Filter turns 404 into 503 12 | When as user 'None:None' I GET 'http://127.0.0.1:8083/wrongurl' 13 | Then I expect HTTP code 503 14 | 15 | Scenario: Request filter adds a header + wrap around requests 16 | When I prepare HTTP header 'Accept' = 'application/json' 17 | When as user 'None:None' I GET 'http://127.0.0.1:8083/' 18 | Then I expect HTTP code 200 19 | # 'custom-header' should be added 20 | # 'x-wrap-input' should be added from wrap request filter 21 | And I expect JSON content 22 | """ 23 | { 24 | "accept": "application/json", 25 | "accept-encoding": "gzip, deflate", 26 | "custom-header": "Custom Header Value", 27 | "host": "127.0.0.1:8083", 28 | "x-wrap-input": "Input" 29 | } 30 | """ 31 | # 'x-wrap-header' should be added from wrap response filter 32 | And I expect 'x-wrap-output' header matches 'Output' 33 | -------------------------------------------------------------------------------- /corepost/test/feature/issues.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @issues 4 | Feature: Issues 5 | Fixes for issues reported on github 6 | 7 | @issue1 8 | Scenario: Issue 1 (unable to access self.var in a router method) 9 | Given 'home_resource' is running 10 | When as user 'None:None' I GET 'http://127.0.0.1:8080/issues/1' 11 | Then I expect HTTP code 200 12 | And I expect content contains 'issue 1' 13 | 14 | -------------------------------------------------------------------------------- /corepost/test/feature/rest_app.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @rest 4 | Feature: REST App 5 | CorePost should be able to build REST applications 6 | for nested REST resources 7 | 8 | Background: 9 | Given 'rest_resource' is running 10 | # make sure it is empty 11 | When as user 'None:None' I DELETE 'http://127.0.0.1:8085/customer' 12 | Then I expect HTTP code 200 13 | # add a few default customers 14 | When as user 'None:None' I POST 'http://127.0.0.1:8085/customer' with 'customerId=d1&firstName=John&lastName=Doe1' 15 | Then I expect HTTP code 201 16 | When as user 'None:None' I POST 'http://127.0.0.1:8085/customer' with 'customerId=d2&firstName=John&lastName=Doe2' 17 | Then I expect HTTP code 201 18 | When as user 'None:None' I POST 'http://127.0.0.1:8085/customer' with 'customerId=d3&firstName=John&lastName=Doe3' 19 | Then I expect HTTP code 201 20 | 21 | 22 | @customer 23 | Scenario: Full Customer lifecycle 24 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer' 25 | Then I expect HTTP code 200 26 | And I expect JSON content 27 | """ 28 | [ 29 | { 30 | "addresses": {}, 31 | "customerId": "d2", 32 | "firstName": "John", 33 | "lastName": "Doe2" 34 | }, 35 | { 36 | "addresses": {}, 37 | "customerId": "d3", 38 | "firstName": "John", 39 | "lastName": "Doe3" 40 | }, 41 | { 42 | "addresses": {}, 43 | "customerId": "d1", 44 | "firstName": "John", 45 | "lastName": "Doe1" 46 | } 47 | ] 48 | """ 49 | # add 1 50 | When as user 'None:None' I POST 'http://127.0.0.1:8085/customer' with 'customerId=c1&firstName=John&lastName=Doe' 51 | Then I expect HTTP code 201 52 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/c1' 53 | Then I expect HTTP code 200 54 | And I expect JSON content 55 | """ 56 | { 57 | "addresses": {}, 58 | "customerId": "c1", 59 | "firstName": "John", 60 | "lastName": "Doe" 61 | } 62 | """ 63 | # update 64 | When as user 'None:None' I PUT 'http://127.0.0.1:8085/customer/c1' with 'firstName=Jill&lastName=Jones' 65 | Then I expect HTTP code 200 66 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/c1' 67 | Then I expect HTTP code 200 68 | And I expect JSON content 69 | """ 70 | { 71 | "addresses": {}, 72 | "customerId": "c1", 73 | "firstName": "Jill", 74 | "lastName": "Jones" 75 | } 76 | """ 77 | # delete 78 | When as user 'None:None' I DELETE 'http://127.0.0.1:8085/customer/c1' 79 | Then I expect HTTP code 200 80 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/c1' 81 | Then I expect HTTP code 404 82 | # delete all 83 | When as user 'None:None' I DELETE 'http://127.0.0.1:8085/customer' 84 | Then I expect HTTP code 200 85 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer' 86 | Then I expect HTTP code 200 87 | And I expect JSON content 88 | """ 89 | [] 90 | """ 91 | 92 | @customer_address 93 | Scenario: Full Customer Address lifecycle 94 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address' 95 | Then I expect HTTP code 200 96 | And I expect JSON content 97 | """ 98 | {} 99 | """ 100 | # add 1 101 | When as user 'None:None' I POST 'http://127.0.0.1:8085/customer/d1/address' with 'addressId=HOME&streetNumber=100&streetName=MyStreet&stateCode=CA&countryCode=US' 102 | Then I expect HTTP code 201 103 | # get just the address 104 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME' 105 | Then I expect HTTP code 200 106 | And I expect JSON content 107 | """ 108 | { 109 | "countryCode": "US", 110 | "stateCode": "CA", 111 | "streetName": "MyStreet", 112 | "streetNumber": "100" 113 | } 114 | """ 115 | # get the customer with the address 116 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1' 117 | Then I expect HTTP code 200 118 | And I expect JSON content 119 | """ 120 | { 121 | "addresses": { 122 | "HOME": { 123 | "countryCode": "US", 124 | "stateCode": "CA", 125 | "streetName": "MyStreet", 126 | "streetNumber": "100" 127 | } 128 | }, 129 | "customerId": "d1", 130 | "firstName": "John", 131 | "lastName": "Doe1" 132 | } 133 | """ 134 | # update address 135 | When as user 'None:None' I PUT 'http://127.0.0.1:8085/customer/d1/address/HOME' with 'streetNumber=1002&streetName=MyStreet2&stateCode=CA&countryCode=US' 136 | Then I expect HTTP code 200 137 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME' 138 | Then I expect HTTP code 200 139 | And I expect JSON content 140 | """ 141 | { 142 | "countryCode": "US", 143 | "stateCode": "CA", 144 | "streetName": "MyStreet2", 145 | "streetNumber": "1002" 146 | } 147 | """ 148 | # delete address 149 | When as user 'None:None' I DELETE 'http://127.0.0.1:8085/customer/d1/address/HOME' 150 | Then I expect HTTP code 200 151 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME' 152 | Then I expect HTTP code 404 153 | 154 | @customer @xml @issue4 155 | Scenario: Customers as XML (Issue #4) 156 | # all 157 | When I prepare HTTP header 'Accept' = 'application/xml' 158 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer' 159 | Then I expect HTTP code 200 160 | Then I expect content contains 'Doe2d2JohnDoe3d3JohnDoe1d1John' 161 | # 1 162 | When I prepare HTTP header 'Accept' = 'application/xml' 163 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d2' 164 | Then I expect HTTP code 200 165 | Then I expect content contains 'Doe2d2John' 166 | # 1 with address 167 | When as user 'None:None' I POST 'http://127.0.0.1:8085/customer/d2/address' with 'addressId=HOME&streetNumber=100&streetName=MyStreet&stateCode=CA&countryCode=US' 168 | When I prepare HTTP header 'Accept' = 'application/xml' 169 | When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d2' 170 | Then I expect HTTP code 200 171 | Then I expect content contains 'Doe2d2JohnUSMyStreet100CA' 172 | -------------------------------------------------------------------------------- /corepost/test/feature/url_routing.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @url_routing 4 | Feature: URL routing 5 | CorePost should be able to 6 | correctly route requests 7 | depending on how the Resource instances 8 | were registered 9 | 10 | @single @single_get 11 | Scenario: Single resource - GET 12 | Given 'home_resource' is running 13 | When as user 'None:None' I GET 'http://127.0.0.1:8080' 14 | Then I expect HTTP code 200 15 | And I expect content contains '{}' 16 | When as user 'None:None' I GET 'http://127.0.0.1:8080/?test=value' 17 | Then I expect HTTP code 200 18 | And I expect content contains '{'test': 'value'}' 19 | When as user 'None:None' I GET 'http://127.0.0.1:8080/test?query=test' 20 | Then I expect HTTP code 200 21 | And I expect content contains '{'query': 'test'}' 22 | When as user 'None:None' I GET 'http://127.0.0.1:8080/test/23/resource/someid' 23 | Then I expect HTTP code 200 24 | And I expect content contains '23 - someid' 25 | 26 | @single @single_post 27 | Scenario: Single resource - POST 28 | Given 'home_resource' is running 29 | When as user 'None:None' I POST 'http://127.0.0.1:8080/post' with 'test=value&test2=value2' 30 | Then I expect HTTP code 201 31 | And I expect content contains '{'test': 'value', 'test2': 'value2'}' 32 | 33 | @single @single_put 34 | Scenario: Single resource - PUT 35 | Given 'home_resource' is running 36 | When as user 'None:None' I PUT 'http://127.0.0.1:8080/put' with 'test=value&test2=value2' 37 | Then I expect HTTP code 200 38 | And I expect content contains '{'test': 'value', 'test2': 'value2'}' 39 | 40 | @single @single_delete 41 | Scenario: Single resource - DELETE 42 | Given 'home_resource' is running 43 | When as user 'None:None' I DELETE 'http://127.0.0.1:8080/delete' 44 | Then I expect HTTP code 200 45 | 46 | @single @single_post @single_put 47 | Scenario: Single resource - multiple methods at same URL 48 | Given 'home_resource' is running 49 | When as user 'None:None' I POST 'http://127.0.0.1:8080/postput' with 'test=value&test2=value2' 50 | # POST return 201 by default 51 | Then I expect HTTP code 201 52 | And I expect content contains '{'test': 'value', 'test2': 'value2'}' 53 | When as user 'None:None' I PUT 'http://127.0.0.1:8080/postput' with 'test=value&test3=value3' 54 | # PUT return 200 by default 55 | Then I expect HTTP code 200 56 | And I expect content contains '{'test': 'value', 'test3': 'value3'}' 57 | 58 | @multi 59 | Scenario Outline: Multiple resources with submodules 60 | Given 'multi_resource' is running 61 | When as user 'None:None' I GET '' 62 | Then I expect HTTP code 200 63 | 64 | Examples: 65 | | url | 66 | | http://127.0.0.1:8081 | 67 | | http://127.0.0.1:8081/ | 68 | | http://127.0.0.1:8081/module1 | 69 | | http://127.0.0.1:8081/module1/ | 70 | | http://127.0.0.1:8081/module1/sub | 71 | | http://127.0.0.1:8081/module2 | 72 | | http://127.0.0.1:8081/module2/ | 73 | | http://127.0.0.1:8081/module2/sub | 74 | 75 | @501 76 | Scenario: Existing URLs with wrong HTTP method returns 501 error 77 | Given 'home_resource' is running 78 | When as user 'None:None' I DELETE 'http://127.0.0.1:8080/postput' 79 | Then I expect HTTP code 501 80 | When as user 'None:None' I GET 'http://127.0.0.1:8080/postput' 81 | Then I expect HTTP code 501 82 | 83 | @head 84 | Scenario: Support for HTTP HEAD 85 | Given 'home_resource' is running 86 | When as user 'None:None' I GET 'http://127.0.0.1:8080/methods/head' 87 | Then I expect HTTP code 501 88 | When as user 'None:None' I HEAD 'http://127.0.0.1:8080/methods/head' 89 | Then I expect HTTP code 200 90 | 91 | @options 92 | Scenario: TODO: Support for HTTP OPTIONS 93 | Given 'home_resource' is running 94 | When as user 'None:None' I GET 'http://127.0.0.1:8080/methods/options' 95 | Then I expect HTTP code 501 96 | When as user 'None:None' I OPTIONS 'http://127.0.0.1:8080/methods/options' 97 | #this is unexpected - need to verify with kaosat 98 | Then I expect HTTP code 501 99 | 100 | @patch 101 | Scenario: TODO: Support for HTTP PATCH 102 | Given 'home_resource' is running 103 | When as user 'None:None' I GET 'http://127.0.0.1:8080/methods/options' 104 | Then I expect HTTP code 501 105 | #this is unexpected - need to verify with kaosat 106 | When as user 'None:None' I PATCH 'http://127.0.0.1:8080/methods/patch' with 'tes1=value1&test2=value2' 107 | Then I expect HTTP code 501 108 | 109 | 110 | -------------------------------------------------------------------------------- /corepost/test/feature/validate.feature: -------------------------------------------------------------------------------- 1 | Using step definitions from: '../steps' 2 | 3 | @validate 4 | Feature: Argument Validators 5 | CorePost should be able to correctly validate path, query and form arguments 6 | 7 | @validate 8 | Scenario Outline: Form argument validation 9 | Given 'arguments' is running 10 | # childId accepts only jacekf or test, via Regex validator 11 | When as user 'None:None' I POST 'http://127.0.0.1:8082/validate/23/' with '' 12 | Then I expect HTTP code 13 | And I expect content contains '' 14 | 15 | Examples: 16 | | url | args | code | content | 17 | # validates using argument-specific validators 18 | | custom | childId=jacekf | 201 | 23 - jacekf - {} | 19 | | custom | childId=jacekf&otherId=test | 201 | 23 - jacekf - {'otherId': 'test'} | 20 | | custom | childId=test | 201 | 23 - test - {} | 21 | | custom | childId=wrong | 400 | childId: The input is not valid ('wrong') | 22 | # validates using Schema 23 | | schema | childId=jacekf | 201 | 23 - jacekf - {} | 24 | | schema | childId=jacekf&otherId=test | 201 | 23 - jacekf - {'otherId': 'test'} | 25 | | schema | childId=test | 201 | 23 - test - {} | 26 | | schema | childId=wrong | 400 | childId: The input is not valid ('wrong') | 27 | -------------------------------------------------------------------------------- /corepost/test/feature/zeromq_resource.py: -------------------------------------------------------------------------------- 1 | ''' 2 | ZeroMQ resource 3 | 4 | @author: jacekf 5 | ''' 6 | 7 | from corepost.web import RESTResource, route 8 | from corepost.enums import Http 9 | from corepost.filters import IRequestFilter, IResponseFilter 10 | from zope.interface import implements 11 | 12 | from multiprocessing import Pool 13 | 14 | class TestService: 15 | 16 | @route("/") 17 | def forward(self,request): 18 | return "" 19 | 20 | def startClient(): 21 | return "TEST" 22 | 23 | 24 | def run_app_multicore(): 25 | #start the ZeroMQ client 26 | pool = Pool(processes=4) 27 | 28 | #start the server 29 | app = RESTResource((TestService(),)) 30 | app.run(8090) 31 | 32 | if __name__ == "__main__": 33 | run_app_multicore() 34 | 35 | -------------------------------------------------------------------------------- /corepost/test/filter_resource.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Server tests 3 | @author: jacekf 4 | ''' 5 | 6 | from corepost.web import RESTResource, route 7 | from corepost.enums import Http 8 | from corepost.filters import IRequestFilter, IResponseFilter 9 | from zope.interface import implements 10 | 11 | class AddCustomHeaderFilter(): 12 | """Implements just a request filter""" 13 | implements(IRequestFilter) 14 | 15 | def filterRequest(self,request): 16 | request.received_headers["Custom-Header"] = "Custom Header Value" 17 | 18 | class Change404to503Filter(): 19 | """Implements just a response filter that changes 404 to 503 statuses""" 20 | implements(IResponseFilter) 21 | 22 | def filterResponse(self,request,response): 23 | if response.code == 404: 24 | response.code = 503 25 | 26 | class WrapAroundFilter(): 27 | """Implements both types of filters in one class""" 28 | implements(IRequestFilter,IResponseFilter) 29 | 30 | def filterRequest(self,request): 31 | del(request.received_headers["user-agent"]) # remove this for unit tests, it varies from one box to another 32 | request.received_headers["X-Wrap-Input"] = "Input" 33 | 34 | def filterResponse(self,request,response): 35 | response.headers["X-Wrap-Output"] = "Output" 36 | 37 | class FilterService(): 38 | path = "/" 39 | 40 | @route("/",Http.GET) 41 | def root(self,request,**kwargs): 42 | return request.received_headers 43 | 44 | def run_filter_app(): 45 | app = RESTResource(services=(FilterService(),),filters=(Change404to503Filter(),AddCustomHeaderFilter(),WrapAroundFilter(),)) 46 | app.run(8083) 47 | 48 | if __name__ == "__main__": 49 | run_filter_app() -------------------------------------------------------------------------------- /corepost/test/home_resource.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Server tests 3 | @author: jacekf 4 | ''' 5 | 6 | from corepost.web import RESTResource, route 7 | from corepost.enums import Http, MediaType, HttpHeader 8 | from twisted.internet import defer 9 | from xml.etree import ElementTree 10 | import json, yaml 11 | 12 | class HomeApp(): 13 | 14 | def __init__(self,*args,**kwargs): 15 | self.issue1 = "issue 1" 16 | 17 | @route("/",Http.GET) 18 | @defer.inlineCallbacks 19 | def root(self,request,**kwargs): 20 | yield 1 21 | request.write("%s" % kwargs) 22 | request.finish() 23 | 24 | @route("/test",Http.GET) 25 | def test(self,request,**kwargs): 26 | return "%s" % kwargs 27 | 28 | @route("/test//resource/",Http.GET) 29 | def test_get_resources(self,request,numericid,stringid,**kwargs): 30 | return "%s - %s" % (numericid,stringid) 31 | 32 | @route("/post",(Http.POST,Http.PUT)) 33 | def test_post(self,request,**kwargs): 34 | return "%s" % kwargs 35 | 36 | @route("/put",(Http.POST,Http.PUT)) 37 | def test_put(self,request,**kwargs): 38 | return "%s" % kwargs 39 | 40 | @route("/postput",(Http.POST,Http.PUT)) 41 | def test_postput(self,request,**kwargs): 42 | return "%s" % kwargs 43 | 44 | @route("/delete",Http.DELETE) 45 | def test_delete(self,request,**kwargs): 46 | return "%s" % kwargs 47 | 48 | @route("/post/json",(Http.POST,Http.PUT)) 49 | def test_json(self,request,**kwargs): 50 | return "%s" % json.dumps(request.json) 51 | 52 | @route("/post/xml",(Http.POST,Http.PUT)) 53 | def test_xml(self,request,**kwargs): 54 | return "%s" % ElementTree.tostring(request.xml) 55 | 56 | @route("/post/yaml",(Http.POST,Http.PUT)) 57 | def test_yaml(self,request,**kwargs): 58 | return "%s" % yaml.dump(request.yaml,indent=4,width=130,default_flow_style=False) 59 | 60 | ################################################################## 61 | # same URLs, routed by incoming content type 62 | ################################################################### 63 | @route("/post/by/content",(Http.POST,Http.PUT),MediaType.APPLICATION_JSON) 64 | def test_content_app_json(self,request,**kwargs): 65 | return request.received_headers[HttpHeader.CONTENT_TYPE] 66 | 67 | @route("/post/by/content",(Http.POST,Http.PUT),(MediaType.TEXT_XML,MediaType.APPLICATION_XML)) 68 | def test_content_xml(self,request,**kwargs): 69 | return request.received_headers[HttpHeader.CONTENT_TYPE] 70 | 71 | @route("/post/by/content",(Http.POST,Http.PUT),MediaType.TEXT_YAML) 72 | def test_content_yaml(self,request,**kwargs): 73 | return request.received_headers[HttpHeader.CONTENT_TYPE] 74 | 75 | @route("/post/by/content",(Http.POST,Http.PUT)) 76 | def test_content_catch_all(self,request,**kwargs): 77 | return MediaType.WILDCARD 78 | 79 | ################################################################## 80 | # one URL, serving different content types 81 | ################################################################### 82 | @route("/return/by/accept") 83 | def test_return_content_by_accepts(self,request,**kwargs): 84 | val = [{"test1":"Test1"},{"test2":"Test2"}] 85 | return val 86 | 87 | @route("/return/by/accept/deferred") 88 | @defer.inlineCallbacks 89 | def test_return_content_by_accept_deferred(self,request,**kwargs): 90 | """Ensure support for inline callbacks and deferred""" 91 | val = yield [{"test1":"Test1"},{"test2":"Test2"}] 92 | defer.returnValue(val) 93 | 94 | @route("/return/by/accept/class") 95 | def test_return_class_content_by_accepts(self,request,**kwargs): 96 | """Uses Python class instead of dict/list""" 97 | 98 | class TestReturn: 99 | """Test return class""" 100 | def __init__(self): 101 | self.__t1 = 'Test' 102 | 103 | t1 = TestReturn() 104 | t1.test1 = 'Test1' 105 | 106 | t2 = TestReturn() 107 | t2.test2="Test2" 108 | return (t1,t2) 109 | 110 | #################################### 111 | # Issues 112 | #################################### 113 | @route("/issues/1") 114 | def test_issue_1(self,request,**kwargs): 115 | return self.issue1 116 | 117 | #################################### 118 | # extra HTTP methods 119 | #################################### 120 | @route("/methods/head",Http.HEAD) 121 | def test_head_http(self,request,**kwargs): 122 | return "" 123 | 124 | @route("/methods/options",Http.OPTIONS) 125 | def test_options_http(self,request,**kwargs): 126 | return "OPTIONS" 127 | 128 | @route("/methods/patch",Http.PATCH) 129 | def test_patch_http(self,request,**kwargs): 130 | return "PATCH=%s" % kwargs 131 | 132 | def run_app_home(): 133 | app = RESTResource((HomeApp(),)) 134 | app.run() 135 | 136 | if __name__ == "__main__": 137 | run_app_home() -------------------------------------------------------------------------------- /corepost/test/multi_resource.py: -------------------------------------------------------------------------------- 1 | ''' 2 | A RESTResource module1 that can be merged into the main RESTResource Resource 3 | ''' 4 | 5 | from corepost.web import RESTResource, route 6 | from corepost.enums import Http 7 | 8 | class HomeApp(): 9 | 10 | @route("/") 11 | def home_root(self,request,**kwargs): 12 | return "HOME %s" % kwargs 13 | 14 | class Module1(): 15 | path = "/module1" 16 | 17 | @route("/",Http.GET) 18 | def module1_get(self,request,**kwargs): 19 | return request.path 20 | 21 | @route("/sub",Http.GET) 22 | def module1e_sub(self,request,**kwargs): 23 | return request.path 24 | 25 | class Module2(): 26 | path = "/module2" 27 | 28 | @route("/",Http.GET) 29 | def module2_get(self,request,**kwargs): 30 | return request.path 31 | 32 | @route("/sub",Http.GET) 33 | def module2_sub(self,request,**kwargs): 34 | return request.path 35 | 36 | def run_app_multi(): 37 | app = RESTResource((HomeApp(),Module1(),Module2())) 38 | app.run(8081) 39 | 40 | if __name__ == "__main__": 41 | run_app_multi() -------------------------------------------------------------------------------- /corepost/test/rest_resource.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Server tests 3 | @author: jacekf 4 | ''' 5 | 6 | from corepost import Response, NotFoundException, AlreadyExistsException 7 | from corepost.web import RESTResource, route, Http 8 | 9 | from twisted.cred.portal import IRealm, Portal 10 | from twisted.cred.checkers import FilePasswordDB 11 | from twisted.web.static import File 12 | from twisted.web.resource import IResource 13 | from twisted.web.guard import HTTPAuthSessionWrapper, BasicCredentialFactory 14 | 15 | from zope.interface import implements 16 | 17 | # Security 18 | 19 | # Database 20 | class DB(): 21 | """Fake in-memory DB for testing""" 22 | customers = {} 23 | 24 | @classmethod 25 | def getAllCustomers(cls): 26 | return DB.customers.values() 27 | 28 | @classmethod 29 | def getCustomer(cls,customerId): 30 | if customerId in DB.customers: 31 | return DB.customers[customerId] 32 | else: 33 | raise NotFoundException("Customer",customerId) 34 | 35 | @classmethod 36 | def saveCustomer(cls,customer): 37 | if customer.customerId in DB.customers: 38 | raise AlreadyExistsException("Customer",customer.customerId) 39 | else: 40 | DB.customers[customer.customerId] = customer 41 | 42 | @classmethod 43 | def deleteCustomer(cls,customerId): 44 | if customerId in DB.customers: 45 | del(DB.customers[customerId]) 46 | else: 47 | raise NotFoundException("Customer",customerId) 48 | 49 | @classmethod 50 | def deleteAllCustomers(cls): 51 | DB.customers.clear() 52 | 53 | @classmethod 54 | def getCustomerAddress(cls,customerId,addressId): 55 | c = DB.getCustomer(customerId) 56 | if addressId in c.addresses: 57 | return c.addresses[addressId] 58 | else: 59 | raise NotFoundException("Customer Address",addressId) 60 | 61 | 62 | class Customer: 63 | """Represents customer entity""" 64 | def __init__(self,customerId,firstName,lastName): 65 | (self.customerId,self.firstName,self.lastName) = (customerId,firstName,lastName) 66 | self.addresses = {} 67 | 68 | class CustomerAddress: 69 | """Represents customer address entity""" 70 | def __init__(self,streetNumber,streetName,stateCode,countryCode): 71 | (self.streetNumber,self.streetName,self.stateCode,self.countryCode) = (streetNumber,streetName,stateCode,countryCode) 72 | 73 | class CustomerRESTService(): 74 | path = "/customer" 75 | 76 | @route("/") 77 | def getAll(self,request): 78 | return DB.getAllCustomers() 79 | 80 | @route("/") 81 | def get(self,request,customerId): 82 | return DB.getCustomer(customerId) 83 | 84 | @route("/",Http.POST) 85 | def post(self,request,customerId,firstName,lastName): 86 | customer = Customer(customerId, firstName, lastName) 87 | DB.saveCustomer(customer) 88 | return Response(201) 89 | 90 | @route("/",Http.PUT) 91 | def put(self,request,customerId,firstName,lastName): 92 | c = DB.getCustomer(customerId) 93 | (c.firstName,c.lastName) = (firstName,lastName) 94 | return Response(200) 95 | 96 | @route("/",Http.DELETE) 97 | def delete(self,request,customerId): 98 | DB.deleteCustomer(customerId) 99 | return Response(200) 100 | 101 | @route("/",Http.DELETE) 102 | def deleteAll(self,request): 103 | DB.deleteAllCustomers() 104 | return Response(200) 105 | 106 | class CustomerAddressRESTService(): 107 | path = "/customer//address" 108 | 109 | @route("/") 110 | def getAll(self,request,customerId): 111 | return DB.getCustomer(customerId).addresses 112 | 113 | @route("/") 114 | def get(self,request,customerId,addressId): 115 | return DB.getCustomerAddress(customerId, addressId) 116 | 117 | @route("/",Http.POST) 118 | def post(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode): 119 | c = DB.getCustomer(customerId) 120 | address = CustomerAddress(streetNumber,streetName,stateCode,countryCode) 121 | c.addresses[addressId] = address 122 | return Response(201) 123 | 124 | @route("/",Http.PUT) 125 | def put(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode): 126 | address = DB.getCustomerAddress(customerId, addressId) 127 | (address.streetNumber,address.streetName,address.stateCode,address.countryCode) = (streetNumber,streetName,stateCode,countryCode) 128 | return Response(200) 129 | 130 | @route("/",Http.DELETE) 131 | def delete(self,request,customerId,addressId): 132 | DB.getCustomerAddress(customerId, addressId) #validate address exists 133 | del(DB.getCustomer(customerId).addresses[addressId]) 134 | return Response(200) 135 | 136 | @route("/",Http.DELETE) 137 | def deleteAll(self,request,customerId): 138 | c = DB.getCustomer(customerId) 139 | c.addresses = {} 140 | return Response(200) 141 | 142 | def run_rest_app(): 143 | app = RESTResource((CustomerRESTService(),CustomerAddressRESTService())) 144 | app.run(8085) 145 | 146 | if __name__ == "__main__": 147 | run_rest_app() -------------------------------------------------------------------------------- /corepost/test/sql_resource.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Created on 2012-04-17 3 | 4 | @author: jacekf 5 | ''' 6 | from corepost.web import route 7 | from twisted.python.constants import NamedConstant, Names 8 | 9 | class REST_METHOD(Names): 10 | GET_ALL = NamedConstant() 11 | GET_ONE = NamedConstant() 12 | POST = NamedConstant() 13 | PUT = NamedConstant() 14 | DELETE = NamedConstant() 15 | DELETE_ALL = NamedConstant() 16 | ALL = NamedConstant() 17 | 18 | class DatabaseRegistry: 19 | 20 | __registry = {} 21 | 22 | @classmethod 23 | def getConnection(cls,name=None): 24 | return DatabaseRegistry.__registery[name] 25 | 26 | @classmethod 27 | def registerPool(cls,name,dbPool): 28 | """Registers a DB connection pool under an appropriate name""" 29 | DatabaseRegistry.__registry[name] = dbPool 30 | 31 | @classmethod 32 | def getManager(cls,name=None,queriesFile=None): 33 | """Returns the high-level SQL data manager for easy SQL manipulation""" 34 | pass 35 | 36 | class SqlDataManager: 37 | 38 | def __init__(self,table,columnMapping={}): 39 | pass 40 | 41 | class CustomerSqlService: 42 | path = "/customer" 43 | entityId ="" 44 | dataManager = DatabaseRegistry.getManager("customer") 45 | methods = (REST_METHOD.GET_ONE,REST_METHOD.POST,REST_METHOD.PUT,REST_METHOD.DELETE) 46 | 47 | class CustomerAddressSqlService: 48 | path = "/customer//address" 49 | entityId = "" 50 | dataManager = DatabaseRegistry.getManager("customer_address") 51 | methods = (REST_METHOD.ALL,) 52 | 53 | -------------------------------------------------------------------------------- /corepost/test/steps.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Common Freshen BDD steps 3 | 4 | @author: jacekf 5 | ''' 6 | from multiprocessing import Process 7 | import httplib2, json, re, time, string 8 | from freshen import Before, Given, When, Then, scc, glc, assert_equals, assert_true #@UnresolvedImport 9 | from urllib import urlencode 10 | from corepost.test.home_resource import run_app_home 11 | from corepost.test.multi_resource import run_app_multi 12 | from corepost.test.arguments import run_app_arguments 13 | from corepost.test.filter_resource import run_filter_app 14 | from corepost.test.rest_resource import run_rest_app 15 | 16 | apps = {'home_resource' : run_app_home,'multi_resource':run_app_multi,'arguments':run_app_arguments, 'filter_resource':run_filter_app,'rest_resource':run_rest_app} 17 | 18 | NULL = 'None' 19 | 20 | def as_dict(parameters): 21 | dict_val = {} 22 | for pair in parameters.split('&') : 23 | params = pair.split('=', 1) 24 | if (params[0] != None) and (len(params) == 2): 25 | dict_val[params[0]] = params[1] 26 | return dict_val 27 | 28 | ################################## 29 | # BEFORE / AFTER 30 | ################################## 31 | 32 | @Before 33 | def setup(slc): 34 | scc.http_headers = {} 35 | 36 | ################################## 37 | # GIVEN 38 | ################################## 39 | 40 | @Given(r"^'(.+)' is running\s*$") 41 | def given_process_is_running(processname): 42 | if glc.processes == None: 43 | glc.processes = {} 44 | 45 | if processname not in glc.processes: 46 | # start a process only once, keep it running 47 | # to make test runs faster 48 | process = Process(target=apps[processname]) 49 | process.daemon = True 50 | process.start() 51 | time.sleep(0.25) # let it start up 52 | glc.processes[processname] = process 53 | 54 | ################################## 55 | # WHEN 56 | ################################## 57 | 58 | @When(r"^as user '(.+):(.+)' I (GET|DELETE|HEAD|OPTIONS) '(.+)'\s*$") 59 | def when_as_user_i_send_get_delete_to_url(user,password,method,url): 60 | h = httplib2.Http() 61 | h.follow_redirects = False 62 | h.add_credentials(user, password) 63 | scc.response, scc.content = h.request(url, method, headers = scc.http_headers) 64 | 65 | @When(r"^as user '(.+):(.+)' I (POST|PUT|PATCH) '(.+)' with '(.+)'\s*$") 66 | def when_as_user_i_send_post_put_to_url(user,password,method,url,params): 67 | h = httplib2.Http() 68 | h.follow_redirects = False 69 | h.add_credentials(user, password) 70 | scc.http_headers['Content-type'] = 'application/x-www-form-urlencoded' 71 | scc.response, scc.content = h.request(url, method, urlencode(as_dict(params)), headers = scc.http_headers) 72 | 73 | @When(r"^as user '(.+):(.+)' I (POST|PUT) '(.+)' with (XML|JSON|YAML) body '(.+)'\s*$") 74 | def when_as_user_i_send_post_put_xml_json_to_url(user,password,method,url,request_type,body): 75 | when_as_user_i_send_post_put_xml_json_to_url_multiline(body,user,password,method,url,request_type) 76 | 77 | @When(r"^as user '(.+):(.+)' I (POST|PUT) '(.+)' with (XML|JSON|YAML)\s*$") 78 | def when_as_user_i_send_post_put_xml_json_to_url_multiline(body,user,password,method,url,request_type): 79 | h = httplib2.Http() 80 | h.follow_redirects = False 81 | h.add_credentials(user, password) 82 | if request_type == "JSON": 83 | scc.http_headers['Content-type'] = 'application/json' 84 | elif request_type == "XML": 85 | scc.http_headers['Content-type'] = 'text/xml' 86 | elif request_type == "YAML": 87 | scc.http_headers['Content-type'] = 'text/yaml' 88 | scc.response, scc.content = h.request(url, method, body, headers = scc.http_headers) 89 | 90 | @When("I prepare HTTP header '(.*)' = '(.*)'") 91 | def when_i_define_http_header_with_value(header,value): 92 | if header != NULL: 93 | scc.http_headers[header] = value 94 | 95 | ################################## 96 | # THEN 97 | ################################## 98 | def transform_content(content): 99 | """Support embedded newlines""" 100 | if content != None: 101 | return string.replace(content,"\\n","\n") 102 | else: 103 | return None 104 | 105 | @Then(r"^I expect HTTP code (\d+)\s*$") 106 | def expect_http_code(code): 107 | assert_equals(int(code),int(scc.response.status), msg="%s != %s\n%s\n%s" % (code,scc.response.status,scc.response,scc.content)) 108 | 109 | @Then(r"^I expect content contains '(.+)'\s*$") 110 | def expect_content(content): 111 | content = transform_content(content) 112 | assert_true(scc.content.find(content) >= 0,"Did not find:\n%s\nin content:\n%s" % (content,scc.content)) 113 | 114 | @Then(r"^I expect content contains\s*$") 115 | def expect_content_multiline(content): 116 | content = transform_content(content) 117 | assert_true(scc.content.find(content) >= 0,"Did not find:\n%s\nin content:\n%s" % (content,scc.content)) 118 | 119 | @Then(r"^I expect '([^']*)' header matches '([^']*)'\s*$") 120 | def then_check_http_header_matches(header,regex): 121 | assert_true(re.search(regex,scc.response[header.lower()], re.X | re.I) != None, 122 | "the regex %s does not match the response\n%s" % (regex, scc.response[header.lower()])) 123 | 124 | @Then("^I expect JSON content\s*$") 125 | def then_i_expect_json(content): 126 | expected_json = json.loads(content) 127 | expected_json_sorted = json.dumps(expected_json,sort_keys=True,indent=4) 128 | received_json = json.loads(scc.content) 129 | received_json_sorted = json.dumps(received_json,sort_keys=True,indent=4) 130 | assert_equals(expected_json_sorted,received_json_sorted,"Expected JSON\n%s\n*** actual ****\n%s" % (expected_json_sorted,received_json_sorted)) 131 | 132 | -------------------------------------------------------------------------------- /corepost/utils.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Various CorePost utilities 3 | ''' 4 | from inspect import getargspec 5 | 6 | 7 | def getMandatoryArgumentNames(f): 8 | '''Returns a tuple of the mandatory arguments required in a function''' 9 | args,_,_,defaults = getargspec(f) 10 | if defaults == None: 11 | return args 12 | else: 13 | return args[0:len(args) - len(defaults)] 14 | 15 | 16 | def getRouterKey(method,url): 17 | '''Returns the common key used to represent a function that a request can be routed to''' 18 | return "%s %s" % (method,url) 19 | 20 | 21 | def checkExpectedInterfaces(objects,expectedInterface): 22 | """Verifies that all the objects implement the expected interface""" 23 | for obj in objects: 24 | if not expectedInterface.providedBy(obj): 25 | raise RuntimeError("Object %s does not implement %s interface" % (obj,expectedInterface)) 26 | 27 | def safeDictUpdate(dictObject,key,value): 28 | """Only adds a key to a dictionary. If key exists, it leaves it untouched""" 29 | if key not in dictObject: 30 | dictObject[key] = value 31 | -------------------------------------------------------------------------------- /corepost/web.py: -------------------------------------------------------------------------------- 1 | ''' 2 | Main server classes 3 | 4 | @author: jacekf 5 | ''' 6 | from corepost import Response, IRESTResource 7 | from corepost.enums import Http 8 | from corepost.routing import UrlRouter, RequestRouter 9 | from enums import MediaType 10 | from formencode import FancyValidator, Invalid 11 | from twisted.internet import reactor 12 | from twisted.internet.defer import Deferred 13 | from twisted.web.resource import Resource 14 | from twisted.web.server import Site, NOT_DONE_YET 15 | from zope.interface import implements 16 | 17 | ######################################################### 18 | # 19 | # CLASSES 20 | # 21 | ######################################################### 22 | 23 | class RESTResource(Resource): 24 | ''' 25 | Main resource responsible for routing REST requests to the implementing methods 26 | ''' 27 | isLeaf = True 28 | implements(IRESTResource) 29 | 30 | def __init__(self,services=(),schema=None,filters=()): 31 | ''' 32 | Constructor 33 | ''' 34 | self.services = services 35 | self.__router = RequestRouter(self,schema,filters) 36 | Resource.__init__(self) 37 | 38 | def render_GET(self,request): 39 | """ Handles all GET requests """ 40 | return self.__renderUrl(request) 41 | 42 | def render_POST(self,request): 43 | """ Handles all POST requests""" 44 | return self.__renderUrl(request) 45 | 46 | def render_PUT(self,request): 47 | """ Handles all PUT requests""" 48 | return self.__renderUrl(request) 49 | 50 | def render_DELETE(self,request): 51 | """ Handles all DELETE requests""" 52 | return self.__renderUrl(request) 53 | 54 | def __renderUrl(self,request): 55 | try: 56 | val = self.__router.getResponse(request) 57 | 58 | # return can be Deferred or Response 59 | if isinstance(val,Deferred): 60 | val.addCallback(self.__finishRequest,request) 61 | return NOT_DONE_YET 62 | elif isinstance(val,Response): 63 | self.__applyResponse(request, val.code, val.headers) 64 | return val.entity 65 | else: 66 | raise RuntimeError("Unexpected return type from request router %s" % val) 67 | except Exception as ex: 68 | self.__applyResponse(request, 500, None) 69 | return str(ex) 70 | 71 | def __finishRequest(self,response,request): 72 | if not request.finished: 73 | self.__applyResponse(request, response.code,response.headers) 74 | request.write(response.entity) 75 | request.finish() 76 | 77 | def __applyResponse(self,request,code,headers={"content-type":MediaType.TEXT_PLAIN}): 78 | request.setResponseCode(code) 79 | if headers != None: 80 | for header,value in headers.iteritems(): 81 | request.setHeader(header, value) 82 | 83 | def run(self,port=8080): 84 | """Shortcut for running app within Twisted reactor""" 85 | factory = Site(self) 86 | reactor.listenTCP(port, factory) #@UndefinedVariable 87 | reactor.run() #@UndefinedVariable 88 | 89 | ################################################################################################## 90 | # 91 | # DECORATORS 92 | # 93 | ################################################################################################## 94 | 95 | def route(url,methods=(Http.GET,),accepts=MediaType.WILDCARD,produces=None,cache=True): 96 | ''' 97 | Main decorator for registering REST functions 98 | ''' 99 | def decorator(f): 100 | def wrap(*args,**kwargs): 101 | return f 102 | router = UrlRouter(f, url, methods, accepts, produces, cache) 103 | setattr(wrap,'corepostRequestRouter',router) 104 | 105 | return wrap 106 | return decorator 107 | 108 | def validate(schema=None,**vKwargs): 109 | ''' 110 | Main decorator for registering additional validators for incoming URL arguments 111 | ''' 112 | def fn(realfn): 113 | def wrap(*args,**kwargs): 114 | # first run schema validation, then the custom validators 115 | errors = [] 116 | if schema != None: 117 | try: 118 | schema.to_python(kwargs) 119 | except Invalid as ex: 120 | for arg, error in ex.error_dict.items(): 121 | errors.append("%s: %s ('%s')" % (arg,error.msg,error.value)) 122 | 123 | # custom validators 124 | for arg in vKwargs.keys(): 125 | validator = vKwargs[arg] 126 | if arg in kwargs: 127 | val = kwargs[arg] 128 | try: 129 | validator.to_python(val) 130 | except Invalid as ex: 131 | errors.append("%s: %s ('%s')" % (arg,ex,val)) 132 | else: 133 | if isinstance(validator,FancyValidator) and validator.not_empty: 134 | raise TypeError("Missing mandatory argument '%s'" % arg) 135 | 136 | # fire error if anything failed validation 137 | if len(errors) > 0: 138 | raise TypeError('\n'.join(errors)) 139 | # all OK 140 | return realfn(*args,**kwargs) 141 | return wrap 142 | return fn 143 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | """ 2 | Twisted REST micro-framework 3 | ================================ 4 | 5 | Based on *Flask* API, with plans for integrated multiprocessing support for full usage of all CPUs. 6 | Provides a more Flask/Sinatra-style API on top of the core *twisted.web* APIs. 7 | Integrates FormEncode for path, form and query argument validation. 8 | 9 | An example of a multi--module twisted.web CorePost REST application 10 | which exposes two separate REST services (for a Customer and Customer Address entities): 11 | 12 | :: 13 | 14 | class CustomerRESTService(): 15 | path = "/customer" 16 | 17 | @route("/") 18 | def getAll(self,request): 19 | return DB.getAllCustomers() 20 | 21 | @route("/") 22 | def get(self,request,customerId): 23 | return DB.getCustomer(customerId) 24 | 25 | @route("/",Http.POST) 26 | def post(self,request,customerId,firstName,lastName): 27 | customer = Customer(customerId, firstName, lastName) 28 | DB.saveCustomer(customer) 29 | return Response(201) 30 | 31 | @route("/",Http.PUT) 32 | def put(self,request,customerId,firstName,lastName): 33 | c = DB.getCustomer(customerId) 34 | (c.firstName,c.lastName) = (firstName,lastName) 35 | return Response(200) 36 | 37 | @route("/",Http.DELETE) 38 | def delete(self,request,customerId): 39 | DB.deleteCustomer(customerId) 40 | return Response(200) 41 | 42 | @route("/",Http.DELETE) 43 | def deleteAll(self,request): 44 | DB.deleteAllCustomers() 45 | return Response(200) 46 | 47 | class CustomerAddressRESTService(): 48 | path = "/customer//address" 49 | 50 | @route("/") 51 | def getAll(self,request,customerId): 52 | return DB.getCustomer(customerId).addresses 53 | 54 | @route("/") 55 | def get(self,request,customerId,addressId): 56 | return DB.getCustomerAddress(customerId, addressId) 57 | 58 | @route("/",Http.POST) 59 | def post(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode): 60 | c = DB.getCustomer(customerId) 61 | address = CustomerAddress(streetNumber,streetName,stateCode,countryCode) 62 | c.addresses[addressId] = address 63 | return Response(201) 64 | 65 | @route("/",Http.PUT) 66 | def put(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode): 67 | address = DB.getCustomerAddress(customerId, addressId) 68 | (address.streetNumber,address.streetName,address.stateCode,address.countryCode) = (streetNumber,streetName,stateCode,countryCode) 69 | return Response(200) 70 | 71 | @route("/",Http.DELETE) 72 | def delete(self,request,customerId,addressId): 73 | DB.getCustomerAddress(customerId, addressId) #validate address exists 74 | del(DB.getCustomer(customerId).addresses[addressId]) 75 | return Response(200) 76 | 77 | @route("/",Http.DELETE) 78 | def deleteAll(self,request,customerId): 79 | c = DB.getCustomer(customerId) 80 | c.addresses = {} 81 | return Response(200) 82 | 83 | 84 | def run_rest_app(): 85 | app = RESTResource((CustomerRESTService(),CustomerAddressRESTService())) 86 | app.run(8080) 87 | 88 | if __name__ == "__main__": 89 | run_rest_app() 90 | 91 | And the BDD showing off its different features 92 | 93 | https://github.com/jacek99/corepost/blob/master/corepost/test/feature/rest_app.feature 94 | 95 | Links 96 | ````` 97 | 98 | * `Website `_ 99 | * `Twisted `_ 100 | * `FormEncode `_ 101 | 102 | Changelog 103 | ````````` 104 | * 0.0.16: 105 | - minor bug fix for issue #4 (serializing object graphs to XML), removed Jinja2 as dependency: 106 | https://github.com/jacek99/corepost/issues/4 107 | * 0.0.15: 108 | - minor bug fixes in auto-converting responses to JSON and parsing arguments/paths with unexpectec characters 109 | * 0.0.14: 110 | - automatic parsing of query, form, JSON, YAML and XML arguments: 111 | http://jacek99.github.com/corepost/argument_parsing.html 112 | * 0.0.13: 113 | - perf fix to avoid unnecessary string concatenation when doing URL routing, after code review (thanks to Gerald Tremblay) 114 | * 0.0.12: 115 | - backwards incompatible change: added advanced URL routing for nested REST services. 116 | CorePost object is gone, REST services are now just standard classes. 117 | They get wrapped in a RESTResource object (see sample above) when exposed 118 | * 0.0.11: 119 | - added support for request/response filters 120 | * 0.0.10: 121 | - removed dependency on txZMQ which was not needed at this point (yet) 122 | * 0.0.9: 123 | - fix for issue #3 (wrong class passes as 'self' to router method): 124 | https://github.com/jacek99/corepost/issues/3 125 | * 0.0.8: 126 | - support for serializing of classes to JSON,XML,YAML based on caller's Accept header 127 | - separate routing functionality from CorePost Resource object, in preparation for future multicore support 128 | * 0.0.7: 129 | - automatic parsing of incoming content (JSON, YAML, XML) 130 | - routing by incoming content type 131 | - automatic response conversion based on caller's Accept header (JSON/YAML 132 | - support for defer.returnValue() in @inlineCallbacks route methods 133 | * 0.0.6 - redesigned API around classes and methods, rather than functions and global objects (after feedback from Twisted devs) 134 | * 0.0.5 - added FormEncode validation for arguments 135 | * 0.0.4 - path argument extraction, mandatory argument error checking 136 | 137 | """ 138 | 139 | from setuptools import setup 140 | 141 | setup( 142 | name="CorePost", 143 | version="0.0.16", 144 | author="Jacek Furmankiewicz", 145 | author_email="jacek99@gmail.com", 146 | description=("A Twisted Web REST micro-framework"), 147 | license="BSD", 148 | keywords="twisted rest flask sinatra get post put delete web", 149 | url="https://github.com/jacek99/corepost", 150 | packages=['corepost', ], 151 | long_description=__doc__, 152 | classifiers=[ 153 | "Development Status :: 3 - Alpha", 154 | "Environment :: Web Environment", 155 | "Intended Audience :: Developers", 156 | "License :: OSI Approved :: BSD License", 157 | "Operating System :: OS Independent", 158 | "Programming Language :: Python", 159 | "Topic :: Internet :: WWW/HTTP :: Dynamic Content", 160 | "Topic :: Software Development :: Libraries :: Python Modules", 161 | ], 162 | install_requires=[ 163 | 'twisted>=12.0.0', 164 | 'formencode>=1.2.4', 165 | 'pyyaml>=3.1.0' 166 | ], 167 | tests_require=[ 168 | 'httplib2>=0.7.1', 169 | 'freshen>=0.2', 170 | ], 171 | zip_safe = True 172 | ) 173 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | nosetests --with-freshen -v 2 | 3 | --------------------------------------------------------------------------------