├── tests ├── queries │ ├── echo.gql │ └── garage.gql ├── themes │ └── graphql_twig_test_theme │ │ ├── templates │ │ ├── reset │ │ │ ├── html.html.twig │ │ │ └── page.html.twig │ │ ├── node.html.twig │ │ ├── pages │ │ │ ├── static.html.twig │ │ │ ├── no-title.html.twig │ │ │ ├── error.html.twig │ │ │ ├── no-arguments.html.twig │ │ │ ├── one-argument.html.twig │ │ │ └── multiple-arguments.html.twig │ │ ├── blocks │ │ │ ├── block-static.html.twig │ │ │ ├── block-no-arguments.html.twig │ │ │ ├── block-one-argument.html.twig │ │ │ └── block-multiple-arguments.html.twig │ │ ├── node.html.twig.gql │ │ ├── graphql-echo.html.twig │ │ └── graphql-garage.html.twig │ │ ├── components │ │ └── graphql-vehicle.twig │ │ ├── graphql_twig_test_theme.theme │ │ └── graphql_twig_test_theme.info.yml └── src │ ├── Traits │ └── ThemeTestTrait.php │ ├── Kernel │ ├── ThemeTest.php │ ├── EntityRenderTest.php │ ├── BlockTest.php │ └── RouteTest.php │ └── Unit │ └── GraphQLTwigExtensionTest.php ├── .scrutinizer.yml ├── assets ├── graphql.png ├── debug.js └── debug.css ├── config ├── install │ └── graphql_twig.settings.yml └── schema │ └── graphql_twig.schema.yml ├── graphql_twig.info.yml ├── graphql_twig.links.menu.yml ├── graphql_twig.libraries.yml ├── graphql_twig.routing.yml ├── composer.json ├── src ├── GraphQLTwigExtension.php ├── GraphQLFragmentNode.php ├── Plugin │ ├── GraphQL │ │ └── Fields │ │ │ └── CurrentUrl.php │ ├── Deriver │ │ └── GraphQLTwigBlockDeriver.php │ └── Block │ │ └── GraphQLTwigBlock.php ├── GraphqlTwigServiceProvider.php ├── Controller │ ├── ArgumentResolver.php │ └── RouteController.php ├── GraphQLTokenParser.php ├── Routing │ └── GraphQLTwigRouter.php ├── GraphQLNodeVisitor.php ├── Form │ └── GraphQLTwigSettingsForm.php ├── Template │ └── Loader │ │ └── Loader.php ├── GraphQLNode.php ├── GraphQLTwigEnvironment.php └── GraphQLTemplateTrait.php ├── phpcs.xml.dist ├── graphql_twig.services.yml ├── graphql_twig.module ├── README.md └── .travis.yml /tests/queries/echo.gql: -------------------------------------------------------------------------------- 1 | query($input: String!) { 2 | echo(input: $input) 3 | } -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/reset/html.html.twig: -------------------------------------------------------------------------------- 1 | {{ page }} 2 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/reset/page.html.twig: -------------------------------------------------------------------------------- 1 | {{ page.content }} 2 | -------------------------------------------------------------------------------- /.scrutinizer.yml: -------------------------------------------------------------------------------- 1 | filter: 2 | excluded_paths: 3 | - 'tests/*' 4 | 5 | checks: 6 | php: true 7 | -------------------------------------------------------------------------------- /assets/graphql.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/drupal-graphql/graphql-twig/HEAD/assets/graphql.png -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/node.html.twig: -------------------------------------------------------------------------------- 1 |

{{ graphql.node.title }}

2 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/pages/static.html.twig: -------------------------------------------------------------------------------- 1 |

This page is static.

2 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/pages/no-title.html.twig: -------------------------------------------------------------------------------- 1 |

This page has no title.

2 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/blocks/block-static.html.twig: -------------------------------------------------------------------------------- 1 |

This is a static block.

2 | -------------------------------------------------------------------------------- /config/install/graphql_twig.settings.yml: -------------------------------------------------------------------------------- 1 | # Debug output mode inside twig files: wrapped vs inside 2 | debug_placement: 'wrapped' 3 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/pages/error.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query { 3 | shout 4 | } 5 | #} 6 |

This page will lead to an error.

7 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/node.html.twig.gql: -------------------------------------------------------------------------------- 1 | query($node: String!) { 2 | node:nodeById(id: $node) { 3 | title:entityLabel 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /tests/queries/garage.gql: -------------------------------------------------------------------------------- 1 | query { 2 | garage { 3 | type 4 | ... graphql_vehicle 5 | } 6 | } 7 | fragment graphql_vehicle on Vehicle { 8 | type 9 | wheels 10 | } -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/graphql-echo.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query($input: String!) { 3 | echo(input: $input) 4 | } 5 | #} 6 | {{ graphql.echo }} 7 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/components/graphql-vehicle.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | fragment graphql_vehicle on Vehicle { 3 | type 4 | wheels 5 | } 6 | #} 7 | A {{ type }} with {{ wheels }} wheels. -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/blocks/block-no-arguments.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query { 3 | shout(word: "drupal") 4 | } 5 | #} 6 |

This block shouts: {{ graphql.shout }}

7 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/pages/no-arguments.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query { 3 | shout(word: "Drupal") 4 | } 5 | #} 6 |

This page is supposed to shout: {{ graphql.shout }}

7 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/blocks/block-one-argument.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query ($first: String!) { 3 | shout(word: $first) 4 | } 5 | #} 6 |

This block shouts: {{ graphql.shout }}

7 | -------------------------------------------------------------------------------- /graphql_twig.info.yml: -------------------------------------------------------------------------------- 1 | name: GraphQL Twig 2 | type: module 3 | description: 'Render twig templates with graphql data.' 4 | package: GraphQL 5 | core: 8.x 6 | dependencies: 7 | - graphql 8 | - drupal:system (>= 8.6) 9 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/pages/one-argument.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query ($first: String!) { 3 | shout(word: $first) 4 | } 5 | #} 6 |

This page is supposed to shout: {{ graphql.shout }}

7 | -------------------------------------------------------------------------------- /graphql_twig.links.menu.yml: -------------------------------------------------------------------------------- 1 | graphql_twig.settings: 2 | title: 'GraphQL Twig' 3 | parent: system.admin_config_services 4 | description: 'Configure graphql twig.' 5 | route_name: graphql_twig.settings 6 | weight: 99 7 | -------------------------------------------------------------------------------- /graphql_twig.libraries.yml: -------------------------------------------------------------------------------- 1 | debug: 2 | version: 1.x 3 | css: 4 | theme: 5 | assets/debug.css: {} 6 | js: 7 | assets/debug.js: {} 8 | dependencies: 9 | - core/jquery 10 | - core/jquery.once 11 | - core/drupal 12 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/blocks/block-multiple-arguments.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query ($first: String!, $second: String!) { 3 | first:shout(word: $first) 4 | second:shout(word: $second) 5 | } 6 | #} 7 |

This block shouts: {{ graphql.first }} and {{ graphql.second }}

8 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/graphql-garage.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query { 3 | garage { 4 | type 5 | ... graphql_vehicle 6 | } 7 | } 8 | #} 9 | Garage: 10 | {% for vehicle in graphql.garage %} 11 | {% include '#graphql-vehicle' with vehicle %} 12 | {% endfor %} 13 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/templates/pages/multiple-arguments.html.twig: -------------------------------------------------------------------------------- 1 | {#graphql 2 | query ($first: String!, $second: String!) { 3 | first:shout(word: $first) 4 | second:shout(word: $second) 5 | } 6 | #} 7 |

This page is supposed to shout: {{ graphql.first }} and {{ graphql.second }}

8 | -------------------------------------------------------------------------------- /graphql_twig.routing.yml: -------------------------------------------------------------------------------- 1 | route_callbacks: 2 | - 'graphql_twig.router:routes' 3 | 4 | graphql_twig.settings: 5 | path: '/admin/config/services/graphql_twig' 6 | defaults: 7 | _form: 'Drupal\graphql_twig\Form\GraphQLTwigSettingsForm' 8 | _title: 'GraphQL Twig' 9 | requirements: 10 | _permission: 'administer site configuration' 11 | -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/graphql_twig_test_theme.theme: -------------------------------------------------------------------------------- 1 | [ 14 | 'variables' => ['input' => ''], 15 | ], 16 | 'graphql_garage' => [ 17 | 'variables' => [], 18 | ], 19 | ]; 20 | } 21 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "drupal/graphql_twig", 3 | "type": "drupal-module", 4 | "description": "Feed Twig templates with GraphQL.", 5 | "homepage": "http://drupal.org/project/graphql_twig", 6 | "authors": [ 7 | { 8 | "name": "Philipp Melab", 9 | "homepage": "https://www.drupal.org/u/pmelab" 10 | } 11 | ], 12 | "support": { 13 | "issues": "https://github.com/drupal-graphql/graphql-twig" 14 | }, 15 | "license": "GPL-2.0+", 16 | "minimum-stability": "dev" 17 | } 18 | -------------------------------------------------------------------------------- /src/GraphQLTwigExtension.php: -------------------------------------------------------------------------------- 1 | 2 | 3 | Default PHP CodeSniffer configuration for GraphQL. 4 | . 5 | 6 | 7 | 8 | 9 | 10 | src/Annotation 11 | src/Core/Annotation 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /src/GraphQLFragmentNode.php: -------------------------------------------------------------------------------- 1 | fragment = $fragment; 28 | parent::__construct(); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Plugin/GraphQL/Fields/CurrentUrl.php: -------------------------------------------------------------------------------- 1 | '); 31 | } 32 | 33 | } 34 | -------------------------------------------------------------------------------- /graphql_twig.services.yml: -------------------------------------------------------------------------------- 1 | services: 2 | graphql_twig.twig.embed: 3 | class: Drupal\graphql_twig\GraphQLTwigExtension 4 | tags: 5 | - { name: twig.extension } 6 | graphql_twig.router: 7 | class: Drupal\graphql_twig\Routing\GraphQLTwigRouter 8 | arguments: 9 | - '@theme_handler' 10 | argument_resolver.graphql_twig: 11 | class: Drupal\graphql_twig\Controller\ArgumentResolver 12 | 13 | cache.graphql_twig.components: 14 | class: Drupal\Core\Cache\CacheBackendInterface 15 | tags: 16 | - { name: cache.bin } 17 | factory: cache_factory:get 18 | arguments: [graphql_twig_components] 19 | 20 | twig.loader.graphql: 21 | class: Drupal\graphql_twig\Template\Loader\Loader 22 | arguments: 23 | - '@theme.manager' 24 | - '%twig.config%' 25 | - '@cache.graphql_twig.components' 26 | - '@app.root' 27 | tags: 28 | - { name: twig.loader, priority: 1000 } 29 | -------------------------------------------------------------------------------- /assets/debug.js: -------------------------------------------------------------------------------- 1 | (function ($, Drupal) { 2 | Drupal.behaviors.graphqlTwigDebug = { 3 | attach: function (context, settings) { 4 | $('.graphql-twig-debug-wrapper', context).once('graphql-debug').each(function () { 5 | var query = $(this).attr('data-graphql-query'), 6 | variables = $(this).attr('data-graphql-variables'), 7 | $form = $('
').attr('action', Drupal.url('graphql/explorer')).appendTo(this), 8 | txt = document.createElement('textarea'); 9 | 10 | txt.innerHTML = variables; 11 | variables = txt.value; 12 | $('').val(query).appendTo($form); 13 | $('').val(variables).appendTo($form); 14 | $('').appendTo($form); 15 | }); 16 | } 17 | }; 18 | }(jQuery, Drupal)); 19 | -------------------------------------------------------------------------------- /assets/debug.css: -------------------------------------------------------------------------------- 1 | .graphql-twig-errors { 2 | list-style: none; 3 | border: 1px solid #d80000; 4 | background: #ffdbe3; 5 | color: #a10000; 6 | padding: 1em; 7 | } 8 | 9 | .graphql-twig-errors li:not(:first-child) { 10 | margin-top: 1em; 11 | } 12 | 13 | .graphql-twig-debug-wrapper { 14 | position: relative; 15 | } 16 | 17 | .graphql-twig-debug-wrapper form { 18 | margin: 0!important; 19 | padding: 0!important; 20 | } 21 | 22 | input[type="submit"].graphql-twig-debug-button { 23 | z-index: 99; 24 | position: absolute; 25 | text-indent: -999em; 26 | border-radius: 50%; 27 | background: url(graphql.png) white center center no-repeat; 28 | background-size: 20px 20px; 29 | border: 1px solid #CCC; 30 | width: 26px; 31 | height: 26px; 32 | } 33 | 34 | .graphql-twig-debug-wrapper .graphql-twig-debug-button { 35 | position: absolute; 36 | top: 4px; 37 | left: 4px; 38 | display: none; 39 | } 40 | 41 | .graphql-twig-debug-parent:hover > .graphql-twig-debug-child .graphql-twig-debug-button, 42 | .graphql-twig-debug-wrapper:hover > form > .graphql-twig-debug-button { 43 | display: block; 44 | } 45 | -------------------------------------------------------------------------------- /src/GraphqlTwigServiceProvider.php: -------------------------------------------------------------------------------- 1 | getDefinition('twig') 20 | ->setClass(GraphQLTwigEnvironment::class) 21 | ->addArgument(new Reference('graphql.query_processor')) 22 | ->addArgument(new Reference('renderer')); 23 | 24 | // Inject our own argument resolver. 25 | $def = $container->getDefinition('http_kernel.controller.argument_resolver'); 26 | $argumentResolvers = $def->getArgument(1); 27 | $argumentResolvers[] = new Reference('argument_resolver.graphql_twig'); 28 | $def->setArgument(1, $argumentResolvers); 29 | } 30 | 31 | } 32 | -------------------------------------------------------------------------------- /src/Controller/ArgumentResolver.php: -------------------------------------------------------------------------------- 1 | getName(), [ 23 | '_graphql_arguments', 24 | '_graphql_theme_hook', 25 | '_graphql_title', 26 | '_graphql_title_query', 27 | ]); 28 | } 29 | 30 | /** 31 | * @inheritdoc 32 | */ 33 | public function resolve(Request $request, ArgumentMetadata $argument) { 34 | switch($argument->getName()) { 35 | case '_graphql_arguments': 36 | yield $request->attributes->get('_raw_variables')->all(); 37 | break; 38 | case '_graphql_theme_hook': 39 | yield $request->attributes->get('_theme_hook'); 40 | break; 41 | case '_graphql_title': 42 | yield $request->attributes->get('_title'); 43 | break; 44 | case '_graphql_title_query': 45 | yield $request->attributes->get('_title_query'); 46 | break; 47 | } 48 | } 49 | 50 | 51 | } 52 | -------------------------------------------------------------------------------- /src/Plugin/Deriver/GraphQLTwigBlockDeriver.php: -------------------------------------------------------------------------------- 1 | get('theme_handler')); 21 | } 22 | 23 | public function __construct(ThemeHandlerInterface $themeHandler) { 24 | $this->themeHandler = $themeHandler; 25 | } 26 | 27 | public function getDerivativeDefinitions($base_plugin_definition) { 28 | foreach ($this->themeHandler->listInfo() as $themeName => $info) { 29 | if (isset($info->info['blocks'])) { 30 | foreach ($info->info['blocks'] as $name => $block) { 31 | $this->derivatives[$name] = [ 32 | 'admin_label' => $this->t($block['label']), 33 | 'graphql_theme_hook' => $name, 34 | 'graphql_parameters' => isset($block['parameters']) ? $block['parameters'] : [], 35 | ] + $base_plugin_definition; 36 | } 37 | } 38 | } 39 | return $this->derivatives; 40 | } 41 | 42 | 43 | } 44 | -------------------------------------------------------------------------------- /graphql_twig.module: -------------------------------------------------------------------------------- 1 | listInfo() as $themeName => $info) { 13 | 14 | // Register twig routes as theme hooks. 15 | if (isset($info->info['routes'])) { 16 | foreach ($info->info['routes'] as $name => $route) { 17 | $theme[$name] = [ 18 | 'variables' => [ 19 | 'graphql_arguments' => [], 20 | 'graphql_ext' => $name, 21 | ], 22 | 'function' => '_graphql_twig_missing_template', 23 | ]; 24 | } 25 | } 26 | 27 | // Register twig blocks as theme hooks. 28 | if (isset($info->info['blocks'])) { 29 | foreach ($info->info['blocks'] as $name => $block) { 30 | $theme[$name] = [ 31 | 'variables' => [ 32 | 'graphql_arguments' => [], 33 | 'graphql_ext' => $name, 34 | ], 35 | 'function' => '_graphql_twig_missing_template', 36 | ]; 37 | } 38 | } 39 | 40 | } 41 | return $theme; 42 | } 43 | 44 | /** 45 | * Emits an error message if a dynamic route template is missing. 46 | */ 47 | function _graphql_twig_missing_template($variables) { 48 | return '
' . t('Missing template for %ext.', [ 49 | '%ext' => $variables['graphql_ext'], 50 | ]) . '
'; 51 | } 52 | -------------------------------------------------------------------------------- /src/GraphQLTokenParser.php: -------------------------------------------------------------------------------- 1 | parser->getStream(); 21 | if (!$this->parser->isMainScope()) { 22 | throw new Twig_Error_Syntax( 23 | 'GraphQL queries cannot be defined in blocks.', 24 | $token->getLine(), 25 | $stream->getSourceContext() 26 | ); 27 | } 28 | 29 | $stream->expect(Twig_Token::BLOCK_END_TYPE); 30 | $values = $this->parser->subparse([$this, 'decideBlockEnd'], TRUE); 31 | $stream->expect(Twig_Token::BLOCK_END_TYPE); 32 | if ($values instanceof \Twig_Node_Text) { 33 | try { 34 | return new GraphQLFragmentNode($values->getAttribute('data')); 35 | } 36 | catch (SyntaxError $error) { 37 | throw new Twig_Error_Syntax( 38 | $error->getMessage(), 39 | $token->getLine(), 40 | $stream->getSourceContext() 41 | ); 42 | } 43 | } 44 | 45 | return NULL; 46 | } 47 | 48 | /** 49 | * {@inheritdoc} 50 | */ 51 | public function decideBlockEnd(Twig_Token $token) { 52 | return $token->test('endgraphql'); 53 | } 54 | 55 | /** 56 | * {@inheritdoc} 57 | */ 58 | public function getTag() { 59 | return 'graphql'; 60 | } 61 | 62 | } 63 | -------------------------------------------------------------------------------- /src/Routing/GraphQLTwigRouter.php: -------------------------------------------------------------------------------- 1 | themeHandler = $themeHandler; 26 | } 27 | 28 | /** 29 | * Generate a list of routes based on theme info files. 30 | * 31 | * @return \Symfony\Component\Routing\Route[] 32 | * A list of routes defined by themes. 33 | */ 34 | public function routes() { 35 | $routes = []; 36 | foreach ($this->themeHandler->listInfo() as $info) { 37 | if (isset($info->info['routes'])) { 38 | foreach ($info->info['routes'] as $name => $route) { 39 | $routes['graphql_twig.dynamic.' . $name] = new Route($route['path'], [ 40 | '_controller' => RouteController::class . ':page', 41 | '_title_callback' => RouteController::class . ':title', 42 | '_title' => isset($route['title']) ? $route['title'] : NULL, 43 | '_title_query' => isset($route['title_query']) ? $route['title_query'] : NULL, 44 | '_graphql_theme_hook' => $name, 45 | ], isset($route['requirements']) ? $route['requirements'] : [ 46 | '_access' => 'TRUE', 47 | ]); 48 | } 49 | 50 | } 51 | } 52 | return $routes; 53 | } 54 | 55 | } 56 | -------------------------------------------------------------------------------- /tests/src/Traits/ThemeTestTrait.php: -------------------------------------------------------------------------------- 1 | prophesize(AccountProxy::class); 25 | $currentUser->isAuthenticated()->willReturn(TRUE); 26 | $currentUser->hasPermission(Argument::any())->willReturn(TRUE); 27 | $currentUser->id()->willReturn(1); 28 | $currentUser->getRoles()->willReturn(['administrator']); 29 | $this->container->set('current_user', $currentUser->reveal()); 30 | 31 | // Prepare a mock graphql processor. 32 | $this->processor = $this->prophesize(QueryProcessor::class); 33 | $this->container->set('graphql.query_processor', $this->processor->reveal()); 34 | 35 | $themeName = 'graphql_twig_test_theme'; 36 | 37 | /** @var \Drupal\Core\Extension\ThemeHandler $themeHandler */ 38 | $themeHandler = $this->container->get('theme_handler'); 39 | /** @var \Drupal\Core\Theme\ThemeInitialization $themeInitialization */ 40 | $themeInitialization = $this->container->get('theme.initialization'); 41 | /** @var \Drupal\Core\Theme\ThemeManager $themeManager */ 42 | $themeManager = $this->container->get('theme.manager'); 43 | 44 | $themeHandler->install([$themeName]); 45 | $theme = $themeInitialization->initTheme($themeName); 46 | $themeManager->setActiveTheme($theme); 47 | } 48 | } 49 | } -------------------------------------------------------------------------------- /tests/themes/graphql_twig_test_theme/graphql_twig_test_theme.info.yml: -------------------------------------------------------------------------------- 1 | name: GraphQL Twig test 2 | type: theme 3 | description: 'Test theme for GraphQL Twig integration' 4 | package: GraphQL 5 | version: VERSION 6 | core: 8.x 7 | 8 | routes: 9 | no_arguments: 10 | path: '/no-args' 11 | title: 'Shouting: DRUPAL' 12 | title_query: 'query { shout(word: "drupal") }' 13 | requirements: 14 | _access: 'TRUE' 15 | 16 | one_argument: 17 | path: '/one-arg/{first}' 18 | title: 'Shouting: {{shout}}' 19 | title_query: 'query ($first: String!) { shout(word: $first) }' 20 | requirements: 21 | _access: 'TRUE' 22 | 23 | multiple_arguments: 24 | path: '/multi-args/{first}/{second}' 25 | title: 'Shouting: {{first}} and {{second}}' 26 | title_query: 'query ($first: String!, $second: String!) { first:shout(word: $first) second:shout(word: $second) }' 27 | requirements: 28 | _access: 'TRUE' 29 | 30 | static: 31 | path: '/static' 32 | title: 'This is a static page' 33 | requirements: 34 | _access: 'TRUE' 35 | 36 | no_title: 37 | path: '/no-title' 38 | requirements: 39 | _access: 'TRUE' 40 | 41 | no_access: 42 | path: '/no-access' 43 | requirements: 44 | _access: 'FALSE' 45 | 46 | missing: 47 | path: '/missing' 48 | title: 'Missing template' 49 | requirements: 50 | _access: 'TRUE' 51 | 52 | error: 53 | path: '/error' 54 | title: 'Query error' 55 | requirements: 56 | _access: 'TRUE' 57 | 58 | 59 | 60 | blocks: 61 | 62 | block_no_arguments: 63 | label: 'No Arguments' 64 | 65 | block_one_argument: 66 | label: 'One Argument' 67 | parameters: 68 | first: 69 | title: First 70 | type: textfield 71 | 72 | block_multiple_arguments: 73 | label: 'Multiple Arguments' 74 | parameters: 75 | first: 76 | title: First 77 | type: textfield 78 | second: 79 | title: First 80 | type: textfield 81 | 82 | block_static: 83 | label: 'Static block' 84 | 85 | block_missing: 86 | label: 'Missing template' 87 | -------------------------------------------------------------------------------- /src/Plugin/Block/GraphQLTwigBlock.php: -------------------------------------------------------------------------------- 1 | configuration['graphql_block'])) { 30 | foreach ($this->configuration['graphql_block'] as $arg) { 31 | $arguments[$arg['key']] = $arg['value']; 32 | } 33 | } 34 | 35 | foreach ($this->pluginDefinition['graphql_parameters'] as $name => $el) { 36 | foreach ($el as $key => $value) { 37 | if (in_array($key, ['title', 'description'])) { 38 | $form['graphql_block'][$name]['#' . $key] = $this->t($value); 39 | } 40 | else { 41 | $form['graphql_block'][$name]['#' . $key] = $value; 42 | } 43 | 44 | $form['graphql_block'][$name]['#default_value'] = isset($arguments[$name]) ? $arguments[$name] : ''; 45 | } 46 | } 47 | 48 | return $form; 49 | } 50 | 51 | /** 52 | * @inheritdoc 53 | */ 54 | public function blockSubmit($form, FormStateInterface $form_state) { 55 | parent::blockSubmit($form, $form_state); 56 | 57 | foreach (array_keys($this->pluginDefinition['graphql_parameters']) as $arg) { 58 | $values = $form_state->getValues(); 59 | $this->configuration['graphql_block'][] = [ 60 | 'key' => $arg, 61 | 'value' => $values['graphql_block'][$arg], 62 | ]; 63 | } 64 | } 65 | 66 | /** 67 | * @inheritdoc 68 | */ 69 | public function build() { 70 | $arguments = []; 71 | 72 | foreach ($this->configuration['graphql_block'] as $arg) { 73 | $arguments[$arg['key']] = $arg['value']; 74 | } 75 | 76 | return [ 77 | '#theme' => $this->pluginDefinition['graphql_theme_hook'], 78 | '#graphql_arguments' => $arguments, 79 | ]; 80 | } 81 | 82 | } 83 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # GraphQL Twig for Drupal 2 | 3 | [![Build Status](https://img.shields.io/travis/drupal-graphql/graphql-twig.svg)](https://travis-ci.org/drupal-graphql/graphql-twig) 4 | [![Code Coverage](https://img.shields.io/codecov/c/github/drupal-graphql/graphql-twig.svg)](https://codecov.io/gh/drupal-graphql/graphql-twig) 5 | [![Code Quality](https://img.shields.io/scrutinizer/g/drupal-graphql/graphql-twig.svg)](https://scrutinizer-ci.com/g/drupal-graphql/graphql-twig/?branch=8.x-1.x) 6 | 7 | The GraphQL Twig module allows you to inject data into Twig templates by simply adding 8 | a GraphQL query. No site building or pre-processing necessary. 9 | 10 | ## Requirements 11 | 12 | * Drupal >= 8.6 13 | * PHP >= 7.0 14 | 15 | ## Simple example 16 | 17 | The *"Powered by Drupal"* block only gives credit to Drupal, but what's a website 18 | without administrators and users? Right. Lets fix that. 19 | 20 | Place the following template override for the *"Powered by Drupal"* block in your theme: 21 | 22 | ```twig 23 | {#graphql 24 | query { 25 | admin:userById(id: "1") { 26 | uid 27 | name 28 | } 29 | user:currentUserContext { 30 | uid 31 | } 32 | } 33 | #} 34 | 35 | {% extends '@bartik/block.html.twig' %} 36 | {% block content %} 37 | {% embed '@graphql_twig/query.html.twig' %} 38 | {% block content %} 39 | {% set admin = graphql.admin %} 40 | {% set user = graphql.user %} 41 | 42 | {{ content }} and 43 | {% if user.uid == admin.uid %} 44 | you, {{ admin.name }}. 45 | {% else %} 46 | you, appreciated anonymous visitor. 47 | {% endif %} 48 | 49 | {% endblock %} 50 | {% endembed %} 51 | {% endblock %} 52 | ``` 53 | 54 | For the sake of an example, we assumed that you based your theme on Bartik (which you probably didn't), but 55 | what else happens here? In the `{#graphql ... #}` comment block we annotated a simple GraphQL query, that will 56 | be executed in an additional preprocessing step, to populate your template with an additional variable called 57 | `graphql` that will contain the result. We injected additional information into our template without the need 58 | to fall back to site building or manual preprocessing. 59 | The templates content is wrapped in an embed of `@graphql_twig/query.html.twig`. This is not 60 | necessary, but will emit debug information if Twig's debug setting is set to `true`. 61 | 62 | This is the basic concept of GraphQL in Twig. Additionally it will collect query fragments from 63 | included templates, automatically try to match template variables to query arguments and enable you to tap 64 | into all features of the [Drupal GraphQL] module. 65 | 66 | More documentation coming soon! 67 | 68 | [Drupal GraphQL]: https://github.com/drupal-graphql/graphql 69 | -------------------------------------------------------------------------------- /src/GraphQLNodeVisitor.php: -------------------------------------------------------------------------------- 1 | hasNode('parent')) { 51 | $parent = $node->getNode('parent'); 52 | if ($parent instanceof \Twig_Node_Expression_Constant) { 53 | $this->parent = $parent->getAttribute('value'); 54 | } 55 | } 56 | 57 | // Recurse into embedded templates. 58 | foreach ($node->getAttribute('embedded_templates') as $embed) { 59 | $this->doEnterNode($embed, $env); 60 | } 61 | } 62 | 63 | // Store identifiers of any static includes. 64 | // There is no way to make this work for dynamic includes. 65 | if ($node instanceof \Twig_Node_Include && !($node instanceof \Twig_Node_Embed)) { 66 | $ref = $node->getNode('expr'); 67 | if ($ref instanceof \Twig_Node_Expression_Constant) { 68 | $this->includes[$node->getTemplateName()][] = $ref->getAttribute('value'); 69 | } 70 | } 71 | 72 | // When encountering a GraphQL fragment, add it to the current query. 73 | if ($node instanceof GraphQLFragmentNode) { 74 | $this->query .= $node->fragment; 75 | } 76 | 77 | return $node; 78 | } 79 | 80 | /** 81 | * {@inheritdoc} 82 | */ 83 | protected function doLeaveNode(Twig_Node $node, Twig_Environment $env) { 84 | if ($node instanceof \Twig_Node_Module) { 85 | // Store current query information to be compiled into the templates 86 | // `class_end`. 87 | $includes = isset($this->includes[$node->getTemplateName()]) ? $this->includes[$node->getTemplateName()] : []; 88 | $node->setNode('class_end', new GraphQLNode($this->query, $this->parent, $includes)); 89 | 90 | // Reset query information for the next module. 91 | $this->query = ''; 92 | $this->parent = ''; 93 | } 94 | return $node; 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/Form/GraphQLTwigSettingsForm.php: -------------------------------------------------------------------------------- 1 | get('config.factory') 43 | ); 44 | } 45 | 46 | /** 47 | * {@inheritdoc} 48 | */ 49 | public function getFormId() { 50 | return 'graphql_twig_settings'; 51 | } 52 | 53 | /** 54 | * {@inheritdoc} 55 | */ 56 | protected function getEditableConfigNames() { 57 | return ['graphql_twig.settings']; 58 | } 59 | 60 | /** 61 | * {@inheritdoc} 62 | */ 63 | public function buildForm(array $form, FormStateInterface $form_state) { 64 | $site_config = $this->config('graphql_twig.settings'); 65 | $debug_placement = $site_config->get('debug_placement'); 66 | 67 | $form['debug'] = [ 68 | '#type' => 'details', 69 | '#title' => t('Debug'), 70 | '#open' => TRUE, 71 | ]; 72 | $form['debug']['debug_placement'] = [ 73 | '#type' => 'select', 74 | '#title' => t('Debug placement'), 75 | '#options' => [ 76 | 'wrapped' => $this->t('wrapped'), 77 | 'inside' => $this->t('inside'), 78 | ], 79 | '#default_value' => $debug_placement, 80 | '#required' => TRUE, 81 | ]; 82 | 83 | return parent::buildForm($form, $form_state); 84 | } 85 | 86 | /** 87 | * {@inheritdoc} 88 | */ 89 | public function submitForm(array &$form, FormStateInterface $form_state) { 90 | $this->config('graphql_twig.settings') 91 | ->set('debug_placement', $form_state->getValue('debug_placement')) 92 | ->save(); 93 | 94 | parent::submitForm($form, $form_state); 95 | } 96 | 97 | } 98 | -------------------------------------------------------------------------------- /src/Template/Loader/Loader.php: -------------------------------------------------------------------------------- 1 | cacheBackend = $cacheBackend; 44 | $this->twigConfig = $twigConfig; 45 | $this->themeManager = $themeManager; 46 | } 47 | 48 | /** 49 | * List all components found within a specific path. 50 | * 51 | * @param string $path 52 | * The directory to scan for 53 | * 54 | * @return string[] 55 | * Map of component filenames keyed by component handle. 56 | */ 57 | protected function listComponents($path) { 58 | if ($this->twigConfig['cache'] && $cache = $this->cacheBackend->get($path)) { 59 | return $cache->data; 60 | } 61 | 62 | foreach (file_scan_directory($path, '/.*\.twig$/') as $file) { 63 | $this->components[$file->name] = $file->uri; 64 | } 65 | 66 | if ($this->twigConfig['cache']) { 67 | $this->cacheBackend->set($path, $this->components); 68 | } 69 | 70 | return $this->components; 71 | 72 | } 73 | 74 | /** 75 | * {@inheritdoc} 76 | */ 77 | protected function findTemplate($name) { 78 | if (is_null($this->components)) { 79 | // Scan the directory for any twig files and register them. 80 | // TODO: inherit components from base theme 81 | $activeTheme = $this->themeManager->getActiveTheme(); 82 | $info = system_get_info('theme', $activeTheme->getName()); 83 | $componentsDirectory = array_key_exists('components', $info) 84 | ? $info['components'] 85 | : $activeTheme->getPath() . '/components'; 86 | $this->components = $this->listComponents($componentsDirectory); 87 | } 88 | 89 | if ($name[0] === '#') { 90 | $component = substr($name, 1); 91 | if (array_key_exists($component, $this->components)) { 92 | return $this->components[$component]; 93 | } 94 | } 95 | 96 | return FALSE; 97 | } 98 | 99 | } 100 | -------------------------------------------------------------------------------- /src/Controller/RouteController.php: -------------------------------------------------------------------------------- 1 | get('graphql.query_processor'), 32 | $container->get('twig') 33 | ); 34 | } 35 | 36 | /** 37 | * RouteController constructor. 38 | * 39 | * @param \Drupal\graphql\GraphQL\Execution\QueryProcessor $processor 40 | * A GraphQL query processor. 41 | * @param \Drupal\Core\Template\TwigEnvironment $twig 42 | * A Twig environment. 43 | */ 44 | public function __construct(QueryProcessor $processor, TwigEnvironment $twig) { 45 | $this->queryProcessor = $processor; 46 | $this->twig = $twig; 47 | } 48 | 49 | /** 50 | * Generic page callback. 51 | * 52 | * Accepts a theme hook and an array of theme variables. 53 | * 54 | * @param $_graphql_theme_hook 55 | * The theme hook. 56 | * @param $_graphql_arguments 57 | * Query arguments 58 | * 59 | * @return array 60 | * The render array build. 61 | */ 62 | public function page($_graphql_theme_hook, $_graphql_arguments) { 63 | return [ 64 | '#theme' => $_graphql_theme_hook, 65 | '#graphql_arguments' => $_graphql_arguments, 66 | ]; 67 | } 68 | 69 | /** 70 | * Build a page title from a twig template and a GraphQL query. 71 | * 72 | * @param $_graphql_title 73 | * @param $_graphql_title_query 74 | * @param $_graphql_arguments 75 | * 76 | * @return \Drupal\Component\Render\MarkupInterface|\Drupal\Core\StringTranslation\TranslatableMarkup|string 77 | * @throws \Drupal\Component\Plugin\Exception\PluginException 78 | */ 79 | public function title($_graphql_title, $_graphql_title_query, $_graphql_arguments) { 80 | if (!$_graphql_title) { 81 | return FALSE; 82 | } 83 | if ($_graphql_title_query) { 84 | $result = $this->queryProcessor->processQuery('default:default', 85 | OperationParams::create([ 86 | 'query' => $_graphql_title_query, 87 | 'variables' => $_graphql_arguments, 88 | ]) 89 | ); 90 | 91 | if ($result->errors) { 92 | $_graphql_title = reset($result->errors)->getMessage(); 93 | } 94 | else { 95 | $_graphql_title = $this->twig->renderInline($_graphql_title, $result->data); 96 | } 97 | } 98 | else { 99 | $_graphql_title = $this->t($_graphql_title); 100 | } 101 | return $_graphql_title; 102 | } 103 | 104 | } 105 | -------------------------------------------------------------------------------- /tests/src/Kernel/ThemeTest.php: -------------------------------------------------------------------------------- 1 | setupThemeTest(); 44 | } 45 | 46 | /** 47 | * Test query assembly. 48 | */ 49 | public function testQueryAssembly() { 50 | /** @var \Prophecy\Prophecy\MethodProphecy $process */ 51 | $this->processor 52 | ->processQuery(Argument::any(), Argument::that(function (OperationParams $params) { 53 | return $params->query === $this->getQuery('garage.gql'); 54 | })) 55 | ->willReturn(new QueryResult()) 56 | ->shouldBeCalled(); 57 | 58 | $element = ['#theme' => 'graphql_garage']; 59 | $this->render($element); 60 | } 61 | 62 | /** 63 | * Test query caching. 64 | */ 65 | public function testCacheableQuery() { 66 | 67 | $metadata = new CacheableMetadata(); 68 | $metadata->setCacheMaxAge(-1); 69 | 70 | $process = $this->processor 71 | ->processQuery(Argument::any(), Argument::any()) 72 | ->willReturn(new QueryResult([], [], [], $metadata)); 73 | 74 | $element = [ 75 | '#theme' => 'graphql_garage', 76 | '#cache' => [ 77 | 'keys' => ['garage'], 78 | ], 79 | ]; 80 | 81 | $renderer = $this->container->get('renderer'); 82 | $element_1 = $element; 83 | $element_2 = $element; 84 | 85 | $renderer->renderRoot($element_1); 86 | $renderer->renderRoot($element_2); 87 | 88 | $process->shouldHaveBeenCalledTimes(1); 89 | } 90 | 91 | /** 92 | * Test query caching. 93 | */ 94 | public function testUncacheableQuery() { 95 | 96 | $metadata = new CacheableMetadata(); 97 | $metadata->setCacheMaxAge(0); 98 | 99 | $process = $this->processor 100 | ->processQuery(Argument::any(), Argument::any()) 101 | ->willReturn(new QueryResult([], [], [], $metadata)); 102 | 103 | $element = [ 104 | '#theme' => 'graphql_garage', 105 | '#cache' => [ 106 | 'keys' => ['garage'], 107 | ], 108 | ]; 109 | 110 | $renderer = $this->container->get('renderer'); 111 | $element_1 = $element; 112 | $element_2 = $element; 113 | 114 | $renderer->renderRoot($element_1); 115 | $renderer->renderRoot($element_2); 116 | 117 | $process->shouldHaveBeenCalledTimes(2); 118 | } 119 | 120 | } 121 | -------------------------------------------------------------------------------- /tests/src/Unit/GraphQLTwigExtensionTest.php: -------------------------------------------------------------------------------- 1 | twig = new \Twig_Environment(new\Twig_Loader_Array([ 22 | 'query' => '{% graphql %}query ($arg: String!) { foo(id: [1, 2, 3], search: "test") { bar } }{% endgraphql %}', 23 | 'simple' => '{% graphql %}query a { foo }{% endgraphql %}', 24 | 'extend' => '{% extends "simple" %}', 25 | 'dynamic_extend' => '{% extends simple %}', 26 | 'override_extend' => '{% graphql %}query b { foo }{% endgraphql %}{% extends "simple" %}', 27 | 'include' => '{% graphql %}query a { foo }{% endgraphql %}{% include "sub_fragment" with { foo: "bar" } %}', 28 | 'embed' => '{% embed "embeddable" %}{% block test %} Override {% endblock %}{% endembed %}', 29 | 'embeddable' => '{% graphql %}query a { foo }{% endgraphql %}{% block test %} Test {% endblock %}', 30 | 'nested_include' => '{% graphql %}query a { foo }{% endgraphql %}{% include "fragment" with { foo: "bar" } %}', 31 | 'dynamic_include' => '{% graphql %}query a { foo }{% endgraphql %}{% include sub_fragment with { foo: "bar" } %}', 32 | 'fragment' => '{% graphql %}query b { foo }{% endgraphql %}{% include "sub_fragment" %}', 33 | 'sub_fragment' => '{% graphql %}query c { foo }{% endgraphql %}', 34 | 'extend_include' => '{% graphql %}query a { foo }{% endgraphql %}{% extends "fragment" %}', 35 | 'embed_include' => '{% embed "embeddable" %}{% block test %}{% include "fragment" %}{% endblock %}{% endembed %}', 36 | 'recursive' => '{% graphql %}query a { ... b }{% endgraphql %}{% include "recursive_include" %}', 37 | 'recursive_include' => '{% graphql %}fragment b on foo { bar }{% endgraphql %}{% include "recursive_include" %}', 38 | ])); 39 | $this->twig->addExtension(new GraphQLTwigExtension()); 40 | } 41 | 42 | protected function assertGraphQLQuery($template, $query) { 43 | $template = $this->twig->loadTemplate($template); 44 | $this->assertTrue(method_exists($template, 'getGraphQLQuery')); 45 | $this->assertEquals($query, $template->getGraphQLQuery()); 46 | } 47 | 48 | function testQuery() { 49 | $this->assertGraphQLQuery('query', 'query ($arg: String!) { foo(id: [1, 2, 3], search: "test") { bar } }'); 50 | } 51 | 52 | function testExtend() { 53 | $this->assertGraphQLQuery('extend', 'query a { foo }'); 54 | } 55 | 56 | function testDynamicExtend() { 57 | $this->assertGraphQLQuery('dynamic_extend', ''); 58 | } 59 | 60 | function testInclude() { 61 | $this->assertGraphQLQuery('include', "query a { foo }\nquery c { foo }"); 62 | } 63 | 64 | function testEmbed() { 65 | $this->assertGraphQLQuery('embed', "query a { foo }"); 66 | } 67 | 68 | function testNestedInclude() { 69 | $this->assertGraphQLQuery('nested_include', "query a { foo }\nquery b { foo }\nquery c { foo }"); 70 | } 71 | 72 | function testDynamicInclude() { 73 | $this->assertGraphQLQuery('dynamic_include', "query a { foo }"); 74 | } 75 | 76 | function testExtendInclude() { 77 | $this->assertGraphQLQuery('extend_include', "query a { foo }\nquery c { foo }"); 78 | } 79 | 80 | function testEmbedInclude() { 81 | $this->assertGraphQLQuery('embed_include', "query a { foo }\nquery b { foo }\nquery c { foo }"); 82 | } 83 | 84 | function testRecursiveInclude() { 85 | $this->assertGraphQLQuery('recursive', "query a { ... b }\nfragment b on foo { bar }"); 86 | } 87 | 88 | } 89 | -------------------------------------------------------------------------------- /src/GraphQLNode.php: -------------------------------------------------------------------------------- 1 | query = trim($query); 67 | $this->parent = $parent; 68 | $this->includes = $includes; 69 | 70 | if ($this->query) { 71 | $document = Parser::parse($this->query); 72 | 73 | /** @var \GraphQL\Language\AST\OperationDefinitionNode[] $operations */ 74 | $operations = array_filter(iterator_to_array($document->definitions->getIterator()), function (DefinitionNode $node) { 75 | return $node instanceof OperationDefinitionNode; 76 | }); 77 | 78 | $this->hasOperations = (bool) $operations; 79 | 80 | $this->arguments = array_map(function (VariableDefinitionNode $node) { 81 | return $node->variable->name->value; 82 | }, array_reduce($operations, function ($carry, OperationDefinitionNode $node) { 83 | return array_merge($carry, iterator_to_array($node->variableDefinitions->getIterator())); 84 | }, [])); 85 | } 86 | 87 | parent::__construct(); 88 | } 89 | 90 | /** 91 | * {@inheritdoc} 92 | */ 93 | public function compile(Twig_Compiler $compiler) { 94 | $compiler 95 | // Make the template implement the GraphQLTemplateTrait. 96 | ->write("\nuse \Drupal\graphql_twig\GraphQLTemplateTrait;\n") 97 | // Write metadata properties. 98 | ->write("\npublic static function hasGraphQLOperations() { return ")->repr($this->hasOperations)->write("; }\n") 99 | ->write("\npublic static function rawGraphQLQuery() { return ")->string($this->query)->write("; }\n") 100 | ->write("\npublic static function rawGraphQLParent() { return ")->string($this->parent)->write("; }\n"); 101 | 102 | $compiler->write("\npublic static function rawGraphQLIncludes() { return ["); 103 | 104 | foreach ($this->includes as $include) { 105 | $compiler->string($include)->write(","); 106 | } 107 | 108 | $compiler->write("]; }\n"); 109 | 110 | $compiler->write("\npublic static function rawGraphQLArguments() { return ["); 111 | foreach ($this->arguments as $argument) { 112 | $compiler->string($argument)->write(","); 113 | } 114 | $compiler->write("]; }\n"); 115 | } 116 | 117 | } 118 | -------------------------------------------------------------------------------- /tests/src/Kernel/EntityRenderTest.php: -------------------------------------------------------------------------------- 1 | setupThemeTest(); 46 | $this->installConfig(['system', 'user', 'node']); 47 | $this->installEntitySchema('user'); 48 | $this->installEntitySchema('node'); 49 | $this->installSchema('system', ['sequences']); 50 | NodeType::create([ 51 | 'type' => 'article', 52 | 'name' => 'Article', 53 | ])->save(); 54 | } 55 | 56 | public function testNodeRender() { 57 | $node = Node::create([ 58 | 'title' => 'Test', 59 | 'type' => 'article', 60 | 'uid' => User::create([ 61 | 'name' => 'test', 62 | ])->save(), 63 | ]); 64 | $node->save(); 65 | 66 | $this->processor->processQuery(Argument::any(), Argument::any()) 67 | ->willReturn(new QueryResult([ 68 | 'node' => [ 69 | 'title' => 'Test', 70 | ], 71 | ])); 72 | 73 | $viewBuilder = $this->container->get('entity_type.manager')->getViewBuilder('node'); 74 | $build = $viewBuilder->view($node); 75 | $result = $this->render($build); 76 | $this->assertContains('

Test

', $result); 77 | } 78 | 79 | public function testCacheableNodeRender() { 80 | $node = Node::create([ 81 | 'title' => 'Test', 82 | 'type' => 'article', 83 | 'uid' => User::create([ 84 | 'name' => 'test', 85 | ])->save(), 86 | ]); 87 | $node->save(); 88 | 89 | $process = $this->processor->processQuery(Argument::any(), Argument::any()) 90 | ->willReturn(new QueryResult([ 91 | 'node' => [ 92 | 'title' => 'Test', 93 | ], 94 | ], [], [], (new CacheableMetadata())->setCacheMaxAge(-1))); 95 | 96 | $viewBuilder = $this->container->get('entity_type.manager')->getViewBuilder('node'); 97 | 98 | $build = $viewBuilder->view($node); 99 | $this->render($build); 100 | 101 | $build = $viewBuilder->view($node); 102 | $this->render($build); 103 | 104 | $process->shouldHaveBeenCalledTimes(1); 105 | } 106 | 107 | 108 | public function testUncacheableNodeRender() { 109 | $node = Node::create([ 110 | 'title' => 'Test', 111 | 'type' => 'article', 112 | 'uid' => User::create([ 113 | 'name' => 'test', 114 | ])->save(), 115 | ]); 116 | $node->save(); 117 | $metadata = new CacheableMetadata(); 118 | $metadata->setCacheMaxAge(0); 119 | 120 | $process = $this->processor->processQuery(Argument::any(), Argument::any()) 121 | ->willReturn(new QueryResult([ 122 | 'node' => [ 123 | 'title' => 'Test', 124 | ], 125 | ], [], [], $metadata)); 126 | 127 | $viewBuilder = $this->container->get('entity_type.manager')->getViewBuilder('node'); 128 | 129 | $build = $viewBuilder->view($node); 130 | $this->render($build); 131 | 132 | $build = $viewBuilder->view($node); 133 | $this->render($build); 134 | 135 | $process->shouldHaveBeenCalledTimes(2); 136 | } 137 | 138 | } 139 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: php 2 | sudo: false 3 | 4 | php: 5 | - 7.1 6 | - 7 7 | 8 | env: 9 | global: 10 | - DRUPAL_GRAPHQL=8.x-3.x 11 | - DRUPAL_BUILD_DIR=$TRAVIS_BUILD_DIR/../drupal 12 | - SIMPLETEST_DB=mysql://root:@127.0.0.1/graphql 13 | - TRAVIS=true 14 | matrix: 15 | - DRUPAL_CORE=8.6.x 16 | 17 | matrix: 18 | # Don't wait for the allowed failures to build. 19 | fast_finish: true 20 | include: 21 | - php: 7.1 22 | env: 23 | - DRUPAL_CORE=8.6.x 24 | # Only run code coverage on the latest php and drupal versions. 25 | - WITH_PHPDBG_COVERAGE=true 26 | allow_failures: 27 | # Allow the code coverage report to fail. 28 | - php: 7.1 29 | env: 30 | - DRUPAL_CORE=8.6.x 31 | # Only run code coverage on the latest php and drupal versions. 32 | - WITH_PHPDBG_COVERAGE=true 33 | 34 | mysql: 35 | database: graphql 36 | username: root 37 | encoding: utf8 38 | 39 | # Cache composer downloads. 40 | cache: 41 | directories: 42 | - $HOME/.composer 43 | 44 | before_install: 45 | # Disable xdebug. 46 | - phpenv config-rm xdebug.ini 47 | 48 | # Determine the php settings file location. 49 | - if [[ $TRAVIS_PHP_VERSION = hhvm* ]]; 50 | then export PHPINI=/etc/hhvm/php.ini; 51 | else export PHPINI=$HOME/.phpenv/versions/$(phpenv version-name)/etc/conf.d/travis.ini; 52 | fi 53 | 54 | # PHP Deprecated: Automatically populating $HTTP_RAW_POST_DATA is deprecated 55 | # and will be removed in a future version. To avoid this warning set 56 | # 'always_populate_raw_post_data' to '-1' in php.ini and use the php://input 57 | # stream instead. 58 | - if [[ "$TRAVIS_PHP_VERSION" == "5.6" ]]; 59 | then echo always_populate_raw_post_data = -1 >> $PHPINI; 60 | fi; 61 | 62 | # Disable the default memory limit. 63 | - echo memory_limit = -1 >> $PHPINI 64 | 65 | # Update composer. 66 | - composer self-update 67 | 68 | install: 69 | # Create the database. 70 | - mysql -e 'create database graphql' 71 | 72 | # Download Drupal 8 core from the Github mirror because it is faster. 73 | - git clone --branch $DRUPAL_CORE --depth 1 https://github.com/drupal/drupal.git $DRUPAL_BUILD_DIR 74 | - git clone --branch $DRUPAL_GRAPHQL --depth 1 https://github.com/drupal-graphql/graphql.git $DRUPAL_BUILD_DIR/modules/graphql 75 | 76 | # Reference the module in the build site. 77 | - ln -s $TRAVIS_BUILD_DIR $DRUPAL_BUILD_DIR/modules/graphql_twig 78 | 79 | # Copy the customized phpunit configuration file to the core directory so 80 | # the relative paths are correct. 81 | - cp $DRUPAL_BUILD_DIR/modules/graphql/phpunit.xml.dist $DRUPAL_BUILD_DIR/core/phpunit.xml 82 | 83 | # When running with phpdbg we need to replace all code occurrences that check 84 | # for 'cli' with 'phpdbg'. Some files might be write protected, hence the 85 | # fallback. 86 | - if [[ "$WITH_PHPDBG_COVERAGE" == "true" ]]; 87 | then grep -rl 'cli' $DRUPAL_BUILD_DIR/core $DRUPAL_BUILD_DIR/modules | xargs sed -i "s/'cli'/'phpdbg'/g" || true; 88 | fi 89 | 90 | # Bring in the module dependencies without requiring a merge plugin. The 91 | # require also triggers a full 'composer install'. 92 | - composer --working-dir=$DRUPAL_BUILD_DIR require webonyx/graphql-php:^0.12.5 93 | - composer --working-dir=$DRUPAL_BUILD_DIR run-script drupal-phpunit-upgrade 94 | 95 | script: 96 | # Run the unit tests using phpdbg if the environment variable is 'true'. 97 | - if [[ "$WITH_PHPDBG_COVERAGE" == "true" ]]; 98 | then phpdbg -qrr $DRUPAL_BUILD_DIR/vendor/bin/phpunit --configuration $DRUPAL_BUILD_DIR/core/phpunit.xml --coverage-clover $TRAVIS_BUILD_DIR/coverage.xml $TRAVIS_BUILD_DIR; 99 | fi 100 | 101 | # Run the unit tests with standard php otherwise. 102 | - if [[ "$WITH_PHPDBG_COVERAGE" != "true" ]]; 103 | then $DRUPAL_BUILD_DIR/vendor/bin/phpunit --configuration $DRUPAL_BUILD_DIR/core/phpunit.xml $TRAVIS_BUILD_DIR; 104 | fi 105 | 106 | after_success: 107 | - if [[ "$WITH_PHPDBG_COVERAGE" == "true" ]]; 108 | then bash <(curl -s https://codecov.io/bash); 109 | fi 110 | -------------------------------------------------------------------------------- /src/GraphQLTwigEnvironment.php: -------------------------------------------------------------------------------- 1 | queryProcessor; 38 | } 39 | 40 | /** 41 | * The renderer instance. 42 | * 43 | * @var \Drupal\Core\Render\RendererInterface 44 | */ 45 | protected $renderer; 46 | 47 | /** 48 | * Retrieve the renderer instance. 49 | * 50 | * @return \Drupal\Core\Render\RendererInterface 51 | * The renderer instance. 52 | */ 53 | public function getRenderer() { 54 | return $this->renderer; 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function __construct( 61 | $root, 62 | CacheBackendInterface $cache, 63 | $twig_extension_hash, 64 | StateInterface $state, 65 | \Twig_LoaderInterface $loader = NULL, 66 | array $options = [], 67 | QueryProcessor $queryProcessor = NULL, 68 | RendererInterface $renderer = NULL 69 | ) { 70 | $this->queryProcessor = $queryProcessor; 71 | $this->renderer = $renderer; 72 | parent::__construct( 73 | $root, 74 | $cache, 75 | $twig_extension_hash, 76 | $state, 77 | $loader, 78 | $options 79 | ); 80 | } 81 | 82 | /** 83 | * Regular expression to find a GraphQL annotation in a twig comment. 84 | * 85 | * @var string 86 | */ 87 | public static $GRAPHQL_ANNOTATION_REGEX = '/{#graphql\s+(?.*?)\s+#\}/s'; 88 | 89 | /** 90 | * {@inheritdoc} 91 | */ 92 | public function compileSource($source, $name = NULL) { 93 | if ($source instanceof \Twig_Source) { 94 | // Check if there is a `*.gql` file with the same name as the template. 95 | $graphqlFile = $source->getPath() . '.gql'; 96 | if (file_exists($graphqlFile)) { 97 | $source = new \Twig_Source( 98 | '{% graphql %}' . file_get_contents($graphqlFile) . '{% endgraphql %}' . $source->getCode(), 99 | $source->getName(), 100 | $source->getPath() 101 | ); 102 | } 103 | else { 104 | // Else, try to find an annotation. 105 | $source = new \Twig_Source( 106 | $this->replaceAnnotation($source->getCode()), 107 | $source->getName(), 108 | $source->getPath() 109 | ); 110 | } 111 | 112 | } 113 | else { 114 | // For inline templates, only comment based annotations are supported. 115 | $source = $this->replaceAnnotation($source); 116 | } 117 | 118 | // Compile the modified source. 119 | return parent::compileSource($source, $name); 120 | } 121 | 122 | /** 123 | * Replace `{#graphql ... #}` annotations with `{% graphql ... %}` tags. 124 | * 125 | * @param string $code 126 | * The template code. 127 | * 128 | * @return string 129 | * The template code with all annotations replaced with tags. 130 | */ 131 | public function replaceAnnotation($code) { 132 | return preg_replace(static::$GRAPHQL_ANNOTATION_REGEX, '{% graphql %}$1{% endgraphql %}', $code); 133 | } 134 | 135 | } 136 | -------------------------------------------------------------------------------- /tests/src/Kernel/BlockTest.php: -------------------------------------------------------------------------------- 1 | installConfig(['graphql_twig']); 31 | 32 | $themeName = 'graphql_twig_test_theme'; 33 | 34 | /** @var \Drupal\Core\Extension\ThemeHandler $themeHandler */ 35 | $themeHandler = $this->container->get('theme_handler'); 36 | /** @var \Drupal\Core\Theme\ThemeInitialization $themeInitialization */ 37 | $themeInitialization = $this->container->get('theme.initialization'); 38 | /** @var \Drupal\Core\Theme\ThemeManager $themeManager */ 39 | $themeManager = $this->container->get('theme.manager'); 40 | 41 | $themeHandler->install([$themeName]); 42 | $theme = $themeInitialization->initTheme($themeName); 43 | $themeManager->setActiveTheme($theme); 44 | 45 | $this->mockField('shout', [ 46 | 'name' => 'shout', 47 | 'type' => 'String', 48 | 'arguments' => [ 49 | 'word' => 'String!', 50 | ], 51 | ], function ($value, $args) { 52 | yield strtoupper($args['word']); 53 | }); 54 | 55 | // Rebuild routes to include theme routes. 56 | $this->container->get('router.builder')->rebuild(); 57 | } 58 | 59 | protected function placeGraphQLBlock($id, $arguments = []) { 60 | $parameters = []; 61 | foreach ($arguments as $key => $value) { 62 | $parameters[] = [ 63 | 'key' => $key, 64 | 'value' => $value, 65 | ]; 66 | } 67 | $block = $this->placeBlock('graphql_twig:' . $id, [ 68 | 'region' => 'content', 69 | 'theme' => 'graphql_twig_test_theme', 70 | 'graphql_block' => $parameters, 71 | ]); 72 | $block->save(); 73 | } 74 | 75 | /** 76 | * Test block without arguments. 77 | */ 78 | public function testNoArguments() { 79 | $this->placeGraphQLBlock('block_no_arguments'); 80 | $result = $this->container->get('http_kernel')->handle(Request::create('/static')); 81 | $content = $result->getContent(); 82 | $this->assertContains('

This block shouts: DRUPAL

', $content); 83 | } 84 | 85 | /** 86 | * Test block with one argument. 87 | */ 88 | public function testOneArgument() { 89 | $this->placeGraphQLBlock('block_one_argument', [ 90 | 'first' => 'drupal', 91 | ]); 92 | $result = $this->container->get('http_kernel')->handle(Request::create('/static')); 93 | $content = $result->getContent(); 94 | $this->assertContains('

This block shouts: DRUPAL

', $content); 95 | } 96 | 97 | /** 98 | * Test block with multiple arguments. 99 | */ 100 | public function testMultipleArguments() { 101 | $this->placeGraphQLBlock('block_multiple_arguments', [ 102 | 'first' => 'drupal', 103 | 'second' => 'graphql', 104 | ]); 105 | $result = $this->container->get('http_kernel')->handle(Request::create('/static')); 106 | $content = $result->getContent(); 107 | $this->assertContains('

This block shouts: DRUPAL and GRAPHQL

', $content); 108 | } 109 | 110 | 111 | /** 112 | * Test static block. 113 | */ 114 | public function testStatic() { 115 | $this->placeGraphQLBlock('block_static'); 116 | $result = $this->container->get('http_kernel')->handle(Request::create('/static')); 117 | $content = $result->getContent(); 118 | $this->assertContains('

This is a static block.

', $content); 119 | } 120 | 121 | /** 122 | * Test missing block template. 123 | */ 124 | public function testMissing() { 125 | $this->placeGraphQLBlock('block_missing'); 126 | $result = $this->container->get('http_kernel')->handle(Request::create('/static')); 127 | $content = $result->getContent(); 128 | $this->assertContains('
Missing template for block_missing.
', $content); 129 | } 130 | 131 | } 132 | -------------------------------------------------------------------------------- /tests/src/Kernel/RouteTest.php: -------------------------------------------------------------------------------- 1 | container->get('theme_handler'); 32 | /** @var \Drupal\Core\Theme\ThemeInitialization $themeInitialization */ 33 | $themeInitialization = $this->container->get('theme.initialization'); 34 | /** @var \Drupal\Core\Theme\ThemeManager $themeManager */ 35 | $themeManager = $this->container->get('theme.manager'); 36 | 37 | $themeHandler->install([$themeName]); 38 | $theme = $themeInitialization->initTheme($themeName); 39 | $themeManager->setActiveTheme($theme); 40 | 41 | $this->mockField('shout', [ 42 | 'name' => 'shout', 43 | 'type' => 'String', 44 | 'arguments' => [ 45 | 'word' => 'String!', 46 | ], 47 | ], function ($value, $args) { 48 | yield strtoupper($args['word']); 49 | }); 50 | 51 | // Rebuild routes to include theme routes. 52 | $this->container->get('router.builder')->rebuild(); 53 | } 54 | 55 | /** 56 | * Test page without arguments. 57 | */ 58 | public function testNoArguments() { 59 | $result = $this->container->get('http_kernel')->handle(Request::create('/no-args')); 60 | $content = $result->getContent(); 61 | $this->assertContains('

Shouting: DRUPAL

', $content); 62 | $this->assertContains('

This page is supposed to shout: DRUPAL

', $content); 63 | } 64 | 65 | /** 66 | * Test page with one argument. 67 | */ 68 | public function testOneArgument() { 69 | $result = $this->container->get('http_kernel')->handle(Request::create('/one-arg/drupal')); 70 | $content = $result->getContent(); 71 | $this->assertContains('

Shouting: DRUPAL

', $content); 72 | $this->assertContains('

This page is supposed to shout: DRUPAL

', $content); 73 | } 74 | 75 | /** 76 | * Test page with multiple arguments. 77 | */ 78 | public function testMultipleArguments() { 79 | $result = $this->container->get('http_kernel')->handle(Request::create('/multi-args/drupal/graphql')); 80 | $content = $result->getContent(); 81 | $this->assertContains('

Shouting: DRUPAL and GRAPHQL

', $content); 82 | $this->assertContains('

This page is supposed to shout: DRUPAL and GRAPHQL

', $content); 83 | } 84 | 85 | /** 86 | * Test page without query. 87 | */ 88 | public function testStatic() { 89 | $result = $this->container->get('http_kernel')->handle(Request::create('/static')); 90 | $content = $result->getContent(); 91 | $this->assertContains('

This is a static page

', $content); 92 | $this->assertContains('

This page is static.

', $content); 93 | } 94 | 95 | /** 96 | * Test page without title. 97 | */ 98 | public function testNoTitle() { 99 | $result = $this->container->get('http_kernel')->handle(Request::create('/no-title')); 100 | $content = $result->getContent(); 101 | $this->assertNotContains('

', $content); 102 | $this->assertContains('

This page has no title.

', $content); 103 | } 104 | 105 | /** 106 | * Test page with forbidden access. 107 | */ 108 | public function testNoAccess() { 109 | $result = $this->container->get('http_kernel')->handle(Request::create('/no-access')); 110 | $this->assertEquals(403, $result->getStatusCode()); 111 | } 112 | 113 | /** 114 | * Test page with forbidden access. 115 | */ 116 | public function testMissing() { 117 | $result = $this->container->get('http_kernel')->handle(Request::create('/missing')); 118 | $content = $result->getContent(); 119 | $this->assertContains('

Missing template

', $content); 120 | $this->assertContains('
Missing template for missing.
', $content); 121 | } 122 | 123 | /** 124 | * Test page with a broken query. 125 | */ 126 | public function testError() { 127 | $result = $this->container->get('http_kernel')->handle(Request::create('/error')); 128 | $content = $result->getContent(); 129 | $this->assertContains('Field "shout" argument "word" of type "String!" is required but not provided.', $content); 130 | } 131 | 132 | } 133 | -------------------------------------------------------------------------------- /src/GraphQLTemplateTrait.php: -------------------------------------------------------------------------------- 1 | queryProcessor = $queryProcessor; 55 | } 56 | 57 | /** 58 | * {@inheritdoc} 59 | */ 60 | public function display(array $context, array $blocks = array()) { 61 | if (!static::hasGraphQLOperations()) { 62 | parent::display($context, $blocks); 63 | return; 64 | } 65 | 66 | if (isset($context['graphql_arguments'])) { 67 | $context = $context['graphql_arguments']; 68 | } 69 | 70 | $query = trim($this->getGraphQLQuery()); 71 | 72 | if (!$query) { 73 | parent::display($context, $blocks); 74 | return; 75 | } 76 | 77 | $arguments = []; 78 | foreach (static::rawGraphQLArguments() as $var) { 79 | if (isset($context[$var])) { 80 | $arguments[$var] = $context[$var] instanceof EntityInterface ? $context[$var]->id() : $context[$var]; 81 | } 82 | } 83 | 84 | 85 | $queryResult = $this->env->getQueryProcessor()->processQuery('default:default', OperationParams::create([ 86 | 'query' => $query, 87 | 'variables' => $arguments, 88 | ])); 89 | 90 | $build = [ 91 | '#cache' => [ 92 | 'contexts' => $queryResult->getCacheContexts(), 93 | 'tags' => $queryResult->getCacheTags(), 94 | 'max-age' => $queryResult->getCacheMaxAge(), 95 | ], 96 | ]; 97 | 98 | $this->env->getRenderer()->render($build); 99 | 100 | $config = \Drupal::config('graphql_twig.settings'); 101 | $debug_placement = $config->get('debug_placement'); 102 | 103 | if ($this->env->isDebug() && \Drupal::currentUser()->hasPermission('use graphql explorer')) { 104 | // Auto-attach the debug assets if necessary. 105 | $template_attached = ['#attached' => ['library' => ['graphql_twig/debug']]]; 106 | $this->env->getRenderer()->render($template_attached); 107 | } 108 | 109 | if ($this->env->isDebug() && $debug_placement == 'wrapped') { 110 | printf( 111 | '
', 112 | 'graphql-twig-debug-wrapper', 113 | htmlspecialchars($query), 114 | htmlspecialchars(json_encode($arguments)) 115 | ); 116 | } 117 | 118 | if ($queryResult->errors) { 119 | print('
    '); 120 | foreach ($queryResult->errors as $error) { 121 | printf('
  • %s
  • ', $error->message); 122 | } 123 | print('
'); 124 | } 125 | else { 126 | $context['graphql'] = $queryResult->data; 127 | if ($this->env->isDebug() && $debug_placement == 'inside') { 128 | $context['graphql_debug'] = [ 129 | '#markup' => sprintf( 130 | '
', 131 | 'graphql-twig-debug-wrapper', 132 | htmlspecialchars($query), 133 | htmlspecialchars(json_encode($arguments)) 134 | ), 135 | ]; 136 | 137 | // Add the debug parent class to the element. 138 | /** @var \Drupal\Core\Template\Attribute $attributes */ 139 | $attributes = $context['attributes']; 140 | $attributes->addClass('graphql-twig-debug-parent'); 141 | } 142 | 143 | parent::display($context, $blocks); 144 | } 145 | 146 | if ($this->env->isDebug() && $debug_placement == 'wrapped') { 147 | print('
'); 148 | } 149 | } 150 | 151 | /** 152 | * Recursively build the GraphQL query. 153 | * 154 | * Builds the templates GraphQL query by iterating through all included or 155 | * embedded templates recursively. 156 | */ 157 | public function getGraphQLQuery() { 158 | 159 | $query = ''; 160 | $includes = []; 161 | 162 | if ($this instanceof \Twig_Template) { 163 | $query = $this->getGraphQLFragment(); 164 | 165 | $includes = array_keys($this->getGraphQLIncludes()); 166 | 167 | // Recursively collect all included fragments. 168 | $includes = array_map(function ($template) { 169 | return $this->env->loadTemplate($template)->getGraphQLFragment(); 170 | }, $includes); 171 | 172 | // Always add includes from parent templates. 173 | if ($parent = $this->getGraphQLParent()) { 174 | $includes += array_map(function ($template) { 175 | return $this->env->loadTemplate($template)->getGraphQLQuery(); 176 | }, array_keys($parent->getGraphQLIncludes())); 177 | } 178 | } 179 | 180 | 181 | return implode("\n", [-1 => $query] + $includes); 182 | } 183 | 184 | /** 185 | * Get the files parent template. 186 | * 187 | * @return \Twig_Template|null 188 | * The parent template or null. 189 | */ 190 | protected function getGraphQLParent() { 191 | return static::rawGraphQLParent() ? $this->env->loadTemplate(static::rawGraphQLParent()) : NULL; 192 | } 193 | 194 | /** 195 | * Retrieve the files graphql fragment. 196 | * 197 | * @return string 198 | * The GraphQL fragment. 199 | */ 200 | public function getGraphQLFragment() { 201 | // If there is no query for this template, try to get one from the 202 | // parent template. 203 | if (!($query = static::rawGraphQLQuery()) && ($parent = $this->getGraphQLParent())) { 204 | $query = $parent->getGraphQLFragment(); 205 | } 206 | return $query; 207 | } 208 | 209 | /** 210 | * Retrieve a list of all direct or indirect included templates. 211 | * 212 | * @param string[] $recursed 213 | * The list of templates already recursed into. Used internally. 214 | * 215 | * @return string[] 216 | * The list of included templates. 217 | */ 218 | public function getGraphQLIncludes(&$recursed = []) { 219 | 220 | $includes = array_flip(static::rawGraphQLIncludes()); 221 | foreach ($includes as $include => $key) { 222 | if (in_array($include, $recursed)) { 223 | continue; 224 | } 225 | 226 | $recursed[] = $include; 227 | 228 | // TODO: operate on template class instead. 229 | $includes += $this->env->loadTemplate($include)->getGraphQLIncludes($recursed); 230 | } 231 | 232 | return $includes; 233 | } 234 | } 235 | --------------------------------------------------------------------------------