diff --git a/Makefile b/Makefile
index 91b19b9..a952d1f 100644
--- a/Makefile
+++ b/Makefile
@@ -24,7 +24,7 @@ VIRTUAL_ENV ?= venv
PYTHON_MODULES := $(shell find . -name '*.py')
DOCKER_COMPOSE := $(shell which docker-compose)
-export TWINE_NON_INTERACTIVE=1
+export HATCH_INDEX_USER = __token__
.SILENT: help
.PHONY: setup docs clean
@@ -77,8 +77,8 @@ docs: setup ## Build the documentation
$(VIRTUAL_ENV)/bin/sphinx-build -a docs docs/_build
publish-test: setup ## Publish the library to test pypi
- $(VIRTUAL_ENV)/bin/python setup.py sdist bdist_wheel
- $(VIRTUAL_ENV)/bin/python -m twine upload --repository-url https://test.pypi.org/legacy/ dist/*
+ $(VIRTUAL_ENV)/bin/hatch build -t sdist -t wheel
+ $(VIRTUAL_ENV)/bin/hatch publish --repo https://test.pypi.org/legacy/ dist/*
publish: setup ## Publish the library to pypi
$(VIRTUAL_ENV)/bin/python setup.py sdist bdist_wheel
diff --git a/cannula/__init__.py b/cannula/__init__.py
index 9db57d4..c5a9035 100644
--- a/cannula/__init__.py
+++ b/cannula/__init__.py
@@ -5,7 +5,7 @@
)
from .errors import format_errors
from .utils import gql
-from .schema import build_and_extend_schema
+from .schema import build_and_extend_schema, load_schema
__all__ = [
"API",
@@ -14,6 +14,7 @@
"format_errors",
"gql",
"build_and_extend_schema",
+ "load_schema",
]
-__VERSION__ = "0.0.4"
+__VERSION__ = "0.10.0"
diff --git a/cannula/api.py b/cannula/api.py
index 871762e..443c6a3 100644
--- a/cannula/api.py
+++ b/cannula/api.py
@@ -11,7 +11,7 @@
import functools
import logging
import inspect
-import os
+import pathlib
import typing
from graphql import (
@@ -27,7 +27,6 @@
)
from .context import Context
-from .helpers import get_root_path
from .schema import (
build_and_extend_schema,
fix_abstract_resolve_type,
@@ -44,104 +43,68 @@ class ParseResults(typing.NamedTuple):
class Resolver:
- """Resolver Registry
+ """
+ Resolver Registry
+ -----------------
This class is a helper to organize your project as it grows. It allows you
to put your resolver modules and schema in different packages. For example::
app/
- api.py # `api = cannula.API(__name__)`
+ api.py # `api = cannula.API(args)`
resolvers/
- subpackage/
- app.py # `app = cannula.Resolver(__name__)`
- schema/
- myschema.graphql
+ books.py # `books = cannula.Resolver(args)`
+ movies.py # `movies = cannula.Resolver(args)`
+
+
+ You then register resolvers and dataloaders in the same way:
- You then register resolvers and dataloaders in the same way::
+ resolvers/books.py::
- app.py:
import cannula
- app = cannula.Resolver(__name__)
+ books = cannula.Resolver()
- @app.resolver('Query')
- def my_query(source, info):
+ @books.resolver('Query', 'books')
+ def get_books(source, info, args):
return 'Hello'
- api.py:
+ resolvers/moives.py::
+
import cannula
- from resolvers.subpackage.app import app
+ movies = cannula.Resolver()
+
+ @movies.resolver('Query', 'movies')
+ def get_movies(source, info, args):
+ return 'Hello'
+
+ app/api.py::
+
+ import cannula
+
+ from resolvers.books import books
+ from resolvers.movies import movies
+
+ api = cannula.API(schema=SCHEMA)
+ api.include_resolver(books)
+ api.include_resolver(movies)
- api = cannula.API(__name__)
- api.register_resolver(app)
- :param name: The import name of the resolver, typically `__name__`
- :param schema: GraphQL Schema for this resolver.
- :param schema_directory: Directory name to search for schema files.
- :param query_directory: Directory name to search for query docs.
"""
- # Allow sub-resolvers to apply a base schema before applying custom schema.
- base_schema: typing.Dict[str, DocumentNode] = {}
registry: typing.Dict[str, dict]
datasources: typing.Dict[str, typing.Any]
- _schema_dir: str
def __init__(
self,
- name: str,
- schema: typing.List[typing.Union[str, DocumentNode]] = [],
- schema_directory: str = "schema",
- query_directory: str = "queries",
):
self.registry = collections.defaultdict(dict)
self.datasources = {}
- self._schema_directory = schema_directory
- self._query_directory = query_directory
- self.root_dir = get_root_path(name)
- self._schema = schema
-
- @property
- def schema_directory(self):
- if not hasattr(self, "_schema_dir"):
- if os.path.isabs(self._schema_directory):
- setattr(self, "_schema_dir", self._schema_directory)
- setattr(
- self, "_schema_dir", os.path.join(self.root_dir, self._schema_directory)
- )
- return self._schema_dir
-
- def find_schema(self) -> typing.List[DocumentNode]:
- schemas: typing.List[DocumentNode] = []
- if os.path.isdir(self.schema_directory):
- LOG.debug(f"Searching {self.schema_directory} for schema.")
- schemas = load_schema(self.schema_directory)
-
- for schema in self._schema:
- schemas.append(maybe_parse(schema))
-
- return schemas
- @property
- def query_directory(self) -> str:
- if not hasattr(self, "_query_dir"):
- if os.path.isabs(self._query_directory):
- self._query_dir: str = self._query_directory
- self._query_dir = os.path.join(self.root_dir, self._query_directory)
- return self._query_dir
-
- @functools.lru_cache(maxsize=128)
- def load_query(self, query_name: str) -> DocumentNode:
- path = os.path.join(self.query_directory, f"{query_name}.graphql")
- assert os.path.isfile(path), f"No query found for {query_name}"
-
- with open(path, "r") as query:
- return parse(query.read())
-
- def resolver(self, type_name: str = "Query") -> typing.Any:
+ def resolver(self, type_name: str, field_name: str) -> typing.Any:
def decorator(function):
- self.registry[type_name][function.__name__] = function
+ self.registry[type_name][field_name] = function
return decorator
@@ -153,13 +116,19 @@ def decorator(klass):
class API(Resolver):
- """Cannula API
+ """
+ :param schema: GraphQL Schema for this resolver.
+ :param context: Context class to hold shared state, added to GraphQLResolveInfo object.
+ :param middleware: List of middleware to enable.
+
+ Cannula API
+ -----------
Your entry point into the fun filled world of graphql. Just dive right in::
import cannula
- api = cannula.API(__name__, schema='''
+ api = cannula.API(schema='''
extend type Query {
hello(who: String): String
}
@@ -170,41 +139,44 @@ def hello(who):
return f'Hello {who}!'
"""
+ _schema: typing.Union[str, DocumentNode, pathlib.Path]
+ _resolvers: typing.List[Resolver]
+
def __init__(
self,
- *args,
- resolvers: typing.List[Resolver] = [],
- context: typing.Any = Context,
+ schema: typing.Union[str, DocumentNode, pathlib.Path],
+ context: typing.Optional[Context] = None,
middleware: typing.List[typing.Any] = [],
**kwargs,
):
- super().__init__(*args, **kwargs)
- self._context = context
- self._resolvers = resolvers
+ super().__init__(**kwargs)
+ self._context = context or Context
+ self._resolvers = []
+ self._schema = schema
self.middleware = middleware
+ def include_resolver(self, resolver: Resolver):
+ self._merge_registry(resolver.registry)
+ self.datasources.update(resolver.datasources)
+
+ def _find_schema(self) -> typing.List[DocumentNode]:
+ schemas: typing.List[DocumentNode] = []
+
+ if isinstance(self._schema, pathlib.Path):
+ schemas.extend(load_schema(self._schema))
+ else:
+ schemas.append(maybe_parse(self._schema))
+
+ return schemas
+
@property
def schema(self) -> GraphQLSchema:
if not hasattr(self, "_full_schema"):
self._full_schema = self._build_schema()
return self._full_schema
- def _all_schema(self) -> typing.Iterator[DocumentNode]:
- for document_node in self.find_schema():
- yield document_node
-
- for resolver in self._resolvers:
- self._merge_registry(resolver.registry)
- self.base_schema.update(resolver.base_schema)
- self.datasources.update(resolver.datasources)
- for document_node in resolver.find_schema():
- yield document_node
-
- for document_node in self.base_schema.values():
- yield document_node
-
def _build_schema(self) -> GraphQLSchema:
- schema = build_and_extend_schema(self._all_schema())
+ schema = build_and_extend_schema(self._find_schema())
schema_validation_errors = validate_schema(schema)
if schema_validation_errors:
@@ -238,7 +210,7 @@ def decorator(klass):
return decorator
def get_context(self, request):
- context = self._context(request)
+ context = self._context.init(request)
# Initialize the datasources with a copy of the context without
# any of the datasource attributes set. It may work just fine but
# if you change the order the code may stop working. So discourage
@@ -253,12 +225,12 @@ def _merge_registry(self, registry: dict):
self.registry[type_name].update(value)
@functools.lru_cache(maxsize=128)
- def validate(self, document: DocumentNode) -> typing.List[GraphQLError]:
+ def _validate(self, document: DocumentNode) -> typing.List[GraphQLError]:
"""Validate the document against the schema and store results in lru_cache."""
return validate(self.schema, document)
@functools.lru_cache(maxsize=128)
- def parse_document(self, document: str) -> ParseResults:
+ def _parse_document(self, document: str) -> ParseResults:
"""Parse and store the document in lru_cache."""
try:
document_ast = parse(document)
@@ -278,11 +250,11 @@ async def call(
web framework that is synchronous use the `call_sync` method.
"""
if isinstance(document, str):
- document, errors = self.parse_document(document)
+ document, errors = self._parse_document(document)
if errors:
return ExecutionResult(data=None, errors=errors)
- if validation_errors := self.validate(document):
+ if validation_errors := self._validate(document):
return ExecutionResult(data=None, errors=validation_errors)
context = self.get_context(request)
diff --git a/cannula/context.py b/cannula/context.py
index ea9b4c1..c466d3e 100644
--- a/cannula/context.py
+++ b/cannula/context.py
@@ -11,5 +11,9 @@ class Context:
def __init__(self, request: typing.Any):
self.request = self.handle_request(request)
+ @classmethod
+ def init(cls, request: typing.Any):
+ return cls(request)
+
def handle_request(self, request: typing.Any) -> typing.Any:
return request
diff --git a/cannula/contrib/asgi.py b/cannula/contrib/asgi.py
index c04782b..9b6d440 100644
--- a/cannula/contrib/asgi.py
+++ b/cannula/contrib/asgi.py
@@ -4,6 +4,8 @@
class GraphQLPayload(pydantic.BaseModel):
+ """Model representing a GraphQL request body."""
+
query: str
variables: typing.Optional[typing.Dict[str, typing.Any]] = None
operation: typing.Optional[str] = None
diff --git a/cannula/helpers.py b/cannula/helpers.py
deleted file mode 100644
index 782bdd9..0000000
--- a/cannula/helpers.py
+++ /dev/null
@@ -1,26 +0,0 @@
-import os
-import pkgutil
-import sys
-
-
-def get_root_path(import_name):
- """Returns the path to a package or cwd if that cannot be found.
-
- Inspired by [flask](https://github.com/pallets/flask/blob/master/flask/helpers.py)
- """
- # Module already imported and has a file attribute. Use that first.
- mod = sys.modules.get(import_name)
- if mod is not None and hasattr(mod, "__file__"):
- return os.path.dirname(os.path.abspath(mod.__file__))
-
- # Next attempt: check the loader.
- loader = pkgutil.get_loader(import_name)
-
- # Loader does not exist or we're referring to an unloaded main module
- # or a main module without path (interactive sessions), go with the
- # current working directory.
- if loader is None or import_name == "__main__":
- return os.getcwd()
-
- filepath = loader.get_filename(import_name)
- return os.path.dirname(os.path.abspath(filepath))
diff --git a/cannula/middleware/mocks.py b/cannula/middleware/mocks.py
index da4a132..2960ba8 100644
--- a/cannula/middleware/mocks.py
+++ b/cannula/middleware/mocks.py
@@ -312,10 +312,7 @@ def get_mocks_from_headers(self, context: typing.Any) -> dict:
return {}
async def run_next(self, _next, _resource, _info, **kwargs):
- if inspect.isawaitable(_next):
- results = await _next(_resource, _info, **kwargs)
- else:
- results = _next(_resource, _info, **kwargs)
+ results = _next(_resource, _info, **kwargs)
if inspect.isawaitable(results):
return await results
diff --git a/cannula/schema.py b/cannula/schema.py
index e0142cd..79a108b 100644
--- a/cannula/schema.py
+++ b/cannula/schema.py
@@ -1,5 +1,4 @@
import logging
-import os
import pathlib
import typing
import itertools
@@ -107,13 +106,23 @@ def custom_resolve_type(
return schema
-def load_schema(directory: str) -> typing.List[DocumentNode]:
- assert os.path.isdir(directory), f"Directory not found: {directory}"
- path = pathlib.Path(directory)
+def load_schema(
+ directory: typing.Union[str, pathlib.Path]
+) -> typing.List[DocumentNode]:
+ if isinstance(directory, str):
+ LOG.debug(f"Converting str {directory} to path object")
+ directory = pathlib.Path(directory)
+
+ if directory.is_file():
+ LOG.debug(f"loading schema from file: {directory}")
+ with open(directory.absolute()) as graphfile:
+ return [parse(graphfile.read())]
def find_graphql_files():
- for graph in path.glob("**/*.graphql"):
- with open(os.path.join(directory, graph)) as graphfile:
+ LOG.debug(f"Checking for graphql files to load in: '{directory}'")
+ for graph in directory.glob("**/*.graphql"):
+ LOG.debug(f"loading discovered file: {graph}")
+ with open(graph) as graphfile:
yield graphfile.read()
return [parse(schema) for schema in find_graphql_files()]
diff --git a/docs/conf.py b/docs/conf.py
index 492d3a7..1644d0b 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -15,6 +15,7 @@
sys.path.insert(0, os.path.abspath(".."))
+import cannula
# -- Project information -----------------------------------------------------
@@ -23,7 +24,7 @@
author = "Robert Myers"
# The full version, including alpha/beta/rc tags
-release = "0.0.2"
+release = cannula.__VERSION__
# -- General configuration ---------------------------------------------------
diff --git a/docs/index.rst b/docs/index.rst
index e4eda77..6d34dce 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -24,8 +24,8 @@ Installation
Requirements:
-* Python 3.6+
-* `graphql-core-next `_
+* Python 3.8+
+* `graphql-core `_
Use pip::
diff --git a/examples/cloud/resolvers/__init__.py b/examples/__init__.py
similarity index 100%
rename from examples/cloud/resolvers/__init__.py
rename to examples/__init__.py
diff --git a/examples/cloud/Dockerfile b/examples/cloud/Dockerfile
deleted file mode 100644
index 08a422b..0000000
--- a/examples/cloud/Dockerfile
+++ /dev/null
@@ -1,13 +0,0 @@
-FROM python:3.6
-
-ENV PYTHONPATH=/external/lib
-
-WORKDIR /app
-
-COPY requirements.txt requirements.txt
-
-RUN pip install -r requirements.txt
-
-COPY / /app
-
-CMD [ "python", "app.py" ]
diff --git a/examples/cloud/Makefile b/examples/cloud/Makefile
deleted file mode 100644
index 63badf0..0000000
--- a/examples/cloud/Makefile
+++ /dev/null
@@ -1,9 +0,0 @@
-
-build:
- docker-compose build
-
-up:
- docker-compose up
-
-stop:
- docker-compose stop
diff --git a/examples/cloud/README.md b/examples/cloud/README.md
deleted file mode 100644
index 80418de..0000000
--- a/examples/cloud/README.md
+++ /dev/null
@@ -1,59 +0,0 @@
-Openstack Private Cloud
------------------------
-
-This is an full example of using GraphQL and Cannula on a mock Openstack
-private cloud instance. Openstack is sufficiently complicated to show off the
-power of Cannula to simplify your UI with GraphQL.
-
-This example shows interaction with 4 different apis:
-* Keystone (identity)
-* Neutron (networks)
-* Nova (compute)
-* Cinder (block storage)
-
-You *might* be able to point this at a real Openstack cloud :shrug:
-
-Quick Start
------------
-
-You can start the mock cloud with docker-compose like:
-
-```bash
-$ docker-compose up
-```
-
-Or use the make:
-```
-$ make up
-```
-
-View the mock site at http://localhost:8080/
-
-Login with any username or password that you like. If you use use `admin` or
-`readonly` as the username the UI will unlock or lock features accordingly.
-
-Then view the playground at http://localhost:8080/graphql
-
-Application Details
--------------------
-
-This example application uses [bottle](https://bottlepy.org/docs/dev/) because
-it is small and simple enough that it does not get in the way of our example
-application logic. You can use any framework, or none at all, and get the
-same results.
-
-Here is the project layout:
-
-```
-app.py # API and Views for the application (bottle).
-session.py # Simple in memory session.
-mock_server.py # Our mock API's mimics the services we use.
-resolvers/ # The custom resolvers for our application.
- base.py # Base OpenStack API resolver.
- compute/ # Schema and resolvers for the `compute` API.
- identity/ # Schema and resolvers for the `identity` API.
- network/ # Schema and resolvers for the `network` API.
- storage/ # Schema and resolvers for the `block storage` API.
-static/ # Javascript and CSS for the site.
-views/ # Templates for bottle views.
-```
diff --git a/examples/cloud/api.py b/examples/cloud/api.py
deleted file mode 100644
index e6b833c..0000000
--- a/examples/cloud/api.py
+++ /dev/null
@@ -1,31 +0,0 @@
-"""
-Example Cannula API Usage
-=========================
-
-This is module that joins our schema and our resolvers. I have this in a
-different module so that we can import it separately for testing.
-"""
-
-import cannula
-
-from resolvers.application import application_resolver
-from resolvers.compute import compute_resolver
-from resolvers.dashboard import dashboard_resolver
-from resolvers.identity import identity_resolver
-from resolvers.network import network_resolver
-from resolvers.volume import volume_resolver
-from session import OpenStackContext
-
-
-api = cannula.API(
- __name__,
- resolvers=[
- application_resolver,
- compute_resolver,
- identity_resolver,
- network_resolver,
- volume_resolver,
- dashboard_resolver,
- ],
- context=OpenStackContext,
-)
diff --git a/examples/cloud/app.py b/examples/cloud/app.py
deleted file mode 100644
index 0252bee..0000000
--- a/examples/cloud/app.py
+++ /dev/null
@@ -1,101 +0,0 @@
-import logging
-import os
-
-import cannula
-import uvicorn
-from cannula.middleware import MockMiddleware, DebugMiddleware
-from starlette.applications import Starlette
-from starlette.responses import JSONResponse, RedirectResponse
-from starlette.staticfiles import StaticFiles
-from starlette.templating import Jinja2Templates
-
-import session
-from api import api
-
-PORT = os.getenv("PORT", "8081")
-STATIC = os.path.join(os.getcwd(), "static")
-USE_MOCKS = bool(os.getenv("USE_MOCKS", False))
-CURRENT_DIR = os.path.abspath(os.path.dirname(__file__))
-
-logging.basicConfig(level=logging.DEBUG)
-templates = Jinja2Templates(directory="templates")
-
-LOG = logging.getLogger("application")
-
-app = Starlette(debug=True)
-app.mount("/static", StaticFiles(directory="static"), name="static")
-
-
-@app.route("/dashboard")
-async def dashboard(request):
- LOG.info(request.headers)
- if "xhr" in request.query_params:
- results = await api.call(
- api.load_query("dashboard"),
- variables={"region": "us-east"},
- request=request,
- )
-
- resp = {
- "errors": cannula.format_errors(results.errors),
- "data": results.data or {},
- }
-
- return JSONResponse(resp)
-
- if not session.is_authenticated(request):
- return RedirectResponse("/")
-
- return templates.TemplateResponse("index.html", {"request": request})
-
-
-@app.route("/simple")
-async def simple(request):
- if "xhr" in request.query_params:
- results = await api.call(
- api.load_query("simple"), variables={"region": "us-east"}, request=request
- )
-
- resp = {
- "errors": cannula.format_errors(results.errors),
- "data": results.data or {},
- }
-
- return JSONResponse(resp)
-
- if not session.is_authenticated(request):
- return RedirectResponse("/")
-
- return templates.TemplateResponse("simple.html", {"request": request})
-
-
-@app.route("/")
-async def login(request):
- return templates.TemplateResponse("login.html", {"request": request})
-
-
-@app.route("/", methods=["POST"])
-async def do_login(request):
- form = await request.form()
- username = form.get("username")
- password = form.get("password")
- LOG.info(f"Attempting login for {username}")
- response = await session.login(request, username, password, api)
- return response
-
-
-@app.route("/network/action/{form_name}")
-async def network_action_form_get(request):
- form_name = request.path_params["form_name"]
- query = api.get_form_query(form_name, **request.query_params)
- results = await api.call(query, request=request)
-
- resp = {"errors": cannula.format_errors(results.errors), "data": results.data or {}}
-
- return JSONResponse(resp)
-
-
-if __name__ == "__main__":
- if USE_MOCKS:
- api.middleware.insert(0, MockMiddleware(mock_all=False))
- uvicorn.run(app, host="0.0.0.0", port=int(PORT), debug=True, log_level=logging.INFO)
diff --git a/examples/cloud/docker-compose.yaml b/examples/cloud/docker-compose.yaml
deleted file mode 100644
index 9005e22..0000000
--- a/examples/cloud/docker-compose.yaml
+++ /dev/null
@@ -1,25 +0,0 @@
-version: "3"
-
-services:
- openstack:
- image: cannula-openstack-example
- command: ["python", "mock_server.py"]
- container_name: mock-openstack-server
- ports:
- - "8080:8080"
- volumes:
- - ./:/app
-
- web:
- build:
- context: ./
- dockerfile: Dockerfile
- image: cannula-openstack-example
- container_name: cannula-openstack-server
- volumes:
- - ./:/app
- - ../../:/external/lib/
- ports:
- - "8081:8081"
- environment:
- USE_MOCKS:
diff --git a/examples/cloud/mock_server.py b/examples/cloud/mock_server.py
deleted file mode 100644
index fe30951..0000000
--- a/examples/cloud/mock_server.py
+++ /dev/null
@@ -1,668 +0,0 @@
-import logging
-import random
-import uuid
-from datetime import datetime
-from datetime import timedelta
-
-from starlette.applications import Starlette
-from starlette.responses import HTMLResponse, JSONResponse, RedirectResponse, Response
-
-logging.basicConfig(level=logging.DEBUG)
-
-LOG = logging.getLogger("mock-openstack")
-
-
-HOST = "openstack"
-PORT = "8080"
-MIMIC_URL = f"http://{HOST}:{PORT}"
-COMPUTE_URL = f"{MIMIC_URL}/nova/v2.1/{{project_id}}"
-NEUTRON_URL = f"{MIMIC_URL}/neutron"
-CINDER_URL = f"{MIMIC_URL}/cinder/v3/{{project_id}}"
-KEYSTONE_V3 = f"{MIMIC_URL}/v3"
-SERVERS = dict()
-ACCOUNTS = dict()
-TENANTS = dict()
-USERS = dict()
-SERVER_IDS = ["server3", "server2", "server1"]
-ADJECTIVES = [
- "imminent",
- "perfect",
- "organic",
- "elderly",
- "dapper",
- "reminiscent",
- "mysterious",
- "trashy",
- "workable",
- "flaky",
- "offbeat",
- "spooky",
- "thirsty",
- "stereotyped",
- "wild",
- "devilish",
- "quarrelsome",
- "dysfunctional",
-]
-NOUNS = [
- "note",
- "yak",
- "hammer",
- "cause",
- "price",
- "quill",
- "truck",
- "glass",
- "color",
- "ring",
- "trees",
- "window",
- "letter",
- "seed",
- "sponge",
- "pie",
- "mass",
- "table",
- "plantation",
- "battle",
-]
-
-
-app = Starlette(debug=True)
-
-
-def name():
- """Generate a random name"""
- return f"{random.choice(ADJECTIVES)} {random.choice(NOUNS)}"
-
-
-def get_id():
- """Generate a new uuid for id's"""
- return str(uuid.uuid4())
-
-
-def get_ip():
- """Generate a random ip"""
-
- def bit():
- return random.randint(0, 255)
-
- return f"{bit()}.{bit()}.{bit()}.{bit()}"
-
-
-def catalog(project_id):
- compute_url = COMPUTE_URL.format(project_id=project_id)
- neutron_url = NEUTRON_URL.format(project_id=project_id)
- cinder_url = CINDER_URL.format(project_id=project_id)
- return [
- {
- "endpoints": [
- {
- "url": compute_url,
- "interface": "public",
- "region": "us-east",
- "region_id": "us-east",
- "id": "41e9e3c05091494d83e471a9bf06f3ac",
- },
- {
- "url": compute_url,
- "interface": "public",
- "region": "us-west",
- "region_id": "us-west",
- "id": "4ad8904c486c407b9ebbc379c58ea432",
- },
- ],
- "type": "compute",
- "id": "4a1bd1ae55854833870ad35fdf1f9be1",
- "name": "nova",
- },
- {
- "endpoints": [
- {
- "url": neutron_url,
- "interface": "public",
- "region": "us-east",
- "region_id": "us-east",
- "id": "c5a338861d2b4a609be30fdbf189b5c7",
- },
- {
- "url": neutron_url,
- "interface": "public",
- "region": "us-west",
- "region_id": "us-west",
- "id": "dd3877984b2e4d49a951aa376c7580b2",
- },
- ],
- "type": "network",
- "id": "d78d372c287a4681a0003819c0f97177",
- "name": "neutron",
- },
- {
- "endpoints": [
- {
- "url": cinder_url,
- "interface": "public",
- "region": "us-east",
- "region_id": "us-east",
- "id": "8861d2c5a33b4a609be30fdbf189b5c7",
- },
- {
- "url": cinder_url,
- "interface": "public",
- "region": "us-west",
- "region_id": "us-west",
- "id": "2e4d49a9dd3877984b51aa376c7580b2",
- },
- ],
- "type": "volume",
- "id": "000381d78d372c287a4681a9c0f97177",
- "name": "cinder",
- },
- ]
-
-
-def expires():
- now = datetime.utcnow()
- expires = now + timedelta(hours=2)
- expires_at = "{0}Z".format(expires.isoformat())
- issued_at = "{0}Z".format(now.isoformat())
- return {
- "expires": expires_at,
- "issued": issued_at,
- }
-
-
-@app.route("/v3")
-async def v3(request):
- return {
- "version": {
- "status": "stable",
- "updated": "2016-04-04T00:00:00Z",
- "media-types": [
- {
- "base": "application/json",
- "type": "application/vnd.openstack.identity-v3+json",
- }
- ],
- "id": "v3.6",
- "links": [{"href": KEYSTONE_V3, "rel": "self"}],
- }
- }
-
-
-@app.route("/v3/auth/catalog", methods=["GET"])
-async def v3_catalog(request):
- auth_token = request.headers.get("X-Auth-Token")
- _, project_id = auth_token.split(":")
- _catalog = catalog(project_id)
- return {"catalog": _catalog}
-
-
-@app.route("/v3/auth/tokens", methods=["POST"])
-async def v3_auth_tokens(request):
- LOG.info("Identity Log Request")
- payload = await request.json()
- user = payload["auth"]["identity"]["password"]["user"]["name"]
- project_id = USERS.get(user)
- if project_id is None:
- project_id = get_id()
- USERS[user] = project_id
- ex = expires()
- _catalog = catalog(project_id)
- resp = {
- "token": {
- "methods": ["password"],
- "roles": [
- {
- "id": get_id(),
- "name": "admin",
- }
- ],
- "expires_at": ex["expires"], # "2017-01-17T05:20:17.000000Z",
- "project": {
- "domain": {"id": "default", "name": "Default"},
- "id": project_id,
- "name": "admin",
- },
- "catalog": _catalog,
- "user": {
- "domain": {"id": "default", "name": "Default"},
- "id": get_id(),
- "name": user,
- },
- "audit_ids": ["DriuAdgyRoWcZG95-qpakw"],
- "issued_at": ex["issued"],
- }
- }
- return JSONResponse(resp, headers={"X-Subject-Token": f"{user}:{project_id}"})
-
-
-IMAGES = [
- {
- "status": "ACTIVE",
- "updated": "2016-12-05T22:30:29Z",
- "id": get_id(),
- "OS-EXT-IMG-SIZE:size": 260899328,
- "name": name(),
- "created": "2016-12-05T22:29:35Z",
- "minDisk": random.randint(20, 200),
- "progress": 100,
- "minRam": 1024,
- "metadata": {"architecture": "amd64"},
- },
- {
- "status": "ACTIVE",
- "updated": "2016-12-05T22:30:29Z",
- "id": get_id(),
- "OS-EXT-IMG-SIZE:size": 260899328,
- "name": name(),
- "created": "2016-12-05T22:29:35Z",
- "minDisk": random.randint(20, 200),
- "progress": 100,
- "minRam": 2048,
- "metadata": {"architecture": "amd64"},
- },
- {
- "status": "ACTIVE",
- "updated": "2016-12-05T22:30:29Z",
- "id": get_id(),
- "OS-EXT-IMG-SIZE:size": 260899328,
- "name": name(),
- "created": "2016-12-05T22:29:35Z",
- "minDisk": random.randint(20, 200),
- "progress": 100,
- "minRam": 1024,
- "metadata": {"architecture": "amd64"},
- },
- {
- "status": "ACTIVE",
- "updated": "2016-12-05T22:30:29Z",
- "id": get_id(),
- "OS-EXT-IMG-SIZE:size": 260899328,
- "name": name(),
- "created": "2016-12-05T22:29:35Z",
- "minDisk": 20,
- "progress": 100,
- "minRam": 3196,
- "metadata": {"architecture": "amd64"},
- },
-]
-
-
-@app.route("/nova/v2.1/{project_id}/images/detail", methods=["GET"])
-async def nova_images_details(request):
- resp = {"images": IMAGES}
- return JSONResponse(resp)
-
-
-@app.route("/nova/v2.1/{project_id}/images/{image_id}", methods=["GET"])
-async def nova_image_get(request):
- image_id = request.path_params["image_id"]
- for image in IMAGES:
- if image["id"] == image_id:
- return JSONResponse({"image": image})
-
-
-FLAVORS = [
- {
- "name": name(),
- "ram": 1024,
- "OS-FLV-DISABLED:disabled": False,
- "vcpus": 1,
- "swap": "",
- "os-flavor-access:is_public": True,
- "rxtx_factor": 1.0,
- "OS-FLV-EXT-DATA:ephemeral": 0,
- "disk": 20,
- "id": get_id(),
- },
- {
- "name": name(),
- "ram": 2048,
- "OS-FLV-DISABLED:disabled": False,
- "vcpus": 1,
- "swap": "",
- "os-flavor-access:is_public": True,
- "rxtx_factor": 1.0,
- "OS-FLV-EXT-DATA:ephemeral": 0,
- "disk": 20,
- "id": get_id(),
- },
- {
- "name": name(),
- "ram": 4096,
- "OS-FLV-DISABLED:disabled": False,
- "vcpus": 1,
- "swap": "",
- "os-flavor-access:is_public": True,
- "rxtx_factor": 1.0,
- "OS-FLV-EXT-DATA:ephemeral": 0,
- "disk": 20,
- "id": get_id(),
- },
-]
-
-
-@app.route("/nova/v2.1/{project_id}/flavors/detail", methods=["GET"])
-async def flavor_list_detail(request):
- resp = {"flavors": FLAVORS}
- return JSONResponse(resp)
-
-
-@app.route("/nova/v2.1/{project_id}/flavors/{flavor_id}", methods=["GET"])
-async def flavor_get(request):
- flavor_id = request.path_params["flavor_id"]
- for flavor in FLAVORS:
- if flavor["id"] == flavor_id:
- return JSONResponse({"flavor": flavor})
- raise
-
-
-NETWORKS = [
- {
- "status": "ACTIVE",
- "subnets": ["private-subnet"],
- "name": "private-network",
- "provider:physical_network": None,
- "admin_state_up": True,
- "project_id": "4fd44f30292945e481c7b8a0c8908869",
- "tenant_id": "4fd44f30292945e481c7b8a0c8908869",
- "qos_policy_id": "6a8454ade84346f59e8d40665f878b2e",
- "provider:network_type": "local",
- "router:external": True,
- "mtu": 0,
- "shared": True,
- "id": "d32019d3-bc6e-4319-9c1d-6722fc136a22",
- "provider:segmentation_id": None,
- }
-]
-
-
-@app.route("/neutron/v2.0/networks.json", methods=["GET"])
-async def network_list(request):
- return JSONResponse({"networks": NETWORKS})
-
-
-@app.route("/neutron/v2.0/limits.json", methods=["GET"])
-async def network_limits(request):
- return JSONResponse({"networks": {"used": len(NETWORKS), "limit": 3}})
-
-
-SUBNETS = [
- {
- "name": "private-subnet",
- "enable_dhcp": True,
- "network_id": "db193ab3-96e3-4cb3-8fc5-05f4296d0324",
- "segment_id": None,
- "project_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
- "tenant_id": "26a7980765d0414dbc1fc1f88cdb7e6e",
- "dns_nameservers": [],
- "allocation_pools": [{"start": get_ip(), "end": get_ip()}],
- "host_routes": [],
- "ip_version": 4,
- "gateway_ip": get_ip(),
- "cidr": f"{get_ip()}/24",
- "id": "abc",
- "created_at": "2016-10-10T14:35:34Z",
- "description": "",
- "ipv6_address_mode": None,
- "ipv6_ra_mode": None,
- "revision_number": 2,
- "service_types": [],
- "subnetpool_id": None,
- "updated_at": "2016-10-10T14:35:34Z",
- }
-]
-
-
-@app.route("/neutron/v2.0/subnets.json", methods=["GET"])
-async def subnet_list(request):
- return JSONResponse({"subnets": SUBNETS})
-
-
-@app.route("/nova/v2.1/{project_id}/servers", methods=["POST"])
-async def server_create(request):
- project_id = request.path_params["project_id"]
- body = await request.json()
- image_id = body["server"]["imageRef"]
- flavor_id = body["server"]["flavorRef"]
- name = body["server"]["name"]
- return create_new_server(project_id, image_id, flavor_id, name)
-
-
-def create_new_server(project_id, image_id, flavor_id, name):
- server_id = get_id()
- new_server = {
- "server": {
- "status": "ACTIVE",
- "updated": "2017-01-23T17:25:40Z",
- "hostId": "8e1376bbeee19c6fb07e29eb7876ac26ac81905200a10d3dfac6840c",
- "OS-EXT-SRV-ATTR:host": "saturn-rpc",
- "addresses": {
- "private-net": [
- {
- "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:58:ad:d4",
- "version": 4,
- "addr": get_ip(),
- "OS-EXT-IPS:type": "fixed",
- }
- ],
- "external-net": [
- {
- "OS-EXT-IPS-MAC:mac_addr": "fa:16:3e:b0:a3:13",
- "version": 4,
- "addr": get_ip(),
- "OS-EXT-IPS:type": "fixed",
- }
- ],
- },
- "key_name": None,
- "image": {"id": image_id},
- "OS-EXT-STS:task_state": None,
- "OS-EXT-STS:vm_state": "active",
- "OS-EXT-SRV-ATTR:instance_name": "instance-00000157",
- "OS-SRV-USG:launched_at": "2017-01-23T17:25:40.000000",
- "OS-EXT-SRV-ATTR:hypervisor_hostname": "saturn-rpc",
- "flavor": {"id": flavor_id},
- "id": server_id,
- "security_groups": [{"name": "default"}, {"name": "default"}],
- "OS-SRV-USG:terminated_at": None,
- "OS-EXT-AZ:availability_zone": "nova",
- "user_id": "c95c5f5773864aacb5c09498a4e4ad0c",
- "name": name,
- "created": "2017-01-23T17:25:27Z",
- "tenant_id": project_id,
- "OS-DCF:diskConfig": "MANUAL",
- "os-extended-volumes:volumes_attached": [],
- "accessIPv4": get_ip(),
- "accessIPv6": "",
- "progress": 0,
- "OS-EXT-STS:power_state": 1,
- "config_drive": "",
- "metadata": {},
- }
- }
- SERVERS[server_id] = new_server
- return JSONResponse(new_server)
-
-
-@app.route("/nova/v2.1/{project_id}/servers/detail")
-async def server_list(request):
- resp = {"servers": []}
- for server in SERVERS.values():
- resp["servers"].append(server["server"])
- return JSONResponse(resp)
-
-
-# Note: May also need /nova/v2.1/{project_id/servers?name={server_name} someday
-@app.route("/nova/v2.1/{project_id}/servers/{server_id}", methods=["GET"])
-async def server_get(request):
- server_id = request.path_params["server_id"]
- return JSONResponse(SERVERS.get(server_id))
-
-
-@app.route("/nova/v2.1/{project_id}/servers/{server_id}", methods=["DELETE"])
-async def server_delete(request):
- server_id = request.path_params["server_id"]
- del SERVERS[server_id]
- SERVER_IDS.insert(0, server_id)
- return JSONResponse(None, status=202)
-
-
-@app.route("/nova/v2.1/{project_id}/os-availability-zone")
-async def availability_zone(request):
- return JSONResponse(
- {
- "availabilityZoneInfo": [
- {"hosts": None, "zoneName": "nova", "zoneState": {"available": True}}
- ]
- }
- )
-
-
-@app.route("/nova/v2.1/{project_id}/limits", methods=["GET"])
-async def server_quota_get(request):
- return JSONResponse({"servers": {"used": len(SERVERS), "limit": 10}})
-
-
-VOLUMES = [
- {
- "migration_status": None,
- "availability_zone": "nova",
- "os-vol-host-attr:host": "difleming@lvmdriver-1#lvmdriver-1",
- "encrypted": False,
- "replication_status": "disabled",
- "snapshot_id": None,
- "id": "6edbc2f4-1507-44f8-ac0d-eed1d2608d38",
- "size": 200,
- "user_id": "32779452fcd34ae1a53a797ac8a1e064",
- "os-vol-tenant-attr:tenant_id": "bab7d5c60cd041a0a36f7c4b6e1dd978",
- "os-vol-mig-status-attr:migstat": None,
- "status": "in-use",
- "description": None,
- "multiattach": True,
- "source_volid": None,
- "consistencygroup_id": None,
- "os-vol-mig-status-attr:name_id": None,
- "name": "test-volume-attachments",
- "bootable": "false",
- "created_at": "2015-11-29T03:01:44.000000",
- "volume_type": "lvmdriver-1",
- "group_id": "8fbe5733-eb03-4c88-9ef9-f32b7d03a5e4",
- },
- {
- "migration_status": None,
- "attachments": [],
- "availability_zone": "nova",
- "os-vol-host-attr:host": "difleming@lvmdriver-1#lvmdriver-1",
- "encrypted": False,
- "replication_status": "disabled",
- "snapshot_id": None,
- "id": "173f7b48-c4c1-4e70-9acc-086b39073506",
- "size": 100,
- "user_id": "32779452fcd34ae1a53a797ac8a1e064",
- "os-vol-tenant-attr:tenant_id": "bab7d5c60cd041a0a36f7c4b6e1dd978",
- "os-vol-mig-status-attr:migstat": None,
- "metadata": {},
- "status": "available",
- "description": "",
- "multiattach": False,
- "source_volid": None,
- "consistencygroup_id": None,
- "os-vol-mig-status-attr:name_id": None,
- "name": "test-volume",
- "bootable": "true",
- "created_at": "2015-11-29T02:25:18.000000",
- "volume_type": "lvmdriver-1",
- "group_id": "8fbe5733-eb03-4c88-9ef9-f32b7d03a5e4",
- },
-]
-
-
-@app.route("/cinder/v3/{project_id}/volumes/detail")
-async def cinder_volumes(request):
- return JSONResponse({"volumes": VOLUMES})
-
-
-@app.route("/cinder/v3/{project_id}/limits")
-async def cinder_limits(request):
- return JSONResponse(
- {
- "limits": {
- "absolute": {
- "totalSnapshotsUsed": 0,
- "maxTotalBackups": 10,
- "maxTotalVolumeGigabytes": 1000,
- "totalVolumesUsed": 0,
- "totalBackupsUsed": 0,
- "totalGigabytesUsed": sum(map(lambda vol: vol["size"], VOLUMES)),
- }
- }
- }
- )
-
-
-@app.route("/")
-async def root(request):
- return JSONResponse(
- {
- "versions": {
- "values": [
- {
- "status": "stable",
- "updated": "2016-04-04T00:00:00Z",
- "media-types": [
- {
- "base": "application/json",
- "type": "application/vnd.openstack.identity-v3+json",
- }
- ],
- "id": "v3.6",
- "links": [
- {
- "href": "http://{host}:{port}/v3/".format(
- host=HOST, port=PORT
- ),
- "rel": "self",
- }
- ],
- },
- {
- "status": "stable",
- "updated": "2014-04-17T00:00:00Z",
- "media-types": [
- {
- "base": "application/json",
- "type": "application/vnd.openstack.identity-v2.0+json",
- }
- ],
- "id": "v2.0",
- "links": [
- {
- "href": "http://{host}:{port}/v2.0/".format(
- host=HOST, port=PORT
- ),
- "rel": "self",
- },
- {
- "href": "http://docs.openstack.org/",
- "type": "text/html",
- "rel": "describedby",
- },
- ],
- },
- ]
- }
- }
- )
-
-
-for server_id in SERVER_IDS:
- create_new_server("fake", IMAGES[0].get("id"), FLAVORS[0].get("id"), server_id)
-
-
-if __name__ == "__main__":
- import uvicorn
-
- LOG.info(f"starting mock openstack server on {PORT}")
- uvicorn.run(app, host="0.0.0.0", port=8080, debug=True, log_level=logging.INFO)
diff --git a/examples/cloud/queries/dashboard.graphql b/examples/cloud/queries/dashboard.graphql
deleted file mode 100644
index 4676728..0000000
--- a/examples/cloud/queries/dashboard.graphql
+++ /dev/null
@@ -1,87 +0,0 @@
-# This is a fragment for our quota charts so we don't have to repeat it.
-fragment quotaFields on QuotaChartData {
- datasets {
- data
- backgroundColor
- }
- labels
-}
-
-# Status fragment for our resources
-fragment statusFields on ApplicationStatus {
- label
- color
- working
- icon
- tooltip
-}
-
-fragment actionFields on Action {
- label
- formUrl
- icon
- enabled
- tooltip
-}
-
-query main ($region: String!) {
- serverQuota: quotaChartData(resource: "ComputeServers") {
- ...quotaFields
- }
- networkQuota: quotaChartData(resource: "Networks") {
- ...quotaFields
- }
- volumeQuota: quotaChartData(resource: "Volumes") {
- ...quotaFields
- }
- resources: resources(region: $region) {
- __typename
- ... on ComputeServer {
- name
- id
- appStatus {
- ...statusFields
- }
- }
- ... on Network {
- name
- id
- appStatus {
- ...statusFields
- }
- appActions {
- ...actionFields
- }
- }
- ... on Volume {
- name
- id
- appStatus {
- ...statusFields
- }
- appActions {
- ...actionFields
- }
- }
- }
- images: computeImages(region: $region) {
- name
- minRam
- }
- flavors: computeFlavors(region: $region) {
- name
- ram
- }
- nav: getNavigation(active: "dashboard") {
- title
- items {
- active
- icon
- url
- name
- className
- enabled
- disabledMessage
- }
- }
-}
diff --git a/examples/cloud/queries/simple.graphql b/examples/cloud/queries/simple.graphql
deleted file mode 100644
index d080d86..0000000
--- a/examples/cloud/queries/simple.graphql
+++ /dev/null
@@ -1,30 +0,0 @@
-# Status fragment for our resources
-fragment statusFields on ApplicationStatus {
- label
- color
- working
- icon
- tooltip
-}
-
-query main ($region: String!) {
- resources: computeServers(region: $region) {
- name
- id
- appStatus {
- ...statusFields
- }
- }
- nav: getNavigation(active: "dashboard") {
- title
- items {
- active
- icon
- url
- name
- className
- enabled
- disabledMessage
- }
- }
-}
diff --git a/examples/cloud/requirements.txt b/examples/cloud/requirements.txt
deleted file mode 100644
index 9b5ce0d..0000000
--- a/examples/cloud/requirements.txt
+++ /dev/null
@@ -1,9 +0,0 @@
-bottle
-requests
-graphql-core-next
-wtforms
-starlette
-uvicorn
-aiofiles
-jinja2
-python-multipart
diff --git a/examples/cloud/resolvers/application/__init__.py b/examples/cloud/resolvers/application/__init__.py
deleted file mode 100644
index 17afbc8..0000000
--- a/examples/cloud/resolvers/application/__init__.py
+++ /dev/null
@@ -1,7 +0,0 @@
-from .resolver import application_resolver
-from .actions import Action
-
-__all__ = [
- "application_resolver",
- "Action",
-]
diff --git a/examples/cloud/resolvers/application/actions.py b/examples/cloud/resolvers/application/actions.py
deleted file mode 100644
index ee979ee..0000000
--- a/examples/cloud/resolvers/application/actions.py
+++ /dev/null
@@ -1,59 +0,0 @@
-import typing
-
-from .resolver import application_resolver
-
-
-class Action:
- label: str
- icon: str
- formUrl: str = None
- obj: typing.Any = None
- attribute: str = None
- allow_role: str = None
- allowed_states: typing.List[str] = []
- role_message: str = "You do not have permission to preform this action"
- state_message: str = "Action not allowed"
- action_message: str = None
- state_attribute: str = "state"
- state: str = None
-
- def __init__(self, source, info, **kwargs):
- """Initialize the form with the source as the object."""
- self.state = getattr(source, self.state_attribute, None)
- self.formUrl = self.get_form_url(source, info, **kwargs)
-
- def get_form_url(self, source, info, **kwargs):
- raise NotImplementedError("Subclasses must define `get_form_url`")
-
- def is_enabled(self, user) -> bool:
- role_is_set = self.allow_role is not None
- if role_is_set:
- user_has_permission = user.has_role(self.allow_role)
- return self.action_is_allowed and user_has_permission
-
- return self.action_is_allowed
-
- @property
- def action_is_allowed(self):
- if not self.allowed_states:
- return True
- return self.state in self.allowed_states
-
- def tooltip_message(self, user, state=None):
- """Display the tooltip.
-
- If the action is not allowed return the 'state_message' else
- check if the user has permission and return the 'role_message' if not.
- """
- if not self.action_is_allowed:
- return self.state_message
- elif not self.is_enabled(user):
- return self.role_message
- elif self.action_message:
- return self.action_message
- return None
-
-
-@application_resolver.resolver("Action")
-async def enabled(item, info):
- return item.is_enabled(info.context.user)
diff --git a/examples/cloud/resolvers/application/navigation.py b/examples/cloud/resolvers/application/navigation.py
deleted file mode 100644
index 04dbf7d..0000000
--- a/examples/cloud/resolvers/application/navigation.py
+++ /dev/null
@@ -1,95 +0,0 @@
-import typing
-
-
-class Item(typing.NamedTuple):
- id: str
- icon: str
- url: str
- name: str
- className: str = "navigation-item"
- disabledMessage: str = "You do not have access to this resource"
- role: str = None
-
- def is_enabled(self, user):
- if self.role is not None:
- return user.has_role(self.role)
- return True
-
-
-class Section(typing.NamedTuple):
- title: str
- items: typing.List[Item]
- role: str = None
-
- def is_enabled(self, user):
- if self.role is not None:
- return user.has_role(self.role)
- return True
-
-
-ALL_SECTIONS = [
- Section(
- title="Compute",
- items=[
- Item(
- id="compute-servers",
- icon="server",
- url="/servers/",
- name="Servers",
- role="compute:server",
- ),
- Item(
- id="compute-images",
- icon="image",
- url="/images/",
- name="Images",
- role="compute:image",
- ),
- Item(
- id="compute-flavors",
- icon="flavor",
- url="/flavors/",
- name="Flavors",
- role="compute:flavor",
- ),
- ],
- ),
- Section(
- title="Networks",
- items=[
- Item(
- id="network",
- icon="network",
- url="/networks/",
- name="Networks",
- role="network",
- ),
- Item(
- id="network-new",
- icon="add",
- url="/networks/new/",
- name="Create Network",
- role="network",
- ),
- ],
- ),
- Section(
- title="Volumes",
- items=[
- Item(
- id="volumes",
- icon="volume",
- url="/volumes/",
- name="Volumes",
- role="volume",
- ),
- Item(
- id="volume-new",
- icon="add",
- url="/volumes/new/",
- name="Create Volume",
- role="volume",
- ),
- ],
- ),
-]
diff --git a/examples/cloud/resolvers/application/resolver.py b/examples/cloud/resolvers/application/resolver.py
deleted file mode 100644
index 09d48d6..0000000
--- a/examples/cloud/resolvers/application/resolver.py
+++ /dev/null
@@ -1,16 +0,0 @@
-import cannula
-
-from .navigation import ALL_SECTIONS
-
-
-application_resolver = cannula.Resolver(__name__)
-
-
-@application_resolver.resolver("Query")
-async def getNavigation(source, info, active: str):
- return [s for s in ALL_SECTIONS if s.is_enabled(info.context.user)]
-
-
-@application_resolver.resolver("NavigationItem")
-async def enabled(item, info):
- return item.is_enabled(info.context.user)
diff --git a/examples/cloud/resolvers/application/status.py b/examples/cloud/resolvers/application/status.py
deleted file mode 100644
index e41d6ed..0000000
--- a/examples/cloud/resolvers/application/status.py
+++ /dev/null
@@ -1,9 +0,0 @@
-import typing
-
-
-class Status(typing.NamedTuple):
- label: str
- color: str = "hxEmphasisPurple"
- working: bool = False
- icon: str = None
- tooltip: str = None
diff --git a/examples/cloud/resolvers/base.py b/examples/cloud/resolvers/base.py
deleted file mode 100644
index f684674..0000000
--- a/examples/cloud/resolvers/base.py
+++ /dev/null
@@ -1,45 +0,0 @@
-import cannula
-
-
-class NotAuthenticated(Exception):
- "User is not authenticated"
- code = 401
-
-
-class OpenStackBase(cannula.datasource.http.HTTPDataSource):
- # The name of the service in the catalog for the logged in user.
- catalog_name = None
-
- def get_service_url(self, region: str, path: str):
- """Find the correct service url for region.
-
- The OpenStack services usually add the project id in the url of
- the service. So for each user you need to get the url from the
- service catalog.
- """
- if not hasattr(self.context, "user"):
- raise Exception("You are not using OpenStackContext")
-
- if not self.context.user.is_authenticated:
- raise NotAuthenticated("User is not authenticated")
-
- if self.catalog_name is None:
- raise AttributeError("catalog_name not set")
-
- # normalize the path
- if path.startswith("/"):
- path = path[1:]
-
- service = self.context.user.get_service_url(self.catalog_name, region)
-
- if service is None:
- raise AttributeError(f"No service url found for {region}")
-
- if service.endswith("/"):
- return f"{service}{path}"
- return f"{service}/{path}"
-
- def will_send_request(self, request):
- if hasattr(self.context, "user") and self.context.user.auth_token:
- request.headers.update({"X-Auth-Token": self.context.user.auth_token})
- return request
diff --git a/examples/cloud/resolvers/compute/__init__.py b/examples/cloud/resolvers/compute/__init__.py
deleted file mode 100644
index cdac8c9..0000000
--- a/examples/cloud/resolvers/compute/__init__.py
+++ /dev/null
@@ -1,11 +0,0 @@
-from .flavors import ComputeFlavors
-from .images import ComputeImages
-from .resolver import compute_resolver
-from .servers import ComputeServers
-
-__all__ = [
- "ComputeFlavors",
- "ComputeImages",
- "compute_resolver",
- "ComputeServers",
-]
diff --git a/examples/cloud/resolvers/compute/flavors.py b/examples/cloud/resolvers/compute/flavors.py
deleted file mode 100644
index 437a533..0000000
--- a/examples/cloud/resolvers/compute/flavors.py
+++ /dev/null
@@ -1,37 +0,0 @@
-from ..base import OpenStackBase
-from .resolver import compute_resolver
-
-
-@compute_resolver.datasource()
-class ComputeFlavors(OpenStackBase):
- catalog_name = "compute"
- resource_name = "ComputeFlavor"
-
- async def fetchFlavors(self, region=None):
- url = self.get_service_url(region, "flavors/detail")
- resp = await self.get(url)
- return resp.flavors
-
- async def fetchFlavor(self, region=None, flavor_id=None):
- flavors = await self.fetchFlavors(region)
- data = list(filter(lambda flavor: flavor.id == flavor_id, flavors))
- return data[0]
- # resp = await self.get(f'flavors/{flavor_id}')
- # return resp.flavor
-
-
-@compute_resolver.resolver("Query")
-async def computeFlavors(source, info, region):
- return await info.context.ComputeFlavors.fetchFlavors(region)
-
-
-@compute_resolver.resolver("Query")
-async def computeFlavor(source, info, id, region):
- return await info.context.ComputeFlavors.fetchFlavor(region, flavor_id=id)
-
-
-@compute_resolver.resolver("ComputeServer")
-async def flavor(server, info):
- return await info.context.ComputeFlavors.fetchFlavor(
- server.region, server.flavor.id
- )
diff --git a/examples/cloud/resolvers/compute/images.py b/examples/cloud/resolvers/compute/images.py
deleted file mode 100644
index 080131e..0000000
--- a/examples/cloud/resolvers/compute/images.py
+++ /dev/null
@@ -1,35 +0,0 @@
-from ..base import OpenStackBase
-from .resolver import compute_resolver
-
-
-@compute_resolver.datasource()
-class ComputeImages(OpenStackBase):
- catalog_name = "compute"
- resource_name = "ComputeImage"
-
- async def fetchImages(self, region=None):
- url = self.get_service_url(region, "images/detail")
- resp = await self.get(url)
- return resp.images
-
- async def fetchImage(self, region=None, image_id=None):
- images = await self.fetchImages(region)
- data = list(filter(lambda image: image.id == image_id, images))
- return data[0]
- # resp = await self.get(f'images/{image_id}')
- # return resp.image
-
-
-@compute_resolver.resolver("Query")
-async def computeImages(source, info, region):
- return await info.context.ComputeImages.fetchImages(region)
-
-
-@compute_resolver.resolver("Query")
-async def computeImage(source, info, id, region):
- return await info.context.ComputeImages.fetchImage(region, image_id=id)
-
-
-@compute_resolver.resolver("ComputeServer")
-async def image(server, info):
- return await info.context.ComputeImages.fetchImage(server.region, server.image.id)
diff --git a/examples/cloud/resolvers/compute/resolver.py b/examples/cloud/resolvers/compute/resolver.py
deleted file mode 100644
index a6767ac..0000000
--- a/examples/cloud/resolvers/compute/resolver.py
+++ /dev/null
@@ -1,4 +0,0 @@
-import cannula
-
-
-compute_resolver = cannula.Resolver(__name__)
diff --git a/examples/cloud/resolvers/compute/schema/001_compute_server.graphql b/examples/cloud/resolvers/compute/schema/001_compute_server.graphql
deleted file mode 100644
index 2039b52..0000000
--- a/examples/cloud/resolvers/compute/schema/001_compute_server.graphql
+++ /dev/null
@@ -1,28 +0,0 @@
-type ComputeServer {
- id: ID!
- name: String!
- created: String
- updated: String
- hostId: String
- region: String
- status: String
- appStatus: ApplicationStatus
-}
-
-extend type Query {
- computeServers(region: String): [ComputeServer]
- computeServer(region: String, id: ID): ComputeServer
-}
-
-extend type Mutation {
- createComputeServer(
- region: String!,
- name: String!,
- flavor: ID!,
- image: ID!,
- networks: [ID]
- ): ComputeServer
-
- "TODO: figure out what to return"
- deleteComputeServer(region: String, id: ID): ComputeServer
-}
diff --git a/examples/cloud/resolvers/compute/schema/002_compute_flavor.graphql b/examples/cloud/resolvers/compute/schema/002_compute_flavor.graphql
deleted file mode 100644
index 406d3a6..0000000
--- a/examples/cloud/resolvers/compute/schema/002_compute_flavor.graphql
+++ /dev/null
@@ -1,19 +0,0 @@
-type ComputeFlavor {
- id: ID!
- region: String
- name: String!
- ram: Int
- "Amount of RAM formatted like '16 GB'"
- human: String
- vcpus: Int
- disk: Int
-}
-
-extend type Query {
- computeFlavor(id: String, region: String): ComputeFlavor
- computeFlavors(region: String): [ComputeFlavor]
-}
-
-extend type ComputeServer {
- flavor: ComputeFlavor
-}
diff --git a/examples/cloud/resolvers/compute/schema/003_compute_image.graphql b/examples/cloud/resolvers/compute/schema/003_compute_image.graphql
deleted file mode 100644
index 872d325..0000000
--- a/examples/cloud/resolvers/compute/schema/003_compute_image.graphql
+++ /dev/null
@@ -1,17 +0,0 @@
-type ComputeImage {
- id: ID!
- status: String
- name: String!
- minDisk: String
- minRam: String
- architecture: String
-}
-
-extend type Query {
- computeImages(region: String): [ComputeImage]
- computeImage(region: String, id: ID): ComputeImage
-}
-
-extend type ComputeServer {
- image: ComputeImage
-}
diff --git a/examples/cloud/resolvers/compute/servers.py b/examples/cloud/resolvers/compute/servers.py
deleted file mode 100644
index 151b966..0000000
--- a/examples/cloud/resolvers/compute/servers.py
+++ /dev/null
@@ -1,38 +0,0 @@
-import logging
-
-from ..application import status
-from ..base import OpenStackBase
-from .resolver import compute_resolver
-
-LOG = logging.getLogger(__name__)
-
-
-@compute_resolver.datasource()
-class ComputeServers(OpenStackBase):
- catalog_name = "compute"
- resource_name = "ComputeServer"
-
- async def fetchServers(self, region=None):
- url = self.get_service_url(region, "servers/detail")
- resp = await self.get(url)
- servers = resp.servers
- for server in servers:
- server.region = region
- return servers
-
- async def fetchLimits(self):
- east_url = self.get_service_url("us-east", "limits")
- resp = await self.get(east_url)
- return resp.servers
-
-
-@compute_resolver.resolver("Query")
-async def computeServers(source, info, region):
- return await info.context.ComputeServers.fetchServers(region)
-
-
-@compute_resolver.resolver("ComputeServer")
-async def appStatus(server, info):
- return status.Status(
- label=server.status,
- )
diff --git a/examples/cloud/resolvers/dashboard/__init__.py b/examples/cloud/resolvers/dashboard/__init__.py
deleted file mode 100644
index 352cff0..0000000
--- a/examples/cloud/resolvers/dashboard/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .resolver import dashboard_resolver
-
-__all__ = [
- "dashboard_resolver",
-]
diff --git a/examples/cloud/resolvers/dashboard/resolver.py b/examples/cloud/resolvers/dashboard/resolver.py
deleted file mode 100644
index c789044..0000000
--- a/examples/cloud/resolvers/dashboard/resolver.py
+++ /dev/null
@@ -1,98 +0,0 @@
-import asyncio
-import itertools
-import logging
-import typing
-
-import cannula
-
-LOG = logging.getLogger(__name__)
-
-COLORS = {
- "ComputeServers": "#cc65fe",
- "Networks": "#36a2eb",
- "Volumes": "#ff6384",
-}
-
-dashboard_resolver = cannula.Resolver(__name__)
-
-
-class Dataset(typing.NamedTuple):
- used: int
- limit: int
- color: str
- label: str
-
- @property
- def data(self):
- remaining = self.limit - self.used
- return [self.used, remaining]
-
- @property
- def backgroundColor(self):
- return [self.color]
-
-
-class QuotaData(typing.NamedTuple):
- label: str
- used: int
- limit: int
- color: str
- quota_label: str = "Quota"
-
- @property
- def datasets(self):
- return [
- Dataset(
- used=self.used,
- limit=self.limit,
- color=self.color,
- label=f"{self.label} Quota",
- )
- ]
-
- @property
- def labels(self):
- return [self.label, self.quota_label]
-
-
-@dashboard_resolver.resolver("Query")
-async def quotaChartData(source, info, resource):
- if resource == "ComputeServers":
- server_quota = await info.context.ComputeServers.fetchLimits()
-
- return QuotaData(
- label="Servers",
- used=server_quota.used,
- limit=server_quota.limit,
- color=COLORS.get(resource),
- )
- elif resource == "Networks":
- network_quota = await info.context.Network.fetchLimits()
-
- return QuotaData(
- label="Networks",
- used=network_quota.used,
- limit=network_quota.limit,
- color=COLORS.get(resource),
- )
- elif resource == "Volumes":
- volume_quota = await info.context.Volume.fetchLimits()
-
- return QuotaData(
- label="GB Used",
- used=volume_quota.absolute.totalGigabytesUsed,
- limit=volume_quota.absolute.maxTotalVolumeGigabytes,
- color=COLORS.get(resource),
- quota_label="GB Left",
- )
-
-
-@dashboard_resolver.resolver("Query")
-async def resources(source, info, region):
- servers = info.context.ComputeServers.fetchServers(region)
- networks = info.context.Network.fetchNetworks(region)
- volumes = info.context.Volume.fetchVolumes(region)
-
- results = await asyncio.gather(servers, networks, volumes)
- # results is a list of lists [[results], [results], [results]]
- return itertools.chain(*results)
diff --git a/examples/cloud/resolvers/dashboard/schema/quota.graphql b/examples/cloud/resolvers/dashboard/schema/quota.graphql
deleted file mode 100644
index 9a6b1f7..0000000
--- a/examples/cloud/resolvers/dashboard/schema/quota.graphql
+++ /dev/null
@@ -1,14 +0,0 @@
-type Dataset {
- data: [Int]
- backgroundColor: [String]
- label: String
-}
-
-type QuotaChartData {
- datasets: [Dataset]
- labels: [String]
-}
-
-extend type Query {
- quotaChartData(resource: String): QuotaChartData
-}
diff --git a/examples/cloud/resolvers/dashboard/schema/resources.graphql b/examples/cloud/resolvers/dashboard/schema/resources.graphql
deleted file mode 100644
index d91539f..0000000
--- a/examples/cloud/resolvers/dashboard/schema/resources.graphql
+++ /dev/null
@@ -1,6 +0,0 @@
-
-union Resource = ComputeServer | Network | Volume
-
-extend type Query {
- resources(region: String): [ Resource ]
-}
diff --git a/examples/cloud/resolvers/identity/__init__.py b/examples/cloud/resolvers/identity/__init__.py
deleted file mode 100644
index ee6eaf8..0000000
--- a/examples/cloud/resolvers/identity/__init__.py
+++ /dev/null
@@ -1,5 +0,0 @@
-from .resolver import identity_resolver
-
-__all__ = [
- "identity_resolver",
-]
diff --git a/examples/cloud/resolvers/identity/resolver.py b/examples/cloud/resolvers/identity/resolver.py
deleted file mode 100644
index 2be9eb4..0000000
--- a/examples/cloud/resolvers/identity/resolver.py
+++ /dev/null
@@ -1,42 +0,0 @@
-import json
-import logging
-
-import cannula
-
-from ..base import OpenStackBase
-
-LOG = logging.getLogger(__name__)
-
-identity_resolver = cannula.Resolver(__name__)
-
-
-@identity_resolver.datasource()
-class Identity(OpenStackBase):
- # Identity is special because we have to login first
- # to get the service catalog for the other services. That is why
- # we must specify the base url here.
- base_url = "http://openstack:8080/v3"
-
- async def login(self, username, password):
- body = {
- "auth": {
- "identity": {
- "password": {"user": {"name": username, "password": password}}
- }
- }
- }
- resp = await self.post("auth/tokens", body=body)
- resp.token.authToken = resp.headers.get("X-Subject-Token")
- return resp.token
-
- async def did_receive_response(self, response, request):
- response_object = await super().did_receive_response(response, request)
- # Add the headers to the response object
- response_object.headers = response.headers
- return response_object
-
-
-@identity_resolver.resolver("Mutation")
-async def login(source, info, username, password):
- LOG.info("in login mutation")
- return await info.context.Identity.login(username, password)
diff --git a/examples/cloud/resolvers/identity/schema/001_identity.graphql b/examples/cloud/resolvers/identity/schema/001_identity.graphql
deleted file mode 100644
index 506da67..0000000
--- a/examples/cloud/resolvers/identity/schema/001_identity.graphql
+++ /dev/null
@@ -1,46 +0,0 @@
-
-type CatalogEndpoint {
- id: ID
- url: String
- interface: String
- region: String
- region_id: String
-}
-
-type CatalogEntry {
- id: ID
- endpoints: [CatalogEndpoint]
- type: String
- name: String
-}
-
-type IdentityProject {
- id: ID
- name: String
-}
-
-type IdentityRole {
- id: ID
- name: String
-}
-
-type IdentityUser {
- id: ID
- name: String
-}
-
-type Identity {
- roles: [IdentityRole]
- catalog: [CatalogEntry]
- project: IdentityProject
- user: IdentityUser
- authToken: String
-}
-
-extend type Mutation {
- login(username: String!, password: String!): Identity
-}
-
-extend type Query {
- serviceCatalog: [CatalogEntry]
-}
diff --git a/examples/cloud/resolvers/network/__init__.py b/examples/cloud/resolvers/network/__init__.py
deleted file mode 100644
index 5cdbc50..0000000
--- a/examples/cloud/resolvers/network/__init__.py
+++ /dev/null
@@ -1,107 +0,0 @@
-import asyncio
-import itertools
-import logging
-
-import wtforms
-from cannula.datasource.forms import WTFormsResolver, unwrap_args
-
-from ..application import status, actions
-from ..base import OpenStackBase
-
-LOG = logging.getLogger(__name__)
-
-network_resolver = WTFormsResolver(__name__)
-
-
-@network_resolver.datasource()
-class Network(OpenStackBase):
- catalog_name = "network"
-
- async def fetchNetworks(self, region=None):
- url = self.get_service_url(region, "v2.0/networks.json")
- resp = await self.get(url)
- networks = resp.networks
- for network in networks:
- network.region = region
- return networks
-
- async def fetchNetwork(self, region=None, id=None):
- networks = await self.fetchNetworks(region)
- for network in networks:
- if network.id == id:
- return network
-
- async def fetchLimits(self):
- east_url = self.get_service_url("us-east", "v2.0/limits.json")
- resp = await self.get(east_url)
- return resp.networks
-
-
-@network_resolver.datasource()
-class Subnet(OpenStackBase):
- catalog_name = "network"
-
- async def fetchSubnet(self, region, subnet_id):
- url = self.get_service_url(region, "v2.0/subnets.json")
- resp = await self.get(url)
- subnets = resp.subnets
- for subnet in subnets:
- if subnet.id == subnet_id:
- return subnet
-
-
-@network_resolver.resolver("Query")
-async def getNetworks(source, info, region):
- return await info.context.Network.fetchNetworks(region)
-
-
-@network_resolver.resolver("Network")
-async def subnets(network, info):
- awaitables = []
- for _id in network.subnets:
- awaitables.append(info.context.Subnet.fetchSubnet(network.region, _id))
-
- results = await asyncio.gather(awaitables)
- return itertools.chain(*results)
-
-
-@network_resolver.resolver("Network")
-async def appStatus(network, info):
- return status.Status(
- label=network.status,
- )
-
-
-@network_resolver.register_form(args=["id", "region"])
-class RenameNetwork(wtforms.Form):
- name = wtforms.TextField(
- "New Name", description="Enter a new name for the network."
- )
-
-
-@network_resolver.resolver("Query")
-async def getRenameNetworkForm(source, info, args):
- # Turn the args list back into a dict
- kwargs = unwrap_args(args)
-
- # Use the kwargs to fetch the resource like:
- network = await info.context.Network.fetchNetwork(**kwargs)
-
- action_form = info.context.RenameNetwork.form(obj=network)
- return action_form
-
-
-class RenameNetworkAction(actions.Action):
- label = "Rename Network"
- form_class = RenameNetwork
-
- def get_form_url(self, source, info, **kwargs):
- return f"/network/action/RenameNetwork?id={source.id}®ion={source.region}"
-
-
-NETWORK_ACTIONS = [RenameNetworkAction]
-
-
-@network_resolver.resolver("Network")
-async def appActions(network, info):
- return [action(network, info) for action in NETWORK_ACTIONS]
diff --git a/examples/cloud/resolvers/network/forms.py b/examples/cloud/resolvers/network/forms.py
deleted file mode 100644
index 83a110f..0000000
--- a/examples/cloud/resolvers/network/forms.py
+++ /dev/null
@@ -1,17 +0,0 @@
-import wtforms
-
-from ..application import actions
-
-
-class RenameNetwork(wtforms.Form):
- name = wtforms.TextField(
- "New Name", description="Enter a new name for the network."
- )
-
-
-class RenameNetworkAction(actions.Action):
- label = "Rename Network"
- form_class = RenameNetwork
-
-
-NETWORK_ACTIONS = [RenameNetworkAction]
diff --git a/examples/cloud/resolvers/network/schema/network.graphql b/examples/cloud/resolvers/network/schema/network.graphql
deleted file mode 100644
index 8351c81..0000000
--- a/examples/cloud/resolvers/network/schema/network.graphql
+++ /dev/null
@@ -1,20 +0,0 @@
-type Subnet {
- id: ID!
- cidr: String
- gateway_ip: String
-}
-
-type Network {
- id: ID!
- name: String!
- network_type: String
- status: String
- subnets: [Subnet]
- external: Boolean
- appStatus: ApplicationStatus
- appActions: [Action]
-}
-
-extend type Query {
- getNetworks(region: String!): [Network]
-}
diff --git a/examples/cloud/resolvers/volume/__init__.py b/examples/cloud/resolvers/volume/__init__.py
deleted file mode 100644
index 58d9d24..0000000
--- a/examples/cloud/resolvers/volume/__init__.py
+++ /dev/null
@@ -1,40 +0,0 @@
-import logging
-
-import cannula
-
-from ..application import status
-from ..base import OpenStackBase
-
-LOG = logging.getLogger(__name__)
-
-volume_resolver = cannula.Resolver(__name__)
-
-
-@volume_resolver.datasource()
-class Volume(OpenStackBase):
- catalog_name = "volume"
-
- async def fetchVolumes(self, region=None):
- url = self.get_service_url(region, "volumes/detail")
- resp = await self.get(url)
- volumes = resp.volumes
- for volume in volumes:
- volume.region = region
- return volumes
-
- async def fetchLimits(self):
- east_url = self.get_service_url("us-east", "limits")
- resp = await self.get(east_url)
- return resp.limits
-
-
-@volume_resolver.resolver("Query")
-async def getVolumes(source, info, region):
- return await info.context.Volume.fetchVolumes(region)
-
-
-@volume_resolver.resolver("Volume")
-async def appStatus(volume, info):
- return status.Status(
- label=volume.status,
- )
diff --git a/examples/cloud/resolvers/volume/schema/volume.graphql b/examples/cloud/resolvers/volume/schema/volume.graphql
deleted file mode 100644
index 195b2e8..0000000
--- a/examples/cloud/resolvers/volume/schema/volume.graphql
+++ /dev/null
@@ -1,12 +0,0 @@
-type Volume {
- id: ID!
- name: String!
- size: Int
- status: String
- appStatus: ApplicationStatus
- appActions: [Action]
-}
-
-extend type Query {
- getVolumes(region: String): [Volume]
-}
diff --git a/examples/cloud/schema/application/actions.graphql b/examples/cloud/schema/application/actions.graphql
deleted file mode 100644
index f6f7850..0000000
--- a/examples/cloud/schema/application/actions.graphql
+++ /dev/null
@@ -1,26 +0,0 @@
-"""
-## Application Actions
-
-This is an generic action type that could be preformed on some other type.
-The particular actions that are possible are determined by the type of object
-and the state of the object and potetially the permissions of the user.
-We can define the action and inputs required with validation rules. That
-way we can re-use the same components on the front end with all of the logic
-to display forms or messages in a central spot.
-
-Actions should be small and not require a ton of user input. Some good
-examples are a delete confirmation box, or a rename widget. Actions that
-require a complex form or multiple data sources should most likely define
-their own custom types.
-"""
-type Action {
- label: String!
- icon: String
-
- "Allow for lazy loading the form"
- formUrl: String
- enabled: Boolean
-
- "Message for the user, this could be extra details about the action or a message to display when disabled."
- tooltip: String
-}
diff --git a/examples/cloud/schema/application/navigation.graphql b/examples/cloud/schema/application/navigation.graphql
deleted file mode 100644
index 4cf05ac..0000000
--- a/examples/cloud/schema/application/navigation.graphql
+++ /dev/null
@@ -1,18 +0,0 @@
-type NavigationItem {
- active: Boolean
- icon: String
- url: String
- name: String
- className: String
- enabled: Boolean
- disabledMessage: String
-}
-
-type NavigationSection {
- title: String
- items: [NavigationItem]
-}
-
-extend type Query {
- getNavigation(active: String): [NavigationSection]
-}
diff --git a/examples/cloud/schema/application/status.graphql b/examples/cloud/schema/application/status.graphql
deleted file mode 100644
index fe955f5..0000000
--- a/examples/cloud/schema/application/status.graphql
+++ /dev/null
@@ -1,11 +0,0 @@
-# This is a Status label for a given resource.
-# Every different type have multiple different statuses and or error messages
-# and the logic of what statuses should be considered an error or not varies.
-# This provides a contract for the UI to display them.
-type ApplicationStatus {
- label: String!
- color: String!
- working: Boolean!
- icon: String
- tooltip: String
-}
diff --git a/examples/cloud/session.py b/examples/cloud/session.py
deleted file mode 100644
index a6f7f89..0000000
--- a/examples/cloud/session.py
+++ /dev/null
@@ -1,152 +0,0 @@
-"""
-This is just a simple in memory session for storing our logged in users.
-
-Not really production worthy, just an example of using a session. It is just
-a dict. Nothing fancy going on here. It lives in this module so that we can
-avoid circular imports.
-"""
-import logging
-import typing
-import uuid
-
-import cannula
-from cannula.datasource.http import HTTPContext
-from graphql import parse
-from starlette.responses import RedirectResponse
-
-SESSION = {}
-SESSION_COOKE_NAME = "openstack_session_id"
-LOG = logging.getLogger(__name__)
-
-
-class User(typing.NamedTuple):
- catalog: dict = {}
- auth_token: str = ""
- username: str = "anonymous"
- roles: typing.List[str] = []
- session_id: str = ""
-
- @property
- def is_admin(self) -> bool:
- return "admin" in self.roles
-
- @property
- def is_authenticated(self) -> bool:
- return bool(self.auth_token)
-
- def has_role(self, role):
- return (role in self.roles) or self.is_admin
-
- def get_service_url(self, service: str, region: str) -> str:
- return self.catalog.get(service, {}).get(region)
-
-
-def flatten_catalog(catalog: typing.List[dict]) -> dict:
- """Turn the raw service catalog into a simple dict."""
- return {
- service["type"]: {
- endpoint["region"]: endpoint["url"] for endpoint in service["endpoints"]
- }
- for service in catalog
- }
-
-
-def get_user(session_id: str) -> User:
- user = SESSION.get(session_id)
- if user is not None:
- return user
-
- # Return an anonymous user object
- return User()
-
-
-def set_user(
- username: str,
- auth_token: str,
- catalog: typing.List[dict],
- roles: typing.List[dict],
-) -> User:
- session_id = str(uuid.uuid4())
- service_catalog = flatten_catalog(catalog)
- user_roles = [role["name"] for role in roles]
- user = User(
- catalog=service_catalog,
- auth_token=auth_token,
- username=username,
- roles=user_roles,
- session_id=session_id,
- )
- SESSION[session_id] = user
- return user
-
-
-LOGIN_MUTATION = parse(
- """
- mutation token ($username: String!, $password: String!) {
- login(username: $username, password: $password) {
- roles {
- name
- }
- catalog {
- type
- endpoints {
- region
- url
- }
- }
- user {
- name
- }
- authToken
- }
- }
-"""
-)
-
-
-async def login(
- request: typing.Any,
- username: str,
- password: str,
- api: cannula.API,
-) -> bool:
- resp = await api.call(
- LOGIN_MUTATION,
- variables={
- "username": username,
- "password": password,
- },
- request=request,
- )
-
- if resp.errors:
- LOG.error(f"{resp.errors}")
- raise Exception("Unable to login user")
-
- LOG.info(f"Auth Response: {resp.data}")
- token = resp.data["login"]
- user = set_user(
- username=token["user"]["name"],
- auth_token=token["authToken"],
- catalog=token["catalog"],
- roles=token["roles"],
- )
-
- response = RedirectResponse("/dashboard")
- response.set_cookie(SESSION_COOKE_NAME, user.session_id)
- return response
-
-
-class OpenStackContext(HTTPContext):
- def handle_request(self, request):
- session_id = request.cookies.get(SESSION_COOKE_NAME)
- self.user = get_user(session_id)
-
- return request
-
-
-def is_authenticated(request) -> bool:
- session_id = request.cookies.get(SESSION_COOKE_NAME)
- user = get_user(session_id)
- LOG.info(f"{user} {session_id}")
- return user.is_authenticated
diff --git a/examples/cloud/static/css/main.css b/examples/cloud/static/css/main.css
deleted file mode 100644
index b960b2d..0000000
--- a/examples/cloud/static/css/main.css
+++ /dev/null
@@ -1,26 +0,0 @@
-.main-navigation {
- min-height: 100vh;
-}
-
-th.sortable {
- cursor: pointer;
-}
-
-hx-panel.login {
- margin: 3em auto;
- border: 1px solid #b6e3eb;
- padding: 2em;
- background: #e4f9f9
-}
-
-label abbr {
- color: #eb0000;
-}
-
-.helpText {
- font-size: .75rem;
-}
-
-form {
- margin-bottom: 2em;
-}
diff --git a/examples/cloud/static/js/app-navigation.js b/examples/cloud/static/js/app-navigation.js
deleted file mode 100644
index b8050a0..0000000
--- a/examples/cloud/static/js/app-navigation.js
+++ /dev/null
@@ -1,48 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-const renderItem = (item) => {
- if (item.enabled) {
- return html`${item.name} `;
- }
- return html`${item.name} `;
-}
-
-const renderSection = (section) => {
- return html`
-
- ${section.title}
-
-
-
- ${section.items.map(renderItem)}
-
- `;
-}
-
-class AppNavigation extends LitElement {
- static get properties() {
- return {
- navItems: { type: Array },
- }
- }
-
- constructor() {
- super();
- this.navItems = [];
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { navItems } = this;
- return html`
-
- ${navItems.map(renderSection)}
-
- `;
- }
-}
-
-customElements.define('app-navigation', AppNavigation);
diff --git a/examples/cloud/static/js/compute/flavor-list.js b/examples/cloud/static/js/compute/flavor-list.js
deleted file mode 100644
index 26fd206..0000000
--- a/examples/cloud/static/js/compute/flavor-list.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-import { Column } from '../tables/column.js';
-import '../tables/data_table.js';
-
-const columns = [
- new Column('name', {sortable: true, sorted: true}),
- new Column('ram', {sortable: true, align: 'hxRight'}),
-]
-
-class FlavorList extends LitElement {
- static get properties() {
- return {
- flavors: { type: Array },
- errors: { type: Array }
- }
- }
-
- constructor() {
- super();
- this.flavors = [];
- this.errors = [];
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { flavors, errors } = this;
- const className = 'hxTable--condensed';
- return html`
-
Available Flavors
-
- `;
- }
-}
-
-customElements.define('flavor-list', FlavorList);
diff --git a/examples/cloud/static/js/compute/helpers.js b/examples/cloud/static/js/compute/helpers.js
deleted file mode 100644
index c67f286..0000000
--- a/examples/cloud/static/js/compute/helpers.js
+++ /dev/null
@@ -1,5 +0,0 @@
-
-export const flavorRam = (server) => {
- const flavor = server.flavor || {};
- return flavor.ram
-}
diff --git a/examples/cloud/static/js/compute/image-list.js b/examples/cloud/static/js/compute/image-list.js
deleted file mode 100644
index 152a894..0000000
--- a/examples/cloud/static/js/compute/image-list.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-import { Column } from '../tables/column.js';
-import '../tables/data_table.js';
-
-const columns = [
- new Column('name', {sortable: true, sorted: true}),
- new Column('minRam', { header: 'Min Ram', sortable: true, align: 'hxRight'}),
-]
-
-class ImageList extends LitElement {
- static get properties() {
- return {
- images: { type: Array },
- errors: { type: Array }
- }
- }
-
- constructor() {
- super();
- this.images = [];
- this.errors = [];
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { images, errors } = this;
- const className = 'hxTable--condensed';
- return html`
- Available Images
-
- `;
- }
-}
-
-customElements.define('image-list', ImageList);
diff --git a/examples/cloud/static/js/compute/server-list-compact.js b/examples/cloud/static/js/compute/server-list-compact.js
deleted file mode 100644
index e3fedb9..0000000
--- a/examples/cloud/static/js/compute/server-list-compact.js
+++ /dev/null
@@ -1,39 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-import { Column } from '../tables/column.js';
-import { flavorRam } from './helpers.js';
-import '../tables/data_table.js';
-
-const columns = [
- new Column('id'),
- new Column('name'),
- new Column(flavorRam, {header: 'flavor'}),
-]
-
-class ServerListCompact extends LitElement {
- static get properties() {
- return {
- servers: { type: Array },
- errors: { type: Array }
- }
- }
-
- constructor() {
- super();
- this.servers = [];
- this.errors = [];
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { servers, errors } = this;
- return html`
-
- `;
- }
-}
-
-customElements.define('server-list-compact', ServerListCompact);
diff --git a/examples/cloud/static/js/dashboard/chart.js b/examples/cloud/static/js/dashboard/chart.js
deleted file mode 100644
index bc42d40..0000000
--- a/examples/cloud/static/js/dashboard/chart.js
+++ /dev/null
@@ -1,66 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-class DashboardChart extends LitElement {
- static get properties() {
- return {
- chartType: { type: String },
- chartData: { type: Object }
- }
- }
-
- constructor() {
- super();
- this._chart = null;
- this.chartType = 'doughnut';
- this.chartData = {};
- }
-
- firstUpdated() {
- const { chartData, chartType } = this;
- let _canvas = this.shadowRoot.querySelector('canvas').getContext('2d');
- if (!this._chart) {
- this._chart = new Chart(_canvas, {
- type: chartType,
- data: chartData
- });
- } else {
- this._chart.data = chartData;
- this._chart.type = chartType;
- this._chart.update();
- }
- }
-
- updated(changedProperties) {
- const { chartData, _chart } = this;
- changedProperties.forEach((oldValue, propName) => {
- if (propName === 'chartData' && oldValue) {
- // Test if we need to update the chart with new values since we
- // are not directly using an lit-html template for these values.
- // Probably is a better way... but this works good enough!
- let origData = oldValue.datasets[0].data.toString();
- let newData = chartData.datasets[0].data.toString();
- if (origData !== newData) {
- _chart.data = chartData;
- _chart.update();
- }
- }
- });
- }
-
- render() {
- return html`
-
-
-
-
- `;
- }
-}
-
-customElements.define('dashboard-chart', DashboardChart);
diff --git a/examples/cloud/static/js/dashboard/resource-list.js b/examples/cloud/static/js/dashboard/resource-list.js
deleted file mode 100644
index 4634e82..0000000
--- a/examples/cloud/static/js/dashboard/resource-list.js
+++ /dev/null
@@ -1,43 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-import { Column } from '../tables/column.js';
-import '../tables/data_table.js';
-import { statusCell } from '../widgets/status-cell.js';
-import { actionCell } from '../widgets/action-cell.js';
-
-
-const columns = [
- new Column('appActions', {cell: actionCell, header: html` `, defaultValue: []}),
- new Column('appStatus', {cell: statusCell, header: 'Status'}),
- new Column('name', {sortable: true, sorted: true}),
- new Column('id')
-]
-
-class ResourceList extends LitElement {
- static get properties() {
- return {
- resources: { type: Array },
- errors: { type: Array }
- }
- }
-
- constructor() {
- super();
- this.resources = [];
- this.errors = [];
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { resources, errors } = this;
- const className = "hxHoverable resource-list";
- return html`
-
- `;
- }
-}
-
-customElements.define('resource-list', ResourceList);
diff --git a/examples/cloud/static/js/openstack-app.js b/examples/cloud/static/js/openstack-app.js
deleted file mode 100644
index 54ea846..0000000
--- a/examples/cloud/static/js/openstack-app.js
+++ /dev/null
@@ -1,122 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-import './app-navigation.js';
-import './compute/flavor-list.js';
-import './compute/image-list.js';
-import './dashboard/chart.js';
-import './dashboard/resource-list.js';
-
-class OpenstackApp extends LitElement {
- static get properties() {
- return {
- flavors: { type: Array },
- images: { type: Array },
- servers: { type: Array },
- nav: { type: Array },
- errors: { type: Object },
- loaded: { type: Boolean },
- pollingInterval: { type: Number }
- }
- }
-
- constructor() {
- super();
- this.servers = [];
- this.flavors = [];
- this.images = [];
- this.nav = [];
- this.errors = {};
- this.loaded = false;
- this.pollingInterval = null;
- }
-
- /*
- * Fetch data from the server at the specified polling interval
- */
- _fetchData() {
- // Only preform the fetch if the browser is in focus cause they aren't
- // looking at it anyway, if you want you can still do it
- fetch('?xhr=1')
- .then((response) => response.json())
- .then((response) => {
- this.loaded = true;
- this.data = response.data;
- this.errors = response.errors;
- });
- }
-
- firstUpdated() {
- const { pollingInterval } = this;
- if (pollingInterval) {
- // Setup the request to poll you need to bind the function this
- // in order for the response to update our properties.
- setInterval(this._fetchData.bind(this), pollingInterval);
- }
- this._fetchData()
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { data, errors, loaded } = this;
- let errorMessages = null;
- if (!loaded) {
- return html`
-
-
-
-
- Resources
-
-
-
-
- `;
- }
-
- if (errors && errors.errors) {
- errorMessages = errors.errors.map((error) => html`${error} `)
- }
-
- return html`
-
-
-
-
-
-
- Resources
-
-
-
-
-
-
-
-
- ${errorMessages}
-
-
-
-
-
-
-
-
- `;
- }
-}
-
-customElements.define('openstack-app', OpenstackApp);
diff --git a/examples/cloud/static/js/simple-app.js b/examples/cloud/static/js/simple-app.js
deleted file mode 100644
index a5b0f74..0000000
--- a/examples/cloud/static/js/simple-app.js
+++ /dev/null
@@ -1,118 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-import './app-navigation.js';
-import './compute/flavor-list.js';
-import './compute/image-list.js';
-import './dashboard/chart.js';
-import './dashboard/resource-list.js';
-
-class SimpleApp extends LitElement {
- static get properties() {
- return {
- flavors: { type: Array },
- images: { type: Array },
- servers: { type: Array },
- nav: { type: Array },
- errors: { type: Object },
- loaded: { type: Boolean },
- pollingInterval: { type: Number }
- }
- }
-
- constructor() {
- super();
- this.servers = [];
- this.flavors = [];
- this.images = [];
- this.nav = [];
- this.errors = {};
- this.loaded = false;
- this.pollingInterval = 5000;
- }
-
- /*
- * Fetch data from the server at the specified polling interval
- */
- _fetchData() {
- // Only preform the fetch if the browser is in focus cause they aren't
- // looking at it anyway, if you want you can still do it
- if ( document.hasFocus() ) {
- fetch('?xhr=1')
- .then((response) => response.json())
- .then((response) => {
- this.loaded = true;
- this.data = response.data;
- this.errors = response.errors;
- console.log('Polling for updates.');
- });
- }
- }
-
- firstUpdated() {
- const { pollingInterval } = this;
- if (pollingInterval) {
- // Setup the request to poll you need to bind the function this
- // in order for the response to update our properties.
- setInterval(this._fetchData.bind(this), pollingInterval);
- }
- this._fetchData()
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { data, errors, loaded } = this;
- let errorMessages = null;
- if (!loaded) {
- return html`
-
-
-
-
- Resources
-
-
-
-
- `;
- }
-
- if (errors && errors.errors) {
- errorMessages = errors.errors.map((error) => html`${error} `)
- }
-
- return html`
-
-
-
-
-
-
- Server List
-
-
-
-
-
-
-
- ${errorMessages}
- Lorem ipsum dolor sit amet, consectetur adipiscing elit, sed do eiusmod tempor
- incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud
- exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute
- irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla
- pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia
- deserunt mollit anim id est laborum.
-
-
-
-
-
-
- `;
- }
-}
-
-customElements.define('simple-app', SimpleApp);
diff --git a/examples/cloud/static/js/tables/column.js b/examples/cloud/static/js/tables/column.js
deleted file mode 100644
index 4779e2f..0000000
--- a/examples/cloud/static/js/tables/column.js
+++ /dev/null
@@ -1,116 +0,0 @@
-import { html } from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-/**
- * Default cell template.
- * @param {string|number} data - Data to display.
- * @return {string}
- */
-const defaultCell = (data) => html`${data}
`;
-
-/** Default options for the Column constructor. */
-const defaultOptions = {
- /** Default value when data is missing for this column */
- defaultValue: '',
-
- /** Header text */
- header: undefined,
-
- /** Class name to use instead of '${header}-column' */
- className: undefined,
-
- /** The function to render the cell for this column. */
- cell: defaultCell,
-
- /** The alignment class of the cell (hxRight) */
- align: 'hxLeft',
-
- /** Whether this column is sortable, and should display controls. */
- sortable: false,
-
- /** The direction this column is sorted. */
- sortDirection: 'DESC',
-
- /** Whether this column is the currently sorted. */
- sorted: false,
-
- /** The attribute that has the data-model ID to generate unique html id's. */
- idAttribute: 'id',
-}
-
-/**
- * Column of a data-table object. This handles the header for the column as
- * well a retrieving the display data for the table. When you create a new
- * data-table you can specify any number of columns. The data-table will take
- * an array of objects which each item is passed to the Column object to
- * get the cell data.
- *
- * The data-table is responsible for filtering or sorting the rows. The column
- * can return the data with the `getData()` function which can then be
- * used to sort or filter the data.
- */
-export class Column {
-
- /**
- * Create a Column
- * @param {string} attribute - The attribute to display.
- * @param {defaultOptions} options - Optional settings.
- */
- constructor (attribute, options) {
- let opts = Object.assign({}, defaultOptions, options);
- this.attribute = attribute;
- this.defaultValue = opts.defaultValue;
- this.header = opts.header || attribute;
- this.cell = opts.cell;
- this.align = opts.align;
- this.sortable = opts.sortable;
- this.sortDirection = opts.sortDirection;
- this.sorted = opts.sorted;
- this.idAttribute = opts.idAttribute;
- }
-
- /**
- * Return the value of the attribute on the dataModel object.
- * @param {object} dataModel - A single item from the data table.
- * @return {string|number|Object}
- */
- getData(dataModel) {
- if (typeof this.attribute === "function") {
- return this.attribute(dataModel);
- }
- return dataModel[this.attribute] || this.defaultValue;
- }
-
- /**
- * Return the value of the idAttribute on the dataModel object.
- * @param {object} dataModel - A single item from the data table.
- * @return {string|number}
- */
- getId(dataModel) {
- if (typeof this.idAttribute === "function") {
- return this.idAttribute(dataModel);
- }
- return dataModel[this.idAttribute] || this.defaultValue;
- }
-
- /**
- * Return the cell data.
- * For simple cases this just returns a the data wrapped in a div. You can
- * override the cell to provide a richer html response.
- * @param {object} dataModel - A single item from the data table.
- * @return {string}
- */
- getCell(dataModel) {
- const data = this.getData(dataModel);
- const id = this.getId(dataModel);
- console.log(id);
- return this.cell(data, id);
- }
-
- /**
- * Toggle sort direction.
- */
- toggleSortDirection() {
- this.sortDirection = (this.sortDirection === "DESC") ? "ASC" : "DESC";
- this.sorted = true;
- }
-}
diff --git a/examples/cloud/static/js/tables/data_table.js b/examples/cloud/static/js/tables/data_table.js
deleted file mode 100644
index 517f8d0..0000000
--- a/examples/cloud/static/js/tables/data_table.js
+++ /dev/null
@@ -1,210 +0,0 @@
-import {LitElement, html} from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-/**
- * Default Empty Overlay
- * Used to display help content when there is no data.
- * @return {string}
- */
-const defaultEmptyOverlay = html`No results
`;
-
-/**
- * Default Error Overlay
- * Used to display any errors that are passed in via the errors prop.
- * @param {Array} errors - List of errors to display
- * @return {string|html}
- */
-const defaultErrorOverlay = (errors) => {
- return html`
-
- ${errors.map((error) => html`
${error}
`)}
-
- `;
-};
-
-/**
- * Default Sort Closure
- * @param {Column} column - The column to sort
- */
-const defaultSort = (column) => {
-
- /**
- * Default sort function.
- * @param {any} first - First item to compare
- * @param {any} second - Second item to compare
- * @return {number}
- */
- return (first, second) => {
- const firstItem = column.getData(first);
- const secondItem = column.getData(second);
- const compared = firstItem.toString().localeCompare(secondItem);
- return column.sortDirection === 'DESC' ? compared : -compared;
- };
-};
-
-/**
- * Column Header Display
- * If the column is sortable this will include controls and a callback to
- * update the sortIndex and or the direction on the column.
- * @param {function} sortIndexCallback - Function to update the sort index.
- * @return {function}
- *
- */
-const displayHeader = (sortIndexCallback) => {
-
- /**
- * Column Header Display
- * If the column is sortable this will include controls and a callback to
- * update the sortIndex and or the direction on the column.
- *
- * @param {Column} column - The column to display the header and controls for.
- * @param {number} index - The index of the column.
- * @return {string|html}
- */
- return (column, index) => {
- const { sortable, sortDirection, header, sorted, align } = column;
- let columnIndex = index;
- if (!sortable) {
- return html`${column.header} `;
- }
- const sortedIcon = sortDirection === 'DESC' ? 'sort-down' : 'sort-up';
- const icon = sorted ? sortedIcon : 'sort';
- return html` sortIndexCallback(columnIndex)}>${header} `;
- };
-};
-
-export class DataTable extends LitElement {
-
- constructor () {
- super();
- this.columns = [];
- this.data = [];
- this.errors = [];
- this.displayData = [];
- this.emptyOverlay = defaultEmptyOverlay;
- this.errorOverlay = defaultErrorOverlay;
- this.className = 'hxHoverable';
- this.sortIndex = -1;
- }
-
- static get properties() {
- return {
- data: { type: Array },
- columns: { type: Array },
- errors: { type: Array },
- emptyOverlay: { type: String },
- errorOverlay: { type: String },
- className: { type: String }
- };
- }
-
- _getHeader() {
- const { columns, _updateSortIndex } = this;
- const headerDisplay = displayHeader(_updateSortIndex.bind(this));
- return html`
-
- ${columns.map(headerDisplay)}
-
- `;
- }
-
- _showEmpty() {
- const { emptyOverlay } = this;
- const numberOfColumns = this.columns.length;
- return html`
-
- ${emptyOverlay}
-
- `;
- }
-
- _showError() {
- const { errorOverlay, errors } = this;
- const numberOfColumns = this.columns.length;
- return html`
-
- ${errorOverlay(errors)}
-
- `;
- }
-
- _getFilteredData(data) {
- // TODO: make this work
- return data;
- }
-
- _getSortedData(data) {
- const { sortIndex, columns } = this;
- if (sortIndex >= 0) {
- let column = columns[sortIndex];
- return data.sort(defaultSort(column));
- }
-
- return data;
- }
-
- _filterAndSort(data) {
- let filtered = this._getFilteredData(data);
- let sorted = this._getSortedData(filtered);
- return sorted;
- }
-
- _updateSortIndex(index) {
- const { sortIndex, columns } = this;
- if (sortIndex === index) {
- columns[index].toggleSortDirection();
- } else {
- columns[this.sortIndex].sorted = false;
- columns[index].sorted = true;
- this.sortIndex = index;
- }
- this.update();
- }
-
- _getBody() {
- const { data, columns, errors } = this;
- if (errors && errors.length !== 0) {
- return this._showError();
- }
-
- if (!data || data.length === 0) {
- return this._showEmpty();
- }
-
- let sortedData = this._filterAndSort(data);
-
- return html`
- ${sortedData.map((item) => {
- return html`
-
- ${columns.map((column) => {return html`${column.getCell(item)} `})}
-
- `;
- })}
- `;
- }
-
- connectedCallback() {
- super.connectedCallback();
- const { columns } = this;
- columns.map((column, index) => {
- if (column.sorted) {
- this.sortIndex = index;
- }
- });
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- return html`
-
- ${this._getHeader()}
- ${this._getBody()}
-
- `;
- }
-}
-
-customElements.define('data-table', DataTable);
diff --git a/examples/cloud/static/js/widgets/action-cell.js b/examples/cloud/static/js/widgets/action-cell.js
deleted file mode 100644
index 8823a24..0000000
--- a/examples/cloud/static/js/widgets/action-cell.js
+++ /dev/null
@@ -1,45 +0,0 @@
-import { html } from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-import { render } from 'https://unpkg.com/lit-html?module';
-
-import './action-modal.js';
-
-const actionModal = (formUrl, title) => {
- return html` `
-}
-
-const openModal = (formUrl, title, menuId) => {
- const modalElement = document.getElementById('app-action-modal');
- const parentMenu = document.getElementById(menuId);
- render(actionModal(formUrl, title), modalElement);
- modalElement.open = true;
- parentMenu.open = false;
-}
-
-const actionPopover = (action, menuId) => {
- if (action.enabled) {
- return html`
-
- openModal(action.formUrl, action.label, menuId)}>${action.label}
-
- `;
- }
- return html`
-
- ${action.label}
-
- `;
-}
-
-
-export const actionCell = (actions, id) => {
- const actionList = actions || [];
- const menuId = `action-menu-${id}`;
- return html`
-
-
-
-
- `
-};
diff --git a/examples/cloud/static/js/widgets/action-modal.js b/examples/cloud/static/js/widgets/action-modal.js
deleted file mode 100644
index 3d81425..0000000
--- a/examples/cloud/static/js/widgets/action-modal.js
+++ /dev/null
@@ -1,56 +0,0 @@
-import { LitElement, html } from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-class ActionModal extends LitElement {
- static get properties() {
- return {
- href: { type: String },
- title: { type: String },
- submitText: { type: String },
- form: { type: Object }
- }
- }
-
- constructor() {
- super();
- this.form = {};
- }
-
- firstUpdated() {
- fetch(this.href)
- .then((r) => r.json())
- .then((r) => {
- console.log(r)
- this.form = r.data.form;
- });
- }
-
- createRenderRoot() {
- return this;
- }
-
- render() {
- const { form, title } = this;
- const fields = form.fields || [];
- return html`
- ${title}
-
-
- `;
- }
-}
-
-customElements.define('action-modal', ActionModal);
diff --git a/examples/cloud/static/js/widgets/status-cell.js b/examples/cloud/static/js/widgets/status-cell.js
deleted file mode 100644
index d090594..0000000
--- a/examples/cloud/static/js/widgets/status-cell.js
+++ /dev/null
@@ -1,4 +0,0 @@
-import { html } from 'https://unpkg.com/@polymer/lit-element@0.7.1/lit-element.js?module';
-
-
-export const statusCell = (status) => html`${status.label} `;
diff --git a/examples/cloud/templates/index.html b/examples/cloud/templates/index.html
deleted file mode 100644
index 432e5b1..0000000
--- a/examples/cloud/templates/index.html
+++ /dev/null
@@ -1,38 +0,0 @@
-
-
-
- Frankly
-
-
-
-
-
-
-
-
-
- Skip to main content
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/examples/cloud/templates/login.html b/examples/cloud/templates/login.html
deleted file mode 100644
index fde82d6..0000000
--- a/examples/cloud/templates/login.html
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
- Frankly
-
-
-
-
-
- Skip to main content
-
-
-
-
-
-
-
- Please Login
- Try one of these usernames:
-
- admin (full site admin)
- create (create access)
- read (read only user)
- write (all access)
- managed (privileged customer)
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/examples/cloud/templates/simple.html b/examples/cloud/templates/simple.html
deleted file mode 100644
index 090c335..0000000
--- a/examples/cloud/templates/simple.html
+++ /dev/null
@@ -1,37 +0,0 @@
-
-
-
- Simple Dashboard
-
-
-
-
-
-
-
-
- Skip to main content
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/examples/extends.py b/examples/extends.py
deleted file mode 100644
index c995da3..0000000
--- a/examples/extends.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import cannula
-from graphql import GraphQLObjectType, GraphQLString, GraphQLSchema
-
-schema = cannula.gql(
- """
- type Brocoli {
- taste: String
- }
- type Message {
- text: String
- number: Int
- float: Float
- isOn: Boolean
- id: ID
- brocoli: Brocoli
- }
- type Query {
- mockity: [Message]
- boo: String
- }
-"""
-)
-
-extensions = cannula.gql(
- """
- extend type Brocoli {
- color: String
- }
- extend type Query {
- fancy: [Message]
- }
-"""
-)
-
-# schema = cannula.build_and_extend_schema([schema, extensions])
-
-api = cannula.API(
- __name__,
- schema=[schema, extensions],
-)
-
-SAMPLE_QUERY = cannula.gql(
- """
- query HelloWorld {
- fancy {
- text
- }
- }
-"""
-)
-
-
-print(api.call_sync(SAMPLE_QUERY))
diff --git a/examples/extension/README.md b/examples/extension/README.md
new file mode 100644
index 0000000..72dcdef
--- /dev/null
+++ b/examples/extension/README.md
@@ -0,0 +1,13 @@
+# Extension Example
+
+This shows an example of using multiple schemas that extend functionality. The most simple case is to extend the `Query` or `Mutation` types. But any type can be extended.
+
+The directory `schema` contains multiple graphql files that will be loaded if we specify a `pathlib.Path` type as the `schema` argument.
+
+```python
+
+import cannula
+import pathlib
+
+api = cannula.API(schema=pathlib.Path('./schema'))
+```
\ No newline at end of file
diff --git a/examples/cloud/resolvers/network/resolver.py b/examples/extension/__init__.py
similarity index 100%
rename from examples/cloud/resolvers/network/resolver.py
rename to examples/extension/__init__.py
diff --git a/examples/extension/main.py b/examples/extension/main.py
new file mode 100644
index 0000000..e68039d
--- /dev/null
+++ b/examples/extension/main.py
@@ -0,0 +1,48 @@
+import pathlib
+import pprint
+import logging
+
+import cannula
+import cannula.middleware
+
+logging.basicConfig(level=logging.DEBUG)
+
+BASE_DIR = pathlib.Path(__file__).parent
+
+api = cannula.API(
+ schema=pathlib.Path(BASE_DIR / "schema"),
+ middleware=[
+ cannula.middleware.DebugMiddleware(),
+ ],
+)
+
+
+@api.resolver("Query", "books")
+def get_books(parent, info):
+ return [{"name": "Lost", "author": "Frank"}]
+
+
+@api.resolver("Book", "movies")
+def get_movies_for_book(book, info):
+ return [{"name": "Lost the Movie", "director": "Ted"}]
+
+
+QUERY = cannula.gql(
+ """
+ query BookList {
+ books {
+ name
+ author
+ movies {
+ name
+ director
+ }
+ }
+ }
+"""
+)
+
+
+if __name__ == "__main__":
+ results = api.call_sync(QUERY, None)
+ pprint.pprint(results.data)
diff --git a/examples/extension/schema/base.graphql b/examples/extension/schema/base.graphql
new file mode 100644
index 0000000..518cacf
--- /dev/null
+++ b/examples/extension/schema/base.graphql
@@ -0,0 +1,7 @@
+type GenericThing {
+ name: String
+}
+
+type Query {
+ generic: [GenericThing]
+}
\ No newline at end of file
diff --git a/examples/extension/schema/books.graphql b/examples/extension/schema/books.graphql
new file mode 100644
index 0000000..62d79a8
--- /dev/null
+++ b/examples/extension/schema/books.graphql
@@ -0,0 +1,8 @@
+type Book {
+ name: String
+ author: String
+}
+
+extend type Query {
+ books: [Book]
+}
\ No newline at end of file
diff --git a/examples/extension/schema/movies.graphql b/examples/extension/schema/movies.graphql
new file mode 100644
index 0000000..6566e41
--- /dev/null
+++ b/examples/extension/schema/movies.graphql
@@ -0,0 +1,13 @@
+type Movie {
+ name: String
+ director: String
+ book: Book
+}
+
+extend type Book {
+ movies: [Movie]
+}
+
+extend type Query {
+ movies: [Movie]
+}
\ No newline at end of file
diff --git a/examples/hello.py b/examples/hello.py
index 31b9b7c..a469839 100755
--- a/examples/hello.py
+++ b/examples/hello.py
@@ -4,21 +4,25 @@
import cannula
from cannula.middleware import DebugMiddleware
+from graphql import GraphQLResolveInfo
SCHEMA = cannula.gql(
"""
- type Message {
- text: String
- }
- type Query {
- hello(who: String): Message
- }
+ type Message {
+ text: String
+ }
+ type Query {
+ hello(who: String): Message
+ }
"""
)
logging.basicConfig(level=logging.DEBUG)
-api = cannula.API(__name__, schema=[SCHEMA], middleware=[DebugMiddleware()])
+api = cannula.API(
+ schema=SCHEMA,
+ middleware=[DebugMiddleware()],
+)
class Message(typing.NamedTuple):
@@ -28,8 +32,12 @@ class Message(typing.NamedTuple):
# The query resolver takes a source and info objects
# and any arguments defined by the schema. Here we
# only accept a single argument `who`.
-@api.resolver("Query")
-async def hello(source, info, who):
+@api.resolver("Query", "hello")
+async def hello(
+ source: typing.Any,
+ info: GraphQLResolveInfo,
+ who: str,
+) -> Message:
return Message(f"Hello, {who}!")
@@ -38,17 +46,22 @@ async def hello(source, info, who):
# query functions.
SAMPLE_QUERY = cannula.gql(
"""
- query HelloWorld ($who: String!) {
- hello(who: $who) {
- text
+ query HelloWorld ($who: String!) {
+ hello(who: $who) {
+ text
+ }
}
- }
"""
)
-who = "world"
-if len(sys.argv) > 1:
- who = sys.argv[1]
+def run_hello(who: str = "world"):
+ return api.call_sync(SAMPLE_QUERY, variables={"who": who})
+
+
+if __name__ == "__main__":
+ who = "world"
+ if len(sys.argv) > 1:
+ who = sys.argv[1]
-print(api.call_sync(SAMPLE_QUERY, variables={"who": who}))
+ print(run_hello(who))
diff --git a/examples/mocks.py b/examples/mocks.py
index 11dbd6b..53a58b6 100755
--- a/examples/mocks.py
+++ b/examples/mocks.py
@@ -3,41 +3,40 @@
schema = cannula.gql(
"""
- type Brocoli {
- taste: String
- }
- type Message {
- text: String
- number: Int
- float: Float
- isOn: Boolean
- id: ID
- brocoli: Brocoli
- }
- type Query {
- mockity: [Message]
- }
+ type Brocoli {
+ taste: String
+ }
+ type Message {
+ text: String
+ number: Int
+ float: Float
+ isOn: Boolean
+ id: ID
+ brocoli: Brocoli
+ }
+ type Query {
+ mockity: [Message]
+ }
"""
)
sample_query = cannula.gql(
"""{
- mockity {
- text
- number
- float
- isOn
- id
- brocoli {
- taste
+ mockity {
+ text
+ number
+ float
+ isOn
+ id
+ brocoli {
+ taste
+ }
}
- }
}
"""
)
default = cannula.API(
- __name__,
schema=schema,
middleware=[MockMiddleware()],
)
@@ -61,7 +60,6 @@
}
custom = cannula.API(
- __name__,
schema=schema,
middleware=[
MockMiddleware(mock_objects=custom_mocks, mock_all=True),
@@ -77,7 +75,6 @@
)
limited_mocks = cannula.API(
- __name__,
schema=schema,
middleware=[
MockMiddleware(mock_objects=custom_mocks, mock_all=False),
diff --git a/performance/test_performance.py b/performance/test_performance.py
index ab33010..2e85c4b 100755
--- a/performance/test_performance.py
+++ b/performance/test_performance.py
@@ -90,10 +90,10 @@ def resolve_get_widgets(_, _info, use: str) -> typing.List[dict]:
# Create executable schema instance
exe_schema = ariadne.make_executable_schema(schema, query)
ariadne_app = ariadne.asgi.GraphQL(exe_schema)
-cannula_app = cannula.API(__name__, schema=[schema])
+cannula_app = cannula.API(schema=schema)
-@cannula_app.resolver("Query")
+@cannula_app.resolver("Query", "get_widgets")
def get_widgets(_, _info, use: str) -> typing.Any:
return _get_widgets(use)
diff --git a/pyproject.toml b/pyproject.toml
index f1aace1..7828669 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -35,9 +35,12 @@ test = [
"pytest-cov",
"pytest-mock",
"pytest<8",
- "Sphinx==2.0.1",
+ "Sphinx==7.2.6",
+ "sphinx-autodoc-typehints",
+ "sphinx-material",
"twine==4.0.2",
"types-requests",
+ "hatch",
]
performance = [
"pytest<8",
diff --git a/tests/conftest.py b/tests/conftest.py
new file mode 100644
index 0000000..b9f9ca8
--- /dev/null
+++ b/tests/conftest.py
@@ -0,0 +1,3 @@
+import logging
+
+logging.basicConfig(level=logging.DEBUG)
diff --git a/tests/fixtures/examples b/tests/fixtures/examples
new file mode 120000
index 0000000..d15735c
--- /dev/null
+++ b/tests/fixtures/examples
@@ -0,0 +1 @@
+../../examples
\ No newline at end of file
diff --git a/tests/middleware/test_debug.py b/tests/middleware/test_debug.py
index 0681dfb..5627bce 100644
--- a/tests/middleware/test_debug.py
+++ b/tests/middleware/test_debug.py
@@ -41,15 +41,14 @@ async def test_debug_middleware(mocker):
logger = mock.Mock(spec=logging.Logger)
api = cannula.API(
- __name__,
- schema=[SCHEMA],
+ schema=SCHEMA,
middleware=[DebugMiddleware(logger=logger)],
)
# The query resolver takes a source and info objects
# and any arguments defined by the schema. Here we
# only accept a single argument `who`.
- @api.resolver("Query")
+ @api.resolver("Query", "hello")
async def hello(_source, _info, who):
return Message(f"Hello, {who}!")
diff --git a/tests/test_examples.py b/tests/test_examples.py
new file mode 100644
index 0000000..9e0c512
--- /dev/null
+++ b/tests/test_examples.py
@@ -0,0 +1,62 @@
+def test_hello_world():
+ from tests.fixtures.examples import hello
+
+ results = hello.run_hello("sammy")
+ assert results.data == {"hello": {"text": "Hello, sammy!"}}
+
+
+def test_extension_works_properly_from_multiple_file():
+ from tests.fixtures.examples.extension import main
+
+ results = main.api.call_sync(main.QUERY)
+ assert results.data == {
+ "books": [
+ {
+ "author": "Frank",
+ "movies": [{"director": "Ted", "name": "Lost the Movie"}],
+ "name": "Lost",
+ }
+ ]
+ }
+
+
+def test_mocks_work_properly():
+ from tests.fixtures.examples import mocks
+
+ # all values are mocked
+ default_results = mocks.default.call_sync(mocks.sample_query)
+ assert default_results.data
+ mock_result = default_results.data["mockity"][0]
+ assert isinstance(mock_result["text"], str)
+ assert isinstance(mock_result["number"], int)
+ assert isinstance(mock_result["float"], float)
+ assert isinstance(mock_result["isOn"], bool)
+ assert isinstance(mock_result["id"], str)
+
+ # some values are constant while the rest are mocked
+ custom_results = mocks.custom.call_sync(mocks.sample_query)
+ assert custom_results.data
+ mock_result = custom_results.data["mockity"][0]
+ # we need to remove the mocked values
+ assert isinstance(mock_result.pop("id"), str)
+ assert isinstance(mock_result.pop("float"), float)
+ assert isinstance(mock_result.pop("isOn"), bool)
+ # assert the rest is the custom values we set
+ assert mock_result == {
+ "text": "This will be used for all Strings",
+ "number": 42,
+ "brocoli": {"taste": "Delicious"},
+ }
+
+ # limited mocks only return the custom values
+ limited_results = mocks.limited_mocks.call_sync(mocks.sample_query)
+ assert limited_results.data
+ mock_result = limited_results.data["mockity"][0]
+ assert mock_result == {
+ "text": "This will be used for all Strings",
+ "number": 42,
+ "float": None,
+ "id": None,
+ "isOn": False,
+ "brocoli": {"taste": "Delicious"},
+ }
diff --git a/tests/test_schema.py b/tests/test_schema.py
index 96f91d0..0a79fe4 100644
--- a/tests/test_schema.py
+++ b/tests/test_schema.py
@@ -1,11 +1,14 @@
+import tempfile
+import os
+import pathlib
+
from typing import cast
from unittest import mock
-from graphql import GraphQLUnionType, GraphQLResolveInfo, parse
+from graphql import DocumentNode, GraphQLUnionType, GraphQLResolveInfo, parse
import cannula
-schema = cannula.gql(
- """
+SCHEMA = """
type Sender {
name: String @deprecated(reason: "Use `email`.")
}
@@ -17,10 +20,8 @@
messages: [Message]
}
"""
-)
-extensions = cannula.gql(
- """
+EXTENTIONS = """
extend type Sender {
email: String
}
@@ -28,7 +29,6 @@
get_sender_by_email(email: String): Sender
}
"""
-)
async def get_sender_by_email(email: str) -> dict:
@@ -36,9 +36,9 @@ async def get_sender_by_email(email: str) -> dict:
async def test_extentions_are_correct():
- api = cannula.API(__name__, schema=[schema, extensions])
+ api = cannula.API(schema=SCHEMA + EXTENTIONS)
- @api.resolver()
+ @api.resolver("Query", "get_sender_by_email")
async def get_sender_by_email(_root, _info, email: str) -> dict:
return {"email": email, "name": "tester"}
@@ -63,7 +63,7 @@ async def get_sender_by_email(_root, _info, email: str) -> dict:
async def test_union_types():
with_union = cannula.schema.build_and_extend_schema(
- [schema, "union Thing = Sender | Message"]
+ [SCHEMA, "union Thing = Sender | Message"]
)
fixed = cannula.schema.fix_abstract_resolve_type(with_union)
thing_type = fixed.get_type("Thing")
@@ -95,3 +95,33 @@ async def test_directives():
defs = parsed.get("definitions", [])
# TODO get the directives to work
assert len(defs[0].get("fields")) == 2
+
+
+def test_load_schema_from_filename():
+ with tempfile.NamedTemporaryFile(mode="w") as graph_schema:
+ graph_schema.write(SCHEMA)
+ graph_schema.seek(0)
+
+ parsed = cannula.load_schema(graph_schema.name)
+ assert len(parsed) == 1
+ assert isinstance(parsed[0], DocumentNode)
+
+
+def test_load_schema_from_pathlib_path():
+ with tempfile.NamedTemporaryFile(mode="w") as graph_schema:
+ graph_schema.write(SCHEMA)
+ graph_schema.seek(0)
+
+ parsed = cannula.load_schema(pathlib.Path(graph_schema.name))
+ assert len(parsed) == 1
+ assert isinstance(parsed[0], DocumentNode)
+
+
+def test_load_schema_from_directory():
+ with tempfile.NamedTemporaryFile(mode="w", suffix=".graphql") as graph_schema:
+ graph_schema.write(SCHEMA)
+ graph_schema.seek(0)
+
+ parsed = cannula.load_schema(os.path.dirname(graph_schema.name))
+ assert len(parsed) == 1
+ assert isinstance(parsed[0], DocumentNode)