summaryrefslogtreecommitdiff
path: root/opendc-web/opendc-web-api
diff options
context:
space:
mode:
Diffstat (limited to 'opendc-web/opendc-web-api')
-rw-r--r--opendc-web/opendc-web-api/.pylintrc3
-rw-r--r--opendc-web/opendc-web-api/Dockerfile12
-rw-r--r--opendc-web/opendc-web-api/README.md65
-rwxr-xr-xopendc-web/opendc-web-api/app.py133
-rw-r--r--opendc-web/opendc-web-api/conftest.py27
-rw-r--r--opendc-web/opendc-web-api/docs/component-diagram.png (renamed from opendc-web/opendc-web-api/misc/artwork/opendc-web-server-component-diagram.png)bin90161 -> 90161 bytes
-rwxr-xr-xopendc-web/opendc-web-api/main.py188
-rw-r--r--opendc-web/opendc-web-api/opendc/api/portfolios.py135
-rw-r--r--opendc-web/opendc-web-api/opendc/api/prefabs.py120
-rw-r--r--opendc-web/opendc-web-api/opendc/api/projects.py195
-rw-r--r--opendc-web/opendc-web-api/opendc/api/scenarios.py81
-rw-r--r--opendc-web/opendc-web-api/opendc/api/schedulers.py46
-rw-r--r--opendc-web/opendc-web-api/opendc/api/topologies.py93
-rw-r--r--opendc-web/opendc-web-api/opendc/api/traces.py51
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/paths.json19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py67
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py49
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py125
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py152
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py22
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py71
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py23
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py50
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py145
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py24
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py32
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py17
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py43
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py66
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py35
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py85
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py122
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py31
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py52
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py25
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py59
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py149
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py2
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py58
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py119
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py10
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py6
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py14
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py15
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py30
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py34
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py59
-rw-r--r--opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py56
-rw-r--r--opendc-web/opendc-web-api/opendc/auth.py240
-rw-r--r--opendc-web/opendc-web-api/opendc/database.py (renamed from opendc-web/opendc-web-api/opendc/util/database.py)41
-rw-r--r--opendc-web/opendc-web-api/opendc/exts.py60
-rw-r--r--opendc-web/opendc-web-api/opendc/models/model.py19
-rw-r--r--opendc-web/opendc-web-api/opendc/models/portfolio.py38
-rw-r--r--opendc-web/opendc-web-api/opendc/models/prefab.py40
-rw-r--r--opendc-web/opendc-web-api/opendc/models/project.py48
-rw-r--r--opendc-web/opendc-web-api/opendc/models/scenario.py60
-rw-r--r--opendc-web/opendc-web-api/opendc/models/topology.py96
-rw-r--r--opendc-web/opendc-web-api/opendc/models/user.py36
-rw-r--r--opendc-web/opendc-web-api/opendc/util.py32
-rw-r--r--opendc-web/opendc-web-api/opendc/util/__init__.py0
-rw-r--r--opendc-web/opendc-web-api/opendc/util/exceptions.py64
-rw-r--r--opendc-web/opendc-web-api/opendc/util/json.py12
-rw-r--r--opendc-web/opendc-web-api/opendc/util/parameter_checker.py85
-rw-r--r--opendc-web/opendc-web-api/opendc/util/path_parser.py36
-rw-r--r--opendc-web/opendc-web-api/opendc/util/rest.py141
-rw-r--r--opendc-web/opendc-web-api/requirements.txt6
-rw-r--r--opendc-web/opendc-web-api/static/index.html22
-rw-r--r--opendc-web/opendc-web-api/static/schema.yml1293
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_portfolios.py324
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_prefabs.py252
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_projects.py167
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_scenarios.py135
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_schedulers.py22
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_topologies.py140
-rw-r--r--opendc-web/opendc-web-api/tests/api/test_traces.py40
97 files changed, 3888 insertions, 2595 deletions
diff --git a/opendc-web/opendc-web-api/.pylintrc b/opendc-web/opendc-web-api/.pylintrc
index 7fe24187..4dbb0b50 100644
--- a/opendc-web/opendc-web-api/.pylintrc
+++ b/opendc-web/opendc-web-api/.pylintrc
@@ -65,7 +65,8 @@ disable=duplicate-code,
invalid-name,
bare-except,
too-few-public-methods,
- fixme
+ fixme,
+ no-self-use
# 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
diff --git a/opendc-web/opendc-web-api/Dockerfile b/opendc-web/opendc-web-api/Dockerfile
index 49702c90..505a69de 100644
--- a/opendc-web/opendc-web-api/Dockerfile
+++ b/opendc-web/opendc-web-api/Dockerfile
@@ -1,4 +1,4 @@
-FROM python:3.8
+FROM python:3.9-slim
MAINTAINER OpenDC Maintainers <opendc@atlarge-research.com>
# Ensure the STDOUT is not buffered by Python so that our logs become visible
@@ -9,9 +9,15 @@ ENV PYTHONUNBUFFERED 1
COPY ./ /opendc
# Fetch web server dependencies
-RUN pip install -r /opendc/requirements.txt
+RUN pip install -r /opendc/requirements.txt && pip install pyuwsgi
+
+# Create opendc user
+RUN groupadd --gid 1000 opendc \
+ && useradd --uid 1000 --gid opendc --shell /bin/bash --create-home opendc
+RUN chown -R opendc:opendc /opendc
+USER opendc
# Set working directory
WORKDIR /opendc
-CMD ["python3", "main.py"]
+CMD uwsgi -M --socket 0.0.0.0:80 --protocol=http --wsgi-file app.py --enable-threads --processes 2 --lazy-app
diff --git a/opendc-web/opendc-web-api/README.md b/opendc-web/opendc-web-api/README.md
index 4932f823..d1c469c1 100644
--- a/opendc-web/opendc-web-api/README.md
+++ b/opendc-web/opendc-web-api/README.md
@@ -9,15 +9,19 @@
<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).
+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)).
+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 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.
-![OpenDC Web Server Component Diagram](misc/artwork/opendc-web-server-component-diagram.png)
+![OpenDC Web Server Component Diagram](docs/component-diagram.png)
The OpenDC API is implemented by the `Main Server Loop`, which is the only component in the base package.
@@ -25,74 +29,91 @@ The OpenDC API is implemented by the `Main Server Loop`, which is the only compo
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.
+* `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`.
+* `REST`: Parses 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`).
+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.
+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.
+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.
+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`.
+The following steps will guide you through setting up the OpenDC web server locally for development.
### 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.
+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.
+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.
+Clone OpenDC and follow the [instructions from the deployment guide](../../docs/deploy.md) to set up an [Auth0](https://auth0.com)
+application and environment variables.
**Important:** Be sure to set up environment variables according to those instructions, in a `.env` file.
-If you want to test REST calls manually, add your own `OAUTH_CLIENT_ID` in `content=` on line `2` in `api/static/index.html`.
-
#### Set up the database
-You can selectively run only the database services from the standard OpenDC `docker-compose` setup (in the root directory):
+You can selectively run only the database services from the standard OpenDC `docker-compose` setup (in the root
+directory):
```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. Add the simulator images to the command lists above if you want to test simulation capabilities, as well.
+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. Add the simulator
+images to the command lists above if you want to test simulation capabilities, as well.
### Local Development
Run the server.
```bash
-cd api
-python main.py
+python3 -m flask run --port 8081
```
-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.
+When editing the web server code, restart the server (`CTRL` + `c` followed by `python app.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. The script uses `yapf` internally to format everything automatically.
+To format all files, run `format.sh` in this directory. The script uses `yapf` internally to format everything
+automatically.
To check if code style is up to modern standards, run `check.sh` in this directory. The script uses `pylint` internally.
diff --git a/opendc-web/opendc-web-api/app.py b/opendc-web/opendc-web-api/app.py
new file mode 100755
index 00000000..5916046b
--- /dev/null
+++ b/opendc-web/opendc-web-api/app.py
@@ -0,0 +1,133 @@
+#!/usr/bin/env python3
+import mimetypes
+import os
+
+from dotenv import load_dotenv
+from flask import Flask, jsonify, redirect
+from flask_compress import Compress
+from flask_cors import CORS
+from flask_restful import Api
+from flask_swagger_ui import get_swaggerui_blueprint
+from marshmallow import ValidationError
+
+from opendc.api.portfolios import Portfolio, PortfolioScenarios
+from opendc.api.prefabs import Prefab, PrefabList
+from opendc.api.projects import ProjectList, Project, ProjectTopologies, ProjectPortfolios
+from opendc.api.scenarios import Scenario
+from opendc.api.schedulers import SchedulerList
+from opendc.api.topologies import Topology
+from opendc.api.traces import TraceList, Trace
+from opendc.auth import AuthError
+from opendc.util import JSONEncoder
+
+
+# Load environmental variables from dotenv file
+load_dotenv()
+
+
+def setup_sentry():
+ """
+ Setup the Sentry integration for Flask if a DSN is supplied via the environmental variables.
+ """
+ if 'SENTRY_DSN' not in os.environ:
+ return
+
+ import sentry_sdk
+ from sentry_sdk.integrations.flask import FlaskIntegration
+
+ sentry_sdk.init(
+ integrations=[FlaskIntegration()],
+ traces_sample_rate=0.1
+ )
+
+
+def setup_api(app):
+ """
+ Setup the API interface.
+ """
+ api = Api(app)
+ # Map to ('string', 'ObjectId') passing type and format
+ api.add_resource(ProjectList, '/projects/')
+ api.add_resource(Project, '/projects/<string:project_id>')
+ api.add_resource(ProjectTopologies, '/projects/<string:project_id>/topologies')
+ api.add_resource(ProjectPortfolios, '/projects/<string:project_id>/portfolios')
+ api.add_resource(Topology, '/topologies/<string:topology_id>')
+ api.add_resource(PrefabList, '/prefabs/')
+ api.add_resource(Prefab, '/prefabs/<string:prefab_id>')
+ api.add_resource(Portfolio, '/portfolios/<string:portfolio_id>')
+ api.add_resource(PortfolioScenarios, '/portfolios/<string:portfolio_id>/scenarios')
+ api.add_resource(Scenario, '/scenarios/<string:scenario_id>')
+ api.add_resource(TraceList, '/traces/')
+ api.add_resource(Trace, '/traces/<string:trace_id>')
+ api.add_resource(SchedulerList, '/schedulers/')
+
+ @app.errorhandler(AuthError)
+ def handle_auth_error(ex):
+ response = jsonify(ex.error)
+ response.status_code = ex.status_code
+ return response
+
+ @app.errorhandler(ValidationError)
+ def handle_validation_error(ex):
+ return {'message': 'Input validation failed', 'errors': ex.messages}, 400
+
+ return api
+
+
+def setup_swagger(app):
+ """
+ Setup Swagger UI
+ """
+ SWAGGER_URL = '/docs'
+ API_URL = '../schema.yml'
+
+ swaggerui_blueprint = get_swaggerui_blueprint(
+ SWAGGER_URL,
+ API_URL,
+ config={
+ 'app_name': "OpenDC API v2"
+ },
+ oauth_config={
+ 'clientId': os.environ.get("AUTH0_DOCS_CLIENT_ID", ""),
+ }
+ )
+ app.register_blueprint(swaggerui_blueprint)
+
+
+def create_app(testing=False):
+ app = Flask(__name__, static_url_path='/')
+ app.config['TESTING'] = testing
+ app.config['SECRET_KEY'] = os.environ['OPENDC_FLASK_SECRET']
+ app.config['RESTFUL_JSON'] = {'cls': JSONEncoder}
+ app.json_encoder = JSONEncoder
+
+ # Define YAML content type
+ mimetypes.add_type('text/yaml', '.yml')
+
+ # Setup Sentry if DSN is specified
+ setup_sentry()
+
+ # Set up CORS support
+ CORS(app)
+
+ # Setup compression
+ compress = Compress()
+ compress.init_app(app)
+
+ setup_api(app)
+ setup_swagger(app)
+
+ @app.route('/')
+ def index():
+ """
+ Redirect the user to the API documentation if it accesses the API root.
+ """
+ return redirect('docs/')
+
+ return app
+
+
+application = create_app(testing="OPENDC_FLASK_TESTING" in os.environ)
+
+if __name__ == '__main__':
+ application.run()
diff --git a/opendc-web/opendc-web-api/conftest.py b/opendc-web/opendc-web-api/conftest.py
index 1f4831b8..430262f1 100644
--- a/opendc-web/opendc-web-api/conftest.py
+++ b/opendc-web/opendc-web-api/conftest.py
@@ -1,15 +1,34 @@
"""
Configuration file for all unit tests.
"""
+
+from functools import wraps
import pytest
+from flask import _request_ctx_stack, g
+from opendc.database import Database
+
-from main import FLASK_CORE_APP
+def decorator(f):
+ @wraps(f)
+ def decorated_function(*args, **kwargs):
+ _request_ctx_stack.top.current_user = {'sub': 'test'}
+ return f(*args, **kwargs)
+ return decorated_function
@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
+ # Disable authorization for test API endpoints
+ from opendc import exts
+ exts.requires_auth = decorator
+
+ from app import create_app
+
+ app = create_app(testing=True)
+
+ with app.app_context():
+ g.db = Database()
+ with app.test_client() as client:
+ yield client
diff --git a/opendc-web/opendc-web-api/misc/artwork/opendc-web-server-component-diagram.png b/opendc-web/opendc-web-api/docs/component-diagram.png
index 91b26006..91b26006 100644
--- a/opendc-web/opendc-web-api/misc/artwork/opendc-web-server-component-diagram.png
+++ b/opendc-web/opendc-web-api/docs/component-diagram.png
Binary files differ
diff --git a/opendc-web/opendc-web-api/main.py b/opendc-web/opendc-web-api/main.py
deleted file mode 100755
index 5c6dac31..00000000
--- a/opendc-web/opendc-web-api/main.py
+++ /dev/null
@@ -1,188 +0,0 @@
-#!/usr/bin/env python3
-import json
-import os
-import sys
-import traceback
-import urllib.request
-
-import flask_socketio
-from dotenv import load_dotenv
-from flask import Flask, request, jsonify
-from flask_compress import Compress
-from flask_cors import CORS
-from oauth2client import client, crypt
-
-from opendc.models.user import User
-from opendc.util import rest, path_parser, database
-from opendc.util.exceptions import AuthorizationTokenError, RequestInitializationError
-from opendc.util.json import JSONEncoder
-
-load_dotenv()
-
-TEST_MODE = "OPENDC_FLASK_TESTING" in os.environ
-
-# Setup Sentry if DSN is specified
-if 'SENTRY_DSN' in os.environ:
- import sentry_sdk
- from sentry_sdk.integrations.flask import FlaskIntegration
-
- sentry_sdk.init(
- integrations=[FlaskIntegration()],
- traces_sample_rate=0.1
- )
-
-# 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.get('OPENDC_DB_HOST', 'localhost'))
-
-# Set up the core app
-FLASK_CORE_APP = Flask(__name__)
-FLASK_CORE_APP.testing = TEST_MODE
-FLASK_CORE_APP.config['SECRET_KEY'] = os.environ['OPENDC_FLASK_SECRET']
-FLASK_CORE_APP.json_encoder = JSONEncoder
-
-# Set up CORS support
-CORS(FLASK_CORE_APP)
-
-compress = Compress()
-compress.init_app(FLASK_CORE_APP)
-
-SOCKET_IO_CORE = flask_socketio.SocketIO(FLASK_CORE_APP, cors_allowed_origins="*")
-
-API_VERSIONS = {'v2'}
-
-
-@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('/<string:version>/<path:endpoint_path>', methods=['GET', 'POST', 'PUT', 'DELETE'])
-def api_call(version, endpoint_path):
- """Call an API endpoint directly over HTTP."""
-
- # Check whether given version is valid
- if version not in API_VERSIONS:
- return jsonify(error='API version not found'), 404
-
- # 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
-
-
-@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, use_reloader=False)
diff --git a/opendc-web/opendc-web-api/opendc/api/portfolios.py b/opendc-web/opendc-web-api/opendc/api/portfolios.py
new file mode 100644
index 00000000..b07e9da5
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/portfolios.py
@@ -0,0 +1,135 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.exts import requires_auth, current_user
+from opendc.models.portfolio import Portfolio as PortfolioModel, PortfolioSchema
+from opendc.models.project import Project
+from opendc.models.scenario import ScenarioSchema, Scenario
+from opendc.models.topology import Topology
+
+
+class Portfolio(Resource):
+ """
+ Resource representing a portfolio.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, portfolio_id):
+ """
+ Get a portfolio by identifier.
+ """
+ portfolio = PortfolioModel.from_id(portfolio_id)
+
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], False)
+
+ data = portfolio.obj
+ return {'data': data}
+
+ def put(self, portfolio_id):
+ """
+ Replace the portfolio.
+ """
+ schema = Portfolio.PutSchema()
+ result = schema.load(request.json)
+
+ portfolio = PortfolioModel.from_id(portfolio_id)
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], True)
+
+ portfolio.set_property('name', result['portfolio']['name'])
+ portfolio.set_property('targets.enabledMetrics', result['portfolio']['targets']['enabledMetrics'])
+ portfolio.set_property('targets.repeatsPerScenario', result['portfolio']['targets']['repeatsPerScenario'])
+
+ portfolio.update()
+ data = portfolio.obj
+ return {'data': data}
+
+ def delete(self, portfolio_id):
+ """
+ Delete a portfolio.
+ """
+ portfolio = PortfolioModel.from_id(portfolio_id)
+
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], True)
+
+ portfolio_id = portfolio.get_id()
+
+ project = Project.from_id(portfolio.obj['projectId'])
+ project.check_exists()
+ if portfolio_id in project.obj['portfolioIds']:
+ project.obj['portfolioIds'].remove(portfolio_id)
+ project.update()
+
+ old_object = portfolio.delete()
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a portfolio.
+ """
+ portfolio = fields.Nested(PortfolioSchema, required=True)
+
+
+class PortfolioScenarios(Resource):
+ """
+ Resource representing the scenarios of a portfolio.
+ """
+ method_decorators = [requires_auth]
+
+ def post(self, portfolio_id):
+ """
+ Add a new scenario to this portfolio
+ """
+ schema = PortfolioScenarios.PostSchema()
+ result = schema.load(request.json)
+
+ portfolio = PortfolioModel.from_id(portfolio_id)
+
+ portfolio.check_exists()
+ portfolio.check_user_access(current_user['sub'], True)
+
+ scenario = Scenario(result['scenario'])
+
+ topology = Topology.from_id(scenario.obj['topology']['topologyId'])
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], True)
+
+ scenario.set_property('portfolioId', portfolio.get_id())
+ scenario.set_property('simulation', {'state': 'QUEUED'})
+ scenario.set_property('topology.topologyId', topology.get_id())
+
+ scenario.insert()
+
+ portfolio.obj['scenarioIds'].append(scenario.get_id())
+ portfolio.update()
+ data = scenario.obj
+ return {'data': data}
+
+ class PostSchema(Schema):
+ """
+ Schema for the POST operation on a portfolio's scenarios.
+ """
+ scenario = fields.Nested(ScenarioSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/prefabs.py b/opendc-web/opendc-web-api/opendc/api/prefabs.py
new file mode 100644
index 00000000..7bb17e7d
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/prefabs.py
@@ -0,0 +1,120 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.models.prefab import Prefab as PrefabModel, PrefabSchema
+from opendc.database import Database
+from opendc.exts import current_user, requires_auth, db
+
+
+class PrefabList(Resource):
+ """
+ Resource for the list of prefabs available to the user.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """
+ Get the available prefabs for a user.
+ """
+ user_id = current_user['sub']
+
+ own_prefabs = db.fetch_all({'authorId': user_id}, PrefabModel.collection_name)
+ public_prefabs = db.fetch_all({'visibility': 'public'}, PrefabModel.collection_name)
+
+ authorizations = {"authorizations": []}
+ authorizations["authorizations"].append(own_prefabs)
+ authorizations["authorizations"].append(public_prefabs)
+ return {'data': authorizations}
+
+ def post(self):
+ """
+ Create a new prefab.
+ """
+ schema = PrefabList.PostSchema()
+ result = schema.load(request.json)
+
+ prefab = PrefabModel(result['prefab'])
+ prefab.set_property('datetimeCreated', Database.datetime_to_string(datetime.now()))
+ prefab.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+
+ user_id = current_user['sub']
+ prefab.set_property('authorId', user_id)
+
+ prefab.insert()
+ return {'data': prefab.obj}
+
+ class PostSchema(Schema):
+ """
+ Schema for the POST operation on the prefab list.
+ """
+ prefab = fields.Nested(PrefabSchema, required=True)
+
+
+class Prefab(Resource):
+ """
+ Resource representing a single prefab.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, prefab_id):
+ """Get this Prefab."""
+ prefab = PrefabModel.from_id(prefab_id)
+ prefab.check_exists()
+ prefab.check_user_access(current_user['sub'])
+ return {'data': prefab.obj}
+
+ def put(self, prefab_id):
+ """Update a prefab's name and/or contents."""
+
+ schema = Prefab.PutSchema()
+ result = schema.load(request.json)
+
+ prefab = PrefabModel.from_id(prefab_id)
+ prefab.check_exists()
+ prefab.check_user_access(current_user['sub'])
+
+ prefab.set_property('name', result['prefab']['name'])
+ prefab.set_property('rack', result['prefab']['rack'])
+ prefab.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now()))
+ prefab.update()
+
+ return {'data': prefab.obj}
+
+ def delete(self, prefab_id):
+ """Delete this Prefab."""
+ prefab = PrefabModel.from_id(prefab_id)
+
+ prefab.check_exists()
+ prefab.check_user_access(current_user['sub'])
+
+ old_object = prefab.delete()
+
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a prefab.
+ """
+ prefab = fields.Nested(PrefabSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/projects.py b/opendc-web/opendc-web-api/opendc/api/projects.py
new file mode 100644
index 00000000..8c44b680
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/projects.py
@@ -0,0 +1,195 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.models.portfolio import Portfolio, PortfolioSchema
+from opendc.models.topology import Topology, TopologySchema
+from opendc.models.project import Project as ProjectModel, ProjectSchema
+from opendc.exts import current_user, requires_auth
+from opendc.database import Database
+
+
+class ProjectList(Resource):
+ """
+ Resource representing the list of projects available to a user.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """Get the authorized projects of the user"""
+ user_id = current_user['sub']
+ projects = ProjectModel.get_for_user(user_id)
+ return {'data': projects}
+
+ def post(self):
+ """Create a new project, and return that new project."""
+ user_id = current_user['sub']
+
+ schema = Project.PutSchema()
+ result = schema.load(request.json)
+
+ topology = Topology({'name': 'Default topology', 'rooms': []})
+ topology.insert()
+
+ project = ProjectModel(result['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.set_property('authorizations', [{'userId': user_id, 'level': 'OWN'}])
+ project.insert()
+
+ topology.set_property('projectId', project.get_id())
+ topology.update()
+
+ return {'data': project.obj}
+
+
+class Project(Resource):
+ """
+ Resource representing a single project.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, project_id):
+ """Get this Project."""
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], False)
+
+ return {'data': project.obj}
+
+ def put(self, project_id):
+ """Update a project's name."""
+ schema = Project.PutSchema()
+ result = schema.load(request.json)
+
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ project.set_property('name', result['project']['name'])
+ project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.update()
+
+ return {'data': project.obj}
+
+ def delete(self, project_id):
+ """Delete this Project."""
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], 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()
+
+ old_object = project.delete()
+
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a project.
+ """
+ project = fields.Nested(ProjectSchema, required=True)
+
+
+class ProjectTopologies(Resource):
+ """
+ Resource representing the topologies of a project.
+ """
+ method_decorators = [requires_auth]
+
+ def post(self, project_id):
+ """Add a new Topology to the specified project and return it"""
+ schema = ProjectTopologies.PutSchema()
+ result = schema.load(request.json)
+
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ topology = Topology({
+ 'projectId': project.get_id(),
+ 'name': result['topology']['name'],
+ 'rooms': result['topology']['rooms'],
+ })
+
+ topology.insert()
+
+ project.obj['topologyIds'].append(topology.get_id())
+ project.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+ project.update()
+
+ return {'data': topology.obj}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a project topology.
+ """
+ topology = fields.Nested(TopologySchema, required=True)
+
+
+class ProjectPortfolios(Resource):
+ """
+ Resource representing the portfolios of a project.
+ """
+ method_decorators = [requires_auth]
+
+ def post(self, project_id):
+ """Add a new Portfolio for this Project."""
+ schema = ProjectPortfolios.PutSchema()
+ result = schema.load(request.json)
+
+ project = ProjectModel.from_id(project_id)
+
+ project.check_exists()
+ project.check_user_access(current_user['sub'], True)
+
+ portfolio = Portfolio(result['portfolio'])
+
+ portfolio.set_property('projectId', project.get_id())
+ portfolio.set_property('scenarioIds', [])
+
+ portfolio.insert()
+
+ project.obj['portfolioIds'].append(portfolio.get_id())
+ project.update()
+
+ return {'data': portfolio.obj}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a project portfolio.
+ """
+ portfolio = fields.Nested(PortfolioSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/scenarios.py b/opendc-web/opendc-web-api/opendc/api/scenarios.py
new file mode 100644
index 00000000..b566950a
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/scenarios.py
@@ -0,0 +1,81 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.models.scenario import Scenario as ScenarioModel, ScenarioSchema
+from opendc.models.portfolio import Portfolio
+from opendc.exts import current_user, requires_auth
+
+
+class Scenario(Resource):
+ """
+ A Scenario resource.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, scenario_id):
+ """Get scenario by identifier."""
+ scenario = ScenarioModel.from_id(scenario_id)
+ scenario.check_exists()
+ scenario.check_user_access(current_user['sub'], False)
+ data = scenario.obj
+ return {'data': data}
+
+ def put(self, scenario_id):
+ """Update this Scenarios name."""
+ schema = Scenario.PutSchema()
+ result = schema.load(request.json)
+
+ scenario = ScenarioModel.from_id(scenario_id)
+
+ scenario.check_exists()
+ scenario.check_user_access(current_user['sub'], True)
+
+ scenario.set_property('name', result['scenario']['name'])
+
+ scenario.update()
+ data = scenario.obj
+ return {'data': data}
+
+ def delete(self, scenario_id):
+ """Delete this Scenario."""
+ scenario = ScenarioModel.from_id(scenario_id)
+ scenario.check_exists()
+ scenario.check_user_access(current_user['sub'], True)
+
+ scenario_id = scenario.get_id()
+
+ portfolio = Portfolio.from_id(scenario.obj['portfolioId'])
+ portfolio.check_exists()
+ if scenario_id in portfolio.obj['scenarioIds']:
+ portfolio.obj['scenarioIds'].remove(scenario_id)
+ portfolio.update()
+
+ old_object = scenario.delete()
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the put operation.
+ """
+ scenario = fields.Nested(ScenarioSchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/schedulers.py b/opendc-web/opendc-web-api/opendc/api/schedulers.py
new file mode 100644
index 00000000..b00d8c31
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/schedulers.py
@@ -0,0 +1,46 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+
+from flask_restful import Resource
+from opendc.exts import requires_auth
+
+SCHEDULERS = [
+ 'mem',
+ 'mem-inv',
+ 'core-mem',
+ 'core-mem-inv',
+ 'active-servers',
+ 'active-servers-inv',
+ 'provisioned-cores',
+ 'provisioned-cores-inv',
+ 'random'
+]
+
+
+class SchedulerList(Resource):
+ """
+ Resource for the list of schedulers to pick from.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """Get all available Traces."""
+ return {'data': [{'name': name} for name in SCHEDULERS]}
diff --git a/opendc-web/opendc-web-api/opendc/api/topologies.py b/opendc-web/opendc-web-api/opendc/api/topologies.py
new file mode 100644
index 00000000..eedf049d
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/topologies.py
@@ -0,0 +1,93 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from datetime import datetime
+
+from flask import request
+from flask_restful import Resource
+from marshmallow import Schema, fields
+
+from opendc.database import Database
+from opendc.models.project import Project
+from opendc.models.topology import Topology as TopologyModel, TopologySchema
+from opendc.exts import current_user, requires_auth
+
+
+class Topology(Resource):
+ """
+ Resource representing a single topology.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, topology_id):
+ """
+ Get a single topology.
+ """
+ topology = TopologyModel.from_id(topology_id)
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], False)
+ data = topology.obj
+ return {'data': data}
+
+ def put(self, topology_id):
+ """
+ Replace the topology.
+ """
+ topology = TopologyModel.from_id(topology_id)
+
+ schema = Topology.PutSchema()
+ result = schema.load(request.json)
+
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], True)
+
+ topology.set_property('name', result['topology']['name'])
+ topology.set_property('rooms', result['topology']['rooms'])
+ topology.set_property('datetimeLastEdited', Database.datetime_to_string(datetime.now()))
+
+ topology.update()
+ data = topology.obj
+ return {'data': data}
+
+ def delete(self, topology_id):
+ """
+ Delete a topology.
+ """
+ topology = TopologyModel.from_id(topology_id)
+
+ topology.check_exists()
+ topology.check_user_access(current_user['sub'], True)
+
+ topology_id = topology.get_id()
+
+ project = Project.from_id(topology.obj['projectId'])
+ project.check_exists()
+ if topology_id in project.obj['topologyIds']:
+ project.obj['topologyIds'].remove(topology_id)
+ project.update()
+
+ old_object = topology.delete()
+ return {'data': old_object}
+
+ class PutSchema(Schema):
+ """
+ Schema for the PUT operation on a topology.
+ """
+ topology = fields.Nested(TopologySchema, required=True)
diff --git a/opendc-web/opendc-web-api/opendc/api/traces.py b/opendc-web/opendc-web-api/opendc/api/traces.py
new file mode 100644
index 00000000..f685f00c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/api/traces.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from flask_restful import Resource
+
+from opendc.exts import requires_auth
+from opendc.models.trace import Trace as TraceModel
+
+
+class TraceList(Resource):
+ """
+ Resource for the list of traces to pick from.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self):
+ """Get all available Traces."""
+ traces = TraceModel.get_all()
+ data = traces.obj
+ return {'data': data}
+
+
+class Trace(Resource):
+ """
+ Resource representing a single trace.
+ """
+ method_decorators = [requires_auth]
+
+ def get(self, trace_id):
+ """Get trace information by identifier."""
+ trace = TraceModel.from_id(trace_id)
+ trace.check_exists()
+ data = trace.obj
+ return {'data': data}
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/paths.json b/opendc-web/opendc-web-api/opendc/api/v2/paths.json
deleted file mode 100644
index 652be5bc..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/paths.json
+++ /dev/null
@@ -1,19 +0,0 @@
-[
- "/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}",
- "/prefabs/authorizations"
-]
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py
deleted file mode 100644
index 0ba61a13..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/endpoint.py
+++ /dev/null
@@ -1,67 +0,0 @@
-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)
-
- portfolio_id = portfolio.get_id()
-
- project = Project.from_id(portfolio.obj['projectId'])
- project.check_exists()
- if portfolio_id in project.obj['portfolioIds']:
- project.obj['portfolioIds'].remove(portfolio_id)
- project.update()
-
- old_object = portfolio.delete()
-
- return Response(200, 'Successfully deleted portfolio.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py
deleted file mode 100644
index 2f042e06..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/endpoint.py
+++ /dev/null
@@ -1,49 +0,0 @@
-from opendc.models.portfolio import Portfolio
-from opendc.models.scenario import Scenario
-from opendc.models.topology import Topology
-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'])
-
- topology = Topology.from_id(scenario.obj['topology']['topologyId'])
- topology.check_exists()
- topology.check_user_access(request.google_id, True)
-
- scenario.set_property('portfolioId', portfolio.get_id())
- scenario.set_property('simulation', {'state': 'QUEUED'})
- scenario.set_property('topology.topologyId', topology.get_id())
-
- scenario.insert()
-
- portfolio.obj['scenarioIds'].append(scenario.get_id())
- portfolio.update()
-
- return Response(200, 'Successfully added Scenario.', scenario.obj)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py
deleted file mode 100644
index e5982b7f..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/scenarios/test_endpoint.py
+++ /dev/null
@@ -1,125 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_scenario_missing_parameter(client):
- assert '400' in client.post('/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(f'/v2/portfolios/{test_id}/scenarios',
- json={
- 'scenario': {
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- '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': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/v2/portfolios/{test_id}/scenarios',
- json={
- 'scenario': {
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- }
- }).status
-
-
-def test_add_scenario(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'portfolioIds': [test_id],
- 'scenarioIds': [test_id],
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'EDIT'
- }],
- 'simulation': {
- 'state': 'QUEUED',
- },
- })
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- 'portfolioId': test_id,
- 'simulationState': {
- 'state': 'QUEUED',
- },
- })
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.post(
- f'/v2/portfolios/{test_id}/scenarios',
- json={
- 'scenario': {
- 'name': 'test',
- 'trace': {
- 'traceId': test_id,
- 'loadSamplingFraction': 1.0,
- },
- 'topology': {
- 'topologyId': test_id,
- },
- 'operational': {
- 'failuresEnabled': True,
- 'performanceInterferenceEnabled': False,
- 'schedulerName': 'DEFAULT',
- },
- }
- })
- assert 'portfolioId' in res.json['content']
- assert 'simulation' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py
deleted file mode 100644
index 52f71aa4..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/portfolios/portfolioId/test_endpoint.py
+++ /dev/null
@@ -1,152 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_portfolio_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/portfolios/{test_id}').status
-
-
-def test_get_portfolio_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
- res = client.get(f'/v2/portfolios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_portfolio_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id_2,
- 'authorizationLevel': 'OWN'
- }]
- })
- res = client.get(f'/v2/portfolios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_portfolio(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/portfolios/{test_id}')
- assert '200' in res.status
-
-
-def test_update_portfolio_missing_parameter(client):
- assert '400' in client.put(f'/v2/portfolios/{test_id}').status
-
-
-def test_update_portfolio_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/portfolios/{test_id}', 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': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/portfolios/{test_id}', 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': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }],
- 'targets': {
- 'enabledMetrics': [],
- 'repeatsPerScenario': 1
- }
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- res = client.put(f'/v2/portfolios/{test_id}', 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(f'/v2/portfolios/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/portfolios/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value={})
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.delete(f'/v2/portfolios/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py
deleted file mode 100644
index 0d9ad5cd..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/endpoint.py
+++ /dev/null
@@ -1,22 +0,0 @@
-from opendc.models.prefab import Prefab
-from opendc.util.database import DB
-from opendc.models.user import User
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Return all prefabs the user is authorized to access"""
-
- user = User.from_google_id(request.google_id)
-
- user.check_exists()
-
- own_prefabs = DB.fetch_all({'authorId': user.get_id()}, Prefab.collection_name)
- public_prefabs = DB.fetch_all({'visibility': 'public'}, Prefab.collection_name)
-
- authorizations = {"authorizations": []}
-
- authorizations["authorizations"].append(own_prefabs)
- authorizations["authorizations"].append(public_prefabs)
-
- return Response(200, 'Successfully fetched authorizations.', authorizations)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py
deleted file mode 100644
index 6d36d428..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/authorizations/test_endpoint.py
+++ /dev/null
@@ -1,71 +0,0 @@
-from opendc.util.database import DB
-from unittest.mock import Mock
-
-test_id = 24 * '1'
-
-
-def test_get_authorizations(client, mocker):
- DB.fetch_all = Mock()
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id})
- DB.fetch_all.side_effect = [
- [{
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'private'
- },
- {
- '_id': '2' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'private'
- },
- {
- '_id': '3' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'public'
- },
- {
- '_id': '4' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id,
- 'visibility' : 'public'
- }],
- [{
- '_id': '5' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '6' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '7' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- },
- {
- '_id': '8' * 24,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': '2' * 24,
- 'visibility' : 'public'
- }]
- ]
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id})
- res = client.get('/v2/prefabs/authorizations')
- assert '200' in res.status
-
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py
deleted file mode 100644
index 723a2f0d..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/endpoint.py
+++ /dev/null
@@ -1,23 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py
deleted file mode 100644
index 7b81f546..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/endpoint.py
+++ /dev/null
@@ -1,50 +0,0 @@
-from datetime import datetime
-
-from opendc.models.prefab import Prefab
-from opendc.util.database import Database
-from opendc.util.rest import Response
-
-
-def GET(request):
- """Get this Prefab."""
-
- request.check_required_parameters(path={'prefabId': 'string'})
-
- prefab = Prefab.from_id(request.params_path['prefabId'])
- prefab.check_exists()
- prefab.check_user_access(request.google_id)
-
- return Response(200, 'Successfully retrieved prefab', prefab.obj)
-
-
-def PUT(request):
- """Update a prefab's name and/or contents."""
-
- request.check_required_parameters(body={'prefab': {'name': 'name'}}, path={'prefabId': 'string'})
-
- prefab = Prefab.from_id(request.params_path['prefabId'])
-
- prefab.check_exists()
- prefab.check_user_access(request.google_id)
-
- prefab.set_property('name', request.params_body['prefab']['name'])
- prefab.set_property('rack', request.params_body['prefab']['rack'])
- prefab.set_property('datetime_last_edited', Database.datetime_to_string(datetime.now()))
- prefab.update()
-
- return Response(200, 'Successfully updated prefab.', prefab.obj)
-
-
-def DELETE(request):
- """Delete this Prefab."""
-
- request.check_required_parameters(path={'prefabId': 'string'})
-
- prefab = Prefab.from_id(request.params_path['prefabId'])
-
- prefab.check_exists()
- prefab.check_user_access(request.google_id)
-
- old_object = prefab.delete()
-
- return Response(200, 'Successfully deleted prefab.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py
deleted file mode 100644
index 2daeb6bf..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/prefabId/test_endpoint.py
+++ /dev/null
@@ -1,145 +0,0 @@
-from opendc.util.database import DB
-from unittest.mock import Mock
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_prefab_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/prefabs/{test_id}').status
-
-
-def test_get_private_prefab_not_authorized(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/v2/prefabs/{test_id}')
- assert '403' in res.status
-
-
-def test_get_private_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/v2/prefabs/{test_id}')
- assert '200' in res.status
-
-
-def test_get_public_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'public',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- res = client.get(f'/v2/prefabs/{test_id}')
- assert '200' in res.status
-
-
-def test_update_prefab_missing_parameter(client):
- assert '400' in client.put(f'/v2/prefabs/{test_id}').status
-
-
-def test_update_prefab_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/prefabs/{test_id}', json={'prefab': {'name': 'S'}}).status
-
-
-def test_update_prefab_not_authorized(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}).status
-
-
-def test_update_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'update', return_value={})
- res = client.put(f'/v2/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}})
- assert '200' in res.status
-
-
-def test_delete_prefab_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.delete(f'/v2/prefabs/{test_id}').status
-
-
-def test_delete_prefab_different_user(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id_2,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/prefabs/{test_id}').status
-
-
-def test_delete_prefab(client, mocker):
- DB.fetch_one = Mock()
- DB.fetch_one.side_effect = [{
- '_id': test_id,
- 'name': 'test prefab',
- 'authorId': test_id,
- 'visibility': 'private',
- 'rack': {}
- },
- {
- '_id': test_id
- }
- ]
- mocker.patch.object(DB, 'delete_one', return_value={'prefab': {'name': 'name'}})
- res = client.delete(f'/v2/prefabs/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py
deleted file mode 100644
index 39a78c21..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/prefabs/test_endpoint.py
+++ /dev/null
@@ -1,24 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_prefab_missing_parameter(client):
- assert '400' in client.post('/v2/prefabs').status
-
-
-def test_add_prefab(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'authorId': test_id
- })
- res = client.post('/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/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py
deleted file mode 100644
index bf031382..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/endpoint.py
+++ /dev/null
@@ -1,32 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py
deleted file mode 100644
index 9f6a60ec..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/endpoint.py
+++ /dev/null
@@ -1,17 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py
deleted file mode 100644
index bebd6cff..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/authorizations/test_endpoint.py
+++ /dev/null
@@ -1,43 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-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(f'/v2/projects/{test_id}/authorizations').status
-
-
-def test_get_authorizations_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'name': 'test trace',
- 'authorizations': [{
- 'projectId': test_id_2,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'fetch_all', return_value=[])
- res = client.get(f'/v2/projects/{test_id}/authorizations')
- assert '403' in res.status
-
-
-def test_get_authorizations(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'name': 'test trace',
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'fetch_all', return_value=[])
- res = client.get(f'/v2/projects/{test_id}/authorizations')
- assert len(res.json['content']) == 0
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py
deleted file mode 100644
index caac37ca..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/endpoint.py
+++ /dev/null
@@ -1,66 +0,0 @@
-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: x['projectId'] != project.get_id(), user.obj['authorizations']))
- user.update()
-
- old_object = project.delete()
-
- return Response(200, 'Successfully deleted project.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py
deleted file mode 100644
index 2cdb1194..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/endpoint.py
+++ /dev/null
@@ -1,35 +0,0 @@
-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', project.get_id())
- 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/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py
deleted file mode 100644
index 04c699b5..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/portfolios/test_endpoint.py
+++ /dev/null
@@ -1,85 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_portfolio_missing_parameter(client):
- assert '400' in client.post(f'/v2/projects/{test_id}/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(f'/v2/projects/{test_id}/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': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/v2/projects/{test_id}/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': test_id,
- 'projectId': test_id,
- 'portfolioIds': [test_id],
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'EDIT'
- }]
- })
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'name': 'test',
- 'targets': {
- 'enabledMetrics': ['test'],
- 'repeatsPerScenario': 2
- },
- 'projectId': test_id,
- 'scenarioIds': [],
- })
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.post(
- f'/v2/projects/{test_id}/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/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py
deleted file mode 100644
index f9ffaf37..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/test_endpoint.py
+++ /dev/null
@@ -1,122 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/projects/{test_id}').status
-
-
-def test_get_project_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'authorizations': []})
- res = client.get(f'/v2/projects/{test_id}')
- assert '403' in res.status
-
-
-def test_get_project_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id_2,
- 'authorizationLevel': 'OWN'
- }]
- })
- res = client.get(f'/v2/projects/{test_id}')
- assert '403' in res.status
-
-
-def test_get_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/projects/{test_id}')
- assert '200' in res.status
-
-
-def test_update_project_missing_parameter(client):
- assert '400' in client.put(f'/v2/projects/{test_id}').status
-
-
-def test_update_project_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/projects/{test_id}', json={'project': {'name': 'S'}}).status
-
-
-def test_update_project_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/projects/{test_id}', json={'project': {'name': 'S'}}).status
-
-
-def test_update_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- res = client.put(f'/v2/projects/{test_id}', 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(f'/v2/projects/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }],
- 'topologyIds': []
- })
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/projects/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'googleId': 'test',
- 'authorizations': [{
- 'projectId': test_id,
- '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(f'/v2/projects/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py
deleted file mode 100644
index 44a0d575..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/endpoint.py
+++ /dev/null
@@ -1,31 +0,0 @@
-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': project.get_id(),
- '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/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py
deleted file mode 100644
index 71e88f00..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/projectId/topologies/test_endpoint.py
+++ /dev/null
@@ -1,52 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_topology_missing_parameter(client):
- assert '400' in client.post(f'/v2/projects/{test_id}/topologies').status
-
-
-def test_add_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }],
- 'topologyIds': []
- })
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'topologyIds': []
- })
- mocker.patch.object(DB, 'update', return_value={})
- res = client.post(f'/v2/projects/{test_id}/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': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- assert '403' in client.post(f'/v2/projects/{test_id}/topologies',
- json={
- 'topology': {
- 'name': 'test_topology',
- 'rooms': {}
- }
- }).status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py
deleted file mode 100644
index 9444b1e4..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/projects/test_endpoint.py
+++ /dev/null
@@ -1,25 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_add_project_missing_parameter(client):
- assert '400' in client.post('/v2/projects').status
-
-
-def test_add_project(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
- mocker.patch.object(DB,
- 'insert',
- return_value={
- '_id': test_id,
- 'datetimeCreated': '000',
- 'datetimeLastEdited': '000',
- 'topologyIds': []
- })
- mocker.patch.object(DB, 'update', return_value={})
- res = client.post('/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/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py
deleted file mode 100644
index 88a74e9c..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/endpoint.py
+++ /dev/null
@@ -1,59 +0,0 @@
-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)
-
- scenario_id = scenario.get_id()
-
- portfolio = Portfolio.from_id(scenario.obj['portfolioId'])
- portfolio.check_exists()
- if scenario_id in portfolio.obj['scenarioIds']:
- portfolio.obj['scenarioIds'].remove(scenario_id)
- portfolio.update()
-
- old_object = scenario.delete()
-
- return Response(200, 'Successfully deleted scenario.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py
deleted file mode 100644
index cd4bcdf8..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/scenarios/scenarioId/test_endpoint.py
+++ /dev/null
@@ -1,149 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_scenario_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/scenarios/{test_id}').status
-
-
-def test_get_scenario_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={
- 'portfolioId': '1',
- 'authorizations': []
- })
- res = client.get(f'/v2/scenarios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_scenario_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- 'portfolioId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id_2,
- 'authorizationLevel': 'OWN'
- }]
- })
- res = client.get(f'/v2/scenarios/{test_id}')
- assert '403' in res.status
-
-
-def test_get_scenario(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- 'projectId': test_id,
- 'portfolioId': test_id,
- '_id': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/scenarios/{test_id}')
- assert '200' in res.status
-
-
-def test_update_scenario_missing_parameter(client):
- assert '400' in client.put(f'/v2/scenarios/{test_id}').status
-
-
-def test_update_scenario_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/scenarios/{test_id}', json={
- 'scenario': {
- 'name': 'test',
- }
- }).status
-
-
-def test_update_scenario_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/scenarios/{test_id}', json={
- 'scenario': {
- 'name': 'test',
- }
- }).status
-
-
-def test_update_scenario(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }],
- 'targets': {
- 'enabledMetrics': [],
- 'repeatsPerScenario': 1
- }
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- res = client.put(f'/v2/scenarios/{test_id}', 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(f'/v2/scenarios/{test_id}').status
-
-
-def test_delete_project_different_user(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'googleId': 'other_test',
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value=None)
- assert '403' in client.delete(f'/v2/scenarios/{test_id}').status
-
-
-def test_delete_project(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'portfolioId': test_id,
- 'googleId': 'test',
- 'scenarioIds': [test_id],
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value={})
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.delete(f'/v2/scenarios/{test_id}')
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py
deleted file mode 100644
index f33159bf..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/endpoint.py
+++ /dev/null
@@ -1,19 +0,0 @@
-from opendc.util.rest import Response
-
-SCHEDULERS = [
- 'mem',
- 'mem-inv',
- 'core-mem',
- 'core-mem-inv',
- 'active-servers',
- 'active-servers-inv',
- 'provisioned-cores',
- 'provisioned-cores-inv',
- 'random'
-]
-
-
-def GET(_):
- """Get all available Schedulers."""
-
- return Response(200, 'Successfully retrieved Schedulers.', [{'name': name} for name in SCHEDULERS])
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py
deleted file mode 100644
index 4950ca4c..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/schedulers/test_endpoint.py
+++ /dev/null
@@ -1,2 +0,0 @@
-def test_get_schedulers(client):
- assert '200' in client.get('/v2/schedulers').status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py
deleted file mode 100644
index ea82b2e2..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/endpoint.py
+++ /dev/null
@@ -1,58 +0,0 @@
-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)
-
- topology_id = topology.get_id()
-
- project = Project.from_id(topology.obj['projectId'])
- project.check_exists()
- if topology_id in project.obj['topologyIds']:
- project.obj['topologyIds'].remove(topology_id)
- project.update()
-
- old_object = topology.delete()
-
- return Response(200, 'Successfully deleted topology.', old_object)
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py
deleted file mode 100644
index 4da0bc64..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/topologies/topologyId/test_endpoint.py
+++ /dev/null
@@ -1,119 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-test_id_2 = 24 * '2'
-
-
-def test_get_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'EDIT'
- }]
- })
- res = client.get(f'/v2/topologies/{test_id}')
- 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('/v2/topologies/1').status
-
-
-def test_get_topology_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id_2,
- 'authorizationLevel': 'OWN'
- }]
- })
- res = client.get(f'/v2/topologies/{test_id}')
- assert '403' in res.status
-
-
-def test_get_topology_no_authorizations(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
- res = client.get(f'/v2/topologies/{test_id}')
- assert '403' in res.status
-
-
-def test_update_topology_missing_parameter(client):
- assert '400' in client.put(f'/v2/topologies/{test_id}').status
-
-
-def test_update_topology_non_existent(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/topologies/{test_id}', json={'topology': {'name': 'test_topology', 'rooms': {}}}).status
-
-
-def test_update_topology_not_authorized(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'VIEW'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
- assert '403' in client.put(f'/v2/topologies/{test_id}', json={
- 'topology': {
- 'name': 'updated_topology',
- 'rooms': {}
- }
- }).status
-
-
-def test_update_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'update', return_value={})
-
- assert '200' in client.put(f'/v2/topologies/{test_id}', json={
- 'topology': {
- 'name': 'updated_topology',
- 'rooms': {}
- }
- }).status
-
-
-def test_delete_topology(client, mocker):
- mocker.patch.object(DB,
- 'fetch_one',
- return_value={
- '_id': test_id,
- 'projectId': test_id,
- 'googleId': 'test',
- 'topologyIds': [test_id],
- 'authorizations': [{
- 'projectId': test_id,
- 'authorizationLevel': 'OWN'
- }]
- })
- mocker.patch.object(DB, 'delete_one', return_value={})
- mocker.patch.object(DB, 'update', return_value=None)
- res = client.delete(f'/v2/topologies/{test_id}')
- 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(f'/v2/topologies/{test_id}').status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py
deleted file mode 100644
index ee699e02..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/endpoint.py
+++ /dev/null
@@ -1,10 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py
deleted file mode 100644
index 36846bd9..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/test_endpoint.py
+++ /dev/null
@@ -1,6 +0,0 @@
-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('/v2/traces').status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py
deleted file mode 100644
index 670f88d1..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/endpoint.py
+++ /dev/null
@@ -1,14 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py
deleted file mode 100644
index 0c51538b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/traces/traceId/test_endpoint.py
+++ /dev/null
@@ -1,15 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_get_trace_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/traces/{test_id}').status
-
-
-def test_get_trace(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'name': 'test trace'})
- res = client.get(f'/v2/traces/{test_id}')
- assert 'name' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/users/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py
deleted file mode 100644
index 0dcf2463..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/users/endpoint.py
+++ /dev/null
@@ -1,30 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py
deleted file mode 100644
index 13b63b20..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/users/test_endpoint.py
+++ /dev/null
@@ -1,34 +0,0 @@
-from opendc.util.database import DB
-
-
-def test_get_user_by_email_missing_parameter(client):
- assert '400' in client.get('/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('/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('/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('/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('/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('/v2/users', json={'user': {'email': 'test@test.com'}})
- assert 'email' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py
deleted file mode 100644
index be3462c0..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/endpoint.py
+++ /dev/null
@@ -1,59 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py b/opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py
deleted file mode 100644
index 4085642f..00000000
--- a/opendc-web/opendc-web-api/opendc/api/v2/users/userId/test_endpoint.py
+++ /dev/null
@@ -1,56 +0,0 @@
-from opendc.util.database import DB
-
-test_id = 24 * '1'
-
-
-def test_get_user_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.get(f'/v2/users/{test_id}').status
-
-
-def test_get_user(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'email': 'test@test.com'})
- res = client.get(f'/v2/users/{test_id}')
- assert 'email' in res.json['content']
- assert '200' in res.status
-
-
-def test_update_user_missing_parameter(client):
- assert '400' in client.put(f'/v2/users/{test_id}').status
-
-
-def test_update_user_non_existing(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value=None)
- assert '404' in client.put(f'/v2/users/{test_id}', json={'user': {'givenName': 'A', 'familyName': 'B'}}).status
-
-
-def test_update_user_different_user(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'other_test'})
- assert '403' in client.put(f'/v2/users/{test_id}', json={'user': {'givenName': 'A', 'familyName': 'B'}}).status
-
-
-def test_update_user(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'test'})
- mocker.patch.object(DB, 'update', return_value={'givenName': 'A', 'familyName': 'B'})
- res = client.put(f'/v2/users/{test_id}', 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(f'/v2/users/{test_id}').status
-
-
-def test_delete_user_different_user(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'other_test'})
- assert '403' in client.delete(f'/v2/users/{test_id}').status
-
-
-def test_delete_user(client, mocker):
- mocker.patch.object(DB, 'fetch_one', return_value={'_id': test_id, 'googleId': 'test', 'authorizations': []})
- mocker.patch.object(DB, 'delete_one', return_value={'googleId': 'test'})
- res = client.delete(f'/v2/users/{test_id}', )
-
- assert 'googleId' in res.json['content']
- assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/opendc/auth.py b/opendc-web/opendc-web-api/opendc/auth.py
new file mode 100644
index 00000000..1870f01c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/auth.py
@@ -0,0 +1,240 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import json
+import time
+
+import urllib3
+from flask import request
+from jose import jwt, JWTError
+
+
+def get_token():
+ """
+ Obtain the Access Token from the Authorization Header
+ """
+ auth = request.headers.get("Authorization", None)
+ if not auth:
+ raise AuthError({
+ "code": "authorization_header_missing",
+ "description": "Authorization header is expected"
+ }, 401)
+
+ parts = auth.split()
+
+ if parts[0].lower() != "bearer":
+ raise AuthError({
+ "code": "invalid_header",
+ "description": "Authorization header must start with"
+ " Bearer"
+ }, 401)
+ if len(parts) == 1:
+ raise AuthError({"code": "invalid_header", "description": "Token not found"}, 401)
+ if len(parts) > 2:
+ raise AuthError({"code": "invalid_header", "description": "Authorization header must be" " Bearer token"}, 401)
+
+ token = parts[1]
+ return token
+
+
+class AuthError(Exception):
+ """
+ This error is thrown when the request failed to authorize.
+ """
+ def __init__(self, error, status_code):
+ Exception.__init__(self, error)
+ self.error = error
+ self.status_code = status_code
+
+
+class AuthContext:
+ """
+ This class handles the authorization of requests.
+ """
+ def __init__(self, alg, issuer, audience):
+ self._alg = alg
+ self._issuer = issuer
+ self._audience = audience
+
+ def validate(self, token):
+ """
+ Validate the specified JWT token.
+ :param token: The authorization token specified by the user.
+ :return: The token payload on success, otherwise `AuthError`.
+ """
+ try:
+ header = jwt.get_unverified_header(token)
+ except JWTError as e:
+ raise AuthError({"code": "invalid_token", "message": str(e)}, 401)
+
+ alg = header.get('alg', None)
+ if alg != self._alg.algorithm:
+ raise AuthError(
+ {
+ "code":
+ "invalid_header",
+ "message":
+ f"Signature algorithm of {alg} is not supported. Expected the ID token "
+ f"to be signed with {self._alg.algorithm}"
+ }, 401)
+
+ kid = header.get('kid', None)
+ try:
+ secret_or_certificate = self._alg.get_key(key_id=kid)
+ except TokenValidationError as e:
+ raise AuthError({"code": "invalid_header", "message": str(e)}, 401)
+ try:
+ payload = jwt.decode(token,
+ key=secret_or_certificate,
+ algorithms=[self._alg.algorithm],
+ audience=self._audience,
+ issuer=self._issuer)
+ return payload
+ except jwt.ExpiredSignatureError:
+ raise AuthError({"code": "token_expired", "message": "Token is expired"}, 401)
+ except jwt.JWTClaimsError:
+ raise AuthError(
+ {
+ "code": "invalid_claims",
+ "message": "Incorrect claims, please check the audience and issuer"
+ }, 401)
+ except Exception as e:
+ print(e)
+ raise AuthError({"code": "invalid_header", "message": "Unable to parse authentication token."}, 401)
+
+
+class SymmetricJwtAlgorithm:
+ """Verifier for HMAC signatures, which rely on shared secrets.
+ Args:
+ shared_secret (str): The shared secret used to decode the token.
+ algorithm (str, optional): The expected signing algorithm. Defaults to "HS256".
+ """
+ def __init__(self, shared_secret, algorithm="HS256"):
+ self.algorithm = algorithm
+ self._shared_secret = shared_secret
+
+ # pylint: disable=W0613
+ def get_key(self, key_id=None):
+ """
+ Obtain the key for this algorithm.
+ :param key_id: The identifier of the key.
+ :return: The JWK key.
+ """
+ return self._shared_secret
+
+
+class AsymmetricJwtAlgorithm:
+ """Verifier for RSA signatures, which rely on public key certificates.
+ Args:
+ jwks_url (str): The url where the JWK set is located.
+ algorithm (str, optional): The expected signing algorithm. Defaults to "RS256".
+ """
+ def __init__(self, jwks_url, algorithm="RS256"):
+ self.algorithm = algorithm
+ self._fetcher = JwksFetcher(jwks_url)
+
+ def get_key(self, key_id=None):
+ """
+ Obtain the key for this algorithm.
+ :param key_id: The identifier of the key.
+ :return: The JWK key.
+ """
+ return self._fetcher.get_key(key_id)
+
+
+class TokenValidationError(Exception):
+ """
+ Error thrown when the token cannot be validated
+ """
+
+
+class JwksFetcher:
+ """Class that fetches and holds a JSON web key set.
+ This class makes use of an in-memory cache. For it to work properly, define this instance once and re-use it.
+ Args:
+ jwks_url (str): The url where the JWK set is located.
+ cache_ttl (str, optional): The lifetime of the JWK set cache in seconds. Defaults to 600 seconds.
+ """
+ CACHE_TTL = 600 # 10 min cache lifetime
+
+ def __init__(self, jwks_url, cache_ttl=CACHE_TTL):
+ self._jwks_url = jwks_url
+ self._http = urllib3.PoolManager()
+ self._cache_value = {}
+ self._cache_date = 0
+ self._cache_ttl = cache_ttl
+ self._cache_is_fresh = False
+
+ def _fetch_jwks(self, force=False):
+ """Attempts to obtain the JWK set from the cache, as long as it's still valid.
+ When not, it will perform a network request to the jwks_url to obtain a fresh result
+ and update the cache value with it.
+ Args:
+ force (bool, optional): whether to ignore the cache and force a network request or not. Defaults to False.
+ """
+ has_expired = self._cache_date + self._cache_ttl < time.time()
+
+ if not force and not has_expired:
+ # Return from cache
+ self._cache_is_fresh = False
+ return self._cache_value
+
+ # Invalidate cache and fetch fresh data
+ self._cache_value = {}
+ response = self._http.request('GET', self._jwks_url)
+
+ if response.status == 200:
+ # Update cache
+ jwks = json.loads(response.data.decode('utf-8'))
+ self._cache_value = self._parse_jwks(jwks)
+ self._cache_is_fresh = True
+ self._cache_date = time.time()
+ return self._cache_value
+
+ @staticmethod
+ def _parse_jwks(jwks):
+ """Converts a JWK string representation into a binary certificate in PEM format.
+ """
+ keys = {}
+
+ for key in jwks['keys']:
+ keys[key["kid"]] = key
+ return keys
+
+ def get_key(self, key_id):
+ """Obtains the JWK associated with the given key id.
+ Args:
+ key_id (str): The id of the key to fetch.
+ Returns:
+ the JWK associated with the given key id.
+
+ Raises:
+ TokenValidationError: when a key with that id cannot be found
+ """
+ keys = self._fetch_jwks()
+
+ if keys and key_id in keys:
+ return keys[key_id]
+
+ if not self._cache_is_fresh:
+ keys = self._fetch_jwks(force=True)
+ if keys and key_id in keys:
+ return keys[key_id]
+ raise TokenValidationError(f"RSA Public Key with ID {key_id} was not found.")
diff --git a/opendc-web/opendc-web-api/opendc/util/database.py b/opendc-web/opendc-web-api/opendc/database.py
index dd26533d..f9a33b66 100644
--- a/opendc-web/opendc-web-api/opendc/util/database.py
+++ b/opendc-web/opendc-web-api/opendc/database.py
@@ -1,3 +1,23 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
import urllib.parse
from datetime import datetime
@@ -9,19 +29,27 @@ 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):
+ def __init__(self, db=None):
"""Initializes the database connection."""
+ self.opendc_db = db
+ @classmethod
+ def from_credentials(cls, user, password, database, host):
+ """
+ Construct a database instance from the specified credentials.
+ :param user: The username to connect with.
+ :param password: The password to connect with.
+ :param database: The database name to connect to.
+ :param host: The host to connect to.
+ :return: The database instance.
+ """
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
+ return cls(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
@@ -72,6 +100,3 @@ class Database:
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/opendc-web/opendc-web-api/opendc/exts.py b/opendc-web/opendc-web-api/opendc/exts.py
new file mode 100644
index 00000000..f088a29c
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/exts.py
@@ -0,0 +1,60 @@
+import os
+from functools import wraps
+
+from flask import g, _request_ctx_stack
+from werkzeug.local import LocalProxy
+
+from opendc.database import Database
+from opendc.auth import AuthContext, AsymmetricJwtAlgorithm, get_token
+
+
+def get_db():
+ """
+ Return the configured database instance for the application.
+ """
+ _db = getattr(g, 'db', None)
+ if _db is None:
+ _db = Database.from_credentials(user=os.environ['OPENDC_DB_USERNAME'],
+ password=os.environ['OPENDC_DB_PASSWORD'],
+ database=os.environ['OPENDC_DB'],
+ host=os.environ.get('OPENDC_DB_HOST', 'localhost'))
+ g.db = _db
+ return _db
+
+
+db = LocalProxy(get_db)
+
+
+def get_auth_context():
+ """
+ Return the configured auth context for the application.
+ """
+ _auth_context = getattr(g, 'auth_context', None)
+ if _auth_context is None:
+ _auth_context = AuthContext(
+ alg=AsymmetricJwtAlgorithm(jwks_url=f"https://{os.environ['AUTH0_DOMAIN']}/.well-known/jwks.json"),
+ issuer=f"https://{os.environ['AUTH0_DOMAIN']}/",
+ audience=os.environ['AUTH0_AUDIENCE']
+ )
+ g.auth_context = _auth_context
+ return _auth_context
+
+
+auth_context = LocalProxy(get_auth_context)
+
+
+def requires_auth(f):
+ """Decorator to determine if the Access Token is valid.
+ """
+
+ @wraps(f)
+ def decorated(*args, **kwargs):
+ token = get_token()
+ payload = auth_context.validate(token)
+ _request_ctx_stack.top.current_user = payload
+ return f(*args, **kwargs)
+
+ return decorated
+
+
+current_user = LocalProxy(lambda: getattr(_request_ctx_stack.top, 'current_user', None))
diff --git a/opendc-web/opendc-web-api/opendc/models/model.py b/opendc-web/opendc-web-api/opendc/models/model.py
index f9dfc9ad..28299453 100644
--- a/opendc-web/opendc-web-api/opendc/models/model.py
+++ b/opendc-web/opendc-web-api/opendc/models/model.py
@@ -1,8 +1,7 @@
from bson.objectid import ObjectId
+from werkzeug.exceptions import NotFound
-from opendc.util.database import DB
-from opendc.util.exceptions import ClientError
-from opendc.util.rest import Response
+from opendc.exts import db
class Model:
@@ -15,15 +14,13 @@ class Model:
"""Fetches the document with given ID from the collection."""
if isinstance(_id, str) and len(_id) == 24:
_id = ObjectId(_id)
- elif not isinstance(_id, ObjectId):
- return cls(None)
- return cls(DB.fetch_one({'_id': _id}, cls.collection_name))
+ 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))
+ return cls(db.fetch_all({}, cls.collection_name))
def __init__(self, obj):
self.obj = obj
@@ -35,7 +32,7 @@ class Model:
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.'))
+ raise NotFound('Entity not found.')
def set_property(self, key, value):
"""Sets the given property on the enclosed object, with support for simple nested access."""
@@ -48,11 +45,11 @@ class Model:
def insert(self):
"""Inserts the enclosed object and generates a UUID for it."""
self.obj['_id'] = ObjectId()
- DB.insert(self.obj, self.collection_name)
+ 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)
+ db.update(self.get_id(), self.obj, self.collection_name)
def delete(self):
"""Deletes the enclosed object in the database, if it existed."""
@@ -60,5 +57,5 @@ class Model:
return None
old_object = self.obj.copy()
- DB.delete_one({'_id': self.get_id()}, self.collection_name)
+ db.delete_one({'_id': self.get_id()}, self.collection_name)
return old_object
diff --git a/opendc-web/opendc-web-api/opendc/models/portfolio.py b/opendc-web/opendc-web-api/opendc/models/portfolio.py
index 32961b63..aff1d3f0 100644
--- a/opendc-web/opendc-web-api/opendc/models/portfolio.py
+++ b/opendc-web/opendc-web-api/opendc/models/portfolio.py
@@ -1,7 +1,26 @@
+from marshmallow import Schema, fields
+
+from opendc.models.project import Project
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 TargetSchema(Schema):
+ """
+ Schema representing a target.
+ """
+ enabledMetrics = fields.List(fields.String())
+ repeatsPerScenario = fields.Integer(required=True)
+
+
+class PortfolioSchema(Schema):
+ """
+ Schema representing a portfolio.
+ """
+ _id = fields.String()
+ projectId = fields.String()
+ name = fields.String(required=True)
+ scenarioIds = fields.List(fields.String())
+ targets = fields.Nested(TargetSchema)
class Portfolio(Model):
@@ -9,16 +28,13 @@ class Portfolio(Model):
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.
+ def check_user_access(self, user_id, edit_access):
+ """Raises an error if the user with given [user_id] has insufficient access.
Checks access on the parent project.
- :param google_id: The Google ID of the user.
+ :param user_id: The User 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.'))
+ project = Project.from_id(self.obj['projectId'])
+ project.check_user_access(user_id, edit_access)
diff --git a/opendc-web/opendc-web-api/opendc/models/prefab.py b/opendc-web/opendc-web-api/opendc/models/prefab.py
index edf1d4c4..d83ef4cb 100644
--- a/opendc-web/opendc-web-api/opendc/models/prefab.py
+++ b/opendc-web/opendc-web-api/opendc/models/prefab.py
@@ -1,28 +1,30 @@
+from marshmallow import Schema, fields
+from werkzeug.exceptions import Forbidden
+
+from opendc.models.topology import ObjectSchema
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 PrefabSchema(Schema):
+ """
+ Schema for a Prefab.
+ """
+ _id = fields.String()
+ name = fields.String(required=True)
+ datetimeCreated = fields.DateTime()
+ datetimeLastEdited = fields.DateTime()
+ rack = fields.Nested(ObjectSchema)
class Prefab(Model):
- """Model representing a Project."""
+ """Model representing a Prefab."""
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.
+ def check_user_access(self, user_id):
+ """Raises an error if the user with given [user_id] has insufficient access to view this prefab.
- :param google_id: The Google ID of the user.
+ :param user_id: The user ID of the user.
"""
- user = User.from_google_id(google_id)
-
- # TODO(Jacob) add special handling for OpenDC-provided prefabs
-
- #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
+ if self.obj['authorId'] != user_id and self.obj['visibility'] == "private":
+ raise Forbidden("Forbidden from retrieving prefab.")
diff --git a/opendc-web/opendc-web-api/opendc/models/project.py b/opendc-web/opendc-web-api/opendc/models/project.py
index b57e9f77..ee84c73e 100644
--- a/opendc-web/opendc-web-api/opendc/models/project.py
+++ b/opendc-web/opendc-web-api/opendc/models/project.py
@@ -1,8 +1,20 @@
+from marshmallow import Schema, fields
+from werkzeug.exceptions import Forbidden
+
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
+from opendc.exts import db
+
+
+class ProjectSchema(Schema):
+ """
+ Schema representing a Project.
+ """
+ _id = fields.String()
+ name = fields.String(required=True)
+ datetimeCreated = fields.DateTime()
+ datetimeLastEdited = fields.DateTime()
+ topologyIds = fields.List(fields.String())
+ portfolioIds = fields.List(fields.String())
class Project(Model):
@@ -10,22 +22,18 @@ class Project(Model):
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.
+ def check_user_access(self, user_id, edit_access):
+ """Raises an error if the user with given [user_id] has insufficient access.
- :param google_id: The Google ID of the user.
+ :param user_id: The User 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)
- ]
+ for authorization in self.obj['authorizations']:
+ if user_id == authorization['userId'] and authorization['level'] != 'VIEW' or not edit_access:
+ return
+ raise Forbidden("Forbidden from retrieving project.")
+
+ @classmethod
+ def get_for_user(cls, user_id):
+ """Get all projects for the specified user id."""
+ return db.fetch_all({'authorizations.userId': user_id}, Project.collection_name)
diff --git a/opendc-web/opendc-web-api/opendc/models/scenario.py b/opendc-web/opendc-web-api/opendc/models/scenario.py
index 8d53e408..2911b1ae 100644
--- a/opendc-web/opendc-web-api/opendc/models/scenario.py
+++ b/opendc-web/opendc-web-api/opendc/models/scenario.py
@@ -1,8 +1,50 @@
+from marshmallow import Schema, fields
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 SimulationSchema(Schema):
+ """
+ Simulation details.
+ """
+ state = fields.String()
+
+
+class TraceSchema(Schema):
+ """
+ Schema for specifying the trace of a scenario.
+ """
+ traceId = fields.String()
+ loadSamplingFraction = fields.Float()
+
+
+class TopologySchema(Schema):
+ """
+ Schema for topology specification for a scenario.
+ """
+ topologyId = fields.String()
+
+
+class OperationalSchema(Schema):
+ """
+ Schema for the operational phenomena for a scenario.
+ """
+ failuresEnabled = fields.Boolean()
+ performanceInterferenceEnabled = fields.Boolean()
+ schedulerName = fields.String()
+
+
+class ScenarioSchema(Schema):
+ """
+ Schema representing a scenario.
+ """
+ _id = fields.String()
+ portfolioId = fields.String()
+ name = fields.String(required=True)
+ simulation = fields.Nested(SimulationSchema)
+ trace = fields.Nested(TraceSchema)
+ topology = fields.Nested(TopologySchema)
+ operational = fields.Nested(OperationalSchema)
class Scenario(Model):
@@ -10,17 +52,13 @@ class Scenario(Model):
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.
+ def check_user_access(self, user_id, edit_access):
+ """Raises an error if the user with given [user_id] has insufficient access.
Checks access on the parent project.
- :param google_id: The Google ID of the user.
+ :param user_id: The User 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.'))
+ portfolio.check_user_access(user_id, edit_access)
diff --git a/opendc-web/opendc-web-api/opendc/models/topology.py b/opendc-web/opendc-web-api/opendc/models/topology.py
index cb4c4bab..c6354ae6 100644
--- a/opendc-web/opendc-web-api/opendc/models/topology.py
+++ b/opendc-web/opendc-web-api/opendc/models/topology.py
@@ -1,7 +1,81 @@
+from marshmallow import Schema, fields
+
+from opendc.models.project import Project
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 MemorySchema(Schema):
+ """
+ Schema representing a memory unit.
+ """
+ _id = fields.String()
+ name = fields.String()
+ speedMbPerS = fields.Integer()
+ sizeMb = fields.Integer()
+ energyConsumptionW = fields.Integer()
+
+
+class PuSchema(Schema):
+ """
+ Schema representing a processing unit.
+ """
+ _id = fields.String()
+ name = fields.String()
+ clockRateMhz = fields.Integer()
+ numberOfCores = fields.Integer()
+ energyConsumptionW = fields.Integer()
+
+
+class MachineSchema(Schema):
+ """
+ Schema representing a machine.
+ """
+ _id = fields.String()
+ position = fields.Integer()
+ cpus = fields.List(fields.Nested(PuSchema))
+ gpus = fields.List(fields.Nested(PuSchema))
+ memories = fields.List(fields.Nested(MemorySchema))
+ storages = fields.List(fields.Nested(MemorySchema))
+
+
+class ObjectSchema(Schema):
+ """
+ Schema representing a room object.
+ """
+ _id = fields.String()
+ name = fields.String()
+ capacity = fields.Integer()
+ powerCapacityW = fields.Integer()
+ machines = fields.List(fields.Nested(MachineSchema))
+
+
+class TileSchema(Schema):
+ """
+ Schema representing a room tile.
+ """
+ _id = fields.String()
+ positionX = fields.Integer()
+ positionY = fields.Integer()
+ rack = fields.Nested(ObjectSchema)
+
+
+class RoomSchema(Schema):
+ """
+ Schema representing a room.
+ """
+ _id = fields.String()
+ name = fields.String(required=True)
+ tiles = fields.List(fields.Nested(TileSchema), required=True)
+
+
+class TopologySchema(Schema):
+ """
+ Schema representing a datacenter topology.
+ """
+ _id = fields.String()
+ projectId = fields.String()
+ name = fields.String(required=True)
+ rooms = fields.List(fields.Nested(RoomSchema), required=True)
class Topology(Model):
@@ -9,19 +83,13 @@ class Topology(Model):
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.
+ def check_user_access(self, user_id, edit_access):
+ """Raises an error if the user with given [user_id] has insufficient access.
Checks access on the parent project.
- :param google_id: The Google ID of the user.
+ :param user_id: The User 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.'))
+ project = Project.from_id(self.obj['projectId'])
+ project.check_user_access(user_id, edit_access)
diff --git a/opendc-web/opendc-web-api/opendc/models/user.py b/opendc-web/opendc-web-api/opendc/models/user.py
deleted file mode 100644
index 8e8ff945..00000000
--- a/opendc-web/opendc-web-api/opendc/models/user.py
+++ /dev/null
@@ -1,36 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/util.py b/opendc-web/opendc-web-api/opendc/util.py
new file mode 100644
index 00000000..e7dc07a4
--- /dev/null
+++ b/opendc-web/opendc-web-api/opendc/util.py
@@ -0,0 +1,32 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+import flask
+from bson.objectid import ObjectId
+
+
+class JSONEncoder(flask.json.JSONEncoder):
+ """
+ A customized JSON encoder to handle unsupported types.
+ """
+ def default(self, o):
+ if isinstance(o, ObjectId):
+ return str(o)
+ return flask.json.JSONEncoder.default(self, o)
diff --git a/opendc-web/opendc-web-api/opendc/util/__init__.py b/opendc-web/opendc-web-api/opendc/util/__init__.py
deleted file mode 100644
index e69de29b..00000000
--- a/opendc-web/opendc-web-api/opendc/util/__init__.py
+++ /dev/null
diff --git a/opendc-web/opendc-web-api/opendc/util/exceptions.py b/opendc-web/opendc-web-api/opendc/util/exceptions.py
deleted file mode 100644
index 7724a407..00000000
--- a/opendc-web/opendc-web-api/opendc/util/exceptions.py
+++ /dev/null
@@ -1,64 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/util/json.py b/opendc-web/opendc-web-api/opendc/util/json.py
deleted file mode 100644
index 2ef4f965..00000000
--- a/opendc-web/opendc-web-api/opendc/util/json.py
+++ /dev/null
@@ -1,12 +0,0 @@
-import flask
-from bson.objectid import ObjectId
-
-
-class JSONEncoder(flask.json.JSONEncoder):
- """
- A customized JSON encoder to handle unsupported types.
- """
- def default(self, o):
- if isinstance(o, ObjectId):
- return str(o)
- return flask.json.JSONEncoder.default(self, o)
diff --git a/opendc-web/opendc-web-api/opendc/util/parameter_checker.py b/opendc-web/opendc-web-api/opendc/util/parameter_checker.py
deleted file mode 100644
index 14dd1dc0..00000000
--- a/opendc-web/opendc-web-api/opendc/util/parameter_checker.py
+++ /dev/null
@@ -1,85 +0,0 @@
-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/opendc-web/opendc-web-api/opendc/util/path_parser.py b/opendc-web/opendc-web-api/opendc/util/path_parser.py
deleted file mode 100644
index c8452f20..00000000
--- a/opendc-web/opendc-web-api/opendc/util/path_parser.py
+++ /dev/null
@@ -1,36 +0,0 @@
-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('{'):
- parameters[name.strip('{}')] = value
-
- return '{}/{}'.format(version, '/'.join(path)), parameters
diff --git a/opendc-web/opendc-web-api/opendc/util/rest.py b/opendc-web/opendc-web-api/opendc/util/rest.py
deleted file mode 100644
index c9e98295..00000000
--- a/opendc-web/opendc-web-api/opendc/util/rest.py
+++ /dev/null
@@ -1,141 +0,0 @@
-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, default=str)
diff --git a/opendc-web/opendc-web-api/requirements.txt b/opendc-web/opendc-web-api/requirements.txt
index 146f1717..bbad97d0 100644
--- a/opendc-web/opendc-web-api/requirements.txt
+++ b/opendc-web/opendc-web-api/requirements.txt
@@ -9,6 +9,8 @@ Flask==1.1.2
Flask-Compress==1.5.0
Flask-Cors==3.0.9
Flask-SocketIO==4.3.1
+flask-swagger-ui==3.36.0
+Flask-Restful==0.3.8
greenlet==0.4.17
httplib2==0.19.0
isort==4.3.21
@@ -16,6 +18,7 @@ itsdangerous==1.1.0
Jinja2==2.11.3
lazy-object-proxy==1.4.3
MarkupSafe==1.1.1
+marshmallow==3.12.1
mccabe==0.6.1
monotonic==1.5
more-itertools==8.6.0
@@ -33,8 +36,7 @@ pytest-cov==2.11.1
pytest-env==0.6.2
pytest-mock==3.2.0
python-dotenv==0.14.0
-python-engineio==3.13.2
-python-socketio==4.6.0
+python-jose==3.2.0
rsa==4.7
sentry-sdk==0.19.2
six==1.15.0
diff --git a/opendc-web/opendc-web-api/static/index.html b/opendc-web/opendc-web-api/static/index.html
deleted file mode 100644
index ac78cbfb..00000000
--- a/opendc-web/opendc-web-api/static/index.html
+++ /dev/null
@@ -1,22 +0,0 @@
-<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
diff --git a/opendc-web/opendc-web-api/static/schema.yml b/opendc-web/opendc-web-api/static/schema.yml
new file mode 100644
index 00000000..99e88095
--- /dev/null
+++ b/opendc-web/opendc-web-api/static/schema.yml
@@ -0,0 +1,1293 @@
+openapi: 3.0.0
+info:
+ version: 2.1.0
+ title: OpenDC REST API v2
+ description: OpenDC is an open-source datacenter simulator for education, featuring
+ real-time online collaboration, diverse simulation models, and detailed
+ performance feedback statistics.
+ license:
+ name: MIT
+ url: https://spdx.org/licenses/MIT
+ contact:
+ name: Support
+ url: https://opendc.org
+servers:
+ - url: https://api.opendc.org/v2
+externalDocs:
+ description: OpenDC REST API v2
+ url: https://api.opendc.com/v2/docs/
+security:
+ - auth0:
+ - openid
+paths:
+ /projects:
+ get:
+ tags:
+ - projects
+ description: List Projects of the active user
+ responses:
+ "200":
+ description: Successfully
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/Project"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ post:
+ tags:
+ - projects
+ description: Add a Project.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ name:
+ type: string
+ description: The new Project.
+ required: true
+ responses:
+ "200":
+ description: Successfully added Project.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Project"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "/projects/{projectId}":
+ get:
+ tags:
+ - projects
+ description: Get this Project.
+ parameters:
+ - name: projectId
+ in: path
+ description: Project's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Project.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Project"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Project.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Project not found
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ put:
+ tags:
+ - projects
+ description: Update this Project.
+ parameters:
+ - name: projectId
+ in: path
+ description: Project's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ project:
+ $ref: "#/components/schemas/Project"
+ description: Project's new properties.
+ required: true
+ responses:
+ "200":
+ description: Successfully updated Project.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Project"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from updating Project.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Project not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ delete:
+ tags:
+ - projects
+ description: Delete this project.
+ parameters:
+ - name: projectId
+ in: path
+ description: Project's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully deleted Project.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Project"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from deleting Project.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Project not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ "/projects/{projectId}/topologies":
+ post:
+ tags:
+ - projects
+ description: Add a Topology.
+ parameters:
+ - name: projectId
+ in: path
+ description: Project's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ topology:
+ $ref: "#/components/schemas/Topology"
+ description: The new Topology.
+ required: true
+ responses:
+ "200":
+ description: Successfully added Topology.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Topology"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "404":
+ description: Project not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ "/projects/{projectId}/portfolios":
+ post:
+ tags:
+ - portfolios
+ description: Add a Portfolio.
+ parameters:
+ - name: projectId
+ in: path
+ description: Project's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ topology:
+ $ref: "#/components/schemas/Portfolio"
+ description: The new Portfolio.
+ required: true
+ responses:
+ "200":
+ description: Successfully added Portfolio.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Portfolio"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "404":
+ description: Project not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ "/topologies/{topologyId}":
+ get:
+ tags:
+ - topologies
+ description: Get this Topology.
+ parameters:
+ - name: topologyId
+ in: path
+ description: Topology's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Topology.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Topology"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Topology.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Topology not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ put:
+ tags:
+ - topologies
+ description: Update this Topology's name.
+ parameters:
+ - name: topologyId
+ in: path
+ description: Topology's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ topology:
+ $ref: "#/components/schemas/Topology"
+ description: Topology's new properties.
+ required: true
+ responses:
+ "200":
+ description: Successfully updated Topology.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Topology"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Project.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Project not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ delete:
+ tags:
+ - topologies
+ description: Delete this Topology.
+ parameters:
+ - name: topologyId
+ in: path
+ description: Topology's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully deleted Topology.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Topology"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from deleting Topology.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Topology not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ "/portfolios/{portfolioId}":
+ get:
+ tags:
+ - portfolios
+ description: Get this Portfolio.
+ parameters:
+ - name: portfolioId
+ in: path
+ description: Portfolio's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Portfolio.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Portfolio"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Portfolio.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Portfolio not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ put:
+ tags:
+ - portfolios
+ description: Update this Portfolio.
+ parameters:
+ - name: portfolioId
+ in: path
+ description: Portfolio's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Portfolio"
+ description: Portfolio's new properties.
+ required: true
+ responses:
+ "200":
+ description: Successfully updated Portfolio.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Portfolio"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Portfolio.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Portfolio not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ delete:
+ tags:
+ - portfolios
+ description: Delete this Portfolio.
+ parameters:
+ - name: portfolioId
+ in: path
+ description: Portfolio's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully deleted Portfolio.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Portfolio"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Portfolio.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Portfolio not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ "/scenarios/{scenarioId}":
+ get:
+ tags:
+ - scenarios
+ description: Get this Scenario.
+ parameters:
+ - name: scenarioId
+ in: path
+ description: Scenario's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Scenario.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Scenario"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Scenario.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Scenario not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ put:
+ tags:
+ - scenarios
+ description: Update this Scenario's name (other properties are read-only).
+ parameters:
+ - name: scenarioId
+ in: path
+ description: Scenario's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ $ref: "#/components/schemas/Scenario"
+ description: Scenario with new name.
+ required: true
+ responses:
+ "200":
+ description: Successfully updated Scenario.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Scenario"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Scenario.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Scenario not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ delete:
+ tags:
+ - scenarios
+ description: Delete this Scenario.
+ parameters:
+ - name: scenarioId
+ in: path
+ description: Scenario's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully deleted Scenario.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Scenario"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Scenario.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Scenario not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ /schedulers:
+ get:
+ tags:
+ - simulation
+ description: Get all available Schedulers
+ responses:
+ "200":
+ description: Successfully retrieved Schedulers.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/Scheduler"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ /traces:
+ get:
+ tags:
+ - simulation
+ description: Get all available Traces
+ responses:
+ "200":
+ description: Successfully retrieved Traces.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ type: array
+ items:
+ type: object
+ properties:
+ _id:
+ type: string
+ name:
+ type: string
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "/traces/{traceId}":
+ get:
+ tags:
+ - simulation
+ description: Get this Trace.
+ parameters:
+ - name: traceId
+ in: path
+ description: Trace's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Trace.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Trace"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "404":
+ description: Trace not found
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ /prefabs:
+ get:
+ tags:
+ - prefabs
+ description: Get all Prefabs the user has rights to view.
+ responses:
+ "200":
+ description: Successfully retrieved prefabs the user is authorized on.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ type: array
+ items:
+ $ref: "#/components/schemas/Prefab"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ post:
+ tags:
+ - prefabs
+ description: Add a Prefab.
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ name:
+ type: string
+ description: The new Prefab.
+ required: true
+ responses:
+ "200":
+ description: Successfully added Prefab.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Prefab"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "/prefabs/{prefabId}":
+ get:
+ tags:
+ - prefabs
+ description: Get this Prefab.
+ parameters:
+ - name: prefabId
+ in: path
+ description: Prefab's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully retrieved Prefab.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Prefab"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Prefab.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Prefab not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ put:
+ tags:
+ - prefabs
+ description: Update this Prefab.
+ parameters:
+ - name: prefabId
+ in: path
+ description: Prefab's ID.
+ required: true
+ schema:
+ type: string
+ requestBody:
+ content:
+ application/json:
+ schema:
+ properties:
+ project:
+ $ref: "#/components/schemas/Prefab"
+ description: Prefab's new properties.
+ required: true
+ responses:
+ "200":
+ description: Successfully updated Prefab.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Prefab"
+ "400":
+ description: Missing or incorrectly typed parameter.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Invalid"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "403":
+ description: Forbidden from retrieving Prefab.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Forbidden"
+ "404":
+ description: Prefab not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+ delete:
+ tags:
+ - prefabs
+ description: Delete this prefab.
+ parameters:
+ - name: prefabId
+ in: path
+ description: Prefab's ID.
+ required: true
+ schema:
+ type: string
+ responses:
+ "200":
+ description: Successfully deleted Prefab.
+ content:
+ "application/json":
+ schema:
+ type: object
+ required:
+ - data
+ properties:
+ data:
+ $ref: "#/components/schemas/Prefab"
+ "401":
+ description: Unauthorized.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/Unauthorized"
+ "404":
+ description: Prefab not found.
+ content:
+ "application/json":
+ schema:
+ $ref: "#/components/schemas/NotFound"
+components:
+ securitySchemes:
+ auth0:
+ type: oauth2
+ x-token-validation-url: https://opendc.eu.auth0.com/userinfo
+ flows:
+ authorizationCode:
+ authorizationUrl: https://opendc.eu.auth0.com/authorize
+ tokenUrl: https://opendc.eu.auth0.com/oauth/token
+ scopes:
+ openid: Grants access to user_id
+ schemas:
+ Unauthorized:
+ type: object
+ required:
+ - message
+ properties:
+ message:
+ type: string
+ Invalid:
+ type: object
+ required:
+ - message
+ - errors
+ properties:
+ message:
+ type: string
+ errors:
+ type: array
+ items:
+ type: string
+ Forbidden:
+ type: object
+ required:
+ - message
+ properties:
+ message:
+ type: string
+ NotFound:
+ type: object
+ required:
+ - message
+ properties:
+ message:
+ type: string
+ Scheduler:
+ type: object
+ properties:
+ name:
+ type: string
+ Project:
+ type: object
+ properties:
+ _id:
+ type: string
+ name:
+ type: string
+ datetimeCreated:
+ type: string
+ format: dateTime
+ datetimeLastEdited:
+ type: string
+ format: dateTime
+ topologyIds:
+ type: array
+ items:
+ type: string
+ portfolioIds:
+ type: array
+ items:
+ type: string
+ authorizations:
+ type: array
+ items:
+ type: object
+ properties:
+ userId:
+ type: string
+ level:
+ type: string
+ enum: ['OWN', 'EDIT', 'VIEW']
+ Topology:
+ type: object
+ properties:
+ _id:
+ type: string
+ projectId:
+ type: string
+ name:
+ type: string
+ rooms:
+ type: array
+ items:
+ type: object
+ properties:
+ _id:
+ type: string
+ name:
+ type: string
+ tiles:
+ type: array
+ items:
+ type: object
+ properties:
+ _id:
+ type: string
+ positionX:
+ type: integer
+ positionY:
+ type: integer
+ object:
+ type: object
+ properties:
+ capacity:
+ type: integer
+ powerCapacityW:
+ type: integer
+ machines:
+ type: array
+ items:
+ type: object
+ properties:
+ position:
+ type: integer
+ cpuItems:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ clockRateMhz:
+ type: integer
+ numberOfCores:
+ type: integer
+ gpuItems:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ clockRateMhz:
+ type: integer
+ numberOfCores:
+ type: integer
+ memoryItems:
+ type: array
+ items:
+ type: object
+ properties:
+ name:
+ type: string
+ speedMbPerS:
+ type: integer
+ sizeMb:
+ type: integer
+ storageItems:
+ type: array
+ items:
+ type: integer
+ properties:
+ name:
+ type: string
+ speedMbPerS:
+ type: integer
+ sizeMb:
+ type: integer
+ Portfolio:
+ type: object
+ properties:
+ _id:
+ type: string
+ projectId:
+ type: string
+ name:
+ type: string
+ scenarioIds:
+ type: array
+ items:
+ type: string
+ targets:
+ type: object
+ properties:
+ enabledMetrics:
+ type: array
+ items:
+ type: string
+ repeatsPerScenario:
+ type: integer
+ Scenario:
+ type: object
+ properties:
+ _id:
+ type: string
+ portfolioId:
+ type: string
+ name:
+ type: string
+ simulation:
+ type: object
+ properties:
+ state:
+ type: string
+ results:
+ type: object
+ trace:
+ type: object
+ properties:
+ traceId:
+ type: string
+ loadSamplingFraction:
+ type: number
+ topology:
+ type: object
+ properties:
+ topologyId:
+ type: string
+ operational:
+ type: object
+ properties:
+ failuresEnabled:
+ type: boolean
+ performanceInterferenceEnabled:
+ type: boolean
+ schedulerName:
+ type: string
+ Trace:
+ type: object
+ properties:
+ _id:
+ type: string
+ name:
+ type: string
+ path:
+ type: string
+ type:
+ type: string
+ Prefab:
+ type: object
+ properties:
+ _id:
+ type: string
+ name:
+ type: string
+ datetimeCreated:
+ type: string
+ format: dateTime
+ datetimeLastEdited:
+ type: string
+ format: dateTime
diff --git a/opendc-web/opendc-web-api/tests/api/test_portfolios.py b/opendc-web/opendc-web-api/tests/api/test_portfolios.py
new file mode 100644
index 00000000..da7991f6
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_portfolios.py
@@ -0,0 +1,324 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_portfolio_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/portfolios/{test_id}').status
+
+
+def test_get_portfolio_no_authorizations(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
+ res = client.get(f'/portfolios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_portfolio_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ 'projectId': test_id,
+ '_id': test_id,
+ 'authorizations': []
+ })
+ res = client.get(f'/portfolios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_portfolio(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ 'projectId': test_id,
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ res = client.get(f'/portfolios/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_portfolio_missing_parameter(client):
+ assert '400' in client.put(f'/portfolios/{test_id}').status
+
+
+def test_update_portfolio_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/portfolios/{test_id}', 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': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/portfolios/{test_id}', 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': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }],
+ 'targets': {
+ 'enabledMetrics': [],
+ 'repeatsPerScenario': 1
+ }
+ })
+ mocker.patch.object(db, 'update', return_value={})
+
+ res = client.put(f'/portfolios/{test_id}', 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(f'/portfolios/{test_id}').status
+
+
+def test_delete_project_different_user(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'other_test',
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/portfolios/{test_id}').status
+
+
+def test_delete_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value={})
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.delete(f'/portfolios/{test_id}')
+ assert '200' in res.status
+
+
+def test_add_topology_missing_parameter(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ assert '400' in client.post(f'/projects/{test_id}/topologies').status
+
+
+def test_add_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }],
+ 'topologyIds': []
+ })
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'topologyIds': []
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ res = client.post(f'/projects/{test_id}/topologies', json={'topology': {'name': 'test project', 'rooms': []}})
+ assert 'rooms' in res.json['data']
+ assert '200' in res.status
+
+
+def test_add_topology_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ assert '403' in client.post(f'/projects/{test_id}/topologies',
+ json={
+ 'topology': {
+ 'name': 'test_topology',
+ 'rooms': []
+ }
+ }).status
+
+
+def test_add_portfolio_missing_parameter(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ assert '400' in client.post(f'/projects/{test_id}/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(f'/projects/{test_id}/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': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ assert '403' in client.post(f'/projects/{test_id}/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': test_id,
+ 'projectId': test_id,
+ 'portfolioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ },
+ 'projectId': test_id,
+ 'scenarioIds': [],
+ })
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.post(
+ f'/projects/{test_id}/portfolios',
+ json={
+ 'portfolio': {
+ 'name': 'test',
+ 'targets': {
+ 'enabledMetrics': ['test'],
+ 'repeatsPerScenario': 2
+ }
+ }
+ })
+ assert 'projectId' in res.json['data']
+ assert 'scenarioIds' in res.json['data']
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_prefabs.py b/opendc-web/opendc-web-api/tests/api/test_prefabs.py
new file mode 100644
index 00000000..ea3d92d6
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_prefabs.py
@@ -0,0 +1,252 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from unittest.mock import Mock
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_add_prefab_missing_parameter(client):
+ assert '400' in client.post('/prefabs/').status
+
+
+def test_add_prefab(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id
+ })
+ res = client.post('/prefabs/', json={'prefab': {'name': 'test prefab'}})
+ assert 'datetimeCreated' in res.json['data']
+ assert 'datetimeLastEdited' in res.json['data']
+ assert 'authorId' in res.json['data']
+ assert '200' in res.status
+
+
+def test_get_prefabs(client, mocker):
+ db.fetch_all = Mock()
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id})
+ db.fetch_all.side_effect = [
+ [{
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'private'
+ },
+ {
+ '_id': '2' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'private'
+ },
+ {
+ '_id': '3' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '4' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': test_id,
+ 'visibility' : 'public'
+ }],
+ [{
+ '_id': '5' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '6' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '7' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ },
+ {
+ '_id': '8' * 24,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'authorId': '2' * 24,
+ 'visibility' : 'public'
+ }]
+ ]
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id})
+ res = client.get('/prefabs/')
+ assert '200' in res.status
+
+
+def test_get_prefab_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/prefabs/{test_id}').status
+
+
+def test_get_private_prefab_not_authorized(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ res = client.get(f'/prefabs/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_private_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': 'test',
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ res = client.get(f'/prefabs/{test_id}')
+ assert '200' in res.status
+
+
+def test_get_public_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'public',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ res = client.get(f'/prefabs/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_prefab_missing_parameter(client):
+ assert '400' in client.put(f'/prefabs/{test_id}').status
+
+
+def test_update_prefab_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'S'}}).status
+
+
+def test_update_prefab_not_authorized(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}}).status
+
+
+def test_update_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': 'test',
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'update', return_value={})
+ res = client.put(f'/prefabs/{test_id}', json={'prefab': {'name': 'test prefab', 'rack': {}}})
+ assert '200' in res.status
+
+
+def test_delete_prefab_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.delete(f'/prefabs/{test_id}').status
+
+
+def test_delete_prefab_different_user(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': test_id_2,
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/prefabs/{test_id}').status
+
+
+def test_delete_prefab(client, mocker):
+ db.fetch_one = Mock()
+ db.fetch_one.side_effect = [{
+ '_id': test_id,
+ 'name': 'test prefab',
+ 'authorId': 'test',
+ 'visibility': 'private',
+ 'rack': {}
+ },
+ {
+ '_id': test_id
+ }
+ ]
+ mocker.patch.object(db, 'delete_one', return_value={'prefab': {'name': 'name'}})
+ res = client.delete(f'/prefabs/{test_id}')
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_projects.py b/opendc-web/opendc-web-api/tests/api/test_projects.py
new file mode 100644
index 00000000..c4c82e0d
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_projects.py
@@ -0,0 +1,167 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+
+
+def test_get_user_projects(client, mocker):
+ mocker.patch.object(db, 'fetch_all', return_value={'_id': test_id, 'authorizations': [{'userId': 'test',
+ 'level': 'OWN'}]})
+ res = client.get('/projects/')
+ assert '200' in res.status
+
+
+def test_add_project_missing_parameter(client):
+ assert '400' in client.post('/projects/').status
+
+
+def test_add_project(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'_id': test_id, 'authorizations': []})
+ mocker.patch.object(db,
+ 'insert',
+ return_value={
+ '_id': test_id,
+ 'datetimeCreated': '000',
+ 'datetimeLastEdited': '000',
+ 'topologyIds': []
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ res = client.post('/projects/', json={'project': {'name': 'test project'}})
+ assert 'datetimeCreated' in res.json['data']
+ assert 'datetimeLastEdited' in res.json['data']
+ assert 'topologyIds' in res.json['data']
+ assert '200' in res.status
+
+
+def test_get_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/projects/{test_id}').status
+
+
+def test_get_project_no_authorizations(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'authorizations': []})
+ res = client.get(f'/projects/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_project_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': []
+ })
+ res = client.get(f'/projects/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ res = client.get(f'/projects/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_project_missing_parameter(client):
+ assert '400' in client.put(f'/projects/{test_id}').status
+
+
+def test_update_project_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status
+
+
+def test_update_project_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/projects/{test_id}', json={'project': {'name': 'S'}}).status
+
+
+def test_update_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+
+ res = client.put(f'/projects/{test_id}', 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(f'/projects/{test_id}').status
+
+
+def test_delete_project_different_user(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'googleId': 'other_test',
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'VIEW'
+ }],
+ 'topologyIds': []
+ })
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/projects/{test_id}').status
+
+
+def test_delete_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'googleId': 'test',
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }],
+ 'topologyIds': [],
+ 'portfolioIds': [],
+ })
+ mocker.patch.object(db, 'update', return_value=None)
+ mocker.patch.object(db, 'delete_one', return_value={'googleId': 'test'})
+ res = client.delete(f'/projects/{test_id}')
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_scenarios.py b/opendc-web/opendc-web-api/tests/api/test_scenarios.py
new file mode 100644
index 00000000..bdd5c4a3
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_scenarios.py
@@ -0,0 +1,135 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_scenario_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/scenarios/{test_id}').status
+
+
+def test_get_scenario_no_authorizations(client, mocker):
+ m = mocker.MagicMock()
+ m.side_effect = ({'portfolioId': test_id}, {'projectId': test_id}, {'authorizations': []})
+ mocker.patch.object(db, 'fetch_one', m)
+ res = client.get(f'/scenarios/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_scenario(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'OWN'}]
+ }])
+ res = client.get(f'/scenarios/{test_id}')
+ assert '200' in res.status
+
+
+def test_update_scenario_missing_parameter(client):
+ assert '400' in client.put(f'/scenarios/{test_id}').status
+
+
+def test_update_scenario_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/scenarios/{test_id}', json={
+ 'scenario': {
+ 'name': 'test',
+ }
+ }).status
+
+
+def test_update_scenario_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'VIEW'}]
+ }])
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/scenarios/{test_id}', json={
+ 'scenario': {
+ 'name': 'test',
+ }
+ }).status
+
+
+def test_update_scenario(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'_id': test_id, 'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'OWN'}]
+ }])
+ mocker.patch.object(db, 'update', return_value={})
+
+ res = client.put(f'/scenarios/{test_id}', 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(f'/scenarios/{test_id}').status
+
+
+def test_delete_project_different_user(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ side_effect=[
+ {'_id': test_id, 'portfolioId': test_id},
+ {'projectId': test_id},
+ {'authorizations':
+ [{'userId': 'test', 'level': 'VIEW'}]
+ }])
+ mocker.patch.object(db, 'delete_one', return_value=None)
+ assert '403' in client.delete(f'/scenarios/{test_id}').status
+
+
+def test_delete_project(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'portfolioId': test_id,
+ 'googleId': 'test',
+ 'scenarioIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value={})
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.delete(f'/scenarios/{test_id}')
+ assert '200' in res.status
diff --git a/opendc-web/opendc-web-api/tests/api/test_schedulers.py b/opendc-web/opendc-web-api/tests/api/test_schedulers.py
new file mode 100644
index 00000000..5d9e6995
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_schedulers.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+def test_get_schedulers(client):
+ assert '200' in client.get('/schedulers/').status
diff --git a/opendc-web/opendc-web-api/tests/api/test_topologies.py b/opendc-web/opendc-web-api/tests/api/test_topologies.py
new file mode 100644
index 00000000..6e7c54ef
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_topologies.py
@@ -0,0 +1,140 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+test_id_2 = 24 * '2'
+
+
+def test_get_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'EDIT'
+ }]
+ })
+ res = client.get(f'/topologies/{test_id}')
+ 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('/topologies/1').status
+
+
+def test_get_topology_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': []
+ })
+ res = client.get(f'/topologies/{test_id}')
+ assert '403' in res.status
+
+
+def test_get_topology_no_authorizations(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'projectId': test_id, 'authorizations': []})
+ res = client.get(f'/topologies/{test_id}')
+ assert '403' in res.status
+
+
+def test_update_topology_missing_parameter(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': []
+ })
+ assert '400' in client.put(f'/topologies/{test_id}').status
+
+
+def test_update_topology_non_existent(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.put(f'/topologies/{test_id}', json={'topology': {'name': 'test_topology', 'rooms': []}}).status
+
+
+def test_update_topology_not_authorized(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': []
+ })
+ mocker.patch.object(db, 'update', return_value={})
+ assert '403' in client.put(f'/topologies/{test_id}', json={
+ 'topology': {
+ 'name': 'updated_topology',
+ 'rooms': []
+ }
+ }).status
+
+
+def test_update_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'update', return_value={})
+
+ assert '200' in client.put(f'/topologies/{test_id}', json={
+ 'topology': {
+ 'name': 'updated_topology',
+ 'rooms': []
+ }
+ }).status
+
+
+def test_delete_topology(client, mocker):
+ mocker.patch.object(db,
+ 'fetch_one',
+ return_value={
+ '_id': test_id,
+ 'projectId': test_id,
+ 'googleId': 'test',
+ 'topologyIds': [test_id],
+ 'authorizations': [{
+ 'userId': 'test',
+ 'level': 'OWN'
+ }]
+ })
+ mocker.patch.object(db, 'delete_one', return_value={})
+ mocker.patch.object(db, 'update', return_value=None)
+ res = client.delete(f'/topologies/{test_id}')
+ 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(f'/topologies/{test_id}').status
diff --git a/opendc-web/opendc-web-api/tests/api/test_traces.py b/opendc-web/opendc-web-api/tests/api/test_traces.py
new file mode 100644
index 00000000..0b252c2f
--- /dev/null
+++ b/opendc-web/opendc-web-api/tests/api/test_traces.py
@@ -0,0 +1,40 @@
+# Copyright (c) 2021 AtLarge Research
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+
+from opendc.exts import db
+
+test_id = 24 * '1'
+
+
+def test_get_traces(client, mocker):
+ mocker.patch.object(db, 'fetch_all', return_value=[])
+ assert '200' in client.get('/traces/').status
+
+
+def test_get_trace_non_existing(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value=None)
+ assert '404' in client.get(f'/traces/{test_id}').status
+
+
+def test_get_trace(client, mocker):
+ mocker.patch.object(db, 'fetch_one', return_value={'name': 'test trace'})
+ res = client.get(f'/traces/{test_id}')
+ assert 'name' in res.json['data']
+ assert '200' in res.status