├── .project
├── .pydevproject
├── COPYRIGHT
├── LICENSE
├── README.md
├── __init__.py
├── dist.txt
├── example.py
├── restdata.py
├── restlite-1.0.tgz
└── restlite.py
/.project:
--------------------------------------------------------------------------------
1 |
2 |
3 | restlite
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 |
6 | /restlite
7 |
8 | python 2.6
9 | Default
10 |
11 |
--------------------------------------------------------------------------------
/COPYRIGHT:
--------------------------------------------------------------------------------
1 | restlite: REST + Python + JSON + XML + SQLite + authentication.
2 |
3 | https://github.com/theintencity/restlite
4 | Copyright (c) 2009, Kundan Singh, kundan10@gmail.com. All rights reserved.
5 | License: released under LGPL (Lesser GNU Public License).
6 |
7 | Visit the project website for details on how to use this library.
8 | --
9 | Kundan Singh
10 | kundan10@gmail.com
11 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | GNU LESSER GENERAL PUBLIC LICENSE
2 | Version 3, 29 June 2007
3 |
4 | Copyright (C) 2007 Free Software Foundation, Inc.
5 | Everyone is permitted to copy and distribute verbatim copies
6 | of this license document, but changing it is not allowed.
7 |
8 |
9 | This version of the GNU Lesser General Public License incorporates
10 | the terms and conditions of version 3 of the GNU General Public
11 | License, supplemented by the additional permissions listed below.
12 |
13 | 0. Additional Definitions.
14 |
15 | As used herein, "this License" refers to version 3 of the GNU Lesser
16 | General Public License, and the "GNU GPL" refers to version 3 of the GNU
17 | General Public License.
18 |
19 | "The Library" refers to a covered work governed by this License,
20 | other than an Application or a Combined Work as defined below.
21 |
22 | An "Application" is any work that makes use of an interface provided
23 | by the Library, but which is not otherwise based on the Library.
24 | Defining a subclass of a class defined by the Library is deemed a mode
25 | of using an interface provided by the Library.
26 |
27 | A "Combined Work" is a work produced by combining or linking an
28 | Application with the Library. The particular version of the Library
29 | with which the Combined Work was made is also called the "Linked
30 | Version".
31 |
32 | The "Minimal Corresponding Source" for a Combined Work means the
33 | Corresponding Source for the Combined Work, excluding any source code
34 | for portions of the Combined Work that, considered in isolation, are
35 | based on the Application, and not on the Linked Version.
36 |
37 | The "Corresponding Application Code" for a Combined Work means the
38 | object code and/or source code for the Application, including any data
39 | and utility programs needed for reproducing the Combined Work from the
40 | Application, but excluding the System Libraries of the Combined Work.
41 |
42 | 1. Exception to Section 3 of the GNU GPL.
43 |
44 | You may convey a covered work under sections 3 and 4 of this License
45 | without being bound by section 3 of the GNU GPL.
46 |
47 | 2. Conveying Modified Versions.
48 |
49 | If you modify a copy of the Library, and, in your modifications, a
50 | facility refers to a function or data to be supplied by an Application
51 | that uses the facility (other than as an argument passed when the
52 | facility is invoked), then you may convey a copy of the modified
53 | version:
54 |
55 | a) under this License, provided that you make a good faith effort to
56 | ensure that, in the event an Application does not supply the
57 | function or data, the facility still operates, and performs
58 | whatever part of its purpose remains meaningful, or
59 |
60 | b) under the GNU GPL, with none of the additional permissions of
61 | this License applicable to that copy.
62 |
63 | 3. Object Code Incorporating Material from Library Header Files.
64 |
65 | The object code form of an Application may incorporate material from
66 | a header file that is part of the Library. You may convey such object
67 | code under terms of your choice, provided that, if the incorporated
68 | material is not limited to numerical parameters, data structure
69 | layouts and accessors, or small macros, inline functions and templates
70 | (ten or fewer lines in length), you do both of the following:
71 |
72 | a) Give prominent notice with each copy of the object code that the
73 | Library is used in it and that the Library and its use are
74 | covered by this License.
75 |
76 | b) Accompany the object code with a copy of the GNU GPL and this license
77 | document.
78 |
79 | 4. Combined Works.
80 |
81 | You may convey a Combined Work under terms of your choice that,
82 | taken together, effectively do not restrict modification of the
83 | portions of the Library contained in the Combined Work and reverse
84 | engineering for debugging such modifications, if you also do each of
85 | the following:
86 |
87 | a) Give prominent notice with each copy of the Combined Work that
88 | the Library is used in it and that the Library and its use are
89 | covered by this License.
90 |
91 | b) Accompany the Combined Work with a copy of the GNU GPL and this license
92 | document.
93 |
94 | c) For a Combined Work that displays copyright notices during
95 | execution, include the copyright notice for the Library among
96 | these notices, as well as a reference directing the user to the
97 | copies of the GNU GPL and this license document.
98 |
99 | d) Do one of the following:
100 |
101 | 0) Convey the Minimal Corresponding Source under the terms of this
102 | License, and the Corresponding Application Code in a form
103 | suitable for, and under terms that permit, the user to
104 | recombine or relink the Application with a modified version of
105 | the Linked Version to produce a modified Combined Work, in the
106 | manner specified by section 6 of the GNU GPL for conveying
107 | Corresponding Source.
108 |
109 | 1) Use a suitable shared library mechanism for linking with the
110 | Library. A suitable mechanism is one that (a) uses at run time
111 | a copy of the Library already present on the user's computer
112 | system, and (b) will operate properly with a modified version
113 | of the Library that is interface-compatible with the Linked
114 | Version.
115 |
116 | e) Provide Installation Information, but only if you would otherwise
117 | be required to provide such information under section 6 of the
118 | GNU GPL, and only to the extent that such information is
119 | necessary to install and execute a modified version of the
120 | Combined Work produced by recombining or relinking the
121 | Application with a modified version of the Linked Version. (If
122 | you use option 4d0, the Installation Information must accompany
123 | the Minimal Corresponding Source and Corresponding Application
124 | Code. If you use option 4d1, you must provide the Installation
125 | Information in the manner specified by section 6 of the GNU GPL
126 | for conveying Corresponding Source.)
127 |
128 | 5. Combined Libraries.
129 |
130 | You may place library facilities that are a work based on the
131 | Library side by side in a single library together with other library
132 | facilities that are not Applications and are not covered by this
133 | License, and convey such a combined library under terms of your
134 | choice, if you do both of the following:
135 |
136 | a) Accompany the combined library with a copy of the same work based
137 | on the Library, uncombined with any other library facilities,
138 | conveyed under the terms of this License.
139 |
140 | b) Give prominent notice with the combined library that part of it
141 | is a work based on the Library, and explaining where to find the
142 | accompanying uncombined form of the same work.
143 |
144 | 6. Revised Versions of the GNU Lesser General Public License.
145 |
146 | The Free Software Foundation may publish revised and/or new versions
147 | of the GNU Lesser General Public License from time to time. Such new
148 | versions will be similar in spirit to the present version, but may
149 | differ in detail to address new problems or concerns.
150 |
151 | Each version is given a distinguishing version number. If the
152 | Library as you received it specifies that a certain numbered version
153 | of the GNU Lesser General Public License "or any later version"
154 | applies to it, you have the option of following the terms and
155 | conditions either of that published version or of any later version
156 | published by the Free Software Foundation. If the Library as you
157 | received it does not specify a version number of the GNU Lesser
158 | General Public License, you may choose any version of the GNU Lesser
159 | General Public License ever published by the Free Software Foundation.
160 |
161 | If the Library as you received it specifies that a proxy can decide
162 | whether future versions of the GNU Lesser General Public License shall
163 | apply, that proxy's public statement of acceptance of any version is
164 | permanent authorization for you to choose that version for the
165 | Library.
166 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Light-weight RESTful server tools in Python #
2 |
3 | > This project was migrated from on May 17, 2015
4 | > Keywords: *RESTful*, *REST*, *WebService*, *WSGI*, *WSGIREF*, *JSON*, *XML*, *SQLite*, *authentication*
5 | > Members: *kundan10*, *theintencity*
6 | > Links: [Support](http://groups.google.com/group/myprojectguide), [Download](/restlite-1.0.tgz) first version of RESTlite with example, Nov 2009, 11.8 KB, download count 944
7 | > License: [GNU Lesser GPL](http://www.gnu.org/licenses/lgpl.html)
8 | > Others: starred by 21 users
9 |
10 | **New:** The new RTClite project is a superset of this RESTlite project. Please migrate to or start using the [RTClite project](https://github.com/theintencity/rtclite) project instead of this. More information at https://github.com/theintencity/rtclite
11 |
12 |
13 | 1. [What is restlite?](#What_is_restlite?.md)
14 | * [features](#Features.md), [dependencies](#Dependencies.md), [feedback](#Feedback.md)
15 | 1. [How to get started?](#Getting_Started.md)
16 | * [download](#Getting_the_software.md), [wsgi](#What_is_WSGI?.md), [router](#REST_URL_router.md), [@resource](#High_level_resource.md), [bind](#Binding_to_Python_variables.md), [represent](#Representation.md), [Model](#Data_model.md), [AuthModel](#Authentication.md), [unit test](#Testing.md)
17 | 1. [Why did I create restlite?](#Motivation.md)
18 |
19 | # What is restlite? #
20 | Restlite is a light-weight Python implementation of server tools for quick prototyping of your RESTful web service. Instead of building a complex framework, it aims at providing functions and classes that allows your to build your own application.
21 |
22 | >> restlite = REST + Python + JSON + XML + SQLite + authentication
23 |
24 | ### Features ###
25 |
26 | 1. Very lightweight module with single file in pure Python and no other dependencies hence ideal for quick prototyping.
27 | 1. Two levels of API: one is not intrusive (for low level WSGI) and other is intrusive (for high level @resource).
28 | 1. High level API can conveniently use sqlite3 database for resource storage.
29 | 1. Common list and tuple-based representation that is converted to JSON and/or XML.
30 | 1. Supports pure REST as well as allows browser and Flash Player access (with GET, POST only).
31 | 1. Integrates unit testing using doctest module.
32 | 1. Handles HTTP cookies and authentication.
33 | 1. Integrates well with WSGI compliant applications.
34 |
35 | ### Dependencies ###
36 |
37 | **Python 2.6**.
38 | Most of the code works in Python 2.5, but it needs the `json` module available in Python 2.6. You must be familiar with Python programming language to use this software.
39 |
40 | ### Support and Feedback ###
41 |
42 | ## Support ##
43 |
44 | If you want to contribute to this project or report a bug, feedback, patches, comments or suggestions, please send me a note to the [support group](http://groups.google.com/group/myprojectguide). You don't need to subscribe to that group to post a message. **I look forward to hearing from you!**
45 |
46 | # Getting Started #
47 |
48 | This section describes how to start using restlite.
49 |
50 | ## Getting the software ##
51 |
52 | I would suggest you get the sources using [git](https://github.com/theintencity/restlite.git). For some reason, if that does not work, you should download the latest source [archive](/restlite-1.0.tgz), uncompress it, set the directory in PYTHONPATH environment variable. There is one source file for the module, rtmplite.py, and one example file, example.py, which shows how to use it for an example application.
53 |
54 | ## What is WSGI? ##
55 |
56 | The web server and gateway interface (WSGI) specification defines a uniform interface that allows you to build consistent and compliant web services for a wide variety of use cases. Python comes with a reference implementation in [wsgiref](http://docs.python.org/library/wsgiref.html) module. The basic idea that is each web application is callable with two arguments, environment dictionary and a `start_response` function. The application invokes the function to start the response, and returns an `iterable` object which is returned in the response. The HTTP methods and path are available in the environment dictionary.
57 |
58 | For example, you can write a simple WSGI compliant "hello world" web application as follows. If you execute the following code fragment, point your web-browser to localhost:8000, you will see the "Hello World!" message.
59 |
60 | ```
61 | def handle_request(env, start_response):
62 | start_response('200 OK', [('Content-Type', 'text/plain')]
63 | return ['Hello World!']
64 |
65 | from wsgiref.simple_server import make_server
66 | httpd = make_server('', 8000, handle_request)
67 | httpd.serve_forever()
68 | ```
69 |
70 |
71 | ## REST URL router ##
72 |
73 | At the core of restlite, there is a URL router. The router itself is a WSGI application, which in turn takes a list of patterns for HTTP method and URL, performs pattern matching, applies any request transformation for matching request, and invokes another WSGI application for matching request, if any. You can use the routes to do several things: identify the required response type from part of URL, identify and store some parts of the URL in variables, modify some HTTP header or body, or transform the method or URL. The router uses standard regular expression for pattern matching and string formatting for transformation.
74 |
75 | Consider the following example which defines a `files_handler` application and a route which maps `GET /files` to the application.
76 | ```
77 | def files_handler(env, start_response):
78 | return '' + env['ACCEPT'] + 'somefile.txt'
79 |
80 | routes = [
81 | (r'GET,PUT,POST /xml/(?P.*)$', 'GET,PUT,POST /%(path)s', 'ACCEPT=text/xml'),
82 | (r'GET /files$', files_handler)
83 | ]
84 |
85 | import restlite, wsgiref
86 | httpd = wsgiref.simple_server.make_server('', 8000, restlite.router(routes))
87 | httpd.serve_forever()
88 | ```
89 |
90 | Learn how the routes are specified as a list of tuples, where each tuple is a route entry. The first item in a route entry is used for matching the request. A request matches if the method matches one of the comma-separated method and the URL matches the regular expression of the URL. Note that internally it invokes `re.match` which matches the URL pattern at the beginning of the request URL. If you would like to match the full URL in your pattern, you must end your regular expression using `$`. If you would like to match only a prefix of a URL, do not use `$` at the end. The second item in the tuple gives the optional transformation. In this case, the first route transforms a URL of the form `/xml/some/path/here` to `/some/path/here` and sets the `ACCEPT` header to "text/xml". The string formatting syntax is used to substitute the matching `path` regular expression variable in the second item. The request method is substituted in order, e.g., in our case there is no change in the request method. The subsequent items in the route specify any modifications to the request, such as changing a header or body.
91 |
92 | The second route actually invokes the handler application. The first item of the second route is, as before, the pattern for method and URL. The last item of the route may be a callable application, in which case a matching request invokes that callable application and stops further routes for this request. The route matching happens sequentially and stops when a matching route has specified an application. The net result of these two routes is that, it supports `GET /files` as well as `GET /xml/files` where the latter assumes that the ACCEPT header is "text/xml".
93 |
94 | The HTTP headers are identified in the environment dictionary using capitalized names for the headers and `_` instead of `-`, similar to the convention used in CGI. For example, the `Content-Type` header is identified as `CONTENT_TYPE`. You can set a environment variable in the router as mentioned before, or you can access the environment variable in the application if needed.
95 |
96 | The following example is a more realistic use case, where the `file` application is used to read a file on the disk relative to some `directory`. The routes specify only `GET /file` without the trailing `$`, so that it can be invoked as `GET /file/restlite.py`. When the application `file` is invoked, the first argument, `env`, holds all the environment dictionary. The WSGI standard says that `PATH_INFO` environment contains the remaining path, in this case, `restlite.py`, that are not matched by the router. The application retrieves the file relative to the `directory` and returns the content using `Content-Type` of "application/octet-stream". There is a `Status` exception object that you can use to send an immediate failure response. Alternatively, you can also use `start_response` and return.
97 | ```
98 | import os, restlite, wsgiref
99 |
100 | directory = '.'
101 |
102 | def file(env, start_response):
103 | global directory
104 | path = os.path.join(directory, env['PATH_INFO'][1:] if env['PATH_INFO'] else '')
105 | if not os.path.isfile(path): raise restlite.Status, '404 Not Found'
106 | start_response('200 OK', [('Content-Type', 'application/octet-stream')])
107 | try:
108 | with open(path, 'rb') as f: result = f.read()
109 | except: raise restlite.Status, '400 Error Reading File'
110 | return [result]
111 |
112 | routes = [
113 | (r'GET /file', file)
114 | ]
115 |
116 | httpd = wsgiref.simple_server.make_server('', 8000, restlite.router(routes))
117 | httpd.serve_forever()
118 | ```
119 |
120 | The `router` is actually a function that takes a list of route tuples, and returns a WSGI application that performs route matching and application invocation. For most low-level applications, the `router` is the only function you need to implement your RESTful web service. When using the router or supplying a WSGI application as a route application, please pay particular attention to the following:
121 | 1. The matching happens sequentially in the list of routes. A matching route performs transformation, if any, and invokes application, if any. If a matching route has an application, the process stops, otherwise it continues to the next matching route.
122 | 1. The return value must be iterable. In most cases you can return a list containing the value. This is as specified by WSGI.
123 | 1. Any matching regular expression variable will be available in `wsgiorg.routing_args` environment. For example if your regular expression has `(?P...)` then the matching component of the URL will be in `env['wsgiorg.routing_args']['path']`.
124 | 1. You can set any header in the response using `start_response` function.
125 | 1. To allow browser or Flash Player to access (PUT, DELETE) your resources, you may need to use route transforms to map POST or GET to these methods.
126 |
127 | Please see a complete example in example.py available in the repository.
128 |
129 | ## High level resource ##
130 |
131 | Restlite also includes a decorator, `@resource`, that allows you to define high level resource. The basic idea is to convert a function containing HTTP method handlers to a WSGI application, which can be given to the `router`. Consider the following example where a resource is created out of `config` function. The code fragment creates a resource for representing the top-level `directory` we used in the previous example.
132 | ```
133 | directory = '.'
134 |
135 | @restlite.resource
136 | def config():
137 | def GET(request):
138 | global directory
139 | return request.response(('config', ('directory', directory)))
140 | def PUT(request, entity):
141 | global directory
142 | directory = str(entity)
143 | return locals()
144 | ```
145 | Note that the `request` argument to the method handler function actually is an extension of the environment dictionary, hence all the key-values of the environment are also available in `request`. Additionally, `request.start_response` is a reference to the `start_response` function of the WSGI appllication.
146 |
147 | Once your have create a resource using this mechanism, you can supply the application to the router. The following example allows GET, PUT and POST methods on this resource accessed as `/config`, such that POST is transformed to PUT. Hence you only need to define GET and PUT in the resource `config`.
148 | ```
149 | routes = [
150 | ...
151 | (r'GET /config\?directory=(?P.*)', 'PUT /config', 'CONTENT_TYPE=text/plain', 'BODY=%(directory)s', config),
152 | (r'GET,PUT,POST /config$', 'GET,PUT,PUT /config', config),
153 | ]
154 | ```
155 |
156 | The key points to remember are:
157 | 1. Must use `return locals()` at the end of your resource function.
158 | 1. The GET and DELETE methods take one argument, `request`, and the PUT and POST methods take two arguments, `request` and `entity`. The entity is basically the message body.
159 | 1. The `request.start_response` method is also available, if you need.
160 | 1. You can raise the `Status` exception to return an error response.
161 | 1. The handlers can be implemented for GET, PUT, POST and DELETE. You do not need to implement all handlers, in which case it will return '405 Method Not Allowed' response.
162 |
163 | ## Binding to Python variables ##
164 |
165 | Restlite also allows you to bind to a Python variable such as object, list, tuple or dictionary. The following example shows that the list `users` is converted to WSGI application, which is used in the routes to match `GET /users`.
166 |
167 | ```
168 | users = [{'username': 'kundan', 'name': 'Kundan Singh', 'email': 'kundan10@gmail.com'},
169 | {'username': 'alok'}]
170 | users = restlite.bind(users)
171 |
172 | routes = [
173 | ...
174 | (r'GET /users', users),
175 | ]
176 | ```
177 |
178 | Note that there is no trailing `$` in the regular expression, hence it matches the prefix `/users` and can handle several URLs of the form `/users`, `/users/0`, `/users/1/username`. The basic idea behind the `bind` function is to take a Python object and return a WSGI application that allows accessing the object hierarchically. For example, if the top-level object `users` is bound to `/users` and represents a list, then `/users/i` represents the i'th item in that list. Similarly, if `/users/1` is a dictionary then `/users/1/username` represents the value of index `username` in that dictionary. Similarly, an object attribute is accessed by sub-scoping.
179 |
180 | Future work: You may extend the `bind` function to support update and new operations as well.
181 |
182 | ## Representation ##
183 |
184 | Restlite supports two representations, XML and JSON, identified by "text/xml" and "application/json" content type. It also supports primitive "text/plain" representation using the built-in `str` function.
185 |
186 | There is a `restlite.defaultType` variable, which you can modify in your application to use a particular default representation. I use "application/json" as my default.
187 |
188 | To support different representations for structured data, I assume a unified list representation, which gets converted to the XML or JSON representation using the `restlite.represent` or `request.response` function. You might have noticed the use of `request.response` function in the `config` example above.
189 |
190 | The basic idea behind unified list representation is to represent structured data using tuples or list, instead of using dictionary. Why? because the order is lost in dictionary, which may be needed in XML representation. For example, the following represents a 'file' with 'name' and 'acl' properties. The 'acl' property itself is a list of two names.
191 | ```
192 | value = ('file', (('name', 'myfile.txt'),
193 | ('acl', [('allow', 'kundan'), ('allow', 'admin')])))
194 | ```
195 | You can get the corresponding XML and JSON representations as follows. Note that the `represent` function takes a value and optional type, returns a tuple of type and formatted value. If type is not supplied or contains "**/**", then the `defaultType` is assumed.
196 | ```
197 | restlite.represent(value, type='application/json')[1]
198 | # '{"file": {"name": "myfile.txt", "acl": [{"allow": "kundan"}, {"allow": "admin"}]}}'
199 |
200 | restlite.represent(value, type='text/xml')[1]
201 | # 'myfile.txtkundanadmin
202 | ```
203 |
204 | If you would like to customize a particular representation, of a `value` object, you can override the `__str__`, `_json_` or `_xml_` methods. Alternatively, you can override the `_list_` method to customize the unified list representation. The following example shows the user object is customized, and produces the same representation as before.
205 | ```
206 | class user:
207 | def __init__(self, name): self.name = name
208 | def _list_(self): return ('allow', self.name)
209 | def __str(self): return 'allow=' + self.name
210 | u1, u2 = user('kundan'), user('admin')
211 | value = ('file', (('name', 'myfile.txt'), ('acl', [u1, u2])))
212 | ```
213 |
214 | The `restlite.represent` and `request.response` function are available for convenience if you want to support multiple representations of your structured data. By default, `request.response` function understands the `ACCEPT` header in the request and tries to create a representation that best matches the header value. On the other hand, the `restlite.represent` function should be given the desired type if different from default. If you do not wish to support multiple representations of your structured data, you may return the actual representation from your resource or application directly instead of using these functions.
215 |
216 | ## Data model ##
217 |
218 | Restlite has a `Model` class which you can use to create you sqlite3 based data model. You can describe your database tables in text or use the `sql` method. An example is shown below to create two tables:
219 |
220 | ```
221 | data = '''
222 | files
223 | id integer primary key
224 | name text not null
225 | path text not null
226 |
227 | keywords
228 | id integer primary key
229 | file_id int
230 | keyword text
231 | '''
232 | m = restlite.Model()
233 | m.create(data)
234 | m.sql('INSERT INTO files VALUES (NULL, ?, ?)', ('myfile.txt', '/path/to/myfile.txt'))
235 | ```
236 |
237 | The `sql` method returns a cursor, whereas `sql1` method returns the first row item in the query, which is useful typically for `SELECT` queries.
238 |
239 | The `Model` class is provides for convenience and is not related to resource or router described before. However, if you use a data model in your application, you can store your data and access it as needed in various method handlers of your resource or application.
240 |
241 | ## Authentication ##
242 |
243 | Restlite supports two types of authentication: HTTP basic authentication or using cookies and parameters. The `AuthModel` class extends the data model to provide authentication, and stores the user information in `user_login` SQL table. It also provides several methods such as `login`, `logout`, `register`, `hash`, `token`, etc., related to authentication. If you want to use the authentication feature, I encourage you to look at the implementation of `AuthModel`. The following example creates a `private` resource mapped to 'GET /private', and uses `login` method on `AuthModel` to perform authentication.
244 | ```
245 | model = restlite.AuthModel()
246 | model.register('kundan10@gmail.com', 'localhost', 'somepass')
247 |
248 | @restlite.resource
249 | def private():
250 | def GET(request):
251 | global model
252 | model.login(request)
253 | return request.response(('path', request['PATH_INFO']))
254 | return locals()
255 |
256 | routes = [
257 | ...
258 | (r'GET /private/', private)
259 | ]
260 | ```
261 | When you visit the URL, you will be prompted with authentication dialog box, where you can enter username as "kundan10@gmail.com" and password as "somepass" to authenticate.
262 |
263 | The authentication can be done either using HTTP basic or by supplying the `user_id` or `email` in addition to the `token` property to the request. Once authenticated, it creates a `token` which is set in the cookies, so that subsequent requests from the browser will not need to be authenticated using HTTP basic or parameters again.
264 |
265 | If you include authentication in your application, you may also want to incorporate 'GET /login' and 'GET /logout' URL resources that allows the user to login and logout, respectively.
266 |
267 | ## Testing ##
268 |
269 | I have written several unit test code in `restlite.py` which you can invoke by running that module.
270 | ```
271 | python restlite.py -v
272 | ```
273 |
274 | Additionally, there is `example.py` which implements a real file server application using RESTful architecture. It also demonstrates how to use `@resource`, `bind` and authentication. Moreover, it has client-side of the code using Python's `urllib2` module, which implements several unit tests. To perform the unit tests:
275 | ```
276 | python example.py --unittest
277 | ```
278 |
279 | To run the web-based file access server:
280 | ```
281 | python example.py
282 | ```
283 |
284 | # Motivation #
285 |
286 | As you may have noticed, the software provides tools such as (1) regular expression based request matching and dispatching WSGI compliant `router`, (2) high-level resource representation using a decorator and variable binding, (3) functions for converting from unified list representation to JSON and XML, and (3) data model and authentication classes. These tools can be used independent of each other. For example, you just need the `router` function to implement RESTful web services. If you also want to do high-level definitions of your resources you can use the `@resource` decorator, or `bind` functions to convert your function or object to WSGI compliant application that can be given to the `router`. You can return any representation from your application. However, if you want to support multiple consistent representations of XML and JSON, you can use the `represent` function of `request.response` method to do so. Finally, you can have any data model you like, but implementations of common SQL style data model and HTTP basic and cookie based authentication are provided for you to use if needed.
287 |
288 | This software is provided with a hope to help you quickly realize RESTful services in your application without having to deal with the burden of large and complex frameworks. Any feedback is appreciated. If you have trouble using the software or want to learn more on how to use, feel free to send me a note!
289 |
290 |
291 |
292 |
--------------------------------------------------------------------------------
/__init__.py:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theintencity/restlite/a82c5107e7e6701e2859df8e169e33fa9410ecf5/__init__.py
--------------------------------------------------------------------------------
/dist.txt:
--------------------------------------------------------------------------------
1 | restlite/example.py
2 | restlite/LICENSE
3 | restlite/README
4 | restlite/restlite.py
5 |
--------------------------------------------------------------------------------
/example.py:
--------------------------------------------------------------------------------
1 | import os, thread, restlite
2 |
3 | # The top-level directory for all file requests
4 |
5 | directory = '.'
6 |
7 | # The resource to get or set the top-level directory
8 |
9 | @restlite.resource
10 | def config():
11 | def GET(request):
12 | global directory
13 | return request.response(('config', ('directory', directory)))
14 | def PUT(request, entity):
15 | global directory
16 | directory = str(entity)
17 | return locals()
18 |
19 | # The resource to list all the file information under path relative to the top-level directory
20 |
21 | @restlite.resource
22 | def files():
23 | def GET(request):
24 | global directory
25 | if '..' in request['PATH_INFO']: raise restlite.Status, '400 Invalid Path'
26 | path = os.path.join(directory, request['PATH_INFO'][1:] if request['PATH_INFO'] else '')
27 | try:
28 | files = [(name, os.path.join(path, name), request['PATH_INFO'] + '/' + name) for name in os.listdir(path)]
29 | except: raise restlite.Status, '404 Not Found'
30 | def desc(name, path, url):
31 | if os.path.isfile(path):
32 | return ('file', (('name', name), ('url', '/file'+url), ('size', os.path.getsize(path)), ('mtime', int(os.path.getmtime(path)))))
33 | elif os.path.isdir(path):
34 | return ('dir', (('name', name), ('url', '/files'+url)))
35 | files = [desc(*file) for file in files]
36 | return request.response(('files', files))
37 | return locals()
38 |
39 | # download a given file from the path under top-level directory
40 |
41 | def file(env, start_response):
42 | global directory
43 | path = os.path.join(directory, env['PATH_INFO'][1:] if env['PATH_INFO'] else '')
44 | if not os.path.isfile(path): raise restlite.Status, '404 Not Found'
45 | start_response('200 OK', [('Content-Type', 'application/octet-stream')])
46 | try:
47 | with open(path, 'rb') as f: result = f.read()
48 | except: raise restlite.Status, '400 Error Reading File'
49 | return [result]
50 |
51 | # convert a Python object to resource
52 |
53 | users = [{'username': 'kundan', 'name': 'Kundan Singh', 'email': 'kundan10@gmail.com'}, {'username': 'alok'}]
54 | users = restlite.bind(users)
55 |
56 | # create an authenticated data model with one user and perform authentication for the resource
57 |
58 | model = restlite.AuthModel()
59 | model.register('kundan10@gmail.com', 'localhost', 'somepass')
60 |
61 | @restlite.resource
62 | def private():
63 | def GET(request):
64 | global model
65 | model.login(request)
66 | return request.response(('path', request['PATH_INFO']))
67 | return locals()
68 |
69 | # all the routes
70 |
71 | routes = [
72 | (r'GET,PUT,POST /(?P((xml)|(plain)))/(?P.*)', 'GET,PUT,POST /%(path)s', 'ACCEPT=text/%(type)s'),
73 | (r'GET,PUT,POST /(?P((json)))/(?P.*)', 'GET,PUT,POST /%(path)s', 'ACCEPT=application/%(type)s'),
74 | (r'GET /config\?directory=(?P.*)', 'PUT /config', 'CONTENT_TYPE=text/plain', 'BODY=%(directory)s', config),
75 | (r'GET,PUT,POST /config$', 'GET,PUT,PUT /config', config),
76 | (r'GET /files', files),
77 | (r'GET /file', file),
78 | (r'GET /users', users),
79 | (r'GET /private/', private)
80 | ]
81 |
82 | # launch the server on port 8000
83 |
84 | if __name__ == '__main__':
85 | import sys
86 | from wsgiref.simple_server import make_server
87 |
88 | httpd = make_server('', 8000, restlite.router(routes))
89 |
90 | # if unit test is desired, perform unit testing
91 | if len(sys.argv) > 1 and sys.argv[1] == '--unittest':
92 | import urllib2, cookielib
93 | password_mgr = urllib2.HTTPPasswordMgrWithDefaultRealm()
94 | top_level_url = "localhost:8000"
95 | password_mgr.add_password(None, top_level_url, "kundan10@gmail.com", "somepass")
96 | cj = cookielib.CookieJar()
97 | urllib2.install_opener(urllib2.build_opener(urllib2.HTTPBasicAuthHandler(password_mgr), urllib2.HTTPCookieProcessor(cj)))
98 |
99 | def urlopen(url, prefix="http://localhost:8000"):
100 | try: return urllib2.urlopen(prefix + url).read()
101 | except: return sys.exc_info()[1]
102 |
103 | def test():
104 | print urlopen('/config')
105 | print urlopen('/config?directory=..')
106 | print urlopen('/config')
107 | print urlopen('/xml/files')
108 | print urlopen('/xml/files/restlite')
109 | print urlopen('/json/files')
110 | print urlopen('/json/files/restlite')
111 | print '\n'.join(urlopen('/file/restlite/restlite.py').split('\n')[:6])
112 | print urlopen('/json/users')
113 | print urlopen('/json/users/0')
114 | print urlopen('/json/users/1/username')
115 | print urlopen('/json/private/something/is/here')
116 | print urlopen('/json/private/otherthing/is/also/here')
117 |
118 | thread.start_new_thread(test, ())
119 |
120 | try: httpd.serve_forever()
121 | except KeyboardInterrupt: pass
122 |
--------------------------------------------------------------------------------
/restdata.py:
--------------------------------------------------------------------------------
1 | '''
2 | restdata: access controlled structured data for restlite
3 |
4 | https://github.com/theintencity/restlite
5 | Previously http://code.google.com/p/restlite
6 | Copyright (c) 2010, Kundan Singh, kundan10@gmail.com. All rights reserved.
7 | License: released under LGPL (Lesser GNU Public License).
8 |
9 | This module allows you to expose any structured Python data as wsgi application using the
10 | bind method. Additionally, the application supports access control using Unix style
11 | permission on individual data objects. This module's bind method extends the features of
12 | restlite's bind method.
13 |
14 | Dependencies: Python 2.6.
15 | '''
16 |
17 | import sys, json, base64, hashlib
18 | import restlite
19 |
20 | def hash(user, realm, password):
21 | '''MD5(user:realm:password) is used for storing user's encrypted password.'''
22 | return hashlib.md5('%s:%s:%s'%(user, realm, password)).hexdigest()
23 |
24 | class Request(object):
25 | def __init__(self, env, start_response):
26 | self.env, self.start_response = env, start_response
27 | self.method = env['REQUEST_METHOD']
28 | self.pathItems = [x for x in env['PATH_INFO'].split('/') if x != '']
29 | self.user, self.access = None, 'drwxr-xr-x'
30 |
31 | def nextItem(self):
32 | if self.pathItems:
33 | item, self.pathItems = self.pathItems[0], self.pathItems[1:]
34 | else:
35 | item = None
36 | return item
37 |
38 | # returns (user, None) or (None, '401 Unauthorized')
39 | def getAuthUser(self, users, realm, addIfMissing=False):
40 | hdr = self.env.get('HTTP_AUTHORIZATION', None)
41 | if not hdr:
42 | return (None, '401 Missing Authorization')
43 | authMethod, value = map(str.strip, hdr.split(' ', 1))
44 | if authMethod != 'Basic':
45 | return (None, '401 Unsupported Auth Method %s'%(authMethod,))
46 | user, password = base64.b64decode(value).split(':', 1)
47 | hash_recv = hash(user, realm, password)
48 | if user not in users:
49 | if addIfMissing:
50 | users[user] = hash_recv
51 | return (user, '200 OK')
52 | else:
53 | return (user, '404 User Not Found')
54 | if hash_recv != users[user]:
55 | return (user, '401 Not Authorized')
56 | return (user, '200 OK')
57 |
58 | # throw the 401 response with appropriate header
59 | def unauthorized(self, realm, reason='401 Unauthorized'):
60 | self.start_response(reason, [('WWW-Authenticate', 'Basic realm="%s"'%(realm,))])
61 | raise restlite.Status, reason
62 |
63 | def getBody(self):
64 | try:
65 | self.env['BODY'] = self.env['wsgi.input'].read(int(self.env['CONTENT_LENGTH']))
66 | except (TypeError, ValueError):
67 | raise restlite.Status, '400 Invalid Content-Length'
68 | if self.env['CONTENT_TYPE'].lower() == 'application/json' and self.env['BODY']:
69 | try:
70 | self.env['BODY'] = json.loads(self.env['BODY'])
71 | except:
72 | raise restlite.Status, '400 Invalid JSON content'
73 | return self.env['BODY']
74 |
75 | def verifyAccess(self, user, type, obj):
76 | if not obj:
77 | raise restlite.Status, '404 Not Found'
78 | if '_access' in obj:
79 | self.access = obj['_access']
80 | if '_owner' in obj:
81 | self.user = obj['_owner']
82 | index = {'r': 1, 'w': 2, 'x': 3}[type]
83 | if not (user == self.user and self.access[index] != '-' \
84 | or user != self.user and self.access[6+index] != '-'):
85 | raise restlite.Status, '403 Forbidden'
86 |
87 | def represent(self, obj):
88 | prefix = self.env['SCRIPT_NAME'] + self.env['PATH_INFO']
89 | if isinstance(obj, list):
90 | result = [(':id', '%s/%d'%(prefix, i,)) if isinstance(v, dict) and '_access' in v else self.represent(v) for i, v in enumerate(obj)]
91 | elif isinstance(obj, dict):
92 | result = tuple([('%s:id'%(k,), '%s/%s'%(prefix, k)) if isinstance(v, dict) and '_access' in v else (k, self.represent(v)) for k, v in obj.iteritems() if not k.startswith('_')])
93 | else:
94 | result = obj
95 | return result
96 |
97 | class Data(object):
98 | def __init__(self, data, users):
99 | self.data, self.users, self.realm = data, users, 'localhost'
100 |
101 | def traverse(self, obj, item):
102 | if isinstance(obj, dict): return obj[item]
103 | elif isinstance(obj, list):
104 | try: index = int(item)
105 | except: raise restlite.Status, '400 Bad Request'
106 | if index < 0 or index >= len(obj): raise restlite.Status, '400 Bad Request'
107 | return obj[index]
108 | elif hasattr(obj, item): return obj.__dict__[item]
109 | else: return None
110 |
111 | def handler(self, env, start_response):
112 | print 'restdata.handler()', env['SCRIPT_NAME'], env['PATH_INFO']
113 | request = Request(env, start_response)
114 | user, reason = request.getAuthUser(self.users, self.realm, addIfMissing=True)
115 | if not user or not reason.startswith('200'):
116 | return request.unauthorized(self.realm, reason)
117 | current = self.data
118 | while len(request.pathItems) > 1:
119 | item = request.nextItem()
120 | request.verifyAccess(user, 'x', current)
121 | current = self.traverse(current, item)
122 | item = request.nextItem()
123 |
124 | if request.method == 'POST':
125 | if item:
126 | request.verifyAccess(user, 'x', current)
127 | current = self.traverse(current, item)
128 | if not isinstance(current, list):
129 | raise restlite.Status, '405 Method Not Allowed'
130 | value = request.getBody()
131 | current += value
132 | elif request.method == 'PUT':
133 | value = request.getBody()
134 | request.verifyAccess(user, 'w', current)
135 | if isinstance(current, dict):
136 | current[item] = value
137 | elif isinstance(current, list):
138 | try: index = int(item)
139 | except: raise restlite.Status, '400 Bad Request'
140 | if index < 0: current.insert(0, value)
141 | elif index >= len(current): current.append(value)
142 | else: current[index] = value
143 | else:
144 | current.__dict__[item] = value
145 | elif request.method == 'DELETE':
146 | request.verifyAccess(user, 'w', current)
147 | if isinstance(current, dict):
148 | del current[item]
149 | elif isinstance(current, list):
150 | try: index = int(item)
151 | except: raise restlite.Status, '400 Bad Request'
152 | if index < 0 or index >= len(current): raise restlite.Status, '400 Bad Request'
153 | else: del current[index]
154 | elif hasattr(current, item):
155 | del current.__dict__[item]
156 | elif request.method == 'GET':
157 | if item:
158 | request.verifyAccess(user, 'x', current)
159 | current = self.traverse(current, item)
160 | request.verifyAccess(user, 'r', current)
161 | result = request.represent(current)
162 | type, value = restlite.represent(result, type=env.get('ACCEPT', 'application/json'))
163 | start_response('200 OK', [('Content-Type', type)])
164 | return [value]
165 | else: raise restlite.Status, '501 Method Not Implemented'
166 |
167 | def bind(data, users=None):
168 | '''The bind method to bind the returned wsgi application to the supplied data and users.
169 | @param data the original Python data structure which is used and updated as needed.
170 | @param users the optional users dictionary. If missing, it disables access control.
171 | @return: the wsgi application that can be used with restlite.
172 | '''
173 | data = Data(data, users)
174 | def handler(env, start_response):
175 | return data.handler(env, start_response)
176 | return handler
177 |
--------------------------------------------------------------------------------
/restlite-1.0.tgz:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/theintencity/restlite/a82c5107e7e6701e2859df8e169e33fa9410ecf5/restlite-1.0.tgz
--------------------------------------------------------------------------------
/restlite.py:
--------------------------------------------------------------------------------
1 | '''
2 | restlite: REST + Python + JSON + XML + SQLite + authentication.
3 |
4 | https://github.com/theintencity/restlite
5 | Previously http://code.google.com/p/restlite
6 | Copyright (c) 2009, Kundan Singh, kundan10@gmail.com. All rights reserved.
7 | License: released under LGPL (Lesser GNU Public License).
8 |
9 | This light-weight module allows quick prototyping of web services using the RESTful architecture and allows easy
10 | integration with sqlite3 database, and JSON and XML representation format. The approach is to provide all the
11 | appropriate tools which you can use to build your own application, instead of providing a intrusive framework.
12 |
13 | Features:
14 | 1. Very lightweight module in pure Python and no other dependencies hence ideal for quick prototyping.
15 | 2. Two levels of API: one is not intrusive (for low level WSGI) and other is intrusive (for high level @resource).
16 | 3. High level API can conveniently use sqlite3 database for resource storage.
17 | 4. Common list and tuple-based representation that is converted to JSON and/or XML.
18 | 5. Supports pure REST as well as allows browser and Flash Player access (with GET, POST only).
19 | 6. Integrates unit testing using doctest module.
20 | 7. Handles HTTP cookies and authentication.
21 |
22 | Dependencies: Python 2.6.
23 | '''
24 |
25 | from wsgiref.util import setup_testing_defaults
26 | from xml.dom import minidom
27 | import os, re, sys, sqlite3, Cookie, base64, hashlib, time, traceback
28 | try: import json
29 | except: print 'Cannot import json. Please use Python 2.6.'; raise
30 |
31 | _debug = False
32 |
33 | defaultType = 'application/json' # default content type if ACCEPT is */*. Used in represent and router.
34 |
35 | #------------------------------------------------------------------------------
36 | # REST router
37 | #------------------------------------------------------------------------------
38 |
39 | def router(routes):
40 | '''This is the main low level REST router function that takes a list of routes and sequentially tries to match the
41 | request method and URL pattern. If a valid route is matched, request transformation is applied. If an application
42 | is specified for a route, then the (wsgiref) application is invoked and the response is returned. This is used
43 | together with wsgiref.make_server to launch a RESTful service.
44 |
45 | Your can use the routes to do several things: identify the response type (JSON, XML) from the URL, identify
46 | some parts in the URL as variables available to your application handler, modify some HTTP header or message body
47 | based on the URL, convert a GET or POST URL from the browser with URL suffix of /put or /delete to PUT or DELETE
48 | URL to handle these commands from the browser, etc. For more details see the project web page.
49 |
50 | >>> def files_handler(env, start_response):
51 | ... return '' + env['ACCEPT'] + 'somefile.txt'
52 | >>> routes = [
53 | ... (r'GET,PUT,POST /xml/(?P.*)$', 'GET,PUT,POST /%(path)s', 'ACCEPT=text/xml'),
54 | ... (r'GET /files$', files_handler) ]
55 | >>> r = router(routes) # create the router using these routes
56 | >>> # and test using the following code
57 | >>> env, start_response = {'REQUEST_METHOD': 'GET', 'PATH_INFO': '/xml/files', 'SCRIPT_NAME': '', 'QUERY_STRING': ''}, lambda x,y: (x, y)
58 | >>> print r(env, start_response)
59 | text/xmlsomefile.txt
60 | '''
61 | if isinstance(routes, dict) or hasattr(routes, 'items'): routes = routes.iteritems()
62 |
63 | def handler(env, start_response):
64 | # setup_testing_defaults(env)
65 | if 'wsgiorg.routing_args' not in env: env['wsgiorg.routing_args'] = dict()
66 | env['COOKIE'] = Cookie.SimpleCookie()
67 | if 'HTTP_COOKIE' in env: env['COOKIE'].load(env['HTTP_COOKIE'])
68 |
69 | for route in routes:
70 | method, pattern = route[0].split(' ', 1)
71 | methods = method.split(',')
72 | if env['REQUEST_METHOD'] not in methods: continue
73 | path = env['PATH_INFO'] + ('?' + env['QUERY_STRING'] if env['QUERY_STRING'] else '')
74 | match = re.match(pattern, path)
75 | if match:
76 | app = None
77 | if callable(route[-1]):
78 | route, app = route[:-1], route[-1] # found the app
79 | if len(route) > 1:
80 | new_methods, path = route[1].split(' ', 1)
81 | env['REQUEST_METHOD'] = new_methods.split(',')[methods.index(env['REQUEST_METHOD'])]
82 | env['PATH_INFO'], ignore, env['QUERY_STRING'] = (path % match.groupdict()).partition('?')
83 | for name, value in [x.split('=', 1) for x in route[2:]]:
84 | env[name] = value % match.groupdict()
85 | env['wsgiorg.routing_args'].update(match.groupdict())
86 |
87 | if app is not None:
88 | matching = match.group(0)
89 | env['PATH_INFO'], env['SCRIPT_NAME'] = env['PATH_INFO'][len(matching):], env['SCRIPT_NAME'] + env['PATH_INFO'][:len(matching)]
90 | def my_response(status, headers):
91 | if 'RESPONSE_HEADERS' not in env: env['RESPONSE_STATUS'], env['RESPONSE_HEADERS'] = status, headers
92 | try: response = app(env, my_response)
93 | except Status: response, env['RESPONSE_STATUS'] = None, str(sys.exc_info()[1])
94 | except:
95 | if _debug: print traceback.format_exc()
96 | response, env['RESPONSE_STATUS'] = [traceback.format_exc()], '500 Internal Server Error'
97 | if response is None: response = []
98 | headers = env.get('RESPONSE_HEADERS', [('Content-Type', 'text/plain')])
99 | orig = Cookie.SimpleCookie(); cookie = env['COOKIE']
100 | if 'HTTP_COOKIE' in env: orig.load(env['HTTP_COOKIE'])
101 | map(lambda x: cookie.__delitem__(x), [x for x in orig if x in cookie and str(orig[x]) == str(cookie[x])])
102 | if len(cookie): headers.extend([(x[0], x[1].strip()) for x in [str(y).split(':', 1) for y in cookie.itervalues()]])
103 | if 'HTTP_ORIGIN' in env:
104 | headers += [('Access-Control-Allow-Origin', env['HTTP_ORIGIN']), ('Access-Control-Allow-Credentials', 'true')]
105 | start_response(env.get('RESPONSE_STATUS', '200 OK'), headers)
106 | if _debug:
107 | if response: print headers, '\n'+str(response)[:256]
108 | return response
109 |
110 | headers = [('Content-Type', 'text/plain')]
111 | if 'HTTP_ORIGIN' in env:
112 | headers += [('Access-Control-Allow-Origin', env['HTTP_ORIGIN']), ('Access-Control-Allow-Credentials', 'true')]
113 | if env['REQUEST_METHOD'] == 'OPTIONS':
114 | # headers += [('Access-Control-Allow-Methods', env['HTTP_ACCESS_CONTROL_REQUEST_METHOD']), ('Access-Control-Allow-Headers', env['HTTP_ACCESS_CONTROL_REQUEST_HEADERS'])]
115 | headers += [('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE'), ('Access-Control-Allow-Headers', 'content-type, authorization')]
116 | start_response('200 OK', headers)
117 | return []
118 | start_response('404 Not Found', headers)
119 | return ['Use one of these URL forms\n ' + '\n '.join(str(x[0]) for x in routes)]
120 |
121 | return handler
122 |
123 | #------------------------------------------------------------------------------
124 | # Representations: JSON, XML
125 | #------------------------------------------------------------------------------
126 |
127 | def tojson(value):
128 | '''The function converts the supplied value to JSON representation. It assumes the unified list format of value.
129 | Typically you just call represent(value, type=request['ACCEPT']) instead of manually invoking this method.
130 | To be consistent with str(obj) function which uses obj.__str__() method if available, tojson() uses obj._json_()
131 | method if available on value. Otherwise it checks obj._list_() method if available to get the unified list format.
132 | Otherwise it assumes that the value is in unified list format. The _json_ and _list_ semantics allow you to
133 | customize the JSON representation of your object, if needed.
134 |
135 | >>> value = ('file', (('name', 'myfile.txt'), ('acl', [('allow', 'kundan'), ('allow', 'admin')])))
136 | >>> tojson(value)
137 | '{"file": {"name": "myfile.txt", "acl": [{"allow": "kundan"}, {"allow": "admin"}]}}'
138 | '''
139 | def list2dict(value):
140 | if hasattr(value, '_json_') and callable(value._json_): return value._json_()
141 | if hasattr(value, '_list_') and callable(value._list_): value = value._list_()
142 | if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], basestring):
143 | if isinstance(value[1], list):
144 | return {value[0]: [list2dict(x) for x in value[1]]}
145 | elif isinstance(value[1], tuple) and not [x for x in value[1] if not isinstance(x, tuple) or len(x) != 2 or not isinstance(x[0], basestring)]:
146 | return {value[0]: dict([(x[0], list2dict(x[1])) for x in value[1]])}
147 | else:
148 | return {value[0]: list2dict(value[1])}
149 | elif isinstance(value, tuple) and not [x for x in value if not isinstance(x, tuple) or len(x) != 2 or not isinstance(x[0], basestring)]:
150 | return dict([(x[0], list2dict(x[1])) for x in value])
151 | elif isinstance(value, list):
152 | return [list2dict(x) for x in value]
153 | else:
154 | return value
155 | return json.dumps(list2dict(value))
156 |
157 | def xml(value):
158 | '''The function converts the supplied value to XML representation. It assumes the unified list format of value.
159 | Typically you just call represent(value, type=request['ACCEPT']) instead of manually invoking this method.
160 | To be consistent with str(obj) function which uses obj.__str__() method if available, xml() uses obj._xml_()
161 | method if available on value. Otherwise it checks obj._list_() method if available to get the unified list format.
162 | Otherwise it assumes that the value is in unified list format. The _xml_ and _list_ semantics allow you to
163 | customize the XML representation of your object, if needed.
164 |
165 | >>> value = ('file', (('name', 'myfile.txt'), ('acl', [('allow', 'kundan'), ('allow', 'admin')])))
166 | >>> xml(value)
167 | 'myfile.txtkundanadmin'
168 | '''
169 | if hasattr(value, '_xml_') and callable(value._xml_): return value._xml_()
170 | if hasattr(value, '_list_') and callable(value._list_): value = value._list_()
171 | if isinstance(value, tuple) and len(value) == 2 and isinstance(value[0], basestring):
172 | if value[1] is None: return '<%s />'%(value[0])
173 | else: return '<%s>%s%s>'%(value[0], xml(value[1]), value[0])
174 | elif isinstance(value, list) or isinstance(value, tuple):
175 | return ''.join(xml(x) for x in value)
176 | else:
177 | return str(value) if value is not None else None
178 |
179 | def prettyxml(value):
180 | '''This function is similar to xml except that it invokes minidom's toprettyxml() function. Note that due to the
181 | addition of spaces even in text nodes of prettyxml result, you cannot use this reliably for structured data
182 | representation, and should use only for debug trace of XML.
183 | '''
184 | return minidom.parseString(xml(value)).toprettyxml().encode('utf-8')
185 |
186 | def represent(value, type='*/*'):
187 | '''You can use this method to convert a unified value to JSON, XML or text based on the type. The JSON representation
188 | is preferred if type is default, otherwise the type values of "application/json", "text/xml" and
189 | "text/plain" map to tojson, xml and str functions, respectively. If you would like to customize the representation of
190 | your object, you can define _json_(), _xml_() and/or __str__() methods on your object. Note that _json_ and _xml_
191 | fall back to _list_ if available for getting the unified list representation, and __str__ falls back to __repr__ if
192 | available. The return value is a tuple containing type and value.
193 |
194 | >>> class user:
195 | ... def __init__(self, name): self.name = name
196 | ... def _list_(self): return ('allow', self.name)
197 | >>> u1, u2 = user('kundan'), user('admin')
198 | >>> value = ('file', (('name', 'myfile.txt'), ('acl', [u1, u2])))
199 | >>> represent(value, type='application/json')[1]
200 | '{"file": {"name": "myfile.txt", "acl": [{"allow": "kundan"}, {"allow": "admin"}]}}'
201 | >>> represent(value, type='text/xml')[1]
202 | 'myfile.txtkundanadmin'
203 | '''
204 | types = map(lambda x: x.lower(), re.split(r'[, \t]+', type))
205 | if '*/*' in types: types.append(defaultType)
206 | for type, func in (('application/json', tojson), ('text/xml', xml), ('text/plain', str)):
207 | if type in types: return (type, func(value))
208 | return ('application/octet-stream', str(value))
209 |
210 | #------------------------------------------------------------------------------
211 | # High Level API: @resources
212 | #------------------------------------------------------------------------------
213 |
214 | class Request(dict):
215 | '''A request object is supplied to the resource definition in various methods: GET, PUT, POST, DELETE.
216 | It is a dictionary containing env information. Additionally, all the matching attributes from the router are
217 | stored as properties of this object, extracted from env['wsgiorg.routing_args'].'''
218 | def __init__(self, env, start_response):
219 | self.update(env.iteritems())
220 | self.__dict__.update(env.get('wsgiorg.routing_args', {}))
221 | self.start_response = start_response
222 | def response(self, value, type=None, status='200 OK'):
223 | type, result = represent(value, type if type is not None else self.get('ACCEPT', defaultType))
224 | self.start_response(status, [('Content-Type', type)])
225 | return result
226 |
227 | class Status(Exception):
228 | '''The exception object that is used to throw HTTP response exception, e.g., raise Status, '404 Not Found'.
229 | The resource definition can throw this exception.
230 | '''
231 |
232 | def resource(func):
233 | '''A decorator to convert a function with nested function GET, PUT, POST and/or DELETE to a resource. The resource
234 | object allows you to write applications in high-level semantics and translate it to wsgiref compatible handler that
235 | is handled the router. The GET and DELETE methods take one argument (request) of type Request, whereas PUT and POST
236 | take additional argument (first is request of type Request, and second is) entity extracted from message body.
237 | Note that the function definition that is made as a resource, must have a "return locals()" at the end so that all
238 | the methods GET, PUT, POST and/or DELETE are returned when function is called with no arguments.
239 |
240 | >>> @resource
241 | ... def files():
242 | ... def GET(request):
243 | ... return represent(('files', [('file', 'myfile.txt')]), type='text/xml')[1]
244 | ... def PUT(request, entity):
245 | ... pass
246 | ... return locals()
247 | >>> # test using the following code
248 | >>> env, start_response = {'REQUEST_METHOD': 'GET', 'PATH_INFO': '/xml/files', 'SCRIPT_NAME': '', 'QUERY_STRING': ''}, lambda x,y: (x, y)
249 | >>> print files(env, start_response)
250 | ['myfile.txt']
251 | '''
252 | method_funcs = func()
253 | if method_funcs is None:
254 | raise Status, '500 No "return locals()" in the definition of resource "%r"'%(func.__name__)
255 | def handler(env, start_response):
256 | if env['REQUEST_METHOD'] not in method_funcs:
257 | raise Status, '405 Method Not Allowed'
258 |
259 | req = Request(env, start_response)
260 | if env['REQUEST_METHOD'] in ('GET', 'HEAD', 'DELETE'):
261 | result = method_funcs[env['REQUEST_METHOD']](req)
262 | elif env['REQUEST_METHOD'] in ('POST', 'PUT'):
263 | if 'BODY' not in env:
264 | try: env['BODY'] = env['wsgi.input'].read(int(env['CONTENT_LENGTH']))
265 | except (TypeError, ValueError): raise Status, '400 Invalid Content-Length'
266 | if env['CONTENT_TYPE'].lower() == 'application/json' and env['BODY']:
267 | try: env['BODY'] = json.loads(env['BODY'])
268 | except: raise Status, '400 Invalid JSON content'
269 | result = method_funcs[env['REQUEST_METHOD']](req, entity=env['BODY'])
270 | return [result] if result is not None else []
271 | return handler
272 |
273 | def bind(obj):
274 | '''Bind the given object to a resource. It returns a wsgiref compliant application for that resource.
275 | Suppose an object obj={'kundan': user1, 'singh': user2} is bound to a resource '/users'
276 | then GET, PUT, POST and DELETE are implemented on that obj as
277 | 'GET /users' returns the obj description with its properties and methods.
278 | 'GET /users/kundan' returns the user1 object description.
279 | 'PUT /users/kundan' replaces user1 with the supplied value.
280 | 'POST /users' adds a new property, attribute or list element.
281 | '''
282 | def handler(env, start_response):
283 | current, result = obj, None
284 | if env['REQUEST_METHOD'] == 'GET':
285 | while env['PATH_INFO']:
286 | print 'path=', env['PATH_INFO']
287 | part, index = None, env['PATH_INFO'].find('/', 1)
288 | if index < 0: index = len(env['PATH_INFO'])
289 | part, env['SCRIPT_NAME'], env['PATH_INFO'] = env['PATH_INFO'][1:index], env['SCRIPT_NAME'] + env['PATH_INFO'][:index], env['PATH_INFO'][index:]
290 | if not part: break
291 | if current is None: raise Status, '404 Object Not Found'
292 | try: current = current[int(part)] if isinstance(current, list) else current[part] if isinstance(current, dict) else current.__dict__[part] if hasattr(current, part) else None
293 | except: print sys.exc_info(); raise Status, '400 Invalid Scope %r'%(part,)
294 | if current is None: result = None
295 | elif isinstance(current, list): result = [('url', '%s/%d'%(env['SCRIPT_NAME'], i,)) for i in xrange(len(current))]
296 | elif isinstance(current, dict): result = tuple([(k, v if isinstance(v, basestring) else '%s/%s'%(env['SCRIPT_NAME'], k)) for k, v in current.iteritems()])
297 | else:result = current
298 | type, value = represent(('result', result), type=env.get('ACCEPT', 'application/json'))
299 | headers = [('Content-Type', type)]
300 | #if 'HTTP_ORIGIN' in env:
301 | # headers += [('Access-Control-Allow-Origin', env['HTTP_ORIGIN']), ('Access-Control-Allow-Credentials', 'true')]
302 | start_response('200 OK', headers)
303 | return [value]
304 | else: raise Status, '405 Method Not Allowed'
305 | return handler
306 |
307 | #------------------------------------------------------------------------------
308 | # Data Model with sqlite3
309 | #------------------------------------------------------------------------------
310 |
311 | class Model(dict):
312 | '''A data model that abstracts the SQL table creation and uses sqlite3. Instead of defining a ORM (object-relation
313 | mapping), this just lets the application handle the SQL commands. The only convenience of this class is to allow
314 | creating the SQL tables using text description of the data model, define python class for each table that can
315 | constructed using all the values of a row of that table, and define sql and sql1 convenience methods.
316 |
317 | >>> desc = """
318 | ... user
319 | ... id integer primary key
320 | ... name text
321 | ...
322 | ... files
323 | ... id integer primary key
324 | ... name text not null
325 | ... owner int
326 | ... created datetime
327 | ... size int default 0
328 | ... foreign key (owner) references user(id)
329 | ... """
330 | >>> m1 = Model()
331 | >>> m1.create(desc)
332 | >>> c1 = m1.sql('INSERT INTO user VALUES (NULL, ?)', ('Kundan Singh',))
333 | >>> c2 = m1.sql('INSERT INTO user VALUES (NULL, ?)', ('Alok Singh',))
334 | >>> row = m1.sql1('SELECT * FROM user WHERE id=?', (1,))
335 | >>> u1 = m1['user'](*row)
336 | >>> print u1
337 | 'id'=1, 'name'=u'Kundan Singh'
338 | >>> print u1._list_()
339 | ('user', (('id', 1), ('name', u'Kundan Singh')))
340 | >>> print 'table=%r attrs=%r properties=%r'%(u1.__class__._table_, u1.__class__._attrs_, u1.__dict__)
341 | table='user' attrs=['id', 'name'] properties={'id': 1, 'name': u'Kundan Singh'}
342 | '''
343 | def __init__(self, conn=None):
344 | '''Construct the model using optional sqlite3 connection. If missing, use a in-memory database.'''
345 | if conn is None:
346 | self.conn = sqlite3.connect(':memory:')
347 | else:
348 | self.conn = conn
349 | self.conn.isolation_level = None # sql and sql1 assumes autocommit mode
350 |
351 | def close(self):
352 | '''Close the connection with the database.'''
353 | self.conn.close()
354 | self.conn = None
355 |
356 | def sql(self, *args):
357 | '''Execute a single SQL command and return the cursor. For select commands application should use the
358 | cursor as an iterator, or invoke fetchone or fetchall as applicable.'''
359 | if _debug: print 'SQL: ' + ': '.join(map(str, args))
360 | return self.conn.execute(*args)
361 |
362 | def sql1(self, *args):
363 | '''Execute a single SELECT SQL command and return a single row of the result.'''
364 | return self.sql(*args).fetchone()
365 |
366 | def create(self, data_model, createTable=True, createType=True):
367 | '''Create the SQL tables using the data_model text description. An example text description is shown below. It
368 | defines two tables, user and files. Note that the primary key of id must be defined as "integer" instead of
369 | "int" or other variation for auto-increment of the id to work.
370 | '''
371 | # list of tuples (table-name, [list of attributes])
372 | tables = [(x[0], [y.strip() for y in x[1:]]) for x in (z.split('\n') for z in re.split(r'\r?\n\r?\n', re.sub(r'[ \t]{2,}', ' ', '\n'.join(filter(lambda x: not x.lstrip() or x.lstrip()[0] not in ('#', '@'), map(str.rstrip, data_model.strip().split('\n')))))))]
373 | if createTable:
374 | for t in tables:
375 | try: self.sql("CREATE TABLE IF NOT EXISTS %s (%s)"%(t[0], ', '.join(t[1])))
376 | except:
377 | if _debug: traceback.print_exc() # ignore if already exists
378 | if createType:
379 | for name, attrs in tables:
380 | class klass(object):
381 | _defn_ = [(y, z) for y, z in (x.split(' ', 1) for x in attrs) if y.lower() not in ('foreign', 'primary', 'key')]
382 | __doc__ = name + '\n ' + '\n '.join(['%s\t%s'%(x, y) for x, y in _defn_])
383 | __doc__ = name + '\n ' + '\n '.join([x.strip() for x in attrs])
384 | _table_, _attrs_, _defn_ = name, [x for x, y in _defn_], [y for x, y in _defn_]
385 | def __init__(self, *args, **kwargs):
386 | keys = self.__class__._attrs_
387 | for x in keys: self.__dict__[x] = None
388 | for x, y in zip(keys[:len(args)], args): self.__dict__[x] = y
389 | for k, v in kwargs.iteritems(): self.__dict__[k] = v
390 | def __str__(self):
391 | return ', '.join(['%r=%r'%(x, self.__dict__[x]) for x in self.__class__._attrs_ if x in self.__dict__])
392 | def _list_(self):
393 | return (self.__class__._table_, tuple((k, self.__dict__[k]) for k in self.__class__._attrs_ if k in self.__dict__))
394 | def as_dict(self, exclude=None):
395 | return dict([(k, self.__dict__[k]) for k in self.__class__._attrs_ if k in self.__dict__ and (exclude is None or k not in exclude)])
396 | self[name] = klass
397 |
398 | #------------------------------------------------------------------------------
399 | # Authentication
400 | #------------------------------------------------------------------------------
401 |
402 | _loginTable = '''
403 | user_login
404 | id integer primary key
405 | email text not null
406 | realm text not null
407 | hash tinyblob(32) not null
408 | token tinyblob(32)
409 | '''
410 |
411 | class AuthModel(Model):
412 | '''Authenticated Model class, which creates a database table of type user_login and uses that to provide various
413 | authentication methods.'''
414 | def __init__(self, conn=None):
415 | Model.__init__(self, conn)
416 | self.mypass = hashlib.md5(str(id(self)) + str(time.time())).hexdigest()
417 | self.create(_loginTable)
418 |
419 | def hash(self, email, realm, password):
420 | return hashlib.md5('%s:%s:%s'%(email, realm, password)).hexdigest()
421 |
422 | def token(self, user_id):
423 | tm = '%010x'%(int(time.time()),)
424 | return hashlib.md5(self.mypass + str(user_id) + tm).hexdigest() + tm
425 |
426 | def valid(self, user_id, token):
427 | hash, tm = token[:-10], token[-10:]
428 | return hashlib.md5(self.mypass + str(user_id) + tm).hexdigest() == hash
429 |
430 | def registered(self, email, realm):
431 | found = self.sql1('SELECT id, hash FROM user_login WHERE email=? AND realm=?', (email, realm))
432 | return bool(found)
433 |
434 | def __len__(self):
435 | found = self.sql1('SELECT count(*) FROM user_login')
436 | return found and int(found[0])
437 |
438 | def register(self, email, realm, password='', hash=None):
439 | if not hash: hash = self.hash(email, realm, password)
440 | found = self.sql1('SELECT id, hash FROM user_login WHERE email=? AND realm=?', (email, realm))
441 | if not found:
442 | self.sql('INSERT INTO user_login VALUES (NULL, ?, ?, ?, NULL)', (email, realm, hash))
443 | user_id = self.sql1('SELECT last_insert_rowid()')[0]
444 | self.sql('UPDATE user_login SET token=? WHERE id=?', (self.token(user_id), user_id))
445 | else:
446 | user_id = found[0]
447 | if found[1] != hash:
448 | self.sql('UPDATE user_login SET hash=? WHERE id=?', (hash, user_id))
449 | return user_id
450 |
451 | def login(self, request):
452 | hdr = request.get('HTTP_AUTHORIZATION', None)
453 | if hdr:
454 | method, value = map(str.strip, hdr.split(' ', 1))
455 | if method == 'Basic':
456 | email, password = base64.b64decode(value).split(':', 1)
457 | found = self.sql1('SELECT id, hash FROM user_login WHERE email=?', (email,))
458 | if not found:
459 | request.start_response('401 Unauthorized', [('WWW-Authenticate', 'Basic realm="%s"'%('localhost',))])
460 | raise Status, '401 Not Found'
461 | user_id, hash = found;
462 | realm = "localhost" # TODO: implement this
463 | hash_recv = self.hash(email, realm, password)
464 | if hash != hash_recv:
465 | request.start_response('401 Unauthorized', [('WWW-Authenticate', 'Basic realm="%s"'%(realm,))])
466 | raise Status, '401 Unauthorized'
467 | token = self.token(user_id)
468 | self.sql('UPDATE user_login SET token=? WHERE id=?', (token, user_id))
469 | request['COOKIE']['token'] = token; request['COOKIE']['token']['path'] = '/'
470 | request['COOKIE']['user_id'] = user_id; request['COOKIE']['user_id']['path'] = '/'
471 | return (user_id, email, token)
472 | elif (hasattr(request, 'user_id') or hasattr(request, 'email')) and hasattr(request, 'token'):
473 | if request.email == 'admin':
474 | adminhash = hashlib.md5('%s::%s'%(request.email, self.mypass)).hexdigest()
475 | print request.token, adminhash
476 | if adminhash != request.token: raise Status, '401 Not Authorized'
477 | user_id, email, token = 0, request.email, adminhash
478 | else:
479 | found = self.sql1('SELECT id, email, token FROM user_login WHERE (id=? OR email=?) AND (token=? OR hash=?)', (request.user_id, request.email, request.token, request.token))
480 | if not found:
481 | if not self.sql1('SELECT id FROM user_login WHERE id=? OR email=?', (request.user_id, request.email)):
482 | raise Status, '404 Not Found'
483 | else:
484 | raise Status, '401 Unauthorized'
485 | user_id, email, token = int(found[0]), found[1], found[2]
486 | if token != request.token:
487 | token = self.token(user_id)
488 | self.sql('UPDATE user_login SET token=? WHERE id=?', (token, user_id))
489 | request['COOKIE']['token'] = token; request['COOKIE']['token']['path'] = '/'
490 | request['COOKIE']['user_id'] = user_id; request['COOKIE']['user_id']['path'] = '/'
491 | return (user_id, email, token)
492 | elif 'COOKIE' in request and 'user_id' in request['COOKIE'] and 'token' in request['COOKIE']:
493 | user_id, token = int(request['COOKIE'].get('user_id').value), request['COOKIE'].get('token').value
494 | if user_id == 0:
495 | email = 'admin'; hash = hashlib.md5('%s::%s'%(email, self.mypass)).hexdigest()
496 | if hash != token:
497 | raise Status, '401 Not Authorized as Admin'
498 | else:
499 | found = self.sql1('SELECT email FROM user_login WHERE id=? AND token=?', (user_id, token))
500 | if not found:
501 | request['COOKIE']['user_id']['expires'] = 0
502 | request['COOKIE']['user_id']['path'] = '/'
503 | request['COOKIE']['token']['expires'] = 0
504 | request['COOKIE']['token']['path'] = '/'
505 | realm = "localhost"
506 | request.start_response('401 Unauthorized', [('WWW-Authenticate', 'Basic realm="%s"'%(realm,))])
507 | raise Status, '401 Unauthorized'
508 | email = found[0]
509 | return (user_id, email, token)
510 | else:
511 | realm = "localhost"
512 | request.start_response('401 Unauthorized', [('WWW-Authenticate', 'Basic realm="%s"'%(realm,))])
513 | raise Status, '401 Unauthorized'
514 |
515 | def logout(self, request):
516 | if 'COOKIE' in request and 'user_id' in request['COOKIE'] and 'token' in request['COOKIE']:
517 | user_id, token = request['COOKIE']['user_id'].value, request['COOKIE']['token'].value
518 | request['COOKIE']['token']['expires'] = request['COOKIE']['user_id']['expires'] = 0
519 | request['COOKIE']['token']['path'] = request['COOKIE']['user_id']['path'] = '/'
520 |
521 | if user_id != 0:
522 | self.sql('UPDATE user_login SET token=NULL WHERE id=? AND token=?', (user_id, token))
523 |
524 | #------------------------------------------------------------------------------
525 | # Test and Examples
526 | #------------------------------------------------------------------------------
527 |
528 | if __name__ == '__main__':
529 | import doctest
530 | _debug = False
531 | doctest.testmod()
532 |
--------------------------------------------------------------------------------