##// END OF EJS Templates
DEV: Validate models returned from ContentsManager methods.
Scott Sanderson -
Show More
@@ -1,266 +1,312
1 """Tornado handlers for the contents web service."""
1 """Tornado handlers for the contents web service."""
2
2
3 # Copyright (c) IPython Development Team.
3 # Copyright (c) IPython Development Team.
4 # Distributed under the terms of the Modified BSD License.
4 # Distributed under the terms of the Modified BSD License.
5
5
6 import json
6 import json
7
7
8 from tornado import web
8 from tornado import web
9
9
10 from IPython.html.utils import url_path_join, url_escape
10 from IPython.html.utils import url_path_join, url_escape
11 from IPython.utils.jsonutil import date_default
11 from IPython.utils.jsonutil import date_default
12
12
13 from IPython.html.base.handlers import (
13 from IPython.html.base.handlers import (
14 IPythonHandler, json_errors, path_regex,
14 IPythonHandler, json_errors, path_regex,
15 )
15 )
16
16
17
17
18 def sort_key(model):
18 def sort_key(model):
19 """key function for case-insensitive sort by name and type"""
19 """key function for case-insensitive sort by name and type"""
20 iname = model['name'].lower()
20 iname = model['name'].lower()
21 type_key = {
21 type_key = {
22 'directory' : '0',
22 'directory' : '0',
23 'notebook' : '1',
23 'notebook' : '1',
24 'file' : '2',
24 'file' : '2',
25 }.get(model['type'], '9')
25 }.get(model['type'], '9')
26 return u'%s%s' % (type_key, iname)
26 return u'%s%s' % (type_key, iname)
27
27
28
29 def validate_model(model, expect_content):
30 required_keys = {
31 "name"
32 , "path"
33 , "type"
34 , "writable"
35 , "created"
36 # Note: This key is specified as just 'modified' in IPEP-27
37 , "last_modified"
38 , "mimetype"
39 , "content"
40 , "format"
41 }
42 missing = required_keys - set(model.keys())
43 if missing:
44 raise web.HTTPError(
45 500,
46 u"Missing Model Keys: {missing}".format(missing=missing),
47 )
48
49 # Note: Per IPEP-27, 'mimetype' should be present in this list.
50 maybe_none_keys = ['content', 'format']
51 if expect_content:
52 errors = [key for key in maybe_none_keys if model[key] is None]
53 if errors:
54 raise web.HTTPError(
55 500,
56 u"Keys unexpectedly None: {keys}".format(keys=errors),
57 )
58
59 else:
60 errors = [key for key in maybe_none_keys if model[key] is not None]
61 if errors:
62 raise web.HTTPError(
63 500,
64 u"Keys unexpectedly not None: {keys}".format(keys=errors),
65 )
66
67
28 class ContentsHandler(IPythonHandler):
68 class ContentsHandler(IPythonHandler):
29
69
30 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
70 SUPPORTED_METHODS = (u'GET', u'PUT', u'PATCH', u'POST', u'DELETE')
31
71
32 def location_url(self, path):
72 def location_url(self, path):
33 """Return the full URL location of a file.
73 """Return the full URL location of a file.
34
74
35 Parameters
75 Parameters
36 ----------
76 ----------
37 path : unicode
77 path : unicode
38 The API path of the file, such as "foo/bar.txt".
78 The API path of the file, such as "foo/bar.txt".
39 """
79 """
40 return url_escape(url_path_join(
80 return url_escape(url_path_join(
41 self.base_url, 'api', 'contents', path
81 self.base_url, 'api', 'contents', path
42 ))
82 ))
43
83
44 def _finish_model(self, model, location=True):
84 def _finish_model(self, model, location=True):
45 """Finish a JSON request with a model, setting relevant headers, etc."""
85 """Finish a JSON request with a model, setting relevant headers, etc."""
46 if location:
86 if location:
47 location = self.location_url(model['path'])
87 location = self.location_url(model['path'])
48 self.set_header('Location', location)
88 self.set_header('Location', location)
49 self.set_header('Last-Modified', model['last_modified'])
89 self.set_header('Last-Modified', model['last_modified'])
50 self.set_header('Content-Type', 'application/json')
90 self.set_header('Content-Type', 'application/json')
51 self.finish(json.dumps(model, default=date_default))
91 self.finish(json.dumps(model, default=date_default))
52
92
53 @web.authenticated
93 @web.authenticated
54 @json_errors
94 @json_errors
55 def get(self, path=''):
95 def get(self, path=''):
56 """Return a model for a file or directory.
96 """Return a model for a file or directory.
57
97
58 A directory model contains a list of models (without content)
98 A directory model contains a list of models (without content)
59 of the files and directories it contains.
99 of the files and directories it contains.
60 """
100 """
61 path = path or ''
101 path = path or ''
62 type_ = self.get_query_argument('type', default=None)
102 type_ = self.get_query_argument('type', default=None)
63 if type_ not in {None, 'directory', 'file', 'notebook'}:
103 if type_ not in {None, 'directory', 'file', 'notebook'}:
64 raise web.HTTPError(400, u'Type %r is invalid' % type_)
104 raise web.HTTPError(400, u'Type %r is invalid' % type_)
65
105
66 format = self.get_query_argument('format', default=None)
106 format = self.get_query_argument('format', default=None)
67 if format not in {None, 'text', 'base64'}:
107 if format not in {None, 'text', 'base64'}:
68 raise web.HTTPError(400, u'Format %r is invalid' % format)
108 raise web.HTTPError(400, u'Format %r is invalid' % format)
69
109
70 model = self.contents_manager.get(path=path, type_=type_, format=format)
110 model = self.contents_manager.get(path=path, type_=type_, format=format)
71 if model['type'] == 'directory':
111 if model['type'] == 'directory':
72 # group listing by type, then by name (case-insensitive)
112 # group listing by type, then by name (case-insensitive)
73 # FIXME: sorting should be done in the frontends
113 # FIXME: sorting should be done in the frontends
74 model['content'].sort(key=sort_key)
114 model['content'].sort(key=sort_key)
115 validate_model(model, expect_content=True)
75 self._finish_model(model, location=False)
116 self._finish_model(model, location=False)
76
117
77 @web.authenticated
118 @web.authenticated
78 @json_errors
119 @json_errors
79 def patch(self, path=''):
120 def patch(self, path=''):
80 """PATCH renames a file or directory without re-uploading content."""
121 """PATCH renames a file or directory without re-uploading content."""
81 cm = self.contents_manager
122 cm = self.contents_manager
82 model = self.get_json_body()
123 model = self.get_json_body()
83 if model is None:
124 if model is None:
84 raise web.HTTPError(400, u'JSON body missing')
125 raise web.HTTPError(400, u'JSON body missing')
85 model = cm.update(model, path)
126 model = cm.update(model, path)
127 validate_model(model, expect_content=False)
86 self._finish_model(model)
128 self._finish_model(model)
87
129
88 def _copy(self, copy_from, copy_to=None):
130 def _copy(self, copy_from, copy_to=None):
89 """Copy a file, optionally specifying a target directory."""
131 """Copy a file, optionally specifying a target directory."""
90 self.log.info(u"Copying {copy_from} to {copy_to}".format(
132 self.log.info(u"Copying {copy_from} to {copy_to}".format(
91 copy_from=copy_from,
133 copy_from=copy_from,
92 copy_to=copy_to or '',
134 copy_to=copy_to or '',
93 ))
135 ))
94 model = self.contents_manager.copy(copy_from, copy_to)
136 model = self.contents_manager.copy(copy_from, copy_to)
95 self.set_status(201)
137 self.set_status(201)
138 validate_model(model, expect_content=False)
96 self._finish_model(model)
139 self._finish_model(model)
97
140
98 def _upload(self, model, path):
141 def _upload(self, model, path):
99 """Handle upload of a new file to path"""
142 """Handle upload of a new file to path"""
100 self.log.info(u"Uploading file to %s", path)
143 self.log.info(u"Uploading file to %s", path)
101 model = self.contents_manager.new(model, path)
144 model = self.contents_manager.new(model, path)
102 self.set_status(201)
145 self.set_status(201)
146 validate_model(model, expect_content=False)
103 self._finish_model(model)
147 self._finish_model(model)
104
148
105 def _new_untitled(self, path, type='', ext=''):
149 def _new_untitled(self, path, type='', ext=''):
106 """Create a new, empty untitled entity"""
150 """Create a new, empty untitled entity"""
107 self.log.info(u"Creating new %s in %s", type or 'file', path)
151 self.log.info(u"Creating new %s in %s", type or 'file', path)
108 model = self.contents_manager.new_untitled(path=path, type=type, ext=ext)
152 model = self.contents_manager.new_untitled(path=path, type=type, ext=ext)
109 self.set_status(201)
153 self.set_status(201)
154 validate_model(model, expect_content=False)
110 self._finish_model(model)
155 self._finish_model(model)
111
156
112 def _save(self, model, path):
157 def _save(self, model, path):
113 """Save an existing file."""
158 """Save an existing file."""
114 self.log.info(u"Saving file at %s", path)
159 self.log.info(u"Saving file at %s", path)
115 model = self.contents_manager.save(model, path)
160 model = self.contents_manager.save(model, path)
161 validate_model(model, expect_content=False)
116 self._finish_model(model)
162 self._finish_model(model)
117
163
118 @web.authenticated
164 @web.authenticated
119 @json_errors
165 @json_errors
120 def post(self, path=''):
166 def post(self, path=''):
121 """Create a new file in the specified path.
167 """Create a new file in the specified path.
122
168
123 POST creates new files. The server always decides on the name.
169 POST creates new files. The server always decides on the name.
124
170
125 POST /api/contents/path
171 POST /api/contents/path
126 New untitled, empty file or directory.
172 New untitled, empty file or directory.
127 POST /api/contents/path
173 POST /api/contents/path
128 with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
174 with body {"copy_from" : "/path/to/OtherNotebook.ipynb"}
129 New copy of OtherNotebook in path
175 New copy of OtherNotebook in path
130 """
176 """
131
177
132 cm = self.contents_manager
178 cm = self.contents_manager
133
179
134 if cm.file_exists(path):
180 if cm.file_exists(path):
135 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
181 raise web.HTTPError(400, "Cannot POST to files, use PUT instead.")
136
182
137 if not cm.dir_exists(path):
183 if not cm.dir_exists(path):
138 raise web.HTTPError(404, "No such directory: %s" % path)
184 raise web.HTTPError(404, "No such directory: %s" % path)
139
185
140 model = self.get_json_body()
186 model = self.get_json_body()
141
187
142 if model is not None:
188 if model is not None:
143 copy_from = model.get('copy_from')
189 copy_from = model.get('copy_from')
144 ext = model.get('ext', '')
190 ext = model.get('ext', '')
145 type = model.get('type', '')
191 type = model.get('type', '')
146 if copy_from:
192 if copy_from:
147 self._copy(copy_from, path)
193 self._copy(copy_from, path)
148 else:
194 else:
149 self._new_untitled(path, type=type, ext=ext)
195 self._new_untitled(path, type=type, ext=ext)
150 else:
196 else:
151 self._new_untitled(path)
197 self._new_untitled(path)
152
198
153 @web.authenticated
199 @web.authenticated
154 @json_errors
200 @json_errors
155 def put(self, path=''):
201 def put(self, path=''):
156 """Saves the file in the location specified by name and path.
202 """Saves the file in the location specified by name and path.
157
203
158 PUT is very similar to POST, but the requester specifies the name,
204 PUT is very similar to POST, but the requester specifies the name,
159 whereas with POST, the server picks the name.
205 whereas with POST, the server picks the name.
160
206
161 PUT /api/contents/path/Name.ipynb
207 PUT /api/contents/path/Name.ipynb
162 Save notebook at ``path/Name.ipynb``. Notebook structure is specified
208 Save notebook at ``path/Name.ipynb``. Notebook structure is specified
163 in `content` key of JSON request body. If content is not specified,
209 in `content` key of JSON request body. If content is not specified,
164 create a new empty notebook.
210 create a new empty notebook.
165 """
211 """
166 model = self.get_json_body()
212 model = self.get_json_body()
167 if model:
213 if model:
168 if model.get('copy_from'):
214 if model.get('copy_from'):
169 raise web.HTTPError(400, "Cannot copy with PUT, only POST")
215 raise web.HTTPError(400, "Cannot copy with PUT, only POST")
170 if self.contents_manager.file_exists(path):
216 if self.contents_manager.file_exists(path):
171 self._save(model, path)
217 self._save(model, path)
172 else:
218 else:
173 self._upload(model, path)
219 self._upload(model, path)
174 else:
220 else:
175 self._new_untitled(path)
221 self._new_untitled(path)
176
222
177 @web.authenticated
223 @web.authenticated
178 @json_errors
224 @json_errors
179 def delete(self, path=''):
225 def delete(self, path=''):
180 """delete a file in the given path"""
226 """delete a file in the given path"""
181 cm = self.contents_manager
227 cm = self.contents_manager
182 self.log.warn('delete %s', path)
228 self.log.warn('delete %s', path)
183 cm.delete(path)
229 cm.delete(path)
184 self.set_status(204)
230 self.set_status(204)
185 self.finish()
231 self.finish()
186
232
187
233
188 class CheckpointsHandler(IPythonHandler):
234 class CheckpointsHandler(IPythonHandler):
189
235
190 SUPPORTED_METHODS = ('GET', 'POST')
236 SUPPORTED_METHODS = ('GET', 'POST')
191
237
192 @web.authenticated
238 @web.authenticated
193 @json_errors
239 @json_errors
194 def get(self, path=''):
240 def get(self, path=''):
195 """get lists checkpoints for a file"""
241 """get lists checkpoints for a file"""
196 cm = self.contents_manager
242 cm = self.contents_manager
197 checkpoints = cm.list_checkpoints(path)
243 checkpoints = cm.list_checkpoints(path)
198 data = json.dumps(checkpoints, default=date_default)
244 data = json.dumps(checkpoints, default=date_default)
199 self.finish(data)
245 self.finish(data)
200
246
201 @web.authenticated
247 @web.authenticated
202 @json_errors
248 @json_errors
203 def post(self, path=''):
249 def post(self, path=''):
204 """post creates a new checkpoint"""
250 """post creates a new checkpoint"""
205 cm = self.contents_manager
251 cm = self.contents_manager
206 checkpoint = cm.create_checkpoint(path)
252 checkpoint = cm.create_checkpoint(path)
207 data = json.dumps(checkpoint, default=date_default)
253 data = json.dumps(checkpoint, default=date_default)
208 location = url_path_join(self.base_url, 'api/contents',
254 location = url_path_join(self.base_url, 'api/contents',
209 path, 'checkpoints', checkpoint['id'])
255 path, 'checkpoints', checkpoint['id'])
210 self.set_header('Location', url_escape(location))
256 self.set_header('Location', url_escape(location))
211 self.set_status(201)
257 self.set_status(201)
212 self.finish(data)
258 self.finish(data)
213
259
214
260
215 class ModifyCheckpointsHandler(IPythonHandler):
261 class ModifyCheckpointsHandler(IPythonHandler):
216
262
217 SUPPORTED_METHODS = ('POST', 'DELETE')
263 SUPPORTED_METHODS = ('POST', 'DELETE')
218
264
219 @web.authenticated
265 @web.authenticated
220 @json_errors
266 @json_errors
221 def post(self, path, checkpoint_id):
267 def post(self, path, checkpoint_id):
222 """post restores a file from a checkpoint"""
268 """post restores a file from a checkpoint"""
223 cm = self.contents_manager
269 cm = self.contents_manager
224 cm.restore_checkpoint(checkpoint_id, path)
270 cm.restore_checkpoint(checkpoint_id, path)
225 self.set_status(204)
271 self.set_status(204)
226 self.finish()
272 self.finish()
227
273
228 @web.authenticated
274 @web.authenticated
229 @json_errors
275 @json_errors
230 def delete(self, path, checkpoint_id):
276 def delete(self, path, checkpoint_id):
231 """delete clears a checkpoint for a given file"""
277 """delete clears a checkpoint for a given file"""
232 cm = self.contents_manager
278 cm = self.contents_manager
233 cm.delete_checkpoint(checkpoint_id, path)
279 cm.delete_checkpoint(checkpoint_id, path)
234 self.set_status(204)
280 self.set_status(204)
235 self.finish()
281 self.finish()
236
282
237
283
238 class NotebooksRedirectHandler(IPythonHandler):
284 class NotebooksRedirectHandler(IPythonHandler):
239 """Redirect /api/notebooks to /api/contents"""
285 """Redirect /api/notebooks to /api/contents"""
240 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
286 SUPPORTED_METHODS = ('GET', 'PUT', 'PATCH', 'POST', 'DELETE')
241
287
242 def get(self, path):
288 def get(self, path):
243 self.log.warn("/api/notebooks is deprecated, use /api/contents")
289 self.log.warn("/api/notebooks is deprecated, use /api/contents")
244 self.redirect(url_path_join(
290 self.redirect(url_path_join(
245 self.base_url,
291 self.base_url,
246 'api/contents',
292 'api/contents',
247 path
293 path
248 ))
294 ))
249
295
250 put = patch = post = delete = get
296 put = patch = post = delete = get
251
297
252
298
253 #-----------------------------------------------------------------------------
299 #-----------------------------------------------------------------------------
254 # URL to handler mappings
300 # URL to handler mappings
255 #-----------------------------------------------------------------------------
301 #-----------------------------------------------------------------------------
256
302
257
303
258 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
304 _checkpoint_id_regex = r"(?P<checkpoint_id>[\w-]+)"
259
305
260 default_handlers = [
306 default_handlers = [
261 (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
307 (r"/api/contents%s/checkpoints" % path_regex, CheckpointsHandler),
262 (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
308 (r"/api/contents%s/checkpoints/%s" % (path_regex, _checkpoint_id_regex),
263 ModifyCheckpointsHandler),
309 ModifyCheckpointsHandler),
264 (r"/api/contents%s" % path_regex, ContentsHandler),
310 (r"/api/contents%s" % path_regex, ContentsHandler),
265 (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
311 (r"/api/notebooks/?(.*)", NotebooksRedirectHandler),
266 ]
312 ]
General Comments 0
You need to be logged in to leave comments. Login now