1
"""
2
Web handlers for the FractalServer.
3
"""
4 8
import json
5

6 8
import tornado.web
7 8
from pydantic import ValidationError
8 8
from qcelemental.util import deserialize, serialize
9

10 8
from .interface.models.rest_models import rest_model
11 8
from .storage_sockets.storage_utils import add_metadata_template
12

13 8
_valid_encodings = {
14
    "application/json": "json",
15
    "application/json-ext": "json-ext",
16
    "application/msgpack-ext": "msgpack-ext",
17
}
18

19

20 8
class APIHandler(tornado.web.RequestHandler):
21
    """
22
    A requests handler for API calls.
23
    """
24

25
    # Admin authentication required by default
26 8
    _required_auth = "admin"
27 8
    _logging_param_counts = {}
28

29 8
    def initialize(self, **objects):
30
        """
31
        Initializes the request to JSON, adds objects, and logging.
32
        """
33

34 8
        self.content_type = "Not Provided"
35 8
        try:
36
            # default to "application/json"
37 8
            self.content_type = self.request.headers.get("Content-Type", "application/json")
38 8
            self.encoding = _valid_encodings[self.content_type]
39 0
        except KeyError:
40 0
            raise tornado.web.HTTPError(
41
                status_code=401, reason=f"Did not understand 'Content-Type': {self.content_type}"
42
            )
43

44
        # Always reply in the format sent
45 8
        self.set_header("Content-Type", self.content_type)
46

47 8
        self.objects = objects
48 8
        self.storage = self.objects["storage_socket"]
49 8
        self.logger = objects["logger"]
50 8
        self.api_logger = objects["api_logger"]
51 8
        self.view_handler = objects["view_handler"]
52 8
        self.username = None
53

54 8
    def prepare(self):
55 8
        if self._required_auth:
56 8
            self.authenticate(self._required_auth)
57

58 8
        try:
59 8
            if (self.encoding == "json") and isinstance(self.request.body, bytes):
60 8
                blob = self.request.body.decode()
61
            else:
62 8
                blob = self.request.body
63

64 8
            if blob:
65 8
                self.data = deserialize(blob, self.encoding)
66
            else:
67 0
                self.data = None
68 0
        except:
69 0
            raise tornado.web.HTTPError(status_code=401, reason="Could not deserialize body.")
70

71 8
    def on_finish(self):
72

73 8
        exclude_uris = ["/task_queue", "/service_queue", "/queue_manager"]
74 8
        if self.api_logger and self.request.method == "GET" and self.request.uri not in exclude_uris:
75

76 0
            extra_params = self.data.copy()
77 0
            if self._logging_param_counts:
78 0
                for key in self._logging_param_counts:
79 0
                    if extra_params["data"].get(key, None):
80 0
                        extra_params["data"][key] = len(extra_params["data"][key])
81

82 0
            if "data" in extra_params:
83 0
                extra_params["data"] = {k: v for k, v in extra_params["data"].items() if v is not None}
84

85 0
            extra_params = json.dumps(extra_params)
86

87 0
            log = self.api_logger.get_api_access_log(request=self.request, extra_params=extra_params)
88 0
            self.storage.save_access(log)
89

90
        # self.logger.info('Done saving API access to the database')
91

92 8
    def authenticate(self, permission):
93
        """Authenticates request with a given permission setting.
94

95
        Parameters
96
        ----------
97
        permission : str
98
            The required permission ["read", "write", "compute", "admin"]
99

100
        """
101 8
        if "Authorization" in self.request.headers:
102

103 8
            data = json.loads(self.request.headers["Authorization"])
104 8
            username = data["username"]
105 8
            password = data["password"]
106
        else:
107 8
            username = None
108 8
            password = None
109

110 8
        self.username = username
111

112 8
        verified, msg = self.objects["storage_socket"].verify_user(username, password, permission)
113 8
        if verified is False:
114 8
            raise tornado.web.HTTPError(status_code=401, reason=msg)
115

116 8
    def parse_bodymodel(self, model):
117

118 8
        try:
119 8
            return model(**self.data)
120 0
        except ValidationError:
121 0
            raise tornado.web.HTTPError(status_code=401, reason="Invalid REST")
122

123 8
    def write(self, data):
124 8
        if not isinstance(data, (str, bytes)):
125 8
            data = serialize(data, self.encoding)
126

127 8
        return super().write(data)
128

129

130 8
class InformationHandler(APIHandler):
131
    """
132
    A handler that returns public server information.
133
    """
134

135 8
    _required_auth = "read"
136

137 8
    def get(self):
138
        """"""
139

140 8
        self.logger.info("GET: Information")
141

142 8
        self.write(self.objects["public_information"])
143

144

145 8
class KVStoreHandler(APIHandler):
146
    """
147
    A handler to push and get molecules.
148
    """
149

150 8
    _required_auth = "read"
151 8
    _logging_param_counts = {"id"}
152

153 8
    def get(self):
154
        """
155

156
        Experimental documentation, need to find a decent format.
157

158
        Request:
159
            "data" - A list of key requests
160

161
        Returns:
162
            "meta" - Metadata associated with the query
163
                - "errors" - A list of errors in (index, error_id) format.
164
                - "n_found" - The number of molecule found.
165
                - "success" - If the query was successful or not.
166
                - "error_description" - A string based description of the error or False
167
                - "missing" - A list of keys that were not found.
168
            "data" - A dictionary of {key : value} dictionary of the results
169

170
        """
171

172 4
        body_model, response_model = rest_model("kvstore", "get")
173 4
        body = self.parse_bodymodel(body_model)
174

175 4
        ret = self.storage.get_kvstore(body.data.id)
176 4
        ret = response_model(**ret)
177

178 4
        self.logger.info("GET: KVStore - {} pulls.".format(len(ret.data)))
179 4
        self.write(ret)
180

181

182 8
class WavefunctionStoreHandler(APIHandler):
183
    """
184
    A handler to push and get molecules.
185
    """
186

187 8
    _required_auth = "read"
188 8
    _logging_param_counts = {"id"}
189

190 8
    def get(self):
191

192 1
        body_model, response_model = rest_model("wavefunctionstore", "get")
193 1
        body = self.parse_bodymodel(body_model)
194

195 1
        ret = self.storage.get_wavefunction_store(body.data.id, include=body.meta.include)
196 1
        if len(ret["data"]):
197 1
            ret["data"] = ret["data"][0]
198 1
        ret = response_model(**ret)
199

200 1
        self.logger.info("GET: WavefunctionStore - 1 pull.")
201 1
        self.write(ret)
202

203

204 8
class MoleculeHandler(APIHandler):
205
    """
206
    A handler to push and get molecules.
207
    """
208

209 8
    _required_auth = "read"
210 8
    _logging_param_counts = {"id"}
211

212 8
    def get(self):
213
        """
214

215
        Experimental documentation, need to find a decent format.
216

217
        Request:
218
            "meta" - Overall options to the Molecule pull request
219
                - "index" - What kind of index used to find the data ("id", "molecule_hash", "molecular_formula")
220
            "data" - A dictionary of {key : index} requests
221

222
        Returns:
223
            "meta" - Metadata associated with the query
224
                - "errors" - A list of errors in (index, error_id) format.
225
                - "n_found" - The number of molecule found.
226
                - "success" - If the query was successful or not.
227
                - "error_description" - A string based description of the error or False
228
                - "missing" - A list of keys that were not found.
229
            "data" - A dictionary of {key : molecule JSON} results
230

231
        """
232

233 8
        body_model, response_model = rest_model("molecule", "get")
234 8
        body = self.parse_bodymodel(body_model)
235

236 8
        molecules = self.storage.get_molecules(**{**body.data.dict(), **body.meta.dict()})
237 8
        ret = response_model(**molecules)
238

239 8
        self.logger.info("GET: Molecule - {} pulls.".format(len(ret.data)))
240 8
        self.write(ret)
241

242 8
    def post(self):
243
        """
244
            Experimental documentation, need to find a decent format.
245

246
        Request:
247
            "meta" - Overall options to the Molecule pull request
248
                - No current options
249
            "data" - A dictionary of {key : molecule JSON} requests
250

251
        Returns:
252
            "meta" - Metadata associated with the query
253
                - "errors" - A list of errors in (index, error_id) format.
254
                - "n_inserted" - The number of molecule inserted.
255
                - "success" - If the query was successful or not.
256
                - "error_description" - A string based description of the error or False
257
                - "duplicates" - A list of keys that were already inserted.
258
            "data" - A dictionary of {key : id} results
259
        """
260

261 8
        self.authenticate("write")
262

263 8
        body_model, response_model = rest_model("molecule", "post")
264 8
        body = self.parse_bodymodel(body_model)
265

266 8
        ret = self.storage.add_molecules(body.data)
267 8
        response = response_model(**ret)
268

269 8
        self.logger.info("POST: Molecule - {} inserted.".format(response.meta.n_inserted))
270 8
        self.write(response)
271

272

273 8
class KeywordHandler(APIHandler):
274
    """
275
    A handler to push and get molecules.
276
    """
277

278 8
    _required_auth = "read"
279 8
    _logging_param_counts = {"id"}
280

281 8
    def get(self):
282

283 8
        body_model, response_model = rest_model("keyword", "get")
284 8
        body = self.parse_bodymodel(body_model)
285

286 8
        ret = self.storage.get_keywords(**{**body.data.dict(), **body.meta.dict()}, with_ids=False)
287 8
        response = response_model(**ret)
288

289 8
        self.logger.info("GET: Keywords - {} pulls.".format(len(response.data)))
290 8
        self.write(response)
291

292 8
    def post(self):
293 8
        self.authenticate("write")
294

295 8
        body_model, response_model = rest_model("keyword", "post")
296 8
        body = self.parse_bodymodel(body_model)
297

298 8
        ret = self.storage.add_keywords(body.data)
299 8
        response = response_model(**ret)
300

301 8
        self.logger.info("POST: Keywords - {} inserted.".format(response.meta.n_inserted))
302 8
        self.write(response)
303

304

305 8
class CollectionHandler(APIHandler):
306
    """
307
    A handler to push and get molecules.
308
    """
309

310 8
    _required_auth = "read"
311

312 8
    def get(self, collection_id=None, view_function=None):
313

314
        # List collections
315 8
        if (collection_id is None) and (view_function is None):
316 8
            body_model, response_model = rest_model("collection", "get")
317 8
            body = self.parse_bodymodel(body_model)
318

319 8
            cols = self.storage.get_collections(
320
                **body.data.dict(), include=body.meta.include, exclude=body.meta.exclude
321
            )
322 8
            response = response_model(**cols)
323

324 8
            self.logger.info("GET: Collections - {} pulls.".format(len(response.data)))
325 8
            self.write(response)
326 8
            return
327

328
        # Get specific collection
329 8
        elif (collection_id is not None) and (view_function is None):
330 8
            body_model, response_model = rest_model("collection", "get")
331

332 8
            body = self.parse_bodymodel(body_model)
333 8
            cols = self.storage.get_collections(
334
                **body.data.dict(), col_id=int(collection_id), include=body.meta.include, exclude=body.meta.exclude
335
            )
336 8
            response = response_model(**cols)
337

338 8
            self.logger.info("GET: Collections - {} pulls.".format(len(response.data)))
339 8
            self.write(response)
340 8
            return
341

342
        # View-backed function on collection
343 8
        elif (collection_id is not None) and (view_function is not None):
344 8
            body_model, response_model = rest_model(f"collection/{collection_id}/{view_function}", "get")
345 8
            body = self.parse_bodymodel(body_model)
346 8
            if self.view_handler is None:
347 0
                meta = {
348
                    "success": False,
349
                    "error_description": "Server does not support collection views.",
350
                    "errors": [],
351
                    "msgpacked_cols": [],
352
                }
353 0
                self.write(response_model(meta=meta, data=None))
354 0
                self.logger.info("GET: Collections - view request made, but server does not have a view_handler.")
355 0
                return
356

357 8
            result = self.view_handler.handle_request(collection_id, view_function, body.data.dict())
358 8
            response = response_model(**result)
359

360 8
            self.logger.info(f"GET: Collections - {collection_id} view {view_function} pulls.")
361 8
            self.write(response)
362 8
            return
363

364
        # Unreachable?
365
        else:
366 0
            body_model, response_model = rest_model("collection", "get")
367 0
            meta = add_metadata_template()
368 0
            meta["success"] = False
369 0
            meta["error_description"] = "GET request for view with no collection ID not understood."
370 0
            self.write(response_model(meta=meta, data=None))
371 0
            self.logger.info(
372
                "GET: Collections - collection id is None, but view function is not None (should be unreachable)."
373
            )
374 0
            return
375

376 8
    def post(self, collection_id=None, view_function=None):
377 8
        self.authenticate("write")
378

379 8
        body_model, response_model = rest_model("collection", "post")
380 8
        body = self.parse_bodymodel(body_model)
381

382
        # POST requests not supported for anything other than "/collection"
383 8
        if collection_id is not None or view_function is not None:
384 8
            meta = add_metadata_template()
385 8
            meta["success"] = False
386 8
            meta["error_description"] = "POST requests not supported for sub-resources of /collection"
387 8
            self.write(response_model(meta=meta, data=None))
388 8
            self.logger.info("POST: Collections - Access attempted on subresource.")
389 8
            return
390

391 8
        ret = self.storage.add_collection(body.data.dict(), overwrite=body.meta.overwrite)
392 8
        response = response_model(**ret)
393

394 8
        self.logger.info("POST: Collections - {} inserted.".format(response.meta.n_inserted))
395 8
        self.write(response)
396

397 8
    def delete(self, collection_id, _):
398 8
        self.authenticate("write")
399

400 8
        body_model, response_model = rest_model(f"collection/{collection_id}", "delete")
401 8
        ret = self.storage.del_collection(col_id=collection_id)
402 8
        if ret == 0:
403 8
            self.logger.info(f"DELETE: Collections - Attempted to delete non-existent collection {collection_id}.")
404 8
            raise tornado.web.HTTPError(status_code=404, reason=f"Collection {collection_id} does not exist.")
405
        else:
406 8
            self.write(response_model(meta={"success": True, "errors": [], "error_description": False}))
407 8
            self.logger.info(f"DELETE: Collections - Deleted collection {collection_id}.")
408

409

410 8
class ResultHandler(APIHandler):
411
    """
412
    A handler to push and get molecules.
413
    """
414

415 8
    _required_auth = "read"
416 8
    _logging_param_counts = {"id", "molecule"}
417

418 8
    def get(self):
419

420 4
        body_model, response_model = rest_model("result", "get")
421 4
        body = self.parse_bodymodel(body_model)
422

423 4
        ret = self.storage.get_results(**{**body.data.dict(), **body.meta.dict()})
424 4
        result = response_model(**ret)
425

426 4
        self.logger.info("GET: Results - {} pulls.".format(len(result.data)))
427 4
        self.write(result)
428

429

430 8
class ProcedureHandler(APIHandler):
431
    """
432
    A handler to push and get molecules.
433
    """
434

435 8
    _required_auth = "read"
436 8
    _logging_param_counts = {"id"}
437

438 8
    def get(self, query_type="get"):
439

440 8
        body_model, response_model = rest_model("procedure", query_type)
441 8
        body = self.parse_bodymodel(body_model)
442

443 8
        try:
444 8
            if query_type == "get":
445 8
                ret = self.storage.get_procedures(**{**body.data.dict(), **body.meta.dict()})
446
            else:  # all other queries, like 'best_opt_results'
447 0
                ret = self.storage.custom_query("procedure", query_type, **{**body.data.dict(), **body.meta.dict()})
448 8
        except KeyError as e:
449 8
            raise tornado.web.HTTPError(status_code=401, reason=str(e))
450

451 1
        response = response_model(**ret)
452

453 1
        self.logger.info("GET: Procedures - {} pulls.".format(len(response.data)))
454 1
        self.write(response)
455

456

457 8
class OptimizationHandler(APIHandler):
458
    """
459
    A handler to push and get molecules.
460
    """
461

462 8
    _required_auth = "read"
463 8
    _logging_param_counts = {"id"}
464

465 8
    def get(self, query_type="get"):
466

467 8
        body_model, response_model = rest_model(f"optimization/{query_type}", "get")
468 8
        body = self.parse_bodymodel(body_model)
469

470 8
        try:
471 8
            if query_type == "get":
472 0
                ret = self.storage.get_procedures(**{**body.data.dict(), **body.meta.dict()})
473
            else:  # all other queries, like 'best_opt_results'
474 8
                ret = self.storage.custom_query("optimization", query_type, **{**body.data.dict(), **body.meta.dict()})
475 0
        except KeyError as e:
476 0
            raise tornado.web.HTTPError(status_code=401, reason=str(e))
477

478 8
        response = response_model(**ret)
479

480 8
        self.logger.info("GET: Optimization ({}) - {} pulls.".format(query_type, len(response.data)))
481 8
        self.write(response)

Read our documentation on viewing source code .

Loading