diff options
Diffstat (limited to 'api')
84 files changed, 3130 insertions, 0 deletions
diff --git a/api/.gitignore b/api/.gitignore new file mode 100644 index 00000000..fef0da65 --- /dev/null +++ b/api/.gitignore @@ -0,0 +1,15 @@ +.DS_Store +*.pyc +*.pyo +venv +venv* +dist +build +*.egg +*.egg-info +_mailinglist +.tox +.cache/ +.idea/ +config.json +test.json diff --git a/api/.gitlab-ci.yml b/api/.gitlab-ci.yml new file mode 100644 index 00000000..d80ba836 --- /dev/null +++ b/api/.gitlab-ci.yml @@ -0,0 +1,26 @@ +image: "python:3.8" + +variables: + PIP_CACHE_DIR: "$CI_PROJECT_DIR/.cache/pip" + +cache: + paths: + - .cache/pip + +stages: + - static-analysis + - test + +static-analysis: + stage: static-analysis + script: + - python --version + - pip install -r requirements.txt + - pylint opendc + +test: + stage: test + script: + - python --version + - pip install -r requirements.txt + - pytest opendc diff --git a/api/.pylintrc b/api/.pylintrc new file mode 100644 index 00000000..f25e4fc2 --- /dev/null +++ b/api/.pylintrc @@ -0,0 +1,521 @@ +[MASTER] + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code. +extension-pkg-whitelist= + +# Specify a score threshold to be exceeded before program exits with error. +fail-under=10 + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=CVS + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Python code to execute, usually for sys.path manipulation such as +# pygtk.require(). +#init-hook= + +# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the +# number of processors available to use. +jobs=1 + +# Control the amount of potential inferred values when inferring a single +# object. This can help the performance when dealing with large functions or +# complex, nested conditions. +limit-inference-results=100 + +# List of plugins (as comma separated values of python module names) to load, +# usually to register additional checkers. +load-plugins= + +# Pickle collected data for later comparisons. +persistent=yes + +# When enabled, pylint would attempt to guess common misconfiguration and emit +# user-friendly hints instead of false-positive error messages. +suggestion-mode=yes + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED. +confidence= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once). You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use "--disable=all --enable=classes +# --disable=W". +disable=duplicate-code, + missing-module-docstring, + invalid-name, + bare-except, + too-few-public-methods + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +enable=c-extension-no-member + + +[REPORTS] + +# Python expression which should return a score less than or equal to 10. You +# have access to the variables 'error', 'warning', 'refactor', and 'convention' +# which contain the number of messages in each category, as well as 'statement' +# which is the total number of statements analyzed. This score is used by the +# global evaluation report (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details. +#msg-template= + +# Set the output format. Available formats are text, parseable, colorized, json +# and msvs (visual studio). You can also give a reporter class, e.g. +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Tells whether to display a full report or only the messages. +reports=no + +# Activate the evaluation score. +score=yes + + +[REFACTORING] + +# Maximum number of nested blocks for function / method body +max-nested-blocks=5 + +# Complete name of functions that never returns. When checking for +# inconsistent-return-statements if a never returning function is called then +# it will be considered as an explicit return statement and no message will be +# printed. +never-returning-functions=sys.exit + + +[LOGGING] + +# The type of string formatting that logging methods do. `old` means using % +# formatting, `new` is for `{}` formatting. +logging-format-style=old + +# Logging modules to check that the string format arguments are in logging +# function parameter format. +logging-modules=logging + + +[SPELLING] + +# Limits count of emitted suggestions for spelling mistakes. +max-spelling-suggestions=4 + +# Spelling dictionary name. Available dictionaries: none. To make it work, +# install the python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains the private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to the private dictionary (see the +# --spelling-private-dict-file option) instead of raising a message. +spelling-store-unknown-words=no + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=FIXME, + XXX, + TODO + +# Regular expression of note tags to take in consideration. +#notes-rgx= + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# Tells whether to warn about missing members when the owner of the attribute +# is inferred to be None. +ignore-none=yes + +# This flag controls whether pylint should warn about no-member and similar +# checks whenever an opaque object is returned when inferring. The inference +# can return multiple potential results while evaluating a Python object, but +# some branches might not be evaluated, which results in partial inference. In +# that case, it might be useful to still emit no-member and other checks for +# the rest of the inferred objects. +ignore-on-opaque-inference=yes + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis). It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# Show a hint with possible names when a member name was not found. The aspect +# of finding the hint is based on edit distance. +missing-member-hint=yes + +# The minimum edit distance a name should have in order to be considered a +# similar match for a missing member name. +missing-member-hint-distance=1 + +# The total number of similar names that should be taken in consideration when +# showing a hint for a missing member. +missing-member-max-choices=1 + +# List of decorators that change the signature of a decorated function. +signature-mutators= + + +[VARIABLES] + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid defining new builtins when possible. +additional-builtins= + +# Tells whether unused global variables should be treated as a violation. +allow-global-unused-variables=yes + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_, + _cb + +# A regular expression matching the name of dummy variables (i.e. expected to +# not be used). +dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_ + +# Argument names that match this expression will be ignored. Default to name +# with leading underscore. +ignored-argument-names=_.*|^ignored_|^unused_ + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io + + +[FORMAT] + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=^\s*(# )?<?https?://\S+>?$ + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1 +# tab). +indent-string=' ' + +# Maximum number of characters on a single line. +max-line-length=120 + +# Maximum number of lines in a module. +max-module-lines=1000 + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check=trailing-comma, + dict-separator + +# Allow the body of a class to be on the same line as the declaration if body +# contains single statement. +single-line-class-stmt=no + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=no + + +[SIMILARITIES] + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + +# Minimum lines number of a similarity. +min-similarity-lines=4 + + +[BASIC] + +# Naming style matching correct argument names. +argument-naming-style=snake_case + +# Regular expression matching correct argument names. Overrides argument- +# naming-style. +#argument-rgx= + +# Naming style matching correct attribute names. +attr-naming-style=snake_case + +# Regular expression matching correct attribute names. Overrides attr-naming- +# style. +#attr-rgx= + +# Bad variable names which should always be refused, separated by a comma. +bad-names=foo, + bar, + baz, + toto, + tutu, + tata + +# Bad variable names regexes, separated by a comma. If names match any regex, +# they will always be refused +bad-names-rgxs= + +# Naming style matching correct class attribute names. +class-attribute-naming-style=any + +# Regular expression matching correct class attribute names. Overrides class- +# attribute-naming-style. +#class-attribute-rgx= + +# Naming style matching correct class names. +class-naming-style=PascalCase + +# Regular expression matching correct class names. Overrides class-naming- +# style. +#class-rgx= + +# Naming style matching correct constant names. +const-naming-style=UPPER_CASE + +# Regular expression matching correct constant names. Overrides const-naming- +# style. +#const-rgx= + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=-1 + +# Naming style matching correct function names. +function-naming-style=snake_case + +# Regular expression matching correct function names. Overrides function- +# naming-style. +#function-rgx= + +# Good variable names which should always be accepted, separated by a comma. +good-names=i, + j, + k, + ex, + Run, + _ + +# Good variable names regexes, separated by a comma. If names match any regex, +# they will always be accepted +good-names-rgxs= + +# Include a hint for the correct naming format with invalid-name. +include-naming-hint=no + +# Naming style matching correct inline iteration names. +inlinevar-naming-style=any + +# Regular expression matching correct inline iteration names. Overrides +# inlinevar-naming-style. +#inlinevar-rgx= + +# Naming style matching correct method names. +method-naming-style=snake_case + +# Regular expression matching correct method names. Overrides method-naming- +# style. +#method-rgx= + +# Naming style matching correct module names. +module-naming-style=snake_case + +# Regular expression matching correct module names. Overrides module-naming- +# style. +#module-rgx= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=^_ + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +# These decorators are taken in consideration only for invalid-name. +property-classes=abc.abstractproperty + +# Naming style matching correct variable names. +variable-naming-style=snake_case + +# Regular expression matching correct variable names. Overrides variable- +# naming-style. +#variable-rgx= + + +[STRING] + +# This flag controls whether inconsistent-quotes generates a warning when the +# character used as a quote delimiter is used inconsistently within a module. +check-quote-consistency=no + +# This flag controls whether the implicit-str-concat should generate a warning +# on implicit string concatenation in sequences defined over several lines. +check-str-concat-over-line-jumps=no + + +[IMPORTS] + +# List of modules that can be imported at any level, not just the top level +# one. +allow-any-import-level= + +# Allow wildcard imports from modules that define __all__. +allow-wildcard-with-all=no + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + +# Deprecated modules which should not be used, separated by a comma. +deprecated-modules=optparse,tkinter.tix + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled). +ext-import-graph= + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled). +import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled). +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant + +# Couples of modules and preferred modules, separated by a comma. +preferred-modules= + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp, + __post_init__ + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=cls + + +[DESIGN] + +# Maximum number of arguments for function / method. +max-args=5 + +# Maximum number of attributes for a class (see R0902). +max-attributes=12 + +# Maximum number of boolean expressions in an if statement (see R0916). +max-bool-expr=5 + +# Maximum number of branch for function / method body. +max-branches=12 + +# Maximum number of locals for function / method body. +max-locals=15 + +# Maximum number of parents for a class (see R0901). +max-parents=7 + +# Maximum number of public methods for a class (see R0904). +max-public-methods=20 + +# Maximum number of return / yield for function / method body. +max-returns=6 + +# Maximum number of statements in function / method body. +max-statements=50 + +# Minimum number of public methods for a class (see R0903). +min-public-methods=2 + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "BaseException, Exception". +overgeneral-exceptions=BaseException, + Exception diff --git a/api/.style.yapf b/api/.style.yapf new file mode 100644 index 00000000..f5c26c57 --- /dev/null +++ b/api/.style.yapf @@ -0,0 +1,3 @@ +[style] +based_on_style = pep8 +column_limit=120 diff --git a/api/Dockerfile b/api/Dockerfile new file mode 100644 index 00000000..49702c90 --- /dev/null +++ b/api/Dockerfile @@ -0,0 +1,17 @@ +FROM python:3.8 +MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com> + +# Ensure the STDOUT is not buffered by Python so that our logs become visible +# See https://stackoverflow.com/q/29663459/10213073 +ENV PYTHONUNBUFFERED 1 + +# Copy OpenDC directory +COPY ./ /opendc + +# Fetch web server dependencies +RUN pip install -r /opendc/requirements.txt + +# Set working directory +WORKDIR /opendc + +CMD ["python3", "main.py"] diff --git a/api/README.md b/api/README.md new file mode 100644 index 00000000..4e8110d0 --- /dev/null +++ b/api/README.md @@ -0,0 +1,101 @@ +<h1 align="center"> + <img src="../misc/artwork/logo.png" width="100" alt="OpenDC"> + <br> + OpenDC Web Server +</h1> +<p align="center"> + Collaborative Datacenter Simulation and Exploration for Everybody +</p> + +<br> + +The OpenDC web server is the bridge between OpenDC's frontend and database. It is built with Flask/SocketIO in Python and implements the OpenAPI-compliant [OpenDC API specification](../opendc-api-spec.yml). + +This document explains a high-level view of the web server architecture ([jump](#architecture)), and describes how to set up the web server for local development ([jump](#setup-for-local-development)). + +## Architecture + +The following diagram shows a high-level view of the architecture of the OpenDC web server. Squared-off colored boxes indicate packages (colors become more saturated as packages are nested); rounded-off boxes indicate individual components; dotted lines indicate control flow; and solid lines indicate data flow. + + + +The OpenDC API is implemented by the `Main Server Loop`, which is the only component in the base package. + +### Util Package + +The `Util` package handles several miscellaneous tasks: + +* `Database API`: Wraps database access functionality used by `Models` to read themselves from/write themselves into the database. +* `Exceptions`: Holds definitions for exceptions used throughout the web server. +* `Parameter Checker`: Recursively checks whether required `Request` parameters are present and correctly typed. +* `REST`: Parses SocketIO and HTTP messages into `Request` objects, and calls the appropriate `API` endpoint to get a `Response` object to return to the `Main Server Loop`. + +### API Package + +The `API` package contains the logic for the HTTP methods in each API endpoint. Packages are structured to mirror the API: the code for the endpoint `GET api/projects`, for example, would be located at the `endpoint.py` inside the `projects` package (so at `api/projects/endpoint.py`). + +An `endpoint.py` file contains methods for each HTTP method it supports, which takes a request as input (such as `def GET(request):`). Typically, such a method checks whether the parameters were passed correctly (using the `Parameter Checker`); fetches some model from the database; checks whether the data exists and is accessible by the user who made the request; possibly modifies this data and writes it back to the database; and returns a JSON representation of the model. + +The `REST` component dynamically imports the appropriate method from the appropriate `endpoint`, according to request it receives, and executes it. + +### Models Package + +The `models` package contains the logic for mapping Python objects to their database representations. This involves an abstract `model` which has generic CRUD operations. Extensions of `model`, such as a `User` or `Project`, specify some more specific operations and their collection metadata. + +`Endpoint`s import these `models` and use them to execute requests. + +## Setup for Local Development + +The following steps will guide you through setting up the OpenDC web server locally for development. To test individual endpoints, edit `static/index.html`. + +### Local Setup + +#### Install requirements + +Make sure you have Python 3.7+ installed (if not, get it [here](https://www.python.org/)), as well as pip (if not, get it [here](https://pip.pypa.io/en/stable/installing/)). Then run the following to install the requirements. + +```bash +pip install -r requirements.txt +``` + +The web server also requires a running MongoDB instance. We recommend setting this up through docker, by running `docker-compose build` and `docker-compose up` in the [`mongodb` directory](../database) of the main OpenDC repository. + +#### Get and configure the code + +Clone OpenDC and follow the [instructions in the main repository](../) to set up a Google OAuth ID and environment variables. + +**Important:** Be sure to set up environment variables according to those instructions, in a `.env` file. + +In `api/static/index.html`, add your own `OAUTH_CLIENT_ID` in `content=` on line `2`. + +#### Set up the database + +You can selectively run only the database services from the standard OpenDC `docker-compose` setup: + +```bash +docker-compose build mongo mongo-express +docker-compose up mongo mongo-express +``` + +This will set you up with a running MongoDB instance and a visual inspection tool running on [localhost:8082](http://localhost:8082), with which you can view and manipulate the database. + +### Local Development + +Run the server. + +```bash +cd api +python main.py +``` + +When editing the web server code, restart the server (`CTRL` + `c` followed by `python main.py` in the console running the server) to see the result of your changes. + +#### Code Style + +To format all files, run `format.sh` in this directory (uses `yapf` internally). + +To check if code style is up to modern standards, run `check.sh` in this directory (uses `pylint` internally). + +#### Testing + +Run `pytest` in this directory to run all tests. diff --git a/api/check.sh b/api/check.sh new file mode 100755 index 00000000..abe2c596 --- /dev/null +++ b/api/check.sh @@ -0,0 +1 @@ +pylint opendc --ignore-patterns=test_.*?py diff --git a/api/conftest.py b/api/conftest.py new file mode 100644 index 00000000..1f4831b8 --- /dev/null +++ b/api/conftest.py @@ -0,0 +1,15 @@ +""" +Configuration file for all unit tests. +""" +import pytest + +from main import FLASK_CORE_APP + + +@pytest.fixture +def client(): + """Returns a Flask API client to interact with.""" + FLASK_CORE_APP.config['TESTING'] = True + + with FLASK_CORE_APP.test_client() as client: + yield client diff --git a/api/format.sh b/api/format.sh new file mode 100755 index 00000000..18cba452 --- /dev/null +++ b/api/format.sh @@ -0,0 +1 @@ +yapf **/*.py -i diff --git a/api/main.py b/api/main.py new file mode 100755 index 00000000..a2481269 --- /dev/null +++ b/api/main.py @@ -0,0 +1,197 @@ +#!/usr/bin/env python3 +import flask_socketio +import json +import os +import sys +import traceback +import urllib.request +from flask import Flask, request, send_from_directory, jsonify +from flask_compress import Compress +from oauth2client import client, crypt +from flask_cors import CORS +from dotenv import load_dotenv + +from opendc.models.user import User +from opendc.util import rest, path_parser, database +from opendc.util.exceptions import AuthorizationTokenError, RequestInitializationError + +load_dotenv() + +TEST_MODE = "OPENDC_FLASK_TESTING" in os.environ + +# Specify the directory of static assets +if TEST_MODE: + STATIC_ROOT = os.curdir +else: + STATIC_ROOT = os.path.join(os.environ['OPENDC_ROOT_DIR'], 'frontend', 'build') + +# Set up database if not testing +if not TEST_MODE: + database.DB.initialize_database( + user=os.environ['OPENDC_DB_USERNAME'], + password=os.environ['OPENDC_DB_PASSWORD'], + database=os.environ['OPENDC_DB'], + host=os.environ['OPENDC_DB_HOST'] if 'OPENDC_DB_HOST' in os.environ else 'localhost') + +# Set up the core app +FLASK_CORE_APP = Flask(__name__, static_url_path='', static_folder=STATIC_ROOT) +FLASK_CORE_APP.config['SECRET_KEY'] = os.environ['OPENDC_FLASK_SECRET'] + +# Set up CORS support for local setups +if 'localhost' in os.environ['OPENDC_SERVER_BASE_URL']: + CORS(FLASK_CORE_APP) + +compress = Compress() +compress.init_app(FLASK_CORE_APP) + +if 'OPENDC_SERVER_BASE_URL' in os.environ or 'localhost' in os.environ['OPENDC_SERVER_BASE_URL']: + SOCKET_IO_CORE = flask_socketio.SocketIO(FLASK_CORE_APP, cors_allowed_origins="*") +else: + SOCKET_IO_CORE = flask_socketio.SocketIO(FLASK_CORE_APP) + + +@FLASK_CORE_APP.errorhandler(404) +def page_not_found(e): + return send_from_directory(STATIC_ROOT, 'index.html') + + +@FLASK_CORE_APP.route('/tokensignin', methods=['POST']) +def sign_in(): + """Authenticate a user with Google sign in""" + + try: + token = request.form['idtoken'] + except KeyError: + return 'No idtoken provided', 401 + + try: + idinfo = client.verify_id_token(token, os.environ['OPENDC_OAUTH_CLIENT_ID']) + + if idinfo['aud'] != os.environ['OPENDC_OAUTH_CLIENT_ID']: + raise crypt.AppIdentityError('Unrecognized client.') + + if idinfo['iss'] not in ['accounts.google.com', 'https://accounts.google.com']: + raise crypt.AppIdentityError('Wrong issuer.') + except ValueError: + url = "https://www.googleapis.com/oauth2/v3/tokeninfo?id_token={}".format(token) + req = urllib.request.Request(url) + response = urllib.request.urlopen(url=req, timeout=30) + res = response.read() + idinfo = json.loads(res) + except crypt.AppIdentityError as e: + return 'Did not successfully authenticate' + + user = User.from_google_id(idinfo['sub']) + + data = {'isNewUser': user.obj is None} + + if user.obj is not None: + data['userId'] = user.get_id() + + return jsonify(**data) + + +@FLASK_CORE_APP.route('/api/<string:version>/<path:endpoint_path>', methods=['GET', 'POST', 'PUT', 'DELETE']) +def api_call(version, endpoint_path): + """Call an API endpoint directly over HTTP.""" + + # Get path and parameters + (path, path_parameters) = path_parser.parse(version, endpoint_path) + + query_parameters = request.args.to_dict() + for param in query_parameters: + try: + query_parameters[param] = int(query_parameters[param]) + except: + pass + + try: + body_parameters = json.loads(request.get_data()) + except: + body_parameters = {} + + # Create and call request + (req, response) = _process_message({ + 'id': 0, + 'method': request.method, + 'parameters': { + 'body': body_parameters, + 'path': path_parameters, + 'query': query_parameters + }, + 'path': path, + 'token': request.headers.get('auth-token') + }) + + print( + f'HTTP:\t{req.method} to `/{req.path}` resulted in {response.status["code"]}: {response.status["description"]}') + sys.stdout.flush() + + flask_response = jsonify(json.loads(response.to_JSON())) + flask_response.status_code = response.status['code'] + return flask_response + + +@FLASK_CORE_APP.route('/my-auth-token') +def serve_web_server_test(): + """Serve the web server test.""" + return send_from_directory(STATIC_ROOT, 'index.html') + + +@FLASK_CORE_APP.route('/') +@FLASK_CORE_APP.route('/projects') +@FLASK_CORE_APP.route('/projects/<path:project_id>') +@FLASK_CORE_APP.route('/profile') +def serve_index(project_id=None): + return send_from_directory(STATIC_ROOT, 'index.html') + + +@SOCKET_IO_CORE.on('request') +def receive_message(message): + """"Receive a SocketIO request""" + (req, res) = _process_message(message) + + print(f'Socket: {req.method} to `/{req.path}` resulted in {res.status["code"]}: {res.status["description"]}') + sys.stdout.flush() + + flask_socketio.emit('response', res.to_JSON(), json=True) + + +def _process_message(message): + """Process a request message and return the response.""" + + try: + req = rest.Request(message) + res = req.process() + + return req, res + + except AuthorizationTokenError: + res = rest.Response(401, 'Authorization error') + res.id = message['id'] + + except RequestInitializationError as e: + res = rest.Response(400, str(e)) + res.id = message['id'] + + if not 'method' in message: + message['method'] = 'UNSPECIFIED' + if not 'path' in message: + message['path'] = 'UNSPECIFIED' + + except Exception: + res = rest.Response(500, 'Internal server error') + if 'id' in message: + res.id = message['id'] + traceback.print_exc() + + req = rest.Request() + req.method = message['method'] + req.path = message['path'] + + return req, res + + +if __name__ == '__main__': + print("Web server started on 8081") + SOCKET_IO_CORE.run(FLASK_CORE_APP, host='0.0.0.0', port=8081) diff --git a/api/misc/artwork/opendc-web-server-component-diagram.png b/api/misc/artwork/opendc-web-server-component-diagram.png Binary files differnew file mode 100644 index 00000000..91b26006 --- /dev/null +++ b/api/misc/artwork/opendc-web-server-component-diagram.png diff --git a/api/opendc/__init__.py b/api/opendc/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/__init__.py diff --git a/api/opendc/api/__init__.py b/api/opendc/api/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/__init__.py diff --git a/api/opendc/api/v2/__init__.py b/api/opendc/api/v2/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/__init__.py diff --git a/api/opendc/api/v2/paths.json b/api/opendc/api/v2/paths.json new file mode 100644 index 00000000..90d5a2e6 --- /dev/null +++ b/api/opendc/api/v2/paths.json @@ -0,0 +1,18 @@ +[ + "/users", + "/users/{userId}", + "/projects", + "/projects/{projectId}", + "/projects/{projectId}/authorizations", + "/projects/{projectId}/topologies", + "/projects/{projectId}/portfolios", + "/topologies/{topologyId}", + "/portfolios/{portfolioId}", + "/portfolios/{portfolioId}/scenarios", + "/scenarios/{scenarioId}", + "/schedulers", + "/traces", + "/traces/{traceId}", + "/prefabs", + "/prefabs/{prefabId}" +] diff --git a/api/opendc/api/v2/portfolios/__init__.py b/api/opendc/api/v2/portfolios/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/portfolios/__init__.py diff --git a/api/opendc/api/v2/portfolios/portfolioId/__init__.py b/api/opendc/api/v2/portfolios/portfolioId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/portfolios/portfolioId/__init__.py diff --git a/api/opendc/api/v2/portfolios/portfolioId/endpoint.py b/api/opendc/api/v2/portfolios/portfolioId/endpoint.py new file mode 100644 index 00000000..c0ca64e0 --- /dev/null +++ b/api/opendc/api/v2/portfolios/portfolioId/endpoint.py @@ -0,0 +1,65 @@ +from opendc.models.portfolio import Portfolio +from opendc.models.project import Project +from opendc.util.rest import Response + + +def GET(request): + """Get this Portfolio.""" + + request.check_required_parameters(path={'portfolioId': 'string'}) + + portfolio = Portfolio.from_id(request.params_path['portfolioId']) + + portfolio.check_exists() + portfolio.check_user_access(request.google_id, False) + + return Response(200, 'Successfully retrieved portfolio.', portfolio.obj) + + +def PUT(request): + """Update this Portfolio.""" + + request.check_required_parameters(path={'portfolioId': 'string'}, body={'portfolio': { + 'name': 'string', + 'targets': { + 'enabledMetrics': 'list', + 'repeatsPerScenario': 'int', + }, + }}) + + portfolio = Portfolio.from_id(request.params_path['portfolioId']) + + portfolio.check_exists() + portfolio.check_user_access(request.google_id, True) + + portfolio.set_property('name', + request.params_body['portfolio']['name']) + portfolio.set_property('targets.enabledMetrics', + request.params_body['portfolio']['targets']['enabledMetrics']) + portfolio.set_property('targets.repeatsPerScenario', + request.params_body['portfolio']['targets']['repeatsPerScenario']) + + portfolio.update() + + return Response(200, 'Successfully updated portfolio.', portfolio.obj) + + +def DELETE(request): + """Delete this Portfolio.""" + + request.check_required_parameters(path={'portfolioId': 'string'}) + + portfolio = Portfolio.from_id(request.params_path['portfolioId']) + + portfolio.check_exists() + portfolio.check_user_access(request.google_id, True) + + project = Project.from_id(portfolio.obj['projectId']) + project.check_exists() + if request.params_path['portfolioId'] in project.obj['portfolioIds']: + project.obj['portfolioIds'].remove(request.params_path['portfolioId']) + project.update() + + old_object = portfolio.delete() + + return Response(200, 'Successfully deleted portfolio.', old_object) diff --git a/api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py b/api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py diff --git a/api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py b/api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py new file mode 100644 index 00000000..1c5e0ab6 --- /dev/null +++ b/api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py @@ -0,0 +1,43 @@ +from opendc.models.portfolio import Portfolio +from opendc.models.scenario import Scenario +from opendc.util.rest import Response + + +def POST(request): + """Add a new Scenario for this Portfolio.""" + + request.check_required_parameters(path={'portfolioId': 'string'}, + body={ + 'scenario': { + 'name': 'string', + 'trace': { + 'traceId': 'string', + 'loadSamplingFraction': 'float', + }, + 'topology': { + 'topologyId': 'string', + }, + 'operational': { + 'failuresEnabled': 'bool', + 'performanceInterferenceEnabled': 'bool', + 'schedulerName': 'string', + }, + } + }) + + portfolio = Portfolio.from_id(request.params_path['portfolioId']) + + portfolio.check_exists() + portfolio.check_user_access(request.google_id, True) + + scenario = Scenario(request.params_body['scenario']) + + scenario.set_property('portfolioId', request.params_path['portfolioId']) + scenario.set_property('simulationState', 'QUEUED') + + scenario.insert() + + portfolio.obj['scenarioIds'].append(scenario.get_id()) + portfolio.update() + + return Response(200, 'Successfully added Scenario.', scenario.obj) diff --git a/api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py b/api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py new file mode 100644 index 00000000..8b55bab0 --- /dev/null +++ b/api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py @@ -0,0 +1,119 @@ +from opendc.util.database import DB + + +def test_add_scenario_missing_parameter(client): + assert '400' in client.post('/api/v2/portfolios/1/scenarios').status + + +def test_add_scenario_non_existing_portfolio(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.post('/api/v2/portfolios/1/scenarios', + json={ + 'scenario': { + 'name': 'test', + 'trace': { + 'traceId': '1', + 'loadSamplingFraction': 1.0, + }, + 'topology': { + 'topologyId': '1', + }, + 'operational': { + 'failuresEnabled': True, + 'performanceInterferenceEnabled': False, + 'schedulerName': 'DEFAULT', + }, + } + }).status + + +def test_add_scenario_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'portfolioId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + assert '403' in client.post('/api/v2/portfolios/1/scenarios', + json={ + 'scenario': { + 'name': 'test', + 'trace': { + 'traceId': '1', + 'loadSamplingFraction': 1.0, + }, + 'topology': { + 'topologyId': '1', + }, + 'operational': { + 'failuresEnabled': True, + 'performanceInterferenceEnabled': False, + 'schedulerName': 'DEFAULT', + }, + } + }).status + + +def test_add_scenario(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'portfolioId': '1', + 'portfolioIds': ['1'], + 'scenarioIds': ['1'], + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'EDIT' + }], + 'simulationState': 'QUEUED', + }) + mocker.patch.object(DB, + 'insert', + return_value={ + '_id': '1', + 'name': 'test', + 'trace': { + 'traceId': '1', + 'loadSamplingFraction': 1.0, + }, + 'topology': { + 'topologyId': '1', + }, + 'operational': { + 'failuresEnabled': True, + 'performanceInterferenceEnabled': False, + 'schedulerName': 'DEFAULT', + }, + 'portfolioId': '1', + 'simulationState': 'QUEUED', + }) + mocker.patch.object(DB, 'update', return_value=None) + res = client.post( + '/api/v2/portfolios/1/scenarios', + json={ + 'scenario': { + 'name': 'test', + 'trace': { + 'traceId': '1', + 'loadSamplingFraction': 1.0, + }, + 'topology': { + 'topologyId': '1', + }, + 'operational': { + 'failuresEnabled': True, + 'performanceInterferenceEnabled': False, + 'schedulerName': 'DEFAULT', + }, + } + }) + assert 'portfolioId' in res.json['content'] + assert 'simulationState' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py b/api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py new file mode 100644 index 00000000..7ac346d4 --- /dev/null +++ b/api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py @@ -0,0 +1,149 @@ +from opendc.util.database import DB + + +def test_get_portfolio_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/portfolios/1').status + + +def test_get_portfolio_no_authorizations(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'projectId': '1', 'authorizations': []}) + res = client.get('/api/v2/portfolios/1') + assert '403' in res.status + + +def test_get_portfolio_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + 'projectId': '1', + '_id': '1', + 'authorizations': [{ + 'projectId': '2', + 'authorizationLevel': 'OWN' + }] + }) + res = client.get('/api/v2/portfolios/1') + assert '403' in res.status + + +def test_get_portfolio(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + 'projectId': '1', + '_id': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'EDIT' + }] + }) + res = client.get('/api/v2/portfolios/1') + assert '200' in res.status + + +def test_update_portfolio_missing_parameter(client): + assert '400' in client.put('/api/v2/portfolios/1').status + + +def test_update_portfolio_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.put('/api/v2/portfolios/1', json={ + 'portfolio': { + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + } + } + }).status + + +def test_update_portfolio_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + mocker.patch.object(DB, 'update', return_value={}) + assert '403' in client.put('/api/v2/portfolios/1', json={ + 'portfolio': { + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + } + } + }).status + + +def test_update_portfolio(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }], + 'targets': { + 'enabledMetrics': [], + 'repeatsPerScenario': 1 + } + }) + mocker.patch.object(DB, 'update', return_value={}) + + res = client.put('/api/v2/portfolios/1', json={'portfolio': { + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + } + }}) + assert '200' in res.status + + +def test_delete_project_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.delete('/api/v2/portfolios/1').status + + +def test_delete_project_different_user(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'googleId': 'other_test', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + mocker.patch.object(DB, 'delete_one', return_value=None) + assert '403' in client.delete('/api/v2/portfolios/1').status + + +def test_delete_project(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'googleId': 'test', + 'portfolioIds': ['1'], + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'delete_one', return_value={}) + mocker.patch.object(DB, 'update', return_value=None) + res = client.delete('/api/v2/portfolios/1') + assert '200' in res.status diff --git a/api/opendc/api/v2/prefabs/__init__.py b/api/opendc/api/v2/prefabs/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/prefabs/__init__.py diff --git a/api/opendc/api/v2/prefabs/endpoint.py b/api/opendc/api/v2/prefabs/endpoint.py new file mode 100644 index 00000000..723a2f0d --- /dev/null +++ b/api/opendc/api/v2/prefabs/endpoint.py @@ -0,0 +1,23 @@ +from datetime import datetime + +from opendc.models.prefab import Prefab +from opendc.models.user import User +from opendc.util.database import Database +from opendc.util.rest import Response + + +def POST(request): + """Create a new prefab, and return that new prefab.""" + + request.check_required_parameters(body={'prefab': {'name': 'string'}}) + + prefab = Prefab(request.params_body['prefab']) + prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now())) + prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + + user = User.from_google_id(request.google_id) + prefab.set_property('authorId', user.get_id()) + + prefab.insert() + + return Response(200, 'Successfully created prefab.', prefab.obj) diff --git a/api/opendc/api/v2/prefabs/prefabId/__init__.py b/api/opendc/api/v2/prefabs/prefabId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/prefabs/prefabId/__init__.py diff --git a/api/opendc/api/v2/prefabs/prefabId/endpoint.py b/api/opendc/api/v2/prefabs/prefabId/endpoint.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/prefabs/prefabId/endpoint.py diff --git a/api/opendc/api/v2/prefabs/prefabId/test_endpoint.py b/api/opendc/api/v2/prefabs/prefabId/test_endpoint.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/prefabs/prefabId/test_endpoint.py diff --git a/api/opendc/api/v2/prefabs/test_endpoint.py b/api/opendc/api/v2/prefabs/test_endpoint.py new file mode 100644 index 00000000..47029579 --- /dev/null +++ b/api/opendc/api/v2/prefabs/test_endpoint.py @@ -0,0 +1,22 @@ +from opendc.util.database import DB + + +def test_add_prefab_missing_parameter(client): + assert '400' in client.post('/api/v2/prefabs').status + + +def test_add_prefab(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'_id': '1', 'authorizations': []}) + mocker.patch.object(DB, + 'insert', + return_value={ + '_id': '1', + 'datetimeCreated': '000', + 'datetimeLastEdited': '000', + 'authorId': 1 + }) + res = client.post('/api/v2/prefabs', json={'prefab': {'name': 'test prefab'}}) + assert 'datetimeCreated' in res.json['content'] + assert 'datetimeLastEdited' in res.json['content'] + assert 'authorId' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/api/v2/projects/__init__.py b/api/opendc/api/v2/projects/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/projects/__init__.py diff --git a/api/opendc/api/v2/projects/endpoint.py b/api/opendc/api/v2/projects/endpoint.py new file mode 100644 index 00000000..bf031382 --- /dev/null +++ b/api/opendc/api/v2/projects/endpoint.py @@ -0,0 +1,32 @@ +from datetime import datetime + +from opendc.models.project import Project +from opendc.models.topology import Topology +from opendc.models.user import User +from opendc.util.database import Database +from opendc.util.rest import Response + + +def POST(request): + """Create a new project, and return that new project.""" + + request.check_required_parameters(body={'project': {'name': 'string'}}) + + topology = Topology({'name': 'Default topology', 'rooms': []}) + topology.insert() + + project = Project(request.params_body['project']) + project.set_property('datetimeCreated', Database.datetime_to_string(datetime.now())) + project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + project.set_property('topologyIds', [topology.get_id()]) + project.set_property('portfolioIds', []) + project.insert() + + topology.set_property('projectId', project.get_id()) + topology.update() + + user = User.from_google_id(request.google_id) + user.obj['authorizations'].append({'projectId': project.get_id(), 'authorizationLevel': 'OWN'}) + user.update() + + return Response(200, 'Successfully created project.', project.obj) diff --git a/api/opendc/api/v2/projects/projectId/__init__.py b/api/opendc/api/v2/projects/projectId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/__init__.py diff --git a/api/opendc/api/v2/projects/projectId/authorizations/__init__.py b/api/opendc/api/v2/projects/projectId/authorizations/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/authorizations/__init__.py diff --git a/api/opendc/api/v2/projects/projectId/authorizations/endpoint.py b/api/opendc/api/v2/projects/projectId/authorizations/endpoint.py new file mode 100644 index 00000000..9f6a60ec --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/authorizations/endpoint.py @@ -0,0 +1,17 @@ +from opendc.models.project import Project +from opendc.util.rest import Response + + +def GET(request): + """Find all authorizations for a Project.""" + + request.check_required_parameters(path={'projectId': 'string'}) + + project = Project.from_id(request.params_path['projectId']) + + project.check_exists() + project.check_user_access(request.google_id, False) + + authorizations = project.get_all_authorizations() + + return Response(200, 'Successfully retrieved project authorizations', authorizations) diff --git a/api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py b/api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py new file mode 100644 index 00000000..c3bbc093 --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py @@ -0,0 +1,40 @@ +from opendc.util.database import DB + + +def test_get_authorizations_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + mocker.patch.object(DB, 'fetch_all', return_value=None) + assert '404' in client.get('/api/v2/projects/1/authorizations').status + + +def test_get_authorizations_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'name': 'test trace', + 'authorizations': [{ + 'projectId': '2', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'fetch_all', return_value=[]) + res = client.get('/api/v2/projects/1/authorizations') + assert '403' in res.status + + +def test_get_authorizations(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'name': 'test trace', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'fetch_all', return_value=[]) + res = client.get('/api/v2/projects/1/authorizations') + assert len(res.json['content']) == 0 + assert '200' in res.status diff --git a/api/opendc/api/v2/projects/projectId/endpoint.py b/api/opendc/api/v2/projects/projectId/endpoint.py new file mode 100644 index 00000000..77b66d75 --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/endpoint.py @@ -0,0 +1,66 @@ +from datetime import datetime + +from opendc.models.portfolio import Portfolio +from opendc.models.project import Project +from opendc.models.topology import Topology +from opendc.models.user import User +from opendc.util.database import Database +from opendc.util.rest import Response + + +def GET(request): + """Get this Project.""" + + request.check_required_parameters(path={'projectId': 'string'}) + + project = Project.from_id(request.params_path['projectId']) + + project.check_exists() + project.check_user_access(request.google_id, False) + + return Response(200, 'Successfully retrieved project', project.obj) + + +def PUT(request): + """Update a project's name.""" + + request.check_required_parameters(body={'project': {'name': 'name'}}, path={'projectId': 'string'}) + + project = Project.from_id(request.params_path['projectId']) + + project.check_exists() + project.check_user_access(request.google_id, True) + + project.set_property('name', request.params_body['project']['name']) + project.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now())) + project.update() + + return Response(200, 'Successfully updated project.', project.obj) + + +def DELETE(request): + """Delete this Project.""" + + request.check_required_parameters(path={'projectId': 'string'}) + + project = Project.from_id(request.params_path['projectId']) + + project.check_exists() + project.check_user_access(request.google_id, True) + + for topology_id in project.obj['topologyIds']: + topology = Topology.from_id(topology_id) + topology.delete() + + for portfolio_id in project.obj['portfolioIds']: + portfolio = Portfolio.from_id(portfolio_id) + portfolio.delete() + + user = User.from_google_id(request.google_id) + user.obj['authorizations'] = list( + filter(lambda x: str(x['projectId']) != request.params_path['projectId'], user.obj['authorizations'])) + user.update() + + old_object = project.delete() + + return Response(200, 'Successfully deleted project.', old_object) diff --git a/api/opendc/api/v2/projects/projectId/portfolios/__init__.py b/api/opendc/api/v2/projects/projectId/portfolios/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/portfolios/__init__.py diff --git a/api/opendc/api/v2/projects/projectId/portfolios/endpoint.py b/api/opendc/api/v2/projects/projectId/portfolios/endpoint.py new file mode 100644 index 00000000..0bc65565 --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/portfolios/endpoint.py @@ -0,0 +1,35 @@ +from opendc.models.portfolio import Portfolio +from opendc.models.project import Project +from opendc.util.rest import Response + + +def POST(request): + """Add a new Portfolio for this Project.""" + + request.check_required_parameters(path={'projectId': 'string'}, + body={ + 'portfolio': { + 'name': 'string', + 'targets': { + 'enabledMetrics': 'list', + 'repeatsPerScenario': 'int', + }, + } + }) + + project = Project.from_id(request.params_path['projectId']) + + project.check_exists() + project.check_user_access(request.google_id, True) + + portfolio = Portfolio(request.params_body['portfolio']) + + portfolio.set_property('projectId', request.params_path['projectId']) + portfolio.set_property('scenarioIds', []) + + portfolio.insert() + + project.obj['portfolioIds'].append(portfolio.get_id()) + project.update() + + return Response(200, 'Successfully added Portfolio.', portfolio.obj) diff --git a/api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py b/api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py new file mode 100644 index 00000000..24416cc3 --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py @@ -0,0 +1,83 @@ +from opendc.util.database import DB + + +def test_add_portfolio_missing_parameter(client): + assert '400' in client.post('/api/v2/projects/1/portfolios').status + + +def test_add_portfolio_non_existing_project(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.post('/api/v2/projects/1/portfolios', + json={ + 'portfolio': { + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + } + } + }).status + + +def test_add_portfolio_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + assert '403' in client.post('/api/v2/projects/1/portfolios', + json={ + 'portfolio': { + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + } + } + }).status + + +def test_add_portfolio(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'portfolioIds': ['1'], + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'EDIT' + }] + }) + mocker.patch.object(DB, + 'insert', + return_value={ + '_id': '1', + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + }, + 'projectId': '1', + 'scenarioIds': [], + }) + mocker.patch.object(DB, 'update', return_value=None) + res = client.post( + '/api/v2/projects/1/portfolios', + json={ + 'portfolio': { + 'name': 'test', + 'targets': { + 'enabledMetrics': ['test'], + 'repeatsPerScenario': 2 + } + } + }) + assert 'projectId' in res.json['content'] + assert 'scenarioIds' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/api/v2/projects/projectId/test_endpoint.py b/api/opendc/api/v2/projects/projectId/test_endpoint.py new file mode 100644 index 00000000..7a862e8d --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/test_endpoint.py @@ -0,0 +1,119 @@ +from opendc.util.database import DB + + +def test_get_project_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/projects/1').status + + +def test_get_project_no_authorizations(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'authorizations': []}) + res = client.get('/api/v2/projects/1') + assert '403' in res.status + + +def test_get_project_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'authorizations': [{ + 'projectId': '2', + 'authorizationLevel': 'OWN' + }] + }) + res = client.get('/api/v2/projects/1') + assert '403' in res.status + + +def test_get_project(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'EDIT' + }] + }) + res = client.get('/api/v2/projects/1') + assert '200' in res.status + + +def test_update_project_missing_parameter(client): + assert '400' in client.put('/api/v2/projects/1').status + + +def test_update_project_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.put('/api/v2/projects/1', json={'project': {'name': 'S'}}).status + + +def test_update_project_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + mocker.patch.object(DB, 'update', return_value={}) + assert '403' in client.put('/api/v2/projects/1', json={'project': {'name': 'S'}}).status + + +def test_update_project(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'update', return_value={}) + + res = client.put('/api/v2/projects/1', json={'project': {'name': 'S'}}) + assert '200' in res.status + + +def test_delete_project_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.delete('/api/v2/projects/1').status + + +def test_delete_project_different_user(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'googleId': 'other_test', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }], + 'topologyIds': [] + }) + mocker.patch.object(DB, 'delete_one', return_value=None) + assert '403' in client.delete('/api/v2/projects/1').status + + +def test_delete_project(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'googleId': 'test', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }], + 'topologyIds': [], + 'portfolioIds': [], + }) + mocker.patch.object(DB, 'update', return_value=None) + mocker.patch.object(DB, 'delete_one', return_value={'googleId': 'test'}) + res = client.delete('/api/v2/projects/1') + assert '200' in res.status diff --git a/api/opendc/api/v2/projects/projectId/topologies/__init__.py b/api/opendc/api/v2/projects/projectId/topologies/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/topologies/__init__.py diff --git a/api/opendc/api/v2/projects/projectId/topologies/endpoint.py b/api/opendc/api/v2/projects/projectId/topologies/endpoint.py new file mode 100644 index 00000000..211dc15d --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/topologies/endpoint.py @@ -0,0 +1,31 @@ +from datetime import datetime + +from opendc.models.project import Project +from opendc.models.topology import Topology +from opendc.util.rest import Response +from opendc.util.database import Database + + +def POST(request): + """Add a new Topology to the specified project and return it""" + + request.check_required_parameters(path={'projectId': 'string'}, body={'topology': {'name': 'string'}}) + + project = Project.from_id(request.params_path['projectId']) + + project.check_exists() + project.check_user_access(request.google_id, True) + + topology = Topology({ + 'projectId': request.params_path['projectId'], + 'name': request.params_body['topology']['name'], + 'rooms': request.params_body['topology']['rooms'], + }) + + topology.insert() + + project.obj['topologyIds'].append(topology.get_id()) + project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + project.update() + + return Response(200, 'Successfully inserted topology.', topology.obj) diff --git a/api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py b/api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py new file mode 100644 index 00000000..ca123a73 --- /dev/null +++ b/api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py @@ -0,0 +1,50 @@ +from opendc.util.database import DB + + +def test_add_topology_missing_parameter(client): + assert '400' in client.post('/api/v2/projects/1/topologies').status + + +def test_add_topology(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }], + 'topologyIds': [] + }) + mocker.patch.object(DB, + 'insert', + return_value={ + '_id': '1', + 'datetimeCreated': '000', + 'datetimeLastEdited': '000', + 'topologyIds': [] + }) + mocker.patch.object(DB, 'update', return_value={}) + res = client.post('/api/v2/projects/1/topologies', json={'topology': {'name': 'test project', 'rooms': []}}) + assert 'rooms' in res.json['content'] + assert '200' in res.status + + +def test_add_topology_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + assert '403' in client.post('/api/v2/projects/1/topologies', + json={ + 'topology': { + 'name': 'test_topology', + 'rooms': {} + } + }).status diff --git a/api/opendc/api/v2/projects/test_endpoint.py b/api/opendc/api/v2/projects/test_endpoint.py new file mode 100644 index 00000000..a50735b0 --- /dev/null +++ b/api/opendc/api/v2/projects/test_endpoint.py @@ -0,0 +1,23 @@ +from opendc.util.database import DB + + +def test_add_project_missing_parameter(client): + assert '400' in client.post('/api/v2/projects').status + + +def test_add_project(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'_id': '1', 'authorizations': []}) + mocker.patch.object(DB, + 'insert', + return_value={ + '_id': '1', + 'datetimeCreated': '000', + 'datetimeLastEdited': '000', + 'topologyIds': [] + }) + mocker.patch.object(DB, 'update', return_value={}) + res = client.post('/api/v2/projects', json={'project': {'name': 'test project'}}) + assert 'datetimeCreated' in res.json['content'] + assert 'datetimeLastEdited' in res.json['content'] + assert 'topologyIds' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/api/v2/scenarios/__init__.py b/api/opendc/api/v2/scenarios/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/scenarios/__init__.py diff --git a/api/opendc/api/v2/scenarios/scenarioId/__init__.py b/api/opendc/api/v2/scenarios/scenarioId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/scenarios/scenarioId/__init__.py diff --git a/api/opendc/api/v2/scenarios/scenarioId/endpoint.py b/api/opendc/api/v2/scenarios/scenarioId/endpoint.py new file mode 100644 index 00000000..02d39063 --- /dev/null +++ b/api/opendc/api/v2/scenarios/scenarioId/endpoint.py @@ -0,0 +1,57 @@ +from opendc.models.scenario import Scenario +from opendc.models.portfolio import Portfolio +from opendc.util.rest import Response + + +def GET(request): + """Get this Scenario.""" + + request.check_required_parameters(path={'scenarioId': 'string'}) + + scenario = Scenario.from_id(request.params_path['scenarioId']) + + scenario.check_exists() + scenario.check_user_access(request.google_id, False) + + return Response(200, 'Successfully retrieved scenario.', scenario.obj) + + +def PUT(request): + """Update this Scenarios name.""" + + request.check_required_parameters(path={'scenarioId': 'string'}, body={'scenario': { + 'name': 'string', + }}) + + scenario = Scenario.from_id(request.params_path['scenarioId']) + + scenario.check_exists() + scenario.check_user_access(request.google_id, True) + + scenario.set_property('name', + request.params_body['scenario']['name']) + + scenario.update() + + return Response(200, 'Successfully updated scenario.', scenario.obj) + + +def DELETE(request): + """Delete this Scenario.""" + + request.check_required_parameters(path={'scenarioId': 'string'}) + + scenario = Scenario.from_id(request.params_path['scenarioId']) + + scenario.check_exists() + scenario.check_user_access(request.google_id, True) + + portfolio = Portfolio.from_id(scenario.obj['portfolioId']) + portfolio.check_exists() + if request.params_path['scenarioId'] in portfolio.obj['scenarioIds']: + portfolio.obj['scenarioIds'].remove(request.params_path['scenarioId']) + portfolio.update() + + old_object = scenario.delete() + + return Response(200, 'Successfully deleted scenario.', old_object) diff --git a/api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py b/api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py new file mode 100644 index 00000000..09b7d0c0 --- /dev/null +++ b/api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py @@ -0,0 +1,140 @@ +from opendc.util.database import DB + + +def test_get_scenario_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/scenarios/1').status + + +def test_get_scenario_no_authorizations(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={ + 'portfolioId': '1', + 'authorizations': [] + }) + res = client.get('/api/v2/scenarios/1') + assert '403' in res.status + + +def test_get_scenario_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + 'portfolioId': '1', + '_id': '1', + 'authorizations': [{ + 'projectId': '2', + 'authorizationLevel': 'OWN' + }] + }) + res = client.get('/api/v2/scenarios/1') + assert '403' in res.status + + +def test_get_scenario(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + 'portfolioId': '1', + '_id': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'EDIT' + }] + }) + res = client.get('/api/v2/scenarios/1') + assert '200' in res.status + + +def test_update_scenario_missing_parameter(client): + assert '400' in client.put('/api/v2/scenarios/1').status + + +def test_update_scenario_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.put('/api/v2/scenarios/1', json={ + 'scenario': { + 'name': 'test', + } + }).status + + +def test_update_scenario_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'portfolioId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + mocker.patch.object(DB, 'update', return_value={}) + assert '403' in client.put('/api/v2/scenarios/1', json={ + 'scenario': { + 'name': 'test', + } + }).status + + +def test_update_scenario(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'portfolioId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }], + 'targets': { + 'enabledMetrics': [], + 'repeatsPerScenario': 1 + } + }) + mocker.patch.object(DB, 'update', return_value={}) + + res = client.put('/api/v2/scenarios/1', json={'scenario': { + 'name': 'test', + }}) + assert '200' in res.status + + +def test_delete_project_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.delete('/api/v2/scenarios/1').status + + +def test_delete_project_different_user(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'portfolioId': '1', + 'googleId': 'other_test', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + mocker.patch.object(DB, 'delete_one', return_value=None) + assert '403' in client.delete('/api/v2/scenarios/1').status + + +def test_delete_project(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'portfolioId': '1', + 'googleId': 'test', + 'scenarioIds': ['1'], + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'delete_one', return_value={}) + mocker.patch.object(DB, 'update', return_value=None) + res = client.delete('/api/v2/scenarios/1') + assert '200' in res.status diff --git a/api/opendc/api/v2/schedulers/__init__.py b/api/opendc/api/v2/schedulers/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/schedulers/__init__.py diff --git a/api/opendc/api/v2/schedulers/endpoint.py b/api/opendc/api/v2/schedulers/endpoint.py new file mode 100644 index 00000000..a96fdd88 --- /dev/null +++ b/api/opendc/api/v2/schedulers/endpoint.py @@ -0,0 +1,9 @@ +from opendc.util.rest import Response + +SCHEDULERS = ['DEFAULT'] + + +def GET(_): + """Get all available Schedulers.""" + + return Response(200, 'Successfully retrieved Schedulers.', [{'name': name} for name in SCHEDULERS]) diff --git a/api/opendc/api/v2/schedulers/test_endpoint.py b/api/opendc/api/v2/schedulers/test_endpoint.py new file mode 100644 index 00000000..a0bd8758 --- /dev/null +++ b/api/opendc/api/v2/schedulers/test_endpoint.py @@ -0,0 +1,2 @@ +def test_get_schedulers(client): + assert '200' in client.get('/api/v2/schedulers').status diff --git a/api/opendc/api/v2/topologies/__init__.py b/api/opendc/api/v2/topologies/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/topologies/__init__.py diff --git a/api/opendc/api/v2/topologies/topologyId/__init__.py b/api/opendc/api/v2/topologies/topologyId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/topologies/topologyId/__init__.py diff --git a/api/opendc/api/v2/topologies/topologyId/endpoint.py b/api/opendc/api/v2/topologies/topologyId/endpoint.py new file mode 100644 index 00000000..512b050a --- /dev/null +++ b/api/opendc/api/v2/topologies/topologyId/endpoint.py @@ -0,0 +1,56 @@ +from datetime import datetime + +from opendc.util.database import Database +from opendc.models.project import Project +from opendc.models.topology import Topology +from opendc.util.rest import Response + + +def GET(request): + """Get this Topology.""" + + request.check_required_parameters(path={'topologyId': 'string'}) + + topology = Topology.from_id(request.params_path['topologyId']) + + topology.check_exists() + topology.check_user_access(request.google_id, False) + + return Response(200, 'Successfully retrieved topology.', topology.obj) + + +def PUT(request): + """Update this topology""" + request.check_required_parameters(path={'topologyId': 'string'}, body={'topology': {'name': 'string', 'rooms': {}}}) + topology = Topology.from_id(request.params_path['topologyId']) + + topology.check_exists() + topology.check_user_access(request.google_id, True) + + topology.set_property('name', request.params_body['topology']['name']) + topology.set_property('rooms', request.params_body['topology']['rooms']) + topology.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now())) + + topology.update() + + return Response(200, 'Successfully updated topology.', topology.obj) + + +def DELETE(request): + """Delete this topology""" + request.check_required_parameters(path={'topologyId': 'string'}) + + topology = Topology.from_id(request.params_path['topologyId']) + + topology.check_exists() + topology.check_user_access(request.google_id, True) + + project = Project.from_id(topology.obj['projectId']) + project.check_exists() + if request.params_path['topologyId'] in project.obj['topologyIds']: + project.obj['topologyIds'].remove(request.params_path['topologyId']) + project.update() + + old_object = topology.delete() + + return Response(200, 'Successfully deleted topology.', old_object) diff --git a/api/opendc/api/v2/topologies/topologyId/test_endpoint.py b/api/opendc/api/v2/topologies/topologyId/test_endpoint.py new file mode 100644 index 00000000..b25cb798 --- /dev/null +++ b/api/opendc/api/v2/topologies/topologyId/test_endpoint.py @@ -0,0 +1,116 @@ +from opendc.util.database import DB + + +def test_get_topology(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'EDIT' + }] + }) + res = client.get('/api/v2/topologies/1') + assert '200' in res.status + + +def test_get_topology_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/topologies/1').status + + +def test_get_topology_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '2', + 'authorizationLevel': 'OWN' + }] + }) + res = client.get('/api/v2/topologies/1') + assert '403' in res.status + + +def test_get_topology_no_authorizations(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'projectId': '1', 'authorizations': []}) + res = client.get('/api/v2/topologies/1') + assert '403' in res.status + + +def test_update_topology_missing_parameter(client): + assert '400' in client.put('/api/v2/topologies/1').status + + +def test_update_topology_non_existent(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.put('/api/v2/topologies/1', json={'topology': {'name': 'test_topology', 'rooms': {}}}).status + + +def test_update_topology_not_authorized(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'VIEW' + }] + }) + mocker.patch.object(DB, 'update', return_value={}) + assert '403' in client.put('/api/v2/topologies/1', json={ + 'topology': { + 'name': 'updated_topology', + 'rooms': {} + } + }).status + + +def test_update_topology(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'update', return_value={}) + + assert '200' in client.put('/api/v2/topologies/1', json={ + 'topology': { + 'name': 'updated_topology', + 'rooms': {} + } + }).status + + +def test_delete_topology(client, mocker): + mocker.patch.object(DB, + 'fetch_one', + return_value={ + '_id': '1', + 'projectId': '1', + 'googleId': 'test', + 'topologyIds': ['1'], + 'authorizations': [{ + 'projectId': '1', + 'authorizationLevel': 'OWN' + }] + }) + mocker.patch.object(DB, 'delete_one', return_value={}) + mocker.patch.object(DB, 'update', return_value=None) + res = client.delete('/api/v2/topologies/1') + assert '200' in res.status + + +def test_delete_nonexistent_topology(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.delete('/api/v2/topologies/1').status diff --git a/api/opendc/api/v2/traces/__init__.py b/api/opendc/api/v2/traces/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/traces/__init__.py diff --git a/api/opendc/api/v2/traces/endpoint.py b/api/opendc/api/v2/traces/endpoint.py new file mode 100644 index 00000000..ee699e02 --- /dev/null +++ b/api/opendc/api/v2/traces/endpoint.py @@ -0,0 +1,10 @@ +from opendc.models.trace import Trace +from opendc.util.rest import Response + + +def GET(_): + """Get all available Traces.""" + + traces = Trace.get_all() + + return Response(200, 'Successfully retrieved Traces', traces.obj) diff --git a/api/opendc/api/v2/traces/test_endpoint.py b/api/opendc/api/v2/traces/test_endpoint.py new file mode 100644 index 00000000..9f806085 --- /dev/null +++ b/api/opendc/api/v2/traces/test_endpoint.py @@ -0,0 +1,6 @@ +from opendc.util.database import DB + + +def test_get_traces(client, mocker): + mocker.patch.object(DB, 'fetch_all', return_value=[]) + assert '200' in client.get('/api/v2/traces').status diff --git a/api/opendc/api/v2/traces/traceId/__init__.py b/api/opendc/api/v2/traces/traceId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/traces/traceId/__init__.py diff --git a/api/opendc/api/v2/traces/traceId/endpoint.py b/api/opendc/api/v2/traces/traceId/endpoint.py new file mode 100644 index 00000000..670f88d1 --- /dev/null +++ b/api/opendc/api/v2/traces/traceId/endpoint.py @@ -0,0 +1,14 @@ +from opendc.models.trace import Trace +from opendc.util.rest import Response + + +def GET(request): + """Get this Trace.""" + + request.check_required_parameters(path={'traceId': 'string'}) + + trace = Trace.from_id(request.params_path['traceId']) + + trace.check_exists() + + return Response(200, 'Successfully retrieved trace.', trace.obj) diff --git a/api/opendc/api/v2/traces/traceId/test_endpoint.py b/api/opendc/api/v2/traces/traceId/test_endpoint.py new file mode 100644 index 00000000..56792ca9 --- /dev/null +++ b/api/opendc/api/v2/traces/traceId/test_endpoint.py @@ -0,0 +1,13 @@ +from opendc.util.database import DB + + +def test_get_trace_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/traces/1').status + + +def test_get_trace(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'name': 'test trace'}) + res = client.get('/api/v2/traces/1') + assert 'name' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/api/v2/users/__init__.py b/api/opendc/api/v2/users/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/users/__init__.py diff --git a/api/opendc/api/v2/users/endpoint.py b/api/opendc/api/v2/users/endpoint.py new file mode 100644 index 00000000..0dcf2463 --- /dev/null +++ b/api/opendc/api/v2/users/endpoint.py @@ -0,0 +1,30 @@ +from opendc.models.user import User +from opendc.util.rest import Response + + +def GET(request): + """Search for a User using their email address.""" + + request.check_required_parameters(query={'email': 'string'}) + + user = User.from_email(request.params_query['email']) + + user.check_exists() + + return Response(200, 'Successfully retrieved user.', user.obj) + + +def POST(request): + """Add a new User.""" + + request.check_required_parameters(body={'user': {'email': 'string'}}) + + user = User(request.params_body['user']) + user.set_property('googleId', request.google_id) + user.set_property('authorizations', []) + + user.check_already_exists() + + user.insert() + + return Response(200, 'Successfully created user.', user.obj) diff --git a/api/opendc/api/v2/users/test_endpoint.py b/api/opendc/api/v2/users/test_endpoint.py new file mode 100644 index 00000000..d60429b3 --- /dev/null +++ b/api/opendc/api/v2/users/test_endpoint.py @@ -0,0 +1,34 @@ +from opendc.util.database import DB + + +def test_get_user_by_email_missing_parameter(client): + assert '400' in client.get('/api/v2/users').status + + +def test_get_user_by_email_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/users?email=test@test.com').status + + +def test_get_user_by_email(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'}) + res = client.get('/api/v2/users?email=test@test.com') + assert 'email' in res.json['content'] + assert '200' in res.status + + +def test_add_user_missing_parameter(client): + assert '400' in client.post('/api/v2/users').status + + +def test_add_user_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'}) + assert '409' in client.post('/api/v2/users', json={'user': {'email': 'test@test.com'}}).status + + +def test_add_user(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + mocker.patch.object(DB, 'insert', return_value={'email': 'test@test.com'}) + res = client.post('/api/v2/users', json={'user': {'email': 'test@test.com'}}) + assert 'email' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/api/v2/users/userId/__init__.py b/api/opendc/api/v2/users/userId/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/api/v2/users/userId/__init__.py diff --git a/api/opendc/api/v2/users/userId/endpoint.py b/api/opendc/api/v2/users/userId/endpoint.py new file mode 100644 index 00000000..be3462c0 --- /dev/null +++ b/api/opendc/api/v2/users/userId/endpoint.py @@ -0,0 +1,59 @@ +from opendc.models.project import Project +from opendc.models.user import User +from opendc.util.rest import Response + + +def GET(request): + """Get this User.""" + + request.check_required_parameters(path={'userId': 'string'}) + + user = User.from_id(request.params_path['userId']) + + user.check_exists() + + return Response(200, 'Successfully retrieved user.', user.obj) + + +def PUT(request): + """Update this User's given name and/or family name.""" + + request.check_required_parameters(body={'user': { + 'givenName': 'string', + 'familyName': 'string' + }}, + path={'userId': 'string'}) + + user = User.from_id(request.params_path['userId']) + + user.check_exists() + user.check_correct_user(request.google_id) + + user.set_property('givenName', request.params_body['user']['givenName']) + user.set_property('familyName', request.params_body['user']['familyName']) + + user.update() + + return Response(200, 'Successfully updated user.', user.obj) + + +def DELETE(request): + """Delete this User.""" + + request.check_required_parameters(path={'userId': 'string'}) + + user = User.from_id(request.params_path['userId']) + + user.check_exists() + user.check_correct_user(request.google_id) + + for authorization in user.obj['authorizations']: + if authorization['authorizationLevel'] != 'OWN': + continue + + project = Project.from_id(authorization['projectId']) + project.delete() + + old_object = user.delete() + + return Response(200, 'Successfully deleted user.', old_object) diff --git a/api/opendc/api/v2/users/userId/test_endpoint.py b/api/opendc/api/v2/users/userId/test_endpoint.py new file mode 100644 index 00000000..cdff2229 --- /dev/null +++ b/api/opendc/api/v2/users/userId/test_endpoint.py @@ -0,0 +1,53 @@ +from opendc.util.database import DB + + +def test_get_user_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.get('/api/v2/users/1').status + + +def test_get_user(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'}) + res = client.get('/api/v2/users/1') + assert 'email' in res.json['content'] + assert '200' in res.status + + +def test_update_user_missing_parameter(client): + assert '400' in client.put('/api/v2/users/1').status + + +def test_update_user_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.put('/api/v2/users/1', json={'user': {'givenName': 'A', 'familyName': 'B'}}).status + + +def test_update_user_different_user(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'_id': '1', 'googleId': 'other_test'}) + assert '403' in client.put('/api/v2/users/1', json={'user': {'givenName': 'A', 'familyName': 'B'}}).status + + +def test_update_user(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'_id': '1', 'googleId': 'test'}) + mocker.patch.object(DB, 'update', return_value={'givenName': 'A', 'familyName': 'B'}) + res = client.put('/api/v2/users/1', json={'user': {'givenName': 'A', 'familyName': 'B'}}) + assert 'givenName' in res.json['content'] + assert '200' in res.status + + +def test_delete_user_non_existing(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value=None) + assert '404' in client.delete('/api/v2/users/1').status + + +def test_delete_user_different_user(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'_id': '1', 'googleId': 'other_test'}) + assert '403' in client.delete('/api/v2/users/1').status + + +def test_delete_user(client, mocker): + mocker.patch.object(DB, 'fetch_one', return_value={'_id': '1', 'googleId': 'test', 'authorizations': []}) + mocker.patch.object(DB, 'delete_one', return_value={'googleId': 'test'}) + res = client.delete('/api/v2/users/1') + assert 'googleId' in res.json['content'] + assert '200' in res.status diff --git a/api/opendc/models/__init__.py b/api/opendc/models/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/models/__init__.py diff --git a/api/opendc/models/model.py b/api/opendc/models/model.py new file mode 100644 index 00000000..bcb833ae --- /dev/null +++ b/api/opendc/models/model.py @@ -0,0 +1,59 @@ +from uuid import uuid4 + +from opendc.util.database import DB +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class Model: + """Base class for all models.""" + + collection_name = '<specified in subclasses>' + + @classmethod + def from_id(cls, _id): + """Fetches the document with given ID from the collection.""" + return cls(DB.fetch_one({'_id': _id}, cls.collection_name)) + + @classmethod + def get_all(cls): + """Fetches all documents from the collection.""" + return cls(DB.fetch_all({}, cls.collection_name)) + + def __init__(self, obj): + self.obj = obj + + def get_id(self): + """Returns the ID of the enclosed object.""" + return str(self.obj['_id']) + + def check_exists(self): + """Raises an error if the enclosed object does not exist.""" + if self.obj is None: + raise ClientError(Response(404, 'Not found.')) + + def set_property(self, key, value): + """Sets the given property on the enclosed object, with support for simple nested access.""" + if '.' in key: + keys = key.split('.') + self.obj[keys[0]][keys[1]] = value + else: + self.obj[key] = value + + def insert(self): + """Inserts the enclosed object and generates a UUID for it.""" + self.obj['_id'] = str(uuid4()) + DB.insert(self.obj, self.collection_name) + + def update(self): + """Updates the enclosed object and updates the internal reference to the newly inserted object.""" + DB.update(self.get_id(), self.obj, self.collection_name) + + def delete(self): + """Deletes the enclosed object in the database, if it existed.""" + if self.obj is None: + return None + + old_object = self.obj.copy() + DB.delete_one({'_id': self.get_id()}, self.collection_name) + return old_object diff --git a/api/opendc/models/portfolio.py b/api/opendc/models/portfolio.py new file mode 100644 index 00000000..32961b63 --- /dev/null +++ b/api/opendc/models/portfolio.py @@ -0,0 +1,24 @@ +from opendc.models.model import Model +from opendc.models.user import User +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class Portfolio(Model): + """Model representing a Portfolio.""" + + collection_name = 'portfolios' + + def check_user_access(self, google_id, edit_access): + """Raises an error if the user with given [google_id] has insufficient access. + + Checks access on the parent project. + + :param google_id: The Google ID of the user. + :param edit_access: True when edit access should be checked, otherwise view access. + """ + user = User.from_google_id(google_id) + authorizations = list( + filter(lambda x: str(x['projectId']) == str(self.obj['projectId']), user.obj['authorizations'])) + if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'): + raise ClientError(Response(403, 'Forbidden from retrieving/editing portfolio.')) diff --git a/api/opendc/models/prefab.py b/api/opendc/models/prefab.py new file mode 100644 index 00000000..70910c4a --- /dev/null +++ b/api/opendc/models/prefab.py @@ -0,0 +1,26 @@ +from opendc.models.model import Model +from opendc.models.user import User +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class Prefab(Model): + """Model representing a Project.""" + + collection_name = 'prefabs' + + def check_user_access(self, google_id): + """Raises an error if the user with given [google_id] has insufficient access to view this prefab. + + :param google_id: The Google ID of the user. + """ + user = User.from_google_id(google_id) + + #try: + + print(self.obj) + if self.obj['authorId'] != user.get_id() and self.obj['visibility'] == "private": + raise ClientError(Response(403, "Forbidden from retrieving prefab.")) + #except KeyError: + # OpenDC-authored objects don't necessarily have an authorId + # return diff --git a/api/opendc/models/project.py b/api/opendc/models/project.py new file mode 100644 index 00000000..b57e9f77 --- /dev/null +++ b/api/opendc/models/project.py @@ -0,0 +1,31 @@ +from opendc.models.model import Model +from opendc.models.user import User +from opendc.util.database import DB +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class Project(Model): + """Model representing a Project.""" + + collection_name = 'projects' + + def check_user_access(self, google_id, edit_access): + """Raises an error if the user with given [google_id] has insufficient access. + + :param google_id: The Google ID of the user. + :param edit_access: True when edit access should be checked, otherwise view access. + """ + user = User.from_google_id(google_id) + authorizations = list(filter(lambda x: str(x['projectId']) == str(self.get_id()), + user.obj['authorizations'])) + if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'): + raise ClientError(Response(403, "Forbidden from retrieving project.")) + + def get_all_authorizations(self): + """Get all user IDs having access to this project.""" + return [ + str(user['_id']) for user in DB.fetch_all({'authorizations': { + 'projectId': self.obj['_id'] + }}, User.collection_name) + ] diff --git a/api/opendc/models/scenario.py b/api/opendc/models/scenario.py new file mode 100644 index 00000000..8d53e408 --- /dev/null +++ b/api/opendc/models/scenario.py @@ -0,0 +1,26 @@ +from opendc.models.model import Model +from opendc.models.portfolio import Portfolio +from opendc.models.user import User +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class Scenario(Model): + """Model representing a Scenario.""" + + collection_name = 'scenarios' + + def check_user_access(self, google_id, edit_access): + """Raises an error if the user with given [google_id] has insufficient access. + + Checks access on the parent project. + + :param google_id: The Google ID of the user. + :param edit_access: True when edit access should be checked, otherwise view access. + """ + portfolio = Portfolio.from_id(self.obj['portfolioId']) + user = User.from_google_id(google_id) + authorizations = list( + filter(lambda x: str(x['projectId']) == str(portfolio.obj['projectId']), user.obj['authorizations'])) + if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'): + raise ClientError(Response(403, 'Forbidden from retrieving/editing scenario.')) diff --git a/api/opendc/models/topology.py b/api/opendc/models/topology.py new file mode 100644 index 00000000..cb4c4bab --- /dev/null +++ b/api/opendc/models/topology.py @@ -0,0 +1,27 @@ +from opendc.models.model import Model +from opendc.models.user import User +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class Topology(Model): + """Model representing a Project.""" + + collection_name = 'topologies' + + def check_user_access(self, google_id, edit_access): + """Raises an error if the user with given [google_id] has insufficient access. + + Checks access on the parent project. + + :param google_id: The Google ID of the user. + :param edit_access: True when edit access should be checked, otherwise view access. + """ + user = User.from_google_id(google_id) + if 'projectId' not in self.obj: + raise ClientError(Response(400, 'Missing projectId in topology.')) + + authorizations = list( + filter(lambda x: str(x['projectId']) == str(self.obj['projectId']), user.obj['authorizations'])) + if len(authorizations) == 0 or (edit_access and authorizations[0]['authorizationLevel'] == 'VIEW'): + raise ClientError(Response(403, 'Forbidden from retrieving topology.')) diff --git a/api/opendc/models/trace.py b/api/opendc/models/trace.py new file mode 100644 index 00000000..2f6e4926 --- /dev/null +++ b/api/opendc/models/trace.py @@ -0,0 +1,7 @@ +from opendc.models.model import Model + + +class Trace(Model): + """Model representing a Trace.""" + + collection_name = 'traces' diff --git a/api/opendc/models/user.py b/api/opendc/models/user.py new file mode 100644 index 00000000..8e8ff945 --- /dev/null +++ b/api/opendc/models/user.py @@ -0,0 +1,36 @@ +from opendc.models.model import Model +from opendc.util.database import DB +from opendc.util.exceptions import ClientError +from opendc.util.rest import Response + + +class User(Model): + """Model representing a User.""" + + collection_name = 'users' + + @classmethod + def from_email(cls, email): + """Fetches the user with given email from the collection.""" + return User(DB.fetch_one({'email': email}, User.collection_name)) + + @classmethod + def from_google_id(cls, google_id): + """Fetches the user with given Google ID from the collection.""" + return User(DB.fetch_one({'googleId': google_id}, User.collection_name)) + + def check_correct_user(self, request_google_id): + """Raises an error if a user tries to modify another user. + + :param request_google_id: + """ + if request_google_id is not None and self.obj['googleId'] != request_google_id: + raise ClientError(Response(403, f'Forbidden from editing user with ID {self.obj["_id"]}.')) + + def check_already_exists(self): + """Checks if the user already exists in the database.""" + + existing_user = DB.fetch_one({'googleId': self.obj['googleId']}, self.collection_name) + + if existing_user is not None: + raise ClientError(Response(409, 'User already exists.')) diff --git a/api/opendc/util/__init__.py b/api/opendc/util/__init__.py new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/api/opendc/util/__init__.py diff --git a/api/opendc/util/database.py b/api/opendc/util/database.py new file mode 100644 index 00000000..80cdcbab --- /dev/null +++ b/api/opendc/util/database.py @@ -0,0 +1,92 @@ +import json +import urllib.parse +from datetime import datetime + +from bson.json_util import dumps +from pymongo import MongoClient + +DATETIME_STRING_FORMAT = '%Y-%m-%dT%H:%M:%S' +CONNECTION_POOL = None + + +class Database: + """Object holding functionality for database access.""" + def __init__(self): + self.opendc_db = None + + def initialize_database(self, user, password, database, host): + """Initializes the database connection.""" + + user = urllib.parse.quote_plus(user) + password = urllib.parse.quote_plus(password) + database = urllib.parse.quote_plus(database) + host = urllib.parse.quote_plus(host) + + client = MongoClient('mongodb://%s:%s@%s/default_db?authSource=%s' % (user, password, host, database)) + self.opendc_db = client.opendc + + def fetch_one(self, query, collection): + """Uses existing mongo connection to return a single (the first) document in a collection matching the given + query as a JSON object. + + The query needs to be in json format, i.e.: `{'name': prefab_name}`. + """ + bson = getattr(self.opendc_db, collection).find_one(query) + + return self.convert_bson_to_json(bson) + + def fetch_all(self, query, collection): + """Uses existing mongo connection to return all documents matching a given query, as a list of JSON objects. + + The query needs to be in json format, i.e.: `{'name': prefab_name}`. + """ + results = [] + cursor = getattr(self.opendc_db, collection).find(query) + for doc in cursor: + results.append(self.convert_bson_to_json(doc)) + return results + + def insert(self, obj, collection): + """Updates an existing object.""" + bson = getattr(self.opendc_db, collection).insert(obj) + + return self.convert_bson_to_json(bson) + + def update(self, _id, obj, collection): + """Updates an existing object.""" + bson = getattr(self.opendc_db, collection).update({'_id': _id}, obj) + + return self.convert_bson_to_json(bson) + + def delete_one(self, query, collection): + """Deletes one object matching the given query. + + The query needs to be in json format, i.e.: `{'name': prefab_name}`. + """ + getattr(self.opendc_db, collection).delete_one(query) + + def delete_all(self, query, collection): + """Deletes all objects matching the given query. + + The query needs to be in json format, i.e.: `{'name': prefab_name}`. + """ + getattr(self.opendc_db, collection).delete_many(query) + + @staticmethod + def convert_bson_to_json(bson): + """Converts a BSON representation to JSON and returns the JSON representation.""" + json_string = dumps(bson) + return json.loads(json_string) + + @staticmethod + def datetime_to_string(datetime_to_convert): + """Return a database-compatible string representation of the given datetime object.""" + return datetime_to_convert.strftime(DATETIME_STRING_FORMAT) + + @staticmethod + def string_to_datetime(string_to_convert): + """Return a datetime corresponding to the given string representation.""" + return datetime.strptime(string_to_convert, DATETIME_STRING_FORMAT) + + +DB = Database() diff --git a/api/opendc/util/exceptions.py b/api/opendc/util/exceptions.py new file mode 100644 index 00000000..7724a407 --- /dev/null +++ b/api/opendc/util/exceptions.py @@ -0,0 +1,64 @@ +class RequestInitializationError(Exception): + """Raised when a Request cannot successfully be initialized""" + + +class UnimplementedEndpointError(RequestInitializationError): + """Raised when a Request path does not point to a module.""" + + +class MissingRequestParameterError(RequestInitializationError): + """Raised when a Request does not contain one or more required parameters.""" + + +class UnsupportedMethodError(RequestInitializationError): + """Raised when a Request does not use a supported REST method. + + The method must be in all-caps, supported by REST, and implemented by the module. + """ + + +class AuthorizationTokenError(RequestInitializationError): + """Raised when an authorization token is not correctly verified.""" + + +class ForeignKeyError(Exception): + """Raised when a foreign key constraint is not met.""" + + +class RowNotFoundError(Exception): + """Raised when a database row is not found.""" + def __init__(self, table_name): + super(RowNotFoundError, self).__init__('Row in `{}` table not found.'.format(table_name)) + + self.table_name = table_name + + +class ParameterError(Exception): + """Raised when a parameter is either missing or incorrectly typed.""" + + +class IncorrectParameterError(ParameterError): + """Raised when a parameter is of the wrong type.""" + def __init__(self, parameter_name, parameter_location): + super(IncorrectParameterError, + self).__init__('Incorrectly typed `{}` {} parameter.'.format(parameter_name, parameter_location)) + + self.parameter_name = parameter_name + self.parameter_location = parameter_location + + +class MissingParameterError(ParameterError): + """Raised when a parameter is missing.""" + def __init__(self, parameter_name, parameter_location): + super(MissingParameterError, + self).__init__('Missing required `{}` {} parameter.'.format(parameter_name, parameter_location)) + + self.parameter_name = parameter_name + self.parameter_location = parameter_location + + +class ClientError(Exception): + """Raised when a 4xx response is to be returned.""" + def __init__(self, response): + super(ClientError, self).__init__(str(response)) + self.response = response diff --git a/api/opendc/util/parameter_checker.py b/api/opendc/util/parameter_checker.py new file mode 100644 index 00000000..14dd1dc0 --- /dev/null +++ b/api/opendc/util/parameter_checker.py @@ -0,0 +1,85 @@ +from opendc.util import exceptions +from opendc.util.database import Database + + +def _missing_parameter(params_required, params_actual, parent=''): + """Recursively search for the first missing parameter.""" + + for param_name in params_required: + + if param_name not in params_actual: + return '{}.{}'.format(parent, param_name) + + param_required = params_required.get(param_name) + param_actual = params_actual.get(param_name) + + if isinstance(param_required, dict): + + param_missing = _missing_parameter(param_required, param_actual, param_name) + + if param_missing is not None: + return '{}.{}'.format(parent, param_missing) + + return None + + +def _incorrect_parameter(params_required, params_actual, parent=''): + """Recursively make sure each parameter is of the correct type.""" + + for param_name in params_required: + + param_required = params_required.get(param_name) + param_actual = params_actual.get(param_name) + + if isinstance(param_required, dict): + + param_incorrect = _incorrect_parameter(param_required, param_actual, param_name) + + if param_incorrect is not None: + return '{}.{}'.format(parent, param_incorrect) + + else: + + if param_required == 'datetime': + try: + Database.string_to_datetime(param_actual) + except: + return '{}.{}'.format(parent, param_name) + + type_pairs = [ + ('int', (int,)), + ('float', (float, int)), + ('bool', (bool,)), + ('string', (str, int)), + ('list', (list,)), + ] + + for str_type, actual_types in type_pairs: + if param_required == str_type and all(not isinstance(param_actual, t) + for t in actual_types): + return '{}.{}'.format(parent, param_name) + + return None + + +def _format_parameter(parameter): + """Format the output of a parameter check.""" + + parts = parameter.split('.') + inner = ['["{}"]'.format(x) for x in parts[2:]] + return parts[1] + ''.join(inner) + + +def check(request, **kwargs): + """Check if all required parameters are there.""" + + for location, params_required in kwargs.items(): + params_actual = getattr(request, 'params_{}'.format(location)) + + missing_parameter = _missing_parameter(params_required, params_actual) + if missing_parameter is not None: + raise exceptions.MissingParameterError(_format_parameter(missing_parameter), location) + + incorrect_parameter = _incorrect_parameter(params_required, params_actual) + if incorrect_parameter is not None: + raise exceptions.IncorrectParameterError(_format_parameter(incorrect_parameter), location) diff --git a/api/opendc/util/path_parser.py b/api/opendc/util/path_parser.py new file mode 100644 index 00000000..a8bbdeba --- /dev/null +++ b/api/opendc/util/path_parser.py @@ -0,0 +1,39 @@ +import json +import os + + +def parse(version, endpoint_path): + """Map an HTTP endpoint path to an API path""" + + # Get possible paths + with open(os.path.join(os.path.dirname(__file__), '..', 'api', '{}', 'paths.json').format(version)) as paths_file: + paths = json.load(paths_file) + + # Find API path that matches endpoint_path + endpoint_path_parts = endpoint_path.strip('/').split('/') + paths_parts = [x.strip('/').split('/') for x in paths if len(x.strip('/').split('/')) == len(endpoint_path_parts)] + path = None + + for path_parts in paths_parts: + found = True + for (endpoint_part, part) in zip(endpoint_path_parts, path_parts): + if not part.startswith('{') and endpoint_part != part: + found = False + break + if found: + path = path_parts + + if path is None: + return None + + # Extract path parameters + parameters = {} + + for (name, value) in zip(path, endpoint_path_parts): + if name.startswith('{'): + try: + parameters[name.strip('{}')] = int(value) + except: + parameters[name.strip('{}')] = value + + return '{}/{}'.format(version, '/'.join(path)), parameters diff --git a/api/opendc/util/rest.py b/api/opendc/util/rest.py new file mode 100644 index 00000000..abd2f3de --- /dev/null +++ b/api/opendc/util/rest.py @@ -0,0 +1,141 @@ +import importlib +import json +import os + +from oauth2client import client, crypt + +from opendc.util import exceptions, parameter_checker +from opendc.util.exceptions import ClientError + + +class Request: + """WebSocket message to REST request mapping.""" + def __init__(self, message=None): + """"Initialize a Request from a socket message.""" + + # Get the Request parameters from the message + + if message is None: + return + + try: + self.message = message + + self.id = message['id'] + + self.path = message['path'] + self.method = message['method'] + + self.params_body = message['parameters']['body'] + self.params_path = message['parameters']['path'] + self.params_query = message['parameters']['query'] + + self.token = message['token'] + + except KeyError as exception: + raise exceptions.MissingRequestParameterError(exception) + + # Parse the path and import the appropriate module + + try: + self.path = message['path'].strip('/') + + module_base = 'opendc.api.{}.endpoint' + module_path = self.path.replace('{', '').replace('}', '').replace('/', '.') + + self.module = importlib.import_module(module_base.format(module_path)) + except ImportError as e: + print(e) + raise exceptions.UnimplementedEndpointError('Unimplemented endpoint: {}.'.format(self.path)) + + # Check the method + + if self.method not in ['POST', 'GET', 'PUT', 'PATCH', 'DELETE']: + raise exceptions.UnsupportedMethodError('Non-rest method: {}'.format(self.method)) + + if not hasattr(self.module, self.method): + raise exceptions.UnsupportedMethodError('Unimplemented method at endpoint {}: {}'.format( + self.path, self.method)) + + # Verify the user + + if "OPENDC_FLASK_TESTING" in os.environ: + self.google_id = 'test' + return + + try: + self.google_id = self._verify_token(self.token) + except crypt.AppIdentityError as e: + raise exceptions.AuthorizationTokenError(e) + + def check_required_parameters(self, **kwargs): + """Raise an error if a parameter is missing or of the wrong type.""" + + try: + parameter_checker.check(self, **kwargs) + except exceptions.ParameterError as e: + raise ClientError(Response(400, str(e))) + + def process(self): + """Process the Request and return a Response.""" + + method = getattr(self.module, self.method) + + try: + response = method(self) + except ClientError as e: + e.response.id = self.id + return e.response + + response.id = self.id + + return response + + def to_JSON(self): + """Return a JSON representation of this Request""" + + self.message['id'] = 0 + self.message['token'] = None + + return json.dumps(self.message) + + @staticmethod + def _verify_token(token): + """Return the ID of the signed-in user. + + Or throw an Exception if the token is invalid. + """ + + try: + id_info = client.verify_id_token(token, os.environ['OPENDC_OAUTH_CLIENT_ID']) + except Exception as e: + print(e) + raise crypt.AppIdentityError('Exception caught trying to verify ID token: {}'.format(e)) + + if id_info['aud'] != os.environ['OPENDC_OAUTH_CLIENT_ID']: + raise crypt.AppIdentityError('Unrecognized client.') + + if id_info['iss'] not in ['accounts.google.com', 'https://accounts.google.com']: + raise crypt.AppIdentityError('Wrong issuer.') + + return id_info['sub'] + + +class Response: + """Response to websocket mapping""" + def __init__(self, status_code, status_description, content=None): + """Initialize a new Response.""" + + self.id = 0 + self.status = {'code': status_code, 'description': status_description} + self.content = content + + def to_JSON(self): + """"Return a JSON representation of this Response""" + + data = {'id': self.id, 'status': self.status} + + if self.content is not None: + data['content'] = self.content + + return json.dumps(data) diff --git a/api/pytest.ini b/api/pytest.ini new file mode 100644 index 00000000..775a8ff4 --- /dev/null +++ b/api/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +env = + OPENDC_FLASK_TESTING=True + OPENDC_FLASK_SECRET=Secret + OPENDC_SERVER_BASE_URL=localhost diff --git a/api/requirements.txt b/api/requirements.txt new file mode 100644 index 00000000..140a046f --- /dev/null +++ b/api/requirements.txt @@ -0,0 +1,15 @@ +flask==1.1.2 +flask-socketio==4.3.0 +oauth2client==4.1.3 +eventlet==0.25.2 +flask-compress==1.5.0 +flask-cors==3.0.8 +pyasn1-modules==0.2.8 +six==1.15.0 +pymongo==3.10.1 +yapf==0.30.0 +pytest==5.4.3 +pytest-mock==3.1.1 +pytest-env==0.6.2 +pylint==2.5.3 +python-dotenv==0.13.0 diff --git a/api/static/index.html b/api/static/index.html new file mode 100644 index 00000000..ac78cbfb --- /dev/null +++ b/api/static/index.html @@ -0,0 +1,22 @@ +<script src="https://apis.google.com/js/platform.js" async defer></script> +<meta name="google-signin-client_id" content="561588943542-fq7065hk47qdf3lfsc50ebll4spi6u76.apps.googleusercontent.com"> +<script type="text/javascript" src="//cdnjs.cloudflare.com/ajax/libs/socket.io/1.3.6/socket.io.min.js"></script> +<script> + function onSignIn(googleUser) { + document.getElementById('token').innerText = googleUser.getAuthResponse().id_token; + } +</script> +<script> + function signOut() { + var auth2 = gapi.auth2.getAuthInstance(); + auth2.signOut().then(function () { + console.log('User signed out.'); + }); + } +</script> + +<div class="g-signin2" data-onsuccess="onSignIn"></div> +<a href="#" onclick="signOut();">Sign out</a> + +<p>Your auth token:</p> +<p id="token" style="word-wrap:break-word;">Loading...</p>
\ No newline at end of file |
