Commit acc7850f authored by Jacek Furmankiewicz's avatar Jacek Furmankiewicz

preparing for 0.0.12

parent 840b2c9f
Twisted REST micro-framework Twisted REST micro-framework
================================ ================================
Based on *Flask* API, with plans integrated multiprocessing support for full usage of all CPUs. Based on *Flask* API, with plans for integrated multiprocessing support for full usage of all CPUs.
Provides a more Flask/Sinatra-style API on top of the core *twisted.web* APIs. Provides a more Flask/Sinatra-style API on top of the core *twisted.web* APIs.
Geared towards creating REST-oriented server platforms. Geared towards creating REST-oriented server platforms.
...@@ -12,10 +12,10 @@ Single REST module example ...@@ -12,10 +12,10 @@ Single REST module example
The simplest possible REST application: The simplest possible REST application:
from corepost.web import CorePost, route from corepost.web import route, RESTResource
from corepost.enums import Http from corepost.enums import Http
class RestApp(CorePost): class RestApp():
@route("/",Http.GET) @route("/",Http.GET)
def root(self,request,**kwargs): def root(self,request,**kwargs):
...@@ -30,66 +30,157 @@ The simplest possible REST application: ...@@ -30,66 +30,157 @@ The simplest possible REST application:
return "%s" % numericid return "%s" % numericid
if __name__ == '__main__': if __name__ == '__main__':
app = RestApp() app = RESTResource((RestApp,))
app.run() app.run()
Multi-module REST application Multi-module REST application
-------------------------------- --------------------------------
The key CorePost object is just an extension of the regular twisted.web Resource object. Once can assemble a multi-module REST applications with
Therefore, it can easily be used to assemble a multi-module REST applications with different REST services responding from different context paths.
different CorePost resources serving from different context paths: Notice the class *path* attribute which provides a common URL prefix for all REST operations
on a particular service:
from corepost.web import CorePost, route from corepost import Response, NotFoundException, AlreadyExistsException
from corepost.enums import Http from corepost.web import RESTResource, route, Http
from twisted.web.resource import Resource
from twisted.internet import reactor
from twisted.web.server import Site
class HomeApp(CorePost): class DB():
"""Fake in-memory DB for testing"""
customers = {}
@route("/") @classmethod
def home_root(self,request,**kwargs): def getAllCustomers(cls):
return "HOME %s" % kwargs return DB.customers.values()
class Module1(CorePost): @classmethod
def getCustomer(cls,customerId):
if customerId in DB.customers:
return DB.customers[customerId]
else:
raise NotFoundException("Customer",customerId)
@route("/",Http.GET) @classmethod
def module1_get(self,request,**kwargs): def saveCustomer(cls,customer):
return request.path if customer.customerId in DB.customers:
raise AlreadyExistsException("Customer",customer.customerId)
else:
DB.customers[customer.customerId] = customer
@classmethod
def deleteCustomer(cls,customerId):
if customerId in DB.customers:
del(DB.customers[customerId])
else:
raise NotFoundException("Customer",customerId)
@classmethod
def deleteAllCustomers(cls):
DB.customers.clear()
@classmethod
def getCustomerAddress(cls,customerId,addressId):
c = DB.getCustomer(customerId)
if addressId in c.addresses:
return c.addresses[addressId]
else:
raise NotFoundException("Customer Address",addressId)
class Customer:
"""Represents customer entity"""
def __init__(self,customerId,firstName,lastName):
(self.customerId,self.firstName,self.lastName) = (customerId,firstName,lastName)
self.addresses = {}
class CustomerAddress:
"""Represents customer address entity"""
def __init__(self,streetNumber,streetName,stateCode,countryCode):
(self.streetNumber,self.streetName,self.stateCode,self.countryCode) = (streetNumber,streetName,stateCode,countryCode)
class CustomerRESTService():
path = "/customer"
@route("/")
def getAll(self,request):
return DB.getAllCustomers()
@route("/sub",Http.GET) @route("/<customerId>")
def module1e_sub(self,request,**kwargs): def get(self,request,customerId):
return request.path return DB.getCustomer(customerId)
@route("/",Http.POST)
def post(self,request,customerId,firstName,lastName):
customer = Customer(customerId, firstName, lastName)
DB.saveCustomer(customer)
return Response(201)
@route("/<customerId>",Http.PUT)
def put(self,request,customerId,firstName,lastName):
c = DB.getCustomer(customerId)
(c.firstName,c.lastName) = (firstName,lastName)
return Response(200)
class Module2(CorePost): @route("/<customerId>",Http.DELETE)
def delete(self,request,customerId):
DB.deleteCustomer(customerId)
return Response(200)
@route("/",Http.GET) @route("/",Http.DELETE)
def module2_get(self,request,**kwargs): def deleteAll(self,request):
return request.path DB.deleteAllCustomers()
return Response(200)
class CustomerAddressRESTService():
path = "/customer/<customerId>/address"
@route("/")
def getAll(self,request,customerId):
return DB.getCustomer(customerId).addresses
@route("/sub",Http.GET) @route("/<addressId>")
def module2_sub(self,request,**kwargs): def get(self,request,customerId,addressId):
return request.path return DB.getCustomerAddress(customerId, addressId)
@route("/",Http.POST)
def post(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode):
c = DB.getCustomer(customerId)
address = CustomerAddress(streetNumber,streetName,stateCode,countryCode)
c.addresses[addressId] = address
return Response(201)
@route("/<addressId>",Http.PUT)
def put(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode):
address = DB.getCustomerAddress(customerId, addressId)
(address.streetNumber,address.streetName,address.stateCode,address.countryCode) = (streetNumber,streetName,stateCode,countryCode)
return Response(200)
def run_app_multi(): @route("/<addressId>",Http.DELETE)
app = Resource() def delete(self,request,customerId,addressId):
app.putChild('', HomeApp()) DB.getCustomerAddress(customerId, addressId) #validate address exists
app.putChild('module1',Module1()) del(DB.getCustomer(customerId).addresses[addressId])
app.putChild('module2',Module2()) return Response(200)
@route("/",Http.DELETE)
def deleteAll(self,request,customerId):
c = DB.getCustomer(customerId)
c.addresses = {}
return Response(200)
factory = Site(app)
reactor.listenTCP(8081, factory) #@UndefinedVariable def run_rest_app():
reactor.run() #@UndefinedVariable app = RESTResource((CustomerRESTService(),CustomerAddressRESTService()))
app.run(8080)
if __name__ == "__main__":
run_rest_app()
The example above creates 3 modules ("/","module1","/module2") and exposes the following URLs: The example above creates 2 REST services and exposes the following resources:
http://127.0.0.1:8080 http://127.0.0.1:8080/customer
http://127.0.0.1:8080/module1 http://127.0.0.1:8080/customer/<customerId>
http://127.0.0.1:8080/module1/sub http://127.0.0.1:8080/customer/<customerId>/address
http://127.0.0.1:8080/module2 http://127.0.0.1:8080/customer/<customerId>/address/>addressId>
http://127.0.0.1:8080/module2/sub
Path argument extraction Path argument extraction
------------------------ ------------------------
...@@ -126,7 +217,7 @@ Argument validation ...@@ -126,7 +217,7 @@ Argument validation
CorePost integrates the popular 'formencode' package to implement form and query argument validation. CorePost integrates the popular 'formencode' package to implement form and query argument validation.
Validators can be specified using a *formencode* Schema object, or via custom field-specific validators, e.g.: Validators can be specified using a *formencode* Schema object, or via custom field-specific validators, e.g.:
from corepost.web import CorePost, validate, route from corepost.web import validate, route
from corepost.enums import Http from corepost.enums import Http
from formencode import Schema, validators from formencode import Schema, validators
...@@ -134,7 +225,7 @@ Validators can be specified using a *formencode* Schema object, or via custom fi ...@@ -134,7 +225,7 @@ Validators can be specified using a *formencode* Schema object, or via custom fi
allow_extra_fields = True allow_extra_fields = True
childId = validators.Regex(regex="^value1|value2$") childId = validators.Regex(regex="^value1|value2$")
class MyApp(CorePost): class MyApp():
@route("/validate/<int:rootId>/schema",Http.POST) @route("/validate/<int:rootId>/schema",Http.POST)
@validate(schema=TestSchema()) @validate(schema=TestSchema())
...@@ -283,17 +374,17 @@ A filter class can implement either of them or both (for a wrap around filter), ...@@ -283,17 +374,17 @@ A filter class can implement either of them or both (for a wrap around filter),
response.headers["X-Wrap-Output"] = "Output" response.headers["X-Wrap-Output"] = "Output"
In order to activate the filters on a CorePost resource instance, you need to pass a list of them in the constructor as the *filters* parameter, e.g.: In order to activate the filters on a RESTResource instance, you need to pass a list of them in the constructor as the *filters* parameter, e.g.:
class FilterApp(CorePost): class FilterApp():
@route("/",Http.GET) @route("/",Http.GET)
def root(self,request,**kwargs): def root(self,request,**kwargs):
return request.received_headers return request.received_headers
def run_filter_app(): def run_filter_app():
app = FilterApp(filters=(Change404to503Filter(),AddCustomHeaderFilter(),WrapAroundFilter(),)) app = RESTResource(services=((FilterApp(),),filters=(Change404to503Filter(),AddCustomHeaderFilter(),WrapAroundFilter(),))
app.run(8083) app.run(8083)
...@@ -313,4 +404,4 @@ Can be run using: ...@@ -313,4 +404,4 @@ Can be run using:
Plans Plans
----- -----
* integrate multi core support * integrate multi core support
\ No newline at end of file
...@@ -6,7 +6,7 @@ Responsible for converting return values into cleanly serializable dict/tuples/l ...@@ -6,7 +6,7 @@ Responsible for converting return values into cleanly serializable dict/tuples/l
for JSON/XML/YAML output for JSON/XML/YAML output
''' '''
import inspect, collections import collections
from jinja2 import Template from jinja2 import Template
from UserDict import DictMixin from UserDict import DictMixin
...@@ -19,7 +19,7 @@ def convertForSerialization(obj): ...@@ -19,7 +19,7 @@ def convertForSerialization(obj):
return traverseDict(obj) return traverseDict(obj)
elif isClassInstance(obj): elif isClassInstance(obj):
return convertClassToDict(obj) return convertClassToDict(obj)
elif isinstance(obj,collections.Iterable): elif isinstance(obj,collections.Iterable) and not isinstance(obj,str):
# iterable # iterable
values = [] values = []
for val in obj: for val in obj:
...@@ -32,34 +32,31 @@ def convertForSerialization(obj): ...@@ -32,34 +32,31 @@ def convertForSerialization(obj):
def convertClassToDict(clazz): def convertClassToDict(clazz):
"""Converts a class to a dictionary""" """Converts a class to a dictionary"""
properties = {} properties = {}
for prop,val in clazz.__dict__.iteritems(): for prop,val in clazz.__dict__.iteritems():
#omit private fields #omit private fields
if not prop.startswith("_"): if not prop.startswith("_"):
properties[prop] = val properties[prop] = val
return traverseDict(properties) return traverseDict(properties)
def traverseDict(dictObject): def traverseDict(dictObject):
"""Traverses a dict recursively to convertForSerialization any nested classes""" """Traverses a dict recursively to convertForSerialization any nested classes"""
newDict = {} newDict = {}
for prop,val in dictObject.iteritems(): for prop,val in dictObject.iteritems():
if inspect.isclass(val): newDict[prop] = convertForSerialization(val)
# call itself recursively
val = convertClassToDict(val)
newDict[prop] = val
return newDict return newDict
def generateXml(object): def generateXml(obj):
"""Generates basic XML from an object that has already been converted for serialization""" """Generates basic XML from an object that has already been converted for serialization"""
if isinstance(object,dict): if isinstance(object,dict):
return str(xmlTemplate.render(item=object.keys())) return str(xmlTemplate.render(item=obj.keys()))
elif isinstance(object,collections.Iterable): elif isinstance(obj,collections.Iterable):
return str(xmlListTemplate.render(items=object)) return str(xmlListTemplate.render(items=obj))
else: else:
raise RuntimeError("Unable to convert to XML: %s" % object) raise RuntimeError("Unable to convert to XML: %s" % obj)
def isClassInstance(object): def isClassInstance(obj):
"""Checks if a given object is a class instance""" """Checks if a given obj is a class instance"""
return getattr(object, "__class__",None) != None and not isinstance(object,dict) and not isinstance(object,tuple) and not isinstance(object,list) 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)
\ No newline at end of file
...@@ -97,6 +97,7 @@ Feature: REST App ...@@ -97,6 +97,7 @@ Feature: REST App
# add 1 # add 1
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' 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'
Then I expect HTTP code 201 Then I expect HTTP code 201
# get just the address
When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME' When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME'
Then I expect HTTP code 200 Then I expect HTTP code 200
And I expect JSON content And I expect JSON content
...@@ -108,4 +109,42 @@ Feature: REST App ...@@ -108,4 +109,42 @@ Feature: REST App
"streetNumber": "100" "streetNumber": "100"
} }
""" """
# get the customer with the address
When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1'
Then I expect HTTP code 200
And I expect JSON content
"""
{
"addresses": {
"HOME": {
"countryCode": "US",
"stateCode": "CA",
"streetName": "MyStreet",
"streetNumber": "100"
}
},
"customerId": "d1",
"firstName": "John",
"lastName": "Doe1"
}
"""
# update address
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'
Then I expect HTTP code 200
When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME'
Then I expect HTTP code 200
And I expect JSON content
"""
{
"countryCode": "US",
"stateCode": "CA",
"streetName": "MyStreet2",
"streetNumber": "1002"
}
"""
# delete address
When as user 'None:None' I DELETE 'http://127.0.0.1:8085/customer/d1/address/HOME'
Then I expect HTTP code 200
When as user 'None:None' I GET 'http://127.0.0.1:8085/customer/d1/address/HOME'
Then I expect HTTP code 404
\ No newline at end of file
...@@ -10,26 +10,82 @@ The simplest possible twisted.web CorePost REST application: ...@@ -10,26 +10,82 @@ The simplest possible twisted.web CorePost REST application:
:: ::
from corepost.web import CorePost, route class CustomerRESTService():
from corepost.enums import Http path = "/customer"
class RestApp(CorePost): @route("/")
def getAll(self,request):
return DB.getAllCustomers()
@route("/<customerId>")
def get(self,request,customerId):
return DB.getCustomer(customerId)
@route("/",Http.POST)
def post(self,request,customerId,firstName,lastName):
customer = Customer(customerId, firstName, lastName)
DB.saveCustomer(customer)
return Response(201)
@route("/<customerId>",Http.PUT)
def put(self,request,customerId,firstName,lastName):
c = DB.getCustomer(customerId)
(c.firstName,c.lastName) = (firstName,lastName)
return Response(200)
@route("/<customerId>",Http.DELETE)
def delete(self,request,customerId):
DB.deleteCustomer(customerId)
return Response(200)
@route("/",Http.DELETE)
def deleteAll(self,request):
DB.deleteAllCustomers()
return Response(200)
class CustomerAddressRESTService():
path = "/customer/<customerId>/address"
@route("/",Http.GET) @route("/")
def root(self,request,**kwargs): def getAll(self,request,customerId):
return request.path return DB.getCustomer(customerId).addresses
@route("/test",Http.GET) @route("/<addressId>")
def test(self,request,**kwargs): def get(self,request,customerId,addressId):
return request.path return DB.getCustomerAddress(customerId, addressId)
@route("/test/<int:numericid>",Http.GET) @route("/",Http.POST)
def test_get_resources(self,request,numericid,**kwargs): def post(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode):
return "%s" % numericid c = DB.getCustomer(customerId)
address = CustomerAddress(streetNumber,streetName,stateCode,countryCode)
c.addresses[addressId] = address
return Response(201)
@route("/<addressId>",Http.PUT)
def put(self,request,customerId,addressId,streetNumber,streetName,stateCode,countryCode):
address = DB.getCustomerAddress(customerId, addressId)
(address.streetNumber,address.streetName,address.stateCode,address.countryCode) = (streetNumber,streetName,stateCode,countryCode)
return Response(200)
@route("/<addressId>",Http.DELETE)
def delete(self,request,customerId,addressId):
DB.getCustomerAddress(customerId, addressId) #validate address exists
del(DB.getCustomer(customerId).addresses[addressId])
return Response(200)
@route("/",Http.DELETE)
def deleteAll(self,request,customerId):
c = DB.getCustomer(customerId)
c.addresses = {}
return Response(200)
if __name__ == '__main__':
app = RestApp() def run_rest_app():
app.run() app = RESTResource((CustomerRESTService(),CustomerAddressRESTService()))
app.run(8080)
if __name__ == "__main__":
run_rest_app()
Links Links
````` `````
...@@ -40,6 +96,11 @@ Links ...@@ -40,6 +96,11 @@ Links
Changelog Changelog
````````` `````````
* 0.0.12:
- backwards incompatible change: added advancer URL routing for nested REST services.
CorePost object is gone, REST services are now just standard classes.
They get wrapped in a RESTResource object (see sample above) when exposed
* 0.0.11: * 0.0.11:
- added support for request/response filters - added support for request/response filters
* 0.0.10: * 0.0.10:
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment