diff --git a/.github/workflows/development.yaml b/.github/workflows/development.yaml index f8c587e..d18c997 100644 --- a/.github/workflows/development.yaml +++ b/.github/workflows/development.yaml @@ -42,7 +42,7 @@ jobs: run: | export PHARUS_VERSION=$(cat pharus/version.py | tail -1 | awk -F\' '{print $2}') export HOST_UID=$(id -u) - docker-compose -f docker-compose-docs.yaml up --exit-code-from pharus --build + docker-compose -f docker-compose-docs.yaml up --exit-code-from pharus-docs --build echo "PHARUS_VERSION=${PHARUS_VERSION}" >> $GITHUB_ENV - name: Add docs static artifacts uses: actions/upload-artifact@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index f44b88d..dd42bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,21 @@ Observes [Semantic Versioning](https://semver.org/spec/v2.0.0.html) standard and [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) convention. -## [Unreleased] +## [0.1.0] - 2021-03-31 +### Added +- Local database instance pre-populated with sample data for `dev` Docker Compose environment. PR #99 +- Capability to insert multiple, update multiple, and delete multiple. PR #99 +- Allow dependency restriction to include secondary attributes from parent table. PR #99 + ### Changed -- Update `datajoint` to newly released `0.13.0`. +- Update `datajoint` to newly released `0.13.0`. PR #97 +- Rename service `pharus` to `pharus-docs` in `docs` Docker Compose environment to allow simulataneous development. PR #99 +- Update NGINX reverse proxy image reference. PR #99 +- Refactored API design to align with common REST resource naming convention. (#38) PR #99 +- Hide classes and methods that are internal and subject to change. PR #99 + +### Removed +- `InvalidDeleteRequest` exception is no longer available as it is now allowed to delete more than 1 record at a time. PR #99 ## [0.1.0b2] - 2021-03-12 @@ -54,7 +66,7 @@ Observes [Semantic Versioning](https://semver.org/spec/v2.0.0.html) standard and - Support for DataJoint attribute types: `varchar`, `int`, `float`, `datetime`, `date`, `time`, `decimal`, `uuid`. - Check dependency utility to determine child table references. -[Unreleased]: https://github.com/datajoint/pharus/compare/0.1.0b2...HEAD +[0.1.0]: https://github.com/datajoint/pharus/compare/0.1.0b2...0.1.0 [0.1.0b2]: https://github.com/datajoint/pharus/compare/0.1.0b0...0.1.0b2 [0.1.0b0]: https://github.com/datajoint/pharus/compare/0.1.0a5...0.1.0b0 [0.1.0a5]: https://github.com/datajoint/pharus/releases/tag/0.1.0a5 \ No newline at end of file diff --git a/README.rst b/README.rst index ce8548b..7dbd177 100644 --- a/README.rst +++ b/README.rst @@ -1,10 +1,6 @@ User Documentation ================== -.. warning:: - - The Pharus project is still early in its life and the maintainers are currently actively developing with a priority of addressing first critical issues directly related to the deliveries of `Alpha `_ and `Beta `_ milestones. Please be advised that while working through our milestones, we may restructure/refactor the codebase without warning until we issue our `Official Release `_ currently planned as ``0.1.0`` on ``2021-03-31``. - ``pharus`` is a generic REST API server backend for `DataJoint `_ pipelines built on top of ``flask``, ``datajoint``, and ``pyjwt``. - `Documentation `_ @@ -33,13 +29,13 @@ To start the API server, use the command: .. code-block:: bash - PHARUS_VERSION=0.1.0b2 docker-compose -f docker-compose-deploy.yaml up -d + PHARUS_VERSION=0.1.0 docker-compose -f docker-compose-deploy.yaml up -d To stop the API server, use the command: .. code-block:: bash - PHARUS_VERSION=0.1.0b2 docker-compose -f docker-compose-deploy.yaml down + PHARUS_VERSION=0.1.0 docker-compose -f docker-compose-deploy.yaml down References ---------- diff --git a/docker-compose-deploy.yaml b/docker-compose-deploy.yaml index 1e740e9..5520596 100644 --- a/docker-compose-deploy.yaml +++ b/docker-compose-deploy.yaml @@ -1,5 +1,5 @@ -# PHARUS_VERSION=0.1.0b2 docker-compose -f docker-compose-deploy.yaml pull -# PHARUS_VERSION=0.1.0b2 docker-compose -f docker-compose-deploy.yaml up -d +# PHARUS_VERSION=0.1.0 docker-compose -f docker-compose-deploy.yaml pull +# PHARUS_VERSION=0.1.0 docker-compose -f docker-compose-deploy.yaml up -d # # Intended for production deployment. # Note: You must run both commands above for minimal outage @@ -20,7 +20,7 @@ services: # - PHARUS_PREFIX=/ fakeservices.datajoint.io: <<: *net - image: datajoint/nginx:v0.0.15 + image: datajoint/nginx:v0.0.16 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 diff --git a/docker-compose-dev.yaml b/docker-compose-dev.yaml index 0bbc005..8da8d42 100644 --- a/docker-compose-dev.yaml +++ b/docker-compose-dev.yaml @@ -11,6 +11,13 @@ x-net: &net networks: - main services: + local-db: + <<: *net + image: datajoint/mysql:5.7 + environment: + - MYSQL_ROOT_PASSWORD=pharus + volumes: + - ./tests/init/init.sql:/docker-entrypoint-initdb.d/init.sql pharus: <<: *net extends: @@ -21,9 +28,12 @@ services: volumes: - ./pharus:/opt/conda/lib/python3.8/site-packages/pharus command: pharus + depends_on: + local-db: + condition: service_healthy fakeservices.datajoint.io: <<: *net - image: datajoint/nginx:v0.0.15 + image: datajoint/nginx:v0.0.16 environment: - ADD_pharus_TYPE=REST - ADD_pharus_ENDPOINT=pharus:5000 diff --git a/docker-compose-docs.yaml b/docker-compose-docs.yaml index 65d8085..8842064 100644 --- a/docker-compose-docs.yaml +++ b/docker-compose-docs.yaml @@ -1,9 +1,9 @@ -# PY_VER=3.8 IMAGE=djtest DISTRO=alpine HOST_UID=$(id -u) docker-compose -f docker-compose-docs.yaml up --exit-code-from pharus --build +# PY_VER=3.8 IMAGE=djtest DISTRO=alpine HOST_UID=$(id -u) docker-compose -f docker-compose-docs.yaml up --exit-code-from pharus-docs --build # # Used to build documentation artifacts. version: "2.4" services: - pharus: + pharus-docs: image: datajoint/${IMAGE}:py${PY_VER}-${DISTRO} user: ${HOST_UID}:anaconda volumes: diff --git a/pharus/error.py b/pharus/error.py index 50e132c..a3f629b 100644 --- a/pharus/error.py +++ b/pharus/error.py @@ -9,8 +9,3 @@ class UnsupportedTableType(Exception): class InvalidRestriction(Exception): """Exception raised when restrictions result in no records when expected at least one.""" pass - - -class InvalidDeleteRequest(Exception): - """Exception raised when attempting to delete >1 or <1 records.""" - pass diff --git a/pharus/interface.py b/pharus/interface.py index d9a02a2..ea9f263 100644 --- a/pharus/interface.py +++ b/pharus/interface.py @@ -5,18 +5,17 @@ from datajoint import VirtualModule import datetime import numpy as np -from functools import reduce -from .error import InvalidDeleteRequest, InvalidRestriction, UnsupportedTableType +from .error import InvalidRestriction, UnsupportedTableType DAY = 24 * 60 * 60 DEFAULT_FETCH_LIMIT = 1000 # Stop gap measure to deal with super large tables -class DJConnector(): +class _DJConnector(): """Primary connector that communicates with a DataJoint database server.""" @staticmethod - def attempt_login(database_address: str, username: str, password: str) -> dict: + def _attempt_login(database_address: str, username: str, password: str): """ Attempts to authenticate against database with given username and address. @@ -26,9 +25,6 @@ def attempt_login(database_address: str, username: str, password: str) -> dict: :type username: str :param password: Password of user :type password: str - :return: Dictionary with keys: result (``True`` | ``False``), and error (if - applicable) - :rtype: dict """ dj.config['database.host'] = database_address dj.config['database.user'] = username @@ -36,10 +32,9 @@ def attempt_login(database_address: str, username: str, password: str) -> dict: # Attempt to connect return true if successful, false is failed dj.conn(reset=True) - return dict(result=True) @staticmethod - def list_schemas(jwt_payload: dict) -> list: + def _list_schemas(jwt_payload: dict) -> list: """ List all schemas under the database. @@ -50,7 +45,7 @@ def list_schemas(jwt_payload: dict) -> list: ``sys``, ``performance_schema``, ``mysql``) :rtype: list """ - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) # Attempt to connect return true if successful, false is failed return [row[0] for row in dj.conn().query(""" @@ -60,7 +55,7 @@ def list_schemas(jwt_payload: dict) -> list: """)] @staticmethod - def list_tables(jwt_payload: dict, schema_name: str) -> dict: + def _list_tables(jwt_payload: dict, schema_name: str) -> dict: """ List all tables and their type given a schema. @@ -73,49 +68,46 @@ def list_tables(jwt_payload: dict, schema_name: str) -> dict: table names :rtype: dict """ - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) # Get list of tables names tables_name = dj.Schema(schema_name, create_schema=False).list_tables() - # Dict to store list of table name for each type - tables_dict_list = dict(manual_tables=[], lookup_tables=[], computed_tables=[], - imported_tables=[], part_tables=[]) - + tables_dict_list = dict(manual=[], lookup=[], computed=[], + imported=[], part=[]) # Loop through each table name to figure out what type it is and add them to # tables_dict_list for table_name in tables_name: table_type = dj.diagram._get_tier( '`' + schema_name + '`.`' + table_name + '`').__name__ if table_type == 'Manual': - tables_dict_list['manual_tables'].append(dj.utils.to_camel_case(table_name)) + tables_dict_list['manual'].append(dj.utils.to_camel_case(table_name)) elif table_type == 'Lookup': - tables_dict_list['lookup_tables'].append(dj.utils.to_camel_case(table_name)) + tables_dict_list['lookup'].append(dj.utils.to_camel_case(table_name)) elif table_type == 'Computed': - tables_dict_list['computed_tables'].append(dj.utils.to_camel_case(table_name)) + tables_dict_list['computed'].append(dj.utils.to_camel_case(table_name)) elif table_type == 'Imported': - tables_dict_list['imported_tables'].append(dj.utils.to_camel_case(table_name)) + tables_dict_list['imported'].append(dj.utils.to_camel_case(table_name)) elif table_type == 'Part': table_name_parts = table_name.split('__') - tables_dict_list['part_tables'].append( + tables_dict_list['part'].append( to_camel_case(table_name_parts[-2]) + '.' + to_camel_case(table_name_parts[-1])) else: raise UnsupportedTableType(table_name + ' is of unknown table type') - return tables_dict_list @staticmethod - def fetch_tuples(jwt_payload: dict, schema_name: str, table_name: str, - restriction: list = [], limit: int = 1000, page: int = 1, - order=['KEY ASC']) -> tuple: + def _fetch_records(jwt_payload: dict, schema_name: str, table_name: str, + restriction: list = [], limit: int = 1000, page: int = 1, + order=['KEY ASC']) -> tuple: """ - Get records as tuples from table. + Get records from table. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings :type jwt_payload: dict - :param schema_name: Name of schema to list all tables from + :param schema_name: Name of schema :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str @@ -129,38 +121,21 @@ def fetch_tuples(jwt_payload: dict, schema_name: str, table_name: str, :param order: Sequence to order records, defaults to ``['KEY ASC']``. See :class:`~datajoint.fetch.Fetch` for more info. :type order: list, optional - :return: Records in dict form and the total number of records that can be paged + :return: Attribute headers, records in dict form, and the total number of records that + can be paged :rtype: tuple """ - def filter_to_restriction(attribute_filter: dict) -> str: - if attribute_filter['operation'] in ('>', '<', '>=', '<='): - operation = attribute_filter['operation'] - elif attribute_filter['value'] is None: - operation = (' IS ' if attribute_filter['operation'] == '=' - else ' IS NOT ') - else: - operation = attribute_filter['operation'] - - if (isinstance(attribute_filter['value'], str) and - not attribute_filter['value'].isnumeric()): - value = f"'{attribute_filter['value']}'" - else: - value = ('NULL' if attribute_filter['value'] is None - else attribute_filter['value']) - - return f"{attribute_filter['attributeName']}{operation}{value}" - - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) schema_virtual_module = dj.create_virtual_module(schema_name, schema_name) # Get table object from name - table = DJConnector.get_table_object(schema_virtual_module, table_name) + table = _DJConnector._get_table_object(schema_virtual_module, table_name) # Fetch tuples without blobs as dict to be used to create a # list of tuples for returning - query = reduce(lambda q1, q2: q1 & q2, [table()] + [filter_to_restriction(f) - for f in restriction]) + query = table & dj.AndList([_DJConnector._filter_to_restriction(f) + for f in restriction]) non_blobs_rows = query.fetch(*table.heading.non_blobs, as_dict=True, limit=limit, offset=(page-1)*limit, order_by=order) @@ -205,10 +180,10 @@ def filter_to_restriction(attribute_filter: dict) -> str: # Add the row list to tuples rows.append(row) - return rows, len(query) + return list(table.heading.attributes.keys()), rows, len(query) @staticmethod - def get_table_attributes(jwt_payload: dict, schema_name: str, table_name: str) -> dict: + def _get_table_attributes(jwt_payload: dict, schema_name: str, table_name: str) -> dict: """ Method to get primary and secondary attributes of a table. @@ -219,22 +194,23 @@ def get_table_attributes(jwt_payload: dict, schema_name: str, table_name: str) - :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str - :return: Dict with keys ``primary_attributes``, ``secondary_attributes`` containing a + :return: Dict with keys ``attribute_headers`` and ``attributes`` containing + ``primary``, ``secondary`` which each contain a ``list`` of ``tuples`` specifying: ``attribute_name``, ``type``, ``nullable``, ``default``, ``autoincrement``. :rtype: dict """ - DJConnector.set_datajoint_config(jwt_payload) - - schema_virtual_module = dj.create_virtual_module(schema_name, schema_name) + _DJConnector._set_datajoint_config(jwt_payload) + local_values = locals() + local_values[schema_name] = dj.VirtualModule(schema_name, schema_name) # Get table object from name - table = DJConnector.get_table_object(schema_virtual_module, table_name) + table = _DJConnector._get_table_object(local_values[schema_name], table_name) - table_attributes = dict(primary_attributes=[], secondary_attributes=[]) + table_attributes = dict(primary=[], secondary=[]) for attribute_name, attribute_info in table.heading.attributes.items(): if attribute_info.in_key: - table_attributes['primary_attributes'].append(( + table_attributes['primary'].append(( attribute_name, attribute_info.type, attribute_info.nullable, @@ -242,7 +218,7 @@ def get_table_attributes(jwt_payload: dict, schema_name: str, table_name: str) - attribute_info.autoincrement )) else: - table_attributes['secondary_attributes'].append(( + table_attributes['secondary'].append(( attribute_name, attribute_info.type, attribute_info.nullable, @@ -250,53 +226,55 @@ def get_table_attributes(jwt_payload: dict, schema_name: str, table_name: str) - attribute_info.autoincrement )) - return table_attributes + return dict(attribute_headers=['name', 'type', 'nullable', + 'default', 'autoincrement'], + attributes=table_attributes) @staticmethod - def get_table_definition(jwt_payload: dict, schema_name: str, table_name: str) -> str: + def _get_table_definition(jwt_payload: dict, schema_name: str, table_name: str) -> str: """ Get the table definition. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings :type jwt_payload: dict - :param schema_name: Name of schema to list all tables from + :param schema_name: Name of schema :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str :return: Definition of the table :rtype: str """ - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) local_values = locals() local_values[schema_name] = dj.VirtualModule(schema_name, schema_name) return getattr(local_values[schema_name], table_name).describe() @staticmethod - def insert_tuple(jwt_payload: dict, schema_name: str, table_name: str, - tuple_to_insert: dict): + def _insert_tuple(jwt_payload: dict, schema_name: str, table_name: str, + tuple_to_insert: dict): """ Insert record as tuple into table. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings :type jwt_payload: dict - :param schema_name: Name of schema to list all tables from + :param schema_name: Name of schema :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str :param tuple_to_insert: Record to be inserted :type tuple_to_insert: dict """ - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) schema_virtual_module = dj.create_virtual_module(schema_name, schema_name) - getattr(schema_virtual_module, table_name).insert1(tuple_to_insert) + getattr(schema_virtual_module, table_name).insert(tuple_to_insert) @staticmethod - def record_dependency(jwt_payload: dict, schema_name: str, table_name: str, - primary_restriction: dict) -> list: + def _record_dependency(jwt_payload: dict, schema_name: str, table_name: str, + restriction: list = []) -> list: """ Return summary of dependencies associated with a restricted table. Will only show dependencies that user has access to. @@ -308,89 +286,86 @@ def record_dependency(jwt_payload: dict, schema_name: str, table_name: str, :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str - :param primary_restriction: Restriction to be applied to table - :type primary_restriction: dict + :param restriction: Sequence of filters as ``dict`` with ``attributeName``, + ``operation``, ``value`` keys defined, defaults to ``[]`` + :type restriction: list :return: Tables that are dependent on specific records. :rtype: list """ - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) virtual_module = dj.VirtualModule(schema_name, schema_name) table = getattr(virtual_module, table_name) # Retrieve dependencies of related to retricted dependencies = [dict(schema=descendant.database, table=descendant.table_name, - accessible=True, count=len(descendant & primary_restriction)) + accessible=True, count=len( + (table if descendant.full_table_name == table.full_table_name + else descendant * table) & dj.AndList([ + _DJConnector._filter_to_restriction(f) + for f in restriction]))) for descendant in table().descendants(as_objects=True)] return dependencies @staticmethod - def update_tuple(jwt_payload: dict, schema_name: str, table_name: str, - tuple_to_update: dict): + def _update_tuple(jwt_payload: dict, schema_name: str, table_name: str, + tuple_to_update: dict): """ Update record as tuple into table. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings :type jwt_payload: dict - :param schema_name: Name of schema to list all tables from + :param schema_name: Name of schema :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str :param tuple_to_update: Record to be updated :type tuple_to_update: dict """ - DJConnector.set_datajoint_config(jwt_payload) + conn = _DJConnector._set_datajoint_config(jwt_payload) schema_virtual_module = dj.create_virtual_module(schema_name, schema_name) - getattr(schema_virtual_module, table_name).update1(tuple_to_update) + with conn.transaction: + [getattr(schema_virtual_module, table_name).update1(t) for t in tuple_to_update] @staticmethod - def delete_tuple(jwt_payload: dict, schema_name: str, table_name: str, - tuple_to_restrict_by: dict, cascade: bool = False): + def _delete_records(jwt_payload: dict, schema_name: str, table_name: str, + restriction: list = [], cascade: bool = False): """ - Delete a specific record based on the restriction given (supports only deleting one at - a time). + Delete a specific record based on the restriction given. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings :type jwt_payload: dict - :param schema_name: Name of schema to list all tables from + :param schema_name: Name of schema :type schema_name: str :param table_name: Table name under the given schema; must be in camel case :type table_name: str - :param tuple_to_restrict_by: Record to restrict the table by to delete - :type tuple_to_restrict_by: dict + :param restriction: Sequence of filters as ``dict`` with ``attributeName``, + ``operation``, ``value`` keys defined, defaults to ``[]`` + :type restriction: list, optional :param cascade: Allow for cascading delete, defaults to ``False`` - :type cascade: bool + :type cascade: bool, optional """ - DJConnector.set_datajoint_config(jwt_payload) + _DJConnector._set_datajoint_config(jwt_payload) schema_virtual_module = dj.create_virtual_module(schema_name, schema_name) - # Get all the table attributes and create a set - table_attributes = set(getattr(schema_virtual_module, - table_name).heading.primary_key + - getattr(schema_virtual_module, - table_name).heading.secondary_attributes) - # Check to see if the restriction has at least one matching attribute, if not raise an - # error - if len(table_attributes & tuple_to_restrict_by.keys()) == 0: - raise InvalidRestriction('Restriction is invalid: None of the attributes match') + # Get table object from name + table = _DJConnector._get_table_object(schema_virtual_module, table_name) - # Compute restriction - tuple_to_delete = getattr(schema_virtual_module, table_name) & tuple_to_restrict_by + restrictions = [_DJConnector._filter_to_restriction(f) for f in restriction] + # Compute restriction + query = table & dj.AndList(restrictions) # Check if there is only 1 tuple to delete otherwise raise error - if len(tuple_to_delete) > 1: - raise InvalidDeleteRequest("""Cannot delete more than 1 tuple at a time. - Please update the restriction accordingly""") - elif len(tuple_to_delete) == 0: - raise InvalidDeleteRequest('Nothing to delete') + if len(query) == 0: + raise InvalidRestriction('Nothing to delete') # All check pass thus proceed to delete - tuple_to_delete.delete(safemode=False) if cascade else tuple_to_delete.delete_quick() + query.delete(safemode=False) if cascade else query.delete_quick() @staticmethod - def get_table_object(schema_virtual_module: VirtualModule, table_name: str) -> UserTable: + def _get_table_object(schema_virtual_module: VirtualModule, table_name: str) -> UserTable: """ Helper method for getting the table object based on the table name provided. @@ -410,15 +385,44 @@ def get_table_object(schema_virtual_module: VirtualModule, table_name: str) -> U return getattr(schema_virtual_module, table_name_parts[0]) @staticmethod - def set_datajoint_config(jwt_payload: dict): + def _filter_to_restriction(attribute_filter: dict) -> str: + """ + Convert attribute filter to a restriction. + + :param attribute_filter: A filter as ``dict`` with ``attributeName``, ``operation``, + ``value`` keys defined, defaults to ``[]`` + :type attribute_filter: dict + :return: DataJoint-compatible restriction + :rtype: str + """ + if attribute_filter['operation'] in ('>', '<', '>=', '<='): + operation = attribute_filter['operation'] + elif attribute_filter['value'] is None: + operation = (' IS ' if attribute_filter['operation'] == '=' + else ' IS NOT ') + else: + operation = attribute_filter['operation'] + + if (isinstance(attribute_filter['value'], str) and + not attribute_filter['value'].isnumeric()): + value = f"'{attribute_filter['value']}'" + else: + value = ('NULL' if attribute_filter['value'] is None + else attribute_filter['value']) + return f"{attribute_filter['attributeName']}{operation}{value}" + + @staticmethod + def _set_datajoint_config(jwt_payload: dict) -> dj.connection.Connection: """ Method to set credentials for database. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings :type jwt_payload: dict + :return: DataJoint connection object. + :rtype: :class:`~datajoint.connection.Connection` """ dj.config['database.host'] = jwt_payload['databaseAddress'] dj.config['database.user'] = jwt_payload['username'] dj.config['database.password'] = jwt_payload['password'] - dj.conn(reset=True) + return dj.conn(reset=True) diff --git a/pharus/server.py b/pharus/server.py index 5044584..6fb5ce4 100644 --- a/pharus/server.py +++ b/pharus/server.py @@ -1,9 +1,10 @@ """Exposed REST API.""" from os import environ -from .interface import DJConnector +from .interface import _DJConnector from . import __version__ as version from typing import Callable from functools import wraps +from typing import Union # Crypto libaries from cryptography.hazmat.primitives import serialization as crypto_serialization @@ -42,16 +43,16 @@ def protected_route(function: Callable) -> Callable: :param function: Function to decorate, typically routes :type function: :class:`~typing.Callable` - :return: Function output if jwt authetication is successful, otherwise return error + :return: Function output if JWT authetication is successful, otherwise return error message :rtype: :class:`~typing.Callable` """ @wraps(function) - def wrapper(): + def wrapper(**kwargs): try: jwt_payload = jwt.decode(request.headers.get('Authorization').split()[1], environ['PHARUS_PUBLIC_KEY'], algorithms='RS256') - return function(jwt_payload) + return function(jwt_payload, **kwargs) except Exception as e: return str(e), 401 @@ -59,7 +60,7 @@ def wrapper(): return wrapper -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/version") +@app.route(f"{environ.get('PHARUS_PREFIX', '')}/version", methods=['GET']) def api_version() -> str: """ Handler for ``/version`` route. @@ -84,13 +85,16 @@ def api_version() -> str: HTTP/1.1 200 OK Vary: Accept - Content-Type: text/plain + Content-Type: application/json - 0.1.0 + { + "version": "0.1.0" + } :statuscode 200: No error. """ - return version + if request.method in {'GET', 'HEAD'}: + return dict(version=version) @app.route(f"{environ.get('PHARUS_PREFIX', '')}/login", methods=['POST']) @@ -115,12 +119,12 @@ def login() -> dict: Handler for ``/login`` route. - :return: Function output is encoded jwt if successful, otherwise return error message + :return: Function output is an encoded JWT if successful, otherwise return error message :rtype: dict .. http:post:: /login - Route to get authentication token. + Route to generate an authentication token. **Example request**: @@ -164,35 +168,36 @@ def login() -> dict: :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. """ - # Check if request.json has the correct fields - if not request.json.keys() >= {'databaseAddress', 'username', 'password'}: - return dict(error='Invalid json body') - - # Try to login in with the database connection info, if true then create jwt key - try: - DJConnector.attempt_login(request.json['databaseAddress'], - request.json['username'], - request.json['password']) - # Generate JWT key and send it back - encoded_jwt = jwt.encode(request.json, environ['PHARUS_PRIVATE_KEY'], - algorithm='RS256') - return dict(jwt=encoded_jwt) - except Exception as e: - return str(e), 500 - - -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/list_schemas", methods=['GET']) + if request.method == 'POST': + # Check if request.json has the correct fields + if not request.json.keys() >= {'databaseAddress', 'username', 'password'}: + return dict(error='Invalid json body') + + # Try to login in with the database connection info, if true then create jwt key + try: + _DJConnector._attempt_login(request.json['databaseAddress'], + request.json['username'], + request.json['password']) + # Generate JWT key and send it back + encoded_jwt = jwt.encode(request.json, environ['PHARUS_PRIVATE_KEY'], + algorithm='RS256') + return dict(jwt=encoded_jwt) + except Exception as e: + return str(e), 500 + + +@app.route(f"{environ.get('PHARUS_PREFIX', '')}/schema", methods=['GET']) @protected_route -def list_schemas(jwt_payload: dict) -> dict: +def schema(jwt_payload: dict) -> dict: """ - Handler for ``/list_schemas`` route. + Handler for ``/schema`` route. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. :type jwt_payload: dict :return: If successful then sends back a list of schemas names otherwise returns error. :rtype: dict - .. http:get:: /list_schemas + .. http:get:: /schema Route to get list of schemas. @@ -200,8 +205,9 @@ def list_schemas(jwt_payload: dict) -> dict: .. sourcecode:: http - GET /list_schemas HTTP/1.1 + GET /schema HTTP/1.1 Host: fakeservices.datajoint.io + Authorization: Bearer **Example successful response**: @@ -217,7 +223,6 @@ def list_schemas(jwt_payload: dict) -> dict: ] } - **Example unexpected response**: .. sourcecode:: http @@ -234,26 +239,29 @@ def list_schemas(jwt_payload: dict) -> dict: :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. """ - # Get all the schemas - try: - schemas_name = DJConnector.list_schemas(jwt_payload) - return dict(schemaNames=schemas_name) - except Exception as e: - return str(e), 500 + if request.method in {'GET', 'HEAD'}: + # Get all the schemas + try: + schemas_name = _DJConnector._list_schemas(jwt_payload) + return dict(schemaNames=schemas_name) + except Exception as e: + return str(e), 500 -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/list_tables", methods=['POST']) +@app.route(f"{environ.get('PHARUS_PREFIX', '')}/schema//table", methods=['GET']) @protected_route -def list_tables(jwt_payload: dict) -> dict: +def table(jwt_payload: dict, schema_name: str) -> dict: """ - Handler for ``/list_tables`` route. + Handler for ``/schema/{schema_name}/table`` route. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. :type jwt_payload: dict - :return: If successful then sends back a list of tables names otherwise returns error. + :param schema_name: Schema name. + :type schema_name: str + :return: If successful then sends back a list of table names otherwise returns error. :rtype: dict - .. http:post:: /list_tables + .. http:get:: /schema/{schema_name}/table Route to get tables within a schema. @@ -261,13 +269,9 @@ def list_tables(jwt_payload: dict) -> dict: .. sourcecode:: http - POST /list_tables HTTP/1.1 + GET /schema/alpha_company/table HTTP/1.1 Host: fakeservices.datajoint.io - Accept: application/json - - { - "schemaName": "alpha_company" - } + Authorization: Bearer **Example successful response**: @@ -278,20 +282,19 @@ def list_tables(jwt_payload: dict) -> dict: Content-Type: application/json { - "tableTypeAndNames": { - "computed_tables": [], - "imported_tables": [], - "lookup_tables": [ + "tableTypes": { + "computed": [], + "imported": [], + "lookup": [ "Employee" ], - "manual_tables": [ + "manual": [ "Computer" ], - "part_tables": [] + "part": [] } } - **Example unexpected response**: .. sourcecode:: http @@ -303,31 +306,39 @@ def list_tables(jwt_payload: dict) -> dict: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. + :query schema_name: Schema name. :reqheader Authorization: Bearer :resheader Content-Type: text/plain, application/json :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. """ - try: - tables_dict_list = DJConnector.list_tables(jwt_payload, request.json["schemaName"]) - return dict(tableTypeAndNames=tables_dict_list) - except Exception as e: - return str(e), 500 + if request.method in {'GET', 'HEAD'}: + try: + tables_dict_list = _DJConnector._list_tables(jwt_payload, schema_name) + return dict(tableTypes=tables_dict_list) + except Exception as e: + return str(e), 500 -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/fetch_tuples", methods=['POST']) +@app.route( + f"{environ.get('PHARUS_PREFIX', '')}/schema//table//record", + methods=['GET', 'POST', 'PATCH', 'DELETE']) @protected_route -def fetch_tuples(jwt_payload: dict) -> dict: +def record(jwt_payload: dict, schema_name: str, table_name: str) -> Union[dict, str, tuple]: (""" - Handler for ``/fetch_tuple`` route. + Handler for ``/schema/{schema_name}/table/{table_name}/record`` route. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. :type jwt_payload: dict - :return: If successful then sends back dict with records and total count from query - otherwise returns error. - :rtype: dict + :param schema_name: Schema name. + :type schema_name: str + :param table_name: Table name. + :type table_name: str + :return: If successful performs desired operation based on HTTP method, otherwise returns + error. + :rtype: :class:`~typing.Union[dict, str, tuple]` - .. http:post:: /fetch_tuple + .. http:get:: /schema/{schema_name}/table/{table_name}/record Route to fetch records. @@ -335,17 +346,12 @@ def fetch_tuples(jwt_payload: dict) -> dict: .. sourcecode:: http - POST /fetch_tuples?limit=2&page=1&order=computer_id%20DESC&""" - "restriction=W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3BlcmF0aW9uIjogIj49Iiw" - "gInZhbHVlIjogMzJ9XQo=" + GET /schema/alpha_company/table/Computer/record?limit=1&page=2&""" + "order=computer_id%20DESC&restriction=W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLC" + "Aib3BlcmF0aW9uIjogIj49IiwgInZhbHVlIjogMTZ9XQo=" """ HTTP/1.1 Host: fakeservices.datajoint.io - Accept: application/json - - { - "schemaName": "alpha_company", - "tableName": "Computer" - } + Authorization: Bearer **Example successful response**: @@ -356,35 +362,37 @@ def fetch_tuples(jwt_payload: dict) -> dict: Content-Type: application/json { - "total_count": 4, - "tuples": [ + "recordHeader": [ + "computer_id", + "computer_serial", + "computer_brand", + "computer_built", + "computer_processor", + "computer_memory", + "computer_weight", + "computer_cost", + "computer_preowned", + "computer_purchased", + "computer_updates", + "computer_accessories" + ], + "records": [ [ - "eee3491a-86d5-4af7-a013-89bde75528bd", - "ABCDEFJHE", + "4e41491a-86d5-4af7-a013-89bde75528bd", + "DJS1JA17G", "Dell", - 1611705600, + 1590364800, 2.2, - 32, - 11.5, - "1100.93", - 5, - 1614265209, - 0 - ], - [ - "ddd1491a-86d5-4af7-a013-89bde75528bd", - "ABCDEFJHI", - "Dell", - 1614556800, - 2.8, - 64, - 13.5, - "1200.99", - 2, - 1614564122, - null + 16, + 4.4, + "700.99", + 0, + 1603181061, + null, + "=BLOB=" ] - ] + ], + "totalCount": 2 } **Example unexpected response**: @@ -398,59 +406,50 @@ def fetch_tuples(jwt_payload: dict) -> dict: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. + :query schema_name: Schema name. + :query table_name: Table name. :query limit: Limit of how many records per page. Defaults to ``1000``. :query page: Page requested. Defaults to ``1``. :query order: Sort order. Defaults to ``KEY ASC``. :query restriction: Base64-encoded ``AND`` sequence of restrictions. For example, you - could restrict as ``[{"attributeName": "computer_memory">=", "value": 32}]`` with - this param set as ``""" "W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3Bl" - """cmF0aW9uIjo``-``gIj49IiwgInZhbHVlIjogMzJ9XQo=``. Defaults to no restriction. + could restrict as ``[{"attributeName": "computer_memory", "operation": ">=",``- + ``"value": 16}]`` with this param set as + ``W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3Bl``- + ``cmF0aW9uIjogIj49IiwgInZhbHVlIjogMTZ9XQo=``. Defaults to no restriction. :reqheader Authorization: Bearer :resheader Content-Type: text/plain, application/json :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. - """) - try: - table_tuples, total_count = DJConnector.fetch_tuples( - jwt_payload=jwt_payload, - schema_name=request.json["schemaName"], - table_name=request.json["tableName"], - **{k: (int(v) if k in ('limit', 'page') - else (v.split(',') if k == 'order' else loads( - b64decode(v.encode('utf-8')).decode('utf-8')))) - for k, v in request.args.items()}, - ) - return dict(tuples=table_tuples, total_count=total_count) - except Exception as e: - return str(e), 500 - - -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/get_table_definition", methods=['POST']) -@protected_route -def get_table_definition(jwt_payload: dict) -> str: - """ - Handler for ``/get_table_definition`` route. - :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. - :type jwt_payload: dict - :return: If successful then sends back definition for table otherwise returns error. - :rtype: dict + .. http:post:: /schema/{schema_name}/table/{table_name}/record - .. http:post:: /get_table_definition - - Route to get DataJoint table definition. + Route to insert a record. Omitted attributes utilize the default if set. **Example request**: .. sourcecode:: http - POST /get_table_definition HTTP/1.1 + POST /schema/alpha_company/table/Computer/record HTTP/1.1 Host: fakeservices.datajoint.io Accept: application/json + Authorization: Bearer { - "schemaName": "alpha_company", - "tableName": "Computer" + "records": [ + { + "computer_id": "ffffffff-86d5-4af7-a013-89bde75528bd", + "computer_serial": "ZYXWVEISJ", + "computer_brand": "HP", + "computer_built": "2021-01-01", + "computer_processor": 2.7, + "computer_memory": 32, + "computer_weight": 3.7, + "computer_cost": 599.99, + "computer_preowned": 0, + "computer_purchased": "2021-02-01 13:00:00", + "computer_updates": 0 + } + ] } **Example successful response**: @@ -461,20 +460,7 @@ def get_table_definition(jwt_payload: dict) -> str: Vary: Accept Content-Type: text/plain - # Computers that belong to the company - computer_id : uuid # unique id - --- - computer_serial : varchar(9) # manufacturer serial number - computer_brand : enum('HP','Dell') # manufacturer brand - computer_built : date # manufactured date - computer_processor : double # processing power in GHz - computer_memory : int # RAM in GB - computer_weight : float # weight in lbs - computer_cost : decimal(6,2) # purchased price - computer_preowned : tinyint # purchased as new or used - computer_purchased : datetime # purchased date and time - computer_updates=null : time # scheduled daily update timeslot - + Insert Successful **Example unexpected response**: @@ -491,42 +477,36 @@ def get_table_definition(jwt_payload: dict) -> str: :resheader Content-Type: text/plain :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. - """ - try: - table_definition = DJConnector.get_table_definition(jwt_payload, - request.json["schemaName"], - request.json["tableName"]) - return table_definition - except Exception as e: - return str(e), 500 - - -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/get_table_attributes", methods=['POST']) -@protected_route -def get_table_attributes(jwt_payload: dict) -> dict: - """ - Handler for ``/get_table_attributes`` route. - :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. - :type jwt_payload: dict - :return: If successful then sends back dict of table attributes otherwise returns error. - :rtype: dict - - .. http:post:: /get_table_attributes + .. http:patch:: /schema/{schema_name}/table/{table_name}/record - Route to get metadata on table attributes. + Route to update a record. Omitted attributes utilize the default if set. **Example request**: .. sourcecode:: http - POST /get_table_attributes HTTP/1.1 + PATCH /schema/alpha_company/table/Computer/record HTTP/1.1 Host: fakeservices.datajoint.io Accept: application/json + Authorization: Bearer { - "schemaName": "alpha_company", - "tableName": "Computer" + "records": [ + { + "computer_id": "ffffffff-86d5-4af7-a013-89bde75528bd", + "computer_serial": "ZYXWVEISJ", + "computer_brand": "HP", + "computer_built": "2021-01-01", + "computer_processor": 2.7, + "computer_memory": 32, + "computer_weight": 3.7, + "computer_cost": 601.01, + "computer_preowned": 0, + "computer_purchased": "2021-02-01 13:00:00", + "computer_updates": 0 + } + ] } **Example successful response**: @@ -535,91 +515,9 @@ def get_table_attributes(jwt_payload: dict) -> dict: HTTP/1.1 200 OK Vary: Accept - Content-Type: application/json + Content-Type: text/plain - { - "primary_attributes": [ - [ - "computer_id", - "uuid", - false, - null, - false - ] - ], - "secondary_attributes": [ - [ - "computer_serial", - "varchar(9)", - false, - null, - false - ], - [ - "computer_brand", - "enum('HP','Dell')", - false, - null, - false - ], - [ - "computer_built", - "date", - false, - null, - false - ], - [ - "computer_processor", - "double", - false, - null, - false - ], - [ - "computer_memory", - "int", - false, - null, - false - ], - [ - "computer_weight", - "float", - false, - null, - false - ], - [ - "computer_cost", - "decimal(6,2)", - false, - null, - false - ], - [ - "computer_preowned", - "tinyint", - false, - null, - false - ], - [ - "computer_purchased", - "datetime", - false, - null, - false - ], - [ - "computer_updates", - "time", - true, - "null", - false - ] - ] - } + Update Successful **Example unexpected response**: @@ -633,59 +531,24 @@ def get_table_attributes(jwt_payload: dict) -> dict: understand. :reqheader Authorization: Bearer - :resheader Content-Type: text/plain, application/json + :resheader Content-Type: text/plain :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. - """ - try: - return DJConnector.get_table_attributes(jwt_payload, - request.json["schemaName"], - request.json["tableName"]) - except Exception as e: - return str(e), 500 - -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/insert_tuple", methods=['POST']) -@protected_route -def insert_tuple(jwt_payload: dict) -> str: - """ - Handler for ``/insert_tuple`` route. - - :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. - :type jwt_payload: dict - :return: If successful then returns ``Insert Successful`` otherwise returns error. - :rtype: dict + .. http:delete:: /schema/{schema_name}/table/{table_name}/record - .. http:post:: /insert_tuple - - Route to insert a record. + Route to delete a specific record. **Example request**: .. sourcecode:: http - POST /insert_tuple HTTP/1.1 + DELETE /schema/alpha_company/table/Computer/record?cascade=false&""" + "restriction=W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3BlcmF0aW9uIjogIj49Iiw" + "gInZhbHVlIjogMTZ9XQo=" + """ HTTP/1.1 Host: fakeservices.datajoint.io - Accept: application/json - - { - "schemaName": "alpha_company", - "tableName": "Computer", - "tuple": { - "computer_id": "ffffffff-86d5-4af7-a013-89bde75528bd", - "computer_serial": "ZYXWVEISJ", - "computer_brand": "HP", - "computer_built": "2021-01-01", - "computer_processor": 2.7, - "computer_memory": 32, - "computer_weight": 3.7, - "computer_cost": 599.99, - "computer_preowned": 0, - "computer_purchased": "2021-02-01 13:00:00", - "computer_updates": 0 - } - } - + Authorization: Bearer **Example successful response**: @@ -695,7 +558,25 @@ def insert_tuple(jwt_payload: dict) -> str: Vary: Accept Content-Type: text/plain - Insert Successful + Delete Successful + + **Example conflict response**: + + .. sourcecode:: http + + HTTP/1.1 409 Conflict + Vary: Accept + Content-Type: application/json + + { + "error": "IntegrityError", + "error_msg": "Cannot delete or update a parent row: a foreign key constraint + fails (`alpha_company`.`#employee`, CONSTRAINT `#employee_ibfk_1` FOREIGN + KEY (`computer_id`) REFERENCES `computer` (`computer_id`) ON DELETE + RESTRICT ON UPDATE CASCADE", + "child_schema": "alpha_company", + "child_table": "Employee" + } **Example unexpected response**: @@ -708,47 +589,108 @@ def insert_tuple(jwt_payload: dict) -> str: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. + :query cascade: Enable cascading delete. Accepts ``true`` or ``false``. + Defaults to ``false``. + :query restriction: Base64-encoded ``AND`` sequence of restrictions. For example, you + could restrict as ``[{"attributeName": "computer_memory", "operation": ">=",``- + ``"value": 16}]`` with this param set as + ``W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3Bl``- + ``cmF0aW9uIjogIj49IiwgInZhbHVlIjogMTZ9XQo=``. Defaults to no restriction. :reqheader Authorization: Bearer - :resheader Content-Type: text/plain + :resheader Content-Type: text/plain, application/json :statuscode 200: No error. + :statuscode 409: Attempting to delete a record with dependents while ``cascade`` set + to ``false``. :statuscode 500: Unexpected error encountered. Returns the error message as a string. - """ - try: - # Attempt to insert - DJConnector.insert_tuple(jwt_payload, - request.json["schemaName"], - request.json["tableName"], - request.json["tuple"]) - return "Insert Successful" - except Exception as e: - return str(e), 500 - - -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/record/dependency", methods=['GET']) + """) + if request.method in {'GET', 'HEAD'}: + try: + record_header, table_tuples, total_count = _DJConnector._fetch_records( + jwt_payload=jwt_payload, + schema_name=schema_name, + table_name=table_name, + **{k: (int(v) if k in ('limit', 'page') + else (v.split(',') if k == 'order' else loads( + b64decode(v.encode('utf-8')).decode('utf-8')))) + for k, v in request.args.items()}, + ) + return dict(recordHeader=record_header, records=table_tuples, + totalCount=total_count) + except Exception as e: + return str(e), 500 + elif request.method == 'POST': + try: + # Attempt to insert + _DJConnector._insert_tuple(jwt_payload, + schema_name, + table_name, + request.json["records"]) + return "Insert Successful" + except Exception as e: + return str(e), 500 + elif request.method == 'PATCH': + try: + # Attempt to insert + _DJConnector._update_tuple(jwt_payload, + schema_name, + table_name, + request.json["records"]) + return "Update Successful" + except Exception as e: + return str(e), 500 + elif request.method == 'DELETE': + try: + # Attempt to delete tuple + _DJConnector._delete_records(jwt_payload, + schema_name, + table_name, + **{k: loads(b64decode( + v.encode('utf-8')).decode('utf-8')) + for k, v in request.args.items() + if k == 'restriction'}, + **{k: v.lower() == 'true' + for k, v in request.args.items() + if k == 'cascade'}) + return "Delete Sucessful" + except IntegrityError as e: + match = foreign_key_error_regexp.match(e.args[0]) + return dict(error=e.__class__.__name__, + errorMessage=str(e), + childSchema=match.group('child').split('.')[0][1:-1], + childTable=to_camel_case(match.group('child').split('.')[1][1:-1]), + ), 409 + except Exception as e: + return str(e), 500 + + +@app.route( + f"{environ.get('PHARUS_PREFIX', '')}/schema//table//definition", + methods=['GET']) @protected_route -def record_dependency(jwt_payload: dict) -> dict: - (""" - Handler for ``/record/dependency`` route. +def definition(jwt_payload: dict, schema_name: str, table_name: str) -> str: + """ + Handler for ``/schema/{schema_name}/table/{table_name}/definition`` route. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. :type jwt_payload: dict - :return: If sucessfuly sends back a list of dependencies otherwise returns error. - :rtype: dict + :param schema_name: Schema name. + :type schema_name: str + :param table_name: Table name. + :type table_name: str + :return: If successful then sends back definition for table otherwise returns error. + :rtype: str - .. http:get:: /record/dependency + .. http:get:: /schema/{schema_name}/table/{table_name}/definition - Route to get the metadata in relation to the dependent records associated with a """ - """restricted subset of a table. + Route to get DataJoint table definition. **Example request**: .. sourcecode:: http - GET /fetch_tuples?schemaName=alpha_company&tableName=Computer&""" - "restriction=W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3BlcmF0aW9uIjogIj49Iiw" - "gInZhbHVlIjogMzJ9XQo=" - """ HTTP/1.1 + GET /schema/alpha_company/table/Computer/definition HTTP/1.1 Host: fakeservices.datajoint.io + Authorization: Bearer **Example successful response**: @@ -756,24 +698,22 @@ def record_dependency(jwt_payload: dict) -> dict: HTTP/1.1 200 OK Vary: Accept - Content-Type: application/json + Content-Type: text/plain - { - "dependencies": [ - { - "accessible": true, - "count": 7, - "schema": "alpha_company", - "table": "computer" - }, - { - "accessible": true, - "count": 2, - "schema": "alpha_company", - "table": "#employee" - } - ] - } + # Computers that belong to the company + computer_id : uuid # unique id + --- + computer_serial="ABC101" : varchar(9) # manufacturer serial number + computer_brand : enum('HP','Dell') # manufacturer brand + computer_built : date # manufactured date + computer_processor : double # processing power in GHz + computer_memory : int # RAM in GB + computer_weight : float # weight in lbs + computer_cost : decimal(6,2) # purchased price + computer_preowned : tinyint # purchased as new or used + computer_purchased : datetime # purchased date and time + computer_updates=null : time # scheduled daily update timeslot + computer_accessories=null : longblob # included additional accessories **Example unexpected response**: @@ -786,68 +726,48 @@ def record_dependency(jwt_payload: dict) -> dict: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. - :query schemaName: Schema name. - :query tableName: Table name. - :query restriction: Base64-encoded ``AND`` sequence of restrictions. For example, you - could restrict as ``[{"attributeName": "computer_memory">=", "value": 32}]`` with - this param set as ``""" "W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3Bl" - """cmF0aW9uIjo``-``gIj49IiwgInZhbHVlIjogMzJ9XQo=``. :reqheader Authorization: Bearer - :resheader Content-Type: text/plain, application/json + :resheader Content-Type: text/plain :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. - """) - # Get dependencies - try: - dependencies = DJConnector.record_dependency( - jwt_payload, request.args.get('schemaName'), request.args.get('tableName'), - loads(b64decode(request.args.get('restriction').encode('utf-8')).decode('utf-8'))) - return dict(dependencies=dependencies) - except Exception as e: - return str(e), 500 + """ + if request.method in {'GET', 'HEAD'}: + try: + table_definition = _DJConnector._get_table_definition(jwt_payload, schema_name, + table_name) + return table_definition + except Exception as e: + return str(e), 500 -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/update_tuple", methods=['POST']) +@app.route( + f"{environ.get('PHARUS_PREFIX', '')}/schema//table//attribute", + methods=['GET']) @protected_route -def update_tuple(jwt_payload: dict) -> str: +def attribute(jwt_payload: dict, schema_name: str, table_name: str) -> dict: """ - Handler for ``/update_tuple`` route. + Handler for ``/schema/{schema_name}/table/{table_name}/attribute`` route. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. :type jwt_payload: dict - :return: If successful then returns ``Update Successful`` otherwise returns error. + :param schema_name: Schema name. + :type schema_name: str + :param table_name: Table name. + :type table_name: str + :return: If successful then sends back dict of table attributes otherwise returns error. :rtype: dict - .. http:post:: /update_tuple + .. http:GET:: /schema/{schema_name}/table/{table_name}/attribute - Route to update a record. + Route to get metadata on table attributes. **Example request**: .. sourcecode:: http - POST /update_tuple HTTP/1.1 + GET /schema/alpha_company/table/Computer/attribute HTTP/1.1 Host: fakeservices.datajoint.io - Accept: application/json - - { - "schemaName": "alpha_company", - "tableName": "Computer", - "tuple": { - "computer_id": "ffffffff-86d5-4af7-a013-89bde75528bd", - "computer_serial": "ZYXWVEISJ", - "computer_brand": "HP", - "computer_built": "2021-01-01", - "computer_processor": 2.7, - "computer_memory": 32, - "computer_weight": 3.7, - "computer_cost": 399.99, - "computer_preowned": 0, - "computer_purchased": "2021-02-01 13:00:00", - "computer_updates": 0 - } - } - + Authorization: Bearer **Example successful response**: @@ -855,9 +775,107 @@ def update_tuple(jwt_payload: dict) -> str: HTTP/1.1 200 OK Vary: Accept - Content-Type: text/plain + Content-Type: application/json - Update Successful + { + "attributeHeader": [ + "name", + "type", + "nullable", + "default", + "autoincrement" + ], + "attributes": { + "primary": [ + [ + "computer_id", + "uuid", + false, + null, + false + ] + ], + "secondary": [ + [ + "computer_serial", + "varchar(9)", + false, + "\"ABC101\"", + false + ], + [ + "computer_brand", + "enum('HP','Dell')", + false, + null, + false + ], + [ + "computer_built", + "date", + false, + null, + false + ], + [ + "computer_processor", + "double", + false, + null, + false + ], + [ + "computer_memory", + "int", + false, + null, + false + ], + [ + "computer_weight", + "float", + false, + null, + false + ], + [ + "computer_cost", + "decimal(6,2)", + false, + null, + false + ], + [ + "computer_preowned", + "tinyint", + false, + null, + false + ], + [ + "computer_purchased", + "datetime", + false, + null, + false + ], + [ + "computer_updates", + "time", + true, + "null", + false + ], + [ + "computer_accessories", + "longblob", + true, + "null", + false + ] + ] + } + } **Example unexpected response**: @@ -871,51 +889,51 @@ def update_tuple(jwt_payload: dict) -> str: understand. :reqheader Authorization: Bearer - :resheader Content-Type: text/plain + :resheader Content-Type: text/plain, application/json :statuscode 200: No error. :statuscode 500: Unexpected error encountered. Returns the error message as a string. """ - try: - # Attempt to insert - DJConnector.update_tuple(jwt_payload, - request.json["schemaName"], - request.json["tableName"], - request.json["tuple"]) - return "Update Successful" - except Exception as e: - return str(e), 500 - - -@app.route(f"{environ.get('PHARUS_PREFIX', '')}/delete_tuple", methods=['POST']) + if request.method in {'GET', 'HEAD'}: + try: + attributes_meta = _DJConnector._get_table_attributes(jwt_payload, schema_name, + table_name) + return dict(attributeHeaders=attributes_meta['attribute_headers'], + attributes=attributes_meta['attributes']) + except Exception as e: + return str(e), 500 + + +@app.route( + f"{environ.get('PHARUS_PREFIX', '')}/schema//table//dependency", + methods=['GET']) @protected_route -def delete_tuple(jwt_payload: dict) -> dict: - """ - Handler for ``/delete_tuple`` route. +def dependency(jwt_payload: dict, schema_name: str, table_name: str) -> dict: + (""" + Handler for ``/schema/{schema_name}/table/{table_name}/dependency`` route. :param jwt_payload: Dictionary containing databaseAddress, username, and password strings. :type jwt_payload: dict - :return: If successful returns ``Delete Successful`` otherwise returns error. + :param schema_name: Schema name. + :type schema_name: str + :param table_name: Table name. + :type table_name: str + :return: If sucessfuly sends back a list of dependencies otherwise returns error. :rtype: dict - .. http:post:: /delete_tuple + .. http:get:: /schema/{schema_name}/table/{table_name}/dependency - Route to delete a specific record. + Route to get the metadata in relation to the dependent records associated with a """ + """restricted subset of a table. **Example request**: .. sourcecode:: http - POST /delete_tuple HTTP/1.1 + GET /schema/alpha_company/table/Computer/dependency?restriction=W3siYXR0cmlidXR""" + "lTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3BlcmF0aW9uIjogIj49IiwgInZhbHVlIjogMTZ9XQo=" + """ HTTP/1.1 Host: fakeservices.datajoint.io - Accept: application/json - - { - "schemaName": "alpha_company", - "tableName": "Computer", - "restrictionTuple": { - "computer_id": "4e41491a-86d5-4af7-a013-89bde75528bd" - } - } + Authorization: Bearer **Example successful response**: @@ -923,26 +941,23 @@ def delete_tuple(jwt_payload: dict) -> dict: HTTP/1.1 200 OK Vary: Accept - Content-Type: text/plain - - Delete Successful - - **Example conflict response**: - - .. sourcecode:: http - - HTTP/1.1 409 Conflict - Vary: Accept Content-Type: application/json { - "error": "IntegrityError", - "error_msg": "Cannot delete or update a parent row: a foreign key constraint - fails (`alpha_company`.`#employee`, CONSTRAINT `#employee_ibfk_1` FOREIGN - KEY (`computer_id`) REFERENCES `computer` (`computer_id`) ON DELETE - RESTRICT ON UPDATE CASCADE", - "child_schema": "alpha_company", - "child_table": "Employee" + "dependencies": [ + { + "accessible": true, + "count": 2, + "schema": "alpha_company", + "table": "computer" + }, + { + "accessible": true, + "count": 2, + "schema": "alpha_company", + "table": "#employee" + } + ] } **Example unexpected response**: @@ -956,33 +971,28 @@ def delete_tuple(jwt_payload: dict) -> dict: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand. - :query cascade: Enable cascading delete. Accepts ``true`` or ``false``. - Defaults to ``false``. + :query schema_name: Schema name. + :query table_name: Table name. + :query restriction: Base64-encoded ``AND`` sequence of restrictions. For example, you + could restrict as ``[{"attributeName": "computer_memory", "operation": ">=",``- + ``"value": 16}]`` with this param set as + ``W3siYXR0cmlidXRlTmFtZSI6ICJjb21wdXRlcl9tZW1vcnkiLCAib3Bl``- + ``cmF0aW9uIjogIj49IiwgInZhbHVlIjogMTZ9XQo=``. Defaults to no restriction. :reqheader Authorization: Bearer :resheader Content-Type: text/plain, application/json :statuscode 200: No error. - :statuscode 409: Attempting to delete a record with dependents while ``cascade`` set - to ``false``. :statuscode 500: Unexpected error encountered. Returns the error message as a string. - """ - try: - # Attempt to delete tuple - DJConnector.delete_tuple(jwt_payload, - request.json["schemaName"], - request.json["tableName"], - request.json["restrictionTuple"], - **{k: v.lower() == 'true' - for k, v in request.args.items() if k == 'cascade'},) - return "Delete Sucessful" - except IntegrityError as e: - match = foreign_key_error_regexp.match(e.args[0]) - return dict(error=e.__class__.__name__, - error_msg=str(e), - child_schema=match.group('child').split('.')[0][1:-1], - child_table=to_camel_case(match.group('child').split('.')[1][1:-1]), - ), 409 - except Exception as e: - return str(e), 500 + """) + if request.method in {'GET', 'HEAD'}: + # Get dependencies + try: + dependencies = _DJConnector._record_dependency( + jwt_payload, schema_name, table_name, + loads(b64decode( + request.args.get('restriction').encode('utf-8')).decode('utf-8'))) + return dict(dependencies=dependencies) + except Exception as e: + return str(e), 500 def run(): diff --git a/pharus/version.py b/pharus/version.py index a89786e..cb2eb23 100644 --- a/pharus/version.py +++ b/pharus/version.py @@ -1,2 +1,2 @@ """Package metadata.""" -__version__ = '0.1.0b2' +__version__ = '0.1.0' diff --git a/tests/init/init.sql b/tests/init/init.sql new file mode 100644 index 0000000..11f7646 --- /dev/null +++ b/tests/init/init.sql @@ -0,0 +1,46 @@ +CREATE DATABASE `alpha_company`; + +CREATE TABLE `alpha_company`.`~log` ( + `id` int(10) unsigned NOT NULL AUTO_INCREMENT COMMENT 'event order id', + `timestamp` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT 'event timestamp', + `version` varchar(12) NOT NULL COMMENT 'datajoint version', + `user` varchar(255) NOT NULL COMMENT 'user@host', + `host` varchar(255) NOT NULL DEFAULT '' COMMENT 'system hostname', + `event` varchar(255) NOT NULL DEFAULT '' COMMENT 'event message', + PRIMARY KEY (`id`) +) ENGINE=InnoDB DEFAULT CHARSET=latin1 COMMENT='event logging table for `alpha_company`'; + +CREATE TABLE `alpha_company`.`computer` ( + `computer_id` binary(16) NOT NULL COMMENT ':uuid:unique id', + `computer_serial` varchar(9) NOT NULL DEFAULT 'ABC101' COMMENT 'manufacturer serial number', + `computer_brand` enum('HP','Dell') NOT NULL COMMENT 'manufacturer brand', + `computer_built` date NOT NULL COMMENT 'manufactured date', + `computer_processor` double NOT NULL COMMENT 'processing power in GHz', + `computer_memory` int NOT NULL COMMENT 'RAM in GB', + `computer_weight` float NOT NULL COMMENT 'weight in lbs', + `computer_cost` decimal(6,2) NOT NULL COMMENT 'purchased price', + `computer_preowned` tinyint(1) NOT NULL COMMENT 'purchased as new or used', + `computer_purchased` datetime NOT NULL COMMENT 'purchased date and time', + `computer_updates` time DEFAULT NULL COMMENT 'scheduled daily update timeslot', + `computer_accessories` longblob COMMENT 'included additional accessories', + PRIMARY KEY (`computer_id`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Computers that belong to the company'; + +INSERT INTO `alpha_company`.`computer` (computer_id, computer_serial, computer_brand, computer_built, computer_processor, computer_memory, computer_weight, computer_cost, computer_preowned, computer_purchased, computer_updates, computer_accessories) +VALUES +(X'4E41491A86D54AF7A01389BDE75528BD', 'DJS1JA17G', 'Dell', '2020-05-25', 2.2, 16, 4.4, 700.99, 0, '2020-10-20 08:04:21', NULL, NULL) +,(X'DCD4CFD96791433CA805F391C289E6EA', 'HUA20K9LL', 'HP', '2020-07-12', 2.8, 32, 5.7, 693.54, 1, '2020-11-05 13:58:02', '23:30:05', X'646A30000403000000000000001400000000000000050B00000000000000706F7765725F6361626C6504000000000000000A0100010E000000000000000505000000000000006D6F75736504000000000000000A01000111000000000000000508000000000000006B6579626F61726404000000000000000A010001'); + +CREATE TABLE `alpha_company`.`#employee` ( + `computer_id` binary(16) NOT NULL COMMENT ':uuid:unique id', + `employee_name` varchar(30) NOT NULL COMMENT 'employee name', + PRIMARY KEY (`computer_id`), + CONSTRAINT `#employee_ibfk_1` FOREIGN KEY (`computer_id`) REFERENCES `alpha_company`.`computer` (`computer_id`) ON DELETE RESTRICT ON UPDATE CASCADE +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='Employees that are assigned a computer'; + +INSERT INTO `alpha_company`.`#employee` (computer_id, employee_name) +VALUES +(X'4E41491A86D54AF7A01389BDE75528BD', 'Raphael Guzman') +,(X'DCD4CFD96791433CA805F391C289E6EA', 'John Doe'); + +CREATE DATABASE `empty`; diff --git a/tests/test_attributes.py b/tests/test_attributes.py index b827914..83b892d 100644 --- a/tests/test_attributes.py +++ b/tests/test_attributes.py @@ -7,12 +7,24 @@ def validate(table, inserted_value, expected_type, expected_value, client, token): - table.insert([(1, inserted_value)]) - _, REST_value = client.post('/fetch_tuples', + REST_records = client.get(f'/schema/{table.database}/table/{table.__name__}/record', + headers=dict(Authorization=f'Bearer {token}') + ).json['records'] + assert len(REST_records) == 0 + REST_response = client.post(f'/schema/{table.database}/table/{table.__name__}/record', headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=table.database, - tableName=table.__name__)).json['tuples'][0] - assert isinstance(REST_value, expected_type) and REST_value == expected_value + json=dict(records=[{ + 'id': 1, + f'{table.__name__.lower()}_attribute': ( + inserted_value if isinstance(inserted_value, bool) + else str(inserted_value))}])) + assert REST_response.status_code == 200 + REST_records = client.get(f'/schema/{table.database}/table/{table.__name__}/record', + headers=dict(Authorization=f'Bearer {token}') + ).json['records'] + assert len(REST_records) == 1 + assert isinstance(REST_records[0][1], expected_type) and \ + REST_records[0][1] == expected_value def test_int(token, client, Int): @@ -153,19 +165,17 @@ def test_part_table(token, client, ParentPart): ProcessScanData.populate() # Test Parent - REST_value = client.post('/fetch_tuples', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=ScanData.database, - tableName=ProcessScanData.__name__)).json['tuples'][0] + REST_value = client.get( + f'/schema/{ScanData.database}/table/{ProcessScanData.__name__}/record', + headers=dict(Authorization=f'Bearer {token}')).json['records'][0] assert REST_value == [0, 5] # Test Child - REST_value = client.post( - '/fetch_tuples', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=ProcessScanData.database, - tableName=(ProcessScanData.__name__ + '.' + - ProcessScanData.ProcessScanDataPart.__name__))).json['tuples'][0] + REST_value = client.get( + f"""/schema/{ProcessScanData.database}/table/{ + ProcessScanData.__name__ + '.' + + ProcessScanData.ProcessScanDataPart.__name__}/record""", + headers=dict(Authorization=f'Bearer {token}')).json['records'][0] assert REST_value == [0, 10] diff --git a/tests/test_delete.py b/tests/test_delete.py index 05a50f9..8ba76d2 100644 --- a/tests/test_delete.py +++ b/tests/test_delete.py @@ -1,18 +1,22 @@ from . import SCHEMA_PREFIX, token, client, connection, schemas_simple import datajoint as dj +from json import dumps +from base64 import b64encode +from urllib.parse import urlencode def test_delete_dependent_with_cascade(token, client, connection, schemas_simple): schema_name = f'{SCHEMA_PREFIX}group1_simple' table_name = 'TableB' restriction = dict(a_id=0, b_id=11) + filters = [dict(attributeName=k, operation='=', value=v) + for k, v in restriction.items()] + encoded_filters = b64encode(dumps(filters).encode('utf-8')).decode('utf-8') + q = dict(cascade='tRuE', restriction=encoded_filters) vm = dj.VirtualModule('group1_simple', schema_name) - REST_response = client.post( - '/delete_tuple?cascade=tRuE', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=schema_name, - tableName=table_name, - restrictionTuple=restriction)) + REST_response = client.delete( + f'/schema/{schema_name}/table/{table_name}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')) assert REST_response.status_code == 200 assert len(getattr(vm, table_name) & restriction) == 0 assert len(getattr(vm, 'TableC') & restriction) == 0 @@ -22,16 +26,17 @@ def test_delete_dependent_without_cascade(token, client, connection, schemas_sim schema_name = f'{SCHEMA_PREFIX}group1_simple' table_name = 'TableB' restriction = dict(a_id=0, b_id=11) + filters = [dict(attributeName=k, operation='=', value=v) + for k, v in restriction.items()] + encoded_filters = b64encode(dumps(filters).encode('utf-8')).decode('utf-8') + q = dict(restriction=encoded_filters) vm = dj.VirtualModule('group1_simple', schema_name) - REST_response = client.post( - '/delete_tuple', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=schema_name, - tableName=table_name, - restrictionTuple=restriction)) + REST_response = client.delete( + f'/schema/{schema_name}/table/{table_name}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')) assert REST_response.status_code == 409 - assert REST_response.json['child_schema'] == f'{SCHEMA_PREFIX}group1_simple' - assert REST_response.json['child_table'] == 'TableC' + assert REST_response.json['childSchema'] == f'{SCHEMA_PREFIX}group1_simple' + assert REST_response.json['childTable'] == 'TableC' assert len(getattr(vm, table_name) & restriction) == 1 assert len(getattr(vm, 'TableC') & restriction) == 2 @@ -40,13 +45,14 @@ def test_delete_independent_without_cascade(token, client, connection, schemas_s schema_name = f'{SCHEMA_PREFIX}group1_simple' table_name = 'TableB' restriction = dict(a_id=1, b_id=21) + filters = [dict(attributeName=k, operation='=', value=v) + for k, v in restriction.items()] + encoded_filters = b64encode(dumps(filters).encode('utf-8')).decode('utf-8') + q = dict(cascade='fAlSe', restriction=encoded_filters) vm = dj.VirtualModule('group1_simple', schema_name) - REST_response = client.post( - '/delete_tuple?cascade=fAlSe', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=schema_name, - tableName=table_name, - restrictionTuple=restriction)) + REST_response = client.delete( + f'/schema/{schema_name}/table/{table_name}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')) assert REST_response.status_code == 200 assert len(getattr(vm, table_name) & restriction) == 0 @@ -54,14 +60,15 @@ def test_delete_independent_without_cascade(token, client, connection, schemas_s def test_delete_invalid(token, client, connection, schemas_simple): schema_name = f'{SCHEMA_PREFIX}group1_simple' table_name = 'TableB' - restriction = dict() + restriction = dict(a_id=999) + filters = [dict(attributeName=k, operation='=', value=v) + for k, v in restriction.items()] + encoded_filters = b64encode(dumps(filters).encode('utf-8')).decode('utf-8') + q = dict(cascade='TRUE', restriction=encoded_filters) vm = dj.VirtualModule('group1_simple', schema_name) - REST_response = client.post( - '/delete_tuple?cascade=TRUE', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=schema_name, - tableName=table_name, - restrictionTuple=restriction)) + REST_response = client.delete( + f'/schema/{schema_name}/table/{table_name}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')) assert REST_response.status_code == 500 - assert b'Restriction is invalid' in REST_response.data + assert b'Nothing to delete' in REST_response.data assert len(getattr(vm, table_name)()) == 3 diff --git a/tests/test_dependencies.py b/tests/test_dependencies.py index 694a0d2..ec93330 100644 --- a/tests/test_dependencies.py +++ b/tests/test_dependencies.py @@ -1,4 +1,5 @@ from base64 import b64encode +from urllib.parse import urlencode from json import dumps from . import SCHEMA_PREFIX, client, token, group1_token, connection, schemas_simple @@ -6,15 +7,17 @@ def test_dependencies_admin(token, client, schemas_simple): schema_name = f'{SCHEMA_PREFIX}group1_simple' table_name = 'TableA' - restriction = b64encode(dumps(dict(a_id=0)).encode('utf-8')).decode('utf-8') + restriction = dict(a_id=0) + restriction = [dict(attributeName=k, operation='=', value=v) + for k, v in restriction.items()] + restriction = b64encode(dumps(restriction).encode('utf-8')).decode('utf-8') + q = dict(restriction=restriction) REST_dependencies = client.get( - f"""/record/dependency?schemaName={ - schema_name}&tableName={table_name}&restriction={restriction}""", + f'/schema/{schema_name}/table/{table_name}/dependency?{urlencode(q)}', headers=dict(Authorization=f'Bearer {token}')).json['dependencies'] - REST_records = client.post('/fetch_tuples', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=schema_name, - tableName=table_name)).json['tuples'] + REST_records = client.get( + f'/schema/{schema_name}/table/{table_name}/record', + headers=dict(Authorization=f'Bearer {token}')).json['records'] assert len(REST_records) == 2 assert len(REST_dependencies) == 4 table_a = [el for el in REST_dependencies diff --git a/tests/test_filter.py b/tests/test_filter.py index 5692086..3279c08 100644 --- a/tests/test_filter.py +++ b/tests/test_filter.py @@ -14,10 +14,9 @@ def test_filters(token, client, Student): encoded_restriction = b64encode(dumps(restriction).encode('utf-8')).decode('utf-8') q = dict(limit=10, page=1, order='student_enroll_date DESC', restriction=encoded_restriction) - REST_records = client.post(f'/fetch_tuples?{urlencode(q)}', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=Student.database, - tableName='Student')).json['tuples'] + REST_records = client.get( + f'/schema/{Student.database}/table/{"Student"}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')).json['records'] assert len(REST_records) == 10 assert REST_records[0][3] == datetime(2021, 1, 16).timestamp() # 'equal' null @@ -25,10 +24,9 @@ def test_filters(token, client, Student): encoded_restriction = b64encode(dumps(restriction).encode('utf-8')).decode('utf-8') q = dict(limit=10, page=2, order='student_id ASC', restriction=encoded_restriction) - REST_records = client.post(f'/fetch_tuples?{urlencode(q)}', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=Student.database, - tableName='Student')).json['tuples'] + REST_records = client.get( + f'/schema/{Student.database}/table/{"Student"}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')).json['records'] assert len(REST_records) == 10 assert all([r[5] is None for r in REST_records]) assert REST_records[0][0] == 34 @@ -37,10 +35,9 @@ def test_filters(token, client, Student): encoded_restriction = b64encode(dumps(restriction).encode('utf-8')).decode('utf-8') q = dict(limit=10, page=1, order='student_id ASC', restriction=encoded_restriction) - REST_records = client.post(f'/fetch_tuples?{urlencode(q)}', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=Student.database, - tableName='Student')).json['tuples'] + REST_records = client.get( + f'/schema/{Student.database}/table/{"Student"}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')).json['records'] assert len(REST_records) == 10 assert all([r[0] != 2 for r in REST_records]) assert REST_records[-1][0] == 10 @@ -50,10 +47,9 @@ def test_filters(token, client, Student): encoded_restriction = b64encode(dumps(restriction).encode('utf-8')).decode('utf-8') q = dict(limit=10, page=1, order='student_id ASC', restriction=encoded_restriction) - REST_records = client.post(f'/fetch_tuples?{urlencode(q)}', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=Student.database, - tableName='Student')).json['tuples'] + REST_records = client.get( + f'/schema/{Student.database}/table/{"Student"}/record?{urlencode(q)}', + headers=dict(Authorization=f'Bearer {token}')).json['records'] assert len(REST_records) == 1 assert REST_records[0][1] == 'Norma Fisher' assert REST_records[0][6] == 0 diff --git a/tests/test_general.py b/tests/test_general.py index c3f16ab..c569a18 100644 --- a/tests/test_general.py +++ b/tests/test_general.py @@ -3,4 +3,4 @@ def test_version(client): - assert client.get('/version').data.decode() == version + assert client.get('/version').json['version'] == version diff --git a/tests/test_list_tables.py b/tests/test_list_tables.py index e3d6efb..d60f837 100644 --- a/tests/test_list_tables.py +++ b/tests/test_list_tables.py @@ -2,24 +2,23 @@ from flask.wrappers import Response import datajoint as dj + def test_list_tables(token, client, ParentPart): ScanData, ProcessScanData = ParentPart - REST_tables = client.post( - '/list_tables', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=ScanData.database)).json['tableTypeAndNames'] - assert ScanData.__name__ == REST_tables['manual_tables'][0] - assert ProcessScanData.__name__ == REST_tables['computed_tables'][0] + REST_tables = client.get( + f'/schema/{ScanData.database}/table', + headers=dict(Authorization=f'Bearer {token}')).json['tableTypes'] + assert ScanData.__name__ == REST_tables['manual'][0] + assert ProcessScanData.__name__ == REST_tables['computed'][0] assert f"""{ProcessScanData.__name__}.{ - ProcessScanData.ProcessScanDataPart.__name__}""" == REST_tables['part_tables'][0] + ProcessScanData.ProcessScanDataPart.__name__}""" == REST_tables['part'][0] + def test_invalid_schema_list_table(token, client, schema_main): # Test invalid schema - response: Response = client.post( - '/list_tables', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName='invalid_schema') - ) + response: Response = client.get( + f'/schema/{"invalid_schema"}/table', + headers=dict(Authorization=f'Bearer {token}')) assert(response.status_code != 200) - assert('invalid_schema' not in dj.list_schemas()) \ No newline at end of file + assert('invalid_schema' not in dj.list_schemas()) diff --git a/tests/test_schemas.py b/tests/test_schemas.py index 4931dd1..e15712b 100644 --- a/tests/test_schemas.py +++ b/tests/test_schemas.py @@ -4,7 +4,7 @@ def test_schemas(token, client, connection, schemas_simple): - REST_schemas = client.get('/list_schemas', + REST_schemas = client.get('/schema', headers=dict( Authorization=f'Bearer {token}')).json['schemaNames'] assert set(REST_schemas) == set( diff --git a/tests/test_table_metadata.py b/tests/test_table_metadata.py index 1c30202..2bab9de 100644 --- a/tests/test_table_metadata.py +++ b/tests/test_table_metadata.py @@ -3,14 +3,12 @@ def test_definition(token, client, schemas_simple): simple1, simple2 = schemas_simple - REST_definition = client.post('/get_table_definition', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=simple1.database, - tableName='TableB')).data + REST_definition = client.get( + f'/schema/{simple1.database}/table/{"TableB"}/definition', + headers=dict(Authorization=f'Bearer {token}')).data assert f'{simple1.database}.TableA' in REST_definition.decode('utf-8') - REST_definition = client.post('/get_table_definition', - headers=dict(Authorization=f'Bearer {token}'), - json=dict(schemaName=simple2.database, - tableName='DiffTableB')).data + REST_definition = client.get( + f'/schema/{simple2.database}/table/{"DiffTableB"}/definition', + headers=dict(Authorization=f'Bearer {token}')).data assert f'`{simple1.database}`.`#table_a`' in REST_definition.decode('utf-8')