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` - - `; - } -} - -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` - - - - - ${actionList.map((item) => actionPopover(item, menuId))} - - ` -}; 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}

-
-
- ${fields.map(field => html` - - `)} -
-
-
- - -
- `; - } -} - -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 - - - -
-
- -
-
- - - - - - 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)