├── tests ├── __init__.py ├── loaders │ ├── __init__.py │ ├── test_engine.py │ ├── testcases.py │ ├── test_app_directories.py │ ├── test_base.py │ ├── test_parser.py │ ├── test_cached.py │ ├── test_filesystem.py │ └── test_url.py ├── renderers │ ├── __init__.py │ ├── test_relay.py │ └── test_base.py ├── versioning │ ├── __init__.py │ ├── testcases.py │ ├── test_base.py │ ├── test_query_parameter.py │ ├── test_vendor_tree.py │ ├── test_host_name.py │ └── test_accept_header.py ├── documents │ ├── syntax_error.graphql │ ├── v1 │ │ ├── fragments │ │ │ ├── type.graphql │ │ │ ├── enum.graphql │ │ │ └── schema.graphql │ │ └── full │ │ │ └── schema.graphql │ └── schema.graphql ├── settings.py ├── decorators.py ├── testcases.py ├── test_cache.py ├── test_query.py ├── test_parser.py └── test_middleware.py ├── graphql_persist ├── __init__.py ├── renderers │ ├── __init__.py │ ├── relay.py │ └── base.py ├── loaders │ ├── exceptions.py │ ├── app_directories.py │ ├── __init__.py │ ├── cached.py │ ├── filesystem.py │ ├── engine.py │ ├── url.py │ ├── base.py │ └── parser.py ├── cache.py ├── parser.py ├── exceptions.py ├── query.py ├── versioning.py ├── settings.py └── middleware.py ├── .coveragerc ├── requirements ├── test.txt └── flake8.txt ├── MANIFEST.in ├── CHANGES.rst ├── .codeclimate.yml ├── .gitignore ├── setup.cfg ├── Makefile ├── tox.ini ├── LICENSE ├── .travis.yml ├── setup.py └── README.rst /tests/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/loaders/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/versioning/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /tests/documents/syntax_error.graphql: -------------------------------------------------------------------------------- 1 | boom! -------------------------------------------------------------------------------- /graphql_persist/__init__.py: -------------------------------------------------------------------------------- 1 | __version__ = '0.1.1' 2 | -------------------------------------------------------------------------------- /.coveragerc: -------------------------------------------------------------------------------- 1 | [run] 2 | source = graphql_persist 3 | branch = True 4 | -------------------------------------------------------------------------------- /tests/documents/v1/fragments/type.graphql: -------------------------------------------------------------------------------- 1 | fragment typeFields on __Type { 2 | name 3 | } -------------------------------------------------------------------------------- /tests/documents/v1/fragments/enum.graphql: -------------------------------------------------------------------------------- 1 | fragment enumFields on __EnumValue { 2 | description 3 | } -------------------------------------------------------------------------------- /requirements/test.txt: -------------------------------------------------------------------------------- 1 | coverage>=4.4 2 | pytest>=3.3.1 3 | pytest-cov>=2.4.0 4 | pytest-django>=3.1.2 5 | -------------------------------------------------------------------------------- /tests/documents/schema.graphql: -------------------------------------------------------------------------------- 1 | { 2 | __schema { 3 | queryType { 4 | name 5 | } 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include LICENSE 2 | include MANIFEST.in 3 | include README.rst 4 | 5 | recursive-exclude * __pycache__ 6 | recursive-exclude * *.py[co] 7 | -------------------------------------------------------------------------------- /tests/documents/v1/full/schema.graphql: -------------------------------------------------------------------------------- 1 | # from ..fragments.schema import schemaFields 2 | 3 | { 4 | __schema { 5 | ...schemaFields 6 | } 7 | } -------------------------------------------------------------------------------- /requirements/flake8.txt: -------------------------------------------------------------------------------- 1 | flake8>=3.5.0 2 | 3 | flake8-commas>=0.4.3 4 | flake8-comprehensions>=1.4.1 5 | flake8-isort>=2.2.2 6 | flake8-quotes>=0.13.0 7 | -------------------------------------------------------------------------------- /tests/settings.py: -------------------------------------------------------------------------------- 1 | DATABASES = { 2 | 'default': { 3 | 'ENGINE': 'django.db.backends.sqlite3', 4 | }, 5 | } 6 | 7 | SECRET_KEY = 'test' 8 | -------------------------------------------------------------------------------- /tests/decorators.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | 4 | class override_persist_settings(override_settings): 5 | 6 | def __init__(self, **kwargs): 7 | super().__init__(GRAPHQL_PERSIST=kwargs) 8 | -------------------------------------------------------------------------------- /tests/documents/v1/fragments/schema.graphql: -------------------------------------------------------------------------------- 1 | # from .type import typeFields 2 | 3 | fragment schemaFields on __Schema { 4 | queryType { 5 | ...typeFields 6 | enumValues { 7 | ...enumFields 8 | } 9 | } 10 | } -------------------------------------------------------------------------------- /CHANGES.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | 0.1.1 5 | ----- 6 | 7 | * Supported multiple content types 8 | 9 | 0.1.0 10 | ----- 11 | 12 | * Supported recursive fragments calls 13 | 14 | 0.0.1 15 | ----- 16 | 17 | * Good morning Vietnam! 18 | -------------------------------------------------------------------------------- /tests/versioning/testcases.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory, testcases 2 | 3 | 4 | class VersioningTestsCase(testcases.TestCase): 5 | 6 | def setUp(self): 7 | self.request_factory = RequestFactory() 8 | self.version = 'v1' 9 | -------------------------------------------------------------------------------- /.codeclimate.yml: -------------------------------------------------------------------------------- 1 | --- 2 | engines: 3 | duplication: 4 | enabled: true 5 | config: 6 | languages: 7 | - python 8 | fixme: 9 | enabled: true 10 | radon: 11 | enabled: true 12 | ratings: 13 | paths: 14 | - "**.py" 15 | -------------------------------------------------------------------------------- /graphql_persist/renderers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BaseRenderer, BaseStripTagsRenderer 2 | from .relay import StripRelayTagsRenderer 3 | 4 | __all__ = [ 5 | 'BaseRenderer', 6 | 'BaseStripTagsRenderer', 7 | 'StripRelayTagsRenderer', 8 | ] 9 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.*~ 2 | *.directory 3 | *.db 4 | *.DS_Store 5 | *.box 6 | *.cer 7 | *.log 8 | *.pem 9 | *.p12 10 | *.pyc 11 | *.sql 12 | *.sqlite3 13 | *.xls 14 | 15 | __pycache__ 16 | 17 | *.egg* 18 | /.cache 19 | /.coverage 20 | /.pytest_cache 21 | /.tox 22 | 23 | /build 24 | /coverage.xml 25 | /dist 26 | -------------------------------------------------------------------------------- /graphql_persist/loaders/exceptions.py: -------------------------------------------------------------------------------- 1 | 2 | class DocumentDoesNotExist(Exception): 3 | 4 | def __init__(self, query_key): 5 | self.query_key = query_key 6 | return super().__init__(query_key) 7 | 8 | 9 | class DocumentImportError(Exception): 10 | """Document Import Error""" 11 | -------------------------------------------------------------------------------- /tests/loaders/test_engine.py: -------------------------------------------------------------------------------- 1 | from .testcases import LoadersTestsCase 2 | 3 | 4 | class EngineTests(LoadersTestsCase): 5 | 6 | def test_from_string(self): 7 | engine = self.engine_class() 8 | query = '{q}' 9 | document = engine.from_string(query) 10 | 11 | self.assertEqual(document.source.body, query) 12 | -------------------------------------------------------------------------------- /graphql_persist/loaders/app_directories.py: -------------------------------------------------------------------------------- 1 | from django.template.utils import get_app_template_dirs 2 | 3 | from ..settings import persist_settings 4 | from .filesystem import FilesystemLoader 5 | 6 | 7 | class AppDirectoriesLoader(FilesystemLoader): 8 | 9 | def get_dirs(self): 10 | return get_app_template_dirs(persist_settings.APP_DOCUMENT_DIR) 11 | -------------------------------------------------------------------------------- /graphql_persist/cache.py: -------------------------------------------------------------------------------- 1 | from django.core.cache import cache as default_cache 2 | from django.core.cache import caches 3 | from django.core.cache.backends.base import InvalidCacheBackendError 4 | 5 | from .settings import persist_settings 6 | 7 | try: 8 | cache = caches[persist_settings.CACHE_NAME] 9 | except (InvalidCacheBackendError, ValueError): 10 | cache = default_cache 11 | -------------------------------------------------------------------------------- /tests/testcases.py: -------------------------------------------------------------------------------- 1 | from django.test import testcases 2 | 3 | from graphql_persist.query import QueryKey 4 | from graphql_persist.settings import persist_settings 5 | 6 | 7 | class QueryKeyTestsCase(testcases.TestCase): 8 | 9 | def setUp(self): 10 | self.query_key = QueryKey(['v1', 'full', 'schema']) 11 | self.path = '/'.join(self.query_key) + persist_settings.DOCUMENTS_EXT 12 | -------------------------------------------------------------------------------- /tests/versioning/test_base.py: -------------------------------------------------------------------------------- 1 | from django.test import testcases 2 | 3 | from graphql_persist.versioning import BaseVersioning 4 | 5 | 6 | class VersioningNotImplementedError(BaseVersioning): 7 | """Versioning Not Implemented Error""" 8 | 9 | 10 | class BaseTests(testcases.TestCase): 11 | 12 | def test_versioning_not_implemented_error(self): 13 | scheme = VersioningNotImplementedError() 14 | 15 | with self.assertRaises(NotImplementedError): 16 | scheme.get_version(None) 17 | -------------------------------------------------------------------------------- /setup.cfg: -------------------------------------------------------------------------------- 1 | [wheel] 2 | universal = 1 3 | 4 | [flake8] 5 | exclude = .tox,*.egg*,src/,**/migrations/*,build 6 | max-line-length = 79 7 | 8 | [tool:pytest] 9 | django_find_project = false 10 | DJANGO_SETTINGS_MODULE = tests.settings 11 | 12 | [isort] 13 | line_length = 79 14 | multi_line_output = 5 15 | skip = migrations 16 | default_section = THIRDPARTY 17 | known_django = django 18 | known_first_party = graphql_persist 19 | sections = FUTURE,STDLIB,DJANGO,THIRDPARTY,FIRSTPARTY,LOCALFOLDER 20 | include_trailing_comma = yes 21 | -------------------------------------------------------------------------------- /graphql_persist/loaders/__init__.py: -------------------------------------------------------------------------------- 1 | from .app_directories import AppDirectoriesLoader 2 | from .base import BaseLoader 3 | from .cached import CachedEngine 4 | from .engine import Engine 5 | from .exceptions import DocumentDoesNotExist, DocumentImportError 6 | from .filesystem import FilesystemLoader 7 | from .url import URLLoader 8 | 9 | __all__ = [ 10 | 'AppDirectoriesLoader', 11 | 'BaseLoader', 12 | 'CachedEngine', 13 | 'Engine', 14 | 'DocumentDoesNotExist', 15 | 'DocumentImportError', 16 | 'FilesystemLoader', 17 | 'URLLoader', 18 | ] 19 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | 2 | help: 3 | @echo "Please use 'make ' where is one of" 4 | @echo " test Runs tests" 5 | @echo " test-all Runs tests using tox" 6 | @echo " release Makes a release" 7 | 8 | test: 9 | @pytest tests 10 | 11 | coverage: 12 | @pytest\ 13 | --verbose\ 14 | --cov graphql_persist\ 15 | --cov-config .coveragerc\ 16 | --cov-report term\ 17 | --cov-report xml 18 | 19 | test-all: 20 | @tox 21 | 22 | release: 23 | @python setup.py sdist upload 24 | @python setup.py bdist_wheel upload 25 | 26 | .PHONY: help test coverage test-all release 27 | -------------------------------------------------------------------------------- /graphql_persist/loaders/cached.py: -------------------------------------------------------------------------------- 1 | from .engine import Engine 2 | 3 | 4 | class CachedEngine(Engine): 5 | 6 | def __init__(self): 7 | super().__init__() 8 | self.get_document_cache = {} 9 | 10 | def get_document(self, query_key): 11 | document = self.get_document_cache.get(str(query_key)) 12 | 13 | if document is None: 14 | document = super().get_document(query_key) 15 | self.get_document_cache[str(query_key)] = document 16 | return document 17 | 18 | def reset(self): 19 | self.get_document_cache.clear() 20 | -------------------------------------------------------------------------------- /tests/test_cache.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | 3 | from django.core.cache import cache 4 | from django.test import testcases 5 | 6 | from .decorators import override_persist_settings 7 | 8 | 9 | class CacheTests(testcases.TestCase): 10 | 11 | def test_import_default_cache(self): 12 | imported = importlib.import_module('graphql_persist.cache') 13 | self.assertEqual(imported.cache, cache) 14 | 15 | @override_persist_settings(CACHE_NAME='not-found') 16 | def test_import_cache_error(self): 17 | imported = importlib.import_module('graphql_persist.cache') 18 | self.assertEqual(imported.cache, cache) 19 | -------------------------------------------------------------------------------- /graphql_persist/parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | from collections import OrderedDict 3 | 4 | from django.utils.encoding import force_text 5 | 6 | 7 | def parse_json(content, **kwargs): 8 | content = force_text(content, **kwargs) 9 | return json.loads(content, object_pairs_hook=OrderedDict) 10 | 11 | 12 | def parse_body(request): 13 | if request.content_type == 'application/json': 14 | try: 15 | return parse_json(request.body) 16 | except ValueError: 17 | return None 18 | 19 | elif request.content_type in ( 20 | 'application/x-www-form-urlencoded', 21 | 'multipart/form-data'): 22 | 23 | return request.POST.dict() 24 | return None 25 | -------------------------------------------------------------------------------- /graphql_persist/renderers/relay.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | from .base import BaseStripTagsRenderer 4 | 5 | __all__ = ['StripRelayTagsRenderer'] 6 | 7 | 8 | def strip_edges(data): 9 | if isinstance(data, dict): 10 | edges = data.get('edges') 11 | if edges is not None: 12 | if len(data) == 1: 13 | return edges 14 | return OrderedDict( 15 | ('results', edges) if k == 'edges' 16 | else (k, v) for k, v in data.items()) 17 | elif 'node' in data: 18 | return data['node'] 19 | return data 20 | 21 | 22 | class StripRelayTagsRenderer(BaseStripTagsRenderer): 23 | strip_func = staticmethod(strip_edges) 24 | -------------------------------------------------------------------------------- /tests/loaders/testcases.py: -------------------------------------------------------------------------------- 1 | from graphql_persist.loaders.base import Document, Origin 2 | from graphql_persist.settings import persist_settings 3 | 4 | from ..testcases import QueryKeyTestsCase 5 | 6 | 7 | class LoadersTestsCase(QueryKeyTestsCase): 8 | 9 | def setUp(self): 10 | super().setUp() 11 | self.engine_class = persist_settings.DEFAULT_LOADER_ENGINE_CLASS 12 | 13 | 14 | class DocumentTestsCase(LoadersTestsCase): 15 | 16 | def setUp(self): 17 | super().setUp() 18 | self.loader = self.engine_class().get_loaders()[0] 19 | self.origin = Origin(self.path, self.query_key, self.loader) 20 | 21 | def get_document(self, source): 22 | return Document(source, origin=self.origin) 23 | -------------------------------------------------------------------------------- /tests/loaders/test_app_directories.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from graphql_persist.loaders import AppDirectoriesLoader, DocumentDoesNotExist 4 | 5 | from .testcases import LoadersTestsCase 6 | 7 | 8 | class AppDirectoriesTests(LoadersTestsCase): 9 | 10 | @override_settings(INSTALLED_APPS=['tests']) 11 | def test_get_document(self): 12 | document = self.engine_class().get_document(self.query_key) 13 | origin = document.origin 14 | 15 | self.assertIsInstance(origin.loader, AppDirectoriesLoader) 16 | self.assertEqual(origin.query_key, self.query_key) 17 | self.assertIn(self.path, origin.name) 18 | 19 | def test_document_does_not_exist(self): 20 | with self.assertRaises(DocumentDoesNotExist): 21 | self.engine_class().get_document(self.query_key) 22 | -------------------------------------------------------------------------------- /tox.ini: -------------------------------------------------------------------------------- 1 | [tox] 2 | envlist = 3 | flake8 4 | py{34,35,36}-django111, 5 | py{34,35,36,37,38}-django20, 6 | py{35,36,37,38}-django21, 7 | py{35,36,37,38}-django22, 8 | py{36,37,38}-djangomaster, 9 | 10 | [testenv] 11 | whitelist_externals = make 12 | basepython = 13 | py34: python3.4 14 | py35: python3.5 15 | py36: python3.6 16 | py37: python3.7 17 | py38: python3.8 18 | 19 | setenv = 20 | PYTHONPATH={toxinidir} 21 | PYTHONDONTWRITEBYTECODE=1 22 | PYTHONWARNINGS=once 23 | 24 | deps = 25 | -rrequirements/test.txt 26 | django111: Django~=1.11 27 | django20: Django~=2.0 28 | django21: Django~=2.1 29 | django22: Django~=2.2a1 30 | djangomaster: https://github.com/django/django/archive/master.tar.gz 31 | 32 | commands = make coverage 33 | 34 | [testenv:flake8] 35 | basepython = python3 36 | skip_install = true 37 | deps = -rrequirements/flake8.txt 38 | commands = flake8 39 | -------------------------------------------------------------------------------- /graphql_persist/exceptions.py: -------------------------------------------------------------------------------- 1 | from django.http import JsonResponse 2 | from django.utils.translation import ugettext_lazy as _ 3 | 4 | 5 | class GraphQLPersistError(Exception): 6 | """GraphQL Persist Error""" 7 | 8 | 9 | class PersistResponseError(JsonResponse): 10 | default_message = _('A server error occurred') 11 | status_code = 400 12 | 13 | def __init__(self, message=None): 14 | if message is None: 15 | message = self.default_message 16 | 17 | return super().__init__({ 18 | 'errors': [{'message': message}], 19 | }) 20 | 21 | 22 | class DocumentNotFound(PersistResponseError): 23 | status_code = 404 24 | 25 | def __init__(self, query_key): 26 | message = _('Document `{}` not found').format(query_key) 27 | return super().__init__(message) 28 | 29 | 30 | class DocumentSyntaxError(PersistResponseError): 31 | """Document Syntax Error""" 32 | -------------------------------------------------------------------------------- /tests/loaders/test_base.py: -------------------------------------------------------------------------------- 1 | from graphql_persist.loaders import BaseLoader 2 | 3 | from .testcases import DocumentTestsCase 4 | 5 | 6 | class LoaderNotImplementedError(BaseLoader): 7 | """Loader Not Implemented Error""" 8 | 9 | 10 | class BaseTests(DocumentTestsCase): 11 | 12 | def test_origin_str(self): 13 | self.assertEqual(str(self.origin), self.origin.name) 14 | 15 | def test_document_render(self): 16 | query = '{q}' 17 | rendered = self.get_document(query).render() 18 | 19 | self.assertEqual(rendered, query) 20 | 21 | def test_loader_not_implemented_error(self): 22 | engine = self.engine_class() 23 | loader = LoaderNotImplementedError(engine) 24 | 25 | with self.assertRaises(NotImplementedError): 26 | loader.get_sources(self.query_key) 27 | 28 | with self.assertRaises(NotImplementedError): 29 | loader.get_contents(self.origin) 30 | -------------------------------------------------------------------------------- /graphql_persist/renderers/base.py: -------------------------------------------------------------------------------- 1 | from collections import OrderedDict 2 | 3 | __all__ = [ 4 | 'BaseRenderer', 5 | 'BaseStripTagsRenderer', 6 | ] 7 | 8 | 9 | def strip_tags(data, f): 10 | data = f(data) 11 | if isinstance(data, dict): 12 | return OrderedDict( 13 | (k, strip_tags(v, f)) 14 | for k, v in data.items()) 15 | elif isinstance(data, list): 16 | return [strip_tags(v, f) for v in data] 17 | return data 18 | 19 | 20 | class BaseRenderer: 21 | 22 | def render(self, data, context=None): 23 | raise NotImplementedError('.render() must be implemented') 24 | 25 | 26 | class BaseStripTagsRenderer: 27 | 28 | def render(self, data, context=None): 29 | assert hasattr(self, 'strip_func'), ( 30 | '`{cls}.strip_func` argument is required' 31 | .format(cls=self.__class__.__name__) 32 | ) 33 | return strip_tags(data, self.strip_func) 34 | -------------------------------------------------------------------------------- /graphql_persist/loaders/filesystem.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.utils._os import safe_join 4 | 5 | from .base import BaseLoader, Origin 6 | from .exceptions import DocumentDoesNotExist 7 | 8 | 9 | class FilesystemLoader(BaseLoader): 10 | 11 | def get_dirs(self): 12 | return self.engine.dirs 13 | 14 | def get_sources(self, query_key): 15 | for document_dir in self.get_dirs(): 16 | for s in range(len(query_key)): 17 | path = safe_join(document_dir, *query_key.qslice(-s)) 18 | path += self.engine.documents_ext 19 | 20 | if os.path.isfile(path): 21 | yield Origin(name=path, query_key=query_key, loader=self) 22 | 23 | def get_contents(self, origin): 24 | try: 25 | with open(origin.name) as document: 26 | return document.read() 27 | except FileNotFoundError: 28 | raise DocumentDoesNotExist(origin.query_key) 29 | -------------------------------------------------------------------------------- /tests/renderers/test_relay.py: -------------------------------------------------------------------------------- 1 | from django.test import testcases 2 | 3 | from graphql_persist.renderers import StripRelayTagsRenderer 4 | 5 | 6 | class RelayTests(testcases.TestCase): 7 | 8 | def setUp(self): 9 | self.renderer = StripRelayTagsRenderer() 10 | 11 | def test_strip_tags(self): 12 | data = { 13 | 'edges': [{ 14 | 'node': True, 15 | }], 16 | } 17 | 18 | rendered = self.renderer.render(data) 19 | self.assertEqual(rendered, [True]) 20 | 21 | def test_pagination_strip_tags(self): 22 | data = { 23 | 'edges': [{ 24 | 'node': True, 25 | }], 26 | 'pageInfo': { 27 | 'startCursor': 'test', 28 | }, 29 | } 30 | 31 | rendered = self.renderer.render(data) 32 | 33 | self.assertEqual(rendered['results'], [True]) 34 | self.assertEqual(rendered['pageInfo']['startCursor'], 'test') 35 | -------------------------------------------------------------------------------- /graphql_persist/loaders/engine.py: -------------------------------------------------------------------------------- 1 | from ..settings import persist_settings 2 | from .base import Document 3 | from .exceptions import DocumentDoesNotExist 4 | 5 | 6 | class Engine: 7 | 8 | def __init__(self): 9 | self.dirs = persist_settings.DOCUMENTS_DIRS 10 | self.documents_ext = persist_settings.DOCUMENTS_EXT 11 | self.loaders = self.get_loaders() 12 | 13 | def get_document(self, query_key): 14 | return self.find_document(query_key) 15 | 16 | def from_string(self, document_code): 17 | return Document(document_code) 18 | 19 | def find_document(self, query_key): 20 | for loader in self.loaders: 21 | try: 22 | return loader.get_document(query_key) 23 | except DocumentDoesNotExist: 24 | continue 25 | raise DocumentDoesNotExist(query_key) 26 | 27 | def get_loaders(self): 28 | loader_classes = persist_settings.DEFAULT_LOADER_CLASSES 29 | return [loader_class(self) for loader_class in loader_classes] 30 | -------------------------------------------------------------------------------- /tests/loaders/test_parser.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from graphql_persist.loaders import DocumentImportError 4 | from graphql_persist.loaders.parser import parse_document 5 | 6 | from .testcases import DocumentTestsCase 7 | 8 | 9 | class ParserTests(DocumentTestsCase): 10 | 11 | def test_parse_no_imports(self): 12 | query = '{q}' 13 | document = self.get_document(query) 14 | parsed = parse_document(document) 15 | 16 | self.assertEqual(query, parsed) 17 | 18 | def test_import_document_not_found(self): 19 | query = '# from not-found import identifier\n{q}' 20 | document = self.get_document(query) 21 | 22 | with self.assertRaises(DocumentImportError): 23 | parse_document(document) 24 | 25 | @override_settings(INSTALLED_APPS=['tests']) 26 | def test_import_identifier_not_found(self): 27 | query = '# from .schema import not-found\n{q}' 28 | document = self.get_document(query) 29 | 30 | with self.assertRaises(DocumentImportError): 31 | parse_document(document) 32 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2019 mongkok 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /tests/loaders/test_cached.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from graphql_persist.loaders import CachedEngine 4 | 5 | from ..testcases import QueryKeyTestsCase 6 | 7 | 8 | class CachedTests(QueryKeyTestsCase): 9 | 10 | def setUp(self): 11 | super().setUp() 12 | self.engine_class = CachedEngine 13 | 14 | @override_settings(INSTALLED_APPS=['tests']) 15 | def test_get_document(self): 16 | engine = self.engine_class() 17 | document = engine.get_document(self.query_key) 18 | cached = engine.get_document_cache[str(self.query_key)] 19 | 20 | self.assertEqual(cached, document) 21 | 22 | def test_get_cached_document(self): 23 | engine = self.engine_class() 24 | engine.get_document_cache[str(self.query_key)] = True 25 | document = engine.get_document(self.query_key) 26 | 27 | self.assertTrue(document) 28 | 29 | @override_settings(INSTALLED_APPS=['tests']) 30 | def test_reset_cache(self): 31 | engine = self.engine_class() 32 | engine.get_document(self.query_key) 33 | engine.reset() 34 | 35 | self.assertFalse(engine.get_document_cache) 36 | -------------------------------------------------------------------------------- /graphql_persist/query.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | __all__ = ['query_key_handler'] 4 | 5 | 6 | versioning_regex = re.compile(r'\.(?!\d)') 7 | 8 | 9 | def query_key_handler(query_id, request): 10 | query_key = query_id.split(':') 11 | 12 | if request.version is None: 13 | return query_key 14 | 15 | versioning_prefix = versioning_regex.split(request.version) 16 | return versioning_prefix + query_key 17 | 18 | 19 | class QueryKey: 20 | 21 | def __init__(self, keys): 22 | self._keys = keys 23 | 24 | def __iter__(self): 25 | return iter(self._keys) 26 | 27 | def __len__(self): 28 | return len(self._keys) 29 | 30 | def __getitem__(self, index): 31 | value = self._keys[index] 32 | if isinstance(index, slice): 33 | return type(self)(value) 34 | return value 35 | 36 | def __eq__(self, other): 37 | return self._keys == other._keys 38 | 39 | def __add__(self, keys): 40 | return type(self)(self._keys + keys) 41 | 42 | def __repr__(self): 43 | return list.__repr__(self._keys) 44 | 45 | def __str__(self): 46 | return '.'.join(self._keys) 47 | 48 | def qslice(self, end): 49 | return self[:end - 1] + self[-1:]._keys 50 | -------------------------------------------------------------------------------- /tests/loaders/test_filesystem.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | from unittest.mock import patch 3 | 4 | from graphql_persist.loaders import DocumentDoesNotExist, FilesystemLoader 5 | from graphql_persist.settings import persist_settings 6 | 7 | from ..decorators import override_persist_settings 8 | from .testcases import LoadersTestsCase 9 | 10 | documents_dir = ( 11 | Path(__file__).parents[1] / 12 | persist_settings.APP_DOCUMENT_DIR 13 | ).as_posix() 14 | 15 | 16 | class FilesystemTests(LoadersTestsCase): 17 | 18 | @override_persist_settings(DOCUMENTS_DIRS=(documents_dir,)) 19 | def test_get_document(self): 20 | document = self.engine_class().get_document(self.query_key) 21 | origin = document.origin 22 | 23 | self.assertIsInstance(origin.loader, FilesystemLoader) 24 | self.assertEqual(origin.query_key, self.query_key) 25 | self.assertIn(self.path, origin.name) 26 | 27 | @patch('builtins.open') 28 | @override_persist_settings(DOCUMENTS_DIRS=(documents_dir,)) 29 | def test_document_does_not_exist(self, open_mock): 30 | open_mock.side_effect = FileNotFoundError() 31 | 32 | with self.assertRaises(DocumentDoesNotExist): 33 | self.engine_class().get_document(self.query_key) 34 | -------------------------------------------------------------------------------- /tests/renderers/test_base.py: -------------------------------------------------------------------------------- 1 | from django.test import testcases 2 | 3 | from graphql_persist.renderers import BaseRenderer, BaseStripTagsRenderer 4 | from graphql_persist.renderers.base import strip_tags 5 | 6 | 7 | class RendererNotImplementedError(BaseRenderer): 8 | """Renderer Not Implemented Error""" 9 | 10 | 11 | class StripTagsRendererError(BaseStripTagsRenderer): 12 | """Strip Tags Renderer Error""" 13 | 14 | 15 | class BaseTests(testcases.TestCase): 16 | 17 | def test_renderer_not_implemented_error(self): 18 | renderer = RendererNotImplementedError() 19 | 20 | with self.assertRaises(NotImplementedError): 21 | renderer.render({}) 22 | 23 | def test_strip_tags_renderer_error(self): 24 | renderer = StripTagsRendererError() 25 | 26 | with self.assertRaises(Exception): 27 | renderer.render({}) 28 | 29 | def test_strip_tags(self): 30 | data = { 31 | 'results': [{ 32 | 'test': True, 33 | }], 34 | } 35 | 36 | def strip_func(data): 37 | if isinstance(data, dict): 38 | if 'test' in data: 39 | return data['test'] 40 | return data 41 | 42 | rendered = strip_tags(data, strip_func) 43 | self.assertEqual(rendered['results'], [True]) 44 | -------------------------------------------------------------------------------- /graphql_persist/loaders/url.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from django.core.exceptions import ValidationError 4 | from django.core.validators import URLValidator 5 | 6 | import requests 7 | 8 | from .base import BaseLoader, Origin 9 | 10 | __all__ = ['URLLoader'] 11 | 12 | 13 | class URLOrigin(Origin): 14 | 15 | def __init__(self, content, *args, **kwargs): 16 | super().__init__(*args, **kwargs) 17 | self.content = content 18 | 19 | 20 | class URLLoader(BaseLoader): 21 | 22 | def get_dirs(self): 23 | return self.engine.dirs 24 | 25 | def get_sources(self, query_key): 26 | for document_dir in self.get_dirs(): 27 | url = os.path.join(document_dir, *query_key) 28 | url += self.engine.documents_ext 29 | 30 | try: 31 | URLValidator()(url) 32 | except ValidationError: 33 | continue 34 | 35 | try: 36 | response = requests.get(url) 37 | except requests.HTTPError: 38 | continue 39 | 40 | if response.status_code == 200: 41 | yield URLOrigin( 42 | content=response.text, 43 | name=url, 44 | query_key=query_key, 45 | loader=self) 46 | 47 | def get_contents(self, origin): 48 | return origin.content 49 | -------------------------------------------------------------------------------- /tests/versioning/test_query_parameter.py: -------------------------------------------------------------------------------- 1 | from graphql_persist import versioning 2 | from graphql_persist.exceptions import GraphQLPersistError 3 | from graphql_persist.settings import persist_settings 4 | 5 | from .testcases import VersioningTestsCase 6 | 7 | 8 | class QueryParameterVersioningTests(VersioningTestsCase): 9 | 10 | def test_get_version(self): 11 | request = self.request_factory.get('?{0}={1}'.format( 12 | persist_settings.VERSION_PARAM, 13 | self.version), 14 | ) 15 | 16 | scheme = versioning.QueryParameterVersioning() 17 | scheme_version = scheme.get_version(request) 18 | 19 | self.assertEqual(scheme_version, self.version) 20 | 21 | def test_default_version(self): 22 | request = self.request_factory.get('/') 23 | 24 | scheme = versioning.QueryParameterVersioning() 25 | scheme.default_version = 'v2' 26 | scheme_version = scheme.get_version(request) 27 | 28 | self.assertEqual(scheme_version, scheme.default_version) 29 | 30 | def test_version_not_allowed(self): 31 | request = self.request_factory.get('?{0}={1}'.format( 32 | persist_settings.VERSION_PARAM, 33 | self.version), 34 | ) 35 | 36 | scheme = versioning.QueryParameterVersioning() 37 | scheme.allowed_versions = ('v2',) 38 | 39 | with self.assertRaises(GraphQLPersistError): 40 | scheme.get_version(request) 41 | -------------------------------------------------------------------------------- /tests/test_query.py: -------------------------------------------------------------------------------- 1 | from django.test import RequestFactory, testcases 2 | 3 | from graphql_persist.query import QueryKey, query_key_handler 4 | 5 | 6 | class QueryKeyTestCase(testcases.TestCase): 7 | 8 | def setUp(self): 9 | self.keys = ['v1', 'schema'] 10 | self.query_key = QueryKey(self.keys) 11 | 12 | 13 | class QueryKeyHandlerTests(QueryKeyTestCase): 14 | 15 | def setUp(self): 16 | super().setUp() 17 | self.request_factory = RequestFactory() 18 | 19 | def test_versioning(self): 20 | request = self.request_factory.get('/') 21 | request.version = str(self.query_key[:-1]) 22 | keys = query_key_handler(self.keys[-1], request) 23 | 24 | self.assertEqual(keys, self.keys) 25 | 26 | 27 | class QueryKeyTests(QueryKeyTestCase): 28 | 29 | def test_iter(self): 30 | self.assertEqual(next(iter(self.query_key)), self.keys[0]) 31 | 32 | def test_len(self): 33 | self.assertEqual(len(self.query_key), len(self.keys)) 34 | 35 | def test_getitem(self): 36 | self.assertEqual(self.query_key[0], self.keys[0]) 37 | self.assertEqual(self.query_key[0:], self.query_key) 38 | 39 | def test_add(self): 40 | self.assertEqual(self.query_key + [], self.query_key) 41 | 42 | def test_repr(self): 43 | self.assertEqual(repr(self.query_key), repr(self.keys)) 44 | 45 | def test_qslice(self): 46 | self.assertEqual(self.query_key.qslice(-1), self.query_key[1:]) 47 | -------------------------------------------------------------------------------- /tests/versioning/test_vendor_tree.py: -------------------------------------------------------------------------------- 1 | from graphql_persist import versioning 2 | from graphql_persist.exceptions import GraphQLPersistError 3 | 4 | from .testcases import VersioningTestsCase 5 | 6 | 7 | class VendorTreeVersioningTests(VersioningTestsCase): 8 | 9 | def test_get_version(self): 10 | headers = { 11 | 'HTTP_ACCEPT': 12 | 'application/vnd.test.{}+json'.format(self.version), 13 | } 14 | 15 | request = self.request_factory.get('/', **headers) 16 | 17 | scheme = versioning.VendorTreeVersioning() 18 | scheme_version = scheme.get_version(request) 19 | 20 | self.assertEqual(scheme_version, self.version) 21 | 22 | def test_default_version(self): 23 | request = self.request_factory.get('/') 24 | 25 | scheme = versioning.VendorTreeVersioning() 26 | scheme.default_version = 'v2' 27 | scheme_version = scheme.get_version(request) 28 | 29 | self.assertEqual(scheme_version, scheme.default_version) 30 | 31 | def test_version_not_allowed(self): 32 | headers = { 33 | 'HTTP_ACCEPT': 34 | 'application/vnd.test.{}+json'.format(self.version), 35 | } 36 | 37 | request = self.request_factory.get('/', **headers) 38 | 39 | scheme = versioning.VendorTreeVersioning() 40 | scheme.allowed_versions = ('v2',) 41 | 42 | with self.assertRaises(GraphQLPersistError): 43 | scheme.get_version(request) 44 | -------------------------------------------------------------------------------- /tests/test_parser.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from django.test import RequestFactory, testcases 4 | 5 | from graphql_persist import parser 6 | 7 | 8 | class ParserTestCase(testcases.TestCase): 9 | 10 | def setUp(self): 11 | self.request_factory = RequestFactory() 12 | 13 | 14 | class ParseJSONTests(ParserTestCase): 15 | 16 | def test_application_json(self): 17 | request = self.request_factory.post( 18 | '/', 19 | data=json.dumps({'test': True}), 20 | content_type='application/json') 21 | 22 | result = parser.parse_body(request) 23 | self.assertTrue(result['test']) 24 | 25 | def test_decode_error(self): 26 | request = self.request_factory.post( 27 | '/', 28 | data='error', 29 | content_type='application/json') 30 | 31 | result = parser.parse_body(request) 32 | self.assertIsNone(result) 33 | 34 | 35 | class ParseBodyTests(ParserTestCase): 36 | 37 | def test_x_www_form_urlencoded(self): 38 | request = self.request_factory.post('/', data={'test': True}) 39 | result = parser.parse_body(request) 40 | 41 | self.assertTrue(eval(result['test'])) 42 | 43 | def test_unknown_content_type(self): 44 | request = self.request_factory.post( 45 | '/', 46 | data={'test': True}, 47 | content_type='unknown') 48 | 49 | result = parser.parse_body(request) 50 | self.assertIsNone(result) 51 | -------------------------------------------------------------------------------- /tests/versioning/test_host_name.py: -------------------------------------------------------------------------------- 1 | from django.test import override_settings 2 | 3 | from graphql_persist import versioning 4 | from graphql_persist.exceptions import GraphQLPersistError 5 | 6 | from .testcases import VersioningTestsCase 7 | 8 | 9 | class HostNameVersioningTests(VersioningTestsCase): 10 | 11 | @override_settings(ALLOWED_HOSTS=['*']) 12 | def test_get_version(self): 13 | headers = { 14 | 'HTTP_HOST': '{}.example.com'.format(self.version), 15 | } 16 | 17 | request = self.request_factory.get('/', **headers) 18 | 19 | scheme = versioning.HostNameVersioning() 20 | scheme_version = scheme.get_version(request) 21 | 22 | self.assertEqual(scheme_version, self.version) 23 | 24 | def test_default_version(self): 25 | request = self.request_factory.get('/') 26 | 27 | scheme = versioning.HostNameVersioning() 28 | scheme.default_version = 'v2' 29 | scheme_version = scheme.get_version(request) 30 | 31 | self.assertEqual(scheme_version, scheme.default_version) 32 | 33 | @override_settings(ALLOWED_HOSTS=['*']) 34 | def test_version_not_allowed(self): 35 | headers = { 36 | 'HTTP_HOST': '{}.example.com'.format(self.version), 37 | } 38 | 39 | request = self.request_factory.get('/', **headers) 40 | 41 | scheme = versioning.HostNameVersioning() 42 | scheme.allowed_versions = ('v2',) 43 | 44 | with self.assertRaises(GraphQLPersistError): 45 | scheme.get_version(request) 46 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | dist: xenial 2 | language: python 3 | cache: pip 4 | 5 | matrix: 6 | fast_finish: true 7 | include: 8 | - python: 3.7 9 | env: TOXENV=flake8 10 | - python: 3.4 11 | env: TOXENV=py34-django111 12 | - python: 3.5 13 | env: TOXENV=py35-django111 14 | - python: 3.6 15 | env: TOXENV=py36-django111 16 | - python: 3.4 17 | env: TOXENV=py34-django20 18 | - python: 3.5 19 | env: TOXENV=py35-django20 20 | - python: 3.6 21 | env: TOXENV=py36-django20 22 | - python: 3.7 23 | env: TOXENV=py37-django20 24 | - python: 3.8-dev 25 | env: TOXENV=py38-django20 26 | - python: 3.5 27 | env: TOXENV=py35-django21 28 | - python: 3.6 29 | env: TOXENV=py36-django21 30 | - python: 3.7 31 | env: TOXENV=py37-django21 32 | - python: 3.8-dev 33 | env: TOXENV=py38-django21 34 | - python: 3.5 35 | env: TOXENV=py35-django22 36 | - python: 3.6 37 | env: TOXENV=py36-django22 38 | - python: 3.7 39 | env: TOXENV=py37-django22 40 | - python: 3.8-dev 41 | env: TOXENV=py38-django22 42 | - python: 3.6 43 | env: TOXENV=py36-djangomaster 44 | - python: 3.7 45 | env: TOXENV=py37-djangomaster 46 | - python: 3.8-dev 47 | env: TOXENV=py38-djangomaster 48 | allow_failures: 49 | - env: TOXENV=py36-djangomaster 50 | - env: TOXENV=py37-djangomaster 51 | - env: TOXENV=py38-djangomaster 52 | 53 | install: 54 | - pip install tox 55 | 56 | script: 57 | - tox 58 | 59 | after_success: 60 | - pip install codecov 61 | - codecov 62 | -------------------------------------------------------------------------------- /tests/loaders/test_url.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import requests 4 | 5 | from graphql_persist.loaders import DocumentDoesNotExist, URLLoader 6 | 7 | from ..decorators import override_persist_settings 8 | from .testcases import LoadersTestsCase 9 | 10 | 11 | class MockResponse: 12 | 13 | def __init__(self, text, status_code): 14 | self.text = text 15 | self.status_code = status_code 16 | 17 | 18 | class URLTests(LoadersTestsCase): 19 | 20 | @patch('requests.get', side_effect=lambda url: MockResponse('', 200)) 21 | @override_persist_settings(DOCUMENTS_DIRS=('https://example.com',)) 22 | def test_get_document(self, *args): 23 | document = self.engine_class().get_document(self.query_key) 24 | origin = document.origin 25 | 26 | self.assertIsInstance(origin.loader, URLLoader) 27 | self.assertEqual(origin.query_key, self.query_key) 28 | self.assertIn(self.path, origin.name) 29 | 30 | @patch('requests.get', side_effect=requests.HTTPError()) 31 | @override_persist_settings(DOCUMENTS_DIRS=('https://example.com',)) 32 | def test_http_error(self, *args): 33 | 34 | with self.assertRaises(DocumentDoesNotExist): 35 | self.engine_class().get_document(self.query_key) 36 | 37 | @patch('requests.get', side_effect=lambda url: MockResponse('', 404)) 38 | @override_persist_settings(DOCUMENTS_DIRS=('https://example.com',)) 39 | def test_document_does_not_exist(self, *args): 40 | 41 | with self.assertRaises(DocumentDoesNotExist): 42 | self.engine_class().get_document(self.query_key) 43 | -------------------------------------------------------------------------------- /tests/versioning/test_accept_header.py: -------------------------------------------------------------------------------- 1 | from graphql_persist import versioning 2 | from graphql_persist.exceptions import GraphQLPersistError 3 | from graphql_persist.settings import persist_settings 4 | 5 | from .testcases import VersioningTestsCase 6 | 7 | 8 | class AcceptHeaderVersioningTests(VersioningTestsCase): 9 | 10 | def test_get_version(self): 11 | headers = { 12 | 'HTTP_ACCEPT': 'application/json; {0}={1}'.format( 13 | persist_settings.VERSION_PARAM, 14 | self.version), 15 | } 16 | 17 | request = self.request_factory.get('/', **headers) 18 | 19 | scheme = versioning.AcceptHeaderVersioning() 20 | scheme_version = scheme.get_version(request) 21 | 22 | self.assertEqual(scheme_version, self.version) 23 | 24 | def test_default_version(self): 25 | request = self.request_factory.get('/') 26 | 27 | scheme = versioning.AcceptHeaderVersioning() 28 | scheme.default_version = 'v2' 29 | scheme_version = scheme.get_version(request) 30 | 31 | self.assertEqual(scheme_version, scheme.default_version) 32 | 33 | def test_version_not_allowed(self): 34 | headers = { 35 | 'HTTP_ACCEPT': 'application/json; {0}={1}'.format( 36 | persist_settings.VERSION_PARAM, 37 | self.version), 38 | } 39 | 40 | request = self.request_factory.get('/', **headers) 41 | 42 | scheme = versioning.AcceptHeaderVersioning() 43 | scheme.allowed_versions = ('v2',) 44 | 45 | with self.assertRaises(GraphQLPersistError): 46 | scheme.get_version(request) 47 | -------------------------------------------------------------------------------- /graphql_persist/loaders/base.py: -------------------------------------------------------------------------------- 1 | from django.utils.functional import cached_property 2 | 3 | from graphql.language.parser import parse 4 | from graphql.language.source import Source 5 | 6 | from .exceptions import DocumentDoesNotExist 7 | from .parser import parse_document 8 | 9 | 10 | class Origin: 11 | 12 | def __init__(self, name, query_key, loader): 13 | self.name = name 14 | self.query_key = query_key 15 | self.loader = loader 16 | 17 | def __str__(self): 18 | return self.name 19 | 20 | 21 | class Document: 22 | 23 | def __init__(self, source, origin=None): 24 | self.source = Source(source) 25 | self.origin = origin 26 | 27 | @property 28 | def ast(self): 29 | if not hasattr(self, '_ast'): 30 | self._ast = parse(self.source) 31 | return self._ast 32 | 33 | def render(self): 34 | return parse_document(self) 35 | 36 | @cached_property 37 | def definitions(self): 38 | self._ast = parse(self.render()) 39 | 40 | return { 41 | definition.name.value: definition 42 | for definition in self.ast.definitions 43 | if definition.name is not None 44 | } 45 | 46 | 47 | class BaseLoader: 48 | 49 | def __init__(self, engine): 50 | self.engine = engine 51 | 52 | def get_document(self, query_key): 53 | for origin in self.get_sources(query_key): 54 | try: 55 | contents = self.get_contents(origin) 56 | except DocumentDoesNotExist: 57 | continue 58 | return Document(source=contents, origin=origin) 59 | raise DocumentDoesNotExist(query_key) 60 | 61 | def get_sources(self, query_key): 62 | raise NotImplementedError('.get_sources() must be implemented') 63 | 64 | def get_contents(self, origin): 65 | raise NotImplementedError('.get_contents() must be implemented') 66 | -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | 3 | import os 4 | import re 5 | 6 | from setuptools import find_packages, setup 7 | 8 | 9 | def get_long_description(): 10 | for filename in ('README.rst',): 11 | with open(filename, 'r') as f: 12 | yield f.read() 13 | 14 | 15 | def get_version(package): 16 | with open(os.path.join(package, '__init__.py')) as f: 17 | pattern = r'^__version__ = [\'"]([^\'"]*)[\'"]' 18 | return re.search(pattern, f.read(), re.MULTILINE).group(1) 19 | 20 | 21 | setup( 22 | name='django-graphql-persist', 23 | version=get_version('graphql_persist'), 24 | license='MIT', 25 | description='Persisted queries for Django GraphQL', 26 | long_description='\n\n'.join(get_long_description()), 27 | author='mongkok', 28 | author_email='domake.io@gmail.com', 29 | maintainer='mongkok', 30 | url='https://github.com/flavors/django-graphql-persist/', 31 | packages=find_packages(exclude=['tests*']), 32 | install_requires=[ 33 | 'Django>=1.11', 34 | 'graphene-django>=2.0.0', 35 | 'requests', 36 | ], 37 | classifiers=[ 38 | 'Development Status :: 1 - Planning', 39 | 'Environment :: Web Environment', 40 | 'Intended Audience :: Developers', 41 | 'License :: OSI Approved :: MIT License', 42 | 'Operating System :: OS Independent', 43 | 'Programming Language :: Python', 44 | 'Programming Language :: Python :: 3', 45 | 'Programming Language :: Python :: 3.4', 46 | 'Programming Language :: Python :: 3.5', 47 | 'Programming Language :: Python :: 3.6', 48 | 'Programming Language :: Python :: 3.7', 49 | 'Programming Language :: Python :: 3.8', 50 | 'Framework :: Django', 51 | 'Framework :: Django :: 1.11', 52 | 'Framework :: Django :: 2.0', 53 | 'Framework :: Django :: 2.1', 54 | 'Framework :: Django :: 2.2', 55 | ], 56 | zip_safe=False, 57 | tests_require=[ 58 | 'Django>=1.11', 59 | 'graphene-django>=2.0.0', 60 | 'requests', 61 | ], 62 | ) 63 | -------------------------------------------------------------------------------- /graphql_persist/loaders/parser.py: -------------------------------------------------------------------------------- 1 | import re 2 | from functools import partial 3 | 4 | from graphql.language import ast 5 | from graphql.language.printer import print_ast 6 | 7 | from .exceptions import DocumentDoesNotExist, DocumentImportError 8 | 9 | __all__ = ['parse_document'] 10 | 11 | 12 | import_regex = re.compile(r'#\s+from\s+(.*)\s+import\s+(.*)') 13 | 14 | 15 | def fragments_generator(value, definitions): 16 | if getattr(value, 'selection_set', None) is not None: 17 | for selection in value.selection_set.selections: 18 | yield from fragments_generator(selection, definitions) 19 | 20 | elif isinstance(value, ast.FragmentSpread): 21 | if value.name.value in definitions: 22 | fragment = definitions[value.name.value] 23 | yield fragment 24 | yield from fragments_generator(fragment, definitions) 25 | 26 | 27 | def parse_definitions(identifiers, definitions): 28 | ret = '' 29 | fragments = set() 30 | 31 | for identifier in identifiers.split(','): 32 | identifier = identifier.strip() 33 | 34 | if identifier not in definitions: 35 | msg = 'Cannot import definition `{}`'.format(identifier) 36 | raise DocumentImportError(msg) 37 | 38 | definition = definitions[identifier] 39 | ret += print_ast(definition) 40 | fragments.update(fragments_generator(definition, definitions)) 41 | 42 | return ''.join(print_ast(fragment) for fragment in fragments) + ret 43 | 44 | 45 | def import_definitions(origin, match): 46 | name, identifiers = match.groups() 47 | path = name.lstrip('.') 48 | dots = len(name) - len(path) 49 | query_key = origin.query_key[:-dots] + path.split('.') 50 | 51 | try: 52 | imported = origin.loader.engine.get_document(query_key) 53 | except DocumentDoesNotExist: 54 | msg = 'No document named `{}`'.format(query_key) 55 | raise DocumentImportError(msg) 56 | 57 | return parse_definitions(identifiers, imported.definitions) 58 | 59 | 60 | def parse_document(document): 61 | header = document.source.body[:document.ast.loc.start] 62 | origin = document.origin 63 | source = import_regex.sub(partial(import_definitions, origin), header) 64 | return source + document.source.body[document.ast.loc.start:] 65 | -------------------------------------------------------------------------------- /graphql_persist/versioning.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | from django.http.multipartparser import parse_header 4 | from django.utils.translation import ugettext as _ 5 | 6 | from . import exceptions 7 | from .settings import persist_settings 8 | 9 | 10 | class BaseVersioning: 11 | default_version = persist_settings.DEFAULT_VERSION 12 | allowed_versions = persist_settings.ALLOWED_VERSIONS 13 | 14 | def get_version(self, request): 15 | raise NotImplementedError('.get_version() must be implemented') 16 | 17 | def is_allowed_version(self, version): 18 | if not self.allowed_versions: 19 | return True 20 | return (version == self.default_version or 21 | version in self.allowed_versions) 22 | 23 | 24 | class AcceptHeaderVersioning(BaseVersioning): 25 | version_param = persist_settings.VERSION_PARAM 26 | 27 | def parse_header(self, media_type): 28 | base_media_type, params = parse_header(media_type) 29 | return params.get(self.version_param, self.default_version) 30 | 31 | def get_version(self, request): 32 | media_type = request.META.get('HTTP_ACCEPT', '').encode('utf-8') 33 | version = self.parse_header(media_type) 34 | 35 | if hasattr(version, 'decode'): 36 | version = version.decode('iso-8859-1') 37 | 38 | if not self.is_allowed_version(version): 39 | raise exceptions.GraphQLPersistError( 40 | _('Invalid version in "Accept" header')) 41 | return version 42 | 43 | 44 | class VendorTreeVersioning(AcceptHeaderVersioning): 45 | media_type_regex = re.compile( 46 | r'^application/vnd\.{}\.([a-z0-9\.]+[a-z0-9]+)\+json$' 47 | .format(persist_settings.MEDIA_TYPE_NAME).encode(), 48 | re.IGNORECASE) 49 | 50 | def parse_header(self, media_type): 51 | match = self.media_type_regex.match(media_type) 52 | 53 | if not match: 54 | return self.default_version 55 | return match.group(1) 56 | 57 | 58 | class QueryParameterVersioning(BaseVersioning): 59 | version_param = persist_settings.VERSION_PARAM 60 | 61 | def get_version(self, request): 62 | version = request.GET.get(self.version_param, self.default_version) 63 | 64 | if not self.is_allowed_version(version): 65 | raise exceptions.GraphQLPersistError( 66 | _('Invalid version in query parameter')) 67 | return version 68 | 69 | 70 | class HostNameVersioning(BaseVersioning): 71 | hostname_regex = re.compile( 72 | r'^([a-z0-9]+)\.[a-z0-9]+\.[a-z0-9]+$', 73 | re.IGNORECASE) 74 | 75 | def get_version(self, request): 76 | hostname, separator, port = request.get_host().partition(':') 77 | match = self.hostname_regex.match(hostname) 78 | 79 | if not match: 80 | return self.default_version 81 | 82 | version = match.group(1) 83 | 84 | if not self.is_allowed_version(version): 85 | raise exceptions.GraphQLPersistError( 86 | _('Invalid version in hostname')) 87 | return version 88 | -------------------------------------------------------------------------------- /graphql_persist/settings.py: -------------------------------------------------------------------------------- 1 | from importlib import import_module 2 | 3 | from django.conf import settings 4 | from django.test.signals import setting_changed 5 | 6 | DEFAULTS = { 7 | 'DOCUMENTS_DIRS': (), 8 | 'DOCUMENTS_EXT': '.graphql', 9 | 'CACHE_NAME': 'default', 10 | 'QUERY_KEY_HANDLER': 'graphql_persist.query.query_key_handler', 11 | 'DEFAULT_VERSIONING_CLASS': None, 12 | 'DEFAULT_LOADER_ENGINE_CLASS': 'graphql_persist.loaders.Engine', 13 | 'DEFAULT_LOADER_CLASSES': ( 14 | 'graphql_persist.loaders.AppDirectoriesLoader', 15 | 'graphql_persist.loaders.FilesystemLoader', 16 | 'graphql_persist.loaders.URLLoader', 17 | ), 18 | 'DEFAULT_RENDERER_CLASSES': (), 19 | 20 | # Versioning 21 | 'DEFAULT_VERSION': None, 22 | 'ALLOWED_VERSIONS': None, 23 | 'VERSION_PARAM': 'version', 24 | 'MEDIA_TYPE_NAME': r'[a-z0-9]+', 25 | 26 | # Loaders 27 | 'APP_DOCUMENT_DIR': 'documents', 28 | } 29 | 30 | IMPORT_STRINGS = ( 31 | 'QUERY_KEY_HANDLER', 32 | 'DEFAULT_VERSIONING_CLASS', 33 | 'DEFAULT_LOADER_ENGINE_CLASS', 34 | 'DEFAULT_LOADER_CLASSES', 35 | 'DEFAULT_RENDERER_CLASSES', 36 | ) 37 | 38 | 39 | def perform_import(value, setting_name): 40 | if value is None: 41 | return None 42 | if isinstance(value, str): 43 | return import_from_string(value, setting_name) 44 | elif isinstance(value, (list, tuple)): 45 | return [import_from_string(item, setting_name) for item in value] 46 | return value 47 | 48 | 49 | def import_from_string(value, setting_name): 50 | try: 51 | module_path, class_name = value.rsplit('.', 1) 52 | module = import_module(module_path) 53 | return getattr(module, class_name) 54 | except (ImportError, AttributeError) as e: 55 | msg = 'Could not import `{}` for Persist setting `{}`. {}: {}.'.format( 56 | value, setting_name, e.__class__.__name__, e) 57 | raise ImportError(msg) 58 | 59 | 60 | class PersistSettings: 61 | 62 | def __init__(self, defaults, import_strings): 63 | self.defaults = defaults 64 | self.import_strings = import_strings 65 | self._cached_attrs = set() 66 | 67 | def __getattr__(self, attr): 68 | if attr not in self.defaults: 69 | raise AttributeError('Invalid setting: `{}`'.format(attr)) 70 | 71 | value = self.user_settings.get(attr, self.defaults[attr]) 72 | 73 | if attr in self.import_strings: 74 | value = perform_import(value, attr) 75 | 76 | self._cached_attrs.add(attr) 77 | setattr(self, attr, value) 78 | return value 79 | 80 | @property 81 | def user_settings(self): 82 | if not hasattr(self, '_user_settings'): 83 | self._user_settings = getattr(settings, 'GRAPHQL_PERSIST', {}) 84 | return self._user_settings 85 | 86 | def reload(self): 87 | for attr in self._cached_attrs: 88 | delattr(self, attr) 89 | 90 | self._cached_attrs.clear() 91 | 92 | if hasattr(self, '_user_settings'): 93 | delattr(self, '_user_settings') 94 | 95 | 96 | def reload_settings(*args, **kwargs): 97 | setting = kwargs['setting'] 98 | 99 | if setting == 'GRAPHQL_PERSIST': 100 | persist_settings.reload() 101 | 102 | 103 | setting_changed.connect(reload_settings) 104 | 105 | persist_settings = PersistSettings(DEFAULTS, IMPORT_STRINGS) 106 | -------------------------------------------------------------------------------- /graphql_persist/middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from graphene_django.views import GraphQLView 4 | from graphql.error import GraphQLSyntaxError 5 | 6 | from . import exceptions 7 | from .loaders import DocumentDoesNotExist, DocumentImportError 8 | from .parser import parse_body, parse_json 9 | from .query import QueryKey 10 | from .settings import persist_settings 11 | 12 | __all__ = ['PersistMiddleware'] 13 | 14 | 15 | class PersistedQuery(dict): 16 | 17 | def __init__(self, document, data): 18 | super().__init__(data) 19 | self.__dict__ = self 20 | self.document = document 21 | 22 | 23 | class PersistMiddleware: 24 | 25 | def __init__(self, get_response): 26 | self.get_response = get_response 27 | self.loader = persist_settings.DEFAULT_LOADER_ENGINE_CLASS() 28 | self.renderers = self.get_renderers() 29 | self.versioning_class = persist_settings.DEFAULT_VERSIONING_CLASS 30 | 31 | def __call__(self, request): 32 | try: 33 | request.version = self.get_version(request) 34 | except exceptions.GraphQLPersistError as e: 35 | return exceptions.PersistResponseError(str(e)) 36 | 37 | response = self.get_response(request) 38 | 39 | if (response.status_code == 200 and 40 | hasattr(request, 'persisted_query') and 41 | self.renderers): 42 | 43 | self.finalize_response(request, response) 44 | return response 45 | 46 | def process_view(self, request, view_func, *args): 47 | if (hasattr(view_func, 'view_class') and 48 | issubclass(view_func.view_class, GraphQLView)): 49 | 50 | data = parse_body(request) 51 | 52 | if data is None: 53 | return None 54 | 55 | query_id = data.get('id', data.get('operationName')) 56 | 57 | if query_id and not data.get('query'): 58 | query_key = self.get_query_key(query_id, request) 59 | 60 | try: 61 | document = self.loader.get_document(query_key) 62 | except DocumentDoesNotExist: 63 | return exceptions.DocumentNotFound(query_key) 64 | try: 65 | data['query'] = document.render() 66 | except (DocumentImportError, GraphQLSyntaxError) as e: 67 | return exceptions.DocumentSyntaxError(str(e)) 68 | 69 | request.persisted_query = PersistedQuery(document, data) 70 | 71 | if request.content_type == 'application/json': 72 | request._body = json.dumps(data).encode() 73 | else: 74 | request.POST = data 75 | return None 76 | 77 | def get_query_key(self, query_id, request): 78 | return QueryKey(persist_settings.QUERY_KEY_HANDLER(query_id, request)) 79 | 80 | def get_version(self, request): 81 | if self.versioning_class is not None: 82 | return self.versioning_class().get_version(request) 83 | return None 84 | 85 | def get_renderers(self): 86 | renderer_classes = persist_settings.DEFAULT_RENDERER_CLASSES 87 | return [renderer() for renderer in renderer_classes] 88 | 89 | def finalize_response(self, request, response): 90 | data = parse_json(response.content, encoding=response.charset) 91 | 92 | context = { 93 | 'request': request, 94 | } 95 | 96 | for renderer in self.renderers: 97 | data = renderer.render(data, context) 98 | 99 | response.content = json.dumps(data) 100 | 101 | if response.has_header('Content-Length'): 102 | response['Content-Length'] = str(len(response.content)) 103 | -------------------------------------------------------------------------------- /tests/test_middleware.py: -------------------------------------------------------------------------------- 1 | import json 2 | from unittest.mock import Mock 3 | 4 | from django.core.handlers.wsgi import WSGIRequest 5 | from django.http import HttpResponse, JsonResponse 6 | from django.test import RequestFactory, override_settings, testcases 7 | 8 | from graphene_django.views import GraphQLView 9 | 10 | from graphql_persist import exceptions, versioning 11 | from graphql_persist.middleware import PersistedQuery, PersistMiddleware 12 | from graphql_persist.parser import parse_body, parse_json 13 | from graphql_persist.renderers import BaseRenderer 14 | from graphql_persist.settings import persist_settings 15 | 16 | from .decorators import override_persist_settings 17 | 18 | 19 | class VersioningRequestFactory(RequestFactory): 20 | 21 | def request(self, **request): 22 | return VersioningRequest(self._base_environ(**request)) 23 | 24 | 25 | class JSONRequestFactory(VersioningRequestFactory): 26 | 27 | def post(self, path, data=None, *args, **kwargs): 28 | kwargs.setdefault('content_type', 'application/json') 29 | 30 | if isinstance(data, dict): 31 | data = json.dumps(data) 32 | return super().post(path, data=data, *args, **kwargs) 33 | 34 | 35 | class VersioningRequest(WSGIRequest): 36 | version = None 37 | 38 | 39 | class Versioning(versioning.QueryParameterVersioning): 40 | allowed_versions = ('v1',) 41 | 42 | 43 | class Renderer(BaseRenderer): 44 | 45 | def render(self, data, context=None): 46 | data['test'] = True 47 | return data 48 | 49 | 50 | class MiddlewareTests(testcases.TestCase): 51 | 52 | def setUp(self): 53 | self.request_factory = JSONRequestFactory() 54 | self.get_response_mock = Mock(return_value=JsonResponse({})) 55 | self.middleware = PersistMiddleware(self.get_response_mock) 56 | self.view_func = GraphQLView.as_view() 57 | 58 | @override_settings(INSTALLED_APPS=['tests']) 59 | def test_process_view(self): 60 | data = { 61 | 'id': 'schema', 62 | } 63 | 64 | request = self.request_factory.post('/', data=data) 65 | result = self.middleware.process_view(request, self.view_func) 66 | persisted_query = request.persisted_query 67 | document = persisted_query.document 68 | body = parse_body(request) 69 | 70 | self.assertIsNone(result) 71 | self.assertEqual(persisted_query.id, 'schema') 72 | self.assertEqual(document.origin.query_key._keys, ['schema']) 73 | self.assertEqual(document.source.body, body['query']) 74 | 75 | @override_settings(INSTALLED_APPS=['tests']) 76 | def test_process_view_x_www_form_urlencoded(self): 77 | data = { 78 | 'id': 'schema', 79 | } 80 | 81 | request = VersioningRequestFactory().post('/', data=data) 82 | result = self.middleware.process_view(request, self.view_func) 83 | persisted_query = request.persisted_query 84 | document = persisted_query.document 85 | body = request.POST 86 | 87 | self.assertIsNone(result) 88 | self.assertEqual(persisted_query.id, 'schema') 89 | self.assertEqual(document.origin.query_key._keys, ['schema']) 90 | self.assertEqual(document.source.body, body['query']) 91 | 92 | def test_missing_id(self): 93 | request = self.request_factory.post('/', data={}) 94 | result = self.middleware.process_view(request, self.view_func) 95 | 96 | self.assertIsNone(result) 97 | 98 | def test_process_unknown_view(self): 99 | request = self.request_factory.post('/') 100 | result = self.middleware.process_view(request, None) 101 | 102 | self.assertIsNone(result) 103 | 104 | def test_json_decode_error(self): 105 | request = self.request_factory.post('/', data='error') 106 | result = self.middleware.process_view(request, self.view_func) 107 | 108 | self.assertIsNone(result) 109 | 110 | @override_settings(INSTALLED_APPS=['tests']) 111 | def test_syntax_error(self): 112 | data = { 113 | 'id': 'syntax_error', 114 | } 115 | 116 | request = self.request_factory.post('/', data=data) 117 | result = self.middleware.process_view(request, self.view_func) 118 | 119 | self.assertIsInstance(result, exceptions.DocumentSyntaxError) 120 | 121 | def test_document_not_found(self): 122 | data = { 123 | 'id': 'not-found', 124 | } 125 | 126 | request = self.request_factory.post('/', data=data) 127 | result = self.middleware.process_view(request, self.view_func) 128 | 129 | self.assertIsInstance(result, exceptions.DocumentNotFound) 130 | 131 | def test_versioning_not_found(self): 132 | request = self.request_factory.post('/') 133 | response = self.middleware(request) 134 | 135 | self.assertIsNone(request.version) 136 | self.assertEqual(response.status_code, 200) 137 | 138 | @override_persist_settings(DEFAULT_VERSIONING_CLASS=Versioning) 139 | def test_versioning_not_allowed(self): 140 | middleware = PersistMiddleware(self.get_response_mock) 141 | 142 | request = self.request_factory.post('?{0}={1}'.format( 143 | persist_settings.VERSION_PARAM, 144 | 'not-allowed'), 145 | ) 146 | 147 | response = middleware(request) 148 | 149 | self.assertEqual(response.status_code, 400) 150 | self.assertIsInstance(response, exceptions.PersistResponseError) 151 | 152 | @override_persist_settings( 153 | DEFAULT_RENDERER_CLASSES=( 154 | '{}.Renderer'.format(__name__), 155 | ), 156 | ) 157 | def test_finalize_response(self): 158 | middleware = PersistMiddleware(self.get_response_mock) 159 | request = self.request_factory.post('/') 160 | request.persisted_query = PersistedQuery(None, {}) 161 | 162 | response = middleware(request) 163 | data = parse_json(response.content) 164 | 165 | self.assertEqual(response.status_code, 200) 166 | self.assertTrue(data['test']) 167 | 168 | def test_response_content_length(self): 169 | request = self.request_factory.post('/') 170 | content = '{}' 171 | 172 | response = HttpResponse(content) 173 | response['Content-Length'] = None 174 | 175 | self.middleware.finalize_response(request, response) 176 | self.assertEqual(response['Content-Length'], str(len(content))) 177 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | Django GraphQL Persist 2 | ====================== 3 | 4 | |Pypi| |Wheel| |Build Status| |Codecov| |Code Climate| 5 | 6 | 7 | **Persisted queries** for `Django GraphQL`_ 8 | 9 | .. _Django GraphQL: https://github.com/graphql-python/graphene-django 10 | 11 | 12 | Dependencies 13 | ------------ 14 | 15 | * Django ≥ 1.11 16 | * Python ≥ 3.4 17 | 18 | 19 | Installation 20 | ------------ 21 | 22 | Install last stable version from Pypi. 23 | 24 | .. code:: sh 25 | 26 | pip install django-graphql-persist 27 | 28 | 29 | Include the ``PersistMiddleware`` middleware in your *MIDDLEWARE* settings: 30 | 31 | .. code:: python 32 | 33 | MIDDLEWARE = [ 34 | ... 35 | 'graphql_persist.middleware.PersistMiddleware', 36 | ... 37 | ] 38 | 39 | 40 | Loaders 41 | ------- 42 | 43 | *Django-graphql-persist* searches for documents directories in a number of places, depending on your ``DEFAULT_LOADER_CLASSES`` variable. 44 | 45 | By default, *Django-graphql-persist* uses these document loaders: 46 | 47 | * **AppDirectoriesLoader** 48 | 49 | Loads documents from Django apps on the filesystem. For each app in ``INSTALLED_APPS``, the loader looks for a ``documents/`` subdirectory defined by ``APP_DOCUMENT_DIR`` variable. 50 | 51 | * **FilesystemLoader** 52 | 53 | Loads documents from the filesystem, according to ``DOCUMENTS_DIRS`` variable. 54 | 55 | * **URLLoader** 56 | 57 | Loads documents from urls, according to ``DOCUMENTS_DIRS``. 58 | 59 | .. code:: python 60 | 61 | GRAPHQL_PERSIST = { 62 | 'DOCUMENTS_DIRS': [ 63 | '/app/documents', # FilesystemLoader 64 | 'https:// ... /documents', # URLLoader 65 | ], 66 | } 67 | 68 | **Cached Loader** 69 | 70 | By default, the document system reads your documents every time they're rendered. 71 | 72 | Configure the ``CachedEngine`` engine for caching documents. 73 | 74 | 75 | .. code:: python 76 | 77 | GRAPHQL_PERSIST = { 78 | 'DEFAULT_LOADER_ENGINE_CLASS': [ 79 | graphql_persist.loaders.CachedEngine', 80 | ], 81 | } 82 | 83 | Persisted Query definition 84 | -------------------------- 85 | 86 | You can split schemas into separate files... 87 | 88 | ``documents/fragments.graphql`` 89 | 90 | .. code:: graphql 91 | 92 | fragment userFields on UserType { 93 | id 94 | email 95 | } 96 | 97 | and define Pythonic imports prefixed with ``#``. 98 | 99 | ``documents/GetViewer.graphql`` 100 | 101 | .. code:: graphql 102 | 103 | # from fragments import userFields 104 | 105 | { 106 | viewer { 107 | ...userFields 108 | } 109 | } 110 | 111 | 112 | **Persisted Query by** ``id`` 113 | 114 | .. code:: json 115 | 116 | { 117 | "id": "GetViewer", 118 | "variables": {} 119 | } 120 | 121 | 122 | Multiple Operations 123 | ------------------- 124 | 125 | ``documents/users.graphql`` 126 | 127 | .. code:: graphql 128 | 129 | # from fragments import userFields 130 | 131 | query GetViewer { 132 | viewer { 133 | ...userFields 134 | } 135 | } 136 | 137 | query GetUsers($last: Int!) { 138 | users(last: $last) { 139 | ...userFields 140 | } 141 | } 142 | 143 | 144 | **Persisted Query by** ``id`` and ``operationName`` 145 | 146 | .. code:: json 147 | 148 | { 149 | "id": "users", 150 | "operationName": "GetUsers", 151 | "variables": { 152 | "last": 2 153 | } 154 | } 155 | 156 | 157 | ✋ Versioning 158 | ------------- 159 | 160 | The versioning scheme is defined by the ``DEFAULT_VERSIONING_CLASS`` setting variable. 161 | 162 | .. code:: python 163 | 164 | GRAPHQL_PERSIST = { 165 | 'DEFAULT_VERSIONING_CLASS': 'graphql_persist.versioning.AcceptHeaderVersioning' 166 | } 167 | 168 | This is the full **list of versioning classes**. 169 | 170 | +--------------------------+-------------------------------------+ 171 | | DEFAULT_VERSIONING_CLASS | Example | 172 | +==========================+=====================================+ 173 | | AcceptHeaderVersioning | ``application/json; version=v1`` | 174 | +--------------------------+-------------------------------------+ 175 | | VendorTreeVersioning | ``application/vnd.example.v1+json`` | 176 | +--------------------------+-------------------------------------+ 177 | | QueryParameterVersioning | ``?version=v1`` | 178 | +--------------------------+-------------------------------------+ 179 | | HostNameVersioning | ``v1.example.com`` | 180 | +--------------------------+-------------------------------------+ 181 | 182 | Configure the versioning scheme and storage the GraphQL documents according to the version. 183 | 184 | 👇 **Example** 185 | 186 | .. code:: 187 | 188 | POST /graphql HTTP/1.1 189 | Accept: application/json; version=v1.full 190 | 191 | { 192 | "id": "GetViewer", 193 | "variables": {} 194 | } 195 | 196 | .. code:: 197 | 198 | documents/ 199 | | 200 | ├── v1/ 201 | │ ├── full/ 202 | │ | └── GetViewer.graphql 👈 203 | │ └── basic/ 204 | | | └── GetViewer.graphql 205 | | └── fragments/ 206 | | └── users.graphql 207 | └── v2/ 208 | └── full/ 209 | └── basic/ 210 | 211 | 👉 ``documents/v1/full/GetViewer.graphql`` 212 | 213 | .. code:: graphql 214 | 215 | # from ..fragments.users import userFields 216 | 217 | { 218 | viewer { 219 | ...userFields 220 | } 221 | } 222 | 223 | 224 | Settings 225 | -------- 226 | 227 | Here's a **list of settings** available in *Django-graphql-persist* and their default values. 228 | 229 | **DOCUMENTS_DIRS** 230 | 231 | :: 232 | 233 | List of directories or urls searched for GraphQL SDL definitions 234 | Default: () 235 | 236 | **CACHE_NAME** 237 | 238 | :: 239 | 240 | Cache key name `CACHES[name]` to cache the queries results 241 | Default: 'default' 242 | 243 | **QUERY_KEY_HANDLER** 244 | 245 | :: 246 | 247 | A custom function `f(query_id, request)` to generate the persisted query keys 248 | Default: 'graphql_persist.query.query_key_handler' 249 | 250 | 251 | **DEFAULT_VERSIONING_CLASS** 252 | 253 | :: 254 | 255 | A versioning class to determine the `request.version` attribute 256 | Default: None 257 | 258 | **DEFAULT_LOADER_ENGINE_CLASS** 259 | 260 | :: 261 | 262 | Class that takes a list of template loaders and attempts to load templates from them in order 263 | Default: 'graphql_persist.loaders.Engine' 264 | Note: Set variable to 'graphql_persist.loaders.CachedEngine' for caching documents 265 | 266 | **DEFAULT_LOADER_CLASSES** 267 | 268 | :: 269 | 270 | A list of loader classes to import documents from a particular source 271 | Default: ( 272 | 'graphql_persist.loaders.AppDirectoriesLoader', 273 | 'graphql_persist.loaders.FilesystemLoader', 274 | 'graphql_persist.loaders.URLLoader', 275 | ) 276 | 277 | **APP_DOCUMENT_DIR** 278 | 279 | :: 280 | 281 | Subdirectory of installed applications for searches documents 282 | Default: 'documents' 283 | 284 | **DOCUMENTS_EXT** 285 | 286 | :: 287 | 288 | GraphQL document file extension 289 | Default: '.graphql' 290 | 291 | **DEFAULT_RENDERER_CLASSES** 292 | 293 | :: 294 | 295 | A list of renderer classes that may be used when returning a persisted query response 296 | Default: () 297 | 298 | 299 | .. |Pypi| image:: https://img.shields.io/pypi/v/django-graphql-persist.svg 300 | :target: https://pypi.python.org/pypi/django-graphql-persist 301 | 302 | .. |Wheel| image:: https://img.shields.io/pypi/wheel/django-graphql-persist.svg 303 | :target: https://pypi.python.org/pypi/django-graphql-persist 304 | 305 | .. |Build Status| image:: https://travis-ci.org/flavors/django-graphql-persist.svg?branch=master 306 | :target: https://travis-ci.org/flavors/django-graphql-persist 307 | 308 | .. |Codecov| image:: https://img.shields.io/codecov/c/github/flavors/django-graphql-persist.svg 309 | :target: https://codecov.io/gh/flavors/django-graphql-persist 310 | 311 | .. |Code Climate| image:: https://api.codeclimate.com/v1/badges/46eaf45a95441d5470a4/maintainability 312 | :target: https://codeclimate.com/github/flavors/django-graphql-persist 313 | --------------------------------------------------------------------------------