├── .gitignore ├── .travis.yml ├── LICENSE ├── MANIFEST.in ├── Pipfile ├── Pipfile.lock ├── README.rst ├── changes.rst ├── demos ├── README.rst ├── TODO.rst ├── angular_cors │ ├── README.rst │ ├── app.js │ ├── app.py │ ├── controllers.js │ ├── edit.partial.html │ ├── index.html │ ├── lib │ │ ├── angular-resource.min.js │ │ ├── angular-route.min.js │ │ ├── angular.min.js │ │ ├── bootstrap.min.css │ │ └── lodash.min.js │ ├── list.partial.html │ └── view.partial.html ├── angular_helloworld │ ├── README.rst │ ├── app.js │ ├── app.py │ ├── index.html │ └── lib │ │ ├── angular.min.js │ │ └── bootstrap.min.css ├── angular_resource │ ├── README.rst │ ├── app.js │ ├── app.py │ ├── controllers.js │ ├── edit.partial.html │ ├── index.html │ ├── lib │ │ ├── angular-resource.min.js │ │ ├── angular-route.min.js │ │ ├── angular.min.js │ │ ├── bootstrap.min.css │ │ └── lodash.min.js │ ├── list.partial.html │ └── view.partial.html ├── angular_security │ ├── README.rst │ ├── app.js │ ├── app.py │ ├── controllers.js │ ├── edit.partial.html │ ├── index.html │ ├── lib │ │ ├── angular-resource.min.js │ │ ├── angular-route.min.js │ │ ├── angular.min.js │ │ ├── bootstrap.min.css │ │ └── lodash.min.js │ ├── list.partial.html │ └── view.partial.html ├── angular_todo │ ├── README.rst │ ├── app.js │ ├── app.py │ ├── controllers.js │ ├── edit.partial.html │ ├── index.html │ ├── lib │ │ ├── angular-route.min.js │ │ ├── angular.min.js │ │ ├── bootstrap.min.css │ │ └── lodash.min.js │ ├── list.partial.html │ └── view.partial.html └── macauth_demo.py ├── doc ├── Makefile ├── api.rst ├── basics.rst ├── changes.rst ├── comparison.rst ├── conf.py ├── index.rst ├── philosophy.rst ├── security.rst └── sql.rst ├── pytest.ini ├── setup.cfg ├── setup.py ├── src └── rest_toolkit │ ├── __init__.py │ ├── abc.py │ ├── compat.py │ ├── error.py │ ├── ext │ ├── __init__.py │ ├── colander.py │ ├── jsonschema.py │ └── sql.py │ ├── state.py │ ├── utils.py │ └── views.py └── tests ├── controller.py ├── ext ├── __init__.py ├── test_colander.py ├── test_jsonschema.py └── test_sql.py ├── resource_abc.py ├── resource_abc_override.py ├── resource_error.py ├── resource_get.py ├── resource_get_renderer.py ├── resource_only.py ├── resource_route_name.py ├── resource_sql.py ├── test_error.py ├── test_resource.py └── test_utils.py /.gitignore: -------------------------------------------------------------------------------- 1 | *.pyc 2 | .*.sw[po] 3 | /build 4 | /dist 5 | /doc/_build 6 | /env* 7 | 8 | *.egg-info 9 | 10 | /.Python 11 | /bin 12 | /include 13 | /lib 14 | /pip-selfcheck.json 15 | 16 | /pyvenv.cfg 17 | 18 | /.idea 19 | /.cache 20 | /.pytest_cache 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | dist: xenial 3 | python: 4 | - "3.6" 5 | - "3.7" 6 | install: 7 | - pip install pipenv 8 | - pipenv sync --dev 9 | script: py.test 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2014, Wichert Akkerman 2 | All rights reserved. 3 | 4 | Redistribution and use in source and binary forms, with or without 5 | modification, are permitted provided that the following conditions 6 | are met: 7 | 8 | * Redistributions of source code must retain the above copyright 9 | notice, this list of conditions and the following disclaimer. 10 | 11 | * Redistributions in binary form must reproduce the above 12 | copyright notice, this list of conditions and the following 13 | disclaimer in the documentation and/or other materials provided 14 | with the distribution. 15 | 16 | THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 17 | "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 18 | LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 19 | A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 20 | OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 21 | SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 22 | LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 23 | DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 24 | THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 25 | (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 26 | OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. 27 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.rst 2 | include *.txt 3 | recursive-include tests *.py 4 | graft doc 5 | prune doc/_build 6 | -------------------------------------------------------------------------------- /Pipfile: -------------------------------------------------------------------------------- 1 | [[source]] 2 | url = "https://pypi.python.org/simple" 3 | verify_ssl = true 4 | name = "pypi" 5 | 6 | [packages] 7 | "e1839a8" = {path = ".", editable = true} 8 | 9 | [dev-packages] 10 | "flake8" = "*" 11 | pytest = "*" 12 | webtest = "*" 13 | colander = "*" 14 | jsonschema = "*" 15 | pyramid-sqlalchemy = "*" 16 | pyramid-tm = "*" 17 | -------------------------------------------------------------------------------- /Pipfile.lock: -------------------------------------------------------------------------------- 1 | { 2 | "_meta": { 3 | "hash": { 4 | "sha256": "46c35f0acc95e48909aa73e81fa3da66384dbd822ad16b09f903b7511bb26c95" 5 | }, 6 | "pipfile-spec": 6, 7 | "requires": {}, 8 | "sources": [ 9 | { 10 | "name": "pypi", 11 | "url": "https://pypi.python.org/simple", 12 | "verify_ssl": true 13 | } 14 | ] 15 | }, 16 | "default": { 17 | "e1839a8": { 18 | "editable": true, 19 | "path": "." 20 | }, 21 | "hupper": { 22 | "hashes": [ 23 | "sha256:3b1c2222ec7b8159e7ad059e4493c6cc634c86184af0bf2ce5aba6edd241cf5f", 24 | "sha256:5ab839f9428dd2d993092193ad0032f968eae007873916794c3856131b2df112" 25 | ], 26 | "version": "==1.9.1" 27 | }, 28 | "pastedeploy": { 29 | "hashes": [ 30 | "sha256:d423fb9d51fdcf853aa4ff43ac7ec469b643ea19590f67488122d6d0d772350a", 31 | "sha256:fe53697ec2754703096b75d0ba29112b0590b4ce46726fe4f9408fd006e4eefc" 32 | ], 33 | "version": "==2.0.1" 34 | }, 35 | "plaster": { 36 | "hashes": [ 37 | "sha256:215c921a438b5349931fd7df9a5a11a3572947f20f4bc6dd622ac08f1c3ba249", 38 | "sha256:8351c7c7efdf33084c1de88dd0f422cbe7342534537b553c49b857b12d98c8c3" 39 | ], 40 | "version": "==1.0" 41 | }, 42 | "plaster-pastedeploy": { 43 | "hashes": [ 44 | "sha256:391d93a4e1ff81fc3bae27508ebb765b61f1724ae6169f83577f06b6357be7fd", 45 | "sha256:7c8aa37c917b615c70bf942b24dc1e0455c49f62f1a2214b1a0dd98871644bbb" 46 | ], 47 | "version": "==0.7" 48 | }, 49 | "pyramid": { 50 | "hashes": [ 51 | "sha256:51bf64647345237c00d2fe558935e0e4938c156e29f17e203457fd8e1d757dc7", 52 | "sha256:d80ccb8cfa550139b50801591d4ca8a5575334adb493c402fce2312f55d07d66" 53 | ], 54 | "version": "==1.10.4" 55 | }, 56 | "translationstring": { 57 | "hashes": [ 58 | "sha256:4ee44cfa58c52ade8910ea0ebc3d2d84bdcad9fa0422405b1801ec9b9a65b72d", 59 | "sha256:e26c7bf383413234ed442e0980a2ebe192b95e3745288a8fd2805156d27515b4" 60 | ], 61 | "version": "==1.3" 62 | }, 63 | "venusian": { 64 | "hashes": [ 65 | "sha256:06e7385786ad3a15c70740b2af8d30dfb063a946a851dcb4159f9e2a2302578f", 66 | "sha256:f6842b7242b1039c0c28f6feef29016e7e7dd3caaeb476a193acf737db31ee38" 67 | ], 68 | "version": "==3.0.0" 69 | }, 70 | "webob": { 71 | "hashes": [ 72 | "sha256:05aaab7975e0ee8af2026325d656e5ce14a71f1883c52276181821d6d5bf7086", 73 | "sha256:36db8203c67023d68c1b00208a7bf55e3b10de2aa317555740add29c619de12b" 74 | ], 75 | "version": "==1.8.5" 76 | }, 77 | "zope.deprecation": { 78 | "hashes": [ 79 | "sha256:0d453338f04bacf91bbfba545d8bcdf529aa829e67b705eac8c1a7fdce66e2df", 80 | "sha256:f1480b74995958b24ce37b0ef04d3663d2683e5d6debc96726eff18acf4ea113" 81 | ], 82 | "version": "==4.4.0" 83 | }, 84 | "zope.interface": { 85 | "hashes": [ 86 | "sha256:048b16ac882a05bc7ef534e8b9f15c9d7a6c190e24e8938a19b7617af4ed854a", 87 | "sha256:05816cf8e7407cf62f2ec95c0a5d69ec4fa5741d9ccd10db9f21691916a9a098", 88 | "sha256:065d6a1ac89d35445168813bed45048ed4e67a4cdfc5a68fdb626a770378869f", 89 | "sha256:14157421f4121a57625002cc4f48ac7521ea238d697c4a4459a884b62132b977", 90 | "sha256:18dc895945694f397a0be86be760ff664b790f95d8e7752d5bab80284ff9105d", 91 | "sha256:1962c9f838bd6ae4075d0014f72697510daefc7e1c7e48b2607df0b6e157989c", 92 | "sha256:1a67408cacd198c7e6274a19920bb4568d56459e659e23c4915528686ac1763a", 93 | "sha256:21bf781076dd616bd07cf0223f79d61ab4f45176076f90bc2890e18c48195da4", 94 | "sha256:21c0a5d98650aebb84efa16ce2c8df1a46bdc4fe8a9e33237d0ca0b23f416ead", 95 | "sha256:23cfeea25d1e42ff3bf4f9a0c31e9d5950aa9e7c4b12f0c4bd086f378f7b7a71", 96 | "sha256:24b6fce1fb71abf9f4093e3259084efcc0ef479f89356757780685bd2b06ef37", 97 | "sha256:24f84ce24eb6b5fcdcb38ad9761524f1ae96f7126abb5e597f8a3973d9921409", 98 | "sha256:25e0ef4a824017809d6d8b0ce4ab3288594ba283e4d4f94d8cfb81d73ed65114", 99 | "sha256:2e8fdd625e9aba31228e7ddbc36bad5c38dc3ee99a86aa420f89a290bd987ce9", 100 | "sha256:2f3bc2f49b67b1bea82b942d25bc958d4f4ea6709b411cb2b6b9718adf7914ce", 101 | "sha256:35d24be9d04d50da3a6f4d61de028c1dd087045385a0ff374d93ef85af61b584", 102 | "sha256:35dbe4e8c73003dff40dfaeb15902910a4360699375e7b47d3c909a83ff27cd0", 103 | "sha256:3dfce831b824ab5cf446ed0c350b793ac6fa5fe33b984305cb4c966a86a8fb79", 104 | "sha256:3f7866365df5a36a7b8de8056cd1c605648f56f9a226d918ed84c85d25e8d55f", 105 | "sha256:455cc8c01de3bac6f9c223967cea41f4449f58b4c2e724ec8177382ddd183ab4", 106 | "sha256:4bb937e998be9d5e345f486693e477ba79e4344674484001a0b646be1d530487", 107 | "sha256:52303a20902ca0888dfb83230ca3ee6fbe63c0ad1dd60aa0bba7958ccff454d8", 108 | "sha256:6e0a897d4e09859cc80c6a16a29697406ead752292ace17f1805126a4f63c838", 109 | "sha256:6e1816e7c10966330d77af45f77501f9a68818c065dec0ad11d22b50a0e212e7", 110 | "sha256:73b5921c5c6ce3358c836461b5470bf675601c96d5e5d8f2a446951470614f67", 111 | "sha256:8093cd45cdb5f6c8591cfd1af03d32b32965b0f79b94684cd0c9afdf841982bb", 112 | "sha256:864b4a94b60db301899cf373579fd9ef92edddbf0fb2cd5ae99f53ef423ccc56", 113 | "sha256:8a27b4d3ea9c6d086ce8e7cdb3e8d319b6752e2a03238a388ccc83ccbe165f50", 114 | "sha256:91b847969d4784abd855165a2d163f72ac1e58e6dce09a5e46c20e58f19cc96d", 115 | "sha256:b47b1028be4758c3167e474884ccc079b94835f058984b15c145966c4df64d27", 116 | "sha256:b68814a322835d8ad671b7acc23a3b2acecba527bb14f4b53fc925f8a27e44d8", 117 | "sha256:bcb50a032c3b6ec7fb281b3a83d2b31ab5246c5b119588725b1350d3a1d9f6a3", 118 | "sha256:c56db7d10b25ce8918b6aec6b08ac401842b47e6c136773bfb3b590753f7fb67", 119 | "sha256:c94b77a13d4f47883e4f97f9fa00f5feadd38af3e6b3c7be45cfdb0a14c7149b", 120 | "sha256:db381f6fdaef483ad435f778086ccc4890120aff8df2ba5cfeeac24d280b3145", 121 | "sha256:e6487d01c8b7ed86af30ea141fcc4f93f8a7dde26f94177c1ad637c353bd5c07", 122 | "sha256:e86923fa728dfba39c5bb6046a450bd4eec8ad949ac404eca728cfce320d1732", 123 | "sha256:f6ca36dc1e9eeb46d779869c60001b3065fb670b5775c51421c099ea2a77c3c9", 124 | "sha256:fb62f2cbe790a50d95593fb40e8cca261c31a2f5637455ea39440d6457c2ba25" 125 | ], 126 | "version": "==4.7.1" 127 | } 128 | }, 129 | "develop": { 130 | "atomicwrites": { 131 | "hashes": [ 132 | "sha256:03472c30eb2c5d1ba9227e4c2ca66ab8287fbfbbda3888aa93dc2e28fc6811b4", 133 | "sha256:75a9445bac02d8d058d5e1fe689654ba5a6556a1dfd8ce6ec55a0ed79866cfa6" 134 | ], 135 | "version": "==1.3.0" 136 | }, 137 | "attrs": { 138 | "hashes": [ 139 | "sha256:08a96c641c3a74e44eb59afb61a24f2cb9f4d7188748e76ba4bb5edfa3cb7d1c", 140 | "sha256:f7b7ce16570fe9965acd6d30101a28f62fb4a7f9e926b3bbc9b61f8b04247e72" 141 | ], 142 | "version": "==19.3.0" 143 | }, 144 | "beautifulsoup4": { 145 | "hashes": [ 146 | "sha256:05fd825eb01c290877657a56df4c6e4c311b3965bda790c613a3d6fb01a5462a", 147 | "sha256:9fbb4d6e48ecd30bcacc5b63b94088192dcda178513b2ae3c394229f8911b887", 148 | "sha256:e1505eeed31b0f4ce2dbb3bc8eb256c04cc2b3b72af7d551a4ab6efd5cbe5dae" 149 | ], 150 | "version": "==4.8.2" 151 | }, 152 | "colander": { 153 | "hashes": [ 154 | "sha256:d758163a22d22c39b9eaae049749a5cd503f341231a02ed95af480b1145e81f2", 155 | "sha256:f79795d04bd06958bd03ce83d25aeadfe5a04e2877cf0a9f1f4da2d84a9530c3" 156 | ], 157 | "index": "pypi", 158 | "version": "==1.7.0" 159 | }, 160 | "flake8": { 161 | "hashes": [ 162 | "sha256:6a35f5b8761f45c5513e3405f110a86bea57982c3b75b766ce7b65217abe1670", 163 | "sha256:c01f8a3963b3571a8e6bd7a4063359aff90749e160778e03817cd9b71c9e07d2" 164 | ], 165 | "index": "pypi", 166 | "version": "==3.6.0" 167 | }, 168 | "hupper": { 169 | "hashes": [ 170 | "sha256:3b1c2222ec7b8159e7ad059e4493c6cc634c86184af0bf2ce5aba6edd241cf5f", 171 | "sha256:5ab839f9428dd2d993092193ad0032f968eae007873916794c3856131b2df112" 172 | ], 173 | "version": "==1.9.1" 174 | }, 175 | "iso8601": { 176 | "hashes": [ 177 | "sha256:210e0134677cc0d02f6028087fee1df1e1d76d372ee1db0bf30bf66c5c1c89a3", 178 | "sha256:49c4b20e1f38aa5cf109ddcd39647ac419f928512c869dc01d5c7098eddede82", 179 | "sha256:bbbae5fb4a7abfe71d4688fd64bff70b91bbd74ef6a99d964bab18f7fdf286dd" 180 | ], 181 | "version": "==0.1.12" 182 | }, 183 | "jsonschema": { 184 | "hashes": [ 185 | "sha256:000e68abd33c972a5248544925a0cae7d1125f9bf6c58280d37546b946769a08", 186 | "sha256:6ff5f3180870836cae40f06fa10419f557208175f13ad7bc26caa77beb1f6e02" 187 | ], 188 | "index": "pypi", 189 | "version": "==2.6.0" 190 | }, 191 | "mccabe": { 192 | "hashes": [ 193 | "sha256:ab8a6258860da4b6677da4bd2fe5dc2c659cff31b3ee4f7f5d64e79735b80d42", 194 | "sha256:dd8d182285a0fe56bace7f45b5e7d1a6ebcbf524e8f3bd87eb0f125271b8831f" 195 | ], 196 | "version": "==0.6.1" 197 | }, 198 | "more-itertools": { 199 | "hashes": [ 200 | "sha256:b84b238cce0d9adad5ed87e745778d20a3f8487d0f0cb8b8a586816c7496458d", 201 | "sha256:c833ef592a0324bcc6a60e48440da07645063c453880c9477ceb22490aec1564" 202 | ], 203 | "version": "==8.0.2" 204 | }, 205 | "pastedeploy": { 206 | "hashes": [ 207 | "sha256:d423fb9d51fdcf853aa4ff43ac7ec469b643ea19590f67488122d6d0d772350a", 208 | "sha256:fe53697ec2754703096b75d0ba29112b0590b4ce46726fe4f9408fd006e4eefc" 209 | ], 210 | "version": "==2.0.1" 211 | }, 212 | "plaster": { 213 | "hashes": [ 214 | "sha256:215c921a438b5349931fd7df9a5a11a3572947f20f4bc6dd622ac08f1c3ba249", 215 | "sha256:8351c7c7efdf33084c1de88dd0f422cbe7342534537b553c49b857b12d98c8c3" 216 | ], 217 | "version": "==1.0" 218 | }, 219 | "plaster-pastedeploy": { 220 | "hashes": [ 221 | "sha256:391d93a4e1ff81fc3bae27508ebb765b61f1724ae6169f83577f06b6357be7fd", 222 | "sha256:7c8aa37c917b615c70bf942b24dc1e0455c49f62f1a2214b1a0dd98871644bbb" 223 | ], 224 | "version": "==0.7" 225 | }, 226 | "pluggy": { 227 | "hashes": [ 228 | "sha256:15b2acde666561e1298d71b523007ed7364de07029219b604cf808bfa1c765b0", 229 | "sha256:966c145cd83c96502c3c3868f50408687b38434af77734af1e9ca461a4081d2d" 230 | ], 231 | "version": "==0.13.1" 232 | }, 233 | "py": { 234 | "hashes": [ 235 | "sha256:64f65755aee5b381cea27766a3a147c3f15b9b6b9ac88676de66ba2ae36793fa", 236 | "sha256:dc639b046a6e2cff5bbe40194ad65936d6ba360b52b3c3fe1d08a82dd50b5e53" 237 | ], 238 | "version": "==1.8.0" 239 | }, 240 | "pycodestyle": { 241 | "hashes": [ 242 | "sha256:cbc619d09254895b0d12c2c691e237b2e91e9b2ecf5e84c26b35400f93dcfb83", 243 | "sha256:cbfca99bd594a10f674d0cd97a3d802a1fdef635d4361e1a2658de47ed261e3a" 244 | ], 245 | "version": "==2.4.0" 246 | }, 247 | "pyflakes": { 248 | "hashes": [ 249 | "sha256:9a7662ec724d0120012f6e29d6248ae3727d821bba522a0e6b356eff19126a49", 250 | "sha256:f661252913bc1dbe7fcfcbf0af0db3f42ab65aabd1a6ca68fe5d466bace94dae" 251 | ], 252 | "version": "==2.0.0" 253 | }, 254 | "pyramid": { 255 | "hashes": [ 256 | "sha256:51bf64647345237c00d2fe558935e0e4938c156e29f17e203457fd8e1d757dc7", 257 | "sha256:d80ccb8cfa550139b50801591d4ca8a5575334adb493c402fce2312f55d07d66" 258 | ], 259 | "version": "==1.10.4" 260 | }, 261 | "pyramid-sqlalchemy": { 262 | "hashes": [ 263 | "sha256:377a18834e15ff59ba89c882be7d40bdc92ab5ad39a881ff2eba111b84f2418b", 264 | "sha256:42176e3df63538ec80aaa9acf0abee759c67c52e7d63aa8863f3f1a1108de959" 265 | ], 266 | "index": "pypi", 267 | "version": "==1.6" 268 | }, 269 | "pyramid-tm": { 270 | "hashes": [ 271 | "sha256:7e374c0b774d4a5c83b04fe4c195a601c7219023cf4944a8e4251a86b7e288da", 272 | "sha256:fde97db9d92039a154ca6afffdd2485874c7d3e7a6432adb51b7a60810bad422" 273 | ], 274 | "index": "pypi", 275 | "version": "==2.2.1" 276 | }, 277 | "pytest": { 278 | "hashes": [ 279 | "sha256:3e65a22eb0d4f1bdbc1eacccf4a3198bf8d4049dea5112d70a0c61b00e748d02", 280 | "sha256:5924060b374f62608a078494b909d341720a050b5224ff87e17e12377486a71d" 281 | ], 282 | "index": "pypi", 283 | "version": "==4.1.0" 284 | }, 285 | "six": { 286 | "hashes": [ 287 | "sha256:1f1b7d42e254082a9db6279deae68afb421ceba6158efa6131de7b3003ee93fd", 288 | "sha256:30f610279e8b2578cab6db20741130331735c781b56053c59c4076da27f06b66" 289 | ], 290 | "version": "==1.13.0" 291 | }, 292 | "soupsieve": { 293 | "hashes": [ 294 | "sha256:bdb0d917b03a1369ce964056fc195cfdff8819c40de04695a80bc813c3cfa1f5", 295 | "sha256:e2c1c5dee4a1c36bcb790e0fabd5492d874b8ebd4617622c4f6a731701060dda" 296 | ], 297 | "version": "==1.9.5" 298 | }, 299 | "sqlalchemy": { 300 | "hashes": [ 301 | "sha256:bfb8f464a5000b567ac1d350b9090cf081180ec1ab4aa87e7bca12dab25320ec" 302 | ], 303 | "version": "==1.3.12" 304 | }, 305 | "transaction": { 306 | "hashes": [ 307 | "sha256:3b0ad400cb7fa25f95d1516756c4c4557bb78890510f69393ad0bd15869eaa2d", 308 | "sha256:e0397e7733124e23a670cd3eee4096c197b48f1e14730d7cc7da868389f016b7" 309 | ], 310 | "version": "==3.0.0" 311 | }, 312 | "translationstring": { 313 | "hashes": [ 314 | "sha256:4ee44cfa58c52ade8910ea0ebc3d2d84bdcad9fa0422405b1801ec9b9a65b72d", 315 | "sha256:e26c7bf383413234ed442e0980a2ebe192b95e3745288a8fd2805156d27515b4" 316 | ], 317 | "version": "==1.3" 318 | }, 319 | "venusian": { 320 | "hashes": [ 321 | "sha256:06e7385786ad3a15c70740b2af8d30dfb063a946a851dcb4159f9e2a2302578f", 322 | "sha256:f6842b7242b1039c0c28f6feef29016e7e7dd3caaeb476a193acf737db31ee38" 323 | ], 324 | "version": "==3.0.0" 325 | }, 326 | "waitress": { 327 | "hashes": [ 328 | "sha256:3776cbb9abebefb51e5b654f8728928aa17b656d9f6943c58ce8f48e87cef4e3", 329 | "sha256:f4118cbce75985fd60aeb4f0d781aba8dc7ae28c18e50753e913d7a7dee76b62" 330 | ], 331 | "index": "pypi", 332 | "version": "==1.4.1" 333 | }, 334 | "webob": { 335 | "hashes": [ 336 | "sha256:05aaab7975e0ee8af2026325d656e5ce14a71f1883c52276181821d6d5bf7086", 337 | "sha256:36db8203c67023d68c1b00208a7bf55e3b10de2aa317555740add29c619de12b" 338 | ], 339 | "version": "==1.8.5" 340 | }, 341 | "webtest": { 342 | "hashes": [ 343 | "sha256:4221020d502ff414c5fba83c1213985b83219cb1cc611fe58aa4feaf96b5e062", 344 | "sha256:9f1e6faad0b732911793e4d6f54aede292b0c3ee0b3ef7afb2011ec4f4044cc8" 345 | ], 346 | "index": "pypi", 347 | "version": "==2.0.32" 348 | }, 349 | "zope.deprecation": { 350 | "hashes": [ 351 | "sha256:0d453338f04bacf91bbfba545d8bcdf529aa829e67b705eac8c1a7fdce66e2df", 352 | "sha256:f1480b74995958b24ce37b0ef04d3663d2683e5d6debc96726eff18acf4ea113" 353 | ], 354 | "version": "==4.4.0" 355 | }, 356 | "zope.interface": { 357 | "hashes": [ 358 | "sha256:048b16ac882a05bc7ef534e8b9f15c9d7a6c190e24e8938a19b7617af4ed854a", 359 | "sha256:05816cf8e7407cf62f2ec95c0a5d69ec4fa5741d9ccd10db9f21691916a9a098", 360 | "sha256:065d6a1ac89d35445168813bed45048ed4e67a4cdfc5a68fdb626a770378869f", 361 | "sha256:14157421f4121a57625002cc4f48ac7521ea238d697c4a4459a884b62132b977", 362 | "sha256:18dc895945694f397a0be86be760ff664b790f95d8e7752d5bab80284ff9105d", 363 | "sha256:1962c9f838bd6ae4075d0014f72697510daefc7e1c7e48b2607df0b6e157989c", 364 | "sha256:1a67408cacd198c7e6274a19920bb4568d56459e659e23c4915528686ac1763a", 365 | "sha256:21bf781076dd616bd07cf0223f79d61ab4f45176076f90bc2890e18c48195da4", 366 | "sha256:21c0a5d98650aebb84efa16ce2c8df1a46bdc4fe8a9e33237d0ca0b23f416ead", 367 | "sha256:23cfeea25d1e42ff3bf4f9a0c31e9d5950aa9e7c4b12f0c4bd086f378f7b7a71", 368 | "sha256:24b6fce1fb71abf9f4093e3259084efcc0ef479f89356757780685bd2b06ef37", 369 | "sha256:24f84ce24eb6b5fcdcb38ad9761524f1ae96f7126abb5e597f8a3973d9921409", 370 | "sha256:25e0ef4a824017809d6d8b0ce4ab3288594ba283e4d4f94d8cfb81d73ed65114", 371 | "sha256:2e8fdd625e9aba31228e7ddbc36bad5c38dc3ee99a86aa420f89a290bd987ce9", 372 | "sha256:2f3bc2f49b67b1bea82b942d25bc958d4f4ea6709b411cb2b6b9718adf7914ce", 373 | "sha256:35d24be9d04d50da3a6f4d61de028c1dd087045385a0ff374d93ef85af61b584", 374 | "sha256:35dbe4e8c73003dff40dfaeb15902910a4360699375e7b47d3c909a83ff27cd0", 375 | "sha256:3dfce831b824ab5cf446ed0c350b793ac6fa5fe33b984305cb4c966a86a8fb79", 376 | "sha256:3f7866365df5a36a7b8de8056cd1c605648f56f9a226d918ed84c85d25e8d55f", 377 | "sha256:455cc8c01de3bac6f9c223967cea41f4449f58b4c2e724ec8177382ddd183ab4", 378 | "sha256:4bb937e998be9d5e345f486693e477ba79e4344674484001a0b646be1d530487", 379 | "sha256:52303a20902ca0888dfb83230ca3ee6fbe63c0ad1dd60aa0bba7958ccff454d8", 380 | "sha256:6e0a897d4e09859cc80c6a16a29697406ead752292ace17f1805126a4f63c838", 381 | "sha256:6e1816e7c10966330d77af45f77501f9a68818c065dec0ad11d22b50a0e212e7", 382 | "sha256:73b5921c5c6ce3358c836461b5470bf675601c96d5e5d8f2a446951470614f67", 383 | "sha256:8093cd45cdb5f6c8591cfd1af03d32b32965b0f79b94684cd0c9afdf841982bb", 384 | "sha256:864b4a94b60db301899cf373579fd9ef92edddbf0fb2cd5ae99f53ef423ccc56", 385 | "sha256:8a27b4d3ea9c6d086ce8e7cdb3e8d319b6752e2a03238a388ccc83ccbe165f50", 386 | "sha256:91b847969d4784abd855165a2d163f72ac1e58e6dce09a5e46c20e58f19cc96d", 387 | "sha256:b47b1028be4758c3167e474884ccc079b94835f058984b15c145966c4df64d27", 388 | "sha256:b68814a322835d8ad671b7acc23a3b2acecba527bb14f4b53fc925f8a27e44d8", 389 | "sha256:bcb50a032c3b6ec7fb281b3a83d2b31ab5246c5b119588725b1350d3a1d9f6a3", 390 | "sha256:c56db7d10b25ce8918b6aec6b08ac401842b47e6c136773bfb3b590753f7fb67", 391 | "sha256:c94b77a13d4f47883e4f97f9fa00f5feadd38af3e6b3c7be45cfdb0a14c7149b", 392 | "sha256:db381f6fdaef483ad435f778086ccc4890120aff8df2ba5cfeeac24d280b3145", 393 | "sha256:e6487d01c8b7ed86af30ea141fcc4f93f8a7dde26f94177c1ad637c353bd5c07", 394 | "sha256:e86923fa728dfba39c5bb6046a450bd4eec8ad949ac404eca728cfce320d1732", 395 | "sha256:f6ca36dc1e9eeb46d779869c60001b3065fb670b5775c51421c099ea2a77c3c9", 396 | "sha256:fb62f2cbe790a50d95593fb40e8cca261c31a2f5637455ea39440d6457c2ba25" 397 | ], 398 | "version": "==4.7.1" 399 | }, 400 | "zope.sqlalchemy": { 401 | "hashes": [ 402 | "sha256:069eaad5a15f187603f368a10e0e6b0d485663498c2fe2f8ac7e93f810326eeb", 403 | "sha256:882cb812f39a703252f3748e02fcd27a338330763589c8d8bbc4d18d09fb185d" 404 | ], 405 | "version": "==1.2" 406 | } 407 | } 408 | } 409 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | *rest_toolkit* is a Python package which provides a very convenient way to 2 | build REST servers. It is build on top of 3 | `Pyramid `_, but you do not 4 | need to know much about Pyramid to use rest_toolkit. 5 | 6 | 7 | Quick example 8 | ============= 9 | 10 | This is a minimal example which defines a ``Root`` resource with a ``GET`` 11 | view, and starts a simple HTTP server. If you run this example you can request 12 | ``http://localhost:8080/`` and you will see a JSON response with a status 13 | message. 14 | 15 | :: 16 | 17 | from rest_toolkit import quick_serve 18 | from rest_toolkit import resource 19 | 20 | 21 | @resource('/') 22 | class Root(object): 23 | def __init__(self, request): 24 | pass 25 | 26 | 27 | @Root.GET() 28 | def show_root(root, request): 29 | return {'status': 'OK'} 30 | 31 | 32 | if __name__ == '__main__': 33 | quick_serve() 34 | -------------------------------------------------------------------------------- /changes.rst: -------------------------------------------------------------------------------- 1 | doc/changes.rst -------------------------------------------------------------------------------- /demos/README.rst: -------------------------------------------------------------------------------- 1 | ================== 2 | rest_toolkit Demos 3 | ================== 4 | 5 | These demos show various topics in building frontends to REST services 6 | implemented using ``rest_toolkit``. 7 | 8 | - ``angular_helloworld`` 9 | 10 | - ``angular_todo`` 11 | 12 | - ``angular_resource`` 13 | 14 | - ``angular_cors`` 15 | 16 | -------------------------------------------------------------------------------- /demos/TODO.rst: -------------------------------------------------------------------------------- 1 | 2 | Questions 3 | ========= 4 | 5 | - (Chris) Getting the root when in routes 6 | 7 | TODO 8 | ==== 9 | 10 | - Security demo 11 | 12 | 13 | angular_todo_jwtauth 14 | ==================== 15 | 16 | angular_restcontroller 17 | ====================== 18 | 19 | angular_todo_validation 20 | ======================= 21 | 22 | - ngForm patterns 23 | -------------------------------------------------------------------------------- /demos/angular_cors/README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | angular_cors README 3 | =================== 4 | 5 | Running the AngularJS frontend at a different URL than the backend. 6 | 7 | Shows 8 | ===== 9 | 10 | - Starts with ``demos/angular/resource`` 11 | 12 | - Wires up the Pyramid ``Configurator`` to allow cross-origin requests 13 | 14 | Usage 15 | ===== 16 | 17 | - ``python app.py`` 18 | 19 | - Go to ``static/index.html`` on your local webserver outside Pyramid 20 | 21 | Implementation 22 | ============== 23 | 24 | - Instead of using ``quick_serve``, wire up a Pyramid application 25 | using the configurator 26 | 27 | - Extend the configuration using events that put the CORS information 28 | on responses -------------------------------------------------------------------------------- /demos/angular_cors/app.js: -------------------------------------------------------------------------------- 1 | angular.module("app", ['ngRoute', 'ngResource']) 2 | 3 | .value("endpointURL", "http://localhost:8088") 4 | 5 | .config( 6 | function ($routeProvider) { 7 | 8 | $routeProvider 9 | .when( 10 | '/', 11 | { 12 | templateUrl: "list.partial.html", 13 | controller: "ListCtrl", 14 | controllerAs: "ListCtrl", 15 | resolve: { 16 | todoList: function (Todos) { 17 | return Todos.get({}).$promise; 18 | } 19 | } 20 | }) 21 | 22 | .when( 23 | '/todos/:todoId', 24 | { 25 | templateUrl: "view.partial.html", 26 | controller: "ViewCtrl", 27 | controllerAs: "ViewCtrl", 28 | resolve: { 29 | todo: function (Todos, $route) { 30 | return Todos 31 | .get({id: $route.current.params.todoId}).$promise; 32 | } 33 | } 34 | }) 35 | 36 | .when( 37 | '/todos/:todoId/edit', 38 | { 39 | templateUrl: "edit.partial.html", 40 | controller: "EditCtrl", 41 | controllerAs: "EditCtrl", 42 | resolve: { 43 | todo: function (Todos, $route) { 44 | return Todos 45 | .get({id: $route.current.params.todoId}).$promise; 46 | } 47 | } 48 | }) 49 | 50 | .otherwise({redirectTo: "/"}) 51 | }) 52 | 53 | .factory( 54 | "Todos", 55 | function ($resource, endpointURL) { 56 | return $resource( 57 | endpointURL + '/todos/:id', 58 | { id: '@id' }, 59 | { 60 | update: { 61 | method: "PUT", 62 | isArray: false 63 | } 64 | }); 65 | }); -------------------------------------------------------------------------------- /demos/angular_cors/app.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | from os.path import realpath 3 | from random import randint 4 | from wsgiref.simple_server import make_server 5 | from pyramid.config import Configurator 6 | from pyramid.events import NewRequest 7 | from rest_toolkit import resource 8 | 9 | todos = { 10 | "td1": {"id": "td1", "title": "Firstie"}, 11 | "td2": {"id": "td2", "title": "Second"}, 12 | "td3": {"id": "td3", "title": "Another"}, 13 | "td4": {"id": "td4", "title": "Last"} 14 | } 15 | 16 | 17 | @resource('/todos') 18 | class TodoCollection(object): 19 | def __init__(self, request): 20 | pass 21 | 22 | 23 | @TodoCollection.GET() 24 | def list_todos(collection, request): 25 | return {"data": list(todos.values())} 26 | 27 | 28 | @TodoCollection.POST() 29 | def add_todo(collection, request): 30 | todo = { 31 | "id": "td" + str(randint(100, 9999)), 32 | "title": request.json_body["title"] 33 | } 34 | todos[todo["id"]] = todo 35 | return {"data": todo} 36 | 37 | 38 | @resource('/todos/{id}') 39 | class TodoResource(object): 40 | def __init__(self, request): 41 | self.data = todos[request.matchdict['id']] 42 | 43 | 44 | @TodoResource.GET() 45 | def view_todo(todo, request): 46 | return todo.data 47 | 48 | 49 | @TodoResource.PUT() 50 | def update_todo(todo, request): 51 | todo.data['title'] = request.json_body['title'] 52 | return {} 53 | 54 | 55 | @TodoResource.DELETE() 56 | def delete_todo(todo, request): 57 | del todos[todo.data["id"]] 58 | return {} 59 | 60 | 61 | def add_cors_callback(event): 62 | headers = "Origin, Content-Type, Accept, Authorization" 63 | def cors_headers(request, response): 64 | response.headers.update({ 65 | # In production you would be careful with this 66 | "Access-Control-Allow-Origin": "*", 67 | "Access-Control-Allow-Headers": headers 68 | }) 69 | 70 | event.request.add_response_callback(cors_headers) 71 | 72 | 73 | if __name__ == '__main__': 74 | config = Configurator() 75 | config.include('rest_toolkit') 76 | # Publish the module's path as a static asset view 77 | config.add_static_view('static', dirname(realpath(__file__))) 78 | config.add_subscriber(add_cors_callback, NewRequest) 79 | config.scan(".") 80 | app = config.make_wsgi_app() 81 | server = make_server('0.0.0.0', 8088, app) 82 | server.serve_forever() 83 | -------------------------------------------------------------------------------- /demos/angular_cors/controllers.js: -------------------------------------------------------------------------------- 1 | angular.module("app") 2 | 3 | .controller( 4 | "ListCtrl", 5 | function ($http, Todos, todoList) { 6 | 7 | var ctrl = this; 8 | ctrl.todos = todoList.data; 9 | ctrl.newTitle = ""; 10 | 11 | ctrl.addTodo = function (todoTitle) { 12 | Todos 13 | .save({}, {title: todoTitle}, 14 | function (todo) { 15 | ctrl.todos.push(todo.data); 16 | ctrl.newTitle = ""; 17 | } 18 | ); 19 | }; 20 | 21 | ctrl.deleteTodo = function (todoId) { 22 | Todos 23 | .delete({id: todoId}, function () { 24 | // Removed on the server, let's remove locally 25 | _.remove(ctrl.todos, {id: todoId}); 26 | }); 27 | }; 28 | 29 | }) 30 | 31 | 32 | .controller("ViewCtrl", function (todo) { 33 | this.todo = todo; 34 | }) 35 | 36 | 37 | .controller( 38 | "EditCtrl", 39 | function (todo, $location, endpointURL) { 40 | var ctrl = this; 41 | ctrl.todo = todo; 42 | 43 | // Handle the submit 44 | ctrl.updateTodo = function () { 45 | todo.$update(function () { 46 | $location.path(endpointURL); 47 | }); 48 | } 49 | }); -------------------------------------------------------------------------------- /demos/angular_cors/edit.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

Edit title

3 | 4 |
6 | 7 |
8 | 9 | 11 |
12 | 13 |
14 | 15 |
-------------------------------------------------------------------------------- /demos/angular_cors/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ context.title }} 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /demos/angular_cors/lib/angular-resource.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.2.16 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(H,a,A){'use strict';function D(p,g){g=g||{};a.forEach(g,function(a,c){delete g[c]});for(var c in p)!p.hasOwnProperty(c)||"$"===c.charAt(0)&&"$"===c.charAt(1)||(g[c]=p[c]);return g}var v=a.$$minErr("$resource"),C=/^(\.[a-zA-Z_$][0-9a-zA-Z_$]*)+$/;a.module("ngResource",["ng"]).factory("$resource",["$http","$q",function(p,g){function c(a,c){this.template=a;this.defaults=c||{};this.urlParams={}}function t(n,w,l){function r(h,d){var e={};d=x({},w,d);s(d,function(b,d){u(b)&&(b=b());var k;if(b&& 7 | b.charAt&&"@"==b.charAt(0)){k=h;var a=b.substr(1);if(null==a||""===a||"hasOwnProperty"===a||!C.test("."+a))throw v("badmember",a);for(var a=a.split("."),f=0,c=a.length;fa||typeof i=="undefined")return 1;if(ie?0:e);++r=b&&i===n,l=[];if(f){var p=o(r);p?(i=t,r=p):f=false}for(;++ui(r,p)&&l.push(p);return f&&c(r),l}function ut(n,t,e,r){r=(r||0)-1;for(var u=n?n.length:0,o=[];++r=b&&f===n,h=u||v?a():s; 18 | for(v&&(h=o(h),f=t);++if(h,y))&&((u||v)&&h.push(y),s.push(g))}return v?(l(h.k),c(h)):u&&l(h),s}function lt(n){return function(t,e,r){var u={};e=J.createCallback(e,r,3),r=-1;var o=t?t.length:0;if(typeof o=="number")for(;++re?Ie(0,o+e):e)||0,Te(n)?i=-1o&&(o=a)}}else t=null==t&&kt(n)?r:J.createCallback(t,e,3),St(n,function(n,e,r){e=t(n,e,r),e>u&&(u=e,o=n)});return o}function Dt(n,t,e,r){if(!n)return e;var u=3>arguments.length;t=J.createCallback(t,r,4);var o=-1,i=n.length;if(typeof i=="number")for(u&&(e=n[++o]);++oarguments.length;return t=J.createCallback(t,r,4),Et(n,function(n,r,o){e=u?(u=false,n):t(e,n,r,o)}),e}function Tt(n){var t=-1,e=n?n.length:0,r=Xt(typeof e=="number"?e:0);return St(n,function(n){var e=at(0,++t);r[t]=r[e],r[e]=n}),r}function Ft(n,t,e){var r;t=J.createCallback(t,e,3),e=-1;var u=n?n.length:0;if(typeof u=="number")for(;++er?Ie(0,u+r):r||0}else if(r)return r=zt(t,e),t[r]===e?r:-1;return n(t,e,r)}function qt(n,t,e){if(typeof t!="number"&&null!=t){var r=0,u=-1,o=n?n.length:0;for(t=J.createCallback(t,e,3);++u>>1,e(n[r])e?0:e);++t=v; 29 | m?(i&&(i=ve(i)),s=f,a=n.apply(l,o)):i||(i=_e(r,v))}return m&&c?c=ve(c):c||t===h||(c=_e(u,t)),e&&(m=true,a=n.apply(l,o)),!m||c||i||(o=l=null),a}}function Ut(n){return n}function Gt(n,t,e){var r=true,u=t&&bt(t);t&&(e||u.length)||(null==e&&(e=t),o=Q,t=n,n=J,u=bt(t)),false===e?r=false:wt(e)&&"chain"in e&&(r=e.chain);var o=n,i=dt(o);St(u,function(e){var u=n[e]=t[e];i&&(o.prototype[e]=function(){var t=this.__chain__,e=this.__wrapped__,i=[e];if(be.apply(i,arguments),i=u.apply(n,i),r||t){if(e===i&&wt(i))return this; 30 | i=new o(i),i.__chain__=t}return i})})}function Ht(){}function Jt(n){return function(t){return t[n]}}function Qt(){return this.__wrapped__}e=e?Y.defaults(G.Object(),e,Y.pick(G,A)):G;var Xt=e.Array,Yt=e.Boolean,Zt=e.Date,ne=e.Function,te=e.Math,ee=e.Number,re=e.Object,ue=e.RegExp,oe=e.String,ie=e.TypeError,ae=[],fe=re.prototype,le=e._,ce=fe.toString,pe=ue("^"+oe(ce).replace(/[.*+?^${}()|[\]\\]/g,"\\$&").replace(/toString| for [^\]]+/g,".*?")+"$"),se=te.ceil,ve=e.clearTimeout,he=te.floor,ge=ne.prototype.toString,ye=vt(ye=re.getPrototypeOf)&&ye,me=fe.hasOwnProperty,be=ae.push,_e=e.setTimeout,de=ae.splice,we=ae.unshift,je=function(){try{var n={},t=vt(t=re.defineProperty)&&t,e=t(n,n,n)&&t 31 | }catch(r){}return e}(),ke=vt(ke=re.create)&&ke,xe=vt(xe=Xt.isArray)&&xe,Ce=e.isFinite,Oe=e.isNaN,Ne=vt(Ne=re.keys)&&Ne,Ie=te.max,Se=te.min,Ee=e.parseInt,Re=te.random,Ae={};Ae[$]=Xt,Ae[T]=Yt,Ae[F]=Zt,Ae[B]=ne,Ae[q]=re,Ae[W]=ee,Ae[z]=ue,Ae[P]=oe,Q.prototype=J.prototype;var De=J.support={};De.funcDecomp=!vt(e.a)&&E.test(s),De.funcNames=typeof ne.name=="string",J.templateSettings={escape:/<%-([\s\S]+?)%>/g,evaluate:/<%([\s\S]+?)%>/g,interpolate:N,variable:"",imports:{_:J}},ke||(nt=function(){function n(){}return function(t){if(wt(t)){n.prototype=t; 32 | var r=new n;n.prototype=null}return r||e.Object()}}());var $e=je?function(n,t){M.value=t,je(n,"__bindData__",M)}:Ht,Te=xe||function(n){return n&&typeof n=="object"&&typeof n.length=="number"&&ce.call(n)==$||false},Fe=Ne?function(n){return wt(n)?Ne(n):[]}:H,Be={"&":"&","<":"<",">":">",'"':""","'":"'"},We=_t(Be),qe=ue("("+Fe(We).join("|")+")","g"),ze=ue("["+Fe(Be).join("")+"]","g"),Pe=ye?function(n){if(!n||ce.call(n)!=q)return false;var t=n.valueOf,e=vt(t)&&(e=ye(t))&&ye(e);return e?n==e||ye(n)==e:ht(n) 33 | }:ht,Ke=lt(function(n,t,e){me.call(n,e)?n[e]++:n[e]=1}),Le=lt(function(n,t,e){(me.call(n,e)?n[e]:n[e]=[]).push(t)}),Me=lt(function(n,t,e){n[e]=t}),Ve=Rt,Ue=vt(Ue=Zt.now)&&Ue||function(){return(new Zt).getTime()},Ge=8==Ee(d+"08")?Ee:function(n,t){return Ee(kt(n)?n.replace(I,""):n,t||0)};return J.after=function(n,t){if(!dt(t))throw new ie;return function(){return 1>--n?t.apply(this,arguments):void 0}},J.assign=U,J.at=function(n){for(var t=arguments,e=-1,r=ut(t,true,false,1),t=t[2]&&t[2][t[1]]===n?1:r.length,u=Xt(t);++e=b&&o(r?e[r]:s)))}var p=e[0],h=-1,g=p?p.length:0,y=[];n:for(;++h(m?t(m,v):f(s,v))){for(r=u,(m||s).push(v);--r;)if(m=i[r],0>(m?t(m,v):f(e[r],v)))continue n;y.push(v)}}for(;u--;)(m=i[u])&&c(m);return l(i),l(s),y},J.invert=_t,J.invoke=function(n,t){var e=p(arguments,2),r=-1,u=typeof t=="function",o=n?n.length:0,i=Xt(typeof o=="number"?o:0);return St(n,function(n){i[++r]=(u?t:n[t]).apply(n,e)}),i},J.keys=Fe,J.map=Rt,J.mapValues=function(n,t,e){var r={}; 39 | return t=J.createCallback(t,e,3),h(n,function(n,e,u){r[e]=t(n,e,u)}),r},J.max=At,J.memoize=function(n,t){function e(){var r=e.cache,u=t?t.apply(this,arguments):m+arguments[0];return me.call(r,u)?r[u]:r[u]=n.apply(this,arguments)}if(!dt(n))throw new ie;return e.cache={},e},J.merge=function(n){var t=arguments,e=2;if(!wt(n))return n;if("number"!=typeof t[2]&&(e=t.length),3e?Ie(0,r+e):Se(e,r-1))+1);r--;)if(n[r]===t)return r;return-1},J.mixin=Gt,J.noConflict=function(){return e._=le,this},J.noop=Ht,J.now=Ue,J.parseInt=Ge,J.random=function(n,t,e){var r=null==n,u=null==t;return null==e&&(typeof n=="boolean"&&u?(e=n,n=1):u||typeof t!="boolean"||(e=t,u=true)),r&&u&&(t=1),n=+n||0,u?(t=n,n=0):t=+t||0,e||n%1||t%1?(e=Re(),Se(n+e*(t-n+parseFloat("1e-"+((e+"").length-1))),t)):at(n,t) 50 | },J.reduce=Dt,J.reduceRight=$t,J.result=function(n,t){if(n){var e=n[t];return dt(e)?n[t]():e}},J.runInContext=s,J.size=function(n){var t=n?n.length:0;return typeof t=="number"?t:Fe(n).length},J.some=Ft,J.sortedIndex=zt,J.template=function(n,t,e){var r=J.templateSettings;n=oe(n||""),e=_({},e,r);var u,o=_({},e.imports,r.imports),r=Fe(o),o=xt(o),a=0,f=e.interpolate||S,l="__p+='",f=ue((e.escape||S).source+"|"+f.source+"|"+(f===N?x:S).source+"|"+(e.evaluate||S).source+"|$","g");n.replace(f,function(t,e,r,o,f,c){return r||(r=o),l+=n.slice(a,c).replace(R,i),e&&(l+="'+__e("+e+")+'"),f&&(u=true,l+="';"+f+";\n__p+='"),r&&(l+="'+((__t=("+r+"))==null?'':__t)+'"),a=c+t.length,t 51 | }),l+="';",f=e=e.variable,f||(e="obj",l="with("+e+"){"+l+"}"),l=(u?l.replace(w,""):l).replace(j,"$1").replace(k,"$1;"),l="function("+e+"){"+(f?"":e+"||("+e+"={});")+"var __t,__p='',__e=_.escape"+(u?",__j=Array.prototype.join;function print(){__p+=__j.call(arguments,'')}":";")+l+"return __p}";try{var c=ne(r,"return "+l).apply(v,o)}catch(p){throw p.source=l,p}return t?c(t):(c.source=l,c)},J.unescape=function(n){return null==n?"":oe(n).replace(qe,gt)},J.uniqueId=function(n){var t=++y;return oe(null==n?"":n)+t 52 | },J.all=Ot,J.any=Ft,J.detect=It,J.findWhere=It,J.foldl=Dt,J.foldr=$t,J.include=Ct,J.inject=Dt,Gt(function(){var n={};return h(J,function(t,e){J.prototype[e]||(n[e]=t)}),n}(),false),J.first=Bt,J.last=function(n,t,e){var r=0,u=n?n.length:0;if(typeof t!="number"&&null!=t){var o=u;for(t=J.createCallback(t,e,3);o--&&t(n[o],o,n);)r++}else if(r=t,null==r||e)return n?n[u-1]:v;return p(n,Ie(0,u-r))},J.sample=function(n,t,e){return n&&typeof n.length!="number"&&(n=xt(n)),null==t||e?n?n[at(0,n.length-1)]:v:(n=Tt(n),n.length=Se(Ie(0,t),n.length),n) 53 | },J.take=Bt,J.head=Bt,h(J,function(n,t){var e="sample"!==t;J.prototype[t]||(J.prototype[t]=function(t,r){var u=this.__chain__,o=n(this.__wrapped__,t,r);return u||null!=t&&(!r||e&&typeof t=="function")?new Q(o,u):o})}),J.VERSION="2.4.1",J.prototype.chain=function(){return this.__chain__=true,this},J.prototype.toString=function(){return oe(this.__wrapped__)},J.prototype.value=Qt,J.prototype.valueOf=Qt,St(["join","pop","shift"],function(n){var t=ae[n];J.prototype[n]=function(){var n=this.__chain__,e=t.apply(this.__wrapped__,arguments); 54 | return n?new Q(e,n):e}}),St(["push","reverse","sort","unshift"],function(n){var t=ae[n];J.prototype[n]=function(){return t.apply(this.__wrapped__,arguments),this}}),St(["concat","slice","splice"],function(n){var t=ae[n];J.prototype[n]=function(){return new Q(t.apply(this.__wrapped__,arguments),this.__chain__)}}),J}var v,h=[],g=[],y=0,m=+new Date+"",b=75,_=40,d=" \t\x0B\f\xa0\ufeff\n\r\u2028\u2029\u1680\u180e\u2000\u2001\u2002\u2003\u2004\u2005\u2006\u2007\u2008\u2009\u200a\u202f\u205f\u3000",w=/\b__p\+='';/g,j=/\b(__p\+=)''\+/g,k=/(__e\(.*?\)|\b__t\))\+'';/g,x=/\$\{([^\\}]*(?:\\.[^\\}]*)*)\}/g,C=/\w*$/,O=/^\s*function[ \n\r\t]+\w/,N=/<%=([\s\S]+?)%>/g,I=RegExp("^["+d+"]*0+(?=.$)"),S=/($^)/,E=/\bthis\b/,R=/['\n\r\t\u2028\u2029\\]/g,A="Array Boolean Date Function Math Number Object RegExp String _ attachEvent clearTimeout isFinite isNaN parseInt setTimeout".split(" "),D="[object Arguments]",$="[object Array]",T="[object Boolean]",F="[object Date]",B="[object Function]",W="[object Number]",q="[object Object]",z="[object RegExp]",P="[object String]",K={}; 55 | K[B]=false,K[D]=K[$]=K[T]=K[F]=K[W]=K[q]=K[z]=K[P]=true;var L={leading:false,maxWait:0,trailing:false},M={configurable:false,enumerable:false,value:null,writable:false},V={"boolean":false,"function":true,object:true,number:false,string:false,undefined:false},U={"\\":"\\","'":"'","\n":"n","\r":"r","\t":"t","\u2028":"u2028","\u2029":"u2029"},G=V[typeof window]&&window||this,H=V[typeof exports]&&exports&&!exports.nodeType&&exports,J=V[typeof module]&&module&&!module.nodeType&&module,Q=J&&J.exports===H&&H,X=V[typeof global]&&global;!X||X.global!==X&&X.window!==X||(G=X); 56 | var Y=s();typeof define=="function"&&typeof define.amd=="object"&&define.amd?(G._=Y, define(function(){return Y})):H&&J?Q?(J.exports=Y)._=Y:H._=Y:G._=Y}).call(this); -------------------------------------------------------------------------------- /demos/angular_cors/list.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

To Do Items

3 | 4 | 5 | 6 | 9 | 16 | 17 |
7 | title 8 | 10 | edit 12 | 15 |
18 |
20 | 22 | 23 |
24 | 25 |
-------------------------------------------------------------------------------- /demos/angular_cors/view.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

title

3 |
-------------------------------------------------------------------------------- /demos/angular_helloworld/README.rst: -------------------------------------------------------------------------------- 1 | ========================= 2 | angular_helloworld README 3 | ========================= 4 | 5 | A tiny Todo example of an AngularJS app fronting a simple endpoint. 6 | 7 | Shows 8 | ===== 9 | 10 | - A static HTML page with AngularJS providing the UI 11 | 12 | - A REST endpoint that provides a list of Todo items 13 | 14 | Usage 15 | ===== 16 | 17 | - ``python app.py`` 18 | 19 | - Go to ``http://localhost:8088/static/index.html`` 20 | 21 | Implementation 22 | ============== 23 | 24 | - Pyramid-based Python server providing a ``rest_toolkit`` endpoint and 25 | serving up static files like an HTTP server 26 | 27 | - Uses the demo-oriented ``quick_serve`` from ``rest_toolkit`` to point 28 | at the static files and serve them up under 29 | ``http://localhost:8088/static`` with the REST API under 30 | ``http://localhost:8088/todos`` 31 | 32 | - An AngularJS application in ``index.html`` which loads the module in 33 | ``app.js``, plus Boostrap CSS -------------------------------------------------------------------------------- /demos/angular_helloworld/app.js: -------------------------------------------------------------------------------- 1 | angular.module("app", []) 2 | 3 | .value("endpointURL", "http://localhost:8088") 4 | 5 | .controller("HomeCtrl", function ($http, endpointURL) { 6 | var ctrl = this; 7 | ctrl.todos = []; 8 | $http.get(endpointURL + "/todos") 9 | .success(function (data) { 10 | ctrl.todos = data.todos; 11 | }); 12 | }); -------------------------------------------------------------------------------- /demos/angular_helloworld/app.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import quick_serve 2 | from rest_toolkit import resource 3 | 4 | 5 | @resource('/todos') 6 | class TodoCollection(object): 7 | def __init__(self, request): 8 | pass 9 | 10 | 11 | @TodoCollection.GET() 12 | def list_todos(collection, request): 13 | return {"todos": [ 14 | {"id": "t1", "title": "Firstie"}, 15 | {"id": "t2", "title": "Second"}, 16 | {"id": "t3", "title": "Another"}, 17 | {"id": "t4", "title": "Last"} 18 | ]} 19 | 20 | 21 | if __name__ == '__main__': 22 | quick_serve(port=8088) -------------------------------------------------------------------------------- /demos/angular_helloworld/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularJS Hello World Demo 6 | 7 | 8 | 9 | 10 |
11 | 12 |

To Do Items

13 |
    14 |
  • 15 | title 16 |
  • 17 |
18 | 19 |
20 | 21 | 22 | 23 | 24 | 25 | -------------------------------------------------------------------------------- /demos/angular_resource/README.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | angular_resource README 3 | ======================= 4 | 5 | CRUD Todo example using AngularJS's ``$resource`` abstraction for 6 | REST APIs. 7 | 8 | Shows 9 | ===== 10 | 11 | - List/Add/View/Edit/Delete on Todo items via an ngResource factory 12 | 13 | Usage 14 | ===== 15 | 16 | - ``python app.py`` 17 | 18 | - Go to ``http://localhost:8088/static/index.html`` 19 | 20 | Implementation 21 | ============== 22 | 23 | - Uses ``demos/angular_todo`` as the starting point 24 | 25 | - Replaces ``$http`` with ``$resource`` 26 | 27 | - This reduces the amount of code significantly 28 | 29 | - It also shows the conventional pattern for design of the URL space 30 | client-side and server-side -------------------------------------------------------------------------------- /demos/angular_resource/app.js: -------------------------------------------------------------------------------- 1 | angular.module("app", ['ngRoute', 'ngResource']) 2 | 3 | .value("endpointURL", "http://localhost:8088") 4 | 5 | .config( 6 | function ($routeProvider) { 7 | 8 | $routeProvider 9 | .when( 10 | '/', 11 | { 12 | templateUrl: "list.partial.html", 13 | controller: "ListCtrl", 14 | controllerAs: "ListCtrl", 15 | resolve: { 16 | todoList: function (Todos) { 17 | return Todos.get({}).$promise; 18 | } 19 | } 20 | }) 21 | 22 | .when( 23 | '/todos/:todoId', 24 | { 25 | templateUrl: "view.partial.html", 26 | controller: "ViewCtrl", 27 | controllerAs: "ViewCtrl", 28 | resolve: { 29 | todo: function (Todos, $route) { 30 | return Todos 31 | .get({id: $route.current.params.todoId}).$promise; 32 | } 33 | } 34 | }) 35 | 36 | .when( 37 | '/todos/:todoId/edit', 38 | { 39 | templateUrl: "edit.partial.html", 40 | controller: "EditCtrl", 41 | controllerAs: "EditCtrl", 42 | resolve: { 43 | todo: function (Todos, $route) { 44 | return Todos 45 | .get({id: $route.current.params.todoId}).$promise; 46 | } 47 | } 48 | }) 49 | 50 | .otherwise({redirectTo: "/"}) 51 | }) 52 | 53 | .factory( 54 | "Todos", 55 | function ($resource, endpointURL) { 56 | return $resource( 57 | endpointURL + '/todos/:id', 58 | { id: '@id' }, 59 | { 60 | update: { 61 | method: "PUT", 62 | isArray: false 63 | } 64 | }); 65 | }); -------------------------------------------------------------------------------- /demos/angular_resource/app.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from rest_toolkit import quick_serve 4 | from rest_toolkit import resource 5 | 6 | todos = { 7 | "td1": {"id": "td1", "title": "Firstie"}, 8 | "td2": {"id": "td2", "title": "Second"}, 9 | "td3": {"id": "td3", "title": "Another"}, 10 | "td4": {"id": "td4", "title": "Last"} 11 | } 12 | 13 | 14 | @resource('/todos') 15 | class TodoCollection(object): 16 | def __init__(self, request): 17 | pass 18 | 19 | 20 | @TodoCollection.GET() 21 | def list_todos(collection, request): 22 | return {"data": list(todos.values())} 23 | 24 | 25 | @TodoCollection.POST() 26 | def add_todo(collection, request): 27 | todo = { 28 | "id": "td" + str(randint(100, 9999)), 29 | "title": request.json_body["title"] 30 | } 31 | todos[todo["id"]] = todo 32 | return {"data": todo} 33 | 34 | 35 | @resource('/todos/{id}') 36 | class TodoResource(object): 37 | def __init__(self, request): 38 | self.data = todos[request.matchdict['id']] 39 | 40 | 41 | @TodoResource.GET() 42 | def view_todo(todo, request): 43 | return todo.data 44 | 45 | 46 | @TodoResource.PUT() 47 | def update_todo(todo, request): 48 | todo.data['title'] = request.json_body['title'] 49 | return {} 50 | 51 | 52 | @TodoResource.DELETE() 53 | def delete_todo(todo, request): 54 | del todos[todo.data["id"]] 55 | return {} 56 | 57 | 58 | if __name__ == '__main__': 59 | quick_serve(port=8088) -------------------------------------------------------------------------------- /demos/angular_resource/controllers.js: -------------------------------------------------------------------------------- 1 | angular.module("app") 2 | 3 | .controller( 4 | "ListCtrl", 5 | function ($http, Todos, todoList) { 6 | 7 | var ctrl = this; 8 | ctrl.todos = todoList.data; 9 | ctrl.newTitle = ""; 10 | 11 | ctrl.addTodo = function (todoTitle) { 12 | Todos 13 | .save({}, {title: todoTitle}, 14 | function (todo) { 15 | ctrl.todos.push(todo.data); 16 | ctrl.newTitle = ""; 17 | } 18 | ); 19 | }; 20 | 21 | ctrl.deleteTodo = function (todoId) { 22 | Todos 23 | .delete({id: todoId}, function () { 24 | // Removed on the server, let's remove locally 25 | _.remove(ctrl.todos, {id: todoId}); 26 | }); 27 | }; 28 | 29 | }) 30 | 31 | 32 | .controller("ViewCtrl", function (todo) { 33 | this.todo = todo; 34 | }) 35 | 36 | 37 | .controller( 38 | "EditCtrl", 39 | function (todo, $location, endpointURL) { 40 | var ctrl = this; 41 | ctrl.todo = todo; 42 | 43 | // Handle the submit 44 | ctrl.updateTodo = function () { 45 | todo.$update(function () { 46 | $location.path(endpointURL); 47 | }); 48 | } 49 | }); -------------------------------------------------------------------------------- /demos/angular_resource/edit.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

Edit title

3 | 4 |
6 | 7 |
8 | 9 | 11 |
12 | 13 |
14 | 15 |
-------------------------------------------------------------------------------- /demos/angular_resource/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ context.title }} 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /demos/angular_resource/lib/angular-resource.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.2.16 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(H,a,A){'use strict';function D(p,g){g=g||{};a.forEach(g,function(a,c){delete g[c]});for(var c in p)!p.hasOwnProperty(c)||"$"===c.charAt(0)&&"$"===c.charAt(1)||(g[c]=p[c]);return g}var v=a.$$minErr("$resource"),C=/^(\.[a-zA-Z_$][0-9a-zA-Z_$]*)+$/;a.module("ngResource",["ng"]).factory("$resource",["$http","$q",function(p,g){function c(a,c){this.template=a;this.defaults=c||{};this.urlParams={}}function t(n,w,l){function r(h,d){var e={};d=x({},w,d);s(d,function(b,d){u(b)&&(b=b());var k;if(b&& 7 | b.charAt&&"@"==b.charAt(0)){k=h;var a=b.substr(1);if(null==a||""===a||"hasOwnProperty"===a||!C.test("."+a))throw v("badmember",a);for(var a=a.split("."),f=0,c=a.length;f 2 |

To Do Items

3 | 4 | 5 | 6 | 9 | 16 | 17 |
7 | title 8 | 10 | edit 12 | 15 |
18 |
20 | 22 | 23 |
24 | 25 |
-------------------------------------------------------------------------------- /demos/angular_resource/view.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

title

3 |
-------------------------------------------------------------------------------- /demos/angular_security/README.rst: -------------------------------------------------------------------------------- 1 | ======================= 2 | angular_security README 3 | ======================= 4 | 5 | Showcase Pyramid's authentication and authorization using JWT tokens. 6 | 7 | Shows 8 | ===== 9 | 10 | 11 | Usage 12 | ===== 13 | 14 | - ``python app.py`` 15 | 16 | - Go to ``static/index.html`` on your local webserver outside Pyramid 17 | 18 | Implementation 19 | ============== 20 | 21 | -------------------------------------------------------------------------------- /demos/angular_security/app.js: -------------------------------------------------------------------------------- 1 | angular.module("app", ['ngRoute', 'ngResource']) 2 | 3 | .value("endpointURL", "http://localhost:8088") 4 | 5 | .config( 6 | function ($routeProvider) { 7 | 8 | $routeProvider 9 | .when( 10 | '/', 11 | { 12 | templateUrl: "list.partial.html", 13 | controller: "ListCtrl", 14 | controllerAs: "ListCtrl", 15 | resolve: { 16 | todoList: function (Todos) { 17 | return Todos.get({}).$promise; 18 | } 19 | } 20 | }) 21 | 22 | .when( 23 | '/todos/:todoId', 24 | { 25 | templateUrl: "view.partial.html", 26 | controller: "ViewCtrl", 27 | controllerAs: "ViewCtrl", 28 | resolve: { 29 | todo: function (Todos, $route) { 30 | return Todos 31 | .get({id: $route.current.params.todoId}).$promise; 32 | } 33 | } 34 | }) 35 | 36 | .when( 37 | '/todos/:todoId/edit', 38 | { 39 | templateUrl: "edit.partial.html", 40 | controller: "EditCtrl", 41 | controllerAs: "EditCtrl", 42 | resolve: { 43 | todo: function (Todos, $route) { 44 | return Todos 45 | .get({id: $route.current.params.todoId}).$promise; 46 | } 47 | } 48 | }) 49 | 50 | .otherwise({redirectTo: "/"}) 51 | }) 52 | 53 | .factory( 54 | "Todos", 55 | function ($resource, endpointURL) { 56 | return $resource( 57 | endpointURL + '/todos/:id', 58 | { id: '@id' }, 59 | { 60 | update: { 61 | method: "PUT", 62 | isArray: false 63 | } 64 | }); 65 | }); -------------------------------------------------------------------------------- /demos/angular_security/app.py: -------------------------------------------------------------------------------- 1 | from os.path import dirname 2 | from os.path import realpath 3 | from random import randint 4 | from wsgiref.simple_server import make_server 5 | from pyramid.config import Configurator 6 | from pyramid.events import NewRequest 7 | from rest_toolkit import resource 8 | 9 | todos = { 10 | "td1": {"id": "td1", "title": "Firstie"}, 11 | "td2": {"id": "td2", "title": "Second"}, 12 | "td3": {"id": "td3", "title": "Another"}, 13 | "td4": {"id": "td4", "title": "Last"} 14 | } 15 | 16 | 17 | @resource('/todos') 18 | class TodoCollection(object): 19 | def __init__(self, request): 20 | pass 21 | 22 | 23 | @TodoCollection.GET() 24 | def list_todos(collection, request): 25 | return {"data": list(todos.values())} 26 | 27 | 28 | @TodoCollection.POST() 29 | def add_todo(collection, request): 30 | todo = { 31 | "id": "td" + str(randint(100, 9999)), 32 | "title": request.json_body["title"] 33 | } 34 | todos[todo["id"]] = todo 35 | return {"data": todo} 36 | 37 | 38 | @resource('/todos/{id}') 39 | class TodoResource(object): 40 | def __init__(self, request): 41 | self.data = todos[request.matchdict['id']] 42 | 43 | 44 | @TodoResource.GET() 45 | def view_todo(todo, request): 46 | return todo.data 47 | 48 | 49 | @TodoResource.PUT() 50 | def update_todo(todo, request): 51 | todo.data['title'] = request.json_body['title'] 52 | return {} 53 | 54 | 55 | @TodoResource.DELETE() 56 | def delete_todo(todo, request): 57 | del todos[todo.data["id"]] 58 | return {} 59 | 60 | 61 | def add_cors_callback(event): 62 | headers = "Origin, Content-Type, Accept, Authorization" 63 | def cors_headers(request, response): 64 | response.headers.update({ 65 | # In production you would be careful with this 66 | "Access-Control-Allow-Origin": "*", 67 | "Access-Control-Allow-Headers": headers 68 | }) 69 | 70 | event.request.add_response_callback(cors_headers) 71 | 72 | 73 | if __name__ == '__main__': 74 | config = Configurator() 75 | config.include('rest_toolkit') 76 | # Publish the module's path as a static asset view 77 | config.add_static_view('static', dirname(realpath(__file__))) 78 | config.add_subscriber(add_cors_callback, NewRequest) 79 | config.scan(".") 80 | app = config.make_wsgi_app() 81 | server = make_server('0.0.0.0', 8088, app) 82 | server.serve_forever() 83 | -------------------------------------------------------------------------------- /demos/angular_security/controllers.js: -------------------------------------------------------------------------------- 1 | angular.module("app") 2 | 3 | .controller( 4 | "ListCtrl", 5 | function ($http, Todos, todoList) { 6 | 7 | var ctrl = this; 8 | ctrl.todos = todoList.data; 9 | ctrl.newTitle = ""; 10 | 11 | ctrl.addTodo = function (todoTitle) { 12 | Todos 13 | .save({}, {title: todoTitle}, 14 | function (todo) { 15 | ctrl.todos.push(todo.data); 16 | ctrl.newTitle = ""; 17 | } 18 | ); 19 | }; 20 | 21 | ctrl.deleteTodo = function (todoId) { 22 | Todos 23 | .delete({id: todoId}, function () { 24 | // Removed on the server, let's remove locally 25 | _.remove(ctrl.todos, {id: todoId}); 26 | }); 27 | }; 28 | 29 | }) 30 | 31 | 32 | .controller("ViewCtrl", function (todo) { 33 | this.todo = todo; 34 | }) 35 | 36 | 37 | .controller( 38 | "EditCtrl", 39 | function (todo, $location, endpointURL) { 40 | var ctrl = this; 41 | ctrl.todo = todo; 42 | 43 | // Handle the submit 44 | ctrl.updateTodo = function () { 45 | todo.$update(function () { 46 | $location.path(endpointURL); 47 | }); 48 | } 49 | }); -------------------------------------------------------------------------------- /demos/angular_security/edit.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

Edit title

3 | 4 |
6 | 7 |
8 | 9 | 11 |
12 | 13 |
14 | 15 |
-------------------------------------------------------------------------------- /demos/angular_security/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | {{ context.title }} 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /demos/angular_security/lib/angular-resource.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.2.16 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(H,a,A){'use strict';function D(p,g){g=g||{};a.forEach(g,function(a,c){delete g[c]});for(var c in p)!p.hasOwnProperty(c)||"$"===c.charAt(0)&&"$"===c.charAt(1)||(g[c]=p[c]);return g}var v=a.$$minErr("$resource"),C=/^(\.[a-zA-Z_$][0-9a-zA-Z_$]*)+$/;a.module("ngResource",["ng"]).factory("$resource",["$http","$q",function(p,g){function c(a,c){this.template=a;this.defaults=c||{};this.urlParams={}}function t(n,w,l){function r(h,d){var e={};d=x({},w,d);s(d,function(b,d){u(b)&&(b=b());var k;if(b&& 7 | b.charAt&&"@"==b.charAt(0)){k=h;var a=b.substr(1);if(null==a||""===a||"hasOwnProperty"===a||!C.test("."+a))throw v("badmember",a);for(var a=a.split("."),f=0,c=a.length;f 2 |

To Do Items

3 | 4 | 5 | 6 | 9 | 16 | 17 |
7 | title 8 | 10 | edit 12 | 15 |
18 |
20 | 22 | 23 |
24 | 25 |
-------------------------------------------------------------------------------- /demos/angular_security/view.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

title

3 |
-------------------------------------------------------------------------------- /demos/angular_todo/README.rst: -------------------------------------------------------------------------------- 1 | =================== 2 | angular_todo README 3 | =================== 4 | 5 | CRUD Todo example using AngularJS in a single-page application (SPA). 6 | 7 | Shows 8 | ===== 9 | 10 | - List/Add/View/Edit/Delete on Todo items 11 | 12 | - Single-page application with views for list/edit/view 13 | 14 | Usage 15 | ===== 16 | 17 | - ``python app.py`` 18 | 19 | - Go to ``http://localhost:8088/static/index.html`` 20 | 21 | Implementation 22 | ============== 23 | 24 | - Angular routes in ``app.py`` which provides the template/controller 25 | for each view, plus resolves any needed promises for getting data 26 | before opening the view 27 | 28 | - Collection and Item resources in ``app.py`` with a module-level 29 | dictionary holding the (modifiable) listing of todo items 30 | -------------------------------------------------------------------------------- /demos/angular_todo/app.js: -------------------------------------------------------------------------------- 1 | angular.module("app", ['ngRoute']) 2 | 3 | .value("endpointURL", "http://localhost:8088") 4 | 5 | .config(function ($routeProvider) { 6 | 7 | $routeProvider 8 | .when( 9 | '/', 10 | { 11 | templateUrl: "list.partial.html", 12 | controller: "ListCtrl", 13 | controllerAs: "ListCtrl" 14 | }) 15 | 16 | .when( 17 | '/todos/:todoId', 18 | { 19 | templateUrl: "view.partial.html", 20 | controller: "ViewCtrl", 21 | controllerAs: "ViewCtrl" 22 | }) 23 | 24 | .when( 25 | '/todos/:todoId/edit', 26 | { 27 | templateUrl: "edit.partial.html", 28 | controller: "EditCtrl", 29 | controllerAs: "EditCtrl" 30 | }) 31 | 32 | .otherwise({redirectTo: "/"}) 33 | }); -------------------------------------------------------------------------------- /demos/angular_todo/app.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from rest_toolkit import ( 4 | quick_serve, 5 | resource 6 | ) 7 | 8 | from rest_toolkit.abc import ( 9 | ViewableResource, 10 | EditableResource, 11 | DeletableResource 12 | ) 13 | 14 | todos = { 15 | "td1": {"id": "td1", "title": "Firstie"}, 16 | "td2": {"id": "td2", "title": "Second"}, 17 | "td3": {"id": "td3", "title": "Another"}, 18 | "td4": {"id": "td4", "title": "Last"} 19 | } 20 | 21 | 22 | @resource('/todos') 23 | class TodoCollection(object): 24 | def __init__(self, request): 25 | pass 26 | 27 | 28 | @TodoCollection.GET() 29 | def list_todos(collection, request): 30 | return {"todos": list(todos.values())} 31 | 32 | 33 | @TodoCollection.POST() 34 | def add_todo(collection, request): 35 | todo = { 36 | "id": "td" + str(randint(100, 9999)), 37 | "title": request.json_body["title"] 38 | } 39 | todos[todo["id"]] = todo 40 | return {"todo": todo} 41 | 42 | 43 | @resource('/todos/{id}') 44 | class TodoResource(EditableResource, ViewableResource, DeletableResource): 45 | def __init__(self, request): 46 | todo_id = request.matchdict['id'] 47 | self.todo = todos.get(todo_id) 48 | if self.todo is None: 49 | raise KeyError('Unknown event id') 50 | 51 | def to_dict(self): 52 | return self.todo 53 | 54 | def update_from_dict(self, data, replace=True): 55 | self.todo.title = data.title 56 | return {} 57 | 58 | def validate(self, data, partial): 59 | pass 60 | 61 | def delete(self): 62 | del todos[self.todo["id"]] 63 | 64 | 65 | if __name__ == '__main__': 66 | quick_serve(port=8088) -------------------------------------------------------------------------------- /demos/angular_todo/controllers.js: -------------------------------------------------------------------------------- 1 | angular.module("app") 2 | 3 | .controller( 4 | "ListCtrl", 5 | function ($http, endpointURL) { 6 | var ctrl = this; 7 | ctrl.todos = []; 8 | ctrl.newTitle = ""; 9 | 10 | $http.get(endpointURL + "/todos") 11 | .success(function (data) { 12 | ctrl.todos = data.todos; 13 | }); 14 | 15 | ctrl.addTodo = function (todoTitle) { 16 | $http.post(endpointURL + "/todos", {title: todoTitle}) 17 | .success(function (data) { 18 | ctrl.todos.push(data.todo); 19 | ctrl.newTitle = ""; 20 | }); 21 | }; 22 | 23 | ctrl.deleteTodo = function (todoId) { 24 | $http.delete(endpointURL + "/todos/" + todoId) 25 | .success(function () { 26 | // Removed on the server, let's remove locally 27 | _.remove(ctrl.todos, {id: todoId}); 28 | }); 29 | }; 30 | 31 | }) 32 | 33 | .controller( 34 | "ViewCtrl", 35 | function ($routeParams, $http, endpointURL) { 36 | var ctrl = this; 37 | var todoId = $routeParams.todoId; 38 | 39 | ctrl.todo = {}; 40 | $http.get(endpointURL + "/todos/" + todoId) 41 | .success(function (data) { 42 | ctrl.todo = data; 43 | }); 44 | }) 45 | 46 | .controller( 47 | "EditCtrl", 48 | function ($routeParams, $http, $location, endpointURL) { 49 | var ctrl = this; 50 | var todoId = $routeParams.todoId; 51 | 52 | ctrl.todo = {}; 53 | $http.get(endpointURL + "/todos/" + todoId) 54 | .success(function (data) { 55 | ctrl.todo.title = data.title; 56 | }); 57 | 58 | // Handle the submit 59 | ctrl.updateTodo = function () { 60 | $http.put(endpointURL + "/todos/" + todoId, ctrl.todo) 61 | .success(function () { 62 | $location.path("#/"); 63 | }); 64 | } 65 | }); -------------------------------------------------------------------------------- /demos/angular_todo/edit.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

Edit title

3 | 4 |
6 | 7 |
8 | 9 | 11 |
12 | 13 |
14 | 15 |
-------------------------------------------------------------------------------- /demos/angular_todo/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | AngularJS Todo Demo 6 | 7 | 8 | 9 | 10 |
11 | 12 |
13 | 14 |
15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | -------------------------------------------------------------------------------- /demos/angular_todo/lib/angular-route.min.js: -------------------------------------------------------------------------------- 1 | /* 2 | AngularJS v1.2.16 3 | (c) 2010-2014 Google, Inc. http://angularjs.org 4 | License: MIT 5 | */ 6 | (function(n,e,A){'use strict';function x(s,g,k){return{restrict:"ECA",terminal:!0,priority:400,transclude:"element",link:function(a,c,b,f,w){function y(){p&&(p.remove(),p=null);h&&(h.$destroy(),h=null);l&&(k.leave(l,function(){p=null}),p=l,l=null)}function v(){var b=s.current&&s.current.locals;if(e.isDefined(b&&b.$template)){var b=a.$new(),d=s.current;l=w(b,function(d){k.enter(d,null,l||c,function(){!e.isDefined(t)||t&&!a.$eval(t)||g()});y()});h=d.scope=b;h.$emit("$viewContentLoaded");h.$eval(u)}else y()} 7 | var h,l,p,t=b.autoscroll,u=b.onload||"";a.$on("$routeChangeSuccess",v);v()}}}function z(e,g,k){return{restrict:"ECA",priority:-400,link:function(a,c){var b=k.current,f=b.locals;c.html(f.$template);var w=e(c.contents());b.controller&&(f.$scope=a,f=g(b.controller,f),b.controllerAs&&(a[b.controllerAs]=f),c.data("$ngControllerController",f),c.children().data("$ngControllerController",f));w(a)}}}n=e.module("ngRoute",["ng"]).provider("$route",function(){function s(a,c){return e.extend(new (e.extend(function(){}, 8 | {prototype:a})),c)}function g(a,e){var b=e.caseInsensitiveMatch,f={originalPath:a,regexp:a},k=f.keys=[];a=a.replace(/([().])/g,"\\$1").replace(/(\/)?:(\w+)([\?\*])?/g,function(a,e,b,c){a="?"===c?c:null;c="*"===c?c:null;k.push({name:b,optional:!!a});e=e||"";return""+(a?"":e)+"(?:"+(a?e:"")+(c&&"(.+?)"||"([^/]+)")+(a||"")+")"+(a||"")}).replace(/([\/$\*])/g,"\\$1");f.regexp=RegExp("^"+a+"$",b?"i":"");return f}var k={};this.when=function(a,c){k[a]=e.extend({reloadOnSearch:!0},c,a&&g(a,c));if(a){var b= 9 | "/"==a[a.length-1]?a.substr(0,a.length-1):a+"/";k[b]=e.extend({redirectTo:a},g(b,c))}return this};this.otherwise=function(a){this.when(null,a);return this};this.$get=["$rootScope","$location","$routeParams","$q","$injector","$http","$templateCache","$sce",function(a,c,b,f,g,n,v,h){function l(){var d=p(),m=r.current;if(d&&m&&d.$$route===m.$$route&&e.equals(d.pathParams,m.pathParams)&&!d.reloadOnSearch&&!u)m.params=d.params,e.copy(m.params,b),a.$broadcast("$routeUpdate",m);else if(d||m)u=!1,a.$broadcast("$routeChangeStart", 10 | d,m),(r.current=d)&&d.redirectTo&&(e.isString(d.redirectTo)?c.path(t(d.redirectTo,d.params)).search(d.params).replace():c.url(d.redirectTo(d.pathParams,c.path(),c.search())).replace()),f.when(d).then(function(){if(d){var a=e.extend({},d.resolve),c,b;e.forEach(a,function(d,c){a[c]=e.isString(d)?g.get(d):g.invoke(d)});e.isDefined(c=d.template)?e.isFunction(c)&&(c=c(d.params)):e.isDefined(b=d.templateUrl)&&(e.isFunction(b)&&(b=b(d.params)),b=h.getTrustedResourceUrl(b),e.isDefined(b)&&(d.loadedTemplateUrl= 11 | b,c=n.get(b,{cache:v}).then(function(a){return a.data})));e.isDefined(c)&&(a.$template=c);return f.all(a)}}).then(function(c){d==r.current&&(d&&(d.locals=c,e.copy(d.params,b)),a.$broadcast("$routeChangeSuccess",d,m))},function(c){d==r.current&&a.$broadcast("$routeChangeError",d,m,c)})}function p(){var a,b;e.forEach(k,function(f,k){var q;if(q=!b){var g=c.path();q=f.keys;var l={};if(f.regexp)if(g=f.regexp.exec(g)){for(var h=1,p=g.length;h 2 |

To Do Items

3 | 4 | 5 | 6 | 9 | 16 | 17 |
7 | title 8 | 10 | edit 12 | 15 |
18 |
20 | 22 | 23 |
24 | 25 |
-------------------------------------------------------------------------------- /demos/angular_todo/view.partial.html: -------------------------------------------------------------------------------- 1 |
2 |

title

3 |
-------------------------------------------------------------------------------- /demos/macauth_demo.py: -------------------------------------------------------------------------------- 1 | 2 | 3 | import random 4 | import wsgiref.simple_server 5 | 6 | from pyramid.config import Configurator 7 | from pyramid.interfaces import IAuthenticationPolicy 8 | from pyramid.response import Response 9 | from pyramid.security import authenticated_userid 10 | from pyramid.exceptions import Forbidden 11 | 12 | from pyramid_macauth import MACAuthenticationPolicy 13 | 14 | 15 | TEMPLATE = """ 16 | Hello {userid}! 17 | Your lucky number for today is {number}. 18 | """ 19 | 20 | 21 | def lucky_number(request): 22 | """Pyramid view to generate a lucky number.""" 23 | 24 | # Check that the user is authenticated. 25 | userid = authenticated_userid(request) 26 | if userid is None: 27 | raise Forbidden() 28 | 29 | # Generate and return the lucky number. 30 | number = random.randint(1,100) 31 | return Response(TEMPLATE.format(**locals()), content_type="text/plain") 32 | 33 | 34 | def provision_creds(request): 35 | """Pyramid view to provision MACAuth credentials.""" 36 | 37 | # Check that the user is authenticated. 38 | userid = authenticated_userid(request) 39 | if userid is None: 40 | raise Forbidden() 41 | 42 | # Get a reference to the MACAuthenticationPolicy plugin. 43 | policy = request.registry.getUtility(IAuthenticationPolicy) 44 | policy = policy.get_policy(MACAuthenticationPolicy) 45 | 46 | # Generate a new id and secret key for the current user. 47 | id, key = policy.encode_mac_id(request, userid) 48 | return {"id": id, "key": key} 49 | 50 | 51 | def main(): 52 | """Construct and return a WSGI app for the luckynumber service.""" 53 | 54 | settings = { 55 | # The pyramid_persona plugin needs a master secret to use for 56 | # signing login cookies, and the expected hostname of your website 57 | # to prevent fradulent login attempts. 58 | "persona.secret": "TED KOPPEL IS A ROBOT", 59 | "persona.audiences": "localhost:8080", 60 | 61 | # The pyramid_macauth plugin needs a master secret to use for signing 62 | # its access tokens. We could use the same secret as above, but it's 63 | # generally a good idea to use different secrets for different things. 64 | "macauth.master_secret": "V8 JUICE IS 1/8TH GASOLINE", 65 | 66 | # The pyramid_multiauth plugin needs to be told what sub-policies to 67 | # load, and the order in which they should be tried. 68 | "multiauth.policies": "pyramid_persona pyramid_macauth", 69 | } 70 | 71 | config = Configurator(settings=settings) 72 | config.add_route("number", "/") 73 | config.add_view(lucky_number, route_name="number") 74 | 75 | # Including pyramid_multiauth magically enables authentication, loading 76 | # both of the policies we specified in the settings. 77 | config.include("pyramid_multiauth") 78 | 79 | # Both of our chosen policies configure a "forbidden view" to handle 80 | # unauthenticated access. We have to resolve this conflict by explicitly 81 | # picking which one we want to use. 82 | config.add_forbidden_view("pyramid_persona.views.forbidden") 83 | 84 | config.add_route("provision", "/provision") 85 | config.add_view(provision_creds, route_name="provision", renderer="json") 86 | 87 | return config.make_wsgi_app() 88 | 89 | 90 | if __name__ == "__main__": 91 | app = main() 92 | server = wsgiref.simple_server.make_server("", 8081, app) 93 | server.serve_forever() 94 | 95 | -------------------------------------------------------------------------------- /doc/Makefile: -------------------------------------------------------------------------------- 1 | # Makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line. 5 | SPHINXOPTS = 6 | SPHINXBUILD = sphinx-build 7 | PAPER = 8 | BUILDDIR = _build 9 | 10 | # User-friendly check for sphinx-build 11 | ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) 12 | $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) 13 | endif 14 | 15 | # Internal variables. 16 | PAPEROPT_a4 = -D latex_paper_size=a4 17 | PAPEROPT_letter = -D latex_paper_size=letter 18 | ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 19 | # the i18n builder cannot share the environment and doctrees with the others 20 | I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) . 21 | 22 | .PHONY: help clean html dirhtml singlehtml pickle json htmlhelp qthelp devhelp epub latex latexpdf text man changes linkcheck doctest gettext 23 | 24 | help: 25 | @echo "Please use \`make ' where is one of" 26 | @echo " html to make standalone HTML files" 27 | @echo " dirhtml to make HTML files named index.html in directories" 28 | @echo " singlehtml to make a single large HTML file" 29 | @echo " pickle to make pickle files" 30 | @echo " json to make JSON files" 31 | @echo " htmlhelp to make HTML files and a HTML help project" 32 | @echo " qthelp to make HTML files and a qthelp project" 33 | @echo " devhelp to make HTML files and a Devhelp project" 34 | @echo " epub to make an epub" 35 | @echo " latex to make LaTeX files, you can set PAPER=a4 or PAPER=letter" 36 | @echo " latexpdf to make LaTeX files and run them through pdflatex" 37 | @echo " latexpdfja to make LaTeX files and run them through platex/dvipdfmx" 38 | @echo " text to make text files" 39 | @echo " man to make manual pages" 40 | @echo " texinfo to make Texinfo files" 41 | @echo " info to make Texinfo files and run them through makeinfo" 42 | @echo " gettext to make PO message catalogs" 43 | @echo " changes to make an overview of all changed/added/deprecated items" 44 | @echo " xml to make Docutils-native XML files" 45 | @echo " pseudoxml to make pseudoxml-XML files for display purposes" 46 | @echo " linkcheck to check all external links for integrity" 47 | @echo " doctest to run all doctests embedded in the documentation (if enabled)" 48 | 49 | clean: 50 | rm -rf $(BUILDDIR)/* 51 | 52 | html: 53 | $(SPHINXBUILD) -b html $(ALLSPHINXOPTS) $(BUILDDIR)/html 54 | @echo 55 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/html." 56 | 57 | dirhtml: 58 | $(SPHINXBUILD) -b dirhtml $(ALLSPHINXOPTS) $(BUILDDIR)/dirhtml 59 | @echo 60 | @echo "Build finished. The HTML pages are in $(BUILDDIR)/dirhtml." 61 | 62 | singlehtml: 63 | $(SPHINXBUILD) -b singlehtml $(ALLSPHINXOPTS) $(BUILDDIR)/singlehtml 64 | @echo 65 | @echo "Build finished. The HTML page is in $(BUILDDIR)/singlehtml." 66 | 67 | pickle: 68 | $(SPHINXBUILD) -b pickle $(ALLSPHINXOPTS) $(BUILDDIR)/pickle 69 | @echo 70 | @echo "Build finished; now you can process the pickle files." 71 | 72 | json: 73 | $(SPHINXBUILD) -b json $(ALLSPHINXOPTS) $(BUILDDIR)/json 74 | @echo 75 | @echo "Build finished; now you can process the JSON files." 76 | 77 | htmlhelp: 78 | $(SPHINXBUILD) -b htmlhelp $(ALLSPHINXOPTS) $(BUILDDIR)/htmlhelp 79 | @echo 80 | @echo "Build finished; now you can run HTML Help Workshop with the" \ 81 | ".hhp project file in $(BUILDDIR)/htmlhelp." 82 | 83 | qthelp: 84 | $(SPHINXBUILD) -b qthelp $(ALLSPHINXOPTS) $(BUILDDIR)/qthelp 85 | @echo 86 | @echo "Build finished; now you can run "qcollectiongenerator" with the" \ 87 | ".qhcp project file in $(BUILDDIR)/qthelp, like this:" 88 | @echo "# qcollectiongenerator $(BUILDDIR)/qthelp/RESTToolkit.qhcp" 89 | @echo "To view the help file:" 90 | @echo "# assistant -collectionFile $(BUILDDIR)/qthelp/RESTToolkit.qhc" 91 | 92 | devhelp: 93 | $(SPHINXBUILD) -b devhelp $(ALLSPHINXOPTS) $(BUILDDIR)/devhelp 94 | @echo 95 | @echo "Build finished." 96 | @echo "To view the help file:" 97 | @echo "# mkdir -p $$HOME/.local/share/devhelp/RESTToolkit" 98 | @echo "# ln -s $(BUILDDIR)/devhelp $$HOME/.local/share/devhelp/RESTToolkit" 99 | @echo "# devhelp" 100 | 101 | epub: 102 | $(SPHINXBUILD) -b epub $(ALLSPHINXOPTS) $(BUILDDIR)/epub 103 | @echo 104 | @echo "Build finished. The epub file is in $(BUILDDIR)/epub." 105 | 106 | latex: 107 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 108 | @echo 109 | @echo "Build finished; the LaTeX files are in $(BUILDDIR)/latex." 110 | @echo "Run \`make' in that directory to run these through (pdf)latex" \ 111 | "(use \`make latexpdf' here to do that automatically)." 112 | 113 | latexpdf: 114 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 115 | @echo "Running LaTeX files through pdflatex..." 116 | $(MAKE) -C $(BUILDDIR)/latex all-pdf 117 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 118 | 119 | latexpdfja: 120 | $(SPHINXBUILD) -b latex $(ALLSPHINXOPTS) $(BUILDDIR)/latex 121 | @echo "Running LaTeX files through platex and dvipdfmx..." 122 | $(MAKE) -C $(BUILDDIR)/latex all-pdf-ja 123 | @echo "pdflatex finished; the PDF files are in $(BUILDDIR)/latex." 124 | 125 | text: 126 | $(SPHINXBUILD) -b text $(ALLSPHINXOPTS) $(BUILDDIR)/text 127 | @echo 128 | @echo "Build finished. The text files are in $(BUILDDIR)/text." 129 | 130 | man: 131 | $(SPHINXBUILD) -b man $(ALLSPHINXOPTS) $(BUILDDIR)/man 132 | @echo 133 | @echo "Build finished. The manual pages are in $(BUILDDIR)/man." 134 | 135 | texinfo: 136 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 137 | @echo 138 | @echo "Build finished. The Texinfo files are in $(BUILDDIR)/texinfo." 139 | @echo "Run \`make' in that directory to run these through makeinfo" \ 140 | "(use \`make info' here to do that automatically)." 141 | 142 | info: 143 | $(SPHINXBUILD) -b texinfo $(ALLSPHINXOPTS) $(BUILDDIR)/texinfo 144 | @echo "Running Texinfo files through makeinfo..." 145 | make -C $(BUILDDIR)/texinfo info 146 | @echo "makeinfo finished; the Info files are in $(BUILDDIR)/texinfo." 147 | 148 | gettext: 149 | $(SPHINXBUILD) -b gettext $(I18NSPHINXOPTS) $(BUILDDIR)/locale 150 | @echo 151 | @echo "Build finished. The message catalogs are in $(BUILDDIR)/locale." 152 | 153 | changes: 154 | $(SPHINXBUILD) -b changes $(ALLSPHINXOPTS) $(BUILDDIR)/changes 155 | @echo 156 | @echo "The overview file is in $(BUILDDIR)/changes." 157 | 158 | linkcheck: 159 | $(SPHINXBUILD) -b linkcheck $(ALLSPHINXOPTS) $(BUILDDIR)/linkcheck 160 | @echo 161 | @echo "Link check complete; look for any errors in the above output " \ 162 | "or in $(BUILDDIR)/linkcheck/output.txt." 163 | 164 | doctest: 165 | $(SPHINXBUILD) -b doctest $(ALLSPHINXOPTS) $(BUILDDIR)/doctest 166 | @echo "Testing of doctests in the sources finished, look at the " \ 167 | "results in $(BUILDDIR)/doctest/output.txt." 168 | 169 | xml: 170 | $(SPHINXBUILD) -b xml $(ALLSPHINXOPTS) $(BUILDDIR)/xml 171 | @echo 172 | @echo "Build finished. The XML files are in $(BUILDDIR)/xml." 173 | 174 | pseudoxml: 175 | $(SPHINXBUILD) -b pseudoxml $(ALLSPHINXOPTS) $(BUILDDIR)/pseudoxml 176 | @echo 177 | @echo "Build finished. The pseudo-XML files are in $(BUILDDIR)/pseudoxml." 178 | -------------------------------------------------------------------------------- /doc/api.rst: -------------------------------------------------------------------------------- 1 | API documentation 2 | ================= 3 | 4 | rest_toolkit 5 | ------------ 6 | 7 | .. module:: rest_toolkit 8 | 9 | .. autofunction:: includeme 10 | 11 | .. autofunction:: quick_serve 12 | 13 | .. autoclass:: resource 14 | 15 | .. autoclass:: ViewDecorator 16 | 17 | .. autoclass:: ControllerDecorator 18 | 19 | 20 | rest_toolkit.abc 21 | ---------------- 22 | 23 | .. module:: rest_toolkit.abc 24 | 25 | .. autoclass:: DeletableResource 26 | :members: 27 | 28 | .. autoclass:: EditableResource 29 | :members: 30 | 31 | .. autoclass:: ViewableResource 32 | :members: 33 | 34 | 35 | rest_toolkit.utils 36 | ------------------ 37 | 38 | .. module:: rest_toolkit.utils 39 | 40 | .. autofunction:: merge 41 | 42 | 43 | 44 | rest_toolkit.ext.colander 45 | --------------------------- 46 | 47 | .. module:: rest_toolkit.ext.colander 48 | 49 | .. autoclass:: ColanderSchemaValidationMixin 50 | :members: 51 | 52 | .. autofunction:: validate 53 | 54 | 55 | rest_toolkit.ext.jsonschema 56 | --------------------------- 57 | 58 | .. module:: rest_toolkit.ext.jsonschema 59 | 60 | .. autoclass:: JsonSchemaValidationMixin 61 | :members: 62 | 63 | .. autofunction:: validate 64 | 65 | 66 | rest_toolkit.ext.sql 67 | -------------------- 68 | 69 | .. module:: rest_toolkit.ext.sql 70 | 71 | .. autoclass:: SQLResource 72 | :members: 73 | 74 | .. autofunction:: set_sqlalchemy_session_factory 75 | 76 | .. autofunction:: includeme 77 | -------------------------------------------------------------------------------- /doc/basics.rst: -------------------------------------------------------------------------------- 1 | Basic usage 2 | =========== 3 | 4 | Defining a resource 5 | ------------------- 6 | 7 | Defining a resource is a simple two-step process: define a class for the 8 | resource, and then register it with rest_toolkit using the ``resource`` 9 | decorator. 10 | 11 | .. sidebar:: Decorators 12 | 13 | `Decorators` are a convenient way to add extra information or modify 14 | behaviour of a class or function. rest_toolkit uses decorators to 15 | register classes as resources and to configure views. For more information 16 | on decorators see the `decorator section of the Python Wikipedia page 17 | `_. 18 | 19 | 20 | .. code-block:: python 21 | :linenos: 22 | 23 | from rest_toolkit import resource 24 | from .models import DBSession 25 | from .models import Event 26 | 27 | 28 | @resource('/events/{id:\d+}') 29 | class EventResource(object): 30 | def __init__(self, request): 31 | event_id = request.matchdict['id'] 32 | self.event = DBSession.query(Event).get(event_id) 33 | if self.event is None: 34 | raise KeyError('Unknown event id') 35 | 36 | .. sidebar:: Pyramid implementation detail 37 | 38 | If you are familiar with Pyramid you may see that the ``resource`` decorator 39 | configures a route, using the class as context factory. 40 | 41 | 42 | The resource decorator does a couple of things: 43 | 44 | * It registers your class as a resource and associates it with the URL pattern. 45 | * It add CORS headers to the HTTP responses for all views associated with the 46 | resource.. 47 | * It adds a default views for ``OPTIONS`` requests. This will return an empty 48 | response with CORS headers indicating the supported HTTP methods. 49 | * It will return a `HTTP 405 Method Not Supported` response for any requests 50 | using a method for which no view is defined. 51 | 52 | 53 | Responding to requests 54 | ---------------------- 55 | 56 | A resource is only useful if it knows how to respond to HTTP requests. This 57 | is done by adding methods and using the ``view`` decorator to inform the system 58 | that they handle a specific HTTP method. 59 | 60 | .. code-block:: python 61 | :linenos: 62 | 63 | @EventResource.GET() 64 | def view_event(resource, request): 65 | return {...} 66 | 67 | 68 | @EventResource.PUT() 69 | def update_event(resource, request): 70 | return {...} 71 | 72 | .. sidebar:: Pyramid implementation detail 73 | 74 | The request-method decorators are thin wrappers around pyramid's 75 | :meth:`pyramid:pyramid.config.Configurator.add_view` method. All arguments 76 | accepted by add_view can be used with request-method decorators as well. 77 | 78 | If a browser sends a ``GET`` request for ``/events/12`` an instance of the 79 | ``EventResource`` class is created, and its ``GET`` view, the ``view_event`` 80 | function in the above example, is called to generate a response. 81 | 82 | 83 | .. _default-views: 84 | 85 | Default views 86 | ------------- 87 | 88 | If your resource class meets certain requirements rest_toolkit will provide 89 | default views. For example if your resource class is derived from 90 | :py:class:`rest_toolkit.abc.ViewableResource` and implements the `to_dict` 91 | method you automatically get a `GET` view which returns the data returned 92 | by that method. 93 | 94 | .. code-block:: python 95 | :linenos: 96 | 97 | from rest_toolkit import resource 98 | from rest_toolkit.abc import ViewableResource 99 | 100 | 101 | @resource('/events/{id:\d+}') 102 | class EventResource(ViewableResource): 103 | def __init__(self, request): 104 | ... 105 | 106 | def to_dict(self): 107 | return {'id': self.event.id, 108 | 'title': self.event.title} 109 | 110 | The table below lists the base class you must implement for each 111 | default view. 112 | 113 | +--------+------------------------------------------------+ 114 | | Method | Class | 115 | +========+================================================+ 116 | | DELETE | :py:class:`rest_toolkit.abc.DeletableResource` | 117 | +--------+------------------------------------------------+ 118 | | GET | :py:class:`rest_toolkit.abc.ViewableResource` | 119 | +--------+------------------------------------------------+ 120 | | PATCH | :py:class:`rest_toolkit.abc.EditableResource` | 121 | +--------+------------------------------------------------+ 122 | | PUT | :py:class:`rest_toolkit.abc.EditableResource` | 123 | +--------+------------------------------------------------+ 124 | 125 | 126 | Adding a controller 127 | ------------------- 128 | 129 | A controller is a special type of resource which is used to trigger an action. 130 | A controller is similar to a button: it does not have any state itself, but it 131 | can modify state of something else. For example a reboot button which will 132 | trigger a server reset. You can define a controller resource manually, but 133 | you can also do so directly on a normal resource using the `controller` 134 | decorator. 135 | 136 | 137 | .. code-block:: python 138 | :linenos: 139 | 140 | @EventResource.controller(name='reboot') 141 | def reboot(resource, request): 142 | return {...} 143 | 144 | If you send a ``POST`` to ``/servers/47/reboot`` an instance of the ``Server`` 145 | resource will be created, and its ``reboot`` method will be called. 146 | 147 | Controllers normally only respond to ``POST`` requests. You can use the 148 | ``request_method`` option to respond to different request method. You can 149 | also use the controller decorator multiple times to registered separate views 150 | for each request method. 151 | 152 | .. code-block:: python 153 | :linenos: 154 | 155 | @EventResource.controller(name='lockdown') 156 | def lockdown(resource, request): 157 | return {...} 158 | 159 | @EventResource.controller(name='lockdown', request_method='GET') 160 | def is_locked_down(resource, request): 161 | return {...} 162 | -------------------------------------------------------------------------------- /doc/changes.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.17 - January 10, 2019 5 | ----------------------- 6 | 7 | - Fix usage of NotImplemented 8 | 9 | 10 | 0.16 - April 14, 2018 11 | --------------------- 12 | 13 | - Fix error in handling of JSON validation errors in arrays. 14 | 15 | 16 | 0.15 - April 5, 2018 17 | -------------------- 18 | 19 | - Do not set content-type header on 204 responses. 20 | 21 | 22 | 0.14 - May 25, 2017 23 | ------------------- 24 | 25 | - Fix a Pyramid depcreation warning for ``unauthenticated_userid``. 26 | 27 | 28 | 0.13 - August 18, 2016 29 | ---------------------- 30 | 31 | - Preserve response exceptions raised by resource constructors or views if they 32 | already have a JSON content type. 33 | 34 | - Modify default JSON validation error response format: use the field with the 35 | validation error as key in the response data. 36 | 37 | - Use a custom ``rest_toolkit.ext.jsonschema.JSONValidationError`` exception for 38 | JSON validation errors. This allows for easy customisation of validation error 39 | response by defining a view for ``JSONValidationError``. 40 | 41 | 42 | 0.12 - June 1, 2016 43 | ------------------- 44 | 45 | - Pass extra resource and controller arguments to the underlying ``add_view()`` 46 | calls. This allows using predicates for views. 47 | 48 | 49 | 0.11 - May 6, 2016 50 | ------------------ 51 | 52 | - Allow ``update_from_dict`` to return a custom response, which will be used 53 | by the default PATCH and PUT views. 54 | 55 | 56 | 0.10 - May 4, 2016 57 | ------------------ 58 | 59 | - Do not require any permssions for OPTIONS requests. This can badly break 60 | authentication, since OPTIONS will be called to check if auth-related 61 | headers may be send. 62 | 63 | - Do not register catch-all exception view if debugging is enabled. 64 | 65 | 66 | 0.9 - September 20, 2015 67 | ------------------------ 68 | 69 | - Default to not allowing primary key changes for SQLResource objects. This can 70 | be toggled with a new ``allow_primary_key_change`` variable on the resource 71 | class. 72 | 73 | - Correctly set ``Access-Control-Allow-Methods`` header for resources using 74 | default views. 75 | 76 | 77 | 0.8 - September 5, 2015 78 | ----------------------- 79 | 80 | - Correctly handle OPTIONS requests for controllers. 81 | 82 | - Do not require any permissions for the generic error view. This fixes any 83 | errors being converted to forbidden errors on sites with a default 84 | permission. 85 | 86 | 87 | 0.7 - March 12, 2015 88 | -------------------- 89 | 90 | - Fix editing of SQL resource. 91 | 92 | - Update default views and validation extensions not to assume anything about 93 | the ``to_dict()`` return format. 94 | 95 | - If the ``rest_toolkit.debug`` is set, or the ``REST_TOOLKIT_DEBUG`` 96 | environment variable is set to ``true``, or the pyramid's debug-all flag is 97 | set the system error exception handler will add the exception traceback to 98 | the response under a new ``traceback`` key. 99 | 100 | - Add basic support for collection resources. These can handle ``POST`` 101 | requests to create child objects. 102 | 103 | 104 | 0.6 - November 4, 2014 105 | ---------------------- 106 | 107 | - Make sure controllers for resource whose path do not end in a slash are 108 | reachable. This fixes `issue 12 109 | `_. 110 | 111 | - Fix mismatch between code and documentation: use ``request_method`` 112 | as parameter name for the ``controller`` decorator. 113 | 114 | 115 | 0.5 - October 24, 2014 116 | ---------------------- 117 | 118 | - Allow overriding the request method for controllers. This fixes 119 | `issue 10 `_. 120 | 121 | - Add ``read_permission``, ``update_permission`` and ``delete_permission`` 122 | options to the ``resource`` decorator to set permissions for default views. 123 | This fixes `issue 8 `_. 124 | 125 | - Rely on fixtures provided by pyramid_sqlalchemy for our SQL-related tests. 126 | 127 | - Preserve headers when converting a HTTP response to JSON. This fixes 128 | `issue 6 `_. 129 | 130 | - The route name for a resource can now be configured with a ``route_name`` parameter 131 | for the ``resource`` decorator. 132 | 133 | 134 | 0.4.1 - July 18, 2014 135 | --------------------- 136 | 137 | - Make sure all raised HTTP exceptions are converted to JSON responses. 138 | 139 | 140 | 0.4 - July 18, 2014 141 | ------------------- 142 | 143 | This releases focuses on improving the documentation and fixing problems in the 144 | SQL extension. 145 | 146 | - Fix several errors in the SQLResource defaults views. 147 | 148 | - Configuring the SQL extension is no longer necessary if you use 149 | `pyramid_sqlalchemy `_ to handle 150 | SQLAlchemy integration. 151 | 152 | - `Travis `_ is now setup to 153 | automatically run tests on CPython 2.7, CPython 3.3, CPython 3.4 and PyPy. 154 | 155 | - Fix Python 3 compatibility problem in the generic error view. 156 | 157 | - Drop explicit Python 2.6 support. The tests use too many set literals to make 158 | Python 2.6 worthwile. 159 | 160 | - Modify EditableResource to not inherit from ViewableResource. This makes 161 | the separation between editing and viewing explicit, and works around the 162 | inability of Python to handle the inheritance schemes where a base classes 163 | is used multiple times. 164 | 165 | - Remove the default value for ``replace`` in 166 | ``EditableResource.updat_from_dict()``. This did not serve a useful purpose, 167 | and could be confusing. 168 | 169 | - Set ``self.request`` in SQLResource constructor. 170 | 171 | 172 | 0.3 - July 11, 2014 173 | ------------------- 174 | 175 | This release fixes several critical errors in the SQL extension: 176 | 177 | - Fix the invoction of the context query. 178 | 179 | - Return not-found error from SQLResource instead of an internal error when no 180 | SQL row could be found. 181 | 182 | - Do not enable default views for SQLResource automatically. This should be 183 | an explicit decision by the user. 184 | 185 | 186 | 0.2.2 - July 11, 2014 187 | --------------------- 188 | 189 | - Fix several errors in SQL extension. 190 | 191 | 192 | 0.2.1 - July 10, 2014 193 | --------------------- 194 | 195 | - Add a MANIFEST.in to the source distribution installable. 196 | 197 | 198 | 0.2 - July 9, 2014 199 | ------------------ 200 | 201 | - Several demos showing how to use rest_toolkit with AngularJS have been added. 202 | 203 | - Support for default DELETE, GET, PATCH and PUT views has been added. 204 | 205 | - Various documentation fixes and improvements. 206 | 207 | 208 | 0.1 - Released 24 June, 2014 209 | ---------------------------- 210 | 211 | This is the first release. 212 | -------------------------------------------------------------------------------- /doc/comparison.rst: -------------------------------------------------------------------------------- 1 | Comparison with other frameworks 2 | ================================ 3 | 4 | cornice 5 | ------- 6 | 7 | - Cornice has both a Service and a resource concept, but does not explain how 8 | they relate. Internally a resource creates a Service and adds views to it, 9 | but the rationale for the distinction is unclear. It mostly seems to be a 10 | design alternative: a Service has views as external functions or classes, 11 | while a resource is a class that is the resource and contains all its views 12 | as methods. resources also add extra magic collection-logic. All in all 13 | they seem like a vaguely defined convenience thing that should not be 14 | (presented as) part of the core framework. 15 | 16 | - Cornice does not seem to (conveniently) support a controller resource. 17 | 18 | - I can't find any useful documentation for configurating ACLs or permissions 19 | with cornice. There is an `acl` parameters for the `Service` class, but 20 | that conflicts with `factory`. That seems like a strange un-pyramidy design. 21 | 22 | - Cornice does a lot itself: filtering, validating, error handling, etc. A lot 23 | of that is unneeded for many REST services, and all of that can be added on 24 | top of a simpler framework, for example by using mix-in classes for resources. 25 | 26 | - Cornice uses a global internally to track all registered resources. This makes 27 | it impossible to use multiple cornice instances in the same process (i.e. a 28 | composite app). Personally I find that a non-goal anyway, but it's arguably 29 | a design flaw. 30 | 31 | - Interaction with standard Pyramid tools (predicates, request method filters, 32 | ACLs, etc. etc.) is either undocumented, very incompletely or missing 33 | completely. 34 | 35 | - For unknown reasons cornice uses its own JSON renderer. 36 | 37 | - cornice has some facilities to automatically create documentation for an 38 | API using code comments and colander schemas. rest_toolkit does not try to 39 | do that: in my experience good documentation can never be generated. 40 | Writing documentation requires a different mindset and structure than 41 | writing code, and you should not try to mix the two. 42 | 43 | 44 | 45 | Django REST 46 | ----------- 47 | 48 | - A Django app, which means have to use Django infrastructure and 49 | tools. This may not be a good match for non-typical Django applications. 50 | 51 | - It implements its own authorisation mechanism. I’m guessing Django does not 52 | have a standard version it can leverage? 53 | 54 | - Require a bit more boilerplate than should be necessary. 55 | 56 | 57 | sandman 58 | ------- 59 | 60 | - sandman forces your REST API structure to exactly match your database model. 61 | In non-trivial systems that will generally not work: if there is any 62 | hierarchy in your data model sandman will not reflect that, normalisation is 63 | not undone in a REST API which means your REST interface will be much more 64 | complex and require more requests than needed, relationships between objects 65 | are not exposed. 66 | 67 | - sandman does not support any form of authorisation. You can either do nothing 68 | at all, or everything. 69 | 70 | - sandman does not support any way to add controllers that perform actions. 71 | Since it’s flask under the hook you could probably add that yourself if 72 | necessary, but this is undocumented. 73 | 74 | -------------------------------------------------------------------------------- /doc/conf.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | # 3 | # REST Toolkit documentation build configuration file, created by 4 | # sphinx-quickstart on Fri Jun 20 12:43:40 2014. 5 | # 6 | # This file is execfile()d with the current directory set to its 7 | # containing dir. 8 | # 9 | # Note that not all possible configuration values are present in this 10 | # autogenerated file. 11 | # 12 | # All configuration values have a default; values that are commented out 13 | # serve to show the default. 14 | 15 | import sys 16 | import os 17 | 18 | # If extensions (or modules to document with autodoc) are in another directory, 19 | # add these directories to sys.path here. If the directory is relative to the 20 | # documentation root, use os.path.abspath to make it absolute, like shown here. 21 | sys.path.insert(0, os.path.abspath('../src')) 22 | sys.path.insert(0, os.path.abspath('.')) 23 | import rest_toolkit 24 | 25 | # -- General configuration ------------------------------------------------ 26 | 27 | # If your documentation needs a minimal Sphinx version, state it here. 28 | #needs_sphinx = '1.0' 29 | 30 | # Add any Sphinx extension module names here, as strings. They can be 31 | # extensions coming with Sphinx (named 'sphinx.ext.*') or your custom 32 | # ones. 33 | extensions = ['sphinx.ext.autodoc', 34 | 'sphinx.ext.graphviz', 35 | 'sphinx.ext.intersphinx'] 36 | 37 | # Add any paths that contain templates here, relative to this directory. 38 | templates_path = ['_templates'] 39 | 40 | # The suffix of source filenames. 41 | source_suffix = '.rst' 42 | 43 | # The encoding of source files. 44 | #source_encoding = 'utf-8-sig' 45 | 46 | # The master toctree document. 47 | master_doc = 'index' 48 | 49 | # General information about the project. 50 | project = u'REST Toolkit' 51 | copyright = u'2014, Wichert Akkerman' 52 | 53 | # The version info for the project you're documenting, acts as replacement for 54 | # |version| and |release|, also used in various other places throughout the 55 | # built documents. 56 | # 57 | # The short X.Y version. 58 | version = '1.0' 59 | # The full version, including alpha/beta/rc tags. 60 | release = '1.0' 61 | 62 | # The language for content autogenerated by Sphinx. Refer to documentation 63 | # for a list of supported languages. 64 | #language = None 65 | 66 | # There are two options for replacing |today|: either, you set today to some 67 | # non-false value, then it is used: 68 | #today = '' 69 | # Else, today_fmt is used as the format for a strftime call. 70 | #today_fmt = '%B %d, %Y' 71 | 72 | # List of patterns, relative to source directory, that match files and 73 | # directories to ignore when looking for source files. 74 | exclude_patterns = ['_build'] 75 | 76 | # The reST default role (used for this markup: `text`) to use for all 77 | # documents. 78 | #default_role = None 79 | 80 | # If true, '()' will be appended to :func: etc. cross-reference text. 81 | #add_function_parentheses = True 82 | 83 | # If true, the current module name will be prepended to all description 84 | # unit titles (such as .. function::). 85 | #add_module_names = True 86 | 87 | # If true, sectionauthor and moduleauthor directives will be shown in the 88 | # output. They are ignored by default. 89 | #show_authors = False 90 | 91 | # The name of the Pygments (syntax highlighting) style to use. 92 | pygments_style = 'sphinx' 93 | 94 | # A list of ignored prefixes for module index sorting. 95 | #modindex_common_prefix = [] 96 | 97 | # If true, keep warnings as "system message" paragraphs in the built documents. 98 | #keep_warnings = False 99 | 100 | 101 | # -- Options for HTML output ---------------------------------------------- 102 | 103 | # The theme to use for HTML and HTML Help pages. See the documentation for 104 | # a list of builtin themes. 105 | html_theme = 'haiku' 106 | 107 | # Theme options are theme-specific and customize the look and feel of a theme 108 | # further. For a list of options available for each theme, see the 109 | # documentation. 110 | #html_theme_options = {} 111 | 112 | # Add any paths that contain custom themes here, relative to this directory. 113 | #html_theme_path = [] 114 | 115 | # The name for this set of Sphinx documents. If None, it defaults to 116 | # " v documentation". 117 | #html_title = None 118 | 119 | # A shorter title for the navigation bar. Default is the same as html_title. 120 | #html_short_title = None 121 | 122 | # The name of an image file (relative to this directory) to place at the top 123 | # of the sidebar. 124 | #html_logo = None 125 | 126 | # The name of an image file (within the static path) to use as favicon of the 127 | # docs. This file should be a Windows icon file (.ico) being 16x16 or 32x32 128 | # pixels large. 129 | #html_favicon = None 130 | 131 | # Add any paths that contain custom static files (such as style sheets) here, 132 | # relative to this directory. They are copied after the builtin static files, 133 | # so a file named "default.css" will overwrite the builtin "default.css". 134 | html_static_path = ['_static'] 135 | 136 | # Add any extra paths that contain custom files (such as robots.txt or 137 | # .htaccess) here, relative to this directory. These files are copied 138 | # directly to the root of the documentation. 139 | #html_extra_path = [] 140 | 141 | # If not '', a 'Last updated on:' timestamp is inserted at every page bottom, 142 | # using the given strftime format. 143 | #html_last_updated_fmt = '%b %d, %Y' 144 | 145 | # If true, SmartyPants will be used to convert quotes and dashes to 146 | # typographically correct entities. 147 | #html_use_smartypants = True 148 | 149 | # Custom sidebar templates, maps document names to template names. 150 | #html_sidebars = {} 151 | 152 | # Additional templates that should be rendered to pages, maps page names to 153 | # template names. 154 | #html_additional_pages = {} 155 | 156 | # If false, no module index is generated. 157 | #html_domain_indices = True 158 | 159 | # If false, no index is generated. 160 | #html_use_index = True 161 | 162 | # If true, the index is split into individual pages for each letter. 163 | #html_split_index = False 164 | 165 | # If true, links to the reST sources are added to the pages. 166 | #html_show_sourcelink = True 167 | 168 | # If true, "Created using Sphinx" is shown in the HTML footer. Default is True. 169 | #html_show_sphinx = True 170 | 171 | # If true, "(C) Copyright ..." is shown in the HTML footer. Default is True. 172 | #html_show_copyright = True 173 | 174 | # If true, an OpenSearch description file will be output, and all pages will 175 | # contain a tag referring to it. The value of this option must be the 176 | # base URL from which the finished HTML is served. 177 | #html_use_opensearch = '' 178 | 179 | # This is the file name suffix for HTML files (e.g. ".xhtml"). 180 | #html_file_suffix = None 181 | 182 | # Output file base name for HTML help builder. 183 | htmlhelp_basename = 'RESTToolkitdoc' 184 | 185 | 186 | # -- Options for LaTeX output --------------------------------------------- 187 | 188 | latex_elements = { 189 | # The paper size ('letterpaper' or 'a4paper'). 190 | #'papersize': 'letterpaper', 191 | 192 | # The font size ('10pt', '11pt' or '12pt'). 193 | #'pointsize': '10pt', 194 | 195 | # Additional stuff for the LaTeX preamble. 196 | #'preamble': '', 197 | } 198 | 199 | # Grouping the document tree into LaTeX files. List of tuples 200 | # (source start file, target name, title, 201 | # author, documentclass [howto, manual, or own class]). 202 | latex_documents = [ 203 | ('index', 'RESTToolkit.tex', u'REST Toolkit Documentation', 204 | u'Wichert Akkerman', 'manual'), 205 | ] 206 | 207 | # The name of an image file (relative to this directory) to place at the top of 208 | # the title page. 209 | #latex_logo = None 210 | 211 | # For "manual" documents, if this is true, then toplevel headings are parts, 212 | # not chapters. 213 | #latex_use_parts = False 214 | 215 | # If true, show page references after internal links. 216 | #latex_show_pagerefs = False 217 | 218 | # If true, show URL addresses after external links. 219 | #latex_show_urls = False 220 | 221 | # Documents to append as an appendix to all manuals. 222 | #latex_appendices = [] 223 | 224 | # If false, no module index is generated. 225 | #latex_domain_indices = True 226 | 227 | 228 | # -- Options for manual page output --------------------------------------- 229 | 230 | # One entry per manual page. List of tuples 231 | # (source start file, name, description, authors, manual section). 232 | man_pages = [ 233 | ('index', 'resttoolkit', u'REST Toolkit Documentation', 234 | [u'Wichert Akkerman'], 1) 235 | ] 236 | 237 | # If true, show URL addresses after external links. 238 | #man_show_urls = False 239 | 240 | 241 | # -- Options for Texinfo output ------------------------------------------- 242 | 243 | # Grouping the document tree into Texinfo files. List of tuples 244 | # (source start file, target name, title, author, 245 | # dir menu entry, description, category) 246 | texinfo_documents = [ 247 | ('index', 'RESTToolkit', u'REST Toolkit Documentation', 248 | u'Wichert Akkerman', 'RESTToolkit', 'One line description of project.', 249 | 'Miscellaneous'), 250 | ] 251 | 252 | # Documents to append as an appendix to all manuals. 253 | #texinfo_appendices = [] 254 | 255 | # If false, no module index is generated. 256 | #texinfo_domain_indices = True 257 | 258 | # How to display URL addresses: 'footnote', 'no', or 'inline'. 259 | #texinfo_show_urls = 'footnote' 260 | 261 | # If true, do not generate a @detailmenu in the "Top" node's menu. 262 | #texinfo_no_detailmenu = False 263 | 264 | intersphinx_mapping = { 265 | 'python': ('http://docs.python.org/2/', None), 266 | 'pyramid': ('http://pyramid.readthedocs.org/en/latest/', None), 267 | 'sqlalchemy': ('http://docs.sqlalchemy.org/en/latest/', None) 268 | } 269 | 270 | 271 | graphviz_output_format = 'svg' 272 | -------------------------------------------------------------------------------- /doc/index.rst: -------------------------------------------------------------------------------- 1 | Introduction 2 | ============ 3 | 4 | *rest_toolkit* is a Python package which provides a very convenient way to 5 | build REST servers. It is build on top of 6 | `Pyramid `_, but you do not 7 | need to know much about Pyramid to use rest_toolkit. 8 | 9 | 10 | Quick example 11 | ============= 12 | 13 | This is a minimal example which defines a ``Root`` resource with a ``GET`` 14 | view, and starts a simple HTTP server. If you run this example you can request 15 | ``http://localhost:8080/`` and you will see a JSON response with a status 16 | message. 17 | 18 | .. code-block:: python 19 | :linenos: 20 | 21 | from rest_toolkit import quick_serve 22 | from rest_toolkit import resource 23 | 24 | 25 | @resource('/') 26 | class Root(object): 27 | def __init__(self, request): 28 | pass 29 | 30 | @Root.GET() 31 | def show_root(root, request): 32 | return {'status': 'OK'} 33 | 34 | 35 | if __name__ == '__main__': 36 | quick_serve() 37 | 38 | 39 | The previous example is simple, but real REST services are likely to be 40 | much more complex, for example because they need to request data from a 41 | SQL server. The next example shows how you can use SQL data. 42 | 43 | 44 | .. code-block:: python 45 | :linenos: 46 | 47 | from rest_toolkit import quick_serve 48 | from rest_toolkit import resource 49 | from rest_toolkit.ext.sql import SQLResource 50 | from sqlalchemy import Column, Integer, String, bindparam 51 | from sqlalchemy.orm import Query 52 | from pyramid_sqlalchemy import BaseObject 53 | from pyramid_sqlalchemy import DBSession 54 | 55 | 56 | class User(BaseObject): 57 | __tablename__ = 'user' 58 | id = Column(Integer, primary_key=True) 59 | fullname = Column(String) 60 | 61 | 62 | @resource('/users') 63 | class UserCollection(object): 64 | def __init__(self, request): 65 | pass 66 | 67 | 68 | @UserCollection.GET() 69 | def list_users(collection, request): 70 | return {'users': [{'id': user.id, 71 | 'fullname': user.fullname} 72 | for user in DBSession.query(User)]} 73 | 74 | 75 | @resource('/users/{id}') 76 | class UserResource(SQLResource): 77 | context_query = Query(User) .filter(User.id == bindparam('id')) 78 | 79 | 80 | @UserResource.GET() 81 | def show_user(user, request): 82 | return {'id': user.id, 'fullname': user.fullname} 83 | 84 | 85 | if __name__ == '__main__': 86 | quick_serve(DBSession) 87 | 88 | This example creates two resources: a ``/users`` collection which will return a 89 | list of all users for a ``GET``-request, and a ``/users/`` resource which 90 | will return information for an individual user on a ``GET``-request. 91 | 92 | 93 | Contents 94 | ======== 95 | 96 | .. toctree:: 97 | :maxdepth: 2 98 | 99 | basics 100 | sql 101 | security 102 | philosophy 103 | api 104 | comparison 105 | changes 106 | 107 | 108 | Indices and tables 109 | ================== 110 | 111 | * :ref:`genindex` 112 | * :ref:`modindex` 113 | * :ref:`search` 114 | -------------------------------------------------------------------------------- /doc/philosophy.rst: -------------------------------------------------------------------------------- 1 | .. _philosophy-chapter: 2 | 3 | Philosophy 4 | ========= 5 | 6 | *rest_toolkit* tries to follow the standard 7 | `REST `_ 8 | standards for HTTP as much as possible: 9 | 10 | * every URL uniquely identifies a *resource* 11 | * an ``OPTIONS`` request must return the list of supported request methods in 12 | an ``Access-Control-Allow-Methods`` header. 13 | * a request using an unsupported request method must return a HTTP 405 error. 14 | 15 | A resource typically corresponds to something stored in a database. The mapping 16 | does not need to be one-to-one: stored data can be exposed at multiple places 17 | by an API, and each location is a separate resource from a REST point of view. 18 | For example in an event management system a user can see see event information 19 | in a list of events he has registered for as ``/users/12/events/13``, while an 20 | event staff member manages the event via a ``/events/13``. Both URLs will use the 21 | same event object in the database, but are separate REST resources, and will 22 | return different data, use a different ACL, etc. 23 | 24 | *rest_toolkit* follows this philosophy ant matches URLs to resources instead of 25 | stored data. This has several advantages: 26 | 27 | * your data model does not need to be aware of frontend-specific things like 28 | access control lists or JSON formatting. 29 | 30 | * you can easily present the same data in multiple ways. 31 | 32 | Request flow 33 | ------------ 34 | 35 | When processing a request pyramid will go through several steps. 36 | 37 | .. graphviz:: 38 | :alt: Visual overview of the request flow. 39 | 40 | digraph flow { 41 | rankdir=LR; 42 | node [shape=rounded, style=filled, penwidth=0.5, fontname=Arial, fontsize=12] 43 | edge [fontname="Arial:italic", fontsize=11, penwidth=0.5] 44 | 45 | request [label="GET /events/123"] 46 | resource [label="EventResource"] 47 | view [label="view_event() function"] 48 | response [label="JSON response"] 49 | 50 | request -> resource [label="Find resource\nfor /events/1"] 51 | resource -> view [label="Find GET view\nfor EventResource"] 52 | view -> response [label="Call view_event()"] 53 | } 54 | 55 | 1. When a request comes in the first step is to find a resource class which 56 | matches the requested URL. 57 | 2. The constructor for the resource class found in step 1 is called to create 58 | the resource instance. The constructor can raise an exception at this step 59 | to indicate no resource data could be found, for example if an requested 60 | id can not be found in a database. 61 | 3. Try to find a view for the resource and request type. This can either be a 62 | :ref:`default view ` or a view defined via an request method 63 | decorator. If no view is found a `HTTP 405 Method Not Allowed` error is 64 | returned. 65 | 4. The view is invoked. The data it returns will be converted to JSON and 66 | returned to the client. 67 | -------------------------------------------------------------------------------- /doc/security.rst: -------------------------------------------------------------------------------- 1 | Security 2 | ======== 3 | 4 | *rest_toolkit* allows you to use the :ref:`Pyramid's security 5 | ` system directly. To use this you need to do a 6 | couple of things: 7 | 8 | * :ref:`Configure authentication and authorization policies 9 | `. 10 | * Define an :ref:`Access Control List (ACL) ` for 11 | your resources. 12 | 13 | 14 | Since REST resources as request context (sometimes also called request 15 | objects in Pyramid's documentation) protection resources is as simple as 16 | adding an ``__acl__`` attribute or method to your resource, and specifying a 17 | permission on a view. 18 | 19 | 20 | .. code-block:: python 21 | :linenos: 22 | :emphasize-lines: 10,11,12,14 23 | 24 | from pyramid.security import Allow 25 | from pyramid.security import Everyone 26 | from pyramid_rest import Resource 27 | 28 | 29 | @resource('/events/{id:\d+}') 30 | class EventResource(Resource): 31 | ... 32 | 33 | def __acl__(self): 34 | return [(Allow, Everyone, ['read']), 35 | (Allow, self.event.owner.id, ['delete', 'update'])] 36 | 37 | @EventResource.GET(permission='read') 38 | def view(self): 39 | return {...} 40 | 41 | 42 | This example above uses a method to define the ACL in lines 10-12. The ACL does 43 | two things: it specifies that everyone has `read`-permissions, and the owner 44 | of the event also has `delete` and `update` permissions. The ``GET`` view 45 | is the configured to require the read-permission in line 14. 46 | 47 | If you use the default views for DELETE, GET, PATCH or PUT provided by 48 | rest_toolkit you can set their permissions using the ``read_permission``, 49 | ``update_permission`` and ``delete_permission`` arguments to the 50 | ``resource()`` constructor. 51 | 52 | .. code-block:: python 53 | :linenos: 54 | 55 | from rest_toolkit import resource 56 | from rest_toolkit.abc import ViewableResource 57 | 58 | 59 | @resource('/events/{id:\d+}', read_permission='read') 60 | class EventResource(ViewableResource): 61 | def __acl__(self): 62 | return [(Allow, Everyone, ['read'])] 63 | -------------------------------------------------------------------------------- /doc/sql.rst: -------------------------------------------------------------------------------- 1 | SQL support 2 | =========== 3 | 4 | *rest_toolkit* has a SQL-extension that makes it easy to use `SQLAlchemy 5 | `_ models in your REST application. 6 | 7 | In line with rest_toolkit's :ref:`philosophy ` a SQLAlchemy 8 | model is not used directly as a resource. Instead a `SQLResource` class is used 9 | which wraps a SQL model. This resource class can define things like the 10 | :ref:`ACLs ` and methods needed for :ref:`default views 11 | `. This keeps a clean separation between the data model and 12 | related business logic and the frontend. 13 | 14 | 15 | Setup 16 | ----- 17 | 18 | The toolkit assumes you are using `pyramid_sqlalchemy 19 | `_ to handle the SQLAlchemy 20 | integration. If you do not use pyramid_sqlalchemy you will need to point the 21 | SQL extension to your session factory. This is done during application 22 | initialisation via the ``config`` object: 23 | 24 | .. code-block:: python 25 | :linenos: 26 | 27 | config = Configurator() 28 | config.include('rest_toolkit') 29 | config.include('rest_toolkit.ext.sql') 30 | config.set_sqlalchemy_session_factory(DBSession) 31 | 32 | The ``DBSession`` object is the SQLAlchemy session maker. This is usually 33 | called ``DBSession`` or ``Sesssion``. Again, this is only necessary if you 34 | do not use pyramid_sqlalchemy. 35 | 36 | 37 | Defining a resource 38 | ------------------- 39 | 40 | Once you have done this you can use the :py:class:`SQLResource 41 | ` class to define your resources. 42 | 43 | .. code-block:: python 44 | :linenos: 45 | 46 | from sqlalchemy import bindparam 47 | from sqlalchemy.orm import Query 48 | from rest_toolkit.ext.sql import SQLResource 49 | 50 | 51 | @resource('/users/{id}') 52 | class UserResource(SQLResource): 53 | context_query = Query(User).filter(User.id == bindparam('id')) 54 | 55 | 56 | @UserResource.GET() 57 | def view_user(resource, request): 58 | user = resource.context # Get the SQLAlchemy model from the resource 59 | return {'id': user.id, 60 | 'name': user.full_name} 61 | 62 | Line 4 defines the URL path for the resource. This path includes an 63 | ``id``-variable, which will be used in a SQL query. The query is defined in 64 | line 6. This query uses a :py:func:`bound expression 65 | ` to specify where the 66 | ``id`` request variable must be used. 67 | 68 | When a request comes in for ``/users/123`` a number of things will happen 69 | internally: 70 | 71 | 1. The ``id`` will be extracted from the URL path. In this case the resulting 72 | id is ``123``. 73 | 2. The SQL query specified in ``context_query`` is executed, with the ``id`` 74 | variable from the request passed in. 75 | 3. If the SQL query returns a single response it is assigned to the ``context`` 76 | variable of the ``UserResource`` instance. If the SQL query did not return 77 | any results or returned more than one result a HTTP 404 error will be 78 | generated directly. 79 | 80 | 81 | Default views 82 | ------------- 83 | 84 | SQLResource support default views, but does automatically enable them to 85 | prevent accidental data exposure or edit/delete functionality. 86 | 87 | To enable the default GET view for a SQL resource you only need to add 88 | :py:class:`ViewableResource ` to the 89 | list of base classes. SQLResource includes a default `to_dict` method which 90 | returns a dictionary with all column defined in the SQLAlchemy model used in 91 | `context_query`, which will be used to generate the response for GET requests. 92 | 93 | .. code-block:: python 94 | :linenos: 95 | 96 | from rest_toolkit.abc import ViewableResource 97 | from rest_toolkit.ext.sql import SQLResource 98 | 99 | @resource('/users/{id}') 100 | class UserResource(SQLResource, ViewableResource): 101 | context_query = Query(User).filter(User.id == bindparam('id')) 102 | 103 | .. warning:: 104 | 105 | It is important that when defining your class you list the SQLResource 106 | class *before* ViewableResource or any of the other base classes for 107 | default views. If you do not do this you will get a Python error on 108 | application startup. 109 | 110 | There is also a default `delete` method which deletes the SQL object from 111 | the database. To expose those you can add 112 | :py:class:`DeletableResource ` to the 113 | base classes for your resource. 114 | 115 | There is also a default implementation of the `update_from_dict` method which 116 | can be used as part of the 117 | :py:class:`EditableResource ` interface. 118 | You must supply an implementation for `validate` yourself. 119 | 120 | .. code-block:: python 121 | :linenos: 122 | 123 | from rest_toolkit.abc import EditableResource 124 | from rest_toolkit.ext.sql import SQLResource 125 | 126 | @resource('/users/{id}') 127 | class UserResource(SQLResource, EditableResource): 128 | context_query = Query(User).filter(User.id == bindparam('id')) 129 | 130 | def validate(self, data, partial): 131 | # Validate data here 132 | 133 | The default update logic will not update and primary keys. If you want to allow 134 | key changes you can set the ``allow_primary_key_change`` class attribute. 135 | 136 | 137 | .. code-block:: python 138 | :linenos: 139 | 140 | from rest_toolkit.abc import EditableResource 141 | from rest_toolkit.ext.sql import SQLResource 142 | 143 | @resource('/users/{id}') 144 | class UserResource(SQLResource, EditableResource): 145 | context_query = Query(User).filter(User.id == bindparam('id')) 146 | 147 | allow_primary_key_change = True 148 | 149 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | addopts = --tb=short 3 | norecursedirs = bin include lib .Python 4 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [bdist_wheel] 2 | universal=1 3 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | from setuptools import setup, find_packages 2 | from setuptools.command.test import test as TestCommand 3 | import sys 4 | 5 | version = '0.17' 6 | 7 | install_requires = [ 8 | 'pyramid >=1.6a2', 9 | ] 10 | 11 | tests_require = [ 12 | 'pytest', 13 | 'WebTest', 14 | 'colander', 15 | 'jsonschema', 16 | 'pyramid_sqlalchemy >=1.2', 17 | 'pyramid_tm', 18 | ] 19 | 20 | 21 | class PyTest(TestCommand): 22 | def finalize_options(self): 23 | TestCommand.finalize_options(self) 24 | self.test_args = ['tests'] 25 | self.test_suite = True 26 | 27 | def run_tests(self): 28 | import pytest 29 | errno = pytest.main(self.test_args) 30 | sys.exit(errno) 31 | 32 | 33 | setup(name='rest_toolkit', 34 | version=version, 35 | description='REST toolkit', 36 | long_description=open('README.rst').read() + '\n' + 37 | open('changes.rst').read(), 38 | classifiers=[ 39 | 'Development Status :: 3 - Alpha', 40 | 'Environment :: Web Environment', 41 | 'Framework :: Pyramid', 42 | 'Intended Audience :: Developers', 43 | 'License :: DFSG approved', 44 | 'License :: OSI Approved :: BSD License', 45 | 'Operating System :: OS Independent', 46 | 'Programming Language :: Python :: 2', 47 | 'Programming Language :: Python :: 2.7', 48 | 'Programming Language :: Python :: 3', 49 | 'Programming Language :: Python :: 3.4', 50 | 'Programming Language :: Python :: 3.5', 51 | 'Programming Language :: Python :: 3.6', 52 | 'Topic :: Internet :: WWW/HTTP :: WSGI', 53 | 'Topic :: Software Development :: Libraries :: Python Modules', 54 | ], 55 | keywords='REST Pyramid', 56 | author='Wichert Akkerman', 57 | author_email='wichert@wiggy.net', 58 | url='https://github.com/wichert/rest_toolkit', 59 | license='BSD', 60 | packages=find_packages('src'), 61 | package_dir={'': 'src'}, 62 | include_package_data=True, 63 | zip_safe=True, 64 | install_requires=install_requires, 65 | tests_require=tests_require, 66 | extras_require={'tests': tests_require}, 67 | cmdclass={'test': PyTest}, 68 | ) 69 | -------------------------------------------------------------------------------- /src/rest_toolkit/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | from wsgiref.simple_server import make_server 3 | from webob.exc import WSGIHTTPException 4 | from pyramid.config import Configurator 5 | from pyramid.interfaces import IExceptionResponse 6 | from pyramid.path import caller_package 7 | from pyramid.path import package_path 8 | from pyramid.security import NO_PERMISSION_REQUIRED 9 | from pyramid.settings import asbool 10 | import venusian 11 | from .abc import CollectionResource 12 | from .abc import DeletableResource 13 | from .abc import EditableResource 14 | from .abc import ViewableResource 15 | from .state import RestState 16 | from .views import unsupported_method_view 17 | from .views import default_delete_view 18 | from .views import default_get_view 19 | from .views import default_options_view 20 | from .views import default_patch_view 21 | from .views import default_post_view 22 | from .views import default_put_view 23 | 24 | 25 | METHODS = ['DELETE', 'GET', 'OPTIONS', 'PATCH', 'POST', 'PUT'] 26 | 27 | 28 | class BaseDecorator(object): 29 | def __call__(self, wrapped, depth=1): 30 | info = venusian.attach(wrapped, self.callback, 'pyramid', depth=depth) 31 | self.module = info.module 32 | return wrapped 33 | 34 | 35 | class ViewDecorator(BaseDecorator): 36 | """Base class for HTTP request method decorators for resources. 37 | 38 | This class should never be used directly. It is used internally to create 39 | the ``DELETE``, ``GET``, ``OPTIONS``, ``PATCH``, ``POST`` and ``PUT`` 40 | decorators for resources classes when the :py:func:`resource` decorator is 41 | used. 42 | 43 | .. code-block:: python 44 | :linenos: 45 | 46 | @MyResource.GET() 47 | def get_view_for_my_resource(resource, request): 48 | '''Handle GET requests for MyResource. 49 | ''' 50 | """ 51 | default_arguments = {'renderer': 'json'} 52 | 53 | def __init__(self, **kw): 54 | self.view_arguments = self.default_arguments.copy() 55 | self.view_arguments.update(kw) 56 | 57 | def callback(self, scanner, name, view): 58 | config = scanner.config.with_package(self.module) 59 | route_name = self.state.route_name 60 | self.state.add_method(self.request_method, view) 61 | config.add_view(view, 62 | route_name=route_name, 63 | request_method=self.request_method, 64 | context=self.state.resource_class, 65 | **self.view_arguments) 66 | 67 | 68 | class ControllerDecorator(BaseDecorator): 69 | """Base class for controller views for resources. 70 | 71 | This class should never be used directly. It is used internally to create 72 | the `controller`decorator for resources classes when the 73 | :py:func:`resource` decorator is used. 74 | 75 | .. code-block:: python 76 | :linenos: 77 | 78 | @MyResource.controller('frobnicate') 79 | def frobnicate_my_resource(resource, request): 80 | '''Handle POST requests to ``/myresource/frobnicate`` 81 | ''' 82 | """ 83 | default_arguments = {'renderer': 'json'} 84 | 85 | def __init__(self, name, request_method='POST', **kw): 86 | self.name = name 87 | self.request_method = request_method 88 | self.view_arguments = self.default_arguments.copy() 89 | self.view_arguments.update(kw) 90 | 91 | def _must_register_route(self, config, route_name): 92 | registered = getattr(config.registry, '_rest_controllers', None) 93 | if registered is None: 94 | registered = config.registry._rest_controllers = set() 95 | new = route_name in registered 96 | registered.add(route_name) 97 | return new 98 | 99 | def callback(self, scanner, name, view): 100 | config = scanner.config.with_package(self.module) 101 | route_path = ''.join([self.state.route_path, 102 | '' if self.state.route_path.endswith('/') else '/', 103 | self.name]) 104 | route_name = '%s-%s' % (self.state.route_name, self.name) 105 | self.state.add_controller(self.name, view, self.request_method) 106 | if not self._must_register_route(config, route_name): 107 | config.add_route(route_name, route_path, factory=self.state.resource_class) 108 | 109 | def opt(resource, request): 110 | return default_options_view(resource, request, [self.request_method]) 111 | 112 | config.add_view(opt, route_name=route_name, request_method='OPTIONS', 113 | permission=NO_PERMISSION_REQUIRED) 114 | config.add_view(unsupported_method_view, route_name=route_name, renderer='json') 115 | config.add_view(view, 116 | route_name=route_name, 117 | request_method=self.request_method, 118 | context=self.state.resource_class, 119 | **self.view_arguments) 120 | 121 | 122 | class resource(BaseDecorator): 123 | """Configure a REST resource. 124 | 125 | This decorator must be used to declare REST resources. 126 | 127 | .. code-block:: python 128 | :linenos: 129 | 130 | from rest_toolkit import resource 131 | 132 | @resource('/users/{id}') 133 | class User: 134 | def __init__(self, request): 135 | self.user_id = request.matchdict['id'] 136 | 137 | 138 | :param route_path: The URL route pattern to use for the resource. 139 | 140 | For more information on route patterns please see the :ref:`Pyramid 141 | route pattern syntax ` documentation. 142 | 143 | :param route_name: The name to use for the route. 144 | 145 | This may be needed if you want to generate URLs to resources using 146 | request.route_url(). 147 | 148 | :param create_permission: Permission for the default create view. 149 | 150 | If no create permission is specified all users, including anonymous 151 | visitors, are allowed to issue POST requests for the resource. 152 | 153 | This permission is only applied to the default POST view. If you specify 154 | a custom POST view you need to specify the permission in the ``POST`` 155 | decorator call. 156 | 157 | :param read_permission: Permission for the default read view. 158 | 159 | If no read permission is specified all users, including anonymous 160 | visitors, are allowed to issue GET requests for the resource. 161 | 162 | This permission is only applied to the default GET view. If you specify 163 | a custom GET view you need to specify the permission in the ``GET`` 164 | decorator call. 165 | 166 | :param update_permission: Permission for default update views. 167 | 168 | If no update permission is specified all users, including anonymous 169 | visitors, are allowed to issue PATCH and PUT requests for the resource. 170 | 171 | This permission is only applied to the default views. If you specify 172 | a custom PATCH or PUT view you need to specify the permission in the 173 | decorator call. 174 | 175 | :param delete_permission: Permission for the default delete view. 176 | 177 | If no delete permission is specified all users, including anonymous 178 | visitors, are allowed to issue DELETE requests for the resource. 179 | 180 | This permission is only applied to the default DELETE view. If you 181 | specify a custom DELETE view you need to specify the permission in the 182 | ``DELETE`` decorator call. 183 | """ 184 | def __init__(self, route_path, route_name=None, create_permission=None, 185 | read_permission=None, update_permission=None, 186 | delete_permission=None, 187 | **view_arguments): 188 | self.route_path = route_path 189 | self.route_name = route_name 190 | self.create_permission = create_permission 191 | self.read_permission = read_permission 192 | self.update_permission = update_permission 193 | self.delete_permission = delete_permission 194 | self.view_arguments = view_arguments 195 | 196 | def callback(self, scanner, name, cls): 197 | state = RestState.from_resource(cls) 198 | config = scanner.config.with_package(self.module) 199 | config.add_route(state.route_name, state.route_path, factory=cls) 200 | config.add_view(default_options_view, route_name=state.route_name, 201 | request_method='OPTIONS', permission=NO_PERMISSION_REQUIRED) 202 | config.add_view(unsupported_method_view, route_name=state.route_name, renderer='json') 203 | for (request_method, base_class, view, permission) in [ 204 | ('DELETE', DeletableResource, default_delete_view, self.delete_permission), 205 | ('GET', ViewableResource, default_get_view, self.read_permission), 206 | ('PATCH', EditableResource, default_patch_view, self.update_permission), 207 | ('POST', CollectionResource, default_post_view, self.create_permission), 208 | ('PUT', EditableResource, default_put_view, self.update_permission)]: 209 | if issubclass(cls, base_class): 210 | state.add_method(request_method, view) 211 | config.add_view(view, 212 | route_name=state.route_name, 213 | context=base_class, 214 | renderer='json', 215 | request_method=request_method, 216 | permission=permission, 217 | **self.view_arguments) 218 | 219 | def __call__(self, cls): 220 | state = RestState.add_to_resource(cls, self.route_path, self.route_name) 221 | for method in METHODS: 222 | setattr(cls, method, type('ViewDecorator%s' % method, 223 | (ViewDecorator, object), 224 | {'request_method': method, 225 | 'state': state})) 226 | cls.controller = type('ControllerDecorator', 227 | (ControllerDecorator, object), 228 | {'state': state}) 229 | return super(resource, self).__call__(cls, depth=2) 230 | 231 | 232 | def includeme(config): 233 | """Configure basic REST settings for a Pyramid application. 234 | 235 | You should not call this function directly, but use 236 | :py:func:`pyramid.config.Configurator.include` to initialise 237 | the REST toolkit. 238 | 239 | .. code-block:: python 240 | :linenos: 241 | 242 | config = Configurator() 243 | config.include('rest_toolkit') 244 | """ 245 | settings = config.registry.settings 246 | settings['rest_toolkit.debug'] = \ 247 | settings.get('debug_all') or \ 248 | settings.get('pyramid.debug_all') or \ 249 | settings.get('rest_toolkit.debug') or \ 250 | asbool(os.environ.get('PYRAMID_DEBUG_ALL')) or \ 251 | asbool(os.environ.get('REST_TOOLKIT_DEBUG')) 252 | if not settings['rest_toolkit.debug']: 253 | config.add_view('rest_toolkit.error.generic', 254 | context=Exception, renderer='json', 255 | permission=NO_PERMISSION_REQUIRED) 256 | config.add_view('rest_toolkit.error.http_error', context=IExceptionResponse, renderer='json') 257 | config.add_view('rest_toolkit.error.http_error', context=WSGIHTTPException, renderer='json') 258 | config.add_notfound_view('rest_toolkit.error.notfound', renderer='json') 259 | config.add_forbidden_view('rest_toolkit.error.forbidden', renderer='json') 260 | 261 | 262 | def quick_serve(sql_session_factory=None, port=8080): 263 | """Start a HTTP server for your REST service. 264 | 265 | This function provides quick way to run a webserver for your REST service. 266 | The webserver will listen on port 8080 on all IP addresses of the local 267 | machine. 268 | 269 | If you need to configure the underlying Pyramid system, or you want to use 270 | a different HTTP server you will need to create the WSGI application 271 | yourself. Instead of using `quick_serve` you will need to do something like 272 | this: 273 | 274 | .. code-block:: python 275 | :linenos: 276 | 277 | from pyramid.config import Configurator 278 | from wsgiref.simple_server import make_server 279 | 280 | config = Configurator() 281 | config.include('rest_toolkit') 282 | config.scan() 283 | app = config.make_wsgi_app() 284 | make_server('0.0.0.0', 8080, app).serve_forever() 285 | 286 | :param sql_session_factory: A factory function to return a SQLAlchemy 287 | session. This is generally a :py:class:`scoped_session 288 | ` instance, and 289 | commonly called ``Session`` or ``DBSession``. 290 | :param int port: TCP port to use for HTTP server. 291 | """ 292 | config = Configurator() 293 | config.include('rest_toolkit') 294 | if sql_session_factory is not None: 295 | config.include('rest_toolkit.ext.sql') 296 | config.set_sqlalchemy_session_factory(sql_session_factory) 297 | pkg = caller_package() 298 | config.add_static_view('static', package_path(pkg)) 299 | config.scan(pkg) 300 | app = config.make_wsgi_app() 301 | server = make_server('0.0.0.0', port, app) 302 | server.serve_forever() 303 | 304 | 305 | __all__ = ['resource', 'quick_serve'] 306 | -------------------------------------------------------------------------------- /src/rest_toolkit/abc.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import abc 3 | from .compat import add_metaclass 4 | from .utils import merge 5 | 6 | 7 | @add_metaclass(abc.ABCMeta) 8 | class ViewableResource(object): 9 | """Base class for resources using the default GET view. 10 | 11 | If a resource class is derived from this class it must implement the 12 | :py:meth:`to_dict` method. Doing this will automatically enable the default 13 | GET view from rest_toolkit. 14 | """ 15 | 16 | @abc.abstractmethod 17 | def to_dict(self): 18 | """Generate a (JSON-compatible) dictionary with resource data. 19 | 20 | This method is used by the default GET, PATCH and PUT views to generate 21 | the data for the response. It is also used by by the PATCH view 22 | to complete the (partial) data provided by a client before validation 23 | is done (see :py:class:`EditableResource` for details). 24 | """ 25 | raise NotImplementedError() 26 | 27 | 28 | @add_metaclass(abc.ABCMeta) 29 | class EditableResource(object): 30 | """Base class for resources using the default PATCH and PUT views. 31 | 32 | If a resource class is derived from this class it must implement the 33 | :py:meth:`to_dict`, :py:meth:`validate` and :py:meth:`update_from_dict` 34 | methods. Doing this will automatically enable the default GET, PATCH and 35 | PUT views from rest_toolkit. 36 | """ 37 | 38 | @abc.abstractmethod 39 | def validate(self, data, partial): 40 | """Validate new data for the resource. 41 | 42 | This method is called to validate data received from a client before it 43 | is passed to :py:meth:`update_from_dict`. 44 | 45 | :param dict data: data to validate. The data is usually taken directly 46 | from JSON send by a client. 47 | :param bool partial: indicates if data contains the full resource state 48 | (as received in a PUT request), or only partial state (as received 49 | in a PATCH request). You can reconstruct the full resource state 50 | from partial data by using the :py:meth:`complete_partial_data` 51 | method. 52 | 53 | :raises HTTPException: if all further request processing should be aborted 54 | and the exception returned directly. 55 | """ 56 | raise NotImplementedError() 57 | 58 | @abc.abstractmethod 59 | def to_dict(self): 60 | """Generate a (JSON-compatible) dictionary with resource data. 61 | 62 | This method is used by the default GET, PATCH and PUT views to generate 63 | the data for the response. 64 | """ 65 | raise NotImplementedError() 66 | 67 | @abc.abstractmethod 68 | def update_from_dict(self, data, replace): 69 | """Update a resource. 70 | 71 | :param dict data: The data to validate. The data is usually taken 72 | directly from JSON send by a client. 73 | :param bool replace: Indicates if the provided data should fully 74 | replace the resource state (as should be done for a PUT request), 75 | or if only provided keys should be updated (as should be done 76 | for a PATCH request). 77 | """ 78 | raise NotImplementedError() 79 | 80 | def complete_partial_data(self, data): 81 | """ 82 | Complete partial object data. 83 | 84 | This method will be used by the validation extension to create a 85 | complete data overview from partial information, as submitted in 86 | a PATCH request, before trying to validate it. 87 | 88 | :param dict data: The partial data to extend. The data is usually taken 89 | directly from a PATCH request. This dictionary will not be modified. 90 | :rtype: dict 91 | :return: a new dictionary with the complete data for the resource. 92 | """ 93 | return merge(self.to_dict(), data) 94 | 95 | 96 | @add_metaclass(abc.ABCMeta) 97 | class DeletableResource(object): 98 | """Base class for resources using the default DELETE views. 99 | 100 | If a resource class is derived from this class it must implement the 101 | :py:meth:`dlete` method. Doing this will automatically enable the default 102 | DELETE view from rest_toolkit. 103 | """ 104 | 105 | @abc.abstractmethod 106 | def delete(self): 107 | """Delete the resource. 108 | 109 | This method must delete the resource, or mark it as deleted, so that it 110 | is no longer accessible through the REST API. 111 | """ 112 | raise NotImplementedError() 113 | 114 | 115 | @add_metaclass(abc.ABCMeta) 116 | class CollectionResource(object): 117 | def validate_child(self, data): 118 | """Validate data for a new child resource. 119 | 120 | This method is called to validate data received from a client before it 121 | is passed to :py:meth:`add_child`. 122 | 123 | :param dict data: data to validate. The data is usually taken directly 124 | from JSON send by a client. 125 | 126 | :raises HTTPException: if all further request processing should be aborted 127 | and the exception returned directly. 128 | """ 129 | raise NotImplementedError() 130 | 131 | def add_child(self, data): 132 | """Create a new child resource. 133 | 134 | :param dict data: data for the new child. This data will already have been 135 | validated by the :py:meth:`validate_child` method. 136 | :return: response data for the view. This will generally be information 137 | about the newly created child. 138 | :rtype: dict 139 | """ 140 | raise NotImplementedError() 141 | 142 | 143 | __all__ = ['DeletableResource', 'EditableResource', 'ViewableResource', 'CollectionResource'] 144 | -------------------------------------------------------------------------------- /src/rest_toolkit/compat.py: -------------------------------------------------------------------------------- 1 | # Python compatibility support code 2 | # This is taken from six 3 | 4 | 5 | def add_metaclass(metaclass): 6 | """Class decorator for creating a class with a metaclass.""" 7 | def wrapper(cls): 8 | orig_vars = cls.__dict__.copy() 9 | orig_vars.pop('__dict__', None) 10 | orig_vars.pop('__weakref__', None) 11 | slots = orig_vars.get('__slots__') 12 | if slots is not None: 13 | if isinstance(slots, str): 14 | slots = [slots] 15 | for slots_var in slots: 16 | orig_vars.pop(slots_var) 17 | return metaclass(cls.__name__, cls.__bases__, orig_vars) 18 | return wrapper 19 | -------------------------------------------------------------------------------- /src/rest_toolkit/error.py: -------------------------------------------------------------------------------- 1 | import traceback 2 | import webob 3 | from pyramid.httpexceptions import HTTPNotFound 4 | 5 | 6 | def generic(context, request): 7 | request.response.status_int = 500 8 | try: 9 | response = {'message': context.args[0]} 10 | except IndexError: 11 | response = {'message': 'Unknown error'} 12 | if request.registry.settings.get('rest_toolkit.debug'): 13 | response['traceback'] = ''.join( 14 | traceback.format_exception(*request.exc_info)) 15 | return response 16 | 17 | 18 | def http_error(context, request): 19 | if isinstance(context, webob.Response) and context.content_type == 'application/json': 20 | return context 21 | if context.status_int == 204: 22 | return context 23 | request.response.status = context.status 24 | for (header, value) in context.headers.items(): 25 | if header in {'Content-Type', 'Content-Length'}: 26 | continue 27 | request.response.headers[header] = value 28 | if context.message: 29 | return {'message': context.message} 30 | else: 31 | return {'message': context.status} 32 | 33 | 34 | def notfound(context, request): 35 | message = 'Resource not found' 36 | if isinstance(context, HTTPNotFound): 37 | if context.content_type == 'application/json': 38 | return context 39 | elif context.detail: 40 | message = context.detail 41 | request.response.status_int = 404 42 | return {'message': message} 43 | 44 | 45 | def forbidden(request): 46 | if request.unauthenticated_userid: 47 | request.response.status_int = 403 48 | return {'message': 'You are not allowed to perform this action.'} 49 | else: 50 | request.response.status_int = 401 51 | return {'message': 'You must login to perform this action.'} 52 | -------------------------------------------------------------------------------- /src/rest_toolkit/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wichert/rest_toolkit/e7f9682dbbdc1ce50b9e2d7d30e80cae68110ec7/src/rest_toolkit/ext/__init__.py -------------------------------------------------------------------------------- /src/rest_toolkit/ext/colander.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import abc 3 | from pyramid.httpexceptions import HTTPBadRequest 4 | import colander 5 | from ..compat import add_metaclass 6 | 7 | 8 | def validate(data, schema): 9 | """Validate data against a Colander schema class. 10 | 11 | This is a helper function used by :py:class:`ColanderSchemaValidationMixin` 12 | to validate data against a Colander schema. If validation fails this function 13 | will raise a :py:class:`pyramid.httpexceptions.HTTPBadRequest` exception 14 | describing the validation error. 15 | 16 | :raises pyramid.httpexceptions.HTTPBadRequest: if validation fails this 17 | exception is raised to abort any further processing. 18 | """ 19 | schema_instance = schema() 20 | try: 21 | schema_instance.deserialize(data) 22 | except colander.Invalid as e: 23 | raise HTTPBadRequest(e.msg) 24 | 25 | 26 | @add_metaclass(abc.ABCMeta) 27 | class ColanderSchemaValidationMixin(object): 28 | """Mix-in class to add colander-based validation to a resource. 29 | 30 | This mix-in class provides an implementation for :py:meth:`validate` 31 | as required by :py:class:`EditableResource 32 | ` which uses `colander 33 | `_ for validation. 34 | 35 | .. code-block:: python 36 | :linenos: 37 | 38 | class AccountSchema(colander.Schema): 39 | email = colander.SchemaNode(colander.String()) 40 | password = colander.SchemaNode(colander.String()) 41 | 42 | 43 | class DummyResource(ColanderSchemaValidationMixin): 44 | schema = AccountSchema 45 | 46 | """ 47 | 48 | @abc.abstractproperty 49 | def schema(self): 50 | """Colander schema class. 51 | """ 52 | raise NotImplementedError() 53 | 54 | def validate(self, data, partial=False): 55 | if partial: 56 | data = self.complete_partial_data(data) 57 | validate(data, self.schema) 58 | 59 | 60 | __all__ = ['ColanderSchemaValidationMixin', 'validate'] 61 | -------------------------------------------------------------------------------- /src/rest_toolkit/ext/jsonschema.py: -------------------------------------------------------------------------------- 1 | from __future__ import absolute_import 2 | import abc 3 | import jsonschema 4 | from pyramid.httpexceptions import HTTPBadRequest 5 | from ..compat import add_metaclass 6 | from ..utils import add_missing 7 | 8 | 9 | class JSONValidationError(HTTPBadRequest): 10 | """HTTP response for JSON validation errors. 11 | """ 12 | 13 | def validate(data, schema): 14 | """Validate data against a JSON schema. 15 | 16 | This is a helper function used by :py:class:`JsonSchemaValidationMixin` 17 | to validate data against a JSON schema. If validation fails this function 18 | will raise a :py:class:`pyramid.httpexceptions.HTTPBadRequest` exception 19 | describing the validation error. 20 | 21 | :raises pyramid.httpexceptions.HTTPBadRequest: if validation fails this 22 | exception is raised to abort any further processing. 23 | """ 24 | try: 25 | jsonschema.validate(data, schema, 26 | format_checker=jsonschema.draft4_format_checker) 27 | except jsonschema.ValidationError as e: 28 | error = { 29 | '.'.join(str(p) for p in e.path): e.message 30 | } 31 | response = JSONValidationError(json=error) 32 | response.validation_error = e 33 | raise response 34 | 35 | 36 | @add_metaclass(abc.ABCMeta) 37 | class JsonSchemaValidationMixin(object): 38 | """Mix-in class to add JSON schema validation to a resource. 39 | 40 | This mix-in class provides an implementation for :py:meth:`validate` 41 | as required by :py:class:`EditableResource 42 | ` which uses `JSON schemas 43 | `). 44 | 45 | .. code-block:: python 46 | :linenos: 47 | 48 | class Account(EditableResource, JsonSchemaValidationMixin): 49 | schema = { 50 | '$schema': 'http://json-schema.org/draft-04/schema', 51 | 'type': 'object', 52 | 'properties': { 53 | 'email': { 54 | 'type': 'string', 55 | 'format': 'email', 56 | }, 57 | 'password': { 58 | 'type': 'string', 59 | 'minLength': 1, 60 | }, 61 | }, 62 | 'additionalProperties': False, 63 | 'required': ['email', 'password'], 64 | } 65 | 66 | The `jsonschema `_ package is used 67 | to implement validation. All validation errors reported by jsonschema are 68 | returned as a standard error JSON response with HTTP status code 400. 69 | """ 70 | 71 | @abc.abstractproperty 72 | def schema(self): 73 | """JSON schema. 74 | 75 | This attribute must contain a valid JSON schema. This will be used by 76 | :py:meth:`validate` to validate submitted data. 77 | """ 78 | raise NotImplementedError() 79 | 80 | def validate(self, data, partial=False): 81 | if partial: 82 | data = self.complete_partial_data(data) 83 | validate(data, self.schema) 84 | 85 | 86 | @add_metaclass(abc.ABCMeta) 87 | class JsonSchemaChildValidationMixin(object): 88 | """Mix-in class to add JSON schema validation to a resource. 89 | 90 | This mix-in class provides an implementation for :py:meth:`validate_child` 91 | as required by :py:class:`EditableResource 92 | ` which uses `JSON schemas 93 | `). 94 | 95 | .. code-block:: python 96 | :linenos: 97 | 98 | class Account(EditableResource, JsonSchemaValidationMixin): 99 | schema = { 100 | '$schema': 'http://json-schema.org/draft-04/schema', 101 | 'type': 'object', 102 | 'properties': { 103 | 'email': { 104 | 'type': 'string', 105 | 'format': 'email', 106 | }, 107 | 'password': { 108 | 'type': 'string', 109 | 'minLength': 1, 110 | }, 111 | }, 112 | 'additionalProperties': False, 113 | 'required': ['email', 'password'], 114 | } 115 | 116 | The `jsonschema `_ package is used 117 | to implement validation. All validation errors reported by jsonschema are 118 | returned as a standard error JSON response with HTTP status code 400. 119 | """ 120 | 121 | @abc.abstractproperty 122 | def child_schema(self): 123 | """JSON schema. 124 | 125 | This attribute must contain a valid JSON schema. This will be used by 126 | :py:meth:`validate` to validate submitted data. 127 | """ 128 | raise NotImplementedError() 129 | 130 | def validate_child(self, data): 131 | validate(data, self.child_schema) 132 | 133 | 134 | __all__ = ['JsonSchemaValidationMixin', 'JsonSchemaChildValidationMixin', 'validate'] 135 | -------------------------------------------------------------------------------- /src/rest_toolkit/ext/sql.py: -------------------------------------------------------------------------------- 1 | import abc 2 | from pyramid.httpexceptions import HTTPNotFound 3 | from sqlalchemy.orm import object_session 4 | from ..compat import add_metaclass 5 | 6 | try: 7 | from pyramid_sqlalchemy import Session as _session_factory 8 | except ImportError: # pragma: noqa 9 | _session_factory = None 10 | 11 | 12 | def _column_keys(query): 13 | return [(column.primary_key, column.key) for column in query._primary_entity.entity_zero.columns] 14 | 15 | 16 | @add_metaclass(abc.ABCMeta) 17 | class SQLResource(object): 18 | """Base class for resources based on SQLAlchemy ORM models. 19 | """ 20 | 21 | allow_primary_key_change = False 22 | 23 | @abc.abstractproperty 24 | def context_query(self): 25 | """A SQLAlchemy query which is used to find a SQLAlchemy object. 26 | """ 27 | raise NotImplementedError() 28 | 29 | def __init__(self, request): 30 | global _session_factory 31 | assert _session_factory is not None, \ 32 | "config.set_sqlalchemy_session_factory must be called." 33 | self.request = request 34 | self.context = self.context_query\ 35 | .with_session(_session_factory())\ 36 | .params(request.matchdict)\ 37 | .first() 38 | if self.context is None: 39 | raise HTTPNotFound('Resource not found') 40 | 41 | def to_dict(self): 42 | data = {} 43 | for (_, column) in _column_keys(self.context_query): 44 | data[column] = getattr(self.context, column) 45 | return data 46 | 47 | def update_from_dict(self, data, replace=False): 48 | for (key, column) in _column_keys(self.context_query): 49 | if key and not self.allow_primary_key_change: 50 | continue 51 | if not replace: 52 | setattr(self.context, column, data.get(column)) 53 | else: 54 | setattr(self.context, column, data[column]) 55 | 56 | def delete(self): 57 | object_session(self.context).delete(self.context) 58 | 59 | 60 | def set_sqlalchemy_session_factory(config, sql_session_factory): 61 | """Configure the SQLAlchemy session factory. 62 | 63 | This function should not be used directly, but as a method if the 64 | ``config`` object. 65 | 66 | .. code-block:: python 67 | :linenos: 68 | 69 | config.set_sqlalchemy_session_factory(DBSession) 70 | 71 | This function must be called if you use SQL resources. If you forget to do 72 | this any attempt to access a SQL resource will trigger an assertion 73 | exception. 74 | 75 | :param sql_session_factory: A factory function to return a SQLAlchemy 76 | session. This is generally a :py:class:`scoped_session 77 | ` instance, and 78 | commonly called ``Session`` or ``DBSession``. 79 | """ 80 | global _session_factory 81 | _session_factory = sql_session_factory 82 | 83 | 84 | def includeme(config): 85 | """Configure SQLAlchemy integration. 86 | 87 | You should not call this function directly, but use 88 | :py:func:`pyramid.config.Configurator.include` to initialise the REST 89 | toolkit. After you have done this you must call 90 | :py:func:`config.set_sqlalchemy_session_factory` to register your 91 | SQLALchemy session factory. 92 | 93 | .. code-block:: python 94 | :linenos: 95 | 96 | config = Configurator() 97 | config.include('rest_toolkit') 98 | config.include('rest_toolkit.ext.sql') 99 | config.set_sqlalchemy_session_factory(DBSession) 100 | """ 101 | config.add_directive('set_sqlalchemy_session_factory', 102 | set_sqlalchemy_session_factory) 103 | -------------------------------------------------------------------------------- /src/rest_toolkit/state.py: -------------------------------------------------------------------------------- 1 | class RestState(object): 2 | def __init__(self, resource_class, route_path, route_name=None): 3 | self.resource_class = resource_class 4 | self.route_path = route_path 5 | self.views = {} 6 | self.controllers = {} 7 | if route_name: 8 | self.route_name = route_name 9 | else: 10 | self.route_name = 'rest-%s' % self.resource_class.__name__ 11 | 12 | def add_method(self, method, view): 13 | self.views[method] = view 14 | 15 | def add_controller(self, name, view, method): 16 | self.controllers[(name, method)] = view 17 | 18 | @classmethod 19 | def add_to_resource(cls, resource_class, route_path, route_name): 20 | resource_class.__rest__ = state = RestState(resource_class, 21 | route_path, route_name) 22 | return state 23 | 24 | @classmethod 25 | def from_resource(cls, resource_class): 26 | return resource_class.__rest__ 27 | 28 | def supported_methods(self): 29 | return set(self.views) | {'OPTIONS'} 30 | -------------------------------------------------------------------------------- /src/rest_toolkit/utils.py: -------------------------------------------------------------------------------- 1 | _marker = [] 2 | 3 | 4 | def merge(base, overlay): 5 | """Recursively merge two dictionaries. 6 | 7 | This function merges two dictionaries. It is intended to be used to 8 | (re)create the full new state of a resource based on its current 9 | state and any changes passed in via a PATCH request. The merge rules 10 | are: 11 | 12 | * if a key `k` is present in `base` but missing in `overlay` it is 13 | untouched. 14 | * if a key `k` is present in both `base` and `overlay`: 15 | 16 | - and `base[k]` and `overlay[k]` are both dictionaries merge is 17 | applied recursively, 18 | - otherwise to value in `overlay` is used. 19 | 20 | * if a key `k` is not present in `base`, but is present in `overlay` 21 | it the value from `overlay` will be used. 22 | 23 | .. code-block:: python 24 | 25 | >>> merge({'foo': 'bar'}, {'foo': 'buz'}) 26 | {'foo': 'buz'} 27 | >>> merge(['foo': 'bar'}, {'buz': True}) 28 | {'foo': 'bar', 'buz': True} 29 | 30 | :param dict base: Dictionary with default data. 31 | :param dict overlay: Dictioanry with data to overlay on top of `base`. 32 | :rtype: dict 33 | :return: A copy of `base` with data from `overlay` added. 34 | """ 35 | new_base = base.copy() 36 | for (key, new_value) in overlay.items(): 37 | old_value = new_base.get(key, _marker) 38 | if old_value is _marker: 39 | new_base[key] = new_value 40 | elif isinstance(old_value, dict): 41 | if isinstance(new_value, dict): 42 | new_base[key] = merge(old_value, new_value) 43 | else: 44 | new_base[key] = new_value 45 | else: 46 | new_base[key] = new_value 47 | return new_base 48 | 49 | 50 | def add_missing(data, defaults): 51 | for (key, value) in defaults.items(): 52 | if key not in data: 53 | data[key] = value 54 | return data 55 | 56 | 57 | __all__ = ['merge'] 58 | -------------------------------------------------------------------------------- /src/rest_toolkit/views.py: -------------------------------------------------------------------------------- 1 | from pyramid.httpexceptions import HTTPMethodNotAllowed 2 | from pyramid.httpexceptions import HTTPNoContent 3 | from .state import RestState 4 | 5 | 6 | def unsupported_method_view(resource, request): 7 | request.response.status_int = 405 8 | return {'message': 'Unsupported HTTP method'} 9 | 10 | 11 | def default_options_view(resource, request, methods=None): 12 | """Default OPTIONS view for resources.""" 13 | response = HTTPNoContent() 14 | if methods is None: 15 | state = RestState.from_resource(resource) 16 | methods = state.supported_methods() 17 | response.headers['Access-Control-Allow-Methods'] = ', '.join(methods) 18 | return response 19 | 20 | 21 | def default_delete_view(resource, request): 22 | resource.delete() 23 | return HTTPNoContent() 24 | 25 | 26 | def default_get_view(resource, request): 27 | return resource.to_dict() 28 | 29 | 30 | def default_patch_view(resource, request): 31 | try: 32 | data = request.json_body 33 | except ValueError: 34 | request.response.status_int = 400 35 | return {'message': 'No JSON data provided.'} 36 | resource.validate(data, partial=True) 37 | r = resource.update_from_dict(data, replace=False) 38 | return r if r is not None else resource.to_dict() 39 | 40 | 41 | def default_put_view(resource, request): 42 | try: 43 | data = request.json_body 44 | except ValueError: 45 | request.response.status_int = 400 46 | return {'message': 'No JSON data provided.'} 47 | resource.validate(data, partial=False) 48 | r = resource.update_from_dict(data, replace=True) 49 | return r if r is not None else resource.to_dict() 50 | 51 | 52 | def default_post_view(resource, request): 53 | try: 54 | data = request.json_body 55 | except ValueError: 56 | request.response.status_int = 400 57 | return {'message': 'No JSON data provided.'} 58 | resource.validate_child(data) 59 | request.response.status_int = 201 60 | return resource.add_child(data) 61 | -------------------------------------------------------------------------------- /tests/controller.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | 3 | 4 | @resource('/') 5 | class RootResource(object): 6 | def __init__(self, request): 7 | pass 8 | 9 | 10 | @RootResource.controller('engage') 11 | def root_engage(resource, request): 12 | return {'message': 'Ai ai captain'} 13 | 14 | 15 | @RootResource.controller('engage', request_method='GET') 16 | def get_engage(resource, request): 17 | return {'message': 'Warp engine offline'} 18 | 19 | 20 | @resource('/resource') 21 | class Resource(object): 22 | def __init__(self, request): 23 | pass 24 | 25 | 26 | @Resource.controller('engage') 27 | def engage(resource, request): 28 | return {'message': 'Ai ai captain'} 29 | -------------------------------------------------------------------------------- /tests/ext/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/wichert/rest_toolkit/e7f9682dbbdc1ce50b9e2d7d30e80cae68110ec7/tests/ext/__init__.py -------------------------------------------------------------------------------- /tests/ext/test_colander.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyramid.httpexceptions import HTTPBadRequest 3 | from rest_toolkit.abc import EditableResource 4 | from rest_toolkit.ext.colander import ColanderSchemaValidationMixin 5 | import colander 6 | 7 | 8 | class AccountSchema(colander.Schema): 9 | email = colander.SchemaNode(colander.String()) 10 | password = colander.SchemaNode(colander.String()) 11 | 12 | 13 | class DummyResource(ColanderSchemaValidationMixin, EditableResource): 14 | schema = AccountSchema 15 | 16 | def to_dict(self): 17 | return {} 18 | 19 | def update_from_dict(self, data, partial): 20 | pass 21 | 22 | 23 | def test_valid_request(): 24 | resource = DummyResource() 25 | resource.validate({'email': 'john@example.com', 'password': 'Jane'}, partial=False) 26 | 27 | 28 | def test_validation_error(): 29 | resource = DummyResource() 30 | with pytest.raises(HTTPBadRequest): 31 | resource.validate({'email': 'john@example.com'}, partial=False) 32 | 33 | 34 | def test_partial_data(): 35 | resource = DummyResource() 36 | resource.to_dict = lambda: {'password': 'Jane'} 37 | resource.validate({'email': 'john@example.com'}, partial=True) 38 | -------------------------------------------------------------------------------- /tests/ext/test_jsonschema.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from pyramid.httpexceptions import HTTPBadRequest 3 | from rest_toolkit.abc import EditableResource 4 | from rest_toolkit.ext.jsonschema import JsonSchemaValidationMixin 5 | 6 | 7 | class DummyResource(JsonSchemaValidationMixin, EditableResource): 8 | schema = { 9 | '$schema': 'http://json-schema.org/draft-04/schema', 10 | 'type': 'object', 11 | 'properties': { 12 | 'email': { 13 | 'type': 'string', 14 | 'format': 'email', 15 | }, 16 | 'password': { 17 | 'type': 'string', 18 | 'minLength': 1, 19 | }, 20 | 'groups': { 21 | 'type': 'array', 22 | 'items': { 23 | 'type': 'string', 24 | 'enum': ['admin', 'user'], 25 | }, 26 | }, 27 | }, 28 | 'additionalProperties': False, 29 | 'required': ['email', 'password'], 30 | } 31 | 32 | def to_dict(self): 33 | return {} 34 | 35 | def update_from_dict(self, data, partial): 36 | pass 37 | 38 | 39 | def test_valid_request(): 40 | resource = DummyResource() 41 | resource.validate({'email': 'john@example.com', 'password': 'Jane'}, partial=False) 42 | 43 | 44 | def test_validation_error(): 45 | resource = DummyResource() 46 | with pytest.raises(HTTPBadRequest): 47 | resource.validate({'email': 'john@example.com'}, partial=False) 48 | 49 | 50 | def test_array_validation_error(): 51 | resource = DummyResource() 52 | with pytest.raises(HTTPBadRequest): 53 | resource.validate({ 54 | 'email': 'john@example.com', 55 | 'password': 'Jane', 56 | 'groups': ['admin', 'invalid'], 57 | }, partial=False) 58 | 59 | 60 | def test_partial_data(): 61 | resource = DummyResource() 62 | resource.to_dict = lambda: {'password': 'Jane'} 63 | resource.validate({'email': 'john@example.com'}, partial=True) 64 | -------------------------------------------------------------------------------- /tests/ext/test_sql.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webtest import TestApp 3 | from pyramid.config import Configurator 4 | from pyramid.testing import DummyRequest 5 | from pyramid_sqlalchemy import Session 6 | from resource_sql import BalloonModel 7 | from resource_sql import BalloonResource 8 | from rest_toolkit.ext.sql import _column_keys 9 | 10 | 11 | def make_app(config): 12 | return TestApp(config.make_wsgi_app()) 13 | 14 | 15 | def test_column_keys(): 16 | keys = _column_keys(BalloonResource.context_query) 17 | assert set(keys) == {(True, 'id'), (False, 'figure')} 18 | 19 | 20 | @pytest.mark.usefixtures('sql_session') 21 | def test_unknown_id(): 22 | config = Configurator() 23 | config.include('rest_toolkit') 24 | config.scan('resource_sql') 25 | app = make_app(config) 26 | r = app.get('/balloons/1', status=404) 27 | 28 | 29 | @pytest.mark.usefixtures('sql_session') 30 | def test_known_id(): 31 | balloon = BalloonModel(figure=u'Giraffe') 32 | Session.add(balloon) 33 | Session.flush() 34 | config = Configurator() 35 | config.include('rest_toolkit') 36 | config.scan('resource_sql') 37 | app = make_app(config) 38 | r = app.get('/balloons/%s' % balloon.id) 39 | assert r.json['figure'] == u'Giraffe' 40 | 41 | 42 | @pytest.mark.usefixtures('sql_session') 43 | def test_update_instance(): 44 | balloon = BalloonModel(figure=u'Giraffe') 45 | Session.add(balloon) 46 | Session.flush() 47 | request = DummyRequest(matchdict={'id': balloon.id}) 48 | resource = BalloonResource(request) 49 | resource.update_from_dict({'figure': u'Elephant'}) 50 | assert balloon.figure == u'Elephant' 51 | -------------------------------------------------------------------------------- /tests/resource_abc.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | from rest_toolkit.abc import ViewableResource 3 | from pyramid.security import Allow 4 | from pyramid.security import Authenticated 5 | 6 | 7 | @resource('/') 8 | class Resource(ViewableResource): 9 | def __init__(self, request): 10 | pass 11 | 12 | def to_dict(self): 13 | return {'message': 'Hello, world'} 14 | 15 | 16 | @resource('/secure', read_permission='view') 17 | class SecureResource(ViewableResource): 18 | __acl__ = [(Allow, Authenticated, ['view'])] 19 | 20 | def __init__(self, request): 21 | pass 22 | 23 | def to_dict(self): 24 | return {'message': 'Hello, world'} 25 | -------------------------------------------------------------------------------- /tests/resource_abc_override.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | from rest_toolkit.abc import ViewableResource 3 | 4 | 5 | @resource('/') 6 | class Resource(ViewableResource): 7 | def __init__(self, request): 8 | pass 9 | 10 | def to_dict(self): 11 | return {'message': 'Hello, world'} 12 | 13 | 14 | @Resource.GET() 15 | def get(resource, request): 16 | return {'message': 'Welcome'} 17 | -------------------------------------------------------------------------------- /tests/resource_error.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | from pyramid.httpexceptions import HTTPFound 3 | from pyramid.httpexceptions import HTTPNotFound 4 | from pyramid.httpexceptions import HTTPPaymentRequired 5 | from pyramid.httpexceptions import HTTPBadRequest 6 | 7 | 8 | @resource('/keyerror') 9 | class KeyErrorResource(object): 10 | def __init__(self, request): 11 | raise KeyError('BOOM!') 12 | 13 | 14 | @resource('/http-error') 15 | class HTTPErrorResource(object): 16 | def __init__(self, request): 17 | raise HTTPPaymentRequired('BOOM!') 18 | 19 | 20 | @resource('/http-not-found') 21 | class HTTPNotFoundResource(object): 22 | def __init__(self, request): 23 | raise HTTPNotFound() 24 | 25 | 26 | @resource('/http-found') 27 | class HTTPFoundResource(object): 28 | def __init__(self, request): 29 | raise HTTPFound('http://www.wiggy.net') 30 | 31 | 32 | @resource('/custom-json-exception') 33 | class CustomException(object): 34 | def __init__(self, request): 35 | raise HTTPBadRequest(json={'foo': 'bar'}) 36 | -------------------------------------------------------------------------------- /tests/resource_get.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | 3 | 4 | @resource('/') 5 | class Resource(object): 6 | def __init__(self, request): 7 | pass 8 | 9 | 10 | @Resource.GET() 11 | def get(resource, request): 12 | return {'message': 'hello'} 13 | -------------------------------------------------------------------------------- /tests/resource_get_renderer.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | 3 | 4 | @resource('/') 5 | class Resource(object): 6 | def __init__(self, request): 7 | pass 8 | 9 | 10 | @Resource.GET(renderer='string') 11 | def get(resource, request): 12 | return 'hello' 13 | -------------------------------------------------------------------------------- /tests/resource_only.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | 3 | 4 | @resource('/') 5 | class Resource(object): 6 | def __init__(self, request): 7 | pass 8 | -------------------------------------------------------------------------------- /tests/resource_route_name.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | 3 | 4 | @resource('/users/{id}', route_name='user') 5 | class Resource(object): 6 | def __init__(self, request): 7 | pass 8 | -------------------------------------------------------------------------------- /tests/resource_sql.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit import resource 2 | from rest_toolkit.abc import ViewableResource 3 | from rest_toolkit.ext.sql import SQLResource 4 | from pyramid_sqlalchemy import BaseObject 5 | from sqlalchemy import bindparam 6 | from sqlalchemy.orm import Query 7 | from sqlalchemy import schema 8 | from sqlalchemy import types 9 | 10 | 11 | class BalloonModel(BaseObject): 12 | __tablename__ = 'balloon' 13 | 14 | id = schema.Column(types.Integer(), primary_key=True, autoincrement=True) 15 | figure = schema.Column(types.Unicode(), nullable=False) 16 | 17 | 18 | @resource('/balloons/{id}') 19 | class BalloonResource(SQLResource, ViewableResource): 20 | context_query = Query(BalloonModel)\ 21 | .filter(BalloonModel.id == bindparam('id')) 22 | -------------------------------------------------------------------------------- /tests/test_error.py: -------------------------------------------------------------------------------- 1 | from webtest import TestApp 2 | from pyramid.config import Configurator 3 | 4 | 5 | def make_app(config): 6 | return TestApp(config.make_wsgi_app()) 7 | 8 | 9 | def test_resource_constructor_exception(): 10 | config = Configurator() 11 | config.include('rest_toolkit') 12 | config.scan('resource_error') 13 | app = make_app(config) 14 | r = app.get('/keyerror', status=500) 15 | assert r.content_type == 'application/json' 16 | assert set(r.json) == {'message'} 17 | assert r.json['message'] == 'BOOM!' 18 | assert 'traceback' not in r.json 19 | 20 | 21 | def test_add_traceback_in_debug_mode(): 22 | config = Configurator() 23 | config.include('rest_toolkit') 24 | config.scan('resource_error') 25 | config.registry.settings['rest_toolkit.debug'] = True 26 | app = make_app(config) 27 | r = app.get('/keyerror', status=500) 28 | assert 'traceback' in r.json 29 | 30 | 31 | def test_resource_constructor_http_exception(): 32 | config = Configurator() 33 | config.include('rest_toolkit') 34 | config.scan('resource_error') 35 | app = make_app(config) 36 | r = app.get('/http-error', status=402) 37 | assert r.content_type == 'application/json' 38 | assert set(r.json) == {'message'} 39 | assert r.json['message'] == 'BOOM!' 40 | 41 | 42 | def test_resource_constructor_raises_notfound(): 43 | config = Configurator() 44 | config.include('rest_toolkit') 45 | config.include('pyramid_tm') 46 | app = make_app(config) 47 | r = app.get('/http-not-found', status=404) 48 | assert r.content_type == 'application/json' 49 | assert set(r.json) == {'message'} 50 | 51 | 52 | def test_preserve_custom_json_response(): 53 | config = Configurator() 54 | config.include('rest_toolkit') 55 | config.scan('resource_error') 56 | app = make_app(config) 57 | r = app.get('/custom-json-exception', status=400) 58 | assert r.content_type == 'application/json' 59 | assert r.json == {'foo': 'bar'} 60 | 61 | 62 | 63 | def test_notfound_response(): 64 | config = Configurator() 65 | config.include('rest_toolkit') 66 | app = make_app(config) 67 | r = app.get('/', status=404) 68 | assert r.content_type == 'application/json' 69 | assert set(r.json) == {'message'} 70 | 71 | 72 | def test_found_exception(): 73 | config = Configurator() 74 | config.include('rest_toolkit') 75 | config.scan('resource_error') 76 | app = make_app(config) 77 | r = app.get('/http-found', status=302) 78 | assert r.headers['Location'] == 'http://www.wiggy.net' 79 | assert r.content_type == 'application/json' 80 | assert set(r.json) == {'message'} 81 | 82 | 83 | def test_method_not_allowed(): 84 | config = Configurator() 85 | config.include('rest_toolkit') 86 | config.scan('resource_get') 87 | app = make_app(config) 88 | r = app.put('/', status=405) 89 | assert r.content_type == 'application/json' 90 | assert set(r.json) == {'message'} 91 | -------------------------------------------------------------------------------- /tests/test_resource.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from webtest import TestApp 3 | from pyramid.config import Configurator 4 | from pyramid.testing import DummyRequest 5 | from pyramid.authorization import ACLAuthorizationPolicy 6 | from pyramid.authentication import AuthTktAuthenticationPolicy 7 | 8 | 9 | def make_app(config): 10 | return TestApp(config.make_wsgi_app()) 11 | 12 | 13 | @pytest.mark.parametrize('method', ['delete', 'get', 'post', 'patch', 'put']) 14 | def test_unallowed_method_added(method): 15 | config = Configurator() 16 | config.scan('resource_only') 17 | app = make_app(config) 18 | getattr(app, method)('/', status=405) 19 | 20 | 21 | def test_default_options_method(): 22 | config = Configurator() 23 | config.scan('resource_only') 24 | app = make_app(config) 25 | response = app.options('/') 26 | assert response.headers['Access-Control-Allow-Methods'] == 'OPTIONS' 27 | 28 | 29 | def test_request_add_get_view(): 30 | config = Configurator() 31 | config.scan('resource_get') 32 | app = make_app(config) 33 | app.get('/') 34 | 35 | 36 | def test_request_default_to_json_renderer(): 37 | config = Configurator() 38 | config.scan('resource_get') 39 | app = make_app(config) 40 | r = app.get('/') 41 | assert r.content_type == 'application/json' 42 | assert r.json == {'message': 'hello'} 43 | 44 | 45 | def test_request_override_renderer(): 46 | config = Configurator() 47 | config.scan('resource_get_renderer') 48 | app = make_app(config) 49 | r = app.get('/') 50 | assert r.content_type == 'text/plain' 51 | assert r.unicode_body == 'hello' 52 | 53 | 54 | def test_add_controller(): 55 | config = Configurator() 56 | config.scan('controller') 57 | app = make_app(config) 58 | app.post('/engage') 59 | 60 | 61 | def test_nested_controller(): 62 | # Test for https://github.com/wichert/rest_toolkit/issues/12 63 | config = Configurator() 64 | config.scan('controller') 65 | app = make_app(config) 66 | app.post('/resource/engage') 67 | 68 | 69 | def test_controller_default_to_json_renderer(): 70 | config = Configurator() 71 | config.scan('controller') 72 | app = make_app(config) 73 | r = app.post('/engage') 74 | assert r.content_type == 'application/json' 75 | assert r.json == {'message': 'Ai ai captain'} 76 | 77 | 78 | def test_set_controller_method(): 79 | config = Configurator() 80 | config.scan('controller') 81 | app = make_app(config) 82 | r = app.get('/engage') 83 | assert r.json == {'message': 'Warp engine offline'} 84 | 85 | 86 | @pytest.mark.parametrize('method', ['delete', 'get', 'patch', 'put']) 87 | def test_controller_invalid_method(method): 88 | config = Configurator() 89 | config.scan('controller') 90 | app = make_app(config) 91 | getattr(app, method)('/', status=405) 92 | 93 | 94 | def test_default_get_view(): 95 | config = Configurator() 96 | config.scan('resource_abc') 97 | app = make_app(config) 98 | r = app.get('/') 99 | assert r.json == {'message': 'Hello, world'} 100 | 101 | 102 | def test_override_default_view(): 103 | config = Configurator() 104 | config.scan('resource_abc_override') 105 | app = make_app(config) 106 | r = app.get('/') 107 | assert r.json == {'message': 'Welcome'} 108 | 109 | 110 | def test_set_resource_route_name(): 111 | config = Configurator() 112 | config.scan('resource_route_name') 113 | config.make_wsgi_app() 114 | request = DummyRequest() 115 | request.registry = config.registry 116 | assert request.route_path('user', id=15) == '/users/15' 117 | 118 | 119 | def test_secured_default_view_not_allowed(): 120 | config = Configurator() 121 | config.set_authentication_policy(AuthTktAuthenticationPolicy('seekrit')) 122 | config.set_authorization_policy(ACLAuthorizationPolicy()) 123 | config.scan('resource_abc') 124 | app = make_app(config) 125 | app.get('/secure', status=403) 126 | 127 | 128 | def test_secured_default_view_allowed(): 129 | config = Configurator() 130 | config.testing_securitypolicy(1) 131 | config.scan('resource_abc') 132 | app = make_app(config) 133 | app.get('/secure') 134 | -------------------------------------------------------------------------------- /tests/test_utils.py: -------------------------------------------------------------------------------- 1 | from rest_toolkit.utils import merge 2 | 3 | 4 | class Test_merge(object): 5 | def test_disjunct_dictionaries(self): 6 | assert merge({'foo': 'bar'}, {'buz': True}) == {'foo': 'bar', 'buz': True} 7 | 8 | def test_overlap(self): 9 | assert merge({'foo': 'bar'}, {'foo': True}) == {'foo': True} 10 | 11 | def test_key_only_in_base(self): 12 | assert merge({'foo': 'bar'}, {}) == {'foo': 'bar'} 13 | 14 | def test_key_only_in_overlay(self): 15 | assert merge({}, {'foo': 'bar'}) == {'foo': 'bar'} 16 | 17 | def test_recurse(self): 18 | assert merge({'obj': {}}, {'obj': {'foo': 'bar'}}) == {'obj': {'foo': 'bar'}} 19 | 20 | def test_do_not_modify_input(self): 21 | base = {'foo': 'bar'} 22 | overlay = {'buz': True} 23 | merge(base, overlay) 24 | assert base == {'foo': 'bar'} 25 | assert overlay == {'buz': True} 26 | --------------------------------------------------------------------------------