You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

231 lines
8.1 KiB
Python

4 years ago
import sys
from server.models.models import Jsonifiable
from flask import Flask, request, Response, jsonify
from typing import Type, Final, List, Optional
from datetime import datetime
from neomodel import StructuredNode, RelationshipManager, RelationshipDefinition, db
4 years ago
from neomodel.cardinality import ZeroOrOne, ZeroOrMore, One, OneOrMore
import json
"""
This document declares some abstract api functions that will help you implement a REST api
for you StructuredNodes fast and efficiently.
The simplest way to achieve this is by using the register_api_object(cls: Type[StructuredNode])
method.
Example:
given the node type: (note that you need to add the mixin Jsonifiable)
class Person(StructuredNode, Jsonifiable):
uid = UniqueIdProperty()
name = StringProperty(required=True)
children = RelationshipTo('Person', 'CHILDREN')
# you can register the endpoints /api/person and /api/person/<uid> by calling
register_api_object(Person)
# if you want to preotect some attributes from being set this way, simply include them in the
BLOCKED_PROPERTIES global variable in this module
"""
BLOCKED_PROPERTIES: Final[List[str]] = ['id', 'uid', 'password']
class ApiError(Exception):
"""Defines an error that occurred during the handling of an API request"""
def __init__(self, msg, status=400, data=None):
super().__init__()
self.msg = msg
self.status = status
self.data = data
def __str__(self):
return json.dumps(dict(msg=self.msg, status=self.status))
def to_response(self) -> Response:
"""Converts the error into a response"""
resp: Response = jsonify(msg=self.msg, data=self.data)
resp.status_code = self.status
return resp
@staticmethod
def not_found(cls: Type[StructuredNode], uid=None):
return ApiError(f"Cannot find {cls.__name__}" + (f" with uid {uid}" if uid else "") + "!", status=404)
@staticmethod
def bad_request(msg: str, data=None):
return ApiError(msg, status=400, data=data)
def _connect_callback(attr, target):
"""returns a callback to connect attr with target, this is wrapped
because we need to get attr and target into a separate closure"""
return lambda: attr.connect(target)
def _get_target_class(rel: RelationshipDefinition) -> Type[StructuredNode]:
target_cls = rel._raw_class
return getattr(sys.modules[rel.module_name], target_cls) if isinstance(target_cls, str) else target_cls
def construct_object_from_request(cls: Type[StructuredNode], uid=None, data: dict = dict()):
4 years ago
"""Construct an obect of class cls from request data"""
# properties that you do not want to initialize or change this way
if uid:
obj: Final = cls.nodes.get_or_none(uid=uid)
if not obj:
raise ApiError.not_found(cls, uid)
else:
obj: Final = cls()
relationship_attach_callbacks = list()
for name, value in cls.__dict__.items():
if name[0] == '_' or name in BLOCKED_PROPERTIES or not name[0].islower():
continue
if name not in data:
continue
4 years ago
cls_name = value.__class__.__name__
convert_with = None
if cls_name == 'StringProperty':
convert_with = str
elif cls_name == 'DateTimeProperty':
4 years ago
convert_with = datetime.fromisoformat
elif cls_name == 'DateProperty':
convert_with = lambda d: datetime.fromisoformat(d).date()
4 years ago
elif cls_name == 'IntegerProperty':
convert_with = int
elif cls_name == 'FloatProperty':
convert_with = float
elif cls_name == 'RelationshipDefinition':
pass
#target_cls: Type[StructuredNode] = _get_target_class(value)
#
#for val in data[name] if
# target = target_cls.nodes.first_or_none(uid=val)
# if not target:
# raise ApiError(f'Cannot find referenced object uid={val} of type {target_cls.__name__}!')
#
# relationship_attach_callbacks.append(_connect_callback(getattr(obj, name), target))
4 years ago
if not convert_with:
# skip this property
continue
setattr(obj, name, convert_with(data[name]))
4 years ago
return obj, lambda: [c() for c in relationship_attach_callbacks]
def handle_object_api_request(cls: Type[StructuredNode]):
"""basic method for handling (GET|POST) /api/type calls"""
if request.method == 'GET':
limit = request.args.get('$limit', 100, int)
offset = request.args.get('$offset', 0, int)
options = {k: v for k, v in request.args.items() if k[0] != '$'}
return jsonify([
obj.json(include_relations=False)
for obj in cls.nodes.filter(**options).order_by('uid').all()[offset:offset+limit]
])
4 years ago
if request.method == 'POST':
with db.transaction:
obj, attach_relationships = construct_object_from_request(cls, data=request.get_json())
obj.save()
attach_relationships()
return jsonify(obj.json())
4 years ago
def handle_object_api_request_id(cls: Type[StructuredNode], uid: str):
"""basic method for handling (GET|PUT|DELETE) /api/type/uid calls"""
if request.method == 'GET':
obj = cls.nodes.get_or_none(uid=uid)
if not obj:
raise ApiError.not_found(cls, uid)
return jsonify(obj.json())
if request.method == 'PUT':
obj, attach_relationships = construct_object_from_request(cls, uid=uid, data=request.get_json())
4 years ago
obj.save()
attach_relationships()
return jsonify(obj.json())
if request.method == 'DELETE':
obj = cls.nodes.get_or_none(uid=uid)
if not obj:
raise ApiError.not_found(cls, uid)
obj.delete()
return '', 204
4 years ago
4 years ago
def handle_object_api_request_for_relation(cls: Type[StructuredNode], uid, relation, reluid):
obj = cls.nodes.get_or_none(uid=uid)
if not obj:
raise ApiError.not_found(cls, uid)
rel = getattr(cls, relation, None)
if not (rel and isinstance(rel, RelationshipDefinition)):
raise ApiError.bad_request(f'Object of type {cls.__name__} has no relation {relation}!')
target_cls = _get_target_class(rel)
target_obj = target_cls.nodes.get_or_none(uid=reluid)
if not target_obj:
raise ApiError.not_found(target_cls, reluid)
if request.method == 'DELETE':
getattr(obj, relation).disconnect(target_obj)
elif request.method == 'POST':
if isinstance(rel.manager, (ZeroOrOne, One)):
getattr(obj, relation).disconnect_all()
getattr(obj, relation).connect(target_obj)
return jsonify(obj.json())
4 years ago
def register_api_object(app: Flask, cls: Type[StructuredNode], name: Optional[str] = None):
"""Register api paths for the object type cls
routes registered:
GET /api/<name>
POST /api/<name>
GET /api/<name>/<uid>
PUT /api/<name>/<uid>
DELETE /api/<name>/<uid>
POST /api/<name>/<uid>/<relation>/<reluid> connect relationships
DELETE /api/<name>/<uid>/<relation>/<reluid> disconnect relationships
where <name> defaults to the lowercase class name of cls
internally it registers the three view functions api.<name>, api.<name>.with-id and api.<name>.rel
"""
name = name or cls.__name__.lower()
def func_handle():
return handle_object_api_request(cls)
def func_handle_id(uid):
return handle_object_api_request_id(cls, uid)
def func_handle_rel(uid, rel, reluid):
return handle_object_api_request_for_relation(cls, uid, rel, reluid)
app.view_functions[f'api.{name}'] = func_handle
app.view_functions[f'api.{name}.wth-id'] = func_handle_id
app.view_functions[f'api.{name}.rel'] = func_handle_rel
app.add_url_rule(f'/api/{name}',
f'api.{name}', func_handle,
methods=['GET', 'POST'])
app.add_url_rule(f'/api/{name}/<uid>',
f'api.{name}.with-id', func_handle_id,
methods=['GET', 'PUT', 'DELETE'])
app.add_url_rule(f'/api/{name}/<uid>/<rel>/<reluid>',
f'api.{name}.rel', func_handle_rel,
methods=['POST', 'DELETE'])